tickmarkr 2.6.5 → 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.
@@ -49,7 +49,7 @@ const REACH_CHOICES = [
49
49
  { id: "out-workers", label: "out · workers", gloss: "workers skip it; judge/review/consult unaffected" },
50
50
  { id: "out-all", label: "out · all seats", gloss: "no seat routes here" },
51
51
  ];
52
- export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts = {}, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, reloadGuard, stagedRouting, entry = "probe", initialJudge = "", judgeSeats = [], viewRows = Number.POSITIVE_INFINITY, frameColumns = 100, frameRows, ...initialDenyProps }) {
52
+ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts = {}, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, reloadGuard, stagedRouting, holderOf, entry = "probe", initialJudge = "", judgeSeats = [], viewRows = Number.POSITIVE_INFINITY, frameColumns = 100, frameRows, ...initialDenyProps }) {
53
53
  const { exit } = useApp();
54
54
  const [, setRevision] = useState(0);
55
55
  // the props are the fleet command's contract, one per schema-enumerated scope (initialDeny…),
@@ -71,6 +71,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
71
71
  notice: "",
72
72
  lastEdit: null,
73
73
  allowOut: new Set(initialAllowOut),
74
+ seededAllowOut: [],
74
75
  ...Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, new Set(initialDeny[scope.key])])),
75
76
  classifications: [],
76
77
  efforts: structuredClone(initialEfforts),
@@ -139,12 +140,13 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
139
140
  let routingMemo = null;
140
141
  const stagedPolicy = () => {
141
142
  const deny = stagedDeny();
142
- const key = JSON.stringify(deny);
143
+ const stage = stagedMetadata();
144
+ const key = JSON.stringify([deny, stage]);
143
145
  if (routingMemo?.key !== key) {
144
146
  routingMemo = {
145
147
  key,
146
148
  policy: stagedRouting
147
- ? stagedRouting(deny)
149
+ ? stagedRouting(deny, stage)
148
150
  : { ok: true, routing: { deny: denyBlockFrom(denyLists()) } },
149
151
  };
150
152
  }
@@ -163,7 +165,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
163
165
  const reachFor = (adapter, model, identity) => {
164
166
  const policy = stagedPolicy();
165
167
  if (!policy.ok)
166
- return { reach: "unknown", reasons: [`preview unavailable (${policy.error})`], uncoveredAlias: false };
168
+ return { reach: "unknown", reasons: [`preview unavailable (${policy.error})`], uncoveredAlias: false, held: [] };
167
169
  const channel = { adapter, model, ...(identity !== undefined ? { identity } : {}) };
168
170
  const worker = exclusionsOf(channel, policy.routing, "worker");
169
171
  const allSeats = exclusionsOf(channel, policy.routing, "judge");
@@ -175,6 +177,10 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
175
177
  reach,
176
178
  reasons: worker.map(exclusionReason),
177
179
  uncoveredAlias: identity !== undefined && !worker.some((scope) => scope.by === "deny"),
180
+ held: [...new Set(worker.map((scope) => scope.configPath))].flatMap((dotted) => {
181
+ const holder = holderOf?.(dotted);
182
+ return holder === undefined ? [] : [`${dotted} @ ${holder}`];
183
+ }),
178
184
  };
179
185
  };
180
186
  // OBS-1099 review: a displayed row may fold several discovered gateway ids (foldedModels);
@@ -377,12 +383,13 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
377
383
  // ── state transitions ──────────────────────────────────────────────────────
378
384
  // T8: the previews fold the CURRENT staged metadata — never only what the last review saw
379
385
  const stagedMetadata = () => {
380
- const { classifications, efforts } = editorState();
381
- return { classifications, efforts };
386
+ const { classifications, efforts, seededAllowOut } = editorState();
387
+ return { classifications, efforts, seededAllowOut };
382
388
  };
383
389
  const editorState = () => ({
384
390
  ...denyLists(),
385
391
  allowOut: [...ui.allowOut].sort(),
392
+ seededAllowOut: structuredClone(ui.seededAllowOut),
386
393
  classifications: ui.classifications.map((classification) => classification.vendor && classification.channel
387
394
  ? {
388
395
  ...classification,
@@ -430,9 +437,38 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
430
437
  finish({ kind: "no-changes" });
431
438
  return;
432
439
  }
440
+ if (nextReview.kind === "refused") {
441
+ ui.notice = `w refused — ${nextReview.reason}`;
442
+ bump();
443
+ return;
444
+ }
433
445
  setOverlay({ kind: "review", review: nextReview, scroll: 0 });
434
446
  };
447
+ // T10 (D-925/D-939): a model the current allow form leaves out gets its OWN allow entry staged beside
448
+ // its classification — the ownAllow spelling Space clears — so it can be admitted in this session.
449
+ // Re-staging a classification replaces its seed; a row the allow form admits seeds nothing. A row a
450
+ // flat deny ALSO excludes still seeds, so successive owning lifts (deny, then allow) admit it.
451
+ const seedAllow = (adapter, model, displayModel) => {
452
+ // ponytail: a collapsed variant row's allow entry would name no channel (its display name is not the real
453
+ // model), so it is not seeded for same-session admission — classify, save, then admit next session;
454
+ // full grouped-row membership (collapse + catalog fold) is 2.6.7 (T10 owed review F1, D-1016)
455
+ if (model !== displayModel)
456
+ return;
457
+ const identity = groupOf(adapter)?.rows.find((row) => row.model === displayModel)?.evidence?.identity;
458
+ const self = { adapter, model: displayModel, ...(identity !== undefined ? { identity } : {}) };
459
+ const policy = stagedPolicy();
460
+ if (!policy.ok || !exclusionsOf(self, policy.routing, "judge").some((scope) => scope.by === "allow"))
461
+ return;
462
+ const entry = selectOwn(ui.allowOut, self) ?? `${adapter}:${displayModel}`;
463
+ ui.allowOut = new Set([...ui.allowOut, entry]);
464
+ ui.seededAllowOut = [
465
+ ...ui.seededAllowOut.filter((seed) => seed.adapter !== adapter || seed.model !== model),
466
+ { adapter, model, displayModel, ...(identity !== undefined ? { identity } : {}), entry },
467
+ ];
468
+ };
435
469
  const stageSuggested = (adapter, rows, firstTouch) => {
470
+ for (const row of rows)
471
+ seedAllow(adapter, row.model, row.displayModel);
436
472
  ui.classifications = [
437
473
  ...ui.classifications,
438
474
  ...rows.map((row) => ({
@@ -487,6 +523,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
487
523
  };
488
524
  const applyClassification = (overlay) => {
489
525
  const answered = overlay.vendor ? ui.channelByAdapter[overlay.adapter] : undefined;
526
+ seedAllow(overlay.adapter, overlay.model, overlay.displayModel ?? overlay.model);
490
527
  ui.classifications = [
491
528
  ...ui.classifications,
492
529
  {
@@ -1551,7 +1588,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1551
1588
  const group = modelGroups[ui.adapterAt];
1552
1589
  const suggested = rows
1553
1590
  .filter((candidate) => candidate.adapter === group.adapter && !candidate.tier && candidate.suggestion !== undefined)
1554
- .map((candidate) => ({ model: candidate.classifyModel ?? candidate.model, suggestion: candidate.suggestion }));
1591
+ .map((candidate) => ({ model: candidate.classifyModel ?? candidate.model, displayModel: candidate.model, suggestion: candidate.suggestion }));
1555
1592
  if (!suggested.length) {
1556
1593
  ui.notice = "no catalog tier suggestions among the visible unclassified models — evidence comes from the cached catalogs (AA index / API pricing)";
1557
1594
  bump();
@@ -1683,7 +1720,8 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1683
1720
  const reachDetail = (row) => {
1684
1721
  if (row.evidence?.unauthed !== undefined)
1685
1722
  return "unauthed — re-probe with tickmarkr doctor (Space does not toggle reach)";
1686
- const reasons = row.reasons.join("; ");
1723
+ // T10: the reasons, then where each scope is held — the overlay a reviewed w writes
1724
+ const reasons = `${row.reasons.join("; ")}${row.held.length ? ` — held: ${row.held.join("; ")}` : ""}`;
1687
1725
  if (row.reach === "out-all")
1688
1726
  return `reach: out · all seats — ${reasons} — Space picks in or out · workers`;
1689
1727
  if (row.reach === "out-allow")
@@ -1942,8 +1980,8 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1942
1980
  lines.push(coveringDetail(row));
1943
1981
  if (!row.tier) {
1944
1982
  lines.push(row.suggestion
1945
- ? `catalog suggests ${row.suggestion.tier} — Space/Enter classifies with the note pre-typed · s stages every visible suggestion`
1946
- : "unclassified — Space/Enter classifies; unclassified models are never routed");
1983
+ ? `catalog suggests ${row.suggestion.tier} — Space/Enter classifies with the note pre-typed (t too) · s stages every visible suggestion`
1984
+ : "unclassified — Space/Enter classifies (t too), then Space sets its reach in this session; unclassified models are never routed");
1947
1985
  }
1948
1986
  }
1949
1987
  return lines;
@@ -2060,7 +2098,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
2060
2098
  ? _jsx(Text, { color: INK.warn, children: ` ! ${clip(ui.notice, width - 4)}` })
2061
2099
  : details.map((line) => _jsx(Text, { dimColor: true, children: ` ${clip(line, width - 2)}` }, line)), _jsx(KeyBar, { binds: keyBinds() })] }));
2062
2100
  }
2063
- export async function runFleetInkEditor({ ageMs, adapters, health, initialAllowOut = [], initialEfforts, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, reloadGuard, stagedRouting, entry = "probe", initialJudge = "", judgeSeats = [], initialInput = [], input, output, debug = false, ...initialDenyProps }) {
2101
+ export async function runFleetInkEditor({ ageMs, adapters, health, initialAllowOut = [], initialEfforts, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, reloadGuard, stagedRouting, holderOf, entry = "probe", initialJudge = "", judgeSeats = [], initialInput = [], input, output, debug = false, ...initialDenyProps }) {
2064
2102
  const agents = adapters.flatMap((adapter) => {
2065
2103
  const state = health[adapter.id];
2066
2104
  if (!state?.installed)
@@ -2084,7 +2122,7 @@ export async function runFleetInkEditor({ ageMs, adapters, health, initialAllowO
2084
2122
  // rebuilt from output.columns/rows so a pane resize re-renders at the new size. Same root
2085
2123
  // element type on every rerender ⇒ React keeps the component instance, so staged edits,
2086
2124
  // cursor, and overlays all survive the resize.
2087
- const editorElement = () => (_jsx(FleetApp, { ageMs: ageMs, agents: agents, ...initialDenyProps, initialAllowOut: initialAllowOut, initialEfforts: initialEfforts, modelGroups: declaredModelGroups, initialMode: initialMode, modeOptions: modeOptions, initialMap: initialMap, modePreview: modePreview, shapeRows: shapeRows, candidatesForShape: candidatesForShape, preferOptionsForShape: preferOptionsForShape, initialSteering: initialSteering, steeringOptionsFor: steeringOptionsFor, reviewAdvisories: reviewAdvisories, reviewOverlay: reviewOverlay, reloadGuard: reloadGuard, stagedRouting: stagedRouting, entry: entry, initialJudge: initialJudge, judgeSeats: judgeSeats,
2125
+ const editorElement = () => (_jsx(FleetApp, { ageMs: ageMs, agents: agents, ...initialDenyProps, initialAllowOut: initialAllowOut, initialEfforts: initialEfforts, modelGroups: declaredModelGroups, initialMode: initialMode, modeOptions: modeOptions, initialMap: initialMap, modePreview: modePreview, shapeRows: shapeRows, candidatesForShape: candidatesForShape, preferOptionsForShape: preferOptionsForShape, initialSteering: initialSteering, steeringOptionsFor: steeringOptionsFor, reviewAdvisories: reviewAdvisories, reviewOverlay: reviewOverlay, reloadGuard: reloadGuard, stagedRouting: stagedRouting, holderOf: holderOf, entry: entry, initialJudge: initialJudge, judgeSeats: judgeSeats,
2088
2126
  // v1.90.9: cap every list to the terminal minus chrome so the cursor and header can
2089
2127
  // never scroll off-screen (omp's 218-model list did exactly that pre-cap).
2090
2128
  // OBS-523: chrome is ≤12 rows worst case (header, borders, padding, scope+search,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.6.5",
3
+ "version": "2.6.6",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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"