tickmarkr 2.6.4 → 2.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/catalog-remote.d.ts +23 -0
  3. package/dist/adapters/catalog-remote.js +38 -0
  4. package/dist/adapters/claude-code.d.ts +1 -1
  5. package/dist/adapters/claude-code.js +4 -1
  6. package/dist/adapters/codex-commit-check.d.ts +36 -0
  7. package/dist/adapters/codex-commit-check.js +205 -0
  8. package/dist/adapters/codex.d.ts +1 -0
  9. package/dist/adapters/codex.js +12 -6
  10. package/dist/adapters/model-discovery-check.d.ts +33 -0
  11. package/dist/adapters/model-discovery-check.js +123 -0
  12. package/dist/adapters/model-lints.d.ts +1 -1
  13. package/dist/adapters/model-lints.js +4 -0
  14. package/dist/adapters/prompt.js +3 -2
  15. package/dist/adapters/types.d.ts +10 -0
  16. package/dist/cli/commands/approve.js +10 -4
  17. package/dist/cli/commands/doctor.d.ts +2 -0
  18. package/dist/cli/commands/doctor.js +17 -3
  19. package/dist/cli/commands/fleet.d.ts +10 -1
  20. package/dist/cli/commands/fleet.js +363 -106
  21. package/dist/cli/commands/init.js +10 -3
  22. package/dist/cli/commands/plan.js +76 -4
  23. package/dist/cli/commands/verify.d.ts +1 -1
  24. package/dist/cli/commands/verify.js +65 -15
  25. package/dist/config/fleet-overlay.d.ts +16 -1
  26. package/dist/config/fleet-overlay.js +230 -28
  27. package/dist/drivers/orca.d.ts +34 -2
  28. package/dist/drivers/orca.js +206 -32
  29. package/dist/gates/cache.d.ts +4 -0
  30. package/dist/gates/cache.js +10 -2
  31. package/dist/gates/llm.d.ts +4 -1
  32. package/dist/gates/llm.js +6 -1
  33. package/dist/gates/review.js +8 -3
  34. package/dist/gates/run-gates.d.ts +5 -3
  35. package/dist/gates/run-gates.js +112 -56
  36. package/dist/gates/test-manifest.d.ts +3 -0
  37. package/dist/gates/test-manifest.js +1 -1
  38. package/dist/gates/timeout-shaped.d.ts +3 -0
  39. package/dist/gates/timeout-shaped.js +10 -0
  40. package/dist/run/daemon.js +326 -130
  41. package/dist/run/git.js +9 -0
  42. package/dist/run/journal.d.ts +6 -2
  43. package/dist/run/journal.js +22 -7
  44. package/dist/run/launchservices-check.d.ts +56 -0
  45. package/dist/run/launchservices-check.js +117 -0
  46. package/dist/run/operator-state.d.ts +22 -2
  47. package/dist/run/operator-state.js +20 -3
  48. package/dist/run/title-environment.d.ts +45 -0
  49. package/dist/run/title-environment.js +127 -0
  50. package/dist/run/title-preload.cjs +38 -0
  51. package/dist/run/title-preload.d.cts +1 -0
  52. package/dist/tui/cockpit/board.js +12 -6
  53. package/dist/tui/cockpit/keys.d.ts +8 -0
  54. package/dist/tui/cockpit/keys.js +4 -1
  55. package/dist/tui/cockpit/layout.d.ts +2 -2
  56. package/dist/tui/cockpit/layout.js +2 -2
  57. package/dist/tui/cockpit/live-runtime.js +46 -20
  58. package/dist/tui/cockpit/live-store.d.ts +24 -1
  59. package/dist/tui/cockpit/live-store.js +99 -8
  60. package/dist/tui/cockpit/log-view.d.ts +28 -0
  61. package/dist/tui/cockpit/log-view.js +60 -0
  62. package/dist/tui/cockpit/run-view.js +4 -0
  63. package/dist/tui/cockpit/shell.d.ts +2 -1
  64. package/dist/tui/cockpit/shell.js +6 -4
  65. package/dist/tui/ink/fleet-app.d.ts +37 -10
  66. package/dist/tui/ink/fleet-app.js +65 -21
  67. package/package.json +1 -1
  68. package/skills/tickmarkr-auto/SKILL.md +32 -2
  69. package/skills/tickmarkr-loop/SKILL.md +46 -2
  70. package/skills/tickmarkr-overseer/SKILL.md +83 -8
  71. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
  72. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
  73. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
@@ -121,13 +121,13 @@ through brief lineage. **An executor choice nobody made is still an executor cho
121
121
  tab OVERSEER; create ONE tab ORCHESTRATOR.
122
122
  **FIVE-TAB CANON (standing operator layout — corrected three times on 2026-07-27, layout approved
123
123
  2026-07-29, re-earned 2026-08-17):**
124
- - `OVERSEER` — you. Do not add a second live run surface: the daemon self-places the shipped board
125
- ABOVE the supervising seat that invokes the run.
126
- - `ORCH` — the orchestrator with the daemon-placed, run-id-pinned shipped board BESIDE it: the board
124
+ - `OVERSEER` — you. Do not add a live run surface here: launch the daemon from the ORCH tab
125
+ using the host-owned split form below; it self-places the shipped board there.
126
+ - `ORCH` — the orchestrator, its host-owned daemon split, and the daemon-placed, run-id-pinned shipped board BESIDE it: the board
127
127
  takes the RIGHT half of the tab and the orchestrator's own narration keeps the LEFT half. (It was a
128
128
  full-width board above a narration rail until 2026-08-25; the operator changed it, because a task
129
129
  table is a few rows and it was spending height it did not need while squeezing the narration.) **Look for that `role: "watch"` pane; never hand-place or hand-roll a live
130
- run surface. Nothing else, ever: a work seat NEVER splits into the ORCH tab.** Operator verbatim:
130
+ run surface. Only the orchestrator, daemon and board belong here: a work seat NEVER splits into the ORCH tab.** Operator verbatim:
131
131
  *"in orch tab should be the orch and the watcher only."* Re-earned 2026-08-17: a planning seat split
132
132
  beside the orchestrator, and the operator caught it, again. The daemon owns this vertical stack and
133
133
  places it the same way at every terminal width; neither the worker-pane halving floor, nor a
@@ -147,12 +147,13 @@ through brief lineage. **An executor choice nobody made is still an executor cho
147
147
  fraction (`ORCH · v1.19 4/5`, updated on every task-done); tickmarkr opens ONE TAB PER TASK, labelled
148
148
  with the task id and holding that task's worker plus its judge/review/consult panes (tickmarkr
149
149
  updates it). Never long context strings or ✓-chains.
150
- - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: Do not load the `herdr` skill and do not map a Herdr workspace. Work from the current Orca terminal context. Keep the overseer in the launching terminal, name it in the same act — `orca terminal rename --terminal "$ORCA_TERMINAL_HANDLE" --title "OVERSEER · <version>"` (tab title; see the seat-name law under Seat-spawn recipes) — and inspect terminals with `orca terminal list --json`. The daemon self-places the watch board as a horizontal split of the launching terminal (`ORCA_TERMINAL_HANDLE`).
150
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: Do not load the `herdr` skill and do not map a Herdr workspace. Work from the current Orca terminal context. Keep the overseer in the launching terminal, name it in the same act — `orca terminal rename --terminal "$ORCA_TERMINAL_HANDLE" --title "OVERSEER · <version>"` (tab title; see the seat-name law under Seat-spawn recipes) — and inspect terminals with `orca terminal list --json`. Launch the daemon in a host-owned split of the orchestrator terminal using the form below; its self-placed watch board stays in the ORCH tab.
151
151
  2. **Orchestrator**: First consume a successful `tickmarkr fleet --pick consult` in the seat repository, per the Fleet selection contract below; then launch the returned adapter/model with your agent host.
152
152
  - **On herdr (`HERDR_ENV=1`)**: Spawning on current herdr is two-step — the one-shot `agent start --cwd` form was removed in the herdr CLI redesign and now fails with `unknown option` (OBS-138): first create the pane with `herdr tab create --workspace <ws> --cwd <repo> --label "ORCH · <version>"` and parse `result.root_pane.pane_id` from its JSON, then start the agent in it. For Claude Code, use `herdr agent start orchestrator --kind claude --pane <root-pane-id> -- --permission-mode bypassPermissions` (append `--model <picked-model>` after the `--` from the Fleet receipt). For Codex, use `herdr agent start orchestrator --kind codex --pane <root-pane-id> -- --dangerously-bypass-approvals-and-sandbox` (add `--model <picked-model>` from the Fleet receipt). The unsandboxed flag is REQUIRED: codex's `workspace-write` sandbox keeps `.git` refs read-only, so a sandboxed orchestrator's `tickmarkr run` dies at integration-branch creation — do not downgrade it. Workers you never spawn — tickmarkr spawns its own visible worker panes. Auxiliary agents you do spawn (consultants, reviewers, scouts) follow the same forms: never launch a claude session in plan mode or default permission mode for autonomous work — both stall on per-command approval prompts nobody is watching; claude is always `--permission-mode bypassPermissions --settings '{"promptSuggestionEnabled":false}'`. **For a codex consultant, use `-a never --sandbox workspace-write` — NOT `--sandbox read-only`.** ⚠ **`--sandbox read-only` CONTRADICTS this skill's own completion protocol and will hang the seat.** Every seat you spawn is told to deliver an ARTIFACT ending in a terminal MARKER, because that is the only completion signal the artifact watcher can key on (`done` is turn end). A read-only sandbox cannot write that artifact, so codex blocks on `Would you like to make the following edits?` for its OWN report — and the report exists ONLY in the pending edit, so abandoning the prompt destroys the work rather than merely delaying it. Measured 2026-08-28: a consultant spawned `--sandbox read-only` finished a 14,604-byte verdict, sat blocked on the write, and the operator saw the prompt before the supervising tier did. `read-only` is correct ONLY for a seat that writes nothing at all — which, under the artifact+marker rule, is no seat this skill tells you to spawn. When the prompt does appear, answer **"Yes, and don't ask again for these files"** rather than plain yes: plain yes re-blocks on the next write of the same file. **That `--settings` pair is not cosmetic and it is not optional:** claude-code's AUTOSUGGEST renders context-plausible ghost text into an idle seat's prompt line that is BYTE-IDENTICAL to a typed draft in text-format reads (OBS-482), so a supervising tier cannot tell a seat's own unsent work from a rendering artifact without `agent read --format ansi`. Turning the suggester off at spawn removes the ambiguity at its source instead of paying for the discrimination at every read. Verified against the shipped binary: `claude --settings '{"promptSuggestionEnabled":false}' -p …` exits 0 with a real response, and the key appears in the binary's own settings schema. **For kimi, pass `-y`** (`herdr agent start <name> --kind kimi --pane <id> -- -y`) — the adapter already launches its own workers that way (`src/adapters/kimi.ts:204`), and a kimi seat spawned without it sits on an approval prompt having done nothing. **Herdr cannot see that state**: it reports a kimi pane as `agent_status: working` with `screen_detection_skipped: true` while the prompt is up, so the BLOCKED-STATE watcher below is blind on this vendor and the spawn flag is the ONLY control. Every vendor you spawn needs its auto-approve form named here; a vendor absent from this list is a seat that will hang.
153
153
  - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: Seats spawn with `orca terminal create` on a path worktree selector with a command:
154
154
  `orca terminal create --worktree path:<repo> --title "ORCH · <version>" --command "<agent-cmd>" --json`
155
155
  For Claude Code: `orca terminal create --worktree path:<repo> --title "ORCH · <version>" --command "claude --model <picked-model> --permission-mode bypassPermissions --settings '{\"promptSuggestionEnabled\":false}'" --json`. For Codex: `orca terminal create --worktree path:<repo> --title "ORCH · <version>" --command "codex --model <picked-model> --dangerously-bypass-approvals-and-sandbox" --json`. Parse `result.terminal.handle` from the create receipt.
156
+ Require the orchestrator to use the host-owned daemon and detached-beat lifecycle below for both run and resume, and to report the lock pid ancestry check.
156
157
  3. **Standing instructions travel as a brief FILE, never as pane text** — PTY input truncates at ~1024B and a
157
158
  truncated brief silently drops policy. Write the full brief to `<repo>/.tickmarkr/overseer/ORCH-BRIEF.md`
158
159
  (inside the tickmarkr state dir — already self-gitignored, no exclude step needed), then announce the brief file:
@@ -196,9 +197,23 @@ through brief lineage. **An executor choice nobody made is still an executor cho
196
197
  The orchestrator's run log, handoff, and plan live inside its sandbox root and are artifacts of that orchestrator.
197
198
  The overseer reads them from there, using the path the orchestrator reports; do not substitute the
198
199
  overseer's repository or a machine-global planning directory. The orchestrator brief must say: if the seat's
199
- sandbox denies writes under `.git`, report the denial to the overseer and stop; the overseer launches the daemon itself;
200
+ sandbox denies writes under `.git`, report the denial to the overseer and stop; the overseer launches the daemon itself; use the host-owned split of the ORCH pane, never the overseer tab;
200
201
  and the daemon host is never sandboxed.
201
202
 
203
+ **Sandbox-denial daemon launch (OVERSEER):** use the ORCH address recorded at seat creation,
204
+ re-resolved by title if stale; the launching overseer's own terminal is not the target.
205
+
206
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: record the ORCH's
207
+ `result.terminal.handle` from its seat-create receipt. For the fallback, run
208
+ `orca terminal split --terminal <ORCH handle> --direction vertical --command "tickmarkr run"`.
209
+ Here `$ORCA_TERMINAL_HANDLE` names the OVERSEER's own terminal; use it only when the ORCH
210
+ launches its own daemon, never when the OVERSEER launches on the ORCH's behalf.
211
+ - **On herdr (`HERDR_ENV=1`)**: record the ORCH's `result.root_pane.pane_id` from its tab-create
212
+ receipt. For the fallback, use `herdr pane split` targeting that recorded ORCH pane id, then
213
+ `herdr pane run <new> "tickmarkr run"`; never target the OVERSEER's pane.
214
+
215
+ The same recorded ORCH address is required for a fallback `tickmarkr resume <runId>`.
216
+
202
217
  **On herdr (`HERDR_ENV=1`)**, after every `agent start`, wait for and read the model banner. Only then
203
218
  deliver the brief with `pane run`, then read the transcript back and verify that the brief's first words are
204
219
  present before any deadline is armed. **On Orca (`TERM_PROGRAM=Orca` and non-empty
@@ -291,6 +306,40 @@ Kimi `-y`, artifact plus terminal marker, and verified file-brief delivery. Flee
291
306
  nothing the tab did not already hold. Brief delivery travels as a file announced by `orca terminal send --terminal <handle> --text "<announcement>" --enter --wait-submit 15 --json`. Read `result.send.prompt.stages` and treat the brief as delivered only when a `turn_started` stage is present; `accepted: true` proves input acceptance, not a started turn; never resend on `accepted` alone.
292
307
  For Leg-2 (both hosts), a Codex reviewer under `workspace-write` must be briefed with an in-worktree verdict path such as
293
308
  `<repo>/.tickmarkr/overseer/verdicts/<task>.md`, and its verdict must be written there before it is read.
309
+ ## Host-owned daemon and detached beats
310
+
311
+ Launch the daemon as a SPLIT of the orchestrator's own pane in the ORCH tab, owned by the
312
+ host PTY. Never launch it as an agent harness background task or in a separate tab. The daemon
313
+ self-places its board from that split, keeping both daemon and board in the ORCH tab.
314
+
315
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: only when the ORCH
316
+ launches its own daemon, use
317
+ `orca terminal split --terminal "$ORCA_TERMINAL_HANDLE" --direction vertical --command "tickmarkr run"`.
318
+ When the OVERSEER launches the sandbox-denial fallback, substitute the ORCH handle recorded
319
+ at seat creation: `orca terminal split --terminal <ORCH handle> --direction vertical --command "tickmarkr run"`.
320
+ - **On herdr (`HERDR_ENV=1`)**: use `herdr pane split` on the orchestrator's own pane, then
321
+ `herdr pane run <new> "tickmarkr run"` in the returned split. When the OVERSEER launches the
322
+ fallback, target the ORCH pane id recorded at seat creation, never the OVERSEER's pane.
323
+
324
+ For `resume`, use the same host-owned split form with `tickmarkr resume <runId>` in place of
325
+ `tickmarkr run`. After either launch, read this repository's lock pid and walk its ppid chain
326
+ in the process table to the host PTY; verify no agent session is an ancestor. If an agent is
327
+ an ancestor, stop that launch and relaunch through the host split before continuing.
328
+
329
+ Start `tickmarkr beat <tier> --seat <seat> --loop` as a DETACHED background process, one per
330
+ (tier, seat), from the repository root. Use setsid plus nohup (or the host's equivalent detached
331
+ session facility), redirect output to a file, and record a pidfile under the repository's state
332
+ dir. Verify ppid 1 and no agent-session ancestor. Never put beats in a harness background task
333
+ or a visible tab. The lifecycle must be idempotent: start checks the pidfile against the live
334
+ process payload and returns an "already running" no-op for the same (repo, tier, seat); status
335
+ checks that payload and beat freshness; stop sends `--stand-down` for the same tier and seat,
336
+ verifies the loop exits within one interval, then removes its pidfile. A stale or reused pid
337
+ must never be killed without matching its live payload. Include a run-end stop item for every
338
+ beat started, also on failure, park or handoff; verify DISARMED and remove retired beat files.
339
+
340
+ Keep only watchers that must WAKE the seat in the harness. Re-arm them on each wake and at
341
+ their harness cap; daemon and beat lifetimes must not depend on that cap.
342
+
294
343
  ## Supervising tickmarkr as the executor — WHO DOES WHAT
295
344
 
296
345
  When the mission runs `/tickmarkr-auto` (tickmarkr dispatches the workers), supervision changes shape —
@@ -411,6 +460,29 @@ journal tail to decide what happens next, or sweeping orphans — you have taken
411
460
  its death. Liveness comes from the lock's OWN pid (`kill -0`), never a command-name grep. Recovery is
412
461
  `tickmarkr resume <runId>` — **the orchestrator's command, not yours** — and note that resume REPLAYS the
413
462
  journal's `baseRef`, so a fix landed on the base branch is unreachable by the running run.
463
+ - **macOS host: a launchservicesd restart is a host failure, not a kill spree (D-787/D-789).** Measured
464
+ 2026-09-30: during a local full suite launchservicesd crashed at 17:15:00Z (Mach IPC name space
465
+ exhausted, `0x1000600b`, under node `process.title` check-ins) and launchd respawned it at 17:15:09Z
466
+ with an empty app registry. Every GUI app launched before then read "The application … is not open
467
+ anymore", though none had been killed. Replay it from evidence, in this order:
468
+ 1. **Daemon identity.** `/bin/ps -o pid=,lstart= -p "$(pgrep -x launchservicesd)"` beside Dock and
469
+ Finder: a lone young launchservicesd is the tell. Record its pid/lstart before a run and at every lap.
470
+ 2. **Recorded log.** `/usr/bin/log show --start '<that lstart minus two minutes>' --predicate 'process ==
471
+ "launchservicesd" OR process == "launchd"'`, looking for `0x1000600b`, then `service inactive` and
472
+ `Successfully spawned launchservicesd`. Always type the full `/usr/bin/log`: in zsh a bare `log` is a
473
+ builtin that prints nothing, and that silence was once misread as a rotated log (D-787 add.1).
474
+ 3. **Crash report.** `~/Library/Logs/DiagnosticReports/ExcUserFault_node-*.ips` at the same second
475
+ (`uv_set_process_title → _LSApplicationCheckIn`).
476
+ `tickmarkr doctor` and standalone `tickmarkr verify` read step 1 themselves. The check lives in its own
477
+ module, `src/run/launchservices-check.ts`: one bounded `/bin/ps` read with a 5000 ms kill ceiling.
478
+ doctor's wiring is five lines in `src/cli/commands/doctor.ts` around `writeDoctor`. A changed pid or
479
+ start persists one advisory in the `launchServices` object on the FIRST `doctor.json` record. An
480
+ unreadable read is unknown and keeps the prior identity. The advisory never changes a verdict or a
481
+ retry, and the check never reads `/usr/bin/log`, so steps 2–3 stay yours. **The remedy is the
482
+ operator's call, made actionable by that evidence:** quit and relaunch each affected app, or log out /
483
+ restart the Mac. A restart ends every agent session and wipes `/private/tmp`, so stand the run down at
484
+ a clean boundary first and write the handoff; after the reboot, re-resolve seat handles and re-arm
485
+ watchers. Never kill or restart launchservicesd yourself.
414
486
  - **SWEEPING A WORKER ORPHANS ITS CLEANUP, NOT JUST ITS WORK — kill the process GROUP.** Measured
415
487
  2026-08-06: a worker running a legitimate load experiment had spawned CPU burners and held a trailing
416
488
  `kill` line. It was SIGTERM'd as an orphan; **the cleanup never ran**, and **59 surviving burner shells
@@ -1095,9 +1167,11 @@ watching it. Two thirds of that line were constants, not measurements.
1095
1167
  The beat is one shipped command and it arms through two explicit verbs, `--new-arm` and `--loop` —
1096
1168
  never a bare invocation. `--loop` already implies `--new-arm`: it creates a durable arm and beats in
1097
1169
  this process every 10 seconds, exiting on its own within one interval after a stand-down. Run it from
1098
- the repo root as its own `run_in_background` Bash call:
1170
+ the repo root as a detached setsid+nohup process with a pidfile, following the idempotent
1171
+ start/stop/status lifecycle in Host-owned daemon and detached beats; never as a harness task or visible tab:
1099
1172
 
1100
1173
  ```bash
1174
+ # Payload for the detached start above; do not execute as a foreground or harness task.
1101
1175
  cd <repo> && tickmarkr beat overseer --seat <overseer-agent-or-pane> --loop
1102
1176
  cd <repo> && tickmarkr beat overseer --seat <overseer-agent-or-pane> --stand-down # deliberately hand off; --loop exits
1103
1177
  ```
@@ -1156,6 +1230,7 @@ but its process ownership still needs a live check. So:
1156
1230
  legacy bare shell wrapper: unlike `--loop`, it cannot observe the marker and was the form that
1157
1231
  re-armed a stand-down.
1158
1232
 
1233
+ Keep only watchers that must WAKE this seat in the harness, re-armed at their cap.
1159
1234
  Arm the bundled watcher as its OWN Bash call with `run_in_background` — chaining it after other commands
1160
1235
  with `&` orphans it from the wake chain. It prints one wake reason and exits; re-arm after every wake.
1161
1236
 
@@ -1478,7 +1553,7 @@ Create specialist seats using `orca terminal create --worktree path:<repo> --com
1478
1553
  states an act and lets the successor read a fact, and it survived into a handoff exactly once before
1479
1554
  costing two unwatched consult verdicts (OBS-622). The admissible form names the lifetime and the
1480
1555
  work it leaves the successor — *"watchers armed by this session (journal, artifact, dialog, beat);
1481
- they die with it — re-arm on adopt"* — and, per rule 11, says which tier's watchers were NOT armed.
1556
+ harness watchers die with it — re-arm on adopt; detached beats require explicit stop"* — and, per rule 11, says which tier's watchers were NOT armed.
1482
1557
  A detached watcher is the one exception and must be labelled as such, with its heartbeat file, since
1483
1558
  it outlives the seat instead.
1484
1559
  - **SWEEP THE PANES THE RUN LEFT.** A daemon killed by a signal flushes its journal and releases its
@@ -1,11 +1,38 @@
1
1
  #!/bin/bash
2
- # classify-vitest-log.sh <log> — the ONE strict Vitest log classifier (OBS-1184). The public CI
3
- # wrapper (scripts/run-ci-vitest.sh) and grade-ci.sh both call it, so the badge and the grade cannot
4
- # disagree. Reads a raw Vitest log or a gh job log (gh's "job<TAB>step<TAB>timestamp " prefix and ANSI
5
- # styling — the raw ESC byte and gh's `^[` rendering of it — are stripped) and prints ONE line:
6
- # VITEST_LOG verdict=<CLEAN|RPC_ONLY|RED> reason=<why> summaries=S passed=P skipped=K failed=F
7
- # timedout=T unhandled=U runner_rpc_timeouts=R coverage_misses=C signals=G terminal_errors=E
8
- # unhandled counts the unhandled errors that are NOT runner RPC timeouts. Exit 0 CLEAN or RPC_ONLY, 1 RED.
2
+ # classify-vitest-log.sh <log> [expected-files] — the ONE strict Vitest log classifier
3
+ # (OBS-1184). The public CI wrapper (scripts/run-ci-vitest.sh) and grade-ci.sh both call it, so the badge
4
+ # and the grade cannot disagree. Reads a raw Vitest log or a gh job log (gh's "job<TAB>step<TAB>timestamp "
5
+ # prefix and ANSI styling — the raw ESC byte and gh's `^[` rendering of it — are stripped) and prints ONE line:
6
+ # VITEST_LOG verdict=<CLEAN|RPC_ONLY|RED|INFRA_KILLED> reason=<why> summaries=S passed=P skipped=K failed=F
7
+ # timedout=T unhandled=U runner_rpc_timeouts=R coverage_misses=C signals=G terminal_errors=E runner_kills=X
8
+ # unhandled counts the unhandled errors that are NOT runner RPC timeouts. Exit 0 CLEAN or RPC_ONLY, 1 RED,
9
+ # 3 INFRA_KILLED.
10
+ #
11
+ # INFRA_KILLED (D-897): the GitHub runner's own shutdown or cancellation annotation ("##[error]The runner
12
+ # has received a shutdown signal…" / "##[error]The operation was canceled.") with no failed or timed-out
13
+ # test and no coverage-threshold miss. It is never green and never passes a gate — the proof was not
14
+ # finished — but it is not a test red either. Those failures dominate a kill (RED); a kill excuses only
15
+ # the cut-off or short log it leaves behind (runner_kills counts the annotations either way). A failure
16
+ # the reporter already printed before the kill cut its summary off counts too: a dot-reporter "x" (its
17
+ # stream has no newline, so a test's "stdout | …"/"stderr | …" header may finish the same line), a
18
+ # default-reporter "×" or failed module record "❯ file (N tests…)" (❯ marks a failed module even with
19
+ # no failed test — a collect or hook error), a " FAIL " badge or the "Failed Tests/Suites N" banner (only
20
+ # beside a kill — without one, the complete summaries' own counts decide). Only one printed after the last
21
+ # complete summary counts: a complete summary's own counts settle every line before it. Test output never
22
+ # counts: a console block runs from its header until the reporter speaks again — a dot run, a ⎯ banner or
23
+ # a default-reporter module record (✓/❯/↓ file (N tests…)) right after a blank line, or the next summary —
24
+ # so blank lines inside a test's output do not end it.
25
+ # ponytail: a test that prints, after a blank line of its own, a line the reporter could have printed is
26
+ # indistinguishable from the reporter; only that deliberate forgery escapes the block.
27
+ #
28
+ # The declared contract (D-893), when a caller supplies one: expected-files is the number of test files
29
+ # the run must collect — the summaries' "(N)" totals must sum to it exactly. The caller derives it from
30
+ # the declared projects' own candidate-tree discovery, so a never-started declared project is a short
31
+ # count: project presence comes from that discovery, never from log text a test can print. A short count
32
+ # is RED reason=partial: a complete summary of the projects that happened to start proves nothing about
33
+ # the one that did not. With or without a contract, a summary whose finished files (failed + passed +
34
+ # skipped + todo) fall short of its own "(N)" is RED reason=partial too: Vitest's total counts the queued
35
+ # and running files an abort left unfinished.
9
36
  #
10
37
  # RED unless every Vitest summary is complete (Test Files, Tests and Duration lines, and every
11
38
  # "Unhandled Errors" block closed by the summary that follows it — a cut-off log is RED), with no
@@ -29,21 +56,28 @@
29
56
  set -u
30
57
 
31
58
  log=${1:?log path required}
32
- fields='summaries=0 passed=0 skipped=0 failed=0 timedout=0 unhandled=0 runner_rpc_timeouts=0 coverage_misses=0 signals=0 terminal_errors=0'
59
+ fields='summaries=0 passed=0 skipped=0 failed=0 timedout=0 unhandled=0 runner_rpc_timeouts=0 coverage_misses=0 signals=0 terminal_errors=0 runner_kills=0'
33
60
  if [ ! -s "$log" ] || [ ! -r "$log" ]; then
34
61
  echo "VITEST_LOG verdict=RED reason=unreadable $fields"
35
62
  exit 1
36
63
  fi
37
64
 
38
65
  esc=$(printf '\033')
39
- awk -v esc="$esc" '
66
+ awk -v esc="$esc" -v expected="${2:-}" '
40
67
  function num(s, word) { return match(s, "[0-9]+ " word) ? substr(s, RSTART, RLENGTH) + 0 : 0 }
41
68
  function close_block() { rpc += (found < cap ? found : cap); armed = 0; pending = 0 }
42
69
  {
43
70
  line = $0
44
71
  gsub(/\^\[\[[0-9;]*m/, "", line); gsub(esc "\\[[0-9;]*m", "", line)
45
72
  sub(/^[^\t]*\t[^\t]*\t[0-9T:.-]+Z[ ]?/, "", line)
73
+ if (line ~ /^[ \t]*(·|x|\*|-)*(stdout|stderr) \| /) {
74
+ if (line ~ /^[ \t]*(·|\*|-)*x/) reported++
75
+ console = 1
76
+ } else if (after_blank && line ~ /^[ \t]*((·|x|\*|-)*(·|x)(·|x|\*|-)*|(⎯)+( .* (⎯)+)?|(✓|❯|↓) .* [(][0-9]+ tests?( [|] [0-9]+ (failed|skipped))*[)].*)[ \t]*$/) console = 0
77
+ if (!console && line ~ /^[ \t]*((·|\*|-)*x(·|x|\*|-)*|× .*|❯ .* [(][0-9]+ tests?( [|] [0-9]+ (failed|skipped))*[)].*|FAIL[ \t].*|(⎯)+ Failed (Tests|Suites) [0-9]+ (⎯)+)[ \t]*$/) reported++
78
+ after_blank = line !~ /[^ \t]/
46
79
  }
80
+ line ~ /^##\[error\](The runner has received a shutdown signal|The operation was cancell?ed)/ { kills++ }
47
81
  # The Vitest timeout message carries its millisecond count; a test that PRINTS a fingerprint normalized
48
82
  # to #ms (the daemon OBS-1106 notifications) is output, not a timed-out test. A real timeout also fails
49
83
  # its test, so the summary failed count still reds it.
@@ -59,13 +93,17 @@ awk -v esc="$esc" '
59
93
  }
60
94
  line ~ /^[ \t]*Test Files[ \t]/ {
61
95
  if (armed) close_block()
62
- summaries++; in_summary = 1; saw_tests = 0
96
+ summaries++; in_summary = 1; saw_tests = 0; console = 0
63
97
  failed += num(line, "failed"); passed += num(line, "passed"); skipped += num(line, "skipped")
98
+ finished = num(line, "failed") + num(line, "passed") + num(line, "skipped") + num(line, "todo")
99
+ total = match(line, /\([0-9]+\)[ \t]*$/) ? substr(line, RSTART + 1) + 0 : -1
100
+ if (total >= 0) collected += total
101
+ if (finished != total) unfinished++
64
102
  next
65
103
  }
66
104
  in_summary && line ~ /^[ \t]*Tests[ \t]/ { saw_tests = 1; failed_tests += num(line, "failed"); next }
67
105
  in_summary && line ~ /^[ \t]*Errors[ \t]+[0-9]+ error/ { summary_errors += num(line, "error"); next }
68
- in_summary && line ~ /^[ \t]*Duration[ \t]/ { if (saw_tests) complete++; in_summary = 0; next }
106
+ in_summary && line ~ /^[ \t]*Duration[ \t]/ { if (saw_tests) { complete++; reported = 0 }; in_summary = 0; next }
69
107
  armed && line ~ /^(⎯)+ Unhandled Error (⎯)+[ \t]*$/ { pending = 1; next }
70
108
  line ~ /^(⎯)+ (Unhandled|Startup|Collect) Error (⎯)+[ \t]*$/ { terminal++; next }
71
109
  pending && line ~ /[^ \t]/ { pending = 0; if (line ~ /^Error: \[vitest-worker\]: Timeout calling "[A-Za-z]+"[ \t]*$/) found++ }
@@ -73,19 +111,23 @@ awk -v esc="$esc" '
73
111
  unhandled = tally + untallied
74
112
  if (summary_errors > unhandled) unhandled = summary_errors
75
113
  other = unhandled - rpc
76
- if (summaries == 0) reason = "no-summary"
114
+ partial = unfinished || (expected != "" && collected != expected + 0)
115
+ # a kill always names a class: the failure that dominates it, or the kill itself
116
+ if (kills) reason = (failed || failed_tests || reported) ? "failed" : timedout ? "timed-out" : coverage ? "coverage-threshold" : "runner-shutdown"
117
+ else if (summaries == 0) reason = "no-summary"
77
118
  else if (complete != summaries || opening || armed) reason = "truncated"
78
119
  else if (failed || failed_tests) reason = "failed"
79
120
  else if (timedout) reason = "timed-out"
121
+ else if (partial) reason = "partial"
80
122
  else if (coverage) reason = "coverage-threshold"
81
123
  else if (signals) reason = "signal"
82
124
  else if (terminal) reason = "terminal-error"
83
125
  else if (other > 0) reason = "unhandled-error"
84
126
  else if (tally + untallied != summary_errors) reason = "unhandled-mismatch"
85
- verdict = reason != "" ? "RED" : (unhandled > 0 ? "RPC_ONLY" : "CLEAN")
127
+ verdict = reason == "runner-shutdown" ? "INFRA_KILLED" : (reason != "" ? "RED" : (unhandled > 0 ? "RPC_ONLY" : "CLEAN"))
86
128
  if (reason == "") reason = "none"
87
- printf "VITEST_LOG verdict=%s reason=%s summaries=%d passed=%d skipped=%d failed=%d timedout=%d unhandled=%d runner_rpc_timeouts=%d coverage_misses=%d signals=%d terminal_errors=%d\n", \
88
- verdict, reason, summaries, passed, skipped, failed, timedout, other, rpc, coverage, signals, terminal
89
- exit (verdict == "RED")
129
+ printf "VITEST_LOG verdict=%s reason=%s summaries=%d passed=%d skipped=%d failed=%d timedout=%d unhandled=%d runner_rpc_timeouts=%d coverage_misses=%d signals=%d terminal_errors=%d runner_kills=%d\n", \
130
+ verdict, reason, summaries, passed, skipped, failed, timedout, other, rpc, coverage, signals, terminal, kills
131
+ exit (verdict == "INFRA_KILLED" ? 3 : verdict == "RED")
90
132
  }
91
133
  ' "$log"
@@ -1,7 +1,9 @@
1
1
  #!/bin/bash
2
2
  # grade-ci.sh <run-id> <expected-count> [tag] — grade both public CI jobs from their JOB LOGS.
3
3
  # Grade only at run-end. A missing/in-progress/empty log is UNREADABLE, never evidence of green.
4
- # Tri-state: exit 0 GREEN, 1 RED, 2 UNREADABLE. UNREADABLE dominates a mixed result.
4
+ # Exit 0 GREEN, 1 RED, 2 UNREADABLE, 3 INFRA_KILLED: a job the GitHub runner shut down or cancelled with
5
+ # no failed or timed-out test (the classifier's class, D-897) — never green, never a pass, only unproven.
6
+ # UNREADABLE dominates a mixed result, then RED: a failure anywhere dominates a kill.
5
7
  set -u
6
8
 
7
9
  run=${1:?run id required}
@@ -14,7 +16,8 @@ mkdir -p "$out_dir" || { echo "UNREADABLE: cannot create log directory $out_dir"
14
16
 
15
17
  verdict=0
16
18
  mark_unreadable() { verdict=2; }
17
- mark_red() { [ "$verdict" -eq 0 ] && verdict=1; }
19
+ mark_red() { [ "$verdict" -eq 2 ] || verdict=1; }
20
+ mark_killed() { [ "$verdict" -ne 0 ] || verdict=3; }
18
21
  field() { printf '%s\n' "$classified" | sed -n "s/^VITEST_LOG.* $1=\([^ ]*\).*/\1/p"; }
19
22
 
20
23
  jobs=$(gh run view "$run" --repo "$repo" --json jobs \
@@ -52,10 +55,12 @@ while IFS=$'\t' read -r id name status conclusion; do
52
55
  # (scripts/run-ci-vitest.sh), so the badge and this grade cannot disagree. Its rules — complete
53
56
  # summaries, failed/timed-out tests, coverage-threshold misses, and the RPC-timeout-only exception
54
57
  # to "any unhandled error is RED" (OBS-1058) — live in classify-vitest-log.sh, stated once there.
55
- classified=$(bash "$here/classify-vitest-log.sh" "$log" 2>&1)
58
+ # The expected count is the job's declared file contract: both steps' summaries must collect it all.
59
+ classified=$(bash "$here/classify-vitest-log.sh" "$log" "$expected" 2>&1)
56
60
  log_verdict=$(field verdict)
57
61
  passed=$(field passed); skipped=$(field skipped); failed=$(field failed); timedout=$(field timedout)
58
62
  unhandled=$(field unhandled); rpc=$(field runner_rpc_timeouts); coverage=$(field coverage_misses)
63
+ kills=$(field runner_kills)
59
64
  errors=$(grep -oE '##\[error\].*' "$log" | sort | uniq -c | sed 's/^ *//' | tr '\n' ';')
60
65
  # The commands' own outcomes stand, as in the wrapper: GREEN needs a success conclusion with no step
61
66
  # exit annotation, or a failure explained ONLY by steps that exited exactly 1 on a log of their own
@@ -81,8 +86,16 @@ while IFS=$'\t' read -r id name status conclusion; do
81
86
  done <<< "$exits"
82
87
  fi
83
88
 
84
- echo "$name: oracle=[${oracle:-MISSING}] files=[$(printf '%s' "$files" | tr '\n' '|')] passed=$passed skipped=$skipped failed=$failed timedout=$timedout unhandled=$unhandled runner_rpc_timeouts=$rpc coverage_misses=$coverage log=[$(field reason)] outcome=[$outcome] errors=[$errors]"
85
- if [ -z "$oracle" ] || [ -z "$files" ] || [ -z "$log_verdict" ]; then
89
+ echo "$name: oracle=[${oracle:-MISSING}] files=[$(printf '%s' "$files" | tr '\n' '|')] passed=$passed skipped=$skipped failed=$failed timedout=$timedout unhandled=$unhandled runner_rpc_timeouts=$rpc coverage_misses=$coverage runner_kills=${kills:-?} log=[$(field reason)] outcome=[$outcome] errors=[$errors]"
90
+ # A killed runner skips the count step, so its missing oracle is the kill's, not an unreadable log:
91
+ # INFRA_KILLED unless a step's own failing exit or a failed/timed-out test dominates it (then RED).
92
+ if [ "$log_verdict" = INFRA_KILLED ] && [ "${outcome#exit-}" = "$outcome" ]; then
93
+ echo "$name: INFRA_KILLED"
94
+ mark_killed
95
+ elif [ "${kills:-0}" -gt 0 ] 2>/dev/null; then
96
+ echo "$name: RED"
97
+ mark_red
98
+ elif [ -z "$oracle" ] || [ -z "$files" ] || [ -z "$log_verdict" ]; then
86
99
  echo "$name: UNREADABLE"
87
100
  mark_unreadable
88
101
  elif [ "$oracle" = "COUNT_ORACLE GREEN expected=$expected actual=$expected" ] \
@@ -104,5 +117,5 @@ if [ "$seen_macos" -ne 1 ]; then
104
117
  mark_unreadable
105
118
  fi
106
119
 
107
- echo "VERDICT rc=$verdict (0=all GREEN 1=RED 2=UNREADABLE)"
120
+ echo "VERDICT rc=$verdict (0=all GREEN 1=RED 2=UNREADABLE 3=INFRA_KILLED)"
108
121
  exit "$verdict"
@@ -18,79 +18,136 @@
18
18
  # a self-criticism — the one sentence nobody audits, including its writer. The gap is real and the watcher
19
19
  # is justified by the ABSENCE OF COVERAGE, not by that number. See OBS-435.
20
20
  #
21
- # Prints ONE wake reason and exits. Re-arm after every wake.
21
+ # Prints ONE wake reason, ending with its `CURSOR <run> <line>` receipt, and exits. Re-arm after every
22
+ # wake WITH that receipt: it is the acknowledgment, and only the rows it covers are suppressed.
22
23
  #
23
- # usage: watch-parks.sh <runs-dir> [poll-s] [cap-s]
24
- # Tracks the NEWEST run directory, so it follows a resume or a fresh run without being re-aimed.
24
+ # usage: watch-parks.sh <runs-dir> [poll-s] [cap-s] [--since-run <run-id> --since-line <n>]
25
+ # Tracks the NEWEST run (the latestRunId rule: lexical run-id order, journal present), so it follows a
26
+ # resume or a fresh run without being re-aimed.
27
+ #
28
+ # ── THE CURSOR (D-829) — what replaced arm-time seeding ────────────────────────────────────────────────
29
+ # Earlier versions seeded on the journal AS ARMED — a park count, then a line baseline. Both read "already
30
+ # in the file when I started" as "already ruled", and the gap between a wake and its re-arm is exactly
31
+ # where that is false: a park appended while the seat was busy ruling the last one was seeded as history
32
+ # and never reported, released or not. The seat's own acknowledgment is the only honest baseline, so the
33
+ # START read happens before any sleep. The closed cursor inputs:
34
+ # same-run acknowledged receipt run == newest run: complete rows at lines <= n are suppressed, no others
35
+ # gap park a task-human row after n is reported at START
36
+ # later-release a gap park released before the re-arm is STILL reported — this answers "which
37
+ # parks has the seat not seen", never "which parks are still open"
38
+ # new-run newest run != receipt run (or no receipt): the receipt resets to line 0
39
+ # empty-run no run has a journal yet: the receipt is `CURSOR none 0`, and re-arming with
40
+ # `--since-run none --since-line 0` is the same as no receipt, so the first run
41
+ # that appears is read from line 0
42
+ # torn tail a final line without its newline is not a row: never reported, never counted, so
43
+ # the cursor never advances past it and its completed form is reported later
44
+ # A receipt line beyond its run's complete lines is not this journal's acknowledgment: refused (exit 65),
45
+ # because silently suppressing rows on a mismatched receipt is the failure this file exists to prevent.
46
+ # A journal that cannot be read is not an empty scan: exit 66, no receipt, no WATCH_CAP_REACHED. Nor is a
47
+ # runs dir or run directory that cannot be listed or searched: discovery skips only ABSENCE (no runs dir
48
+ # yet, a run with no journal yet), because falling back to an older run hides the newest run's parks. Any
49
+ # other reader failure, or reader output whose last line is not a receipt, is an error too — never
50
+ # "nothing new".
51
+ #
52
+ # A `run-end` after the cursor wakes too (the milestone verdict is a decision), and a park wake reports a
53
+ # run-end in the same range, so a returned cursor never covers a row the seat was not shown.
25
54
 
26
55
  set -u
27
- RUNS="${1:?runs dir required (e.g. .tickmarkr/runs)}"
28
- POLL="${2:-45}"
29
- CAP="${3:-28800}"
56
+ POS=() SINCE_RUN="" SINCE=""
57
+ while [ $# -gt 0 ]; do
58
+ case "$1" in
59
+ --since-run) SINCE_RUN="${2-}"; shift; [ $# -gt 0 ] && shift ;;
60
+ --since-line) SINCE="${2-}"; shift; [ $# -gt 0 ] && shift ;;
61
+ *) POS+=("$1"); shift ;;
62
+ esac
63
+ done
64
+ RUNS="${POS[0]:?runs dir required (e.g. .tickmarkr/runs)}"
65
+ POLL="${POS[1]:-45}"
66
+ CAP="${POS[2]:-28800}"
67
+ # The receipt is both halves or neither: a line without its run cannot be bound to a journal. Run ids follow
68
+ # parseRunId (src/run/journal.ts): run- then an alphanumeric, then alphanumerics, `_` or `-`.
69
+ if [ -n "$SINCE_RUN$SINCE" ] && ! [[ ( "$SINCE_RUN" =~ ^run-[A-Za-z0-9][A-Za-z0-9_-]*$ && "$SINCE" =~ ^[0-9]+$ ) || ( "$SINCE_RUN" = none && "$SINCE" = 0 ) ]]; then
70
+ echo "watch-parks.sh: the receipt is --since-run <run-id> --since-line <n> together, got run='$SINCE_RUN' line='$SINCE'" >&2
71
+ exit 64
72
+ fi
30
73
 
31
- # `grep -c` EXITS 1 WHEN THE COUNT IS ZERO, while still printing `0`. So the idiom
32
- # `n=$(grep -c PAT f || echo 0)` yields the two-line string "0\n0", and every later `[ "$n" -gt … ]`
33
- # dies with `integer expression expected` and evaluates FALSE. Found 2026-08-07 by a positive control,
34
- # not by use: this watcher fired correctly all day because it was always armed on a journal that ALREADY
35
- # held a park, which is the branch where grep exits 0. **Armed at the start of a run — zero parks — it
36
- # could never report the first park, which is the one it exists for.** A guard whose failure is silence
37
- # needs a control; this one had shipped without one.
38
- count_parks() {
39
- local c
40
- c=$(grep -c '"event":"task-human"' "$1" 2>/dev/null || true)
41
- printf '%s' "${c:-0}"
74
+ # ONE JSON reader for every scan, START and poll alike. It applies the journal reader's rule
75
+ # (src/run/journal.ts parseJournalText: skip blanks, drop an unparseable line, keep each row's PHYSICAL
76
+ # line) to the newline-terminated lines only. argv: runs dir, receipt run, receipt line. Prints the wake, if
77
+ # any, then always `CURSOR <run> <last complete line>` (the receipt unchanged while no run has a journal).
78
+ # Exit 0 = scanned (a wake is any line before the receipt), 65 = refused receipt, 66 = failed read. It sets exitCode rather than calling exit, so a
79
+ # wake larger than the pipe buffer drains before node quits (a 1,500-park wake was cut off mid-list).
80
+ # It reports rows; it never refolds which parks are still open — that is the decision fold's job.
81
+ READER='
82
+ process.exitCode = (() => {
83
+ const fs = require("fs"), { join } = require("path");
84
+ const [runs, sinceRun, sinceLine] = process.argv.slice(1);
85
+ const fail = (what, e) => { console.error(`watch-parks.sh: cannot read ${what}: ${e.code ?? e.message}`); return 66; };
86
+ // Discovery: newest run id first, skipping only a journal that is ABSENT (ENOENT/ENOTDIR). EACCES on an
87
+ // unsearchable run directory, or ELOOP, is a failed read, never a fall-back to an older run.
88
+ let ids = [];
89
+ try { ids = fs.readdirSync(runs).filter((d) => d.startsWith("run-")).sort().reverse(); } catch (e) {
90
+ if (e.code !== "ENOENT") return fail(runs, e);
42
91
  }
43
-
44
- newest_journal() {
45
- local d
46
- d=$(ls -t "$RUNS" 2>/dev/null | grep '^run-' | head -1)
47
- [ -n "$d" ] && [ -f "$RUNS/$d/journal.jsonl" ] && printf '%s' "$RUNS/$d/journal.jsonl"
92
+ let run, file;
93
+ for (const id of ids) {
94
+ const f = join(runs, id, "journal.jsonl");
95
+ try { fs.statSync(f); } catch (e) {
96
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") continue;
97
+ return fail(f, e);
98
+ }
99
+ [run, file] = [id, f];
100
+ break;
48
101
  }
102
+ if (!run) { console.log(`CURSOR ${sinceRun || "none"} ${sinceLine || 0}`); return 0; }
103
+ const since = run === sinceRun ? Number(sinceLine) : 0;
104
+ let text;
105
+ try { text = fs.readFileSync(file, "utf8"); } catch (e) { return fail(file, e); }
106
+ const lines = text.split("\n");
107
+ const complete = lines.length - 1; // the last element is "" or a torn tail: never a complete line
108
+ if (since > complete) {
109
+ console.error(`watch-parks.sh: receipt ${run} line ${since} is beyond its ${complete} complete lines — not an acknowledgment of this journal`);
110
+ return 65;
111
+ }
112
+ const parks = [], ends = [];
113
+ for (let n = since + 1; n <= complete; n++) {
114
+ let row;
115
+ try { row = JSON.parse(lines[n - 1]); } catch { continue; }
116
+ if (row?.event === "task-human") parks.push([n, row]);
117
+ else if (row?.event === "run-end") ends.push([n, row]);
118
+ }
119
+ const out = [];
120
+ if (parks.length) {
121
+ out.push(`PARK ${parks.length} new — ${run}`);
122
+ for (const [n, row] of parks) out.push(` ${row.taskId}: ${String(row.data?.reason ?? "").replace(/\s+/g, " ").slice(0, 160)}`, ` park ${n}@${row.ts}`);
123
+ out.push(" a park waits for a RULING and nothing else releases it — read the gate evidence, then rule");
124
+ }
125
+ if (ends.length) {
126
+ out.push(`RUN_END ${run} tipVerify=${ends.at(-1)[1].data?.tipVerify ?? "unknown"}`);
127
+ out.push(" read tipVerify as a FIELD; a failing verify is a DIFFERENT event with no pass field");
128
+ }
129
+ out.push(`CURSOR ${run} ${complete}`);
130
+ console.log(out.join("\n"));
131
+ return 0;
132
+ })();
133
+ '
49
134
 
50
- # Seed on the CURRENT park count so we wake on the next one, not on history already ruled.
51
- J=$(newest_journal)
52
- seen=0
53
- [ -n "${J:-}" ] && seen=$(count_parks "$J")
54
- seen_run="${J:-}"
55
-
56
- # ...and seed a LINE POSITION, because the park counter is not enough (evidence rule 30).
57
- # `run-end` is a HISTORICAL RECORD once written. A whole-file grep for it finds the PREVIOUS run-end
58
- # on every resume, and on any re-arm after a run has ended — so the watcher exits in its first poll,
59
- # a supervisor re-execs it into the same instant exit, and the process table shows coverage that does
60
- # not exist. Measured 2026-08-07: re-arming this watcher after run 264's run-end would have done
61
- # exactly that. Only lines appended AFTER arming are evidence about now.
62
- base=0
63
- [ -n "${J:-}" ] && base=$(wc -l < "$J" 2>/dev/null || echo 0)
64
- since_arm() { tail -n +$((base + 1)) "$1" 2>/dev/null; }
135
+ # Sets `last` to the scan, which always ends in its receipt, and returns 0; any nonzero is a failed read.
136
+ CURSOR_RE='^CURSOR (run-[A-Za-z0-9][A-Za-z0-9_-]* [0-9]+|none 0)$'
137
+ scan() {
138
+ last=$(node -e "$READER" "$RUNS" "$SINCE_RUN" "$SINCE") || return
139
+ [[ "${last##*$'\n'}" =~ $CURSOR_RE ]] || { echo "watch-parks.sh: reader output ended without a receipt" >&2; return 70; }
140
+ }
65
141
 
66
- elapsed=0
67
- while [ "$elapsed" -lt "$CAP" ]; do
142
+ elapsed=0 last=""
143
+ while :; do
144
+ scan || { rc=$?; echo "WATCH_ERROR — journal scan failed (status $rc); no receipt, no coverage until re-armed" >&2; exit "$rc"; }
145
+ # Only a wake prints lines before its receipt.
146
+ [[ "$last" == *$'\n'* ]] && { printf '%s\n' "$last"; exit 0; }
147
+ [ "$elapsed" -ge "$CAP" ] && break
68
148
  sleep "$POLL"
69
149
  elapsed=$((elapsed + POLL))
70
-
71
- J=$(newest_journal)
72
- [ -z "${J:-}" ] && continue
73
-
74
- # A new run resets the baseline — its parks AND its whole journal are unseen by definition.
75
- if [ "$J" != "$seen_run" ]; then seen=0; seen_run="$J"; base=0; fi
76
-
77
- now=$(count_parks "$J")
78
- if [ "$now" -gt "$seen" ]; then
79
- echo "PARK $((now - seen)) new — $(basename "$(dirname "$J")")"
80
- grep '"event":"task-human"' "$J" 2>/dev/null | tail -n "$((now - seen))" \
81
- | sed -n 's/.*"taskId":"\([^"]*\)".*"reason":"\([^"]\{0,160\}\).*/ \1: \2/p'
82
- echo " a park waits for a RULING and nothing else releases it — read the gate evidence, then rule"
83
- exit 0
84
- fi
85
-
86
- # A run that ended is also this seat's business: the milestone verdict is a decision.
87
- # Scoped to lines appended since arming — see the `base` comment above.
88
- if since_arm "$J" | grep -q '"event":"run-end"'; then
89
- tv=$(since_arm "$J" | grep '"event":"run-end"' | tail -1 | sed -n 's/.*"tipVerify":"\([a-z]*\)".*/\1/p')
90
- echo "RUN_END $(basename "$(dirname "$J")") tipVerify=${tv:-unknown}"
91
- echo " read tipVerify as a FIELD; a failing verify is a DIFFERENT event with no pass field"
92
- exit 0
93
- fi
94
150
  done
95
151
 
96
- echo "WATCH_CAP_REACHED — no new park or run-end in ${CAP}s (newest: $(basename "$(dirname "${J:-none/none}")"))"
152
+ echo "WATCH_CAP_REACHED — no new park or run-end in ${CAP}s"
153
+ printf '%s\n' "$last"