tickmarkr 1.42.0 → 1.44.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.
package/dist/brand.d.ts CHANGED
@@ -2,3 +2,8 @@ export declare const BANNER: string;
2
2
  /** ANSI-stripped, trailing-space-trimmed twin of BANNER — README hero and other plain surfaces. */
3
3
  export declare const PLAIN_BANNER: string;
4
4
  export declare function bannerShell(): string;
5
+ export declare const TICKMARKR_EXIT_TRAILER = "printf '\\nTICKMARKR_''EXIT:%s\\n' $?";
6
+ /** OBS-50: visible-pane bootstrap script — banner + agent command + byte-identical exit trailer. */
7
+ export declare function paneDispatchScript(body: string[]): string;
8
+ /** OBS-50: one short herdr pane-run line; bootstrap lives in the script file beside the prompt. */
9
+ export declare function paneDispatchCommand(scriptPath: string): string;
package/dist/brand.js CHANGED
@@ -17,3 +17,14 @@ export function bannerShell() {
17
17
  const printable = BANNER.replaceAll("\x1b", "\\033").replaceAll("\n", "\\n");
18
18
  return `printf '%b\\n' '${printable}'`;
19
19
  }
20
+ // OBS-50: quote-split exit marker — herdr echoes the typed command into the transcript that
21
+ // waitOutput matches, so the literal must not appear unsplit in the dispatch line.
22
+ export const TICKMARKR_EXIT_TRAILER = `printf '\\nTICKMARKR_''EXIT:%s\\n' $?`;
23
+ /** OBS-50: visible-pane bootstrap script — banner + agent command + byte-identical exit trailer. */
24
+ export function paneDispatchScript(body) {
25
+ return ["export BASH_SILENCE_DEPRECATION_WARNING=1", ...body, TICKMARKR_EXIT_TRAILER].join("\n");
26
+ }
27
+ /** OBS-50: one short herdr pane-run line; bootstrap lives in the script file beside the prompt. */
28
+ export function paneDispatchCommand(scriptPath) {
29
+ return `bash ${JSON.stringify(scriptPath)}`;
30
+ }
@@ -1,9 +1,10 @@
1
1
  import { BANNER } from "../../brand.js";
2
2
  import { blockedTasks, 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, journalComparable } 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,23 @@ 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
+ // OBS-52: refuse journal join when the run's recorded graphHash ≠ loaded graph — bare task-id replay lied after recompile.
131
+ comparable = journalComparable(events, g.spec.hash);
132
+ if (comparable) {
133
+ replayed = j.replayStatuses();
134
+ for (const e of events) {
135
+ if (e.event === "task-dispatch" && e.taskId) {
136
+ const a = e.data.assignment;
137
+ if (typeof a.adapter === "string" && typeof a.model === "string")
138
+ assignments.set(e.taskId, `${a.adapter}:${a.model}`);
139
+ }
140
+ if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
141
+ contexts.set(e.taskId, e.data.tokens); // last write wins
142
+ }
136
143
  }
137
144
  }
138
145
  }
@@ -148,7 +155,7 @@ const renderFrame = (cwd) => {
148
155
  const label = starved.has(t.id) ? " starved" : waiting.has(t.id) ? " dep-waiting" : "";
149
156
  const channel = assignments.get(t.id) ?? "-";
150
157
  const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
151
- return { t, st, label, assignCol, states: gateStates(t, events) };
158
+ return { t, st, label, assignCol, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
152
159
  });
153
160
  const statusColor = (st) => st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 36;
154
161
  if (!unicode) {
@@ -160,7 +167,7 @@ const renderFrame = (cwd) => {
160
167
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
161
168
  });
162
169
  const header = runId
163
- ? `tickmarkr status${divider}run ${runId}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
170
+ ? `tickmarkr status${divider}run ${runId}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
164
171
  : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
165
172
  const legend = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
166
173
  return [header, legend, ...rows].join("\n");
@@ -181,7 +188,9 @@ const renderFrame = (cwd) => {
181
188
  .replaceAll(" · ", dot);
182
189
  const tally = `${done}/${g.tasks.length} done`;
183
190
  const header = ` ${color("✓", 32, true)} ${color("tickmarkr", 1, true)}${dot}` +
184
- (runId ? `run ${runId}${dot}${live}${dot}` : `no runs yet${dot}`) +
191
+ (runId
192
+ ? `run ${runId}${dot}${!comparable ? `${NOT_COMPARABLE_NOTICE}${dot}` : ""}${live}${dot}`
193
+ : `no runs yet${dot}`) +
185
194
  `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? color(tally, 32, true) : tally}`;
186
195
  const rule = dim("─".repeat(Math.min(width, 100)));
187
196
  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
@@ -1,7 +1,8 @@
1
1
  import { channelKey, shq } from "../adapters/types.js";
2
2
  import { DEFAULT_DIFF_CAP } from "../config/config.js";
3
3
  import { renderAcceptanceItem } from "../graph/schema.js";
4
- import { sh, shOk } from "../run/git.js";
4
+ import { sh } from "../run/git.js";
5
+ import { checkDiffCap, fetchTaskDiff } from "./review.js";
5
6
  import { extractJson, runLlm } from "./llm.js";
6
7
  const isCommand = (a) => typeof a === "object" && a.oracle === "command";
7
8
  const isTest = (a) => typeof a === "object" && a.oracle === "test";
@@ -57,12 +58,11 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
57
58
  const warn = onlyJudge
58
59
  ? "WARNING: only judge oracles — no deterministic command/test oracle guards this task (deterministic preferred, spec §2).\n"
59
60
  : "";
60
- const diff = await shOk(`git diff '${baseRef}..HEAD'`, worktree);
61
+ const { full: diff, forCap } = await fetchTaskDiff(worktree, baseRef);
61
62
  const diffCap = opts.diffCap ?? DEFAULT_DIFF_CAP;
62
- if (diff.length > diffCap) {
63
- return { gate: "acceptance", pass: false,
64
- details: warn + detBlock + `diff exceeds verifiable cap (${diff.length} > ${diffCap}) — split the task, or raise gates.diffCap` };
65
- }
63
+ const capFail = checkDiffCap("acceptance", forCap.length, diffCap, warn + detBlock);
64
+ if (capFail)
65
+ return capFail;
66
66
  const prompt = `TICKMARKR-JUDGE
67
67
  You are a strict acceptance judge. Decide whether the diff satisfies EVERY acceptance criterion.
68
68
  Judge only what the diff proves — plausible-but-wrong must fail. Do not award partial credit.
@@ -93,12 +93,18 @@ Respond with ONLY this JSON (no prose before or after):
93
93
  meta: { unparseable: true, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
94
94
  }
95
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}`);
96
+ const expected = judgeItems.length;
97
+ const received = v.criteria.length;
98
+ // OBS-51: judges sometimes split one semicolon-joined criterion into multiple verdict items.
99
+ const clauseSplitAllPass = received > expected
100
+ && v.criteria.every((c) => c && typeof c === "object" && c.met === true);
101
+ if (received !== expected && !clauseSplitAllPass) {
102
+ inconsistencies.push(`judge verdict inconsistent: criteria count mismatch — expected ${expected}, received ${received}`);
98
103
  }
99
104
  v.criteria.forEach((c, i) => {
100
105
  if (!c || typeof c !== "object" || c.met !== true) {
101
- inconsistencies.push(`judge verdict inconsistent: criteria[${i}].met must be true`);
106
+ const unmet = c && typeof c === "object" && c.criterion ? ` unmet clause: ${c.criterion}` : "";
107
+ inconsistencies.push(`judge verdict inconsistent: criteria[${i}].met must be true${unmet}`);
102
108
  }
103
109
  });
104
110
  const pass = v.pass === true && inconsistencies.length === 0;
package/dist/gates/llm.js CHANGED
@@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { formatOwnedName, parseOwnedName } from "../drivers/types.js";
5
- import { bannerShell } from "../brand.js";
5
+ import { bannerShell, paneDispatchCommand, paneDispatchScript } from "../brand.js";
6
6
  import { sh } from "../run/git.js";
7
7
  export const GATE_PANE_SEP = " · ";
8
8
  /** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
@@ -38,13 +38,15 @@ export async function runHeadless(adapter, model, prompt, cwd, timeoutMs = 30000
38
38
  // v1.1 default path: the same headless CLI call, but dispatched through the driver
39
39
  // as a visible named agent (herdr pane), with the quote-split completion wrapper.
40
40
  export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs = 300000) {
41
- const pf = join(mkdtempSync(join(tmpdir(), "tickmarkr-llm-")), "prompt.md");
41
+ const dir = mkdtempSync(join(tmpdir(), "tickmarkr-llm-"));
42
+ const pf = join(dir, "prompt.md");
42
43
  writeFileSync(pf, prompt);
44
+ const scriptPath = join(dir, "dispatch.sh");
45
+ // 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)]));
43
47
  const slot = await via.driver.slot(cwd, rolePaneNameFromPrompt(prompt, via.name), via.label ? { label: via.label } : undefined);
44
48
  via.onSlot?.(slot);
45
- // brand banner at pane top — gate panes run headless-print (no alt screen), so it stays visible;
46
- // extractJson is anchored to JSON braces and the exit marker to TICKMARKR_EXIT:\d, neither matches banner text
47
- await via.driver.run(slot, `${bannerShell()}; ${adapter.headlessCommand(pf, model)}; printf '\\nTICKMARKR_''EXIT:%s\\n' $?`);
49
+ await via.driver.run(slot, paneDispatchCommand(scriptPath));
48
50
  // wait for the digit-suffixed marker (a real $? exit code), never a bare "TICKMARKR_EXIT:" that a
49
51
  // self-referential diff under review merely DISPLAYS — same guard the worker path uses (daemon.ts:193).
50
52
  // Without it, a judge/review pane echoing tickmarkr's own source false-completes before its verdict lands.
@@ -7,6 +7,13 @@ export interface ReviewVerdict {
7
7
  approve: boolean;
8
8
  issues: string[];
9
9
  }
10
+ export declare function fetchTaskDiff(worktree: string, baseRef: string): Promise<{
11
+ full: string;
12
+ forCap: string;
13
+ }>;
14
+ export declare function checkDiffCap(gate: string, measured: number, cap: number, prefix?: string): GateResult | null;
15
+ export declare function isDiffCapPark(result: GateResult): boolean;
16
+ export declare function diffCapParkReason(results: GateResult[]): string | null;
10
17
  export declare function modelId(model: string): string;
11
18
  export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[]): BillingChannel | null;
12
19
  export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[]): Promise<GateResult>;
@@ -5,6 +5,32 @@ import { getAdapter } from "../adapters/registry.js";
5
5
  import { shOk } from "../run/git.js";
6
6
  import { marginalCostRank } from "../route/router.js";
7
7
  import { extractJson, runLlm } from "./llm.js";
8
+ // OBS-48: cap on zero-context diff bytes (git diff -U0), not context-padded full diff — scattered
9
+ // one-line hunks no longer trip at ~370 diff-bytes per changed line. Full diff still goes to the judge.
10
+ const DIFF_CAP_REMEDY = "split the task, or raise gates.diffCap";
11
+ export async function fetchTaskDiff(worktree, baseRef) {
12
+ const full = await shOk(`git diff '${baseRef}..HEAD'`, worktree);
13
+ const forCap = await shOk(`git diff -U0 '${baseRef}..HEAD'`, worktree);
14
+ return { full, forCap };
15
+ }
16
+ export function checkDiffCap(gate, measured, cap, prefix = "") {
17
+ if (measured <= cap)
18
+ return null;
19
+ return {
20
+ gate,
21
+ pass: false,
22
+ details: prefix + `diff exceeds verifiable cap (${measured} > ${cap}) — ${DIFF_CAP_REMEDY}`,
23
+ // daemon/run-gates: park('human') immediately — the diff cannot shrink by retrying (OBS-48).
24
+ meta: { park: "human" },
25
+ };
26
+ }
27
+ export function isDiffCapPark(result) {
28
+ return result.pass === false && result.meta?.park === "human" && /diff exceeds verifiable cap/i.test(result.details);
29
+ }
30
+ // ponytail: single policy hook for callers after runGates — skips the escalation ladder on diff-cap trips.
31
+ export function diffCapParkReason(results) {
32
+ return results.find(isDiffCapPark)?.details ?? null;
33
+ }
8
34
  // FLEET-05: canonical model identity = the segment after the last "/". Provider-prefixed ids name ONE
9
35
  // base model behind two harnesses (zai-coding-plan/glm-5.2, zai/glm-5.2 → "glm-5.2"); vendor alone (mixed vs
10
36
  // zhipu) is not a diversity signal. Suffix-stripping over-excludes only if two genuinely different
@@ -37,12 +63,11 @@ export async function reviewGate(task, worktree, baseRef, author, channels, adap
37
63
  ? { gate: "review", pass: false, details: "no cross-vendor reviewer available (diversity rule); set review.required:false to waive" }
38
64
  : { gate: "review", pass: true, details: "WARNING: no cross-vendor reviewer available — review waived by config" };
39
65
  }
40
- const diff = await shOk(`git diff '${baseRef}..HEAD'`, worktree);
66
+ const { full: diff, forCap } = await fetchTaskDiff(worktree, baseRef);
41
67
  const diffCap = cfg.gates.diffCap ?? DEFAULT_DIFF_CAP;
42
- if (diff.length > diffCap) {
43
- return { gate: "review", pass: false,
44
- details: `diff exceeds verifiable cap (${diff.length} > ${diffCap}) — split the task, or raise gates.diffCap` };
45
- }
68
+ const capFail = checkDiffCap("review", forCap.length, diffCap);
69
+ if (capFail)
70
+ return capFail;
46
71
  const prompt = `TICKMARKR-REVIEW
47
72
  You are a skeptical cross-vendor code reviewer. Another agent (vendor: ${author.adapter}) authored this diff.
48
73
  Look for correctness bugs, security issues, and acceptance-criteria gaps. Approve only if you would merge it.
@@ -1,6 +1,7 @@
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";
4
5
  import { extractJson, gatePaneName } from "../gates/llm.js";
5
6
  import { sh } from "./git.js";
6
7
  const MAX_RETRY_GUIDANCE_LINES = 10;
@@ -73,7 +74,10 @@ opts = {}) {
73
74
  ...(opts.runId ? { owned: { role: "consult", taskId: d.taskId, attempt: 0, runId: opts.runId } } : {}),
74
75
  });
75
76
  opts.onSlot?.(slot);
76
- await driver.run(slot, `${adapter.headlessCommand(promptFile, cfg.consult.model)}; printf '\\nTICKMARKR_''EXIT:%s\\n' $?`);
77
+ const scriptPath = join(dir, `${d.taskId}-${n}.sh`);
78
+ // 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)]));
80
+ await driver.run(slot, paneDispatchCommand(scriptPath));
77
81
  // digit-suffixed marker only: a dossier quoting tickmarkr's own "TICKMARKR_EXIT:" literal must not
78
82
  // false-complete the consult pane before its verdict (same guard as daemon.ts:193 / llm.ts).
79
83
  await driver.waitOutput(slot, "TICKMARKR_EXIT:\\d", cfg.consult.stallMinutes * 60_000, { regex: true });
@@ -11,7 +11,7 @@ import { captureBaseline, detectGateCommands } from "../gates/baseline.js";
11
11
  import { runGates } from "../gates/run-gates.js";
12
12
  import { addEvidence, attributeBlocked, blockedTasks, getTask, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus } from "../graph/graph.js";
13
13
  import { consult, renderRetryGuidance } from "./consult.js";
14
- import { cleanupRunWorktrees, gitHead, sh } from "./git.js";
14
+ import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, WORKTREE_LAYOUT_CONTRACT } from "./git.js";
15
15
  import { 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";
@@ -87,7 +87,7 @@ export async function runDaemon(repoRoot, opts = {}) {
87
87
  baseRef = await gitHead(repoRoot);
88
88
  baseline = await captureBaseline(repoRoot, commands);
89
89
  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
90
+ journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphHash: graph.spec.hash }); // graphHash: OBS-52 status join guard; pid: v1.13 (VIS-11) liveness
91
91
  }
92
92
  // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
93
93
  // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
@@ -343,6 +343,11 @@ export async function runDaemon(repoRoot, opts = {}) {
343
343
  }
344
344
  }
345
345
  const promptFile = writePrompt(journal.dir, t, attempt, feedback, nonce);
346
+ // OBS-47: state the worktree layout contract in the worker prompt (cheap-tier workers were
347
+ // committing/deleting node_modules and tripping the scope gate). Prepended, not appended, so the
348
+ // completion trailer stays the structural last line (prompt.ts's design). The harness re-asserts
349
+ // the link itself before gates regardless of what the worker does with it.
350
+ writeFileSync(promptFile, `${WORKTREE_LAYOUT_CONTRACT}\n\n${readFileSync(promptFile, "utf8")}`);
346
351
  const adapter = getAdapter(assignment.adapter, adapters);
347
352
  // VIS-04: workers share one role tab. T2: `owned` names the pane canonically (ownership contract);
348
353
  // the legacy name stays the fallback for drivers without owned handling (subprocess spies).
@@ -556,6 +561,16 @@ export async function runDaemon(repoRoot, opts = {}) {
556
561
  }
557
562
  graph = setStatus(graph, t.id, "gated");
558
563
  saveGraph(repoRoot, graph);
564
+ // OBS-47: re-assert the node_modules link BEFORE gates run on any attempt. A worker may have
565
+ // deleted/replaced the symlink provisioned at worktree creation (run-20260717-004803 T5 lost two
566
+ // attempts + a consult to this); restore it harness-side so a prior attempt's environment damage
567
+ // can never fail a later attempt's gates. Gates never trust worker claims — this runs
568
+ // unconditionally, never on worker say-so. Restoration can fail (EPERM/busy); fail closed with a
569
+ // named environmental verdict instead of letting the test gate mask it as a code red.
570
+ if (!linkNodeModules(repoRoot, wt, { force: true })) {
571
+ await park(t, "environmental: node_modules link could not be re-asserted before gates (OBS-47)", "setup", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
572
+ return;
573
+ }
559
574
  const onGate = async (e) => {
560
575
  if (e.phase === "start")
561
576
  return;
package/dist/run/git.d.ts CHANGED
@@ -8,6 +8,7 @@ export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promis
8
8
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
9
9
  export declare function gitHead(cwd: string): Promise<string>;
10
10
  export declare const sanitizeBranch: (branch: string) => string;
11
+ export declare const WORKTREES_DIR = "worktrees.noindex";
11
12
  export declare const worktreePath: (repo: string, branch: string) => string;
12
13
  /** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
13
14
  export declare function cleanupRunWorktrees(repo: string, branch: string, opts: {
@@ -16,5 +17,8 @@ export declare function cleanupRunWorktrees(repo: string, branch: string, opts:
16
17
  }): Promise<void>;
17
18
  export declare function resolveIntegrationBranch(_repo: string, branch: string): Promise<string>;
18
19
  export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
19
- export declare function linkNodeModules(repo: string, dir: string): void;
20
+ export declare function linkNodeModules(repo: string, dir: string, { force }?: {
21
+ force?: boolean | undefined;
22
+ }): boolean;
23
+ export declare const WORKTREE_LAYOUT_CONTRACT = "## Worktree layout contract (harness-provisioned \u2014 do not modify)\n- node_modules is a symlink into the main repo's node_modules, provisioned by tickmarkr. Never commit, delete, or replace it \u2014 the harness re-asserts this link before gates run, so modifying it cannot help and may fail your attempt.";
20
24
  export declare function removeWorktree(repo: string, dir: string): Promise<void>;
package/dist/run/git.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, symlinkSync } from "node:fs";
2
+ import { existsSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { shq } from "../adapters/types.js";
5
5
  import { tickmarkrDir } from "../graph/graph.js";
@@ -49,7 +49,12 @@ export async function gitHead(cwd) {
49
49
  }
50
50
  export const sanitizeBranch = (branch) => branch.replace(/[^\w.-]+/g, "-");
51
51
  const sanitize = sanitizeBranch;
52
- export const worktreePath = (repo, branch) => join(tickmarkrDir(repo), "worktrees", sanitize(branch));
52
+ // OBS-49: macOS Spotlight skips any *.noindex directory, so worktree churn during gate bursts
53
+ // stops spawning mdworkers (9 mdworkers measured on an 18-core M5 Max at load-77). Accepted cost:
54
+ // CLI trust stores key on exact worktree paths (OBS-16), so each worktree re-prompts for trust
55
+ // once after this rename — the same one-time per-install cost as the v1.38 state-dir rename.
56
+ export const WORKTREES_DIR = "worktrees.noindex";
57
+ export const worktreePath = (repo, branch) => join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
53
58
  /** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
54
59
  export async function cleanupRunWorktrees(repo, branch, opts) {
55
60
  if (opts.removeIntegration)
@@ -69,25 +74,47 @@ const resolveTaskBranch = async (repo, branch) => {
69
74
  };
70
75
  export async function createWorktree(repo, branch, baseRef) {
71
76
  branch = await resolveTaskBranch(repo, branch);
72
- const dir = join(tickmarkrDir(repo), "worktrees", sanitize(branch));
77
+ const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
73
78
  if (existsSync(dir))
74
79
  await removeWorktree(repo, dir);
75
80
  await shOk(`git worktree add -B ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo);
76
81
  linkNodeModules(repo, dir);
77
82
  return dir;
78
83
  }
79
- // best-effort: devDep-based gates (tsx, vitest) shell out root-anchored and ENOENT in a bare
80
- // fresh worktree (OBS-27); failure here must never fail worktree creation
81
- export function linkNodeModules(repo, dir) {
84
+ // OBS-41/OBS-47: the harness provisions node_modules as a symlink into the main repo so devDep-based
85
+ // gates (tsx, vitest) resolve in a bare worktree. Provisioning (createWorktree) calls this LENIENT —
86
+ // create the link only when dest is absent, never clobber (best-effort, never fails worktree creation).
87
+ // The pre-gate re-assert (OBS-47) calls this with force:true — a worker that deleted/replaced the link
88
+ // (real directory, wrong/broken symlink) is restored to the provisioned link. Idempotent: an
89
+ // already-correct link is a no-op. Returns whether dest is the provisioned link: provisioning ignores
90
+ // the result; the pre-gate caller treats false as a named environmental park, never a masked test red.
91
+ export function linkNodeModules(repo, dir, { force = false } = {}) {
82
92
  const src = join(repo, "node_modules");
83
93
  const dest = join(dir, "node_modules");
84
- if (!existsSync(src) || existsSync(dest))
85
- return;
94
+ if (!existsSync(src))
95
+ return true; // nothing provisioned to link — correct state is no link (OBS-27 best-effort)
86
96
  try {
97
+ if (lstatSync(dest).isSymbolicLink() && readlinkSync(dest) === src)
98
+ return true; // already the provisioned link
99
+ }
100
+ catch { /* dest absent — fall through to create */ }
101
+ if (!force && existsSync(dest))
102
+ return false; // lenient provisioning (OBS-41): never clobber an existing entry
103
+ try {
104
+ rmSync(dest, { recursive: true, force: true }); // force tolerates absent; clears a wrong link / real dir / file
87
105
  symlinkSync(src, dest, "dir");
106
+ return true;
107
+ }
108
+ catch {
109
+ return false;
88
110
  }
89
- catch { /* best-effort */ }
90
111
  }
112
+ // OBS-47: the worktree layout the harness provisions, stated in the worker prompt so cheap-tier workers
113
+ // stop tripping the scope gate by committing/deleting/replacing node_modules. The harness re-asserts the
114
+ // link itself before gates regardless (gates never trust worker claims) — this contract just keeps the
115
+ // worker from spending an attempt on environment repair.
116
+ export const WORKTREE_LAYOUT_CONTRACT = `## Worktree layout contract (harness-provisioned — do not modify)
117
+ - node_modules is a symlink into the main repo's node_modules, provisioned by tickmarkr. Never commit, delete, or replace it — the harness re-asserts this link before gates run, so modifying it cannot help and may fail your attempt.`;
91
118
  export async function removeWorktree(repo, dir) {
92
119
  await sh(`git worktree remove --force ${shq(dir)}`, repo); // best-effort; stale dirs are re-added with -B
93
120
  await sh(`rm -rf ${shq(dir)}`, repo);
@@ -62,6 +62,8 @@ 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 recordedGraphHash(events: JournalEvent[]): string | undefined;
66
+ export declare function journalComparable(events: JournalEvent[], loadedHash: string): boolean;
65
67
  export declare function newRunId(now?: Date): string;
66
68
  export declare function readAllTelemetry(repoRoot: string, lastK: number, opts?: {
67
69
  after?: string;
@@ -72,6 +72,18 @@ 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
+ // OBS-52: graph hash on run-start binds status replay to the compiled graph that started the run.
76
+ export function recordedGraphHash(events) {
77
+ for (const e of events) {
78
+ if (e.event === "run-start" && typeof e.data.graphHash === "string")
79
+ return e.data.graphHash;
80
+ }
81
+ return undefined;
82
+ }
83
+ export function journalComparable(events, loadedHash) {
84
+ const recorded = recordedGraphHash(events);
85
+ return recorded !== undefined && recorded === loadedHash;
86
+ }
75
87
  export function newRunId(now = new Date()) {
76
88
  const p = (n, w = 2) => String(n).padStart(w, "0");
77
89
  return `run-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
package/dist/run/merge.js CHANGED
@@ -3,14 +3,14 @@ import { join } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
4
  import { fingerprint } from "../gates/baseline.js";
5
5
  import { tickmarkrDir } from "../graph/graph.js";
6
- import { gitHead, linkNodeModules, resolveIntegrationBranch, sh, shOk } from "./git.js";
6
+ import { gitHead, linkNodeModules, resolveIntegrationBranch, sh, shOk, WORKTREES_DIR } from "./git.js";
7
7
  export function integrationBranch(cfg, runId) {
8
8
  return `${cfg.integrationBranchPrefix}${runId}`;
9
9
  }
10
10
  const sanitize = (branch) => branch.replace(/[^\w.-]+/g, "-");
11
11
  export async function ensureIntegration(repo, branch, baseRef) {
12
12
  branch = await resolveIntegrationBranch(repo, branch);
13
- const dir = join(tickmarkrDir(repo), "worktrees", sanitize(branch));
13
+ const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
14
14
  if (!existsSync(join(dir, ".git"))) {
15
15
  const exists = (await sh(`git rev-parse --verify refs/heads/${shq(branch)}`, repo)).code === 0;
16
16
  if (exists) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.42.0",
3
+ "version": "1.44.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",