tickmarkr 1.62.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;
@@ -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
  }
@@ -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:
@@ -979,6 +986,7 @@ export async function runDaemon(repoRoot, opts = {}) {
979
986
  return;
980
987
  }
981
988
  };
989
+ taskLoopStarted = true;
982
990
  const inflight = new Map();
983
991
  while (true) {
984
992
  // v1.54 T2: a signal that landed while nothing was racing `aborted` (empty inflight window)
@@ -1069,6 +1077,23 @@ export async function runDaemon(repoRoot, opts = {}) {
1069
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" });
1070
1078
  return summary;
1071
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
+ }
1072
1097
  finally {
1073
1098
  // v1.54 T2: deregister on EVERY exit (normal run end, throw, termination unwind) — the daemon
1074
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.62.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: