tickmarkr 2.6.3 → 2.6.5

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