tickmarkr 1.61.0 → 1.63.0

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.
@@ -103,14 +103,27 @@ const daemonPid = (events) => {
103
103
  }
104
104
  return undefined;
105
105
  };
106
+ const terminalFailureCause = (events) => {
107
+ for (let i = events.length - 1; i >= 0; i--) {
108
+ const e = events[i];
109
+ if (e.event !== "run-end" || typeof e.data.error !== "string")
110
+ continue;
111
+ const phase = typeof e.data.phase === "string" && e.data.phase.trim()
112
+ ? `${e.data.phase.trim()} failed`
113
+ : "run failed";
114
+ return `${phase}: ${e.data.error.replace(/\s+/g, " ").trim()}`;
115
+ }
116
+ return undefined;
117
+ };
106
118
  const liveness = (events) => {
107
119
  const last = events.at(-1);
108
120
  if (!last)
109
121
  return "last event unknown · daemon pid unknown";
110
122
  const age = fmtAge(Date.now() - Date.parse(last.ts));
111
123
  const pid = daemonPid(events);
124
+ const cause = terminalFailureCause(events);
112
125
  if (pid === undefined)
113
- return `last event ${age} ago · daemon pid unknown`;
126
+ return `last event ${age} ago · daemon pid unknown${cause ? ` · ${cause}` : ""}`;
114
127
  // a dead pid after run-end is a clean exit, not a crash — "dead" is only alarming (red) while
115
128
  // the run is still incomplete (operator's crash indicator)
116
129
  const ended = events.some((e) => e.event === "run-end");
@@ -122,7 +135,7 @@ const liveness = (events) => {
122
135
  catch (k) {
123
136
  state = k.code === "ESRCH" ? (ended ? "finished" : "dead") : "alive";
124
137
  } // EPERM ⇒ alive
125
- return `last event ${age} ago · daemon pid ${pid} ${state}`;
138
+ return `last event ${age} ago · daemon pid ${pid} ${state}${cause ? ` · ${cause}` : ""}`;
126
139
  };
127
140
  const renderFrame = (cwd) => {
128
141
  const g = loadGraph(cwd);
@@ -21,6 +21,10 @@ export function compileNative(file) {
21
21
  if (!existsSync(file))
22
22
  throw new CompileError(`no such native spec file: ${file}`);
23
23
  const content = readFileSync(file, "utf8");
24
+ // Exact shared-template check: init writes specTemplate(), and any edit changes the body.
25
+ if (content === specTemplate()) {
26
+ throw new CompileError(`${file} is the unedited tickmarkr init scaffold. Edit ${file} before compiling.`);
27
+ }
24
28
  const drafts = [];
25
29
  let plainCount = 0; // v1.19: plain-string acceptance items compiled as judge oracles (compat) — warn once
26
30
  let specMode;
@@ -118,24 +122,89 @@ export function compileNative(file) {
118
122
  throw new CompileError(`acceptance criteria are required on every task. Missing on: ${missing.join(", ")}.\n` +
119
123
  `Add to each section in ${file}:\n- acceptance:\n - <observable outcome>`);
120
124
  }
121
- const csv = (value) => (value && value.toLowerCase() !== "none" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
125
+ // v1.62 (OBS-97): commas inside {a,b} alternatives are part of one glob entry, not separators —
126
+ // split only at brace depth 0 so a brace glob reaches the lint (and the scope gate) intact.
127
+ const splitTop = (value) => {
128
+ const parts = [];
129
+ let depth = 0;
130
+ let current = "";
131
+ for (const ch of value) {
132
+ if (ch === "," && depth === 0) {
133
+ parts.push(current);
134
+ current = "";
135
+ continue;
136
+ }
137
+ if (ch === "{")
138
+ depth++;
139
+ else if (ch === "}" && depth > 0)
140
+ depth--;
141
+ current += ch;
142
+ }
143
+ return [...parts, current];
144
+ };
145
+ const csv = (value) => (value && value.toLowerCase() !== "none" ? splitTop(value).map((item) => item.trim()).filter(Boolean) : []);
122
146
  // OBS-97: a typed test: oracle needs a collectable home. vitest only collects COLLECTABLE_TESTS
123
147
  // paths, so a task whose non-empty files[] cannot host one makes scope-green and acceptance-green
124
148
  // mutually exclusive by construction — run-20260719-210434 burned two dispatch attempts before a
125
149
  // consult diagnosed exactly this. Empty files[] stays exempt: no file scope means unrestricted
126
150
  // (src/gates/scope.ts). An entry hosts a collectable path iff its glob can produce one — probed by
127
- // substituting each wildcard run with a test-shaped segment (also covers literal test-file paths).
151
+ // substituting each wildcard run with a test-shaped segment (also covers literal test-file paths);
152
+ // v1.62 extends the probe to {a,b} brace alternatives and ? single-character wildcards.
128
153
  const collectable = picomatch(COLLECTABLE_TESTS, { dot: true });
129
154
  // Single-token substitution is not a true glob-overlap test (tests/**/*.ts needs "probe.test",
130
155
  // a bare ** needs the whole collectable path) — probe with several test-shaped tokens and accept
131
156
  // if ANY candidate satisfies both globs. Scopes that truly cannot host one still fail every probe.
132
157
  const PROBE_TOKENS = ["probe.test.ts", "probe.test", "tests/probe.test.ts"];
158
+ // v1.62: {a,b} alternatives expand to concrete branches before probing (innermost group first, so
159
+ // nesting resolves); an unbalanced brace is literal to picomatch and stays unexpanded. Expansion is
160
+ // BOUNDED: past BRANCH_CAP branches, further alternatives are dropped — dropping candidates can only
161
+ // reject, never accept (every accept needs a concrete witness), so the cap stays fail-closed while a
162
+ // pathological {a,b}{a,b}… entry can no longer balloon compile time.
163
+ const BRANCH_CAP = 64;
164
+ const expandBraces = (glob) => {
165
+ let branches = [glob];
166
+ for (;;) {
167
+ let changed = false;
168
+ const next = [];
169
+ for (const branch of branches) {
170
+ const inner = branch.match(/\{([^{}]*)\}/);
171
+ if (!inner) {
172
+ next.push(branch);
173
+ continue;
174
+ }
175
+ changed = true;
176
+ for (const alt of inner[1].split(","))
177
+ next.push(branch.replace(inner[0], alt));
178
+ }
179
+ branches = next.slice(0, BRANCH_CAP);
180
+ if (!changed)
181
+ return branches;
182
+ }
183
+ };
184
+ // v1.62: a ? never blocks self-match (it matches any one char), so hosting hinges on the probe
185
+ // clearing the collectable literals — search ? positions over that alphabet PER POSITION (a mixed
186
+ // scope like test?/unit.tes?.ts needs different chars at each ?). Every accepted probe is a concrete
187
+ // path satisfying BOTH globs (a witness), so expansion and substitution can only lift false
188
+ // rejections — a scope with no witness still fails every probe. Bounded: entries with more than
189
+ // QMARK_CAP ?s stay fail-closed (4^4 = 256 candidates is the ceiling per probe).
190
+ const QMARK_CHARS = ["t", "e", "s", "."];
191
+ const QMARK_CAP = 4;
192
+ const qmarkVariants = (probe) => {
193
+ const qCount = (probe.match(/\?/g) ?? []).length;
194
+ if (qCount === 0 || qCount > QMARK_CAP)
195
+ return [probe];
196
+ let variants = [probe];
197
+ for (let i = 0; i < qCount; i++) {
198
+ variants = variants.flatMap((v) => QMARK_CHARS.map((ch) => v.replace("?", ch)));
199
+ }
200
+ return [probe, ...variants];
201
+ };
133
202
  const canHostTest = (entry) => {
134
203
  const self = picomatch(entry, { dot: true });
135
- return PROBE_TOKENS.some((token) => {
136
- const probe = entry.replace(/\*+/g, token);
137
- return self(probe) && collectable(probe);
138
- });
204
+ return expandBraces(entry)
205
+ .flatMap((branch) => PROBE_TOKENS.map((token) => branch.replace(/\*+/g, token)))
206
+ .flatMap(qmarkVariants)
207
+ .some((probe) => self(probe) && collectable(probe));
139
208
  };
140
209
  const homeless = drafts
141
210
  .filter((draft) => {
@@ -1,6 +1,7 @@
1
1
  import { type ExecutorDriver, type NotifyOpts, type Slot, type SlotOpts } from "./types.js";
2
2
  export declare const TRAILER_SAFE_FLOOR_COLS = 108;
3
3
  export declare const TRAILER_WIDTH_MARGIN = 2;
4
+ export declare const DELIVERY_ATTEMPTS = 3;
4
5
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
5
6
  export declare function workerSplitDirection(paneCols: number | null, safeFloor?: number, margin?: number): "right" | "down";
6
7
  export declare class HerdrDriver implements ExecutorDriver {
@@ -6,6 +6,10 @@ import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName }
6
6
  // VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
7
7
  export const TRAILER_SAFE_FLOOR_COLS = 108;
8
8
  export const TRAILER_WIDTH_MARGIN = 2; // cols below (floor + margin) refuse a rightward first split
9
+ // OBS-85 verified delivery: bounded type→read-back→enter attempts before failing closed.
10
+ export const DELIVERY_ATTEMPTS = 3;
11
+ const DELIVERY_VERIFY_TIMEOUT_MS = 2000; // per attempt — a paste that hasn't rendered in 2s is retyped
12
+ const DELIVERY_READ_LINES = 80;
9
13
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
10
14
  export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_COLS, margin = TRAILER_WIDTH_MARGIN) {
11
15
  if (paneCols == null || paneCols <= 0)
@@ -273,11 +277,41 @@ export class HerdrDriver {
273
277
  await this.renameGroupTab(entry);
274
278
  return { id: pane, name, cwd, tabId: entry.tabId, group };
275
279
  }
280
+ // OBS-85 verified delivery: a pane paste can interleave a long dispatch line with itself (codex
281
+ // `$(git rev-parse…)` mashed into its trailing printf — v1.58 T2 attempts 2-4, v1.61 T10). Never
282
+ // the atomic `pane run` (text+Enter in one request, uninspectable between the two): type WITHOUT
283
+ // enter, read the pane back — `wait output --match` checks the same unwrapped transcript pane
284
+ // read exposes, event-driven so wrap and render timing can't race the check — and press Enter
285
+ // only when that read-back contains the typed command. A corrupted paste is captured (pane read),
286
+ // cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
287
+ // transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
276
288
  async run(slot, cmd) {
277
289
  const pane = await this.paneId(slot);
278
- const r = await this.herdr(`pane run ${shq(pane)} ${shq(cmd)}`, slot.cwd);
279
- if (r.code !== 0)
280
- throw new Error(`herdr pane run failed: ${r.stderr || r.stdout}`);
290
+ let transcript = "";
291
+ for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
292
+ if (attempt > 0) {
293
+ // clear the corrupted input line before retyping; a failed clear must NOT be retyped onto —
294
+ // corrupt-prefix + clean-retype would concatenate and false-verify by containment
295
+ const cleared = await this.herdr(`pane send-keys ${shq(pane)} C-u`, slot.cwd);
296
+ if (cleared.code !== 0)
297
+ throw new Error(`herdr delivery clear failed — refusing to retype onto a corrupted line (OBS-85); pane transcript:\n${transcript}`);
298
+ }
299
+ const typed = await this.herdr(`pane send-text ${shq(pane)} ${shq(cmd)}`, slot.cwd);
300
+ if (typed.code !== 0)
301
+ throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
302
+ const back = await this.herdr(`wait output ${shq(pane)} --match ${shq(cmd)} --timeout ${DELIVERY_VERIFY_TIMEOUT_MS}`, slot.cwd, DELIVERY_VERIFY_TIMEOUT_MS + 15_000);
303
+ // ponytail: transcript containment (scrollback included); anchor to the prompt line if a
304
+ // prior echo of the same command ever false-verifies. Dead pane → error envelope → unverified.
305
+ if (this.waitOk(back.code, back.stdout)) {
306
+ const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
307
+ if (enter.code !== 0)
308
+ throw new Error(`herdr pane send-keys Enter failed: ${enter.stderr || enter.stdout}`);
309
+ return;
310
+ }
311
+ // capture the corrupted delivery BEFORE clearing it — the OBS-85 byte-level evidence
312
+ transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
313
+ }
314
+ throw new Error(`herdr delivery corrupted after ${DELIVERY_ATTEMPTS} attempts — enter never pressed (OBS-85); pane transcript:\n${transcript}`);
281
315
  }
282
316
  waitOk(code, stdout) {
283
317
  if (code !== 0 || !stdout.trim())
@@ -4,7 +4,14 @@ export interface Baseline {
4
4
  commands: Record<string, {
5
5
  exitCode: number;
6
6
  fingerprints: string[];
7
+ missingCommand?: boolean;
7
8
  }>;
9
+ warnings?: BaselineWarning[];
10
+ }
11
+ export interface BaselineWarning {
12
+ kind: "wrong-environment";
13
+ commands: string[];
14
+ reason: string;
8
15
  }
9
16
  export declare function fingerprint(output: string): string[];
10
17
  export declare function detectGateCommands(repoRoot: string, cfg: TickmarkrConfig): Record<string, string>;
@@ -41,12 +41,45 @@ export function detectGateCommands(repoRoot, cfg) {
41
41
  }
42
42
  return out;
43
43
  }
44
+ const shellToken = (cmd) => {
45
+ for (const raw of cmd.trim().split(/\s+/)) {
46
+ if (!raw || /^[A-Za-z_][A-Za-z0-9_]*=/.test(raw))
47
+ continue;
48
+ if (raw === "env")
49
+ continue;
50
+ return raw.replace(/^['"]|['"]$/g, "");
51
+ }
52
+ return undefined;
53
+ };
54
+ const reEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ function missingConfiguredCommand(cmd, result) {
56
+ if (result.code !== 127)
57
+ return false;
58
+ const token = shellToken(cmd);
59
+ if (!token)
60
+ return false;
61
+ const output = `${result.stdout}\n${result.stderr}`;
62
+ return new RegExp(`(?:^|[:\\s])${reEscape(token)}:\\s+(?:command not found|No such file or directory)`, "i").test(output);
63
+ }
44
64
  export async function captureBaseline(cwd, commands) {
45
65
  const base = { commands: {} };
46
66
  for (const [name, cmd] of Object.entries(commands)) {
47
67
  const r = await sh(cmd, cwd);
48
68
  // ponytail: strip the executing cwd so repo-root capture and worktree compare fingerprint identically; /private-vs-/tmp symlink variance is out of scope
49
- base.commands[name] = { exitCode: r.code, fingerprints: fingerprint((r.stdout + "\n" + r.stderr).split(cwd).join("")) };
69
+ base.commands[name] = {
70
+ exitCode: r.code,
71
+ fingerprints: fingerprint((r.stdout + "\n" + r.stderr).split(cwd).join("")),
72
+ missingCommand: missingConfiguredCommand(cmd, r),
73
+ };
74
+ }
75
+ const names = Object.keys(commands);
76
+ const missing = names.filter((name) => base.commands[name]?.missingCommand === true);
77
+ if (names.length > 0 && missing.length === names.length) {
78
+ base.warnings = [{
79
+ kind: "wrong-environment",
80
+ commands: missing,
81
+ reason: `wrong environment: every configured baseline command was missing (${missing.join(", ")})`,
82
+ }];
50
83
  }
51
84
  return base;
52
85
  }
@@ -7,7 +7,7 @@ import { stringify } from "yaml";
7
7
  import { trailerPattern, writePrompt } from "../adapters/prompt.js";
8
8
  import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from "../adapters/registry.js";
9
9
  import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
10
- import { bannerShell } from "../brand.js";
10
+ import { bannerShell, paneDispatchCommand } from "../brand.js";
11
11
  import { globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
12
12
  import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js";
13
13
  import { formatOwnedName } from "../drivers/types.js";
@@ -109,6 +109,10 @@ export async function runDaemon(repoRoot, opts = {}) {
109
109
  // v1.54 T2: declared before the try so the finally can always deregister (a throw before
110
110
  // registration leaves it undefined — the guard below covers that path).
111
111
  let onTermination;
112
+ let journal;
113
+ let runStarted = false;
114
+ let taskLoopStarted = false;
115
+ let branch = "";
112
116
  try {
113
117
  let graph = loadGraph(repoRoot);
114
118
  // v1.51 T2: the routing mode resolves BEFORE any routing input is built — run flag > spec front-matter
@@ -184,12 +188,12 @@ export async function runDaemon(repoRoot, opts = {}) {
184
188
  };
185
189
  process.on("SIGINT", onTermination);
186
190
  process.on("SIGTERM", onTermination);
187
- const journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
191
+ journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
188
192
  const branchEvent = opts.resume
189
193
  ? [...journal.read()].reverse().find((e) => (e.event === "run-start" || e.event === "run-end" || e.event === "merge") && typeof e.data.branch === "string")
190
194
  : undefined;
191
195
  const recordedBranch = typeof branchEvent?.data.branch === "string" ? branchEvent.data.branch : undefined;
192
- const branch = recordedBranch
196
+ branch = recordedBranch
193
197
  ? branchEvent.event === "merge" ? recordedBranch.slice(0, recordedBranch.lastIndexOf("--")) : recordedBranch
194
198
  : integrationBranch(cfg, runId);
195
199
  if (lock.reclaimed)
@@ -249,9 +253,12 @@ export async function runDaemon(repoRoot, opts = {}) {
249
253
  baseline = await captureBaseline(repoRoot, commands);
250
254
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
251
255
  journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source, ...(prior ? { supersedes: prior.runId } : {}) }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2; supersedes: v1.53 T5
256
+ runStarted = true;
252
257
  // v1.53 T5: mark the prior run AFTER this run's run-start exists, so the prior journal never
253
258
  // names a successor that has no journal. Append-only — the prior journal is never rewritten.
254
259
  prior?.append("superseded", undefined, { by: runId });
260
+ for (const warning of baseline.warnings ?? [])
261
+ journal.append("baseline-warning", undefined, { ...warning });
255
262
  }
256
263
  // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
257
264
  // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
@@ -575,6 +582,19 @@ export async function runDaemon(repoRoot, opts = {}) {
575
582
  journal.append("worker-mode-fallback", t.id, { reason: driver.interactive ? "adapter" : "driver" });
576
583
  }
577
584
  const interactive = icmd !== null;
585
+ // OBS-85 (v1.62 T1): both dispatch branches deliver ONE short script invocation — banner,
586
+ // adapter command, and nonce exit marker live in a per-attempt script beside the prompt
587
+ // artifact (the same paneDispatchCommand pattern judge/review/consult dispatches use). The
588
+ // delivered pane line carries no command substitution and no trailing shell text, so paste
589
+ // timing can never interleave a `$(…)` with what follows it (the codex corruption class).
590
+ const workerCmd = interactive ? icmd : adapter.invoke(t, wt, assignment, { promptFile }).command;
591
+ const dispatchScript = promptFile.replace(/\.md$/, ".sh");
592
+ writeFileSync(dispatchScript, [
593
+ "export BASH_SILENCE_DEPRECATION_WARNING=1",
594
+ bannerShell(),
595
+ workerCmd,
596
+ exitMarkerCmd,
597
+ ].join("\n"));
578
598
  // SPEND-01: this attempt's dispatch wall-clock — the usage collect cursor. Captured once here, the
579
599
  // single site, so a test can reason about it; keep Date.now() out of profile.ts (still pure) and
580
600
  // out of adapter module scope (the cursor is a parameter, threaded from the daemon).
@@ -617,9 +637,7 @@ export async function runDaemon(repoRoot, opts = {}) {
617
637
  if (interactive) {
618
638
  // v1.2 interactive: the TUI doesn't exit on completion — the trailer is the finish line.
619
639
  // The exit wrapper still fires if the TUI dies (crash/quit): fast-fail instead of burning the timeout.
620
- // T5: the pane announces itself first — same brand header (logo + dim identity) as the
621
- // judge/review/consult dispatch scripts; one printf one-liner, dispatch mechanics unchanged.
622
- await driver.run(slot, `${bannerShell()}; ${icmd}; ${exitMarkerCmd}`);
640
+ await driver.run(slot, paneDispatchCommand(dispatchScript));
623
641
  let paged = false;
624
642
  // v1.22 T5 / OBS-19: auto-answer a fingerprint-matched trust dialog exactly once per slot.
625
643
  // Any other blocked/idle dialog still pages the operator (paged latch below).
@@ -708,9 +726,7 @@ export async function runDaemon(repoRoot, opts = {}) {
708
726
  }
709
727
  }
710
728
  else {
711
- const inv = adapter.invoke(t, wt, assignment, { promptFile });
712
- // T5: same brand header as the interactive path and the gate/consult dispatch scripts.
713
- await driver.run(slot, `${bannerShell()}; ${inv.command}; ${exitMarkerCmd}`);
729
+ await driver.run(slot, paneDispatchCommand(dispatchScript));
714
730
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
715
731
  // OBS-82: same normalized-snapshot compare as the interactive site — spinner-only repaints
716
732
  // exhaust the budget here too; harvest below still reads the raw pane.
@@ -970,6 +986,7 @@ export async function runDaemon(repoRoot, opts = {}) {
970
986
  return;
971
987
  }
972
988
  };
989
+ taskLoopStarted = true;
973
990
  const inflight = new Map();
974
991
  while (true) {
975
992
  // v1.54 T2: a signal that landed while nothing was racing `aborted` (empty inflight window)
@@ -1060,6 +1077,23 @@ export async function runDaemon(repoRoot, opts = {}) {
1060
1077
  await driver.notify(`tickmarkr ${runId}: ${summary.done.length} done, ${summary.failed.length} failed, ${summary.human.length} awaiting human, ${summary.blocked.length} blocked, ${summary.pending.length} pending${attribution ? ` (${attribution})` : ""}${tipFail} — integration branch ${branch} (merge to main is yours)`, { tier: summary.tipVerify === "failed" ? "attention" : "routine" });
1061
1078
  return summary;
1062
1079
  }
1080
+ catch (err) {
1081
+ if (runStarted && !taskLoopStarted && !journal.read().some((e) => e.event === "run-end")) {
1082
+ journal.append("run-end", undefined, {
1083
+ runId,
1084
+ branch,
1085
+ done: [],
1086
+ failed: [],
1087
+ human: [],
1088
+ blocked: [],
1089
+ pending: [],
1090
+ phase: "setup",
1091
+ fatal: true,
1092
+ error: err instanceof Error ? err.message : String(err),
1093
+ });
1094
+ }
1095
+ throw err;
1096
+ }
1063
1097
  finally {
1064
1098
  // v1.54 T2: deregister on EVERY exit (normal run end, throw, termination unwind) — the daemon
1065
1099
  // test suite runs runDaemon dozens of times in one process; a leaked handler would close a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.61.0",
3
+ "version": "1.63.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,7 +16,8 @@ When working in a multi-agent terminal environment, decide your role before star
16
16
  - **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.
17
17
  - **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.
18
18
  - **Claude Code:** `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions`
19
- - **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write`
19
+ - **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --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.
20
+ - **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.
20
21
 
21
22
  Outside a multi-agent terminal environment, run the loop directly.
22
23
 
@@ -15,7 +15,8 @@ When working in a multi-agent terminal environment, decide your role before star
15
15
  - **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.
16
16
  - **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](#stand-down-mission-end-and-retirement) and close its tab.
17
17
  - **Claude Code:** `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions`
18
- - **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write`
18
+ - **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --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.
19
+ - **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.
19
20
 
20
21
  Outside a multi-agent terminal environment, run the loop directly.
21
22
 
@@ -29,7 +29,7 @@ Requires `HERDR_ENV=1`; if unset, say so and stop.
29
29
  main name plus at most ONE hot-state token. Vocabulary: ORCH carries the milestone and progress
30
30
  fraction (`ORCH · v1.19 4/5`, updated on every task-done); WORKERS carries the task token (tickmarkr
31
31
  updates it). Never long context strings or ✓-chains.
32
- 2. **Orchestrator**: Launch the orchestrator with your agent host. For Claude Code, use `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions` (pin a strong model with `--model <m>` if the operator has a policy). For Codex, use `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write` (add `--model <m>` to specify the model). Workers you never spawn — tickmarkr spawns its own visible worker panes.
32
+ 2. **Orchestrator**: Launch the orchestrator with your agent host. For Claude Code, use `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions` (pin a strong model with `--model <m>` if the operator has a policy). For Codex, use `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --dangerously-bypass-approvals-and-sandbox` (add `--model <m>` to specify the model). The unsandboxed flag is REQUIRED: codex's `workspace-write` sandbox keeps `.git` refs read-only, so a sandboxed orchestrator's `tickmarkr run` dies at integration-branch creation — do not downgrade it. Workers you never spawn — tickmarkr spawns its own visible worker panes. Auxiliary agents you do spawn (consultants, reviewers, scouts) follow the same forms: never launch a claude session in plan mode or default permission mode for autonomous work — both stall on per-command approval prompts nobody is watching; claude is always `--permission-mode bypassPermissions`, and a read-only codex consultant may use `--sandbox read-only`.
33
33
  3. **Standing instructions travel as a brief FILE, never as pane text** — PTY input truncates at ~1024B and a
34
34
  truncated brief silently drops policy. Write the full brief to `<repo>/.tickmarkr/overseer/ORCH-BRIEF.md`
35
35
  (inside the tickmarkr state dir — already self-gitignored, no exclude step needed), then send one line: