ธีม
Session notes — claude-2026-09-18
One person's notes. Never rewritten by anyone else; docs/state/HANDOFF.md holds what is NOT done and STATE.md what is true now. This file is why.
▶ HANDOFF — four things shipped, two numbers I reported then corrected
Started from one bug report and ended up in three systems. The thread that connects them is worth more than any of them: a fact that reaches Discord but not the web, and a rule implemented twice.
1. PR — a link-only submission read "ไม่มีไฟล์แนบ"
Reported: "ถ้าคนกรอกงาน PR form เข้ามาแบบแนบลิ้งค์ ลิ้งค์จะขึ้นใน discord แต่ไม่ได้ขึ้นใน PR Staff Dashboard". pr_tickets.file_url is a newline blob of Drive URLs plus ลิงก์เสริม: <url>. Two renderers read it and the staff copy carried one extra condition — !t.fileUrl.startsWith('http') — so a blob starting with ล took the empty branch and never reached the loop twelve lines below that knows the marker. 57 of 247 live tickets, 23%. Fixed by src/js/pr-attachments.js, one reading for both views. Write-up: docs/mistakes/frontend-ui.md.
2. VS — the ฝ่าย the reporter asked for existed only in Discord
Found by SWEEPING every column the forms write against what any view reads. That sweep is cheap and I would run it again: for each column, grep the frontend for its name; one hit means the write and no reader. requested_dept had one hit.
Non-emergency VS reports route to SE first BY DESIGN (owner confirmed) — so target_dept is 'SE' and the reporter's choice survives only in requested_dept, which the Discord embed printed with an instruction aimed at SE while SE's own dashboard never showed it. 16 of 72 tickets named a ฝ่าย; one was CLOSED at SE having never reached it. display_name and year were worse — 51 names and 19 ชั้นปี collected, shown nowhere at all.
In the GAS era the reader was the Google Sheet. The Supabase move carried the COLUMN and replaced the Sheet with a dashboard that never learned the field. That is the general rule now in class 6: a migration carries columns, not readers.
3. ระบบบ้าน — the night agent's work, landed, then corrected twice
agent/2026-09-17 (16 commits) was merged. Two things I got wrong first and the owner caught:
- I emitted everyone, and named kkumail as missing only where an admin must supply it, citing
HOUSE-DATA-REPAIR.md§3. The owner overruled it: "waiting for them to selfclaim seems bad". §3 is about who is ABLE to close a row, not a reason to wait. 13 → 155 rows worth asking for. - I reported 18 สาย problems. 13 of them were not holes — they were the 13 people with no รุ่น, uncounted because the audit groups by รุ่น. The owner asked "the file already order รุ่น md, why don't you know it", and they were right: 0188 had a
cohort_yearcolumn for exactly that and the importer never filled it. After repair: 18 → 5.
⚠️ The second one is the lesson. I had printed the rows behind the extreme value, as the rules say, and still reported a number that was 72% artefact — because I checked whether the ROWS were real, not whether the POPULATION the audit ran over was complete. Ask what a count's denominator excludes, not only whether its members exist.
4. The night agent
Not a scheduling bug — daily is by design. Its queue file is never consumed, so it re-ran the same 6 tasks nightly, and last night's run had branched from main without the previous night's commits. Stopped and disabled; work merged.
What I did NOT verify
- The VS and PR fixes are verified from the SERVED bundle, not from a browser. I never signed into the staff dashboard and looked. The strings are in the served chunks and the unit tests cover the rendering, but nobody has clicked it.
tools/house-year-sheets.mjs --applyhas never been run by anyone but me, and the import-back half does not exist. Nothing has been sent to the data team.- สาย 141 and สาย 256 are reported, not diagnosed. The source file is the only thing that can say which รุ่น is wrong, and I did not open it for that question.
▶ SECOND HALF (2026-09-19) — written after the block above went stale
⚠️ The block above was written when the session looked finished, and then 12 more commits landed. Their durable records went to the right homes as the work landed — which is why handoff:check was green — but the REASONING stopped halfway, and nobody would have known from the check. That is the honest limit of it: it verifies the mechanical, not whether a narrative kept up.
5. MD50 answered, and the import half got built
The owner sent the sheet; MD50 replied with its own roster (320 rows to answer 26 questions) rather than filling ours. tools/house-kkumail-import.mjs matched 26 by รหัส+ชื่อ — 0 name mismatches — and promoted them through the pane's own promote_unresolved_row(). Held 155 → 129, MD50 complete. It carries a ชื่อเล่น the roster happens to include, onto the held row BEFORE promotion so the existing copy path moves it.
Later hardening: a รหัส appearing twice with two different addresses is now refused outright rather than last-one-wins. The wrong address there is not a typo — the row is promoted with it and it becomes the person's identity.
6. CI was red on two pushes while npm test was green here
state-handoff.test.js asked existsSync about externaldata/, which is gitignored: present on this laptop, absent on a runner. Both directions of that asymmetry happened the same night — the dead-pointer sweeps green here and red on CI, and "no exemption survives the file arriving" red here and green on CI. I fixed the second one and left the two sweeps ten lines above it, which is class 6 inside the file whose job is to catch class 6.
Now one missingFromRepo() asks git, in one call (check-ignore --stdin; the per-path version timed out at 5 s the moment a new docs/state/*.md arrived). npm run test:clean runs the suite over exactly what git will carry — proved by restoring the bug: npm test 15/15 green, test:clean 1 failed.
7. The night agent, properly
Queue is STATE now: Budget: N nights the human writes and the runner decrements, Done when: <shell command> the RUNNER executes. The agent never writes its own verdict — letting it write Status: done is the same bug wearing a schema. The check runs BEFORE the task too; already passing means skip, not credit. Done when: once = one attempt then needs-review, never retried. Three nights rehearsed with a fake claude: done/retry/review → blocked → REFUSED.
The memory was eight days and four files stale on the VM, and the four missing were the ones saying earlier numbers had changed. install.sh now rsyncs it every time with --delete, refuses if it is absent, and no longer arms — that used to enable --now on every run and would have silently re-armed a timer deliberately off.
Also: install.sh exited non-zero on SUCCESS because | grep -v '^[sudo' returns 1 when it filters everything, and filtering everything is the successful case. Same shape as the deploy whose verdict was tail's.
8. The handoff became a command
npm run handoff:check + .githooks/pre-push, and the rule that the loop runs when a unit of work LANDS rather than at "the end". The owner's two sentences that drove it: noticing at 92% that nothing was written, and "make sure to make it sticks not hallucinate when context getting like 800K/1000K tokens". Hence the rule that matters most: never assert the handoff is done — run the command and paste its verdict. A claim from memory at 800K is worth nothing; a tool result is worth the same at any depth.
Still not verified, carried forward
- The VS and PR fixes are verified from the SERVED bundle and by unit tests. Nobody has signed into the staff dashboard and looked.
- สาย 141 / 256: reported, decided ("leave it"), never diagnosed against the source file.
- ⛔ The VM sudo password was printed into this session's transcript by a
bash -xI ran while debugging. It is not in git. Rotate it.