tickmarkr 2.5.8 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/adapters/claude-code.js +19 -6
  2. package/dist/adapters/prompt.js +1 -1
  3. package/dist/adapters/qwen.js +30 -3
  4. package/dist/adapters/types.d.ts +3 -0
  5. package/dist/cli/commands/approve.js +15 -3
  6. package/dist/cli/commands/beat.d.ts +2 -0
  7. package/dist/cli/commands/beat.js +28 -29
  8. package/dist/cli/commands/compile.js +11 -0
  9. package/dist/cli/commands/fleet.js +61 -53
  10. package/dist/cli/commands/init.js +1 -1
  11. package/dist/cli/commands/report.js +11 -1
  12. package/dist/cli/commands/verify.d.ts +4 -1
  13. package/dist/cli/commands/verify.js +18 -5
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +6 -4
  16. package/dist/compile/native.js +39 -4
  17. package/dist/config/config.d.ts +41 -3
  18. package/dist/config/config.js +48 -17
  19. package/dist/config/fleet-overlay.js +47 -62
  20. package/dist/drivers/orca.d.ts +1 -1
  21. package/dist/drivers/orca.js +21 -4
  22. package/dist/gates/baseline.d.ts +67 -0
  23. package/dist/gates/baseline.js +172 -10
  24. package/dist/gates/cache.d.ts +8 -0
  25. package/dist/gates/cache.js +26 -13
  26. package/dist/gates/review.d.ts +8 -7
  27. package/dist/gates/review.js +83 -45
  28. package/dist/gates/run-gates.d.ts +4 -1
  29. package/dist/gates/run-gates.js +32 -12
  30. package/dist/gates/test-manifest.d.ts +16 -1
  31. package/dist/gates/test-manifest.js +127 -27
  32. package/dist/gates/test-reporter.js +4 -0
  33. package/dist/graph/graph.d.ts +1 -1
  34. package/dist/graph/graph.js +6 -1
  35. package/dist/graph/schema.d.ts +2 -0
  36. package/dist/graph/schema.js +2 -0
  37. package/dist/route/preference.d.ts +1 -1
  38. package/dist/route/preference.js +10 -39
  39. package/dist/run/daemon.d.ts +7 -1
  40. package/dist/run/daemon.js +237 -53
  41. package/dist/run/git.d.ts +10 -0
  42. package/dist/run/git.js +49 -1
  43. package/dist/run/journal.d.ts +18 -3
  44. package/dist/run/journal.js +141 -17
  45. package/dist/run/merge.d.ts +13 -2
  46. package/dist/run/merge.js +98 -12
  47. package/dist/run/protocol.d.ts +82 -0
  48. package/dist/run/protocol.js +35 -0
  49. package/dist/run/receipt-resolver.d.ts +18 -0
  50. package/dist/run/receipt-resolver.js +132 -0
  51. package/dist/run/supervision.d.ts +14 -1
  52. package/dist/run/supervision.js +122 -24
  53. package/dist/tui/cockpit/evidence-view.d.ts +10 -1
  54. package/dist/tui/cockpit/evidence-view.js +37 -5
  55. package/dist/tui/cockpit/live-runtime.d.ts +4 -0
  56. package/dist/tui/cockpit/live-runtime.js +37 -3
  57. package/dist/tui/cockpit/live-store.d.ts +2 -0
  58. package/dist/tui/ink/fleet-app.d.ts +12 -22
  59. package/dist/tui/ink/fleet-app.js +520 -131
  60. package/package.json +1 -1
  61. package/schema/rungraph.schema.json +7 -0
  62. package/skills/tickmarkr-overseer/SKILL.md +91 -41
  63. package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
@@ -33,9 +33,9 @@ import { cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModu
33
33
  import { runInteractiveSeed } from "./interactive-seed.js";
34
34
  import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
35
35
  import { applyScopeAmendments, activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingApprovalActions, pendingRechecks, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
36
- import { isDiffCapPark, pickReviewer } from "../gates/review.js";
36
+ import { gateReviewerFloor, isDiffCapPark, pickReviewer } from "../gates/review.js";
37
37
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
38
- import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
38
+ import { ensureIntegration, integrationBranch, integrationHead, mergeTask, reusedTipEvidence, verifyIntegrationTip } from "./merge.js";
39
39
  import { climbChannel, marginalCostRank, nextChannel, route } from "../route/router.js";
40
40
  import { desiredPanes } from "./reconcile.js";
41
41
  import { readTierLiveness, readWatchBoard, supervisionBeatPath } from "./supervision.js";
@@ -164,6 +164,27 @@ export function resolveRunMode(repoRoot, opts = {}) {
164
164
  : undefined;
165
165
  return { cfg: resolved.cfg, mode: resolved.mode, source, ...(conflict ? { conflict } : {}) };
166
166
  }
167
+ // Bind approval context before dispatch consumes the release. Restore reconstructs the same
168
+ // binding from journal order; a newer approval, including one without a reason, supersedes it.
169
+ function approvalReviewContext(events, taskId, restore = false) {
170
+ let pending;
171
+ let bound;
172
+ let approved = false;
173
+ for (const row of events) {
174
+ if (row.taskId !== taskId)
175
+ continue;
176
+ if (row.event === "task-approved") {
177
+ pending = typeof row.data.reason === "string" ? row.data.reason.trim() || undefined : undefined;
178
+ approved = true;
179
+ }
180
+ else if (row.event === "task-dispatch" || (row.event === "recheck-battery" && approved)) {
181
+ bound = pending;
182
+ pending = undefined;
183
+ approved = false;
184
+ }
185
+ }
186
+ return restore && !approved ? bound : pending;
187
+ }
167
188
  // T14: the events that prove an approval was ENACTED — narrowly causal, never merely subsequent.
168
189
  // Ordinary, attempt-cap and review-upheld approvals buy a WORKER, so their proof is a dispatch;
169
190
  // recheck has its own battery enactment below. Generic terminal events are deliberately NOT proof: an approved human-gate
@@ -664,7 +685,7 @@ function lastVerifyCycle(events) {
664
685
  if (e.event === "tip-verify-start") {
665
686
  const { tip, cmdHash } = e.data;
666
687
  cur = typeof tip === "string" && typeof cmdHash === "string"
667
- ? { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [e.data.capacity] }
688
+ ? { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [e.data.capacity], evidenceByGate: new Map() }
668
689
  : undefined;
669
690
  afterRunEnd = false;
670
691
  continue;
@@ -687,7 +708,7 @@ function lastVerifyCycle(events) {
687
708
  continue;
688
709
  }
689
710
  if (!cur || afterRunEnd || cur.tip !== tip || cur.cmdHash !== cmdHash) {
690
- cur = { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [] };
711
+ cur = { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [], evidenceByGate: new Map() };
691
712
  }
692
713
  afterRunEnd = false;
693
714
  // T7: EVERY verdict row's own capacity, not just the start row's. The start row is a statement of
@@ -699,6 +720,7 @@ function lastVerifyCycle(events) {
699
720
  cur.failed = true;
700
721
  else {
701
722
  cur.gates.add(gate);
723
+ cur.evidenceByGate.set(gate, e.data);
702
724
  // Q121s review M1: a forgiven green depends on the baseline that forgave it. Baselines are not
703
725
  // part of the cache key, so a forgiven cycle is NEVER cache-eligible — an unchanged tip whose
704
726
  // baseline later vanishes or changes must re-run the full verify, not inherit the green.
@@ -712,7 +734,8 @@ function lastVerifyCycle(events) {
712
734
  * OBS-1077 close rider: what the engagement's LATEST verification cycle proved — its
713
735
  * `tip-verify-start` row and what followed, never a commit comparison. Exactly one kind per close:
714
736
  * fresh (every tip command ran AND passed), reused (an eligible cached cycle carried forward),
715
- * failed, or incomplete (cancelled, cut short, mixed or undelimited). Nothing before the start row
737
+ * failed, or incomplete (cancelled, cut short or undelimited). Completed mixed cycles retain each
738
+ * gate's kind beside the whole-cycle reused kind. Nothing before the start row
716
739
  * is read, so an unfinished cycle inherits nothing from an earlier green one.
717
740
  */
718
741
  export function runEndTipProof(events) {
@@ -737,14 +760,25 @@ export function runEndTipProof(events) {
737
760
  if ((events[start].data.cached === true) !== cachedRow || (cachedRow && carried !== rows.length))
738
761
  return proof("incomplete");
739
762
  // D-131: fresh only when EVERY command executed; one per-gate persisted verdict makes the cycle reused.
740
- return carried > 0 ? proof("reused") : proof("fresh");
763
+ return {
764
+ ...proof(carried > 0 ? "reused" : "fresh"),
765
+ gates: gates.map((gate) => ({
766
+ gate: String(gate),
767
+ kind: rows.some((r) => r.data.gate === gate && r.data.cached === true) ? "reused" : "fresh",
768
+ })),
769
+ };
741
770
  }
742
771
  /** The close notification's statement of the proof — one clause per kind. */
743
772
  export function formatTipProof(p) {
744
773
  const at = p.tip ? ` ${p.tip.slice(0, 12)}` : "";
774
+ const gateReading = p.gates?.map(({ gate, kind }) => `${gate}: ${kind === "fresh" ? "verified fresh" : "cached (reused) — carried, not re-run"}`).join("; ");
775
+ const suffix = gateReading ? `; ${gateReading}` : "";
776
+ if (p.kind === "reused" && p.gates?.some(({ kind }) => kind === "fresh")) {
777
+ return `tip proof: ${p.kind} — commit${at}; ${gateReading}`;
778
+ }
745
779
  switch (p.kind) {
746
- case "fresh": return `tip proof: fresh — every tip command ran and passed on${at || " the integration tip"}`;
747
- case "reused": return `tip proof: reused — carried from verified commit${at}, commands not re-run`;
780
+ case "fresh": return `tip proof: fresh — every tip command ran and passed on${at || " the integration tip"}${suffix}`;
781
+ case "reused": return `tip proof: reused — carried from verified commit${at}, commands not re-run${suffix}`;
748
782
  case "failed": return `tip proof: failed —${at ? ` commit${at}` : ""} latest verification cycle is red`;
749
783
  case "incomplete": return `tip proof: incomplete —${at ? ` commit${at}` : ""} latest verification cycle did not finish`;
750
784
  }
@@ -909,7 +943,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
909
943
  // pass — `cached: true` keeps it honest about not having re-run the command.
910
944
  for (const gate of gates) {
911
945
  const cmd = gate === "test" && commands.tipTest ? commands.tipTest : commands[gate];
912
- journal.append("tip-verify", undefined, { gate, cmd, pass: true, exitCode: 0, cached: true, tip, cmdHash, capacity });
946
+ journal.append("tip-verify", undefined, { ...reusedTipEvidence(last.evidenceByGate.get(gate) ?? {}), gate, cmd, pass: true, exitCode: 0, cached: true, tip, cmdHash, capacity });
913
947
  }
914
948
  return false;
915
949
  }
@@ -918,12 +952,22 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
918
952
  ? cancellableTipBattery(intWt, commands, journal.dir, opts.baseline, opts.signal)
919
953
  : verifyIntegrationTip(intWt, commands, journal.dir, opts.baseline))) {
920
954
  const measuredCapacity = r.capacity ?? capacity;
955
+ const evidence = r.reused ? reusedTipEvidence(r) : {
956
+ ...(r.originRunRoot ? { originRunRoot: r.originRunRoot } : {}),
957
+ ...(r.cause ? { cause: r.cause } : {}),
958
+ ...(r.evidenceReceipt ? { evidenceReceipt: r.evidenceReceipt } : {}),
959
+ ...(r.evidenceReceipts ? { evidenceReceipts: r.evidenceReceipts } : {}),
960
+ ...(r.evidenceAbsence ? { evidenceAbsence: r.evidenceAbsence } : {}),
961
+ ...Object.fromEntries(["nonce", "stdoutPath", "stderrPath"]
962
+ .filter(key => r[key] !== undefined).map(key => [key, r[key]])),
963
+ };
921
964
  if (r.pass) {
922
965
  // Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
923
- journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.reused ? { cached: true } : {}), ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash, capacity: measuredCapacity });
966
+ journal.append("tip-verify", undefined, { ...evidence, gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.reused ? { cached: true } : {}), ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash, capacity: measuredCapacity });
924
967
  }
925
968
  else {
926
969
  journal.append("tip-verify-failed", undefined, {
970
+ ...evidence,
927
971
  gate: r.gate,
928
972
  cmd: r.cmd,
929
973
  exitCode: r.exitCode,
@@ -1444,6 +1488,77 @@ async function cherryPickCommits(wt, commits) {
1444
1488
  }
1445
1489
  return carried;
1446
1490
  }
1491
+ /** Fold lifetime dispatch/carry evidence, independent of attempt budgets and routing exclusions.
1492
+ * Recreation rows name SOURCE hashes, so ownership is joined by stable patch identity. Each
1493
+ * attempt owns only what the next carry (or current subject) adds beyond its own incoming set.
1494
+ * Gate-only recreations do not start an attempt or transfer authorship to the restored seat.
1495
+ */
1496
+ async function subjectAuthors(events, taskId, wt, base) {
1497
+ const cache = new Map();
1498
+ const patches = async (commits) => {
1499
+ const ids = new Set();
1500
+ for (const commit of commits) {
1501
+ if (!cache.has(commit)) {
1502
+ const diff = await shGit(`git show --format= --binary ${shq(commit)}`, wt);
1503
+ if (diff.code !== 0)
1504
+ throw new Error(`cannot read author patch ${commit}`);
1505
+ const id = execFileSync("git", ["patch-id", "--stable"], {
1506
+ cwd: wt, input: diff.stdout, encoding: "utf8", maxBuffer: 32 * 1024 * 1024,
1507
+ }).trim().split(/\s+/)[0];
1508
+ cache.set(commit, id || undefined); // an empty commit authored no patch
1509
+ }
1510
+ const id = cache.get(commit);
1511
+ if (id)
1512
+ ids.add(id);
1513
+ }
1514
+ return ids;
1515
+ };
1516
+ let current;
1517
+ let previous;
1518
+ let awaitingCarry = false;
1519
+ const owners = new Map();
1520
+ const attribute = (ids, attempt) => {
1521
+ for (const id of ids) {
1522
+ if (attempt?.incoming.has(id))
1523
+ continue;
1524
+ const authors = owners.get(id) ?? new Set();
1525
+ authors.add(attempt?.author ?? "unknown author (missing task-dispatch assignment)");
1526
+ owners.set(id, authors);
1527
+ }
1528
+ };
1529
+ try {
1530
+ for (const row of events) {
1531
+ if (row.taskId !== taskId)
1532
+ continue;
1533
+ if (row.event === "task-dispatch") {
1534
+ previous = current;
1535
+ const a = row.data.assignment;
1536
+ current = { author: typeof a?.adapter === "string" && typeof a?.model === "string"
1537
+ ? `${a.adapter}:${a.model}` : "unknown author (missing task-dispatch assignment)", incoming: new Set() };
1538
+ awaitingCarry = true;
1539
+ }
1540
+ else if (row.event === "worktree-recreation") {
1541
+ const carried = await patches(Array.isArray(row.data.carried) ? row.data.carried : []);
1542
+ attribute(carried, awaitingCarry ? previous : current);
1543
+ if (awaitingCarry && current)
1544
+ current.incoming = carried;
1545
+ awaitingCarry = false;
1546
+ }
1547
+ else if (["worker-launch", "worker-result", "gate-result", "task-human"].includes(row.event)) {
1548
+ // The initial checkout has no recreation row. Once work/gates start, a later
1549
+ // recreation is a restore of this attempt, not the input of a new dispatch.
1550
+ awaitingCarry = false;
1551
+ }
1552
+ }
1553
+ const subject = await patches(await commitsAheadOf(base, wt));
1554
+ attribute(subject, current);
1555
+ return [...new Set([...subject].flatMap((id) => [...(owners.get(id) ?? [])]))];
1556
+ }
1557
+ catch (error) {
1558
+ // An unreadable history cannot silently remove an author from the exclusion set.
1559
+ return [`unknown author (${String(error)})`];
1560
+ }
1561
+ }
1447
1562
  // T7 (v1.86): a first run-end append that fails AFTER partial bytes landed leaves a torn tail at
1448
1563
  // EOF with no newline; a blind retry would glue the run-end line onto those bytes and readJsonl's
1449
1564
  // torn-line tolerance would drop the retry too — no terminal record despite a successful write.
@@ -2550,6 +2665,9 @@ export async function runDaemon(repoRoot, opts = {}) {
2550
2665
  // run; a park-instead policy can come later if it ever bites.
2551
2666
  }
2552
2667
  const taskHistory = journal.read().filter((e) => e.taskId === t.id);
2668
+ // Lifetime identity counts every dispatch, including legacy and unchargeable rows. Releases
2669
+ // reset the budget below, never this counter; observational annotations do not consume it.
2670
+ let nextWorkerDispatchOrdinal = taskHistory.filter((e) => e.event === "task-dispatch").length;
2553
2671
  const lastApproval = taskHistory.map((e) => e.event).lastIndexOf("task-approved");
2554
2672
  const priorClimb = taskHistory.slice(lastApproval + 1).reverse().find((e) => e.event === "tier-escalated");
2555
2673
  if (!hintsChanged && priorClimb && taskHistory.indexOf(priorClimb) > taskHistory.map((e) => e.event).lastIndexOf("task-dispatch")) {
@@ -2566,7 +2684,10 @@ export async function runDaemon(repoRoot, opts = {}) {
2566
2684
  // exclusion-list-equality oracle. Daemon-side append only — no journal.ts write-path change (Phase 48
2567
2685
  // stays unblocked); inert to replayStatuses (unknown events ignored, pinned at journal.test.ts:70-80).
2568
2686
  if (rs)
2569
- journal.append("resume-restore", t.id, { attempts: rs.attempts, tried: [...tried], assignment });
2687
+ journal.append("resume-restore", t.id, {
2688
+ attempts: rs.attempts, tried: [...tried], assignment,
2689
+ workerDispatchOrdinal: previousDispatch?.data.workerDispatchOrdinal ?? null,
2690
+ });
2570
2691
  // Keep one live list: recovery retries must see exclusions added by onGate during the round.
2571
2692
  const badReviewers = [...replayedReviewerExclusions];
2572
2693
  const noteReviewEvent = (e) => {
@@ -2597,7 +2718,10 @@ export async function runDaemon(repoRoot, opts = {}) {
2597
2718
  const round = await runGates(task, ctx);
2598
2719
  let review = round.results.find((g) => g.gate === "review");
2599
2720
  while (review?.meta?.noVerdict === true) {
2600
- const next = pickReviewer(ctx.author, ctx.channels, badReviewers, cfg.review.prefer ?? [], task.routingHints?.floor, reviewHistory, undefined, demotedReviewers);
2721
+ // Recovery must honor the gate's floor, including the seats that just failed to
2722
+ // return a verdict; a below-floor alternative cannot replace the infra result.
2723
+ const { floor } = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers]);
2724
+ const next = pickReviewer(ctx.author, ctx.channels, badReviewers, cfg.review.prefer ?? [], floor, reviewHistory, undefined, demotedReviewers);
2601
2725
  if (!next)
2602
2726
  break;
2603
2727
  journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
@@ -2706,6 +2830,9 @@ export async function runDaemon(repoRoot, opts = {}) {
2706
2830
  ...(Array.isArray(g.meta?.selectedTests) ? { selectedTests: g.meta.selectedTests } : {}),
2707
2831
  ...(g.meta?.fullSuite === true ? { fullSuite: true } : {}),
2708
2832
  ...(g.meta?.reapedGroup === true ? { reapedGroup: true } : {}),
2833
+ ...(g.gate === "review" && g.meta?.noEligibleReviewer === true ? {
2834
+ noEligibleReviewer: true, authorVendors: g.meta.authorVendors, unresolvedAuthors: g.meta.unresolvedAuthors,
2835
+ } : {}),
2709
2836
  ...(g.gate === "review" && typeof g.meta?.reviewer === "string" ? {
2710
2837
  reviewer: g.meta.reviewer,
2711
2838
  ...Object.fromEntries(["cause", "seatAuthoredBytes", "bytes", "rawPath", "briefPath", "timeoutMs", "unparseable", "noVerdict", "resolved", "reraised", "reviewerFloor", "reviewerFloorCause", "reviewerTier"]
@@ -2733,6 +2860,14 @@ export async function runDaemon(repoRoot, opts = {}) {
2733
2860
  // every recalibration this telemetry funds, a gap is honest and a zero is a lie. The
2734
2861
  // seven-gate closed set is asserted end-to-end in tests/run/gate-telemetry.test.ts.
2735
2862
  ...gateMeasurement(g.meta),
2863
+ // Carried receipts retain their original invocation and artifact root.
2864
+ ...(g.evidenceReceipt ? { evidenceReceipt: g.evidenceReceipt } : {}),
2865
+ ...(g.evidenceReceipts ? { evidenceReceipts: g.evidenceReceipts } : {}),
2866
+ ...(g.meta?.reused ? {
2867
+ reused: true,
2868
+ ...(g.originRunRoot ? { originRunRoot: g.originRunRoot } : {}),
2869
+ } : Object.fromEntries(["nonce", "stdoutPath", "stderrPath", "classification"]
2870
+ .filter(key => g.meta?.[key] !== undefined).map(key => [key, g.meta[key]]))),
2736
2871
  // T7: the capacity the gate's own command child ran under, lifted verbatim from the result
2737
2872
  // the battery produced — read where the shell built that child's environment, never
2738
2873
  // re-derived from the run's own budget, which would answer a different number than the
@@ -2960,7 +3095,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2960
3095
  // OBS-130/T15: both gate-resume paths consume the persisted task branch with no worker dispatch.
2961
3096
  // An operator approval skips its exact failed gate by authority; observed results skip only the
2962
3097
  // contiguous green prefix whose recorded commit is still the task branch tip.
2963
- const satisfiedGate = satisfiedGates.get(t.id);
3098
+ let satisfiedGate = satisfiedGates.get(t.id);
2964
3099
  const replayedGates = fundedRerun ? undefined : replayedGateResults.get(t.id);
2965
3100
  const recheck = approvalAction?.authority === "battery";
2966
3101
  resumeGateReplay: if (satisfiedGate || replayedGates || recheck) {
@@ -2997,6 +3132,10 @@ export async function runDaemon(repoRoot, opts = {}) {
2997
3132
  // than against the stale worktree whose task-only history cannot see newly merged dependencies.
2998
3133
  const currentTaskTip = await gitHead(wt);
2999
3134
  const currentTaskSubject = await gateCommitSubject(taskBase, currentTaskTip, wt);
3135
+ // Recheck carries only review authority for the exact subject being gated, never tool greens.
3136
+ if (recheck) {
3137
+ satisfiedGate = journal.replaySatisfiedGates(new Map([[t.id, currentTaskSubject]])).get(t.id);
3138
+ }
3000
3139
  journal.append("worktree-recreation", t.id, { attempted: commitsToCarry, carried: carriedCommits });
3001
3140
  // OBS-212: same fail-closed rule as the dispatch path — but this path is worse, because it runs
3002
3141
  // ONLY the gates after the approved one and then MERGES. T3 took it on run-20260728-110135:
@@ -3066,9 +3205,11 @@ export async function runDaemon(repoRoot, opts = {}) {
3066
3205
  continue;
3067
3206
  priorResults.set(e.data.gate, e);
3068
3207
  }
3208
+ const declaredGates = GATE_NAMES.filter((gate) => t.gates.includes(gate));
3209
+ const reused = [];
3069
3210
  let remainingGates;
3070
3211
  if (recheck) {
3071
- remainingGates = GATE_NAMES.filter((gate) => t.gates.includes(gate));
3212
+ remainingGates = declaredGates.filter((gate) => gate !== satisfiedGate);
3072
3213
  }
3073
3214
  else if (satisfiedGate) {
3074
3215
  remainingGates = t.gates.filter((gate) => {
@@ -3119,48 +3260,40 @@ export async function runDaemon(repoRoot, opts = {}) {
3119
3260
  });
3120
3261
  }
3121
3262
  let reusable = sameWorld && sameProtocol && (exactCurrentCommit || canonicalCurrentCommit || recreatedLegacyCommit);
3122
- const reused = [];
3123
- const declaredGates = GATE_NAMES.filter((gate) => t.gates.includes(gate));
3124
3263
  for (const gate of declaredGates) {
3125
3264
  if (!reusable || replayedGates.results.get(gate) !== true)
3126
3265
  reusable = false;
3127
3266
  else
3128
3267
  reused.push(gate);
3129
3268
  }
3130
- // OBS-1049: build outputs are not commits. A replayed build verdict says the tree WAS green;
3131
- // it says nothing about whether the RECREATED checkout holds what that build produced (dist),
3132
- // and the host's npm lifecycle (ignore-scripts) may never rebuild it before the suite runs.
3133
- // So a replayed build re-runs its own command here as provisioning, journaled by name; a red
3134
- // provisioning is not reused — build (and everything behind it) re-enters the battery as a gate.
3135
- // Every resume on this path recreates the checkout (recreateTaskWorktree above), so "reused
3136
- // build" is exactly "build replayed onto a recreated tree".
3137
- // Journal order is the criterion's order: a GREEN provisioning is recorded AFTER the reuse
3138
- // rows it belongs to (gate-reused build, then gate-provisioned build); a RED provisioning is
3139
- // recorded alone, and no reuse row follows it.
3140
- let provisionedRow;
3141
- if (reused.includes("build") && commands.build !== undefined) {
3142
- // Provisioning is a verification command like any gate: it waits for the run's baseline and
3143
- // takes the command lease (suite admission), never a bare shell beside sibling gates.
3144
- await waitForBaseline(t.id);
3145
- const startedAt = Date.now();
3146
- const provisioned = await withCommandContext(t.id, () => sh(commands.build, wt));
3147
- provisionedRow = {
3148
- gate: "build", commit: replayedGates.commit, exitCode: provisioned.code, durationMs: Date.now() - startedAt,
3149
- };
3150
- if (provisioned.code !== 0) {
3151
- reused.splice(reused.indexOf("build"));
3152
- journal.append("gate-provisioned", t.id, provisionedRow);
3153
- provisionedRow = undefined;
3154
- }
3155
- }
3156
- for (const gate of reused) {
3157
- journal.append("gate-reused", t.id, { gate, commit: replayedGates.commit });
3158
- }
3159
- if (provisionedRow !== undefined)
3160
- journal.append("gate-provisioned", t.id, provisionedRow);
3161
3269
  remainingGates = declaredGates.slice(reused.length);
3162
3270
  }
3271
+ // OBS-1094/1049: every replay recreates the checkout, but build outputs are not commits.
3272
+ // Provision whenever build will not run as a gate, including an operator waiver restore.
3273
+ // Defer reuse rows until provisioning succeeds: green keeps reuse-then-provision order;
3274
+ // red records only provisioning and puts build and every declared successor back in gates,
3275
+ // unless build itself was waived: provisioning must not undo the operator's release.
3276
+ let provisionedRow;
3277
+ if (!remainingGates.includes("build") && commands.build !== undefined) {
3278
+ await waitForBaseline(t.id);
3279
+ const startedAt = Date.now();
3280
+ const provisioned = await withCommandContext(t.id, () => sh(commands.build, wt));
3281
+ provisionedRow = {
3282
+ gate: "build", commit: !satisfiedGate && !recheck ? replayedGates.commit : currentTaskSubject,
3283
+ exitCode: provisioned.code, durationMs: Date.now() - startedAt,
3284
+ };
3285
+ if (provisioned.code !== 0 && satisfiedGate !== "build") {
3286
+ reused.length = 0;
3287
+ remainingGates = ["build", ...declaredGates.filter((gate) => gate !== "build")];
3288
+ }
3289
+ }
3290
+ for (const gate of reused) {
3291
+ journal.append("gate-reused", t.id, { gate, commit: replayedGates.commit });
3292
+ }
3293
+ if (provisionedRow !== undefined)
3294
+ journal.append("gate-provisioned", t.id, provisionedRow);
3163
3295
  const resumedTask = { ...t, gates: remainingGates };
3296
+ const operatorContext = approvalReviewContext(journal.read(), t.id, true);
3164
3297
  gateLoop: while (true) {
3165
3298
  fatalStop.signal.throwIfAborted();
3166
3299
  executionSignal()?.throwIfAborted();
@@ -3173,11 +3306,20 @@ export async function runDaemon(repoRoot, opts = {}) {
3173
3306
  // deterministic-fingerprint occurrence/review round for budget accounting.
3174
3307
  ...(!satisfiedGate && !recheck ? { replayMeasurement: true } : {}),
3175
3308
  };
3309
+ if (recheck && satisfiedGate === "review" && gateSubject.commit !== currentTaskSubject) {
3310
+ satisfiedGate = undefined;
3311
+ resumedTask.gates = declaredGates;
3312
+ }
3313
+ if (recheck && satisfiedGate === "review" && !resumedTask.gates.includes("review")) {
3314
+ journal.append("gate-waiver-carried", t.id, { gate: "review", commit: gateSubject.commit, carried: true, release: RECHECK_RELEASE });
3315
+ }
3176
3316
  await trackedDriver.project?.(t.id, "in-review");
3177
3317
  await waitForBaseline(t.id);
3178
3318
  journal.phaseStart(t.id, "gates");
3179
- const { results } = await withCommandContext(t.id, () => runReviewRecovery(resumedTask, {
3319
+ const { results } = await withCommandContext(t.id, async () => runReviewRecovery(resumedTask, {
3320
+ carriedAuthors: await subjectAuthors(journal.read(), t.id, wt, taskBase),
3180
3321
  carriedFindings: outstandingReviewFindings(journal.read(), t.id),
3322
+ operatorContext,
3181
3323
  worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
3182
3324
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
3183
3325
  collateral: collateral.get(t.id) ?? [],
@@ -3194,7 +3336,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3194
3336
  excludeReviewers: badReviewers,
3195
3337
  reviewHistory, demotedReviewers, priorReviewers: taskReviewers(t.id),
3196
3338
  // Leg-2 (OBS-1052): the run-scoped two-strike tally — without it retirement is inert and a
3197
- // flaking seat is re-asked on every task. (carriedAuthors is held back: see SURGEON-LEG2-REPORT.)
3339
+ // flaking seat is re-asked on every task.
3198
3340
  reviewNoVerdicts,
3199
3341
  recheck, // OBS-1055: a recheck discards cached reds — the battery re-measures what the operator questioned
3200
3342
  onGate: async (e) => {
@@ -3231,6 +3373,11 @@ export async function runDaemon(repoRoot, opts = {}) {
3231
3373
  graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
3232
3374
  saveGraph(repoRoot, graph);
3233
3375
  if (!results.every(gateSatisfied)) {
3376
+ const unavailableReview = results.find((g) => gateFailed(g) && g.meta?.noEligibleReviewer === true);
3377
+ if (unavailableReview) {
3378
+ await park(t, gateFailApprovalReason(t.id, unavailableReview.details, true), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
3379
+ return;
3380
+ }
3234
3381
  const infra = results.find((g) => g.meta?.infra === true);
3235
3382
  if (infra) {
3236
3383
  await park(t, `${infra.gate}: ${infra.details}`, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
@@ -3259,7 +3406,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3259
3406
  // Observed green gates are only measurements. If the resumed suffix is red, preserve that
3260
3407
  // result in the journal and return to the ordinary attempt/consult ladder, which rebuilds
3261
3408
  // feedback from those rows. Only an operator-authorized gate release parks on a new red.
3262
- if (!satisfiedGate) {
3409
+ if (!satisfiedGate || recheck) {
3263
3410
  // OBS-1055: a recheck red funds a repair of the pin's own work. A pinned task's repair sits
3264
3411
  // on the pin (OBS-1034's exemption keeps the tried list from excluding it); a pin the fleet
3265
3412
  // cannot seat parks naming the pin — never the ladder.
@@ -3425,6 +3572,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3425
3572
  // fresh re-dispatches. Read from the JOURNAL, before this dispatch's own event lands: a run that
3426
3573
  // stopped between funding the repair and sending it resumes still carrying the findings.
3427
3574
  const journaledSoFar = journal.read(); // read BEFORE this dispatch's own event lands
3575
+ const operatorContext = approvalReviewContext(journaledSoFar, t.id);
3428
3576
  let repairFindings = pendingRepairFindings(journaledSoFar, t.id);
3429
3577
  // OBS-254, one layer below the upheld brief: the ordinary gate-fail brief was loop-local, so any
3430
3578
  // path that rebuilt this task's state (a resume, `--retry-failed`, a fresh daemon) dispatched a
@@ -3515,9 +3663,10 @@ export async function runDaemon(repoRoot, opts = {}) {
3515
3663
  // ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
3516
3664
  // spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
3517
3665
  fatalStop.signal.throwIfAborted();
3666
+ const workerDispatchOrdinal = nextWorkerDispatchOrdinal++;
3518
3667
  journal.append("task-dispatch", t.id, {
3519
3668
  ...(scopeApproval?.data.release === "scope-request" ? { files: t.files, graphDefinitionHash: graphDefinitionHash(graph) } : {}),
3520
- assignment, attempt, provenance: dispatchProvenance([
3669
+ assignment, attempt, workerDispatchOrdinal, provenance: dispatchProvenance([
3521
3670
  channelKey(assignment) === channelKey(r.assignment) ? r.provenance
3522
3671
  : t.routingHints?.pin ? `pin ${t.routingHints.pin.via}:${t.routingHints.pin.model} not re-tried` : "ladder assignment",
3523
3672
  `dispatch ${channelKey(assignment)}`, climbProvenance,
@@ -3624,7 +3773,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3624
3773
  // anything the live brief holds beyond the journaled findings (a consult's guidance) is kept:
3625
3774
  // a repair adds the diff and the fix-only contract, it never subtracts what was already known.
3626
3775
  feedback = feedback && !brief.includes(feedback) ? `${brief}\n\n${feedback}` : brief;
3627
- journal.append("repair-dispatch", t.id, { diffBytes: Buffer.byteLength(diff, "utf8"), capped, ...(capped ? { droppedFiles } : {}) });
3776
+ journal.append("repair-dispatch", t.id, { workerDispatchOrdinal, diffBytes: Buffer.byteLength(diff, "utf8"), capped, ...(capped ? { droppedFiles } : {}) });
3628
3777
  }
3629
3778
  if (feedback || priorNamed.length > 0) {
3630
3779
  feedback = augmentRetryBrief(feedback, { attempted: commitsToCarry, carried: carriedCommits, present: presentCommits });
@@ -3664,6 +3813,20 @@ export async function runDaemon(repoRoot, opts = {}) {
3664
3813
  Object.defineProperty(workerSlotOpts, "agent", { value: assignment.adapter });
3665
3814
  const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, workerSlotOpts);
3666
3815
  const sessionId = retryMode === "resume" ? priorSession.id : slot.name;
3816
+ const readResumeTranscript = () => {
3817
+ try {
3818
+ const sample = adapter.readSessionTranscript?.({ cwd: wt, id: sessionId });
3819
+ return sample && Number.isSafeInteger(sample.bytes) && sample.bytes >= 0 ? sample : null;
3820
+ }
3821
+ catch {
3822
+ return null;
3823
+ }
3824
+ };
3825
+ const resumeBaseline = retryMode === "resume" ? readResumeTranscript() : null;
3826
+ if (retryMode === "resume")
3827
+ journal.append("worker-resume-requested", t.id, {
3828
+ sessionId, attempt, workerDispatchOrdinal, baselineBytes: resumeBaseline?.bytes ?? null,
3829
+ });
3667
3830
  const icmd = retryMode === "resume"
3668
3831
  ? adapter.resumeCommand(sessionId, promptFile, assignment.model)
3669
3832
  : cfg.visibility.worker === "interactive" && driver.interactive
@@ -3691,6 +3854,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3691
3854
  const groupFile = `${dispatchScript}.${nonce}.pgid`;
3692
3855
  workerOwners.set(slot, { taskId: t.id, attempt, groupFile });
3693
3856
  writeFileSync(dispatchScript, [
3857
+ // Shell startup can swallow the driver's leading cd; the payload owns its checkout too.
3858
+ `cd ${shq(wt)} || exit 1`,
3694
3859
  // A driver may launch inside the daemon's group. That group is never worker-owned.
3695
3860
  `worker_pgid=$(ps -o pgid= -p $$ 2>/dev/null); daemon_pgid=$(ps -o pgid= -p ${process.pid} 2>/dev/null)`,
3696
3861
  `if [ -n "$worker_pgid" ] && [ -n "$daemon_pgid" ] && [ "$worker_pgid" != "$daemon_pgid" ]; then printf '%s\\n' "$worker_pgid" > ${shq(groupFile)}; fi`,
@@ -3723,6 +3888,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3723
3888
  journal.append("worker-launch", t.id, {
3724
3889
  attempt,
3725
3890
  retryMode,
3891
+ ...(retryMode === "resume" ? { sessionId, workerDispatchOrdinal } : {}),
3726
3892
  driver: trackedDriver.id,
3727
3893
  slot: { ...slot },
3728
3894
  workspace: driver.id === "herdr" ? process.env.HERDR_WORKSPACE_ID : undefined,
@@ -4750,6 +4916,17 @@ export async function runDaemon(repoRoot, opts = {}) {
4750
4916
  else {
4751
4917
  await closeSlot(slot);
4752
4918
  }
4919
+ if (retryMode === "resume") {
4920
+ const transcript = readResumeTranscript();
4921
+ const identity = resumeBaseline === null || transcript === null
4922
+ ? "unknown" : transcript.bytes > resumeBaseline.bytes ? "confirmed" : "unconfirmed";
4923
+ journal.append("worker-resume-identity", t.id, {
4924
+ sessionId, attempt, workerDispatchOrdinal, identity,
4925
+ baselineBytes: resumeBaseline?.bytes ?? null, observedBytes: transcript?.bytes ?? null,
4926
+ // This is evidence of file growth, not an independent runtime identity handshake.
4927
+ assumption: "external runtime appends to the requested session's own transcript",
4928
+ });
4929
+ }
4753
4930
  // SPEND-01: usage from the harness's own cwd-keyed structured store, read POST-HOC from disk —
4754
4931
  // `wt` is this task's private worktree, so the path is unique; the read is sliced to records
4755
4932
  // stamped at/after this attempt's dispatch instant. Never the harvested pane text, never the
@@ -5091,8 +5268,10 @@ export async function runDaemon(repoRoot, opts = {}) {
5091
5268
  }
5092
5269
  }
5093
5270
  else {
5094
- ({ results, commits } = await withCommandContext(t.id, () => runReviewRecovery(t, {
5271
+ ({ results, commits } = await withCommandContext(t.id, async () => runReviewRecovery(t, {
5272
+ carriedAuthors: await subjectAuthors(journal.read(), t.id, wt, taskBase),
5095
5273
  carriedFindings: outstandingFindings,
5274
+ operatorContext,
5096
5275
  worktree: wt, baseRef: taskBase, result, author: assignment,
5097
5276
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
5098
5277
  collateral: collateral.get(t.id) ?? [],
@@ -5185,6 +5364,11 @@ export async function runDaemon(repoRoot, opts = {}) {
5185
5364
  // already journaled by onGate, before gateFails and before any ladder selection can fund an
5186
5365
  // identical retry in the same environment. Parsed judge refusals never carry infra and keep
5187
5366
  // flowing through the chargeable quality path below.
5367
+ const unavailableReview = results.find((g) => gateFailed(g) && g.meta?.noEligibleReviewer === true);
5368
+ if (unavailableReview) {
5369
+ await park(t, gateFailApprovalReason(t.id, unavailableReview.details, true), "gate-fail", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
5370
+ return;
5371
+ }
5188
5372
  const infraFailure = results.find((g) => gateFailed(g) && g.meta?.infra === true);
5189
5373
  if (infraFailure) {
5190
5374
  await park(t, `${infraFailure.gate}: ${infraFailure.details}${infraFailure.meta?.recoveryBlocked ? ` — ${infraFailure.meta.recoveryBlocked}` : ""}`, "infra", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
package/dist/run/git.d.ts CHANGED
@@ -190,6 +190,16 @@ export declare function resolveIntegrationBranch(_repo: string, branch: string):
190
190
  */
191
191
  export declare function preserveWorktree(cwd: string): Promise<string | undefined>;
192
192
  export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
193
+ export interface DependencyLink {
194
+ /** Package link, relative to node_modules (including @scope/name). */
195
+ link: string;
196
+ target: string;
197
+ classification: "worktree" | "node_modules" | "outside";
198
+ }
199
+ /** Inventory only package-level links; never descend into installed packages. */
200
+ export declare function inventoryDependencyLinks(worktree: string): DependencyLink[];
201
+ /** Shared pre-command refusal; unreadable inventories cannot establish isolation either. */
202
+ export declare function dependencyLinkRefusal(worktree: string): string | undefined;
193
203
  export declare function linkNodeModules(repo: string, dir: string, { force }?: {
194
204
  force?: boolean | undefined;
195
205
  }): boolean;
package/dist/run/git.js CHANGED
@@ -4,7 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { execFileSync, spawn } from "node:child_process";
5
5
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
6
6
  import { availableParallelism, homedir, tmpdir } from "node:os";
7
- import { join, resolve } from "node:path";
7
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { StringDecoder } from "node:string_decoder";
9
9
  import { shq } from "../adapters/types.js";
10
10
  import { tickmarkrDir } from "../graph/graph.js";
@@ -624,6 +624,54 @@ function excludeNodeModules(dir) {
624
624
  }
625
625
  catch { /* not a git checkout (bare tmpdir in tests) — never fail provisioning over the exclude */ }
626
626
  }
627
+ /** Inventory only package-level links; never descend into installed packages. */
628
+ export function inventoryDependencyLinks(worktree) {
629
+ const dependencyPath = join(worktree, "node_modules");
630
+ try {
631
+ lstatSync(dependencyPath);
632
+ }
633
+ catch (error) {
634
+ if (error.code === "ENOENT")
635
+ return [];
636
+ throw error;
637
+ }
638
+ const root = realpathSync(worktree);
639
+ const dependencies = realpathSync(dependencyPath);
640
+ const inside = (parent, target) => {
641
+ const rel = relative(parent, target);
642
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
643
+ };
644
+ const links = [];
645
+ const inspect = (link) => {
646
+ const path = join(dependencies, link);
647
+ if (!lstatSync(path).isSymbolicLink())
648
+ return;
649
+ const target = realpathSync(path);
650
+ links.push({ link, target, classification: inside(root, target) ? "worktree"
651
+ : inside(dependencies, target) ? "node_modules" : "outside" });
652
+ };
653
+ for (const entry of readdirSync(dependencies).sort()) {
654
+ inspect(entry);
655
+ if (entry.startsWith("@") && statSync(join(dependencies, entry)).isDirectory()) {
656
+ for (const child of readdirSync(join(dependencies, entry)).sort())
657
+ inspect(join(entry, child));
658
+ }
659
+ }
660
+ return links;
661
+ }
662
+ /** Shared pre-command refusal; unreadable inventories cannot establish isolation either. */
663
+ export function dependencyLinkRefusal(worktree) {
664
+ try {
665
+ const outside = inventoryDependencyLinks(worktree).filter(link => link.classification === "outside");
666
+ if (!outside.length)
667
+ return undefined;
668
+ return "infra: OBS-1118 refusing verification: dependency links resolve outside the worktree and real node_modules:\n"
669
+ + outside.map(({ link, target }) => `node_modules/${link} -> ${target}`).join("\n");
670
+ }
671
+ catch (error) {
672
+ return `infra: OBS-1118 refusing verification: cannot inventory dependency links in ${worktree}: ${String(error)}`;
673
+ }
674
+ }
627
675
  export function linkNodeModules(repo, dir, { force = false } = {}) {
628
676
  const src = join(repo, "node_modules");
629
677
  const dest = join(dir, "node_modules");