tickmarkr 2.8.4 → 2.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/adapters/claude-code.js +3 -2
  2. package/dist/adapters/codex-commit-check.d.ts +4 -3
  3. package/dist/adapters/codex-commit-check.js +44 -29
  4. package/dist/adapters/codex.d.ts +2 -2
  5. package/dist/adapters/codex.js +38 -32
  6. package/dist/adapters/cursor-agent.js +2 -1
  7. package/dist/adapters/grok.js +2 -1
  8. package/dist/adapters/kimi.js +11 -9
  9. package/dist/adapters/model-lints.js +4 -3
  10. package/dist/adapters/opencode.js +2 -1
  11. package/dist/adapters/pi.js +3 -2
  12. package/dist/adapters/qwen.js +2 -1
  13. package/dist/adapters/registry.js +2 -1
  14. package/dist/adapters/types.d.ts +10 -1
  15. package/dist/adapters/types.js +18 -3
  16. package/dist/cli/commands/approve.js +9 -28
  17. package/dist/cli/commands/doctor.d.ts +49 -5
  18. package/dist/cli/commands/doctor.js +956 -94
  19. package/dist/cli/commands/fleet.js +11 -3
  20. package/dist/cli/commands/init.js +27 -19
  21. package/dist/cli/commands/status.js +9 -6
  22. package/dist/cli/commands/verify.js +12 -3
  23. package/dist/compile/gsd.js +4 -3
  24. package/dist/compile/index.js +4 -1
  25. package/dist/compile/native.js +8 -6
  26. package/dist/config/config.d.ts +15 -0
  27. package/dist/config/config.js +67 -5
  28. package/dist/drivers/host-env.d.ts +3 -0
  29. package/dist/drivers/host-env.js +47 -0
  30. package/dist/drivers/orca.js +3 -1
  31. package/dist/drivers/subprocess.d.ts +1 -1
  32. package/dist/drivers/subprocess.js +4 -3
  33. package/dist/gates/baseline.d.ts +1 -1
  34. package/dist/gates/baseline.js +4 -3
  35. package/dist/gates/cache.js +2 -4
  36. package/dist/graph/schema.js +3 -1
  37. package/dist/run/daemon.js +54 -4
  38. package/dist/run/git-trust.d.ts +11 -0
  39. package/dist/run/git-trust.js +1 -1
  40. package/dist/run/git.d.ts +13 -1
  41. package/dist/run/git.js +34 -14
  42. package/dist/run/journal.d.ts +15 -1
  43. package/dist/run/journal.js +83 -17
  44. package/dist/run/lock.d.ts +5 -0
  45. package/dist/run/lock.js +202 -9
  46. package/dist/run/protocol.d.ts +1 -1
  47. package/dist/tui/cockpit/live-store.js +12 -2
  48. package/package.json +1 -1
  49. package/skills/tickmarkr-auto/SKILL.md +65 -21
  50. package/skills/tickmarkr-loop/SKILL.md +65 -21
  51. package/skills/tickmarkr-overseer/SKILL.md +88 -32
package/dist/run/lock.js CHANGED
@@ -1,7 +1,8 @@
1
- import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { tickmarkrDir, stateDirName } from "../graph/graph.js";
5
+ import { GitTrustRefusal, trustedCommonDir } from "./git-trust.js";
5
6
  import { parseRunId } from "./journal.js";
6
7
  // HARD-01/02: coarse per-run advisory lock over .tickmarkr/graph.json. LOCK-02: the lock is created by
7
8
  // the link(2) idiom — write the full payload to graph.lock.<pid>.tmp, then linkSync(tmp, lockPath),
@@ -17,16 +18,31 @@ export const STALE_MS = 60_000; // 6× heartbeat headroom; PITFALLS floor is ≥
17
18
  // T-10-01: the payload is a trust boundary — parse with zod, fail closed on garbage. Only
18
19
  // pid/runId/startedAt are ever read; anything else is ignored (info-disclosure surface).
19
20
  const PayloadSchema = z.object({ pid: z.number().int().positive(), runId: z.string(), startedAt: z.number() });
21
+ // Queue row 194 (D-2102): graph.lock lives per CHECKOUT, so a run in a linked worktree (Claude Desktop
22
+ // nests each session under <repo>/.claude/worktrees/) never saw a run in the main checkout. The daemon's
23
+ // acquire therefore also takes this REPOSITORY RUN CLAIM at the trusted common git dir — the runner
24
+ // lease's anchor — by the same link(2) idiom and the same decision table (shouldRefuse). compile's two
25
+ // holders take graph.lock only: it guards that checkout's own graph, never the repository's run.
26
+ const ClaimSchema = PayloadSchema.extend({ checkout: z.string() });
27
+ const MarkerSchema = z.object({ pid: z.number().int().positive(), startedAt: z.number() });
28
+ const CLAIM_FILE = "tickmarkr-run.claim";
29
+ const COMPILE_HOLDERS = new Set(["compile", "compile-refusal"]);
30
+ const NO_CHECKOUT = "no checkout encloses this directory"; // git-trust's refusal detail outside any checkout
20
31
  const lockPath = (repoRoot) => join(tickmarkrDir(repoRoot), "graph.lock");
21
32
  const approvalSerializationPath = (repoRoot) => join(tickmarkrDir(repoRoot), "approval.lock");
22
33
  let heartbeat;
23
34
  let heldPath;
35
+ const heldClaims = new Map(); // graph.lock path → its repository claim
24
36
  let approvalSequence = 0;
25
37
  // Best-effort release if the daemon exits without hitting its finally (crash mid-body). NO
26
38
  // SIGINT/SIGTERM handlers — those change kill semantics and leak listeners across the test
27
39
  // suite's many runDaemon calls; signal-death is exactly what stale-reclaim exists for.
28
- process.once("exit", () => { if (heldPath)
29
- unlinkIfOurs(heldPath); });
40
+ process.once("exit", () => {
41
+ for (const claim of heldClaims.values())
42
+ unlinkClaimIfOurs(claim);
43
+ if (heldPath)
44
+ unlinkIfOurs(heldPath);
45
+ });
30
46
  // LOCK-04: the ONE decision table. Both acquireRunLock and isRunLockLive consume this — the two
31
47
  // hand-maintained copies of the rule cannot drift. The table changes HERE, once. previewUnlock /
32
48
  // commitUnlock (below) read the SAME underlying snapshot shape with their own eligibility rules
@@ -65,16 +81,17 @@ export function isPidLive(pid) {
65
81
  }
66
82
  }
67
83
  // statSync throws ENOENT when no lock exists — callers treat that as "not held".
68
- function inspect(p) {
84
+ function inspect(p, schema = PayloadSchema) {
69
85
  const st = statSync(p); // single stat: both the heartbeat mtime and the reclaim-guard inode
70
86
  const mtimeMs = st.mtimeMs;
71
87
  const expired = Date.now() - mtimeMs > STALE_MS;
72
- const parsed = PayloadSchema.safeParse(readPayload(p));
88
+ const parsed = schema.safeParse(readPayload(p));
73
89
  const garbage = !parsed.success; // LOCK-01: its own state — shouldRefuse refuses it unconditionally; only `tickmarkr unlock` removes it
74
90
  const pid = parsed.success ? parsed.data.pid : undefined;
75
91
  // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
76
92
  const dead = pid === undefined ? true : !isPidLive(pid);
77
- return { pid, runId: parsed.success ? parsed.data.runId : undefined, garbage, dead, expired, mtimeMs, ino: st.ino };
93
+ const checkout = parsed.success ? parsed.data.checkout : undefined;
94
+ return { pid, runId: parsed.success ? parsed.data.runId : undefined, startedAt: parsed.success ? parsed.data.startedAt : undefined, checkout, garbage, dead, expired, mtimeMs, ino: st.ino };
78
95
  }
79
96
  function readPayload(p) {
80
97
  try {
@@ -92,7 +109,177 @@ function unlinkIfOurs(p) {
92
109
  }
93
110
  catch { /* ENOENT — nothing to release */ }
94
111
  }
112
+ function unlinkClaimIfOurs(claim) {
113
+ try {
114
+ const parsed = ClaimSchema.safeParse(readPayload(claim.path));
115
+ if (parsed.success && parsed.data.pid === process.pid && parsed.data.checkout === claim.checkout)
116
+ unlinkSync(claim.path);
117
+ }
118
+ catch { /* ENOENT — nothing to release */ }
119
+ }
120
+ /** The repository run claim for the checkout enclosing `repoRoot`; undefined when no checkout encloses it
121
+ * (graph.lock alone guards there, as before). Any other git trust refusal propagates: fail closed. */
122
+ export function repositoryClaimPath(repoRoot) {
123
+ try {
124
+ return join(trustedCommonDir(repoRoot), CLAIM_FILE);
125
+ }
126
+ catch (e) {
127
+ if (e instanceof GitTrustRefusal && e.detail === NO_CHECKOUT)
128
+ return undefined;
129
+ throw e;
130
+ }
131
+ }
132
+ // The case table: a live holder from ANY other checkout of this repository refuses naming its checkout,
133
+ // run id and pid; a provably dead holder is reclaimed, then the taker re-serializes through link(2);
134
+ // garbage refuses and is removed by hand only. A holder in another repository never meets this path: its
135
+ // common dir — and so its claim — differs.
136
+ function takeRepositoryClaim(repoRoot, runId) {
137
+ const p = repositoryClaimPath(repoRoot);
138
+ if (!p)
139
+ return;
140
+ const checkout = realpathSync(repoRoot);
141
+ while (!linkExclusive(p, { pid: process.pid, runId, startedAt: Date.now(), checkout })) {
142
+ const held = readClaim(p);
143
+ if (!held)
144
+ continue; // released between our link and our read
145
+ if (shouldRefuse(held.insp))
146
+ refuseClaim(p, held.insp);
147
+ reclaimDeadClaim(p, held.id);
148
+ }
149
+ heldClaims.set(lockPath(repoRoot), { path: p, checkout });
150
+ // While this live claim is the claim, every reclaim marker is keyed to a claim gone for good — moot,
151
+ // guarding nothing — so the taker sweeps them all; a reclaimer that died holding one strands nothing.
152
+ const prefix = `${basename(p)}.reclaim.`;
153
+ try {
154
+ for (const name of readdirSync(dirname(p)))
155
+ if (name.startsWith(prefix))
156
+ try {
157
+ unlinkSync(join(dirname(p), name));
158
+ }
159
+ catch { /* another taker swept it */ }
160
+ }
161
+ catch { /* best-effort: a later taker sweeps what is left */ }
162
+ }
163
+ // link(2) exclusive create of a complete payload: true when created, false when `p` already exists.
164
+ function linkExclusive(p, payload) {
165
+ const tmp = join(dirname(p), `${CLAIM_FILE}.${process.pid}.tmp`); // one per process: the claim and its markers share it
166
+ try {
167
+ unlinkSync(tmp);
168
+ }
169
+ catch { /* best-effort clean of our own stray tmp from a prior SIGKILL */ }
170
+ writeFileSync(tmp, JSON.stringify(payload));
171
+ try {
172
+ linkSync(tmp, p);
173
+ return true;
174
+ }
175
+ catch (e) {
176
+ if (e.code === "EEXIST")
177
+ return false;
178
+ throw e;
179
+ }
180
+ finally {
181
+ unlinkSync(tmp);
182
+ }
183
+ }
184
+ // A claim's identity is its inode, pid and start time: a claim is never rewritten in place, so an identity
185
+ // once replaced never returns. undefined when no claim exists.
186
+ function readClaim(p) {
187
+ try {
188
+ const insp = inspect(p, ClaimSchema);
189
+ return { insp, id: `${insp.ino}-${insp.pid}-${insp.startedAt}` };
190
+ }
191
+ catch (e) {
192
+ if (e.code === "ENOENT")
193
+ return undefined;
194
+ throw e;
195
+ }
196
+ }
197
+ // The reclaim runs under an exclusive RECLAIM MARKER beside the claim, keyed to the dead claim's identity:
198
+ // two takers that both saw one dead claim could otherwise each pass a re-stat, and the later unlink would
199
+ // delete the earlier taker's live replacement. Under the marker the claim is re-read, and only that same
200
+ // dead claim is removed. A live marker owner refuses the other taker — a concurrent reclaimer is a run
201
+ // starting in this repository. A marker whose owner died inside the reclaim is STRANDED: the next taker
202
+ // passes to the next generation, which only one taker creates — a dead owner never removes its marker, so
203
+ // two live owners never hold one identity at once — and recovers with no hand removal.
204
+ // ponytail: a stranded marker whose dead pid is reused by an unrelated live process reads live and refuses
205
+ // until the next successful taker sweeps it or it is removed by hand — the fail-closed direction, as graph.lock.
206
+ function reclaimDeadClaim(p, id) {
207
+ let marker = `${p}.reclaim.${id}.0`;
208
+ for (let gen = 0; !linkExclusive(marker, { pid: process.pid, startedAt: Date.now() });) {
209
+ if (markerStranded(p, marker))
210
+ marker = `${p}.reclaim.${id}.${++gen}`;
211
+ }
212
+ try {
213
+ const now = readClaim(p);
214
+ if (now?.id !== id)
215
+ return; // replaced since our read: the caller re-serializes through link(2)
216
+ if (shouldRefuse(now.insp))
217
+ refuseClaim(p, now.insp);
218
+ try {
219
+ unlinkSync(p);
220
+ }
221
+ catch (e) {
222
+ if (e.code !== "ENOENT")
223
+ throw e;
224
+ }
225
+ }
226
+ finally {
227
+ try {
228
+ unlinkSync(marker);
229
+ }
230
+ catch { /* swept as moot by the claim's next taker */ }
231
+ }
232
+ }
233
+ // true when `marker`'s owner provably died holding it: dead, and the same bytes on the same inode after the
234
+ // death proof. false when it went or changed under the read (that generation is retried). A live owner
235
+ // refuses; a garbage marker refuses for removal by hand.
236
+ function markerStranded(p, marker) {
237
+ const read = () => {
238
+ try {
239
+ return { ino: statSync(marker).ino, bytes: readFileSync(marker, "utf8") };
240
+ }
241
+ catch (e) {
242
+ if (e.code === "ENOENT")
243
+ return undefined;
244
+ throw e;
245
+ }
246
+ };
247
+ const before = read();
248
+ if (!before)
249
+ return false;
250
+ let owner;
251
+ try {
252
+ owner = MarkerSchema.parse(JSON.parse(before.bytes));
253
+ }
254
+ catch {
255
+ throw new Error(`reclaim marker ${marker} holds an unreadable/garbage payload — refusing to reclaim repository run claim ${p}; once no tickmarkr run is starting in this repository, remove ${marker} by hand`);
256
+ }
257
+ if (isPidLive(owner.pid))
258
+ throw new Error(`repository run claim ${p} is being reclaimed from its dead holder by another tickmarkr process (pid ${owner.pid}) — retry; if none is starting in this repository, remove ${marker} by hand`);
259
+ const after = read();
260
+ return after !== undefined && after.ino === before.ino && after.bytes === before.bytes;
261
+ }
262
+ function refuseClaim(p, insp) {
263
+ if (insp.garbage)
264
+ throw new Error(`repository run claim ${p} holds an unreadable/garbage payload — refusing to reclaim it; once no tickmarkr run is live in this repository, remove ${p} by hand`);
265
+ throw new Error(`repository run claim ${p} held by pid ${insp.pid} (run ${insp.runId}) in checkout ${insp.checkout} — one live tickmarkr run per repository across its main checkout and linked worktrees; wait for that run to end`);
266
+ }
267
+ // Run and resume (runDaemon) take graph.lock, then the repository claim; a claim refusal releases the
268
+ // graph.lock just taken, before any journal exists. compile's holders take graph.lock alone.
95
269
  export function acquireRunLock(repoRoot, runId) {
270
+ const acquired = acquireGraphLock(repoRoot, runId);
271
+ if (COMPILE_HOLDERS.has(runId))
272
+ return acquired;
273
+ try {
274
+ takeRepositoryClaim(repoRoot, runId);
275
+ }
276
+ catch (e) {
277
+ releaseRunLock(repoRoot);
278
+ throw e;
279
+ }
280
+ return acquired;
281
+ }
282
+ function acquireGraphLock(repoRoot, runId) {
96
283
  const p = lockPath(repoRoot);
97
284
  const tmp = join(tickmarkrDir(repoRoot), `graph.lock.${process.pid}.tmp`);
98
285
  try {
@@ -127,7 +314,7 @@ export function acquireRunLock(repoRoot, runId) {
127
314
  // Single-machine tool — this is the ceiling.
128
315
  const st = statSync(p); // re-stat immediately before removing — abort if replaced since inspect()
129
316
  if (st.ino !== insp.ino || st.mtimeMs !== insp.mtimeMs)
130
- return acquireRunLock(repoRoot, runId); // loser path
317
+ return acquireGraphLock(repoRoot, runId); // loser path
131
318
  try {
132
319
  unlinkSync(p);
133
320
  }
@@ -135,7 +322,7 @@ export function acquireRunLock(repoRoot, runId) {
135
322
  if (e2.code !== "ENOENT")
136
323
  throw e2;
137
324
  } // successor already took it
138
- const again = acquireRunLock(repoRoot, runId); // re-serializes two stealers through linkSync
325
+ const again = acquireGraphLock(repoRoot, runId); // re-serializes two stealers through linkSync
139
326
  return { reclaimed: { pid: pid ?? -1, mtimeMs }, ...again };
140
327
  }
141
328
  const stateDir = stateDirName(repoRoot);
@@ -151,6 +338,12 @@ export function releaseRunLock(repoRoot) {
151
338
  clearInterval(heartbeat);
152
339
  heartbeat = undefined;
153
340
  }
341
+ // the claim goes first: released after graph.lock, a successor run in this checkout would meet our claim
342
+ const claim = heldClaims.get(lockPath(repoRoot));
343
+ if (claim) {
344
+ unlinkClaimIfOurs(claim);
345
+ heldClaims.delete(lockPath(repoRoot));
346
+ }
154
347
  unlinkIfOurs(lockPath(repoRoot)); // never delete a reclaiming successor's lock — only ours
155
348
  heldPath = undefined;
156
349
  }
@@ -75,8 +75,8 @@ export declare const GateEvidenceReceiptSchema: z.ZodObject<{
75
75
  termination: z.ZodObject<{
76
76
  kind: z.ZodEnum<{
77
77
  unknown: "unknown";
78
- timeout: "timeout";
79
78
  signal: "signal";
79
+ timeout: "timeout";
80
80
  "not-started": "not-started";
81
81
  exit: "exit";
82
82
  }>;
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { graphPath, stateDirName } from "../../graph/graph.js";
5
+ import { repoOverlayPath } from "../../config/config.js";
5
6
  import { validateGraph } from "../../graph/schema.js";
6
7
  import { foldOwedChecks, parseJournalText, parseRunId } from "../../run/journal.js";
7
8
  import { isPidLive, STALE_MS } from "../../run/lock.js";
@@ -329,7 +330,16 @@ export function createLiveStore(options) {
329
330
  reset: () => { fold = new OperatorStateFold(); }, record: record => fold.apply(record),
330
331
  });
331
332
  const graph = new JsonSource(graphPath(options.cwd), raw => validateGraph(JSON.parse(raw)), STORE_LIMITS.graphBytes);
332
- const config = new JsonSource(options.configPath ?? join(state, "config.yaml"), raw => raw);
333
+ // Queue row 195 / D-2102: the cockpit's live config source follows the ONE repo-layer path rule, re-resolved
334
+ // on every observation so an own overlay created or removed, or a main file
335
+ // appearing, moves the source; an explicit configPath stays fixed.
336
+ let config;
337
+ const readConfig = (at) => {
338
+ const path = options.configPath ?? repoOverlayPath(options.cwd);
339
+ if (config?.path !== path)
340
+ config = new JsonSource(path, raw => raw);
341
+ return config.read(at);
342
+ };
333
343
  const cache = new JsonSource(options.cachePath ?? join(state, "doctor.json"), JSON.parse);
334
344
  const lock = new JsonSource(join(state, "graph.lock"), raw => {
335
345
  const v = JSON.parse(raw);
@@ -379,7 +389,7 @@ export function createLiveStore(options) {
379
389
  owed = owedOf(journal);
380
390
  }
381
391
  const graphReading = graph.read(observedAt);
382
- const configReading = config.read(observedAt);
392
+ const configReading = readConfig(observedAt);
383
393
  const cacheReading = cache.read(observedAt);
384
394
  const owner = lock.read(observedAt);
385
395
  const alive = owner.status === "readable" && owner.value ? (options.isDaemonAlive ?? isPidLive)(owner.value.pid) : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.8.4",
3
+ "version": "2.8.6",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,18 +20,50 @@ Keep `name: tickmarkr-auto` and the installed links to canonical `skills/` intac
20
20
 
21
21
  ## Two-tier by default — role check before the loop
22
22
 
23
- When working in a multi-agent terminal environment, decide your role before starting:
23
+ When working in a multi-agent terminal environment (herdr or Orca), decide your role before starting:
24
24
 
25
25
  - **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <version>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
26
26
  - **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a [verified handoff](#verified-handoffs-agent-to-agent-messaging), then supervise it as OVERSEER.
27
27
  - **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has stood down (monitors stopped, input box empty — dim ghost-text suggestions are UI, not queued input; ANSI-verify before alarming) and close its tab — the journal, records, and ledger hold the story; scrollback is disposable.
28
- - **Spawning on current herdr is two-step** — the one-shot `agent start --cwd/--tab/--no-focus` form was removed in the herdr CLI redesign and now fails with `unknown option` (OBS-138). First create the pane: `herdr tab create --workspace <ws> --cwd <repo> --label "ORCH · <version>"` (tab create does not steal focus unless `--focus` is passed; parse `result.root_pane.pane_id` from its JSON), then start the agent in it:
29
- - **Claude Code:** `herdr agent start orchestrator --kind claude --pane <root-pane-id> -- --permission-mode bypassPermissions`
30
- - **Codex:** `herdr agent start orchestrator --kind codex --pane <root-pane-id> -- --dangerously-bypass-approvals-and-sandbox` — the unsandboxed flag is REQUIRED, not optional: codex's `workspace-write` sandbox keeps `.git` refs read-only, so a sandboxed orchestrator's `tickmarkr run` dies at integration-branch creation (`git worktree add` cannot lock the ref). Do not downgrade this flag; the herdr pane and repo scope are the containment.
28
+ - **On herdr (`HERDR_ENV=1`)**: **spawning on current herdr is two-step** — the one-shot `agent start --cwd/--tab/--no-focus` form was removed in the herdr CLI redesign and now fails with `unknown option` (OBS-138). First create the pane: `herdr tab create --workspace <ws> --cwd <repo> --label "ORCH · <version>"` (tab create does not steal focus unless `--focus` is passed; parse `result.root_pane.pane_id` from its JSON), then start the agent in it:
29
+ - **Claude Code:** `herdr agent start orchestrator --kind claude --pane <root-pane-id> -- --permission-mode bypassPermissions`
30
+ - **Codex:** `herdr agent start orchestrator --kind codex --pane <root-pane-id> -- --dangerously-bypass-approvals-and-sandbox` — the unsandboxed flag is REQUIRED, not optional: codex's `workspace-write` sandbox keeps `.git` refs read-only, so a sandboxed orchestrator's `tickmarkr run` dies at integration-branch creation (`git worktree add` cannot lock the ref). Do not downgrade this flag; the herdr pane and repo scope are the containment.
31
+ - **Auxiliary seats run as the CLI's interactive TUI in their visible pane — never headless** (`claude -p` / `codex exec`): headless buffers output until exit so the pane renders idle for the entire run, is blind to SessionStart hook errors (a broken and a fixed hook both return green), and gives a stall watcher no midpoint — silent-time equals lifetime. Headless is for exit-code probes only (a quota check that wants `rc`), never for work anyone must watch.
32
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: spawn it with `orca terminal create --worktree path:<repo> --title "ORCH · <version>" --command "<agent-cmd>" --json` and parse `result.terminal.handle` from the create receipt. **Auxiliary seats run as the CLI's interactive TUI in their visible terminal — never headless**, for the reasons the herdr branch gives.
31
33
  - **Auxiliary agents you spawn (consultants, reviewers, scouts) follow the same forms.** Never launch a claude session in plan mode or default permission mode for autonomous work — both stall on per-command approval prompts nobody is watching; claude is always `--permission-mode bypassPermissions` (tickmarkr's own adapter uses exactly this for workers, judges, and consults). A read-only codex consultant may use `--sandbox read-only`; any codex session that must touch git needs the unsandboxed flag above.
32
- - **Auxiliary seats run as the CLI's interactive TUI in their visible pane — never headless** (`claude -p` / `codex exec`): headless buffers output until exit so the pane renders idle for the entire run, is blind to SessionStart hook errors (a broken and a fixed hook both return green), and gives a stall watcher no midpoint — silent-time equals lifetime. Headless is for exit-code probes only (a quota check that wants `rc`), never for work anyone must watch.
33
34
 
34
- Outside a multi-agent terminal environment, run the loop directly.
35
+ With neither herdr nor Orca there is no role to decide and no seat to spawn: follow [On a plain host (neither herdr nor Orca)](#on-a-plain-host-neither-herdr-nor-orca) and run the loop directly, single-tier.
36
+
37
+ ## On a plain host (neither herdr nor Orca)
38
+
39
+ Neither `HERDR_ENV=1` nor the Orca markers are set: the Claude Desktop Code tab, a plain terminal, any
40
+ agent session that owns no panes (queue row 192; D-2102). The role check above does not apply.
41
+
42
+ - **This session runs SINGLE-TIER as the orchestrator.** There are no seats, no pane briefs, no tab
43
+ canon and no board: run every step of [the per-spec loop](#per-spec-loop) yourself and skip every
44
+ On herdr and On Orca scope.
45
+ - **An empty mission is asked for, never assumed.** With no target named, ask the operator what to run
46
+ end-to-end before compiling anything; there is no other tier to relay it to.
47
+ - **Run and resume launch through the anchor-free lines** of the
48
+ [detached launch](#host-owned-daemon-and-detached-beats): the same `node` detach with no address, one
49
+ line for `tickmarkr run` and one for `tickmarkr resume <runId>`. The daemon places no board, so a
50
+ missing board is never a fault; confirm the launch from the lock pid as that section says.
51
+ - **Run-end and parks are watched through the host's background wait**, never a foreground wait past
52
+ the host's ten-minute tool cap: the cap kills that wait, ends the turn and leaves the run unwatched.
53
+ Start the wait as a background task whose exit wakes this session, on journal lines after a baseline
54
+ taken at arm time and on the lock holder's death:
55
+ `n=$(wc -l < <journal>); until tail -n +$((n + 1)) <journal> | grep -qE '"event":"(run-end|task-human|task-failed|consult-verdict)"' || ! kill -0 <pid>; do sleep 20; done`
56
+ (`<journal>` is `<state-dir>/runs/<runId>/journal.jsonl`, `<pid>` the lock pid). On every wake read
57
+ CURRENT `tickmarkr status <runId>` first, then re-arm.
58
+ - **The operator's view is `tickmarkr status <runId>`**, plus its live `status --watch --plain` form,
59
+ `tickmarkr status <runId> --watch --plain`, in a read-only Terminal tab beside this session.
60
+ - **Reviews and judges run headless.** The subprocess driver runs every worker, judge and review as a
61
+ headless CLI call with no pane to watch: read their verdicts from the journal, the gate evidence and
62
+ `tickmarkr status <runId>`, never from a screen.
63
+ - **Sessions hand off through the host's cross-session message tool**, confirmed by the peer's reply:
64
+ there is no composer to read back, so a message no peer answered is not a handoff.
65
+ - **Keep a permission mode that never stalls per command** for the whole run (for example
66
+ `bypassPermissions`): nobody answers a per-command approval prompt in an unattended session.
35
67
 
36
68
  ## Stand-down (mission end and retirement)
37
69
 
@@ -79,21 +111,24 @@ concurrently* — so a machine-wide check answers a question nobody asked and bl
79
111
 
80
112
  ## Verified handoffs (agent-to-agent messaging)
81
113
 
82
- When relaying missions between agents in a multi-agent terminal, **never use bare send-text** (`herdr agent send` / pane send-text) — it writes text without pressing Enter, so handoffs sit unsubmitted (OBS-39).
114
+ Never report "briefed" or "relayed" without the confirmation your host gives:
83
115
 
84
- Use one of:
116
+ - **On herdr (`HERDR_ENV=1`)**: when relaying missions between agents in a multi-agent terminal, **never use bare send-text** (`herdr agent send` / pane send-text) — it writes text without pressing Enter, so handoffs sit unsubmitted (OBS-39). Use one of:
117
+ - `herdr pane run <pane> "<message>"` — text plus Enter in the target shell
118
+ - `herdr notification show "<message>"` — OS-level delivery for the operator
85
119
 
86
- - `herdr pane run <pane> "<message>"` — text plus Enter in the target shell
87
- - `herdr notification show "<message>"` — OS-level delivery for the operator
88
-
89
- After sending, **confirm delivery** by reading the target pane and verifying the message landed (input empty, agent status `working`, or notification acknowledged). Never report "briefed" or "relayed" without read-back confirmation.
120
+ After sending, **confirm delivery** by reading the target pane and verifying the message landed (input empty, agent status `working`, or notification acknowledged). Never report "briefed" or "relayed" without read-back confirmation.
121
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: send with `orca terminal send --terminal <handle> --text "<message>" --enter --wait-submit 15 --json` and treat it as delivered only when `result.send.prompt.stages` carries a `turn_started` stage — `accepted: true` proves input acceptance, not a started turn.
122
+ - With neither host, hand off through the host's cross-session message tool, confirmed by the peer's reply ([On a plain host (neither herdr nor Orca)](#on-a-plain-host-neither-herdr-nor-orca)).
90
123
 
91
124
  ## Per-spec loop
92
125
 
93
126
  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.
94
127
  2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
95
128
  3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
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.
129
+ 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, and never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents: the consumer takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict`. A run-end record is history, never green; 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. The consumer is host-specific; with neither host it is the background wait of [On a plain host (neither herdr nor Orca)](#on-a-plain-host-neither-herdr-nor-orca).
130
+ - **On herdr (`HERDR_ENV=1`)**: the consumer is the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800`, which grades the run-end summary's execution clauses for you, `EXECUTION COMPLETE` or `NOT GREEN`, never green — or a foreground `until grep` on the run's terminal events; ensure it is re-armed at most every twenty minutes.
131
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: the consumer is that same shipped journal watcher or a foreground `until grep` on the run's terminal events; ensure it is re-armed at most every twenty minutes.
97
132
  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.
98
133
  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.
99
134
  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.
@@ -101,12 +136,13 @@ After sending, **confirm delivery** by reading the target pane and verifying the
101
136
  ## Host-owned daemon and detached beats
102
137
 
103
138
  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:
139
+ process in its own session, its stdout and stderr appended to `<state-dir>/daemon.log`, and on
140
+ herdr or Orca the ORCH's recorded address as its board anchor, so the daemon self-places its one
141
+ board beside the ORCH in the ORCH tab; with neither host it carries no address and places no board.
142
+ Never launch it in a visible split, pane or tab, as an agent harness background task, or tied to
143
+ an agent session: the launching shell may end, and the daemon keeps running and logging. The
144
+ detach wrapper is `node`, which tickmarkr already requires — no private script, no extra visible
145
+ daemon pane and no CLI log flag:
110
146
 
111
147
  ```bash
112
148
  # On Orca: <ORCH handle> is the ORCH terminal handle
@@ -115,19 +151,27 @@ cd <repo> && ORCA_TERMINAL_HANDLE="<ORCH handle>" node -e "require('child_proces
115
151
  # On herdr: <ORCH pane id> is the ORCH pane id
116
152
  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
153
  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
154
+ # On a plain host (neither herdr nor Orca): the same detach with no address
155
+ cd <repo> && 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
156
+ cd <repo> && 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
157
  ```
119
158
 
120
159
  - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: run the
121
160
  `ORCA_TERMINAL_HANDLE` lines; `<ORCH handle>` is the ORCH's own `$ORCA_TERMINAL_HANDLE`.
122
161
  - **On herdr (`HERDR_ENV=1`)**: run the `HERDR_PANE_ID` lines; `<ORCH pane id>` is the ORCH's own
123
162
  `$HERDR_PANE_ID`.
163
+ - With neither host, run the two lines that carry no address
164
+ ([On a plain host (neither herdr nor Orca)](#on-a-plain-host-neither-herdr-nor-orca)).
124
165
 
125
- After either launch, read this repository's lock pid from `<state-dir>/graph.lock` and confirm it:
166
+ After any launch, read this repository's lock pid from `<state-dir>/graph.lock` and confirm it:
126
167
  `kill -0 <pid>` succeeds, `ps -o pgid= -p <pid>` prints that same pid (its own session group;
127
168
  ppid 1 is never proof on its own), walk its ppid chain and verify no agent session is an ancestor,
128
169
  and read `<state-dir>/daemon.log`. No live holder means the launch failed: read the log, fix the
129
170
  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.
171
+ visible split to watch it.
172
+
173
+ - **On herdr (`HERDR_ENV=1`)**: the daemon-placed board is the live surface.
174
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**: the daemon-placed board is the live surface.
131
175
 
132
176
  Detached beats are product-owned, one per (tier, seat), from the repository root, through the
133
177
  shipped lifecycle verbs — never a hand-rolled setsid plus nohup wrapper, pidfile or shell loop: