tickmarkr 1.44.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.
- package/dist/cli/commands/compile.js +10 -6
- package/dist/cli/commands/resume.js +5 -1
- package/dist/cli/commands/status.js +6 -4
- package/dist/gates/acceptance.d.ts +2 -0
- package/dist/gates/acceptance.js +98 -27
- package/dist/gates/llm.d.ts +8 -0
- package/dist/gates/llm.js +92 -8
- package/dist/gates/review.js +6 -3
- package/dist/graph/graph.d.ts +1 -0
- package/dist/graph/graph.js +12 -0
- package/dist/run/consult.d.ts +1 -1
- package/dist/run/consult.js +19 -10
- package/dist/run/daemon.d.ts +1 -0
- package/dist/run/daemon.js +21 -3
- package/dist/run/journal.d.ts +14 -2
- package/dist/run/journal.js +41 -12
- package/dist/run/lock.js +3 -3
- package/package.json +1 -1
|
@@ -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 {
|
|
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:
|
|
18
|
-
//
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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,7 +1,7 @@
|
|
|
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,
|
|
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
7
|
const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
|
|
@@ -127,8 +127,10 @@ const renderFrame = (cwd) => {
|
|
|
127
127
|
if (runId) {
|
|
128
128
|
const j = Journal.open(cwd, runId);
|
|
129
129
|
events = j.read();
|
|
130
|
-
//
|
|
131
|
-
|
|
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;
|
|
132
134
|
if (comparable) {
|
|
133
135
|
replayed = j.replayStatuses();
|
|
134
136
|
for (const e of events) {
|
|
@@ -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;
|
package/dist/gates/acceptance.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
13
|
-
|
|
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) => `- ${
|
|
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": "
|
|
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
|
|
87
|
-
if (!
|
|
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,25 +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
|
-
const
|
|
97
|
-
const
|
|
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}`);
|
|
103
|
-
}
|
|
104
|
-
v.criteria.forEach((c, i) => {
|
|
105
|
-
if (!c || typeof c !== "object" || c.met !== 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}`);
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
const pass = v.pass === true && inconsistencies.length === 0;
|
|
111
|
-
const lines = v.criteria.map((c, i) => c && typeof c === "object"
|
|
112
|
-
? `${c.met ? "✓" : "✗"} ${c.criterion}: ${c.reason}`
|
|
113
|
-
: `✗ 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}`);
|
|
114
185
|
if (!v.pass)
|
|
115
186
|
lines.push("judge verdict pass=false");
|
|
116
187
|
lines.push(...inconsistencies);
|
package/dist/gates/llm.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
await via.driver.
|
|
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
|
+
}
|
package/dist/gates/review.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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",
|
package/dist/graph/graph.d.ts
CHANGED
|
@@ -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;
|
package/dist/graph/graph.js
CHANGED
|
@@ -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 });
|
package/dist/run/consult.d.ts
CHANGED
|
@@ -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;
|
package/dist/run/consult.js
CHANGED
|
@@ -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
|
|
5
|
-
import {
|
|
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,
|
|
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
|
-
//
|
|
82
|
-
|
|
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 =
|
|
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
|
}
|
package/dist/run/daemon.d.ts
CHANGED
package/dist/run/daemon.js
CHANGED
|
@@ -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,
|
|
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:
|
package/dist/run/journal.d.ts
CHANGED
|
@@ -62,9 +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
|
|
66
|
-
export
|
|
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;
|
|
67
78
|
export declare function newRunId(now?: Date): string;
|
|
79
|
+
export declare function parseRunId(runId: string): string;
|
|
68
80
|
export declare function readAllTelemetry(repoRoot: string, lastK: number, opts?: {
|
|
69
81
|
after?: string;
|
|
70
82
|
}): (TelemetryRow & {
|
package/dist/run/journal.js
CHANGED
|
@@ -72,22 +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
|
-
//
|
|
76
|
-
|
|
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) {
|
|
77
82
|
for (const e of events) {
|
|
78
|
-
if (e.event === "run-start" && typeof e.data.
|
|
79
|
-
return e.data.
|
|
83
|
+
if (e.event === "run-start" && typeof e.data.graphDefinitionHash === "string")
|
|
84
|
+
return e.data.graphDefinitionHash;
|
|
80
85
|
}
|
|
81
86
|
return undefined;
|
|
82
87
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 };
|
|
86
95
|
}
|
|
87
96
|
export function newRunId(now = new Date()) {
|
|
88
97
|
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
89
98
|
return `run-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
90
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
|
+
}
|
|
91
116
|
const runsDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "runs");
|
|
92
117
|
// One JSONL reader for every append-only log: skip blanks, drop any line that
|
|
93
118
|
// won't parse, keeping everything before it intact.
|
|
@@ -163,15 +188,19 @@ export class Journal {
|
|
|
163
188
|
this.narrate = narrate;
|
|
164
189
|
}
|
|
165
190
|
static create(repoRoot, runId, narrate) {
|
|
166
|
-
const
|
|
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}`);
|
|
167
195
|
mkdirSync(dir, { recursive: true });
|
|
168
|
-
return new Journal(dir,
|
|
196
|
+
return new Journal(dir, id, narrate);
|
|
169
197
|
}
|
|
170
198
|
static open(repoRoot, runId, narrate) {
|
|
171
|
-
const
|
|
199
|
+
const id = parseRunId(runId);
|
|
200
|
+
const dir = join(runsDir(repoRoot), id);
|
|
172
201
|
if (!existsSync(join(dir, "journal.jsonl")))
|
|
173
|
-
throw new Error(`no journal for ${
|
|
174
|
-
return new Journal(dir,
|
|
202
|
+
throw new Error(`no journal for ${id} at ${dir}`);
|
|
203
|
+
return new Journal(dir, id, narrate);
|
|
175
204
|
}
|
|
176
205
|
// withJournal: journal.jsonl appears at first append, after Journal.create mkdirs — a caller that
|
|
177
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
|
|
145
|
-
//
|
|
146
|
-
//
|
|
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)));
|