tickmarkr 1.43.0 → 1.45.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.
@@ -2,7 +2,7 @@ import { isAbsolute, join } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { compileSource } from "../../compile/index.js";
4
4
  import { saveGraph, stateDirName } from "../../graph/graph.js";
5
- import { isRunLockLive } from "../../run/lock.js";
5
+ import { acquireRunLock, releaseRunLock } from "../../run/lock.js";
6
6
  export async function compile(argv, cwd = process.cwd()) {
7
7
  const { values, positionals } = parseArgs({
8
8
  args: argv,
@@ -14,11 +14,15 @@ export async function compile(argv, cwd = process.cwd()) {
14
14
  throw new Error("usage: tickmarkr compile <spec-dir-or-md> [--type speckit|prd|gsd|native]");
15
15
  // resolve against the target repo, not the process cwd (the CLI test passes a tmp repo)
16
16
  const g = compileSource(isAbsolute(src) ? src : join(cwd, src), values.type, cwd);
17
- // HARD-01: a compile clobbering a running daemon's graph.json is the same last-write-wins race
18
- // as a second daemon. Read-only check compile never acquires/holds (it's instantaneous).
17
+ // HARD-01 / Sol #3: hold the same link(2) run lock as the daemon around saveGraph so compile
18
+ // cannot swap graph.json under an active run between the daemon's read and act.
19
19
  const stateDir = stateDirName(cwd);
20
- if (isRunLockLive(cwd))
21
- throw new Error(`${stateDir}/graph.lock is held by another tickmarkr run, or is a stale/garbage lock — refusing to overwrite graph.json. If no run is active, run \`tickmarkr unlock\`.`);
22
- saveGraph(cwd, g);
20
+ acquireRunLock(cwd, "compile");
21
+ try {
22
+ saveGraph(cwd, g);
23
+ }
24
+ finally {
25
+ releaseRunLock(cwd);
26
+ }
23
27
  return `compiled ${src} → ${stateDir}/graph.json (${g.tasks.length} tasks, source ${g.spec.source}, hash ${g.spec.hash.slice(0, 12)})`;
24
28
  }
@@ -8,7 +8,11 @@ export async function resume(argv, cwd = process.cwd()) {
8
8
  const runId = argv[0];
9
9
  if (!runId)
10
10
  throw new Error("usage: tickmarkr resume <run-id>");
11
- const s = await runDaemon(cwd, { runId, resume: true, driver: pickDriver(loadConfig(cwd)), narrate: (event) => console.log(formatJournalNarration(event)) });
11
+ // T3: --graph-changed is the operator's audited release of the engagement-identity guard (Sol #2 /
12
+ // Fable F2) — the daemon refuses a mismatched/unbound journal unless this is set, then journals a
13
+ // graph-rehash event naming both hashes. Strip the flag before runId resolution so a bare id still wins.
14
+ const graphChanged = argv.includes("--graph-changed");
15
+ const s = await runDaemon(cwd, { runId, resume: true, graphChanged, driver: pickDriver(loadConfig(cwd)), narrate: (event) => console.log(formatJournalNarration(event)) });
12
16
  const out = `resumed ${s.runId} — ${formatSummary(s)}`;
13
17
  return { out, code: summaryGreen(s) ? 0 : 2 };
14
18
  }
@@ -1,9 +1,10 @@
1
1
  import { BANNER } from "../../brand.js";
2
- import { blockedTasks, loadGraph, pendingTasks } from "../../graph/graph.js";
2
+ import { blockedTasks, graphDefinitionHash, loadGraph, pendingTasks } from "../../graph/graph.js";
3
3
  import { GATE_NAMES } from "../../graph/schema.js";
4
- import { Journal } from "../../run/journal.js";
4
+ import { Journal, engagementComparable } from "../../run/journal.js";
5
5
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
6
6
  const REFRESH_MS = 2000;
7
+ const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
7
8
  // The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
8
9
  // first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
9
10
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -40,6 +41,7 @@ const gateBox = (state, unicode) => {
40
41
  return state === "pass" ? "✓" : state === "fail" ? "✗" : state === "skip" ? "·" : "☐";
41
42
  return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
42
43
  };
44
+ const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
43
45
  const gateStates = (task, events) => {
44
46
  const outcomes = new Map();
45
47
  const start = attemptStartIdx(events, task.id);
@@ -121,18 +123,25 @@ const renderFrame = (cwd) => {
121
123
  let replayed = null;
122
124
  let events = [];
123
125
  const contexts = new Map();
126
+ let comparable = false;
124
127
  if (runId) {
125
128
  const j = Journal.open(cwd, runId);
126
129
  events = j.read();
127
- replayed = j.replayStatuses();
128
- for (const e of events) {
129
- if (e.event === "task-dispatch" && e.taskId) {
130
- const a = e.data.assignment;
131
- if (typeof a.adapter === "string" && typeof a.model === "string")
132
- assignments.set(e.taskId, `${a.adapter}:${a.model}`);
133
- }
134
- if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
135
- contexts.set(e.taskId, e.data.tokens); // last write wins
130
+ // T3 (Sol #2 / Fable F2): the SAME comparator resume uses (engagementComparable) — one decision,
131
+ // two consumers. graphDefinitionHash (compiled task definitions only) survives status/evidence
132
+ // mutation but changes when a task definition changes, so a recompiled graph is detected here too.
133
+ comparable = engagementComparable(events, graphDefinitionHash(g)).comparable;
134
+ if (comparable) {
135
+ replayed = j.replayStatuses();
136
+ for (const e of events) {
137
+ if (e.event === "task-dispatch" && e.taskId) {
138
+ const a = e.data.assignment;
139
+ if (typeof a.adapter === "string" && typeof a.model === "string")
140
+ assignments.set(e.taskId, `${a.adapter}:${a.model}`);
141
+ }
142
+ if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
143
+ contexts.set(e.taskId, e.data.tokens); // last write wins
144
+ }
136
145
  }
137
146
  }
138
147
  }
@@ -148,7 +157,7 @@ const renderFrame = (cwd) => {
148
157
  const label = starved.has(t.id) ? " starved" : waiting.has(t.id) ? " dep-waiting" : "";
149
158
  const channel = assignments.get(t.id) ?? "-";
150
159
  const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
151
- return { t, st, label, assignCol, states: gateStates(t, events) };
160
+ return { t, st, label, assignCol, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
152
161
  });
153
162
  const statusColor = (st) => st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 36;
154
163
  if (!unicode) {
@@ -160,7 +169,7 @@ const renderFrame = (cwd) => {
160
169
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
161
170
  });
162
171
  const header = runId
163
- ? `tickmarkr status${divider}run ${runId}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
172
+ ? `tickmarkr status${divider}run ${runId}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
164
173
  : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
165
174
  const legend = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
166
175
  return [header, legend, ...rows].join("\n");
@@ -181,7 +190,9 @@ const renderFrame = (cwd) => {
181
190
  .replaceAll(" · ", dot);
182
191
  const tally = `${done}/${g.tasks.length} done`;
183
192
  const header = ` ${color("✓", 32, true)} ${color("tickmarkr", 1, true)}${dot}` +
184
- (runId ? `run ${runId}${dot}${live}${dot}` : `no runs yet${dot}`) +
193
+ (runId
194
+ ? `run ${runId}${dot}${!comparable ? `${NOT_COMPARABLE_NOTICE}${dot}` : ""}${live}${dot}`
195
+ : `no runs yet${dot}`) +
185
196
  `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? color(tally, 32, true) : tally}`;
186
197
  const rule = dim("─".repeat(Math.min(width, 100)));
187
198
  const legend = dim(` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
@@ -144,6 +144,15 @@ export function compileNative(file) {
144
144
  if (plainCount > 0) {
145
145
  console.warn(`tickmarkr: ${plainCount} acceptance item${plainCount === 1 ? "" : "s"} in ${file} ${plainCount === 1 ? "is a plain string" : "are plain strings"} — compiled as judge oracle${plainCount === 1 ? "" : "s"}. Prefix with command:/test:/judge: to make the oracle explicit.`);
146
146
  }
147
+ // OBS-51: semicolon-joined judge criteria invite intermittent clause-split verdicts — warn per item.
148
+ for (const draft of drafts) {
149
+ for (const item of draft.acceptance) {
150
+ const text = typeof item === "string" ? item : item.oracle === "judge" ? item.text : null;
151
+ if (text?.includes(";")) {
152
+ console.warn(`tickmarkr: OBS-51: task ${draft.id} judge criterion contains semicolon-joined clauses — split into separate acceptance items: ${JSON.stringify(text)}`);
153
+ }
154
+ }
155
+ }
147
156
  return result;
148
157
  }
149
158
  // Commented native spec written to tickmarkr.spec.md by `tickmarkr init`. Documented via HTML comments (which the
@@ -10,6 +10,8 @@ export interface JudgeVerdict {
10
10
  reason: string;
11
11
  }>;
12
12
  }
13
+ export declare function judgeCriterionId(index: number): string;
14
+ export declare function testFiltered(testCmd: string, name: string): string;
13
15
  export interface AcceptanceGateOpts {
14
16
  testCmd?: string;
15
17
  diffCap?: number;
@@ -1,18 +1,93 @@
1
+ import { z } from "zod";
1
2
  import { channelKey, shq } from "../adapters/types.js";
2
3
  import { DEFAULT_DIFF_CAP } from "../config/config.js";
3
4
  import { renderAcceptanceItem } from "../graph/schema.js";
4
5
  import { sh } from "../run/git.js";
5
6
  import { checkDiffCap, fetchTaskDiff } from "./review.js";
6
- import { extractJson, runLlm } from "./llm.js";
7
+ import { extractVerdictJson, generateVerdictNonce, runLlm, verdictNonceLine } from "./llm.js";
8
+ // Fable F4: acceptance judge shares review's 900s timeout — 300s default killed frontier judges on cap-sized diffs.
9
+ const JUDGE_TIMEOUT_MS = 900_000;
10
+ const JudgeVerdictRowSchema = z.object({
11
+ criterion: z.string(),
12
+ met: z.boolean(),
13
+ reason: z.string(),
14
+ });
15
+ const JudgeVerdictSchema = z.object({
16
+ pass: z.boolean(),
17
+ criteria: z.array(JudgeVerdictRowSchema),
18
+ });
19
+ export function judgeCriterionId(index) {
20
+ return `c${index + 1}`;
21
+ }
22
+ function renderJudgeCriterion(item, index) {
23
+ return `[${judgeCriterionId(index)}] ${renderAcceptanceItem(item)}`;
24
+ }
25
+ function checkJudgeVerdict(raw, expectedIds) {
26
+ const parsed = JudgeVerdictSchema.safeParse(raw);
27
+ if (!parsed.success) {
28
+ return {
29
+ verdict: { pass: false, criteria: [] },
30
+ inconsistencies: ["judge verdict inconsistent: malformed verdict shape"],
31
+ };
32
+ }
33
+ const v = parsed.data;
34
+ const inconsistencies = [];
35
+ const counts = new Map();
36
+ for (const row of v.criteria) {
37
+ counts.set(row.criterion, (counts.get(row.criterion) ?? 0) + 1);
38
+ }
39
+ for (const [id, n] of counts) {
40
+ if (n > 1)
41
+ inconsistencies.push(`judge verdict inconsistent: duplicate criterion id ${id}`);
42
+ if (!expectedIds.includes(id))
43
+ inconsistencies.push(`judge verdict inconsistent: unknown criterion id ${id}`);
44
+ }
45
+ for (const id of expectedIds) {
46
+ if (!counts.has(id))
47
+ inconsistencies.push(`judge verdict inconsistent: missing criterion id ${id}`);
48
+ }
49
+ for (const row of v.criteria) {
50
+ if (!row.met && v.pass) {
51
+ inconsistencies.push(`judge verdict inconsistent: pass=true contradicts unmet criterion ${row.criterion}`);
52
+ }
53
+ }
54
+ if (!v.pass && v.criteria.every((row) => row.met) && inconsistencies.length === 0 && v.criteria.length === expectedIds.length) {
55
+ inconsistencies.push("judge verdict inconsistent: pass=false contradicts all criteria met");
56
+ }
57
+ return { verdict: v, inconsistencies };
58
+ }
7
59
  const isCommand = (a) => typeof a === "object" && a.oracle === "command";
8
60
  const isTest = (a) => typeof a === "object" && a.oracle === "test";
9
61
  const isJudge = (a) => typeof a === "string" || (typeof a === "object" && a.oracle === "judge");
10
62
  // npm/yarn/pnpm/npx script wrappers need `--` to forward -t to the underlying vitest/jest runner; a bare
11
63
  // runner (vitest/jest) takes -t directly. -t is the shared testNamePattern shorthand both honor.
12
- function testFiltered(testCmd, name) {
13
- const fwd = /^\s*(npm|yarn|pnpm|npx)\b/.test(testCmd) ? "-- " : "";
64
+ // OBS-55: when the base command already contains `--`, append -t after forwarded args — a second `--`
65
+ // makes vitest treat -t as a positional file filter and the name filter is dropped.
66
+ export function testFiltered(testCmd, name) {
67
+ const wrapped = /^\s*(npm|yarn|pnpm|npx)\b/.test(testCmd);
68
+ if (wrapped && /\s--\s/.test(testCmd))
69
+ return `${testCmd} -t ${shq(name)}`;
70
+ const fwd = wrapped ? "-- " : "";
14
71
  return `${testCmd} ${fwd}-t ${shq(name)}`;
15
72
  }
73
+ // vitest/jest summary: "Tests N passed | M skipped (T)" — count passed+failed as actually ran.
74
+ function testsRan(output) {
75
+ const lines = output.replace(/\x1b\[[\d;#]*[A-Za-z]/g, "").split("\n");
76
+ for (let i = lines.length - 1; i >= 0; i--) {
77
+ const line = lines[i].trim();
78
+ const m = line.match(/^Tests\s+(.+?)\s*\(\d+\)\s*$/);
79
+ if (!m)
80
+ continue;
81
+ let ran = 0;
82
+ for (const chunk of m[1].split("|").map((s) => s.trim())) {
83
+ const n = chunk.match(/^(\d+)\s+(passed|failed)\b/);
84
+ if (n)
85
+ ran += Number(n[1]);
86
+ }
87
+ return ran;
88
+ }
89
+ return null;
90
+ }
16
91
  // last few non-empty output lines, for context on a failing oracle (capped so details stay readable)
17
92
  function tail(out, n = 8) {
18
93
  const t = out.trim();
@@ -39,10 +114,17 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
39
114
  details: `oracle failed: test "${a.test}" — no test command configured to run it (failing closed)` };
40
115
  }
41
116
  const r = await sh(testFiltered(opts.testCmd, a.test), worktree);
117
+ const out = (r.stderr || "") + "\n" + (r.stdout || "");
42
118
  if (r.code !== 0) {
43
119
  return { gate: "acceptance", pass: false,
44
120
  details: `oracle failed: test "${a.test}" (exit ${r.code})${tail(r.stderr || r.stdout)}` };
45
121
  }
122
+ // OBS-55: exit 0 alone is vacuous when the name filter matched zero tests — fail closed.
123
+ const ran = testsRan(out);
124
+ if (ran === null || ran < 1) {
125
+ return { gate: "acceptance", pass: false,
126
+ details: `oracle failed: test "${a.test}" — name filter matched zero tests (filter: ${a.test})${tail(out)}` };
127
+ }
46
128
  passedDet.push(`✓ test: ${a.test} (exit 0)`);
47
129
  }
48
130
  }
@@ -63,6 +145,8 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
63
145
  const capFail = checkDiffCap("acceptance", forCap.length, diffCap, warn + detBlock);
64
146
  if (capFail)
65
147
  return capFail;
148
+ const expectedIds = judgeItems.map((_, index) => judgeCriterionId(index));
149
+ const nonce = generateVerdictNonce();
66
150
  const prompt = `TICKMARKR-JUDGE
67
151
  You are a strict acceptance judge. Decide whether the diff satisfies EVERY acceptance criterion.
68
152
  Judge only what the diff proves — plausible-but-wrong must fail. Do not award partial credit.
@@ -72,19 +156,22 @@ Deterministic command/test oracles have already passed mechanically; judge ONLY
72
156
  Goal: ${task.goal}
73
157
 
74
158
  ## Acceptance criteria (judge)
75
- ${judgeItems.map((a) => `- ${renderAcceptanceItem(a)}`).join("\n")}
159
+ ${judgeItems.map((a, index) => `- ${renderJudgeCriterion(a, index)}`).join("\n")}
76
160
 
77
161
  ## Diff (vs base)
78
162
  \`\`\`diff
79
163
  ${diff}
80
164
  \`\`\`
81
165
 
166
+ ${verdictNonceLine(nonce)}
167
+
82
168
  Respond with ONLY this JSON (no prose before or after):
83
- {"pass": true|false, "criteria": [{"criterion": "...", "met": true|false, "reason": "..."}]}
169
+ {"nonce": "${nonce}", "pass": true|false, "criteria": [{"criterion": "c1", "met": true|false, "reason": "..."}]}
170
+ Each criteria[].criterion MUST be the stable id from the rubric (c1, c2, ...) exactly once.
84
171
  `;
85
- const raw = await runLlm(judge.adapter, judge.model, prompt, worktree, via);
86
- const v = extractJson(raw);
87
- if (!v || typeof v.pass !== "boolean" || !Array.isArray(v.criteria)) {
172
+ const raw = await runLlm(judge.adapter, judge.model, prompt, worktree, via, JUDGE_TIMEOUT_MS);
173
+ const extracted = extractVerdictJson(raw, nonce);
174
+ if (!extracted) {
88
175
  // GATE-09: structured meta names the flaked judge channel (mirrors review.ts:99 meta precedent) so
89
176
  // run-gates can retry the judge on a failover channel without string-matching details (D-03).
90
177
  // The parsed-verdict paths below are untouched — the flake signal is exactly extractJson→null here.
@@ -92,19 +179,9 @@ Respond with ONLY this JSON (no prose before or after):
92
179
  details: warn + detBlock + "judge output unparseable — failing closed",
93
180
  meta: { unparseable: true, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
94
181
  }
95
- const inconsistencies = [];
96
- if (v.criteria.length !== judgeItems.length) {
97
- inconsistencies.push(`judge verdict inconsistent: criteria count mismatch expected ${judgeItems.length}, received ${v.criteria.length}`);
98
- }
99
- v.criteria.forEach((c, i) => {
100
- if (!c || typeof c !== "object" || c.met !== true) {
101
- inconsistencies.push(`judge verdict inconsistent: criteria[${i}].met must be true`);
102
- }
103
- });
104
- const pass = v.pass === true && inconsistencies.length === 0;
105
- const lines = v.criteria.map((c, i) => c && typeof c === "object"
106
- ? `${c.met ? "✓" : "✗"} ${c.criterion}: ${c.reason}`
107
- : `✗ criteria[${i}]: invalid criterion`);
182
+ const { verdict: v, inconsistencies } = checkJudgeVerdict(extracted, expectedIds);
183
+ const pass = v.pass === true && inconsistencies.length === 0 && v.criteria.every((row) => row.met);
184
+ const lines = v.criteria.map((row) => `${row.met ? "✓" : "✗"} ${row.criterion}: ${row.reason}`);
108
185
  if (!v.pass)
109
186
  lines.push("judge verdict pass=false");
110
187
  lines.push(...inconsistencies);
@@ -1,6 +1,12 @@
1
1
  import type { WorkerAdapter } from "../adapters/types.js";
2
2
  import { type ExecutorDriver, type Slot } from "../drivers/types.js";
3
3
  export declare const GATE_PANE_SEP = " \u00B7 ";
4
+ /** Fable F3: per-call nonce echoed in verdict JSON and gate exit markers. */
5
+ export declare function generateVerdictNonce(): string;
6
+ export declare function verdictNonceLine(nonce: string): string;
7
+ export declare function extractPromptNonce(prompt: string): string | null;
8
+ export declare function gateExitTrailer(nonce: string): string;
9
+ export declare function augmentFakeVerdictOutput(adapter: WorkerAdapter, out: string, nonce: string): string;
4
10
  export type GatePaneRole = "judge" | "review" | "consult";
5
11
  /** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
6
12
  export declare function gatePaneName(role: GatePaneRole, taskId: string, suffix?: string): string;
@@ -23,3 +29,5 @@ export declare function runHeadless(adapter: WorkerAdapter, model: string, promp
23
29
  export declare function runViaDriver(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via: LlmVia, timeoutMs?: number): Promise<string>;
24
30
  export declare function runLlm(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via?: LlmVia, timeoutMs?: number): Promise<string>;
25
31
  export declare function extractJson<T>(raw: string): T | null;
32
+ /** Fable F3: verdict JSON must echo the call nonce — skip unbound or mismatched objects. */
33
+ export declare function extractVerdictJson<T>(raw: string, nonce: string): T | null;
package/dist/gates/llm.js CHANGED
@@ -1,10 +1,35 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { mkdtempSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { formatOwnedName, parseOwnedName } from "../drivers/types.js";
5
- import { bannerShell, paneDispatchCommand, paneDispatchScript } from "../brand.js";
6
+ import { bannerShell, paneDispatchCommand } from "../brand.js";
6
7
  import { sh } from "../run/git.js";
7
8
  export const GATE_PANE_SEP = " · ";
9
+ /** Fable F3: per-call nonce echoed in verdict JSON and gate exit markers. */
10
+ export function generateVerdictNonce() {
11
+ return randomBytes(4).toString("hex");
12
+ }
13
+ export function verdictNonceLine(nonce) {
14
+ return `VERDICT_NONCE: ${nonce}`;
15
+ }
16
+ export function extractPromptNonce(prompt) {
17
+ return /VERDICT_NONCE:\s*([0-9a-f]+)/i.exec(prompt)?.[1] ?? null;
18
+ }
19
+ export function gateExitTrailer(nonce) {
20
+ return `printf '\\nTICKMARKR_''EXIT_${nonce}:%s\\n' $?`;
21
+ }
22
+ // ponytail: fake adapter serves static verdict JSON without nonce; append a bound copy for zero-token tests.
23
+ export function augmentFakeVerdictOutput(adapter, out, nonce) {
24
+ if (adapter.id !== "fake")
25
+ return out;
26
+ const obj = extractJson(out);
27
+ if (!obj || typeof obj !== "object" || obj.nonce === nonce)
28
+ return out;
29
+ if (typeof obj.nonce === "string")
30
+ return out;
31
+ return `${out}\n${JSON.stringify({ ...obj, nonce })}`;
32
+ }
8
33
  /** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
9
34
  export function gatePaneName(role, taskId, suffix = "") {
10
35
  return `${role}${GATE_PANE_SEP}${taskId}${suffix}`;
@@ -33,7 +58,11 @@ export async function runHeadless(adapter, model, prompt, cwd, timeoutMs = 30000
33
58
  const pf = join(mkdtempSync(join(tmpdir(), "tickmarkr-llm-")), "prompt.md");
34
59
  writeFileSync(pf, prompt);
35
60
  const r = await sh(adapter.headlessCommand(pf, model), cwd, timeoutMs);
36
- return r.stdout + "\n" + r.stderr;
61
+ const nonce = extractPromptNonce(prompt);
62
+ let out = r.stdout + "\n" + r.stderr;
63
+ if (nonce)
64
+ out = augmentFakeVerdictOutput(adapter, out, nonce);
65
+ return out;
37
66
  }
38
67
  // v1.1 default path: the same headless CLI call, but dispatched through the driver
39
68
  // as a visible named agent (herdr pane), with the quote-split completion wrapper.
@@ -43,17 +72,23 @@ export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs =
43
72
  writeFileSync(pf, prompt);
44
73
  const scriptPath = join(dir, "dispatch.sh");
45
74
  // OBS-50: bootstrap in a script beside the prompt — pane sees one short bash line + banner, not the raw inline command
46
- writeFileSync(scriptPath, paneDispatchScript([bannerShell(), adapter.headlessCommand(pf, model)]));
75
+ const nonce = extractPromptNonce(prompt) ?? generateVerdictNonce();
76
+ writeFileSync(scriptPath, [
77
+ "export BASH_SILENCE_DEPRECATION_WARNING=1",
78
+ bannerShell(),
79
+ adapter.headlessCommand(pf, model),
80
+ gateExitTrailer(nonce),
81
+ ].join("\n"));
47
82
  const slot = await via.driver.slot(cwd, rolePaneNameFromPrompt(prompt, via.name), via.label ? { label: via.label } : undefined);
48
83
  via.onSlot?.(slot);
49
84
  await via.driver.run(slot, paneDispatchCommand(scriptPath));
50
- // wait for the digit-suffixed marker (a real $? exit code), never a bare "TICKMARKR_EXIT:" that a
51
- // self-referential diff under review merely DISPLAYS — same guard the worker path uses (daemon.ts:193).
52
- // Without it, a judge/review pane echoing tickmarkr's own source false-completes before its verdict lands.
53
- await via.driver.waitOutput(slot, "TICKMARKR_EXIT:\\d", timeoutMs, { regex: true });
54
- const out = await via.driver.read(slot, 400);
85
+ // nonce-suffixed exit only: a displayed bare "TICKMARKR_EXIT:" or another call's marker must not
86
+ // false-complete — same guard the worker path uses (daemon.ts:330-331).
87
+ await via.driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, timeoutMs, { regex: true });
88
+ let out = await via.driver.read(slot, 400);
55
89
  if (!via.keep)
56
90
  await via.driver.close(slot);
91
+ out = augmentFakeVerdictOutput(adapter, out, nonce);
57
92
  return out;
58
93
  }
59
94
  export function runLlm(adapter, model, prompt, cwd, via, timeoutMs = 300000) {
@@ -104,3 +139,52 @@ export function extractJson(raw) {
104
139
  }
105
140
  return null;
106
141
  }
142
+ /** Fable F3: verdict JSON must echo the call nonce — skip unbound or mismatched objects. */
143
+ export function extractVerdictJson(raw, nonce) {
144
+ const fenced = [...raw.matchAll(/```json\s*\n([\s\S]*?)```/g)];
145
+ for (let fi = fenced.length - 1; fi >= 0; fi--) {
146
+ try {
147
+ const v = JSON.parse(fenced[fi][1]);
148
+ if (v && typeof v === "object" && v.nonce === nonce) {
149
+ const { nonce: _n, ...rest } = v;
150
+ return rest;
151
+ }
152
+ }
153
+ catch {
154
+ /* fall through */
155
+ }
156
+ }
157
+ let pos = raw.length - 1;
158
+ while (pos >= 0) {
159
+ const end = raw.lastIndexOf("}", pos);
160
+ if (end === -1)
161
+ return null;
162
+ let depth = 1;
163
+ let stepped = false;
164
+ for (let i = end - 1; i >= 0; i--) {
165
+ if (raw[i] === "}")
166
+ depth++;
167
+ else if (raw[i] === "{") {
168
+ depth--;
169
+ if (depth === 0) {
170
+ stepped = true;
171
+ try {
172
+ const v = JSON.parse(raw.slice(i, end + 1));
173
+ if (v && typeof v === "object" && v.nonce === nonce) {
174
+ const { nonce: _n, ...rest } = v;
175
+ return rest;
176
+ }
177
+ }
178
+ catch {
179
+ /* keep scanning */
180
+ }
181
+ pos = i - 1;
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ if (!stepped)
187
+ return null;
188
+ }
189
+ return null;
190
+ }
@@ -4,7 +4,7 @@ import { renderAcceptanceItem } from "../graph/schema.js";
4
4
  import { getAdapter } from "../adapters/registry.js";
5
5
  import { shOk } from "../run/git.js";
6
6
  import { marginalCostRank } from "../route/router.js";
7
- import { extractJson, runLlm } from "./llm.js";
7
+ import { extractVerdictJson, generateVerdictNonce, runLlm, verdictNonceLine } from "./llm.js";
8
8
  // OBS-48: cap on zero-context diff bytes (git diff -U0), not context-padded full diff — scattered
9
9
  // one-line hunks no longer trip at ~370 diff-bytes per changed line. Full diff still goes to the judge.
10
10
  const DIFF_CAP_REMEDY = "split the task, or raise gates.diffCap";
@@ -68,6 +68,7 @@ export async function reviewGate(task, worktree, baseRef, author, channels, adap
68
68
  const capFail = checkDiffCap("review", forCap.length, diffCap);
69
69
  if (capFail)
70
70
  return capFail;
71
+ const nonce = generateVerdictNonce();
71
72
  const prompt = `TICKMARKR-REVIEW
72
73
  You are a skeptical cross-vendor code reviewer. Another agent (vendor: ${author.adapter}) authored this diff.
73
74
  Look for correctness bugs, security issues, and acceptance-criteria gaps. Approve only if you would merge it.
@@ -81,8 +82,10 @@ ${task.acceptance.map((a) => `- ${renderAcceptanceItem(a)}`).join("\n")}
81
82
  ${diff}
82
83
  \`\`\`
83
84
 
85
+ ${verdictNonceLine(nonce)}
86
+
84
87
  Respond with ONLY this JSON:
85
- {"approve": true|false, "issues": ["..."]}
88
+ {"nonce": "${nonce}", "approve": true|false, "issues": ["..."]}
86
89
  `;
87
90
  const raw = await runLlm(getAdapter(reviewer.adapter, adapters), reviewer.model, prompt, worktree, via ? { driver: via.driver, keep: via.keep, onSlot: via.onSlot, name: via.nameFor("review", reviewer.adapter), label: via.labelFor("review") } : undefined,
88
91
  // frontier reviewers routinely need >5min on a configured-cap-sized diff, and `claude -p` buffers all
@@ -91,7 +94,7 @@ Respond with ONLY this JSON:
91
94
  // (run-20260709-104447 P87-09). ponytail: literal 15min; make it cfg.review.timeoutMs if a
92
95
  // second knob-turner appears.
93
96
  900_000);
94
- const v = extractJson(raw);
97
+ const v = extractVerdictJson(raw, nonce);
95
98
  if (!v || typeof v.approve !== "boolean" || !Array.isArray(v.issues)) {
96
99
  return {
97
100
  gate: "review",
@@ -1,6 +1,7 @@
1
1
  import { type RunGraph, type Task, type TaskStatus } from "./schema.js";
2
2
  export declare function stateDirName(_repoRoot: string): string;
3
3
  export declare function graphPath(repoRoot: string): string;
4
+ export declare function graphDefinitionHash(g: RunGraph): string;
4
5
  export declare function tickmarkrDir(repoRoot: string): string;
5
6
  export declare function loadGraph(repoRoot: string): RunGraph;
6
7
  export declare function saveGraph(repoRoot: string, g: RunGraph): void;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { validateGraph } from "./schema.js";
@@ -7,6 +8,17 @@ export function stateDirName(_repoRoot) {
7
8
  export function graphPath(repoRoot) {
8
9
  return join(repoRoot, stateDirName(repoRoot), "graph.json");
9
10
  }
11
+ // T3 (Sol #2 / Fable F2): ONE canonical engagement identity over COMPILED TASK DEFINITIONS only.
12
+ // status/evidence are runtime-mutated (the daemon flips status, accumulates evidence every attempt) so
13
+ // they are excluded — the identity survives a status flip or evidence growth but changes the instant a
14
+ // task definition changes (goal/acceptance/deps/gates/etc.). Shared by status AND resume through the
15
+ // single comparator in journal.ts (engagementComparable) so the journal↔graph join is decided once.
16
+ // ponytail: sha256 truncated to 16 hex — stable, grep-friendly; promote to full digest only if a
17
+ // collision ever bites (engagement ids are not a trust boundary, collisions just force a re-run).
18
+ export function graphDefinitionHash(g) {
19
+ const definitions = g.tasks.map(({ status: _status, evidence: _evidence, ...def }) => def);
20
+ return createHash("sha256").update(JSON.stringify({ version: g.version, spec: g.spec, tasks: definitions })).digest("hex").slice(0, 16);
21
+ }
10
22
  export function tickmarkrDir(repoRoot) {
11
23
  const dir = join(repoRoot, stateDirName(repoRoot));
12
24
  mkdirSync(dir, { recursive: true });
@@ -18,7 +18,7 @@ export interface Dossier {
18
18
  diff: string;
19
19
  gates: GateResult[];
20
20
  }
21
- export declare function buildDossierPrompt(d: Dossier): string;
21
+ export declare function buildDossierPrompt(d: Dossier, nonce: string): string;
22
22
  export declare function consult(d: Dossier, cfg: TickmarkrConfig, adapters: WorkerAdapter[], driver: ExecutorDriver, cwd: string, runDir: string, opts?: {
23
23
  keep?: boolean;
24
24
  onSlot?: (slot: Slot) => void;
@@ -1,8 +1,8 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAdapter } from "../adapters/registry.js";
4
- import { bannerShell, paneDispatchCommand, paneDispatchScript } from "../brand.js";
5
- import { extractJson, gatePaneName } from "../gates/llm.js";
4
+ import { bannerShell, paneDispatchCommand } from "../brand.js";
5
+ import { augmentFakeVerdictOutput, extractVerdictJson, gateExitTrailer, gatePaneName, generateVerdictNonce, verdictNonceLine } from "../gates/llm.js";
6
6
  import { sh } from "./git.js";
7
7
  const MAX_RETRY_GUIDANCE_LINES = 10;
8
8
  function guidanceParts(text) {
@@ -20,7 +20,7 @@ export function renderRetryGuidance(v) {
20
20
  return lines.slice(0, MAX_RETRY_GUIDANCE_LINES).map((l) => `- ${l}`).join("\n");
21
21
  }
22
22
  const ACTIONS = ["retry", "reroute", "decompose", "human"];
23
- export function buildDossierPrompt(d) {
23
+ export function buildDossierPrompt(d, nonce) {
24
24
  return `TICKMARKR-CONSULT
25
25
  You are a senior engineering consult for the tickmarkr orchestrator. A worker task hit trouble.
26
26
  Read the dossier and return a verdict. Be decisive; cost is the lowest priority, quality the highest.
@@ -47,8 +47,10 @@ channel of that adapter for this task. Use it for environmental failures ("the C
47
47
  trust dialog, broken install) — not when a single model produced bad code. Omit for model-level
48
48
  reroutes so other models of the same adapter remain eligible.
49
49
 
50
+ ${verdictNonceLine(nonce)}
51
+
50
52
  Respond with ONLY this JSON:
51
- {"action": "retry" | "reroute" | "decompose" | "human", "reason": "why this action", "guidance": "imperative steps for the worker (newline-separated ok)", "excludeAdapter"?: "<adapter-id>"}
53
+ {"nonce": "${nonce}", "action": "retry" | "reroute" | "decompose" | "human", "reason": "why this action", "guidance": "imperative steps for the worker (newline-separated ok)", "excludeAdapter"?: "<adapter-id>"}
52
54
  `;
53
55
  }
54
56
  let consultSeq = 0;
@@ -57,15 +59,17 @@ export async function consult(d, cfg, adapters, driver, cwd, runDir,
57
59
  // via SlotOpts.owned; without it the legacy gatePaneName shape survives (non-daemon callers/tests).
58
60
  opts = {}) {
59
61
  const n = ++consultSeq;
62
+ const nonce = generateVerdictNonce();
60
63
  const dir = join(runDir, "consults");
61
64
  mkdirSync(dir, { recursive: true });
62
65
  const promptFile = join(dir, `${d.taskId}-${n}.md`);
63
- writeFileSync(promptFile, buildDossierPrompt(d));
66
+ writeFileSync(promptFile, buildDossierPrompt(d, nonce));
64
67
  const adapter = getAdapter(cfg.consult.adapter, adapters);
65
68
  let out;
66
69
  if (cfg.visibility.llm === "headless") {
67
70
  const r = await sh(adapter.headlessCommand(promptFile, cfg.consult.model), cwd, cfg.consult.stallMinutes * 60_000);
68
71
  out = r.stdout + r.stderr;
72
+ out = augmentFakeVerdictOutput(adapter, out, nonce);
69
73
  }
70
74
  else {
71
75
  // T8: role-first pane name for fleet visibility (consult · T2); consultSeq stays on the dossier artifact only
@@ -76,16 +80,21 @@ opts = {}) {
76
80
  opts.onSlot?.(slot);
77
81
  const scriptPath = join(dir, `${d.taskId}-${n}.sh`);
78
82
  // OBS-50: visible consult panes get the brand banner; headless path above stays banner-free (machine-parsed stdout)
79
- writeFileSync(scriptPath, paneDispatchScript([bannerShell(), adapter.headlessCommand(promptFile, cfg.consult.model)]));
83
+ writeFileSync(scriptPath, [
84
+ "export BASH_SILENCE_DEPRECATION_WARNING=1",
85
+ bannerShell(),
86
+ adapter.headlessCommand(promptFile, cfg.consult.model),
87
+ gateExitTrailer(nonce),
88
+ ].join("\n"));
80
89
  await driver.run(slot, paneDispatchCommand(scriptPath));
81
- // digit-suffixed marker only: a dossier quoting tickmarkr's own "TICKMARKR_EXIT:" literal must not
82
- // false-complete the consult pane before its verdict (same guard as daemon.ts:193 / llm.ts).
83
- await driver.waitOutput(slot, "TICKMARKR_EXIT:\\d", cfg.consult.stallMinutes * 60_000, { regex: true });
90
+ // nonce-suffixed exit only: a dossier quoting tickmarkr's own exit literal must not false-complete.
91
+ await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, cfg.consult.stallMinutes * 60_000, { regex: true });
84
92
  out = await driver.read(slot, 300);
85
93
  if (!opts.keep)
86
94
  await driver.close(slot);
95
+ out = augmentFakeVerdictOutput(adapter, out, nonce);
87
96
  }
88
- const v = extractJson(out);
97
+ const v = extractVerdictJson(out, nonce);
89
98
  if (!v || !ACTIONS.includes(v.action)) {
90
99
  return { action: "human", notes: "consult verdict unparseable — failing safe to human" };
91
100
  }
@@ -4,6 +4,7 @@ import { type JournalEvent } from "./journal.js";
4
4
  export interface RunOptions {
5
5
  runId?: string;
6
6
  resume?: boolean;
7
+ graphChanged?: boolean;
7
8
  concurrency?: number;
8
9
  driver?: ExecutorDriver;
9
10
  adapters?: WorkerAdapter[];
@@ -9,10 +9,10 @@ import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js
9
9
  import { formatOwnedName } from "../drivers/types.js";
10
10
  import { captureBaseline, detectGateCommands } from "../gates/baseline.js";
11
11
  import { runGates } from "../gates/run-gates.js";
12
- import { addEvidence, attributeBlocked, blockedTasks, getTask, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus } from "../graph/graph.js";
12
+ import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus } from "../graph/graph.js";
13
13
  import { consult, renderRetryGuidance } from "./consult.js";
14
14
  import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, WORKTREE_LAYOUT_CONTRACT } from "./git.js";
15
- import { Journal, loadRoutingProfile, newRunId } from "./journal.js";
15
+ import { engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
16
16
  import { acquireRunLock, releaseRunLock } from "./lock.js";
17
17
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
18
18
  import { nextChannel, route } from "../route/router.js";
@@ -74,6 +74,24 @@ export async function runDaemon(repoRoot, opts = {}) {
74
74
  if (!start)
75
75
  throw new Error(`journal for ${runId} has no run-start event`);
76
76
  baseRef = start.data.baseRef;
77
+ // T3 (Sol #2 / Fable F2): refuse to replay this journal's task states onto a graph it does not
78
+ // belong to — overlapping ids would inherit foreign done/human/approval state, missing ids throw.
79
+ // The SAME comparator status uses (engagementComparable); one decision, two consumers. Fail closed:
80
+ // no resume path silently accepts a mismatched or unbound journal. --graph-changed is the operator's
81
+ // audited release for the stop-amend-resume workflow, journaling a graph-rehash naming both hashes.
82
+ const loadedHash = graphDefinitionHash(graph);
83
+ const cmp = engagementComparable(journal.read(), loadedHash);
84
+ if (!cmp.comparable) {
85
+ if (!opts.graphChanged) {
86
+ throw new Error(cmp.reason === "unbound"
87
+ ? `refusing to resume ${runId}: journal has no recorded graph definition hash (older tickmarkr) — pass --graph-changed to override`
88
+ : `refusing to resume ${runId}: graph changed since this run (recorded ${cmp.recorded} ≠ loaded ${loadedHash}) — pass --graph-changed to override`);
89
+ }
90
+ journal.append("graph-rehash", undefined, {
91
+ from: cmp.reason === "mismatch" ? cmp.recorded : null,
92
+ to: loadedHash,
93
+ });
94
+ }
77
95
  baseline = JSON.parse(readFileSync(join(journal.dir, "baseline.json"), "utf8"));
78
96
  for (const [id, st] of journal.replayStatuses()) {
79
97
  // operator release: a graph.json edit back to "pending" beats a replayed human/failed park (locked decision 12)
@@ -87,7 +105,7 @@ export async function runDaemon(repoRoot, opts = {}) {
87
105
  baseRef = await gitHead(repoRoot);
88
106
  baseline = await captureBaseline(repoRoot, commands);
89
107
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
90
- journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch }); // pid: v1.13 (VIS-11) liveness
108
+ journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph) }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness
91
109
  }
92
110
  // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
93
111
  // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
@@ -62,7 +62,21 @@ export declare const TelemetryRowSchema: z.ZodObject<{
62
62
  }>>;
63
63
  }, z.core.$strip>;
64
64
  export type TelemetryRow = z.infer<typeof TelemetryRowSchema>;
65
+ export declare function recordedGraphDefinitionHash(events: JournalEvent[]): string | undefined;
66
+ export type EngagementCompare = {
67
+ comparable: true;
68
+ recorded: string;
69
+ } | {
70
+ comparable: false;
71
+ reason: "mismatch";
72
+ recorded: string;
73
+ } | {
74
+ comparable: false;
75
+ reason: "unbound";
76
+ };
77
+ export declare function engagementComparable(events: JournalEvent[], loadedHash: string): EngagementCompare;
65
78
  export declare function newRunId(now?: Date): string;
79
+ export declare function parseRunId(runId: string): string;
66
80
  export declare function readAllTelemetry(repoRoot: string, lastK: number, opts?: {
67
81
  after?: string;
68
82
  }): (TelemetryRow & {
@@ -72,10 +72,47 @@ export const TelemetryRowSchema = z.object({
72
72
  // v1.29 additive: mode of the attempt represented by this row. Absent on old telemetry.
73
73
  retryMode: z.enum(RETRY_MODES).optional(),
74
74
  });
75
+ // T3 (Sol #2 / Fable F2): one canonical engagement identity, shared by status AND resume. The run-start
76
+ // event records graphDefinitionHash (over compiled task definitions only — see graph.graphDefinitionHash);
77
+ // this is the single field both consumers read, and the single comparator below is the single place the
78
+ // journal↔graph join is decided. unbound (no recorded definition hash, e.g. a pre-v1.44 journal) and
79
+ // mismatch are both not-comparable — status renders the notice either way; resume refuses either way and
80
+ // distinguishes the reason only for its message and the --graph-changed release event.
81
+ export function recordedGraphDefinitionHash(events) {
82
+ for (const e of events) {
83
+ if (e.event === "run-start" && typeof e.data.graphDefinitionHash === "string")
84
+ return e.data.graphDefinitionHash;
85
+ }
86
+ return undefined;
87
+ }
88
+ // THE shared comparator (criterion: status and resume decide through one comparator). status reads
89
+ // .comparable; resume reads .comparable plus .reason/.recorded for its refusal message and the release.
90
+ export function engagementComparable(events, loadedHash) {
91
+ const recorded = recordedGraphDefinitionHash(events);
92
+ if (recorded === undefined)
93
+ return { comparable: false, reason: "unbound" };
94
+ return recorded === loadedHash ? { comparable: true, recorded } : { comparable: false, reason: "mismatch", recorded };
95
+ }
75
96
  export function newRunId(now = new Date()) {
76
97
  const p = (n, w = 2) => String(n).padStart(w, "0");
77
98
  return `run-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
78
99
  }
100
+ // Sol #4: one strict parser for every journal open/create path — generated run-… ids plus test
101
+ // suffix chars only; forbid path separators, dot-segments, and empty ids.
102
+ export function parseRunId(runId) {
103
+ const id = runId.trim();
104
+ if (!id)
105
+ throw new Error("invalid run id: empty");
106
+ if (id.includes("/") || id.includes("\\"))
107
+ throw new Error(`invalid run id: ${runId}`);
108
+ for (const seg of id.split(/[/\\]/)) {
109
+ if (seg === "." || seg === "..")
110
+ throw new Error(`invalid run id: ${runId}`);
111
+ }
112
+ if (!/^run-[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id))
113
+ throw new Error(`invalid run id: ${runId}`);
114
+ return id;
115
+ }
79
116
  const runsDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "runs");
80
117
  // One JSONL reader for every append-only log: skip blanks, drop any line that
81
118
  // won't parse, keeping everything before it intact.
@@ -151,15 +188,19 @@ export class Journal {
151
188
  this.narrate = narrate;
152
189
  }
153
190
  static create(repoRoot, runId, narrate) {
154
- const dir = join(runsDir(repoRoot), runId);
191
+ const id = parseRunId(runId);
192
+ const dir = join(runsDir(repoRoot), id);
193
+ if (existsSync(join(dir, "journal.jsonl")))
194
+ throw new Error(`journal already exists for ${id}`);
155
195
  mkdirSync(dir, { recursive: true });
156
- return new Journal(dir, runId, narrate);
196
+ return new Journal(dir, id, narrate);
157
197
  }
158
198
  static open(repoRoot, runId, narrate) {
159
- const dir = join(runsDir(repoRoot), runId);
199
+ const id = parseRunId(runId);
200
+ const dir = join(runsDir(repoRoot), id);
160
201
  if (!existsSync(join(dir, "journal.jsonl")))
161
- throw new Error(`no journal for ${runId} at ${dir}`);
162
- return new Journal(dir, runId, narrate);
202
+ throw new Error(`no journal for ${id} at ${dir}`);
203
+ return new Journal(dir, id, narrate);
163
204
  }
164
205
  // withJournal: journal.jsonl appears at first append, after Journal.create mkdirs — a caller that
165
206
  // will Journal.open the result (status, report) must fall back to the newest run that is actually
package/dist/run/lock.js CHANGED
@@ -141,9 +141,9 @@ export function releaseRunLock(repoRoot) {
141
141
  unlinkIfOurs(lockPath(repoRoot)); // never delete a reclaiming successor's lock — only ours
142
142
  heldPath = undefined;
143
143
  }
144
- // Read-only check for compile: true iff a lock exists that the decision table would REFUSE on
145
- // (alive, EPERM, or ANY garbage). A provably-dead holder (ESRCH) reads not-live compile proceeds
146
- // in step with resume (LOCK-02/OBS-05). Never mutates the lock.
144
+ // Read-only predicate: true iff a lock exists that the decision table would REFUSE on (alive,
145
+ // EPERM, or ANY garbage). A provably-dead holder (ESRCH) reads not-live (LOCK-02/OBS-05). Never
146
+ // mutates the lock. compile now acquires via acquireRunLock; this remains for drift oracles/tests.
147
147
  export function isRunLockLive(repoRoot) {
148
148
  try {
149
149
  return shouldRefuse(inspect(lockPath(repoRoot)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.43.0",
3
+ "version": "1.45.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",