tickmarkr 2.6.2 → 2.6.4

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.
Files changed (79) hide show
  1. package/README.md +12 -2
  2. package/dist/adapters/codex.d.ts +2 -1
  3. package/dist/adapters/codex.js +26 -5
  4. package/dist/adapters/model-lints.d.ts +1 -0
  5. package/dist/adapters/model-lints.js +48 -1
  6. package/dist/adapters/model-windows.d.ts +2 -1
  7. package/dist/adapters/model-windows.js +18 -7
  8. package/dist/adapters/types.d.ts +6 -0
  9. package/dist/cli/commands/approve.d.ts +6 -3
  10. package/dist/cli/commands/approve.js +43 -6
  11. package/dist/cli/commands/doctor.d.ts +8 -2
  12. package/dist/cli/commands/doctor.js +6 -1
  13. package/dist/cli/commands/fleet.js +264 -59
  14. package/dist/cli/commands/init.js +5 -3
  15. package/dist/cli/commands/plan.js +13 -8
  16. package/dist/cli/commands/report.d.ts +40 -2
  17. package/dist/cli/commands/report.js +278 -11
  18. package/dist/cli/commands/resume.js +2 -4
  19. package/dist/cli/commands/run.d.ts +11 -0
  20. package/dist/cli/commands/run.js +33 -5
  21. package/dist/cli/commands/status.js +49 -4
  22. package/dist/cli/commands/verify.js +332 -121
  23. package/dist/compile/native.js +137 -0
  24. package/dist/config/config.d.ts +40 -9
  25. package/dist/config/config.js +133 -15
  26. package/dist/config/fleet-overlay.d.ts +13 -3
  27. package/dist/config/fleet-overlay.js +12 -8
  28. package/dist/drivers/herdr.d.ts +12 -0
  29. package/dist/drivers/herdr.js +51 -0
  30. package/dist/drivers/orca.d.ts +9 -1
  31. package/dist/drivers/orca.js +29 -7
  32. package/dist/drivers/types.d.ts +2 -0
  33. package/dist/drivers/types.js +2 -2
  34. package/dist/gates/acceptance.d.ts +7 -0
  35. package/dist/gates/acceptance.js +27 -5
  36. package/dist/gates/baseline.d.ts +20 -1
  37. package/dist/gates/baseline.js +100 -20
  38. package/dist/gates/cache.d.ts +8 -0
  39. package/dist/gates/cache.js +12 -2
  40. package/dist/gates/llm.d.ts +6 -0
  41. package/dist/gates/llm.js +27 -8
  42. package/dist/gates/review.d.ts +6 -1
  43. package/dist/gates/review.js +122 -32
  44. package/dist/gates/run-gates.d.ts +54 -3
  45. package/dist/gates/run-gates.js +331 -45
  46. package/dist/gates/test-manifest.d.ts +42 -0
  47. package/dist/gates/test-manifest.js +69 -10
  48. package/dist/route/router.d.ts +23 -1
  49. package/dist/route/router.js +54 -16
  50. package/dist/run/consult.d.ts +3 -1
  51. package/dist/run/consult.js +4 -2
  52. package/dist/run/daemon.d.ts +2 -1
  53. package/dist/run/daemon.js +349 -79
  54. package/dist/run/interactive-seed.d.ts +4 -0
  55. package/dist/run/interactive-seed.js +35 -9
  56. package/dist/run/journal.d.ts +123 -1
  57. package/dist/run/journal.js +480 -17
  58. package/dist/run/lease.d.ts +13 -0
  59. package/dist/run/lease.js +45 -0
  60. package/dist/run/protocol.d.ts +15 -0
  61. package/dist/run/protocol.js +11 -1
  62. package/dist/run/receipt-resolver.d.ts +22 -0
  63. package/dist/run/receipt-resolver.js +40 -1
  64. package/dist/run/repair-selection.d.ts +11 -1
  65. package/dist/run/repair-selection.js +17 -9
  66. package/dist/run/wall-budget.d.ts +48 -0
  67. package/dist/run/wall-budget.js +280 -0
  68. package/dist/tui/cockpit/live-store.d.ts +6 -0
  69. package/dist/tui/cockpit/live-store.js +36 -11
  70. package/dist/tui/cockpit/run-cockpit.js +2 -2
  71. package/dist/tui/cockpit/run-view.d.ts +2 -1
  72. package/dist/tui/cockpit/run-view.js +13 -9
  73. package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
  74. package/dist/tui/cockpit/setup-cockpit.js +4 -0
  75. package/package.json +2 -1
  76. package/schema/config.schema.json +8 -1
  77. package/skills/tickmarkr-auto/SKILL.md +2 -2
  78. package/skills/tickmarkr-loop/SKILL.md +17 -5
  79. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
@@ -7,20 +7,23 @@ import { allAdapters, probeAll, readDoctor, rolePools } from "../../adapters/reg
7
7
  import { channelKey } from "../../adapters/types.js";
8
8
  import { loadConfig } from "../../config/config.js";
9
9
  import { captureBaseline, detectGateCommands, staleFileCountCommands } from "../../gates/baseline.js";
10
+ import { getVerdictStore } from "../../gates/cache.js";
10
11
  import { modelProvider } from "../../gates/review.js";
11
12
  import { runGates } from "../../gates/run-gates.js";
12
13
  import { getTask, loadGraph } from "../../graph/graph.js";
13
14
  import { GATE_NAMES } from "../../graph/schema.js";
14
15
  import { executionSignal } from "../../run/execution-budget.js";
15
16
  import { linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
16
- import { Journal } from "../../run/journal.js";
17
+ import { foldOwedChecks, integrationMapped, owedCriteria, Journal, OWED_DISCHARGE_EVENT } from "../../run/journal.js";
17
18
  import { withRepositoryLease } from "../../run/lease.js";
19
+ import { isPidLive } from "../../run/lock.js";
18
20
  /**
19
21
  * tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
20
22
  *
21
- * Runs the existing seven-gate pipeline (build/test/lint against a captured base baseline, then
22
- * evidence, scope, acceptance ‖ review) against `merge-base(--base, HEAD)..HEAD` of the CURRENT
23
- * checkout. No daemon, no worktree lifecycle, no retries, no approvals, no resumable state: one
23
+ * Runs the existing seven-gate pipeline against `merge-base(--base, HEAD)..HEAD` of the CURRENT
24
+ * checkout in two phases: build/lint against a captured base baseline, evidence, scope, then
25
+ * acceptance ‖ review, all unleased; then, under the repository lease, the test suite against its
26
+ * own base baseline. No daemon, no worktree lifecycle, no retries, no approvals, no resumable state: one
24
27
  * invocation, one immutable candidate, one machine-readable verdict. A verifier failure costs one
25
28
  * rerun. Same fail-closed guarantees — this is a thin caller of runGates, not a second pipeline.
26
29
  */
@@ -157,6 +160,16 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
157
160
  }
158
161
  const cfg = loadConfig(fileRoot("config.yaml"));
159
162
  const head = (await shGitOk("git rev-parse HEAD", cwd)).trim();
163
+ // Git appends every move of HEAD to its reflog — one a gate command makes and undoes itself included —
164
+ // so the log as of this capture is the subject's witness: any later difference is a move (fail closed).
165
+ const headLogPath = resolve(cwd, (await shGitOk("git rev-parse --git-path logs/HEAD", cwd)).trim());
166
+ const headLog = () => { try {
167
+ return readFileSync(headLogPath, "utf8");
168
+ }
169
+ catch {
170
+ return "";
171
+ } };
172
+ const headLogAtCapture = headLog();
160
173
  const baseTip = (await shGitOk(`git rev-parse '${values.base}'`, cwd).catch(() => {
161
174
  throw new Error(`--base ${values.base} is not a resolvable ref — pass --base <ref> naming the branch this diff targets`);
162
175
  })).trim();
@@ -212,24 +225,79 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
212
225
  // capture and gate GREEN), and it enumerates nested files individually instead of collapsing them
213
226
  // to a bare `?? dir/`, so the refusal names every offending path. The `.tickmarkr-*` exemption is
214
227
  // unaffected — the harness's droppings are root-level files, never directories.
215
- const status = await shGit("GIT_OPTIONAL_LOCKS=0 git status --porcelain --untracked-files=all", cwd);
216
- const dirt = status.code !== 0
217
- ? `git status failed (exit ${status.code}) — the worktree cannot be proven clean`
218
- : status.stdout.split("\n").map((l) => l.trimEnd())
219
- .filter((l) => l.trim() && !/^.. \.tickmarkr-[^/]*$/.test(l)).join("\n");
228
+ const candidateDirt = async () => {
229
+ const status = await shGit("GIT_OPTIONAL_LOCKS=0 git status --porcelain --untracked-files=all", cwd);
230
+ const dirt = status.code !== 0
231
+ ? `git status failed (exit ${status.code}) — the worktree cannot be proven clean`
232
+ : status.stdout.split("\n").map((l) => l.trimEnd())
233
+ .filter((l) => l.trim() && !/^.. \.tickmarkr-[^/]*$/.test(l)).join("\n");
234
+ return dirt ? `${DIRTY_WHY}:\n${dirt}` : undefined;
235
+ };
236
+ const dirt = await candidateDirt();
220
237
  if (dirt)
221
- throw new Error(`${DIRTY_WHY}:\n${dirt}`);
238
+ throw new Error(dirt);
239
+ // C1: a --record into a run that owes checks is a discharge attempt, bound before any seat or
240
+ // capture is spent. It must name the exact obligation — its run, task, immutable range (or that
241
+ // range's validated integration merge), required gate and unchanged criteria — or it refuses with
242
+ // exit 2 and the check stays outstanding. Unknown debt refuses outright: nothing proves it paid.
243
+ const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
244
+ const owedBefore = recordJournal ? foldOwedChecks(recordJournal.read(), cwd) : undefined;
245
+ const debtLine = (fold) => `owed checks for ${values.record}: debt ${fold.debt}`
246
+ + (fold.outstanding.length ? ` — outstanding ${fold.outstanding.map((o) => `${o.taskId}/${o.gate} ${o.id}`).join(", ")}` : "")
247
+ + (fold.unknown.length ? ` — unknown: ${fold.unknown.map((u) => `${u.taskId ?? "?"}${u.gate ? `/${u.gate}` : ""} ${u.reason}`).join("; ")}` : "");
248
+ const refuseDischarge = (why) => ({ out: `verify --record refused: ${why}\n${debtLine(owedBefore)}`, code: 2 });
249
+ let owed = [];
250
+ let mapping = "exact";
251
+ if (owedBefore && (!owedBefore.known || owedBefore.outstanding.length > 0)) {
252
+ if (!owedBefore.known)
253
+ return refuseDischarge("the run's owed-check debt is unknown (legacy, missing or malformed evidence)");
254
+ const own = owedBefore.outstanding.filter((o) => o.runId === values.record && o.taskId === values.task);
255
+ if (!own.length)
256
+ return refuseDischarge(`run ${values.record} owes no check for task ${values.task ?? "(no --task)"}`);
257
+ owed = own.filter((o) => o.base === mergeBase && o.head === head);
258
+ if (!owed.length) {
259
+ const events = recordJournal.read();
260
+ owed = own.filter((o) => integrationMapped(cwd, events, o, mergeBase, head));
261
+ mapping = "integration";
262
+ }
263
+ if (!owed.length)
264
+ return refuseDischarge(`${mergeBase.slice(0, 12)}..${head.slice(0, 12)} is neither an owed check's exact range nor a validated integration merge of it (ambiguous or rewritten history is refused)`);
265
+ // The evidence is the obligation's own: its task's scope against a baseline this invocation captures.
266
+ if (values.files?.length)
267
+ return refuseDischarge(`a discharge gates the recorded scope of ${values.task}; --files cannot replace it`);
268
+ if (values.baseline)
269
+ return refuseDischarge("a discharge captures its own baseline; --baseline cannot supply one");
270
+ for (const o of owed) {
271
+ if (JSON.stringify(files) !== JSON.stringify(o.files))
272
+ return refuseDischarge(`check ${o.id} bound another file scope than ${values.task}'s current one`);
273
+ if (!gates.includes(o.gate))
274
+ return refuseDischarge(`check ${o.id} requires the ${o.gate} gate, which this invocation skips`);
275
+ if (owedCriteria(acceptance) !== o.criteria)
276
+ return refuseDischarge(`check ${o.id} bound other acceptance criteria than ${values.task}'s current ones`);
277
+ }
278
+ }
222
279
  // LLM seats only when a semantic gate will run.
223
280
  let channels = [];
224
281
  let judgeChannels;
225
282
  let author = HUMAN_AUTHOR;
283
+ let carriedAuthors = [];
226
284
  const adapters = allAdapters();
227
285
  if (wantAcceptance || wantReview) {
228
286
  const health = readDoctor(fileRoot("doctor.json")) ?? (await probeAll(adapters));
229
287
  const pools = rolePools(cfg, adapters, health);
230
288
  judgeChannels = pools.judge;
231
289
  channels = pools.review;
232
- if (values.author && values.author !== "human") {
290
+ if (owed.length) {
291
+ // The run's lifetime patch authors bind the reviewer's exclusions; --author never overrides
292
+ // them. An author the pool cannot resolve leaves review unseatable, which fails closed.
293
+ carriedAuthors = [...new Set(owed.flatMap((o) => o.authors))];
294
+ if (values.author)
295
+ console.error(`verify: --author ${values.author} ignored — ${values.record}'s recorded patch authors (${carriedAuthors.join(", ")}) bind this discharge`);
296
+ const first = channels.find((c) => channelKey(c) === carriedAuthors[0]);
297
+ if (first)
298
+ author = { adapter: first.adapter, model: first.model, channel: first.channel, tier: first.tier };
299
+ }
300
+ else if (values.author && values.author !== "human") {
233
301
  const [adapter, ...rest] = values.author.split(":");
234
302
  const model = rest.join(":");
235
303
  const c = channels.find((ch) => ch.adapter === adapter && ch.model === model);
@@ -253,127 +321,270 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
253
321
  // ponytail: tmpdir means the baseline cache dies on reboot/cleanup — worst case is one re-capture.
254
322
  const stateDir = verifyStateDir(cwd);
255
323
  mkdirSync(stateDir, { recursive: true });
256
- const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
257
- // OBS-1042: one runner root per repository across every linked worktree — the capture and the
258
- // battery below run under a reservation keyed on the common git dir, and a waiter says who holds it.
259
- return withRepositoryLease(cwd, () => leasedVerify(), {
260
- signal: executionSignal(),
261
- onWait: (holder) => {
262
- console.error(`verify: waiting for the repository's runner lease held by pid ${holder.pid} in ${holder.cwd}`);
263
- recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd });
264
- },
265
- });
266
- async function leasedVerify() {
267
- let baseline;
268
- const cachePath = baselineCachePath(cwd, mergeBase, commands);
324
+ // T5 (D): only the full suite and its baseline need the repository's runner lease. build/lint
325
+ // (baseline and candidate), evidence, scope and judge ‖ review run first and unleased, so a red
326
+ // there ends verify without queueing behind another worktree's suite. Each command subset keeps
327
+ // its own baseline cache identity; the rows of both phases fold into ONE AND verdict and ONE
328
+ // artifact. With no test command there is no suite to lease: the skipped test row stays up front.
329
+ // (tipTest is a tip-verify command runGates never executes here, so neither subset captures it.)
330
+ const subsetOf = (names) => Object.fromEntries(Object.entries(commands).filter(([name]) => names.includes(name)));
331
+ const cheapCommands = subsetOf(["build", "lint"]);
332
+ const leased = gates.filter((g) => g === "test" && commands.test !== undefined);
333
+ // The suite's base capture rebuilds first in its own worktree: a suite that reads ignored build
334
+ // outputs (dist/) must meet them at base as the candidate does, or its unchanged failures read as
335
+ // new. So build belongs to the suite subset's capture — and to its cache identity.
336
+ const suiteCommands = leased.length ? subsetOf(["build", "test"]) : {};
337
+ // A prior verdictless capture is announced before any gate runs, whichever phase will recapture it.
338
+ if (!values.baseline) {
339
+ for (const subset of [cheapCommands, suiteCommands]) {
340
+ if (Object.keys(subset).length && existsSync(`${baselineCachePath(cwd, mergeBase, subset)}.verdictless`)) {
341
+ console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)} for ${Object.keys(subset).join(", ")}`);
342
+ }
343
+ }
344
+ }
345
+ async function baselineFor(subset) {
346
+ if (values.baseline)
347
+ return JSON.parse(readFileSync(values.baseline, "utf8"));
348
+ const names = Object.keys(subset);
349
+ if (!names.length)
350
+ return { commands: {} };
351
+ const cachePath = baselineCachePath(cwd, mergeBase, subset);
269
352
  const verdictlessMarker = `${cachePath}.verdictless`;
270
353
  mkdirSync(dirname(cachePath), { recursive: true });
271
- if (values.baseline) {
272
- baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
273
- }
274
- else {
275
- let cached;
276
- if (existsSync(cachePath)) {
354
+ let cached;
355
+ if (existsSync(cachePath)) {
356
+ try {
277
357
  cached = JSON.parse(readFileSync(cachePath, "utf8"));
278
- const missing = verdictlessCommands(cached, commands);
279
- if (missing.length) {
280
- console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
281
- rmSync(cachePath, { force: true });
282
- cached = undefined;
283
- }
284
- // OBS-1044: a cached vitest count that is a stdout sum may be inflated by nested runner echo;
285
- // applying it would manufacture a deficit. Only a manifest-derived count is a floor.
286
- const stale = cached ? staleFileCountCommands(cached, commands, cwd) : [];
287
- if (stale.length) {
288
- console.error(`verify: cached baseline's file count for ${stale.join(", ")} is not manifest-derived; it will be recaptured`);
289
- rmSync(cachePath, { force: true });
290
- cached = undefined;
291
- }
292
358
  }
293
- if (cached) {
294
- console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
295
- baseline = cached;
359
+ catch {
360
+ console.error(`verify: cached baseline at ${cachePath} is unreadable; it will be recaptured`);
361
+ }
362
+ const missing = cached ? verdictlessCommands(cached, subset) : [];
363
+ if (missing.length) {
364
+ console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
365
+ cached = undefined;
366
+ }
367
+ // OBS-1044: a cached vitest count that is a stdout sum may be inflated by nested runner echo;
368
+ // applying it would manufacture a deficit. Only a manifest-derived count is a floor.
369
+ const stale = cached ? staleFileCountCommands(cached, subset, cwd) : [];
370
+ if (stale.length) {
371
+ console.error(`verify: cached baseline's file count for ${stale.join(", ")} is not manifest-derived; it will be recaptured`);
372
+ cached = undefined;
373
+ }
374
+ if (!cached)
375
+ rmSync(cachePath, { force: true });
376
+ }
377
+ if (cached) {
378
+ console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${names.join(", ")}: ${cachePath})`);
379
+ return cached;
380
+ }
381
+ console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} for ${names.join(", ")} (cached by base, lockfile and command hashes at ${cachePath})`);
382
+ // Unleased captures of the SAME checkout overlap, so each owns a unique base worktree and removes
383
+ // only its own; the pid in the name lets a later verify sweep what a killed one left behind.
384
+ for (const entry of readdirSync(stateDir)) {
385
+ const owner = /^base-[0-9a-f]{12}-(\d+)-\w{6}$/.exec(entry)?.[1];
386
+ if (owner && !isPidLive(Number(owner)))
387
+ await removeWorktree(cwd, join(stateDir, entry));
388
+ }
389
+ const baseDir = mkdtempSync(join(stateDir, `base-${mergeBase.slice(0, 12)}-${process.pid}-`));
390
+ try {
391
+ await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
392
+ linkNodeModules(cwd, baseDir, { force: true });
393
+ const baseline = await captureBaseline(baseDir, subset);
394
+ if (baseline.refusal)
395
+ return baseline;
396
+ const missing = verdictlessCommands(baseline, subset);
397
+ if (missing.length) {
398
+ writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
399
+ rmSync(cachePath, { force: true });
296
400
  }
297
401
  else {
298
- if (existsSync(verdictlessMarker)) {
299
- console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
300
- }
301
- else {
302
- console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
303
- }
304
- const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
305
- await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
306
- await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
307
- try {
308
- linkNodeModules(cwd, baseDir, { force: true });
309
- baseline = await captureBaseline(baseDir, commands);
310
- if (baseline.refusal)
311
- return { out: baseline.refusal, code: 1 };
312
- const missing = verdictlessCommands(baseline, commands);
313
- if (missing.length) {
314
- writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
315
- rmSync(cachePath, { force: true });
316
- }
317
- else {
318
- writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
319
- rmSync(verdictlessMarker, { force: true });
320
- }
321
- }
322
- finally {
323
- await removeWorktree(cwd, baseDir);
324
- }
402
+ writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
403
+ rmSync(verdictlessMarker, { force: true });
325
404
  }
405
+ return baseline;
326
406
  }
327
- const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
328
- mkdirSync(artifactDir, { recursive: true });
329
- const { results } = await runGates(task, {
330
- worktree: cwd, baseRef: mergeBase,
331
- result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
332
- author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
333
- evidence: options.evidence,
334
- verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
335
- onGate: (e) => {
336
- if (e.phase === "start")
337
- console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
338
- else if (e.phase === "note")
339
- console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
340
- else
341
- console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
342
- },
343
- });
344
- const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
345
- const reviewRows = results.filter((result) => result.gate === "review");
346
- const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
347
- const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
348
- return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
349
- }));
350
- const artifactPath = join(artifactDir, "verify-results.json");
351
- const artifactBytes = Buffer.from(JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
352
- let artifactSha256 = null;
353
- let artifactAvailability = "capture-failed";
407
+ finally {
408
+ await removeWorktree(cwd, baseDir);
409
+ }
410
+ }
411
+ const gateRun = async (phaseGates, baseline) => (await runGates({ ...task, gates: phaseGates }, {
412
+ worktree: cwd, baseRef: mergeBase,
413
+ result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
414
+ author, ...(carriedAuthors.length ? { carriedAuthors } : {}), commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
415
+ evidence: options.evidence,
416
+ verificationScope: "standalone", artifactDir, stateDir: stagedStateDir,
417
+ onGate: async (e) => {
418
+ // Every gate boundary re-validates (and latches) the subject: see subjectChange below.
419
+ if (e.phase !== "note")
420
+ await subjectChange();
421
+ if (e.phase === "start")
422
+ console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
423
+ else if (e.phase === "note")
424
+ console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
425
+ else
426
+ console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
427
+ },
428
+ })).results;
429
+ const isGreen = (rows) => rows.length > 0 && rows.every((r) => r.pass || r.meta?.skipped === true);
430
+ const cheapBaseline = await baselineFor(cheapCommands);
431
+ if (cheapBaseline.refusal)
432
+ return { out: cheapBaseline.refusal, code: 1 };
433
+ // Unleased verifies of one checkout can start in the same millisecond: the timestamp only orders
434
+ // the directories, the exclusive suffix makes each invocation's artifact its own.
435
+ const artifactDir = mkdtempSync(join(stateDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-`));
436
+ // The store keys a green on the tree its command STARTED from, so a build that committed a fix would
437
+ // publish a green "for" the unfixed tree — and a verify killed before any cleanup would leave it for a
438
+ // later verify or discharge to reuse. So this invocation's gates read and write a store private to it:
439
+ // seeded only with verdicts a completed subject validation published, and published from only after
440
+ // every phase's subject check held (below). A killed invocation's staging is never read again.
441
+ const sharedVerdicts = getVerdictStore(join(stateRoot, ".tickmarkr"));
442
+ const stagedStateDir = join(artifactDir, "staged-verdicts");
443
+ const staged = getVerdictStore(stagedStateDir);
444
+ const storedRows = (dir) => readdirSync(dir).filter((f) => f.startsWith("verdict-") && f.endsWith(".json")).flatMap((file) => {
354
445
  try {
355
- writeFileSync(artifactPath, artifactBytes, { flag: "wx" });
356
- artifactSha256 = createHash("sha256").update(artifactBytes).digest("hex");
357
- artifactAvailability = "available";
358
- console.error(`verify: artifacts written to ${artifactPath}`);
446
+ return [{ file, record: JSON.parse(readFileSync(join(dir, file), "utf8")) }];
359
447
  }
360
448
  catch {
361
- console.error(`verify: evidence capture failed for ${artifactPath}`);
362
- }
363
- const review = reviewRows.at(-1);
364
- if (recordJournal && review) {
365
- recordJournal.append("review-leg2", values.task ?? "VERIFY", {
366
- base: baseTip, head, mergeBase, author: channelKey(author), artifactPath, artifactSha256, artifactAvailability,
367
- ...review,
368
- });
449
+ return [];
369
450
  }
370
- if (values.json) {
371
- return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, artifactSha256, artifactAvailability, results }, null, 2), code: green ? 0 : 2 };
451
+ });
452
+ for (const { file, record } of storedRows(sharedVerdicts.dir)) {
453
+ if (typeof record.verdict?.meta?.validatedSubject === "string")
454
+ copyFileSync(join(sharedVerdicts.dir, file), join(staged.dir, file));
455
+ }
456
+ // The subject is immutable: every row must answer for `head` on a clean tree. A gate command that
457
+ // commits or dirties the candidate (a build step landing a fix) measured other bytes than the verdict,
458
+ // artifact and any discharge name, so each phase AND each gate boundary (onGate start/end) is checked.
459
+ // The first change LATCHES: a later gate restoring HEAD (lint resetting the build's commit) or the
460
+ // tree never un-refuses it, and HEAD's reflog witnesses a move a single command made and undid.
461
+ // HEAD is re-read through the same reader that captured `head`, so both sides compare like for like.
462
+ let subjectLatch;
463
+ const subjectChange = async () => {
464
+ const now = await shGitOk("git rev-parse HEAD", cwd).then((out) => out.trim(), () => undefined);
465
+ const why = now === undefined ? "HEAD is unreadable"
466
+ : now !== head ? `HEAD moved from ${head.slice(0, 12)} to ${now.slice(0, 12)}`
467
+ : headLog() !== headLogAtCapture ? `HEAD moved and was restored to ${head.slice(0, 12)} (its reflog recorded the move)`
468
+ : await candidateDirt();
469
+ if (why)
470
+ subjectLatch ??= why;
471
+ return subjectLatch;
472
+ };
473
+ const refusedSubject = (why) => `refusing the verdict: the candidate changed while its gates ran, so no one subject carries every gate — ${why}`;
474
+ const cheapGates = gates.filter((g) => !leased.includes(g));
475
+ const changedBefore = await subjectChange();
476
+ let results = changedBefore
477
+ ? [{ gate: cheapGates[0], pass: false, details: refusedSubject(changedBefore), meta: { subjectChanged: changedBefore } }]
478
+ : await gateRun(cheapGates, cheapBaseline);
479
+ const changedAfter = changedBefore ? undefined : await subjectChange();
480
+ if (changedAfter)
481
+ results = results.map((r) => ({ ...r, pass: false, details: `${refusedSubject(changedAfter)}\n${r.details}`, meta: { ...r.meta, subjectChanged: changedAfter } }));
482
+ if (leased.length && isGreen(results)) {
483
+ // ...and the suite must measure those same bytes: its green may only join the AND verdict if they still hold.
484
+ const refused = (why) => `refusing the suite verdict: the candidate changed between semantic completion and suite completion, so no one subject carries every gate — ${why}`;
485
+ // OBS-1042: one runner root per repository across every linked worktree — the suite baseline and
486
+ // the candidate suite run under a reservation keyed on the common git dir; a waiter says who holds it.
487
+ const suite = await withRepositoryLease(cwd, async () => {
488
+ const before = await subjectChange();
489
+ if (before)
490
+ return [{ gate: "test", pass: false, details: refused(before), meta: { subjectChanged: before } }];
491
+ const baseline = await baselineFor(suiteCommands);
492
+ // The earlier gates already answered: a refused suite baseline is the test gate's red, and it
493
+ // joins their rows in the one verdict and artifact rather than discarding them.
494
+ if (baseline.refusal)
495
+ return [{ gate: "test", pass: false, details: baseline.refusal, meta: { infra: true, classification: "infra", retryable: false, kind: "workspace-dependency" } }];
496
+ const rows = await gateRun(leased, baseline);
497
+ const after = await subjectChange();
498
+ return after ? rows.map((r) => ({ ...r, pass: false, details: `${refused(after)}\n${r.details}`, meta: { ...r.meta, subjectChanged: after } })) : rows;
499
+ }, {
500
+ signal: executionSignal(),
501
+ onWait: (holder) => {
502
+ console.error(`verify: waiting for the repository's runner lease held by pid ${holder.pid} in ${holder.cwd}`);
503
+ recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd });
504
+ },
505
+ });
506
+ results = [...results, ...suite].sort((a, b) => GATE_NAMES.indexOf(a.gate) - GATE_NAMES.indexOf(b.gate));
507
+ }
508
+ // The last boundary. A change latched anywhere above refuses every row (so nothing is discharged) and
509
+ // publishes nothing; otherwise every gate answered for `head` on a clean tree, and this invocation's
510
+ // fresh verdicts are published, stamped with the subject they were validated against.
511
+ const changedAtEnd = await subjectChange();
512
+ if (changedAtEnd) {
513
+ results = results.map((r) => r.meta?.subjectChanged ? r : { ...r, pass: false, details: `${refusedSubject(changedAtEnd)}\n${r.details}`, meta: { ...r.meta, subjectChanged: changedAtEnd } });
514
+ }
515
+ else {
516
+ for (const { record } of storedRows(staged.dir)) {
517
+ if (record.verdict?.meta?.runDir === artifactDir)
518
+ sharedVerdicts.set(record.identity, { ...record.verdict, meta: { ...record.verdict.meta, validatedSubject: head } });
372
519
  }
373
- const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
374
- const verdict = green
375
- ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
376
- : `verify RED — first failure decides; artifacts: ${artifactPath}`;
377
- return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
378
520
  }
521
+ rmSync(stagedStateDir, { recursive: true, force: true });
522
+ const green = isGreen(results);
523
+ const reviewRows = results.filter((result) => result.gate === "review");
524
+ const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
525
+ const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
526
+ return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
527
+ }));
528
+ const artifactPath = join(artifactDir, "verify-results.json");
529
+ // `criteria` is the digest of the acceptance this invocation measured: a discharge's fold binds it.
530
+ const artifactBytes = Buffer.from(JSON.stringify({ base: baseTip, head, mergeBase, green, files, criteria: owedCriteria(acceptance), gateRows: results, reviewFindings }, null, 2) + "\n");
531
+ let artifactSha256 = null;
532
+ let artifactAvailability = "capture-failed";
533
+ try {
534
+ writeFileSync(artifactPath, artifactBytes, { flag: "wx" });
535
+ artifactSha256 = createHash("sha256").update(artifactBytes).digest("hex");
536
+ artifactAvailability = "available";
537
+ console.error(`verify: artifacts written to ${artifactPath}`);
538
+ }
539
+ catch {
540
+ console.error(`verify: evidence capture failed for ${artifactPath}`);
541
+ }
542
+ const review = reviewRows.at(-1);
543
+ // C1: ONE discharge row names what the fold re-reads — the artifact by hash, the range mapping, the
544
+ // criteria and each resolved channel — and it is appended only when the fold, the single decider,
545
+ // already accepts it: verify never writes a proof that would turn known debt into unknown debt.
546
+ let dischargeRefusal;
547
+ if (recordJournal && owed.length) {
548
+ const facts = (key) => channels.find((c) => channelKey(c) === key);
549
+ const reviewer = typeof review?.meta?.reviewer === "string" ? facts(review.meta.reviewer) : undefined;
550
+ const authorChannels = carriedAuthors.map(facts).filter((c) => c !== undefined);
551
+ const data = {
552
+ ids: owed.map((o) => o.id), gates: owed.map((o) => o.gate), mapping, mergeBase, head, criteria: owedCriteria(acceptance),
553
+ ...(mapping === "integration" ? { patch: owed[0].patch } : {}),
554
+ artifactPath, artifactSha256,
555
+ ...(reviewer ? { reviewer: { key: channelKey(reviewer), vendor: reviewer.vendor } } : {}),
556
+ authorChannels: authorChannels.map((c) => ({ key: channelKey(c), vendor: c.vendor })),
557
+ };
558
+ const candidate = green && artifactAvailability === "available"
559
+ ? foldOwedChecks([...recordJournal.read(), { ts: new Date().toISOString(), event: OWED_DISCHARGE_EVENT, taskId: values.task, data }], cwd)
560
+ : undefined;
561
+ if (!green)
562
+ dischargeRefusal = "the verdict is not green";
563
+ else if (!candidate)
564
+ dischargeRefusal = "the results artifact is unavailable";
565
+ else if (!candidate.known || !owed.every((o) => candidate.discharged.includes(o.id)))
566
+ dischargeRefusal = candidate.unknown.at(-1)?.reason ?? "the proof does not discharge the check";
567
+ else
568
+ recordJournal.append(OWED_DISCHARGE_EVENT, values.task, data);
569
+ }
570
+ const owedAfter = recordJournal ? foldOwedChecks(recordJournal.read(), cwd) : undefined;
571
+ const code = green && !dischargeRefusal ? 0 : 2;
572
+ const owedLines = owedAfter && (owed.length || owedAfter.acceptedRisk.length || !owedAfter.known)
573
+ ? [...(dischargeRefusal ? [`verify --record refused discharge: ${dischargeRefusal}`] : []), debtLine(owedAfter)] : [];
574
+ if (recordJournal && review) {
575
+ recordJournal.append("review-leg2", values.task ?? "VERIFY", {
576
+ base: baseTip, head, mergeBase, author: channelKey(author), artifactPath, artifactSha256, artifactAvailability,
577
+ ...review,
578
+ });
579
+ }
580
+ if (values.json) {
581
+ return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, artifactSha256, artifactAvailability, results,
582
+ ...(owedAfter && owedLines.length ? { owed: { debt: owedAfter.debt, discharged: dischargeRefusal ? [] : owed.map((o) => o.id), ...(dischargeRefusal ? { refused: dischargeRefusal } : {}) } } : {}),
583
+ }, null, 2), code };
584
+ }
585
+ const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
586
+ const verdict = green
587
+ ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
588
+ : `verify RED — first failure decides; artifacts: ${artifactPath}`;
589
+ return { out: [...lines, "", verdict, ...owedLines].join("\n"), code };
379
590
  }