tickmarkr 2.5.5 → 2.5.6

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 (48) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/commands/approve.js +64 -3
  3. package/dist/cli/commands/doctor.d.ts +10 -0
  4. package/dist/cli/commands/fleet.js +4 -0
  5. package/dist/cli/commands/plan.js +20 -4
  6. package/dist/cli/commands/verify.js +102 -81
  7. package/dist/config/config.d.ts +11 -0
  8. package/dist/config/config.js +21 -12
  9. package/dist/config/fleet-overlay.js +55 -17
  10. package/dist/drivers/index.js +2 -1
  11. package/dist/drivers/orca.d.ts +21 -1
  12. package/dist/drivers/orca.js +209 -27
  13. package/dist/gates/baseline.d.ts +14 -3
  14. package/dist/gates/baseline.js +61 -14
  15. package/dist/gates/cache.d.ts +14 -13
  16. package/dist/gates/cache.js +17 -5
  17. package/dist/gates/llm.d.ts +3 -0
  18. package/dist/gates/llm.js +11 -0
  19. package/dist/gates/review.d.ts +28 -3
  20. package/dist/gates/review.js +106 -14
  21. package/dist/gates/run-gates.d.ts +6 -1
  22. package/dist/gates/run-gates.js +299 -101
  23. package/dist/gates/test-manifest.d.ts +30 -1
  24. package/dist/gates/test-manifest.js +113 -39
  25. package/dist/gates/test-reporter.js +14 -6
  26. package/dist/graph/graph.d.ts +2 -0
  27. package/dist/graph/graph.js +45 -1
  28. package/dist/run/consult.js +5 -4
  29. package/dist/run/daemon.d.ts +2 -0
  30. package/dist/run/daemon.js +480 -99
  31. package/dist/run/execution-budget.d.ts +25 -0
  32. package/dist/run/execution-budget.js +142 -0
  33. package/dist/run/git.d.ts +40 -0
  34. package/dist/run/git.js +89 -4
  35. package/dist/run/journal.d.ts +3 -1
  36. package/dist/run/journal.js +38 -16
  37. package/dist/run/lease.d.ts +44 -0
  38. package/dist/run/lease.js +226 -3
  39. package/dist/run/recovery.d.ts +8 -0
  40. package/dist/run/recovery.js +25 -0
  41. package/dist/run/repair-selection.d.ts +12 -0
  42. package/dist/run/repair-selection.js +56 -0
  43. package/dist/run/stall.d.ts +6 -1
  44. package/dist/run/stall.js +60 -3
  45. package/dist/tui/ink/fleet-app.d.ts +10 -2
  46. package/dist/tui/ink/fleet-app.js +33 -15
  47. package/package.json +2 -2
  48. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
package/README.md CHANGED
@@ -52,7 +52,7 @@ prefer, every example below works with either.
52
52
 
53
53
  - **macOS or Linux.** Every command shells out through `bash`, so native Windows is not
54
54
  supported — use WSL (untested).
55
- - Node ≥ 20 and `git` on PATH.
55
+ - Node ≥ 20.3 and `git` on PATH.
56
56
  - At least one agent CLI on PATH (`claude`, `codex`, `cursor-agent`, `opencode`, `grok`, `pi`,
57
57
  or `kimi`), authenticated through its own login. tickmarkr never handles vendor API keys itself.
58
58
 
@@ -476,6 +476,8 @@ accepted contributions are credited via `Co-authored-by:` on the release commit.
476
476
 
477
477
  ## Documentation
478
478
 
479
+ - [Experimental execution policy](docs/experimental-execution-policy.md) — bounded recovery, cumulative task time, and repair selection.
480
+
479
481
  - **[FLEET.md](https://github.com/alzahrani-khalid/tickmarkr/blob/main/FLEET.md)** — routing modes, steering syntax, tier provenance, and run flags (advanced reference)
480
482
  - **[LICENSE](LICENSE)** — MIT license
481
483
  - **[CONTRIBUTING.md](CONTRIBUTING.md)** — development setup and contribution guidelines
@@ -1,5 +1,8 @@
1
1
  import { graphDefinitionHash, loadGraph, saveGraph } from "../../graph/graph.js";
2
+ import { execFileSync } from "node:child_process";
2
3
  import { userInfo } from "node:os";
4
+ import { loadConfig } from "../../config/config.js";
5
+ import { integrationBranch } from "../../run/merge.js";
3
6
  import { separabilityErrors } from "../../compile/collateral.js";
4
7
  import { GATE_NAMES } from "../../graph/schema.js";
5
8
  import { applyScopeAmendments, engagementComparable, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
@@ -85,6 +88,8 @@ export function permittedDecisionVerbs(park) {
85
88
  }
86
89
  if (park.kind === "infra")
87
90
  return ["approve", "recheck"];
91
+ if (park.kind === "diff-cap")
92
+ return ["recheck"]; // OBS-1007: a cap trip is re-gated under a raised cap, never re-bought
88
93
  return ["approve"];
89
94
  }
90
95
  /** The release marker this command appends for a verb on a park — the fact a read-back must match. */
@@ -138,6 +143,21 @@ export async function approve(argv, cwd = process.cwd()) {
138
143
  if (status === undefined) {
139
144
  throw new Error(`task ${taskId} has no events in run ${runId} — unknown task or never dispatched`);
140
145
  }
146
+ // OBS-1022: a task-failed task whose preserved ref (or task branch) carries commits ahead of the
147
+ // integration base owns verified-able work; --recheck re-gates that tree with no worker. Any other
148
+ // verb on a failed task, or a recheck over nothing landed, is refused naming why.
149
+ if (status === "failed" && recheck) {
150
+ const ahead = failedTaskCommitsAhead(cwd, journal, runId, taskId);
151
+ if (ahead.count === 0) {
152
+ throw new Error(`task ${taskId} is failed and ${ahead.ref} carries no commits ahead of ${ahead.base} — nothing to re-gate; use resume --retry-failed to fund a worker`);
153
+ }
154
+ journal.append("task-approved", taskId, {
155
+ by, ...(reason ? { reason } : {}), via: "cli", release: RECHECK_RELEASE,
156
+ recheckedRef: ahead.ref, commitsAhead: ahead.count,
157
+ ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
158
+ });
159
+ return disposition(cwd, runId, "re-dispatch", `re-checking failed ${taskId} in ${runId} — by ${by}; ${ahead.count} commit(s) on ${ahead.ref} ahead of ${ahead.base}; no worker funded`, serialization.contended);
160
+ }
141
161
  if (status !== "human") {
142
162
  // a silent no-op would be worse than a loud refusal — name the actual status (D-05)
143
163
  throw new Error(`task ${taskId} is ${status}, not a parked human gate — refusing (a silent no-op would be worse)`);
@@ -149,7 +169,7 @@ export async function approve(argv, cwd = process.cwd()) {
149
169
  const lastHuman = park === undefined ? undefined : events[park.index];
150
170
  const capPark = park?.kind === ATTEMPT_CAP_RELEASE;
151
171
  const gateFailPark = park?.kind === "gate-fail";
152
- const infraPark = park?.kind === "infra";
172
+ const infraPark = park?.kind === "infra" || park?.kind === "diff-cap";
153
173
  const failedGate = gateFailPark ? park?.failedGate : undefined;
154
174
  if ((park?.kind === "scope-request" && !decisions) || files !== undefined) {
155
175
  if (park?.kind !== "scope-request")
@@ -205,7 +225,7 @@ export async function approve(argv, cwd = process.cwd()) {
205
225
  }
206
226
  if (recheck) {
207
227
  if ((!gateFailPark || !failedGate) && !infraPark) {
208
- throw new Error(`--recheck applies to a gate-fail or infra park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
228
+ throw new Error(`--recheck applies to a gate-fail, infra or diff-cap park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
209
229
  }
210
230
  journal.append("task-approved", taskId, {
211
231
  by,
@@ -214,7 +234,7 @@ export async function approve(argv, cwd = process.cwd()) {
214
234
  release: RECHECK_RELEASE,
215
235
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
216
236
  });
217
- return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; ${failedGate ? `failed gate ${failedGate}` : "infra park"}; no gate marked satisfied`, serialization.contended);
237
+ return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; ${failedGate ? `failed gate ${failedGate}` : `${park.kind} park`}; no gate marked satisfied`, serialization.contended);
218
238
  }
219
239
  if (waive) {
220
240
  if (!gateFailPark || !failedGate) {
@@ -242,6 +262,11 @@ export async function approve(argv, cwd = process.cwd()) {
242
262
  if (park?.tombstone) {
243
263
  throw new Error(`task ${taskId}'s newest park is a tombstone (${park.reason ?? "no reason"}) — permanent by design; no verb releases it`);
244
264
  }
265
+ // OBS-1007: a cap trip is re-gated under a raised cap, never re-bought — plain approve would fund a
266
+ // worker the production oracle (permittedDecisionVerbs) says this park cannot buy.
267
+ if (park?.kind === "diff-cap") {
268
+ throw new Error(`task ${taskId} is parked on a diff cap; plain approve would fund another worker — raise gates.diffCap and pass --recheck (disposition re-dispatch)`);
269
+ }
245
270
  journal.append("task-approved", taskId, {
246
271
  by,
247
272
  ...(reason ? { reason } : {}),
@@ -256,6 +281,42 @@ export async function approve(argv, cwd = process.cwd()) {
256
281
  serialization.release();
257
282
  }
258
283
  }
284
+ /**
285
+ * OBS-1022: what a failed task has landed. A preserved snapshot is authoritative only when it contains
286
+ * the current task-branch tip; an older preservation can remain in the journal after a later retry has
287
+ * advanced/rebuilt the branch. Missing task branches may still recover from a preserved ref. The selected
288
+ * ref is counted against the run's integration branch; unreadable refs count as zero and fail closed.
289
+ */
290
+ function failedTaskCommitsAhead(cwd, journal, runId, taskId) {
291
+ const cfg = loadConfig(cwd);
292
+ const base = integrationBranch(cfg, runId);
293
+ const taskRef = `${base}--${taskId}`;
294
+ const preserved = [...journal.read()].reverse().find((e) => e.taskId === taskId && e.event === "worktree-preserved" && typeof e.data.ref === "string");
295
+ const preservedRef = preserved?.data.ref;
296
+ let ref = taskRef;
297
+ if (preservedRef) {
298
+ try {
299
+ execFileSync("git", ["rev-parse", "--verify", taskRef], { cwd, stdio: "ignore" });
300
+ execFileSync("git", ["merge-base", "--is-ancestor", taskRef, preservedRef], { cwd, stdio: "ignore" });
301
+ ref = preservedRef;
302
+ }
303
+ catch {
304
+ try {
305
+ execFileSync("git", ["rev-parse", "--verify", taskRef], { cwd, stdio: "ignore" });
306
+ }
307
+ catch {
308
+ ref = preservedRef;
309
+ }
310
+ }
311
+ }
312
+ try {
313
+ const out = execFileSync("git", ["rev-list", "--count", `${base}..${ref}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
314
+ return { ref, base, count: Number.parseInt(out.trim(), 10) || 0 };
315
+ }
316
+ catch {
317
+ return { ref, base, count: 0 };
318
+ }
319
+ }
259
320
  // The status is command OUTPUT, not a typed sibling result: the registered approve function returns
260
321
  // one primitive string and the dispatcher prints those bytes unchanged. A compact sentinel followed
261
322
  // by JSON makes the contract unambiguous to machines without widening the shared command result type.
@@ -203,6 +203,11 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
203
203
  worker: "interactive" | "print";
204
204
  workersPerTab: number;
205
205
  };
206
+ executionPolicy?: {
207
+ boundedInfrastructure: boolean;
208
+ repairSelection: boolean;
209
+ taskExecutionLimitMs: number;
210
+ } | undefined;
206
211
  setup?: string | undefined;
207
212
  cost?: {
208
213
  models?: Record<string, {
@@ -322,6 +327,11 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
322
327
  worker: "interactive" | "print";
323
328
  workersPerTab: number;
324
329
  };
330
+ executionPolicy?: {
331
+ boundedInfrastructure: boolean;
332
+ repairSelection: boolean;
333
+ taskExecutionLimitMs: number;
334
+ } | undefined;
325
335
  setup?: string | undefined;
326
336
  cost?: {
327
337
  models?: Record<string, {
@@ -324,6 +324,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
324
324
  ...structuredClone(initial),
325
325
  denyAdapters: deny.adapters,
326
326
  denyModels: deny.models,
327
+ // OBS-1046: the allow complement is staged beside the deny lists, never folded into them
328
+ allowOut: deny.allowOut ?? initial.allowOut,
327
329
  denyWorkersAdapters: deny.workersAdapters,
328
330
  denyWorkersModels: deny.workersModels,
329
331
  map,
@@ -644,6 +646,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
644
646
  const staged = structuredClone(initial);
645
647
  staged.denyAdapters = state.denyAdapters;
646
648
  staged.denyModels = state.denyModels;
649
+ staged.allowOut = state.allowOut ?? initial.allowOut;
647
650
  // OBS-994/FL-1: the workers-only deny scope rides the same review/write funnel as the flat one.
648
651
  staged.denyWorkersAdapters = state.denyWorkersAdapters;
649
652
  staged.denyWorkersModels = state.denyWorkersModels;
@@ -699,6 +702,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
699
702
  health,
700
703
  initialDenyAdapters: editable.denyAdapters,
701
704
  initialDenyModels: editable.denyModels,
705
+ initialAllowOut: editable.allowOut,
702
706
  initialDenyWorkersAdapters: editable.denyWorkersAdapters,
703
707
  initialDenyWorkersModels: editable.denyWorkersModels,
704
708
  modelGroups,
@@ -7,7 +7,7 @@ import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
7
7
  import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
8
8
  import { classifyContextPath } from "../../compile/native.js";
9
9
  import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
10
- import { graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
10
+ import { chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
11
11
  import { renderAcceptanceItem } from "../../graph/schema.js";
12
12
  import { resolveRunMode } from "../../run/daemon.js";
13
13
  import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
@@ -364,11 +364,27 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
364
364
  : "";
365
365
  return { line: `${channelKey(reviewer)}${rotationNote} — reviewPolicy full`, cost: seatCost(reviewer) };
366
366
  };
367
+ // OBS-1018: depth and wave come from the same functions the daemon admits by, so the plan shows the
368
+ // critical path the run will actually follow at this concurrency.
369
+ const depths = chainDepth(g);
370
+ const waves = dispatchWaves(g, cfg.concurrency);
371
+ const byId = new Map(g.tasks.map((t) => [t.id, t]));
372
+ const chainLine = (t) => {
373
+ const wave = waves.get(t.id);
374
+ // Map absence never implies status: a pending task stays wave-less when a dependency is
375
+ // running/gated/failed/human, so render the real status and name the blocking deps.
376
+ const tail = wave !== undefined
377
+ ? `wave ${wave} at concurrency ${cfg.concurrency}`
378
+ : t.status !== "pending"
379
+ ? `no wave (${t.status})`
380
+ : `no wave (pending; blocked by ${t.deps.filter((d) => byId.get(d)?.status !== "done").map((d) => `${d}:${byId.get(d)?.status ?? "missing"}`).join(", ")})`;
381
+ return ` chain: depth ${depths.get(t.id)} · ${tail}`;
382
+ };
367
383
  const routed = [];
368
384
  for (const t of g.tasks) {
369
385
  const refusals = [...(oracleRefusals.get(t.id) ?? []), ...(inputRefusals.get(t.id) ?? [])];
370
386
  if (refusals.length) {
371
- lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! pre-dispatch refusal — ${refusals.join("; ")}`);
387
+ lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! pre-dispatch refusal — ${refusals.join("; ")}`, chainLine(t));
372
388
  continue;
373
389
  }
374
390
  try {
@@ -382,7 +398,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
382
398
  const judge = judgeSeat(t);
383
399
  const review = reviewSeat(t, r.assignment);
384
400
  cost += est + judge.cost + review.cost;
385
- lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`, ` judge: ${judge.line}${judge.cost ? ` ~$${judge.cost.toFixed(2)}` : ""}`, ` review: ${review.line}${review.cost ? ` ~$${review.cost.toFixed(2)}` : ""}`);
401
+ lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`, ` judge: ${judge.line}${judge.cost ? ` ~$${judge.cost.toFixed(2)}` : ""}`, ` review: ${review.line}${review.cost ? ` ~$${review.cost.toFixed(2)}` : ""}`, chainLine(t));
386
402
  const d = derivation(t.shape);
387
403
  if (d)
388
404
  lines.push(d);
@@ -406,7 +422,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
406
422
  if (!(e instanceof RoutingError))
407
423
  throw e;
408
424
  const msg = `${e.message}${exclusionReason(e.message)}`;
409
- lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! ${msg}`);
425
+ lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! ${msg}`, chainLine(t));
410
426
  const d = derivation(t.shape);
411
427
  if (d)
412
428
  lines.push(d);
@@ -6,13 +6,15 @@ import { parseArgs } from "node:util";
6
6
  import { allAdapters, probeAll, readDoctor, rolePools } from "../../adapters/registry.js";
7
7
  import { channelKey } from "../../adapters/types.js";
8
8
  import { loadConfig } from "../../config/config.js";
9
- import { captureBaseline, detectGateCommands } from "../../gates/baseline.js";
9
+ import { captureBaseline, detectGateCommands, staleFileCountCommands } from "../../gates/baseline.js";
10
10
  import { modelProvider } from "../../gates/review.js";
11
11
  import { runGates } from "../../gates/run-gates.js";
12
12
  import { getTask, loadGraph } from "../../graph/graph.js";
13
13
  import { GATE_NAMES } from "../../graph/schema.js";
14
+ import { executionSignal } from "../../run/execution-budget.js";
14
15
  import { linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
15
16
  import { Journal } from "../../run/journal.js";
17
+ import { withRepositoryLease } from "../../run/lease.js";
16
18
  /**
17
19
  * tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
18
20
  *
@@ -249,95 +251,114 @@ export async function verify(argv, cwd = process.cwd()) {
249
251
  // ponytail: tmpdir means the baseline cache dies on reboot/cleanup — worst case is one re-capture.
250
252
  const stateDir = verifyStateDir(cwd);
251
253
  mkdirSync(stateDir, { recursive: true });
252
- let baseline;
253
- const cachePath = baselineCachePath(cwd, mergeBase, commands);
254
- const verdictlessMarker = `${cachePath}.verdictless`;
255
- mkdirSync(dirname(cachePath), { recursive: true });
256
- if (values.baseline) {
257
- baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
258
- }
259
- else {
260
- let cached;
261
- if (existsSync(cachePath)) {
262
- cached = JSON.parse(readFileSync(cachePath, "utf8"));
263
- const missing = verdictlessCommands(cached, commands);
264
- if (missing.length) {
265
- console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
266
- rmSync(cachePath, { force: true });
267
- cached = undefined;
268
- }
269
- }
270
- if (cached) {
271
- console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
272
- baseline = cached;
254
+ const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
255
+ // OBS-1042: one runner root per repository across every linked worktree — the capture and the
256
+ // battery below run under a reservation keyed on the common git dir, and a waiter says who holds it.
257
+ return withRepositoryLease(cwd, () => leasedVerify(), {
258
+ signal: executionSignal(),
259
+ onWait: (holder) => {
260
+ console.error(`verify: waiting for the repository's runner lease held by pid ${holder.pid} in ${holder.cwd}`);
261
+ recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd });
262
+ },
263
+ });
264
+ async function leasedVerify() {
265
+ let baseline;
266
+ const cachePath = baselineCachePath(cwd, mergeBase, commands);
267
+ const verdictlessMarker = `${cachePath}.verdictless`;
268
+ mkdirSync(dirname(cachePath), { recursive: true });
269
+ if (values.baseline) {
270
+ baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
273
271
  }
274
272
  else {
275
- if (existsSync(verdictlessMarker)) {
276
- console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
277
- }
278
- else {
279
- console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
280
- }
281
- const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
282
- await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
283
- await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
284
- try {
285
- linkNodeModules(cwd, baseDir, { force: true });
286
- baseline = await captureBaseline(baseDir, commands);
287
- const missing = verdictlessCommands(baseline, commands);
273
+ let cached;
274
+ if (existsSync(cachePath)) {
275
+ cached = JSON.parse(readFileSync(cachePath, "utf8"));
276
+ const missing = verdictlessCommands(cached, commands);
288
277
  if (missing.length) {
289
- writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
278
+ console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
290
279
  rmSync(cachePath, { force: true });
280
+ cached = undefined;
291
281
  }
292
- else {
293
- writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
294
- rmSync(verdictlessMarker, { force: true });
282
+ // OBS-1044: a cached vitest count that is a stdout sum may be inflated by nested runner echo;
283
+ // applying it would manufacture a deficit. Only a manifest-derived count is a floor.
284
+ const stale = cached ? staleFileCountCommands(cached, commands, cwd) : [];
285
+ if (stale.length) {
286
+ console.error(`verify: cached baseline's file count for ${stale.join(", ")} is not manifest-derived; it will be recaptured`);
287
+ rmSync(cachePath, { force: true });
288
+ cached = undefined;
295
289
  }
296
290
  }
297
- finally {
298
- await removeWorktree(cwd, baseDir);
291
+ if (cached) {
292
+ console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
293
+ baseline = cached;
294
+ }
295
+ else {
296
+ if (existsSync(verdictlessMarker)) {
297
+ console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
298
+ }
299
+ else {
300
+ console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
301
+ }
302
+ const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
303
+ await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
304
+ await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
305
+ try {
306
+ linkNodeModules(cwd, baseDir, { force: true });
307
+ baseline = await captureBaseline(baseDir, commands);
308
+ const missing = verdictlessCommands(baseline, commands);
309
+ if (missing.length) {
310
+ writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
311
+ rmSync(cachePath, { force: true });
312
+ }
313
+ else {
314
+ writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
315
+ rmSync(verdictlessMarker, { force: true });
316
+ }
317
+ }
318
+ finally {
319
+ await removeWorktree(cwd, baseDir);
320
+ }
299
321
  }
300
322
  }
301
- }
302
- const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
303
- const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
304
- mkdirSync(artifactDir, { recursive: true });
305
- const { results } = await runGates(task, {
306
- worktree: cwd, baseRef: mergeBase,
307
- result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
308
- author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
309
- pipeline: "v185", verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
310
- onGate: (e) => {
311
- if (e.phase === "start")
312
- console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
313
- else if (e.phase === "note")
314
- console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
315
- else
316
- console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
317
- },
318
- });
319
- const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
320
- const reviewRows = results.filter((result) => result.gate === "review");
321
- const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
322
- const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
323
- return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
324
- }));
325
- const artifactPath = join(artifactDir, "verify-results.json");
326
- writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
327
- console.error(`verify: artifacts written to ${artifactPath}`);
328
- const review = reviewRows.at(-1);
329
- if (recordJournal && review) {
330
- recordJournal.append("review-leg2", values.task ?? "VERIFY", {
331
- base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
332
- ...review,
323
+ const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
324
+ mkdirSync(artifactDir, { recursive: true });
325
+ const { results } = await runGates(task, {
326
+ worktree: cwd, baseRef: mergeBase,
327
+ result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
328
+ author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
329
+ verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
330
+ onGate: (e) => {
331
+ if (e.phase === "start")
332
+ console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
333
+ else if (e.phase === "note")
334
+ console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
335
+ else
336
+ console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
337
+ },
333
338
  });
339
+ const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
340
+ const reviewRows = results.filter((result) => result.gate === "review");
341
+ const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
342
+ const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
343
+ return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
344
+ }));
345
+ const artifactPath = join(artifactDir, "verify-results.json");
346
+ writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
347
+ console.error(`verify: artifacts written to ${artifactPath}`);
348
+ const review = reviewRows.at(-1);
349
+ if (recordJournal && review) {
350
+ recordJournal.append("review-leg2", values.task ?? "VERIFY", {
351
+ base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
352
+ ...review,
353
+ });
354
+ }
355
+ if (values.json) {
356
+ return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
357
+ }
358
+ const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
359
+ const verdict = green
360
+ ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
361
+ : `verify RED — first failure decides; artifacts: ${artifactPath}`;
362
+ return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
334
363
  }
335
- if (values.json) {
336
- return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
337
- }
338
- const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
339
- const verdict = green
340
- ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
341
- : `verify RED — first failure decides; artifacts: ${artifactPath}`;
342
- return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
343
364
  }
@@ -132,7 +132,17 @@ export declare function effectiveReviewPolicy(files: ReadonlyArray<string>, revi
132
132
  * under-flagged one skips the only gate that exists for it.
133
133
  */
134
134
  export declare function criticalPathHits(files: ReadonlyArray<string>, criticalPaths?: ReadonlyArray<string>): string[];
135
+ export declare const ExecutionPolicySchema: z.ZodObject<{
136
+ boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
137
+ repairSelection: z.ZodDefault<z.ZodBoolean>;
138
+ taskExecutionLimitMs: z.ZodNumber;
139
+ }, z.core.$strict>;
135
140
  export declare const TickmarkrConfigSchema: z.ZodObject<{
141
+ executionPolicy: z.ZodOptional<z.ZodObject<{
142
+ boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
143
+ repairSelection: z.ZodDefault<z.ZodBoolean>;
144
+ taskExecutionLimitMs: z.ZodNumber;
145
+ }, z.core.$strict>>;
136
146
  concurrency: z.ZodNumber;
137
147
  driver: z.ZodEnum<{
138
148
  auto: "auto";
@@ -366,6 +376,7 @@ export type FleetEditable = {
366
376
  denyModels: string[];
367
377
  denyWorkersAdapters?: string[];
368
378
  denyWorkersModels?: string[];
379
+ allowOut?: string[];
369
380
  tiers: Record<string, Record<string, FleetTierAssignment | null>>;
370
381
  map: Record<string, MapEntry>;
371
382
  floors: Record<string, Tier>;
@@ -264,7 +264,14 @@ const ShapeGateParticipationSchema = z
264
264
  });
265
265
  }
266
266
  });
267
+ export const ExecutionPolicySchema = z.object({
268
+ boundedInfrastructure: z.boolean().default(false),
269
+ repairSelection: z.boolean().default(false),
270
+ taskExecutionLimitMs: z.number().int().positive(),
271
+ }).strict();
267
272
  export const TickmarkrConfigSchema = z.object({
273
+ // Opt-in recovery experiment. The original run snapshot owns this across resumes.
274
+ executionPolicy: ExecutionPolicySchema.optional(),
268
275
  concurrency: z.number().int().positive(),
269
276
  driver: z.enum(["auto", "herdr", "subprocess", "orca"]),
270
277
  integrationBranchPrefix: z
@@ -902,12 +909,13 @@ export function universeEntryMatches(row, model, entry) {
902
909
  export function universeCovers(universe, entry) {
903
910
  return universe.some((row) => entry === row.adapter || row.models.some((m) => entry === m || entry === `${row.adapter}:${m}`));
904
911
  }
905
- // v1.92 fleet membership: with a discovered `universe` (classified models only) the deny sets become
906
- // the EFFECTIVE out-of-fleet set — the routing.allow complement (fail-closed) as adapter ids and
907
- // adapter:model keys, UNION every authored routing.deny adapters/models entry VERBATIM in its own list
908
- // (never workers). LEG2-T3 round 2 finding 2: authored entries are never collapsed into a membership
909
- // key, so one Space press clears one reason; finding 3: allow admission honours recorded identity.
910
- // Absent universe ⇒ deny arrays verbatim, so non-UI callers stay byte-identical.
912
+ // v1.92 fleet membership: with a discovered `universe` (classified models only) the routing.allow
913
+ // complement (fail-closed) rides as `allowOut` — adapter ids and adapter:model keys — BESIDE the
914
+ // authored routing.deny adapters/models entries, which ride VERBATIM in their own lists (never
915
+ // workers). LEG2-T3 round 2 finding 2: authored entries are never collapsed into a membership key;
916
+ // OBS-1046: nor is an allow exclusion folded into a deny list — the two reasons stay distinct so
917
+ // one Space press clears one; finding 3: allow admission honours recorded identity.
918
+ // Absent universe ⇒ deny arrays verbatim and no allowOut, so non-UI callers stay byte-identical.
911
919
  export function fleetEditableFromConfig(cfg, universe) {
912
920
  const tiers = {};
913
921
  for (const [adapter, entry] of Object.entries(cfg.tiers)) {
@@ -916,8 +924,9 @@ export function fleetEditableFromConfig(cfg, universe) {
916
924
  tiers[adapter][model] = { tier };
917
925
  }
918
926
  }
919
- let denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
920
- let denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
927
+ const denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
928
+ const denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
929
+ let allowOut;
921
930
  if (universe !== undefined) {
922
931
  const { allow } = cfg.routing;
923
932
  const allowEntries = allow ? [...(allow.adapters ?? []), ...(allow.models ?? [])] : null;
@@ -933,14 +942,14 @@ export function fleetEditableFromConfig(cfg, universe) {
933
942
  modelsOut.push(...out.map((m) => `${row.adapter}:${m}`));
934
943
  }
935
944
  }
936
- // OBS-517: authored deny entries — covered by the probe universe or not — ride verbatim, so a
937
- // transient probe failure can never un-deny one on the next write (fail-open on the ban list).
938
- denyAdapters = [...new Set([...adaptersOut, ...denyAdapters])].sort();
939
- denyModels = [...new Set([...modelsOut, ...denyModels])].sort();
945
+ // OBS-517: authored deny entries — covered by the probe universe or not — ride verbatim (above),
946
+ // so a transient probe failure can never un-deny one on the next write (fail-open on the ban list).
947
+ allowOut = [...new Set([...adaptersOut, ...modelsOut])].sort();
940
948
  }
941
949
  return {
942
950
  denyAdapters,
943
951
  denyModels,
952
+ ...(allowOut !== undefined ? { allowOut } : {}),
944
953
  // OBS-994: workers-only deny is never folded into the all-seats complement above — it is
945
954
  // returned verbatim beside the flat scopes so the fleet browser can show and toggle it.
946
955
  denyWorkersAdapters: [...(cfg.routing.deny?.workers?.adapters ?? [])].sort(),