tickmarkr 2.6.7 → 2.6.9

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 (43) hide show
  1. package/dist/adapters/catalog-remote.js +5 -2
  2. package/dist/adapters/codex.d.ts +1 -0
  3. package/dist/adapters/codex.js +18 -10
  4. package/dist/adapters/model-lints.js +21 -11
  5. package/dist/adapters/model-windows.js +0 -1
  6. package/dist/adapters/registry.js +7 -3
  7. package/dist/adapters/types.d.ts +2 -0
  8. package/dist/cli/commands/beat.d.ts +3 -3
  9. package/dist/cli/commands/beat.js +39 -19
  10. package/dist/cli/commands/doctor.js +3 -0
  11. package/dist/cli/commands/fleet.js +2 -0
  12. package/dist/cli/commands/plan.js +16 -18
  13. package/dist/cli/commands/verify.js +4 -2
  14. package/dist/cli/help.js +5 -2
  15. package/dist/config/config.js +6 -5
  16. package/dist/drivers/herdr.js +2 -2
  17. package/dist/drivers/orca.d.ts +12 -4
  18. package/dist/drivers/orca.js +200 -39
  19. package/dist/gates/evidence.js +3 -3
  20. package/dist/gates/review.d.ts +1 -1
  21. package/dist/gates/review.js +62 -13
  22. package/dist/gates/run-gates.d.ts +3 -0
  23. package/dist/gates/run-gates.js +115 -20
  24. package/dist/gates/test-manifest.d.ts +36 -2
  25. package/dist/gates/test-manifest.js +55 -10
  26. package/dist/run/beat-lifecycle.d.ts +109 -24
  27. package/dist/run/beat-lifecycle.js +1173 -216
  28. package/dist/run/daemon.d.ts +4 -0
  29. package/dist/run/daemon.js +111 -33
  30. package/dist/run/git-trust.d.ts +78 -3
  31. package/dist/run/git-trust.js +175 -19
  32. package/dist/run/git.d.ts +24 -3
  33. package/dist/run/git.js +140 -21
  34. package/dist/run/merge.js +2 -2
  35. package/dist/run/verification-job.d.ts +2 -0
  36. package/dist/run/verification-job.js +32 -10
  37. package/dist/tui/ink/fleet-app.d.ts +7 -2
  38. package/dist/tui/ink/fleet-app.js +81 -23
  39. package/package.json +4 -1
  40. package/skills/tickmarkr-auto/SKILL.md +58 -14
  41. package/skills/tickmarkr-loop/SKILL.md +65 -14
  42. package/skills/tickmarkr-overseer/SKILL.md +97 -28
  43. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
@@ -44,12 +44,24 @@ function ledgerChannelId(line) {
44
44
  const slash = head.indexOf("/");
45
45
  return slash === -1 ? undefined : `${head.slice(0, slash)}:${head.slice(slash + 1)}`;
46
46
  }
47
+ /**
48
+ * Space picks exactly as Enter does in every single-select overlay (operator field report 2026-10-08,
49
+ * "can't choose here space key doesn't select" — the reach picker even OPENS on Space). Space keeps its
50
+ * own meaning only where it has one: the multi-select chains (candidates, prefer) toggle on it, the text
51
+ * stages (add-model, the classify note) type it, and the review diff confirms only on `y`. The lift
52
+ * listing is a confirm, not a pick: it clears a deny entry for every channel that entry covers, so only
53
+ * Enter lifts — a stray Space there does nothing.
54
+ */
55
+ const spacePicks = (overlay) => overlay.kind === "classify"
56
+ ? overlay.stage !== "note"
57
+ : overlay.kind === "presets" || overlay.kind === "assign" || overlay.kind === "poolmode"
58
+ || overlay.kind === "judge" || overlay.kind === "reach";
47
59
  const REACH_CHOICES = [
48
60
  { id: "in", label: "in", gloss: "every seat may route here" },
49
61
  { id: "out-workers", label: "out · workers", gloss: "workers skip it; judge/review/consult unaffected" },
50
62
  { id: "out-all", label: "out · all seats", gloss: "no seat routes here" },
51
63
  ];
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 }) {
64
+ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts = {}, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, layerStamp, reloadGuard, stagedRouting, holderOf, entry = "probe", initialJudge = "", judgeSeats = [], viewRows = Number.POSITIVE_INFINITY, frameColumns = 100, frameRows, ...initialDenyProps }) {
53
65
  const { exit } = useApp();
54
66
  const [, setRevision] = useState(0);
55
67
  // the props are the fleet command's contract, one per schema-enumerated scope (initialDeny…),
@@ -137,21 +149,51 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
137
149
  // the STAGED policy (fleet loads the candidate bytes; see stagedRouting) and the recorded
138
150
  // identity — never literal membership of the staged sets, which missed identity, bare-model and
139
151
  // family entries and could not tell an allowlist exclusion from a deny.
140
- let routingMemo = null;
152
+ // B-KEYS (D-1338): the staged CONTENT every row and the staged policy derive from — the deny sets,
153
+ // the allow complement, classifications, efforts and seeds, read raw off ui (the provenance a
154
+ // review folds is a pure function of them). Computed per call, never per render: a handler
155
+ // mutates ui and reads rows in the same tick, before bump.
156
+ const stagedKey = () => JSON.stringify([
157
+ DENY_SCOPES.map((scope) => [...ui[scope.key]].sort()),
158
+ [...ui.allowOut].sort(),
159
+ ui.classifications,
160
+ ui.efforts,
161
+ ui.seededAllowOut,
162
+ ]);
163
+ // ponytail: both memos live in refs keyed on stagedKey — a render-local memo reset on every keypress,
164
+ // so each cursor move reloaded the policy and re-derived ~800 rows' reach (B-KEYS). A cursor move,
165
+ // a filter, showAll or an overlay never recompute a row; any staged edit changes the key and
166
+ // recomputes every group. Ceiling: one JSON.stringify of the staged sets per lookup (µs); a dirty
167
+ // counter bumped at every mutation site if a fleet ever outgrows it.
168
+ const routingMemo = useRef(null);
141
169
  const stagedPolicy = () => {
170
+ const key = memoKey();
171
+ const memo = routingMemo.current;
172
+ if (memo?.key === key)
173
+ return memo.policy;
142
174
  const deny = stagedDeny();
143
175
  const stage = stagedMetadata();
144
- const key = JSON.stringify([deny, stage]);
145
- if (routingMemo?.key !== key) {
146
- routingMemo = {
147
- key,
148
- policy: stagedRouting
149
- ? stagedRouting(deny, stage)
150
- : { ok: true, routing: { deny: denyBlockFrom(denyLists()) } },
151
- };
152
- }
153
- return routingMemo.policy;
176
+ const policy = stagedRouting
177
+ ? stagedRouting(deny, stage)
178
+ : { ok: true, routing: { deny: denyBlockFrom(denyLists()) } };
179
+ routingMemo.current = { key, policy };
180
+ return policy;
154
181
  };
182
+ const rowsMemo = useRef({ key: "", byAdapter: new Map() });
183
+ // B-KEYS r2 (D-1446): staged content alone does not identify a row — the rows also derive from the loaded
184
+ // overlay layers (stagedRouting and holderOf read both files) and the props they read. Once per render a
185
+ // generation moves when the layer bytes or one of those props changes, and both memos key on it beside the
186
+ // staged content: a layer written mid-session shows on the next frame, as on the base, while a cursor move
187
+ // over unchanged layers still derives nothing. Cost: one read of each overlay per frame (the B2 previewCfg key).
188
+ const layers = useRef({ stamp: "", inputs: [], gen: 0 });
189
+ {
190
+ const stamp = layerStamp?.() ?? "";
191
+ const inputs = [modelGroups, stagedRouting, holderOf];
192
+ if (stamp !== layers.current.stamp || inputs.some((input, at) => input !== layers.current.inputs[at])) {
193
+ layers.current = { stamp, inputs, gen: layers.current.gen + 1 };
194
+ }
195
+ }
196
+ const memoKey = () => `${layers.current.gen}\u0000${stagedKey()}`;
155
197
  // OBS-1099 add.1: the browser's deny discovery ranges over the schema-enumerated scopes (the
156
198
  // route collector names its four paths by hand, so a scope the schema gains would never render
157
199
  // or lift here). A flat scope reaches every seat; a nested one only the seat its block names
@@ -321,7 +363,19 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
321
363
  const found = members.map(coveringFor).filter((covering) => covering !== undefined);
322
364
  return found.find((covering) => covering.shared) ?? found[0];
323
365
  };
366
+ // B-KEYS: one derivation per group per staged state — the memo above hands every later pass
367
+ // (visibleCount per rail row, modelRows, the hidden and folded counts, a handler's lookup) the
368
+ // same rows; the returned array is read, never mutated, by every caller.
324
369
  const groupRows = (group) => {
370
+ const key = memoKey();
371
+ if (rowsMemo.current.key !== key)
372
+ rowsMemo.current = { key, byAdapter: new Map() };
373
+ const memo = rowsMemo.current.byAdapter;
374
+ const rows = memo.get(group.adapter) ?? deriveGroupRows(group);
375
+ memo.set(group.adapter, rows);
376
+ return rows;
377
+ };
378
+ const deriveGroupRows = (group) => {
325
379
  const rows = group.rows.map((row) => {
326
380
  const members = membersOf(group.adapter, row);
327
381
  const staged = ui.classifications.find((classification) => classification.adapter === group.adapter
@@ -949,6 +1003,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
949
1003
  return true;
950
1004
  };
951
1005
  if (overlay) {
1006
+ const pick = key.return || (input === " " && spacePicks(overlay));
952
1007
  if (overlay.kind === "presets") {
953
1008
  const rowCount = modeOptions.length + (overlay.home ? 1 : 0);
954
1009
  if (key.escape) {
@@ -973,7 +1028,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
973
1028
  bump();
974
1029
  return;
975
1030
  }
976
- if (key.return) {
1031
+ if (pick) {
977
1032
  if (overlay.home && overlay.at === modeOptions.length) {
978
1033
  setOverlay(null); // custom → the browser IS the full editor
979
1034
  return;
@@ -1023,8 +1078,10 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1023
1078
  finish({ kind: "discard" });
1024
1079
  return;
1025
1080
  }
1081
+ // the review screen holds every staged edit by definition — `q` takes the same two presses
1082
+ // as every other screen, never one keystroke to lose the session (`n` is the explicit discard)
1026
1083
  if (input === "q") {
1027
- finish({ kind: "quit" });
1084
+ guardedQuit(armed);
1028
1085
  return;
1029
1086
  }
1030
1087
  if (input === "y") {
@@ -1055,7 +1112,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1055
1112
  bump();
1056
1113
  return;
1057
1114
  }
1058
- if (key.return) {
1115
+ if (pick) {
1059
1116
  const channel = CHANNELS[overlay.channelAt];
1060
1117
  ui.channelByAdapter[overlay.adapter] = channel;
1061
1118
  // OBS-508: a bulk stage waiting on the one first-touch channel answer resumes here.
@@ -1083,7 +1140,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1083
1140
  bump();
1084
1141
  return;
1085
1142
  }
1086
- if (key.return) {
1143
+ if (pick) {
1087
1144
  // OBS-508: keep the suggested band → the evidence note arrives pre-typed; override the
1088
1145
  // band → the note starts empty (the suggestion argues for a DIFFERENT tier).
1089
1146
  const chosen = TIERS[overlay.tierAt];
@@ -1166,7 +1223,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1166
1223
  bump();
1167
1224
  return;
1168
1225
  }
1169
- if (key.return && rows[overlay.at]) {
1226
+ if (pick && rows[overlay.at]) {
1170
1227
  const shape = rows[overlay.at].id;
1171
1228
  ui.map = { ...ui.map, [shape]: { pin: { via: overlay.adapter, model: overlay.model } } };
1172
1229
  setOverlay(null);
@@ -1263,7 +1320,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1263
1320
  bump();
1264
1321
  return;
1265
1322
  }
1266
- if (key.return) {
1323
+ if (pick) {
1267
1324
  const { shape, chain } = overlay.picker;
1268
1325
  // a pool is the shape's WHOLE declaration: pin and prefer leave with it (schema
1269
1326
  // exclusivity — pin+pool and pool+prefer both fail config load)
@@ -1341,7 +1398,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1341
1398
  bump();
1342
1399
  return;
1343
1400
  }
1344
- if (key.return) {
1401
+ if (pick) {
1345
1402
  const choice = REACH_CHOICES[overlay.at].id;
1346
1403
  setOverlay(null);
1347
1404
  applyReach(overlay.target, choice);
@@ -1378,7 +1435,8 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1378
1435
  bump();
1379
1436
  return;
1380
1437
  }
1381
- if (key.return && rows[overlay.at]) {
1438
+ // Space picks too (spacePicks): a judge picker that swallowed it read as "can't choose"
1439
+ if (pick && rows[overlay.at]) {
1382
1440
  // single-select: one seat or back to the config default — never a chain
1383
1441
  const picked = rows[overlay.at];
1384
1442
  ui.judgeSeat = picked === judgeKeepRow ? null : picked;
@@ -2003,7 +2061,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
2003
2061
  const rows = [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, ui.filter));
2004
2062
  const { visible, start, above, below } = windowRows(rows, overlay.at, capacity);
2005
2063
  const selectedSeat = ui.judgeSeat ?? judgeKeepRow;
2006
- return (_jsxs(OverlayPanel, { title: "pick \u00B7 judge", width: bodyW, children: [_jsx(Text, { dimColor: true, children: "one seat judges acceptance criteria \u2014 failover stays runtime (GATE-09)" }), _jsx(SearchRow, { filter: ui.filter, active: true }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((label, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: start + index === overlay.at }), label === selectedSeat ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: start + index === overlay.at, children: ` ${clip(label, bodyW - 10)}` })] }, label))), below > 0 && _jsx(ElisionMark, { count: below, side: "below" })] }));
2064
+ return (_jsxs(OverlayPanel, { title: "pick \u00B7 judge", width: bodyW, children: [_jsx(Text, { dimColor: true, wrap: "truncate", children: clip("Space or Enter picks · Esc keeps · one seat judges acceptance criteria — failover stays runtime (GATE-09)", bodyW - 4) }), _jsx(SearchRow, { filter: ui.filter, active: true }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((label, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: start + index === overlay.at }), label === selectedSeat ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: start + index === overlay.at, children: ` ${clip(label, bodyW - 10)}` })] }, label))), below > 0 && _jsx(ElisionMark, { count: below, side: "below" })] }));
2007
2065
  })();
2008
2066
  const modelVisibilityLine = () => {
2009
2067
  const hidden = hiddenModelCount();
@@ -2180,7 +2238,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
2180
2238
  ? _jsx(Text, { color: INK.warn, children: ` ! ${clip(ui.notice, width - 4)}` })
2181
2239
  : details.map((line) => _jsx(Text, { dimColor: true, children: ` ${clip(line, width - 2)}` }, line)), _jsx(KeyBar, { binds: keyBinds() })] }));
2182
2240
  }
2183
- 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 }) {
2241
+ export async function runFleetInkEditor({ ageMs, adapters, health, initialAllowOut = [], initialEfforts, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, layerStamp, reloadGuard, stagedRouting, holderOf, entry = "probe", initialJudge = "", judgeSeats = [], initialInput = [], input, output, debug = false, ...initialDenyProps }) {
2184
2242
  const agents = adapters.flatMap((adapter) => {
2185
2243
  const state = health[adapter.id];
2186
2244
  if (!state?.installed)
@@ -2204,7 +2262,7 @@ export async function runFleetInkEditor({ ageMs, adapters, health, initialAllowO
2204
2262
  // rebuilt from output.columns/rows so a pane resize re-renders at the new size. Same root
2205
2263
  // element type on every rerender ⇒ React keeps the component instance, so staged edits,
2206
2264
  // cursor, and overlays all survive the resize.
2207
- 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,
2265
+ 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, layerStamp: layerStamp, reloadGuard: reloadGuard, stagedRouting: stagedRouting, holderOf: holderOf, entry: entry, initialJudge: initialJudge, judgeSeats: judgeSeats,
2208
2266
  // v1.90.9: cap every list to the terminal minus chrome so the cursor and header can
2209
2267
  // never scroll off-screen (omp's 218-model list did exactly that pre-cap).
2210
2268
  // 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.7",
3
+ "version": "2.6.9",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -114,6 +114,9 @@
114
114
  "scripts/export-public.sh",
115
115
  "scripts/verify-export.sh",
116
116
  "tests/scripts/verify-export.test.ts",
117
+ "scripts/check-analysis.mjs",
118
+ "scripts/atlas.mjs",
119
+ "tests/docs/analysis-library.test.ts",
117
120
  ".github/workflows/ci.yml",
118
121
  "**/*.local.*"
119
122
  ]
@@ -12,8 +12,9 @@ Use the canonical loop skill's [cockpit, parked decisions and printed twins](../
12
12
  for each target: delivered views are **1 Home, 4 Run, 5 Evidence**; Fleet/Bootstrap and
13
13
  Plan/Health remain follow-ons with CLI entries. The recorded 1/3 merged, human T2, blocked
14
14
  T3 case stays PARTIAL despite tip pass. Review the Run confirmation, read back the appended
15
- `task-approved` receipt, and explicitly launch `tickmarkr resume <runId>` with the host-owned split form below when no live owner exists;
16
- approval records permission and never dispatches work itself. Non-TTY readers keep `status`,
15
+ `task-approved` receipt, and explicitly launch `tickmarkr resume <runId>` with the detached launch below when no live owner exists;
16
+ approval records permission and never dispatches work itself. A waive needs an `OOB: executed`
17
+ read at the task head (the walkthrough's record example); a static CLEAN never funds one. Non-TTY readers keep `status`,
17
18
  `report` and default-watch line output, with `--watch --plain` as the TTY fallback.
18
19
  Keep `name: tickmarkr-auto` and the installed links to canonical `skills/` intact.
19
20
 
@@ -92,26 +93,41 @@ After sending, **confirm delivery** by reading the target pane and verifying the
92
93
  1. **Prepare** — confirm the target list. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
93
94
  2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
94
95
  3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
95
- 4. **Run** — launch `tickmarkr run` using the [host-owned split form](#host-owned-daemon-and-detached-beats), and start detached beats with their run-end stop items. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every execution clause for you; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the relevant agent session.
96
+ 4. **Run** — launch `tickmarkr run` using the [detached launch](#host-owned-daemon-and-detached-beats), and start detached beats with their run-end stop items. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary's execution clauses for you — `EXECUTION COMPLETE` or `NOT GREEN`, never green, because a run-end record is history; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the relevant agent session.
96
97
  5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty, and CURRENT `tickmarkr status <runId>` reads its owed checks outstanding empty AND known (`outstanding 0`) — a run with a parked task is partial, not green. Empty execution buckets alone are not green either (D-660): every bucket empty and the tip passed, but an operator waived one review, leaves one accepted-risk review check owed — status reads `outstanding 1 (T7 review)` and `run`/`resume` still exit 0 on execution alone, so the run is execution complete, not green. It turns green only when a `tickmarkr verify --record <runId>` discharge moves CURRENT status to `outstanding 0`, including a discharge landing after run-end — the historical run-end record keeps the old count, so never read debt from it; `outstanding unknown` is never green. Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
97
98
  6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout; redirect explicitly beside the spec (for example `tickmarkr report <runId> --md > feature.record.md`) and commit the execution record when the repository tracks those records.
98
99
  7. **Continue** — move to the next requested target. If a target fails or is parked, stop with the journal evidence rather than silently skipping it.
99
100
 
100
101
  ## Host-owned daemon and detached beats
101
102
 
102
- Launch the daemon as a SPLIT of the orchestrator's own pane in the ORCH tab, owned by the
103
- host PTY. Never launch it as an agent harness background task or in a separate tab. The daemon
104
- self-places its board from that split, keeping both daemon and board in the ORCH tab.
103
+ Launch the daemon DETACHED, with no pane of its own: one `tickmarkr run` or `tickmarkr resume <runId>`
104
+ process in its own session, its stdout and stderr appended to `<state-dir>/daemon.log`, and the
105
+ ORCH's recorded address as its board anchor, so the daemon self-places its one board beside the
106
+ ORCH in the ORCH tab. Never launch it in a visible split, pane or tab, as an agent harness
107
+ background task, or tied to an agent session: the launching shell may end, and the daemon keeps
108
+ running and logging. The detach wrapper is `node`, which tickmarkr already requires — no private
109
+ script, no extra visible daemon pane and no CLI log flag:
105
110
 
106
- - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**:
107
- `orca terminal split --terminal "$ORCA_TERMINAL_HANDLE" --direction vertical --command "tickmarkr run"`.
108
- - **On herdr (`HERDR_ENV=1`)**: use `herdr pane split` on the orchestrator's own pane, then
109
- `herdr pane run <new> "tickmarkr run"` in the returned split.
111
+ ```bash
112
+ # On Orca: <ORCH handle> is the ORCH terminal handle
113
+ cd <repo> && ORCA_TERMINAL_HANDLE="<ORCH handle>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr run >> <state-dir>/daemon.log 2>&1 < /dev/null
114
+ cd <repo> && ORCA_TERMINAL_HANDLE="<ORCH handle>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr resume <runId> >> <state-dir>/daemon.log 2>&1 < /dev/null
115
+ # On herdr: <ORCH pane id> is the ORCH pane id
116
+ cd <repo> && HERDR_PANE_ID="<ORCH pane id>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr run >> <state-dir>/daemon.log 2>&1 < /dev/null
117
+ cd <repo> && HERDR_PANE_ID="<ORCH pane id>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr resume <runId> >> <state-dir>/daemon.log 2>&1 < /dev/null
118
+ ```
119
+
120
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: run the
121
+ `ORCA_TERMINAL_HANDLE` lines; `<ORCH handle>` is the ORCH's own `$ORCA_TERMINAL_HANDLE`.
122
+ - **On herdr (`HERDR_ENV=1`)**: run the `HERDR_PANE_ID` lines; `<ORCH pane id>` is the ORCH's own
123
+ `$HERDR_PANE_ID`.
110
124
 
111
- For `resume`, use the same host-owned split form with `tickmarkr resume <runId>` in place of
112
- `tickmarkr run`. After either launch, read this repository's lock pid and walk its ppid chain
113
- in the process table to the host PTY; verify no agent session is an ancestor. If an agent is
114
- an ancestor, stop that launch and relaunch through the host split before continuing.
125
+ After either launch, read this repository's lock pid from `<state-dir>/graph.lock` and confirm it:
126
+ `kill -0 <pid>` succeeds, `ps -o pgid= -p <pid>` prints that same pid (its own session group;
127
+ ppid 1 is never proof on its own), walk its ppid chain and verify no agent session is an ancestor,
128
+ and read `<state-dir>/daemon.log`. No live holder means the launch failed: read the log, fix the
129
+ cause and run the same line again — never a second daemon beside a live holder, and never a
130
+ visible split to watch it; the daemon-placed board is the live surface.
115
131
 
116
132
  Detached beats are product-owned, one per (tier, seat), from the repository root, through the
117
133
  shipped lifecycle verbs — never a hand-rolled setsid plus nohup wrapper, pidfile or shell loop:
@@ -133,5 +149,33 @@ generation and identity still match, and reads back DISARMED. Never put beats in
133
149
  background task or a visible tab. Include a run-end `beat stop` item for every beat started, also
134
150
  on failure, park or handoff, and require its DISARMED read-back.
135
151
 
152
+ **Legacy beat migration.** `beat start` refuses a legacy (pre-lifecycle, unowned) arm nonzero and
153
+ prints its exit under the RECORDED seat; `beat stop` refuses it too, so a stop followed by a start
154
+ is a dead end, never a migration. When the refusal names `stop pid <N> first`, that pid is the old
155
+ `--loop` writer: stop exactly that pid yourself (`kill <N>`, then confirm `kill -0 <N>` fails) —
156
+ tickmarkr never signals a process it did not launch. Then run the printed step under the seat it
157
+ names, start again and read back ARMED:
158
+
159
+ ```bash
160
+ cd <repo> && tickmarkr beat <tier> --seat <recorded seat> --stand-down
161
+ cd <repo> && tickmarkr beat start <tier> --seat <seat>
162
+ cd <repo> && tickmarkr beat status <tier> --seat <seat>
163
+ ```
164
+
165
+ **Crash recovery.** A crashed owner, a writer killed inside its claimed tick or a killed recovery
166
+ taker leaves its claim, removal lock or stage directory behind. `beat status` only observes: it
167
+ exits nonzero, names the dead pids, prints the command that recovers them and changes no file —
168
+ status never recovers. Run the command it prints; the next ordinary `stop` or `start` recovers what
169
+ was left, with a notice naming each dead pid, and the start reads back ARMED:
170
+
171
+ ```bash
172
+ cd <repo> && tickmarkr beat status <tier> --seat <seat>
173
+ cd <repo> && tickmarkr beat stop <tier> --seat <seat>
174
+ cd <repo> && tickmarkr beat start <tier> --seat <seat>
175
+ ```
176
+
177
+ Never delete a claim, lock or stage file by hand. A live or unprovable holder refuses BUSY naming
178
+ it: wait for that holder to finish or exit, then run the same command.
179
+
136
180
  Keep only watchers that must WAKE the seat in the harness. Re-arm them on each wake and at
137
181
  their harness cap; daemon and beat lifetimes must not depend on that cap.
@@ -88,25 +88,40 @@ When spawning consultants (agents gathering synthesis input for decisions like S
88
88
  1. **Prepare** — start from the requested spec. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
89
89
  2. **Compile** — run `tickmarkr compile <spec>`. Correct compilation errors in the spec, never in the generated graph.
90
90
  3. **Plan** — run `tickmarkr plan`. Review the routing table, capability-floor warnings, and every human gate, including work that each gate blocks.
91
- 4. **Run** — launch `tickmarkr run` using the [host-owned split form](#host-owned-daemon-and-detached-beats), and start detached beats with their run-end stop items. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every execution clause for you; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the agent session; do not turn them into proxy questions.
91
+ 4. **Run** — launch `tickmarkr run` using the [detached launch](#host-owned-daemon-and-detached-beats), and start detached beats with their run-end stop items. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary's execution clauses for you — `EXECUTION COMPLETE` or `NOT GREEN`, never green, because a run-end record is history; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the agent session; do not turn them into proxy questions.
92
92
  5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty, and CURRENT `tickmarkr status <runId>` reads its owed checks outstanding empty AND known (`outstanding 0`) — a run with a parked task is partial, not green. Empty execution buckets alone are not green either (D-660): every bucket empty and the tip passed, but an operator waived one review, leaves one accepted-risk review check owed — status reads `outstanding 1 (T7 review)` and `run`/`resume` still exit 0 on execution alone, so the run is execution complete, not green. It turns green only when a `tickmarkr verify --record <runId>` discharge moves CURRENT status to `outstanding 0`, including a discharge landing after run-end — the historical run-end record keeps the old count, so never read debt from it; `outstanding unknown` is never green. Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
93
93
  6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout. Redirect it explicitly beside the source spec (for example `tickmarkr report <runId> --md > feature.record.md`) and commit the execution record when the repository tracks those records. Then [stand down](#stand-down-mission-end-and-retirement).
94
94
 
95
95
  ## Host-owned daemon and detached beats
96
96
 
97
- Launch the daemon as a SPLIT of the orchestrator's own pane in the ORCH tab, owned by the
98
- host PTY. Never launch it as an agent harness background task or in a separate tab. The daemon
99
- self-places its board from that split, keeping both daemon and board in the ORCH tab.
97
+ Launch the daemon DETACHED, with no pane of its own: one `tickmarkr run` or `tickmarkr resume <runId>`
98
+ process in its own session, its stdout and stderr appended to `<state-dir>/daemon.log`, and the
99
+ ORCH's recorded address as its board anchor, so the daemon self-places its one board beside the
100
+ ORCH in the ORCH tab. Never launch it in a visible split, pane or tab, as an agent harness
101
+ background task, or tied to an agent session: the launching shell may end, and the daemon keeps
102
+ running and logging. The detach wrapper is `node`, which tickmarkr already requires — no private
103
+ script, no extra visible daemon pane and no CLI log flag:
100
104
 
101
- - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**:
102
- `orca terminal split --terminal "$ORCA_TERMINAL_HANDLE" --direction vertical --command "tickmarkr run"`.
103
- - **On herdr (`HERDR_ENV=1`)**: use `herdr pane split` on the orchestrator's own pane, then
104
- `herdr pane run <new> "tickmarkr run"` in the returned split.
105
+ ```bash
106
+ # On Orca: <ORCH handle> is the ORCH terminal handle
107
+ cd <repo> && ORCA_TERMINAL_HANDLE="<ORCH handle>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr run >> <state-dir>/daemon.log 2>&1 < /dev/null
108
+ cd <repo> && ORCA_TERMINAL_HANDLE="<ORCH handle>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr resume <runId> >> <state-dir>/daemon.log 2>&1 < /dev/null
109
+ # On herdr: <ORCH pane id> is the ORCH pane id
110
+ cd <repo> && HERDR_PANE_ID="<ORCH pane id>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr run >> <state-dir>/daemon.log 2>&1 < /dev/null
111
+ cd <repo> && HERDR_PANE_ID="<ORCH pane id>" node -e "require('child_process').spawn(process.argv[1], process.argv.slice(2), { detached: true, stdio: 'inherit' }).unref()" tickmarkr resume <runId> >> <state-dir>/daemon.log 2>&1 < /dev/null
112
+ ```
113
+
114
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: run the
115
+ `ORCA_TERMINAL_HANDLE` lines; `<ORCH handle>` is the ORCH's own `$ORCA_TERMINAL_HANDLE`.
116
+ - **On herdr (`HERDR_ENV=1`)**: run the `HERDR_PANE_ID` lines; `<ORCH pane id>` is the ORCH's own
117
+ `$HERDR_PANE_ID`.
105
118
 
106
- For `resume`, use the same host-owned split form with `tickmarkr resume <runId>` in place of
107
- `tickmarkr run`. After either launch, read this repository's lock pid and walk its ppid chain
108
- in the process table to the host PTY; verify no agent session is an ancestor. If an agent is
109
- an ancestor, stop that launch and relaunch through the host split before continuing.
119
+ After either launch, read this repository's lock pid from `<state-dir>/graph.lock` and confirm it:
120
+ `kill -0 <pid>` succeeds, `ps -o pgid= -p <pid>` prints that same pid (its own session group;
121
+ ppid 1 is never proof on its own), walk its ppid chain and verify no agent session is an ancestor,
122
+ and read `<state-dir>/daemon.log`. No live holder means the launch failed: read the log, fix the
123
+ cause and run the same line again — never a second daemon beside a live holder, and never a
124
+ visible split to watch it; the daemon-placed board is the live surface.
110
125
 
111
126
  Detached beats are product-owned, one per (tier, seat), from the repository root, through the
112
127
  shipped lifecycle verbs — never a hand-rolled setsid plus nohup wrapper, pidfile or shell loop:
@@ -128,6 +143,34 @@ generation and identity still match, and reads back DISARMED. Never put beats in
128
143
  background task or a visible tab. Include a run-end `beat stop` item for every beat started, also
129
144
  on failure, park or handoff, and require its DISARMED read-back.
130
145
 
146
+ **Legacy beat migration.** `beat start` refuses a legacy (pre-lifecycle, unowned) arm nonzero and
147
+ prints its exit under the RECORDED seat; `beat stop` refuses it too, so a stop followed by a start
148
+ is a dead end, never a migration. When the refusal names `stop pid <N> first`, that pid is the old
149
+ `--loop` writer: stop exactly that pid yourself (`kill <N>`, then confirm `kill -0 <N>` fails) —
150
+ tickmarkr never signals a process it did not launch. Then run the printed step under the seat it
151
+ names, start again and read back ARMED:
152
+
153
+ ```bash
154
+ cd <repo> && tickmarkr beat <tier> --seat <recorded seat> --stand-down
155
+ cd <repo> && tickmarkr beat start <tier> --seat <seat>
156
+ cd <repo> && tickmarkr beat status <tier> --seat <seat>
157
+ ```
158
+
159
+ **Crash recovery.** A crashed owner, a writer killed inside its claimed tick or a killed recovery
160
+ taker leaves its claim, removal lock or stage directory behind. `beat status` only observes: it
161
+ exits nonzero, names the dead pids, prints the command that recovers them and changes no file —
162
+ status never recovers. Run the command it prints; the next ordinary `stop` or `start` recovers what
163
+ was left, with a notice naming each dead pid, and the start reads back ARMED:
164
+
165
+ ```bash
166
+ cd <repo> && tickmarkr beat status <tier> --seat <seat>
167
+ cd <repo> && tickmarkr beat stop <tier> --seat <seat>
168
+ cd <repo> && tickmarkr beat start <tier> --seat <seat>
169
+ ```
170
+
171
+ Never delete a claim, lock or stage file by hand. A live or unprovable holder refuses BUSY naming
172
+ it: wait for that holder to finish or exit, then run the same command.
173
+
131
174
  Keep only watchers that must WAKE the seat in the harness. Re-arm them on each wake and at
132
175
  their harness cap; daemon and beat lifetimes must not depend on that cap.
133
176
 
@@ -146,7 +189,7 @@ run/task, original park `#L`, actor/reason, exact argv, consequence and enactor.
146
189
  confirms; `n`/Esc cancel; Enter never confirms. Read the receipt's appended `task-approved`
147
190
  line and actor/reason back from the journal. Approval records permission, never dispatch,
148
191
  a passed gate or task completion. With no live owner it says **approved; resume required**:
149
- exit the observer and launch `tickmarkr resume <runId>` explicitly with the same host-owned split form above. A matching live daemon
192
+ exit the observer and launch `tickmarkr resume <runId>` explicitly with the same detached launch above. A matching live daemon
150
193
  enacts at its next task boundary; if a different live run owns the repository lock, wait
151
194
  for that run to end before resuming this one.
152
195
 
@@ -174,7 +217,15 @@ Run offers only validated park verbs: human/attempt-cap/other non-gate parks all
174
217
  infra allows approve or `--recheck`; review gate-fail allows `--waive`, `--uphold` or
175
218
  `--recheck`; other gate-fail allows waive/recheck. Waive satisfies only the identified
176
219
  failed gate, uphold funds a fixed attempt carrying review findings, and recheck reruns the
177
- declared battery without satisfying a gate. A stall park that recorded a `reapFailure`
220
+ declared battery without satisfying a gate. A waive needs EXECUTED evidence: mark every
221
+ out-of-band (OOB) read behind it `OOB: static` (diff and source read, nothing run) or
222
+ `OOB: executed` (the finding's claim run at the task's head commit, exit code and log on file).
223
+ Static CLEAN never backs a waive — a static read that missed an executed material defect looks
224
+ like one that found none, and a static read relabelled executed is a false record. When the
225
+ finding's reproduction, executed at the task head, fails, the ruling is uphold or recheck; waive
226
+ only when it passes and the record names it, keeping the park/gate binding and the owed check
227
+ (`outstanding 1 (T2 review)` until `tickmarkr verify --record <runId>`):
228
+ `D-NNN WAIVE T2 review — park <line>@<ts> --gate review — OOB: executed — <reproduction> at <task-head-sha>: exit 0, log <path>`. A stall park that recorded a `reapFailure`
178
229
  (unreadable or surviving worker census) allows approve or `--recheck --park <line>@<ts>`:
179
230
  recheck re-verifies that attempt's owned census and gates its harvested commits with no
180
231
  worker only with an explicitly recorded empty survivors array (`[]`) — a missing,