tickmarkr 2.6.5 → 2.6.7

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 (56) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/claude-code.js +8 -2
  3. package/dist/adapters/codex-commit-check.d.ts +16 -4
  4. package/dist/adapters/codex-commit-check.js +150 -26
  5. package/dist/adapters/codex.d.ts +1 -0
  6. package/dist/adapters/codex.js +20 -8
  7. package/dist/adapters/types.d.ts +4 -1
  8. package/dist/cli/commands/beat.d.ts +6 -1
  9. package/dist/cli/commands/beat.js +123 -76
  10. package/dist/cli/commands/doctor.js +3 -1
  11. package/dist/cli/commands/fleet.js +368 -85
  12. package/dist/cli/commands/init.js +23 -9
  13. package/dist/cli/commands/plan.js +12 -1
  14. package/dist/cli/commands/verify.js +37 -9
  15. package/dist/cli/help.d.ts +1 -0
  16. package/dist/cli/help.js +10 -5
  17. package/dist/cli/index.d.ts +1 -1
  18. package/dist/cli/index.js +1 -1
  19. package/dist/config/fleet-overlay.d.ts +14 -0
  20. package/dist/config/fleet-overlay.js +154 -25
  21. package/dist/drivers/orca.d.ts +17 -3
  22. package/dist/drivers/orca.js +64 -20
  23. package/dist/gates/baseline.d.ts +9 -0
  24. package/dist/gates/baseline.js +14 -3
  25. package/dist/gates/cache.js +1 -1
  26. package/dist/gates/review.js +15 -0
  27. package/dist/gates/run-gates.d.ts +20 -7
  28. package/dist/gates/run-gates.js +345 -210
  29. package/dist/gates/test-manifest.d.ts +11 -0
  30. package/dist/gates/test-manifest.js +151 -111
  31. package/dist/gates/timeout-shaped.d.ts +3 -0
  32. package/dist/gates/timeout-shaped.js +10 -0
  33. package/dist/run/beat-lifecycle.d.ts +154 -0
  34. package/dist/run/beat-lifecycle.js +825 -0
  35. package/dist/run/daemon.js +202 -126
  36. package/dist/run/git-trust.d.ts +27 -0
  37. package/dist/run/git-trust.js +178 -0
  38. package/dist/run/git.js +34 -14
  39. package/dist/run/journal.js +15 -3
  40. package/dist/run/lease.d.ts +23 -0
  41. package/dist/run/lease.js +232 -38
  42. package/dist/run/merge.js +2 -2
  43. package/dist/run/stall.js +20 -1
  44. package/dist/run/supervision.d.ts +8 -0
  45. package/dist/run/supervision.js +24 -4
  46. package/dist/run/verification-job.d.ts +51 -0
  47. package/dist/run/verification-job.js +137 -0
  48. package/dist/tui/ink/fleet-app.d.ts +35 -5
  49. package/dist/tui/ink/fleet-app.js +244 -124
  50. package/package.json +1 -1
  51. package/skills/tickmarkr-auto/SKILL.md +19 -10
  52. package/skills/tickmarkr-loop/SKILL.md +19 -10
  53. package/skills/tickmarkr-overseer/SKILL.md +25 -16
  54. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
  55. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
  56. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
@@ -113,16 +113,25 @@ For `resume`, use the same host-owned split form with `tickmarkr resume <runId>`
113
113
  in the process table to the host PTY; verify no agent session is an ancestor. If an agent is
114
114
  an ancestor, stop that launch and relaunch through the host split before continuing.
115
115
 
116
- Start `tickmarkr beat <tier> --seat <seat> --loop` as a DETACHED background process, one per
117
- (tier, seat), from the repository root. Use setsid plus nohup (or the host's equivalent detached
118
- session facility), redirect output to a file, and record a pidfile under the repository's state
119
- dir. Verify ppid 1 and no agent-session ancestor. Never put beats in a harness background task
120
- or a visible tab. The lifecycle must be idempotent: start checks the pidfile against the live
121
- process payload and returns an "already running" no-op for the same (repo, tier, seat); status
122
- checks that payload and beat freshness; stop sends `--stand-down` for the same tier and seat,
123
- verifies the loop exits within one interval, then removes its pidfile. A stale or reused pid
124
- must never be killed without matching its live payload. Include a run-end stop item for every
125
- beat started, also on failure, park or handoff; verify DISARMED and remove retired beat files.
116
+ Detached beats are product-owned, one per (tier, seat), from the repository root, through the
117
+ shipped lifecycle verbs — never a hand-rolled setsid plus nohup wrapper, pidfile or shell loop:
118
+
119
+ ```bash
120
+ cd <repo> && tickmarkr beat start <tier> --seat <seat>
121
+ cd <repo> && tickmarkr beat status <tier> --seat <seat>
122
+ cd <repo> && tickmarkr beat stop <tier> --seat <seat>
123
+ ```
124
+
125
+ `start` launches the `--loop` child in its own session, logs to `<state-dir>/supervision/<tier>.log`,
126
+ records its pid, birth, argv and cwd, and exits 0 only after it reads back that exact child's own
127
+ advancing beat. A repeat start for the same (tier, seat) is an "already running" no-op; a stale,
128
+ dead, foreign, reused-pid, unreadable or busy tier is a nonzero refusal that changes nothing.
129
+ Verify ppid 1 is never proof on its own — an orphan of a dying session shows it too; `status`
130
+ is the check: it reads the recorded process identity and beat freshness, writes nothing, and exits
131
+ nonzero for STALE, MISMATCH or UNREADABLE. `stop` signals only that recorded pid after its
132
+ generation and identity still match, and reads back DISARMED. Never put beats in a harness
133
+ background task or a visible tab. Include a run-end `beat stop` item for every beat started, also
134
+ on failure, park or handoff, and require its DISARMED read-back.
126
135
 
127
136
  Keep only watchers that must WAKE the seat in the harness. Re-arm them on each wake and at
128
137
  their harness cap; daemon and beat lifetimes must not depend on that cap.
@@ -108,16 +108,25 @@ For `resume`, use the same host-owned split form with `tickmarkr resume <runId>`
108
108
  in the process table to the host PTY; verify no agent session is an ancestor. If an agent is
109
109
  an ancestor, stop that launch and relaunch through the host split before continuing.
110
110
 
111
- Start `tickmarkr beat <tier> --seat <seat> --loop` as a DETACHED background process, one per
112
- (tier, seat), from the repository root. Use setsid plus nohup (or the host's equivalent detached
113
- session facility), redirect output to a file, and record a pidfile under the repository's state
114
- dir. Verify ppid 1 and no agent-session ancestor. Never put beats in a harness background task
115
- or a visible tab. The lifecycle must be idempotent: start checks the pidfile against the live
116
- process payload and returns an "already running" no-op for the same (repo, tier, seat); status
117
- checks that payload and beat freshness; stop sends `--stand-down` for the same tier and seat,
118
- verifies the loop exits within one interval, then removes its pidfile. A stale or reused pid
119
- must never be killed without matching its live payload. Include a run-end stop item for every
120
- beat started, also on failure, park or handoff; verify DISARMED and remove retired beat files.
111
+ Detached beats are product-owned, one per (tier, seat), from the repository root, through the
112
+ shipped lifecycle verbs — never a hand-rolled setsid plus nohup wrapper, pidfile or shell loop:
113
+
114
+ ```bash
115
+ cd <repo> && tickmarkr beat start <tier> --seat <seat>
116
+ cd <repo> && tickmarkr beat status <tier> --seat <seat>
117
+ cd <repo> && tickmarkr beat stop <tier> --seat <seat>
118
+ ```
119
+
120
+ `start` launches the `--loop` child in its own session, logs to `<state-dir>/supervision/<tier>.log`,
121
+ records its pid, birth, argv and cwd, and exits 0 only after it reads back that exact child's own
122
+ advancing beat. A repeat start for the same (tier, seat) is an "already running" no-op; a stale,
123
+ dead, foreign, reused-pid, unreadable or busy tier is a nonzero refusal that changes nothing.
124
+ Verify ppid 1 is never proof on its own — an orphan of a dying session shows it too; `status`
125
+ is the check: it reads the recorded process identity and beat freshness, writes nothing, and exits
126
+ nonzero for STALE, MISMATCH or UNREADABLE. `stop` signals only that recorded pid after its
127
+ generation and identity still match, and reads back DISARMED. Never put beats in a harness
128
+ background task or a visible tab. Include a run-end `beat stop` item for every beat started, also
129
+ on failure, park or handoff, and require its DISARMED read-back.
121
130
 
122
131
  Keep only watchers that must WAKE the seat in the harness. Re-arm them on each wake and at
123
132
  their harness cap; daemon and beat lifetimes must not depend on that cap.
@@ -326,16 +326,25 @@ For `resume`, use the same host-owned split form with `tickmarkr resume <runId>`
326
326
  in the process table to the host PTY; verify no agent session is an ancestor. If an agent is
327
327
  an ancestor, stop that launch and relaunch through the host split before continuing.
328
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.
329
+ Detached beats are product-owned, one per (tier, seat), from the repository root, through the
330
+ shipped lifecycle verbs — never a hand-rolled setsid plus nohup wrapper, pidfile or shell loop:
331
+
332
+ ```bash
333
+ cd <repo> && tickmarkr beat start <tier> --seat <seat>
334
+ cd <repo> && tickmarkr beat status <tier> --seat <seat>
335
+ cd <repo> && tickmarkr beat stop <tier> --seat <seat>
336
+ ```
337
+
338
+ `start` launches the `--loop` child in its own session, logs to `<state-dir>/supervision/<tier>.log`,
339
+ records its pid, birth, argv and cwd, and exits 0 only after it reads back that exact child's own
340
+ advancing beat. A repeat start for the same (tier, seat) is an "already running" no-op; a stale,
341
+ dead, foreign, reused-pid, unreadable or busy tier is a nonzero refusal that changes nothing.
342
+ Verify ppid 1 is never proof on its own — an orphan of a dying session shows it too; `status`
343
+ is the check: it reads the recorded process identity and beat freshness, writes nothing, and exits
344
+ nonzero for STALE, MISMATCH or UNREADABLE. `stop` signals only that recorded pid after its
345
+ generation and identity still match, and reads back DISARMED. Never put beats in a harness
346
+ background task or a visible tab. Include a run-end `beat stop` item for every beat started, also
347
+ on failure, park or handoff, and require its DISARMED read-back.
339
348
 
340
349
  Keep only watchers that must WAKE the seat in the harness. Re-arm them on each wake and at
341
350
  their harness cap; daemon and beat lifetimes must not depend on that cap.
@@ -1166,14 +1175,14 @@ watching it. Two thirds of that line were constants, not measurements.
1166
1175
 
1167
1176
  The beat is one shipped command and it arms through two explicit verbs, `--new-arm` and `--loop` —
1168
1177
  never a bare invocation. `--loop` already implies `--new-arm`: it creates a durable arm and beats in
1169
- this process every 10 seconds, exiting on its own within one interval after a stand-down. Run it from
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:
1178
+ this process every 10 seconds, exiting nonzero with its reason within one interval after a stand-down.
1179
+ Never launch it by hand: `beat start` runs it detached and owns it, following the start/stop/status
1180
+ lifecycle in Host-owned daemon and detached beats; never as a harness task or visible tab:
1172
1181
 
1173
1182
  ```bash
1174
- # Payload for the detached start above; do not execute as a foreground or harness task.
1175
- cd <repo> && tickmarkr beat overseer --seat <overseer-agent-or-pane> --loop
1176
- cd <repo> && tickmarkr beat overseer --seat <overseer-agent-or-pane> --stand-down # deliberately hand off; --loop exits
1183
+ # beat start owns the detached --loop child; do not execute the loop as a foreground or harness task.
1184
+ cd <repo> && tickmarkr beat start overseer --seat <overseer-agent-or-pane>
1185
+ cd <repo> && tickmarkr beat stop overseer --seat <overseer-agent-or-pane> # deliberately hand off; reads back DISARMED
1177
1186
  ```
1178
1187
 
1179
1188
  **The legacy wrapper loop, `while :; do tickmarkr beat overseer --seat <pane>; sleep 10; done`, is the
@@ -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"