tickmarkr 2.2.1 → 2.3.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 (48) hide show
  1. package/README.md +10 -9
  2. package/dist/adapters/types.d.ts +20 -1
  3. package/dist/adapters/types.js +42 -2
  4. package/dist/cli/commands/approve.js +5 -4
  5. package/dist/cli/commands/beat.js +7 -4
  6. package/dist/cli/commands/doctor.d.ts +5 -1
  7. package/dist/cli/commands/doctor.js +67 -7
  8. package/dist/cli/commands/init.js +36 -21
  9. package/dist/cli/commands/plan.js +16 -2
  10. package/dist/cli/commands/report.js +37 -1
  11. package/dist/cli/commands/verify.d.ts +5 -0
  12. package/dist/cli/commands/verify.js +140 -25
  13. package/dist/compile/collateral.js +15 -9
  14. package/dist/compile/native.js +3 -3
  15. package/dist/config/config.js +1 -1
  16. package/dist/drivers/index.d.ts +6 -0
  17. package/dist/drivers/index.js +19 -4
  18. package/dist/drivers/orca.d.ts +22 -1
  19. package/dist/drivers/orca.js +147 -6
  20. package/dist/drivers/subprocess.d.ts +3 -3
  21. package/dist/drivers/subprocess.js +16 -9
  22. package/dist/drivers/types.d.ts +2 -0
  23. package/dist/gates/baseline.d.ts +2 -0
  24. package/dist/gates/baseline.js +21 -4
  25. package/dist/gates/llm.d.ts +6 -0
  26. package/dist/gates/llm.js +25 -9
  27. package/dist/gates/review.d.ts +3 -1
  28. package/dist/gates/review.js +40 -12
  29. package/dist/gates/run-gates.js +17 -10
  30. package/dist/gates/verdict-cause.d.ts +6 -2
  31. package/dist/gates/verdict-cause.js +8 -4
  32. package/dist/run/consult.js +5 -1
  33. package/dist/run/daemon.d.ts +12 -0
  34. package/dist/run/daemon.js +218 -25
  35. package/dist/run/git.d.ts +1 -0
  36. package/dist/run/git.js +4 -0
  37. package/dist/run/journal.d.ts +15 -2
  38. package/dist/run/journal.js +70 -12
  39. package/dist/run/supervision.d.ts +6 -0
  40. package/dist/run/supervision.js +29 -1
  41. package/dist/tui/ink/init-app.js +4 -4
  42. package/package.json +1 -1
  43. package/skills/tickmarkr-overseer/SKILL.md +77 -18
  44. package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
  45. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
  46. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
  47. package/skills/tickmarkr-overseer/scripts/watch-context.sh +33 -7
  48. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +32 -8
@@ -23,9 +23,9 @@ import { GATE_NAMES } from "../graph/schema.js";
23
23
  import { distFingerprint } from "../cli/commands/version.js";
24
24
  import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
25
25
  import { runEnvironment } from "./environment.js";
26
- import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
26
+ import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, SUITE_PARENT_ENV, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
27
27
  import { runInteractiveSeed } from "./interactive-seed.js";
28
- import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, renderStructuredReviewFinding, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
28
+ import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRechecks, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
29
29
  import { isDiffCapPark } from "../gates/review.js";
30
30
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
31
31
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
@@ -69,12 +69,13 @@ export function resolveRunMode(repoRoot, opts = {}) {
69
69
  return { cfg: resolved.cfg, mode: resolved.mode, source, ...(conflict ? { conflict } : {}) };
70
70
  }
71
71
  // T14: the events that prove an approval was ENACTED — narrowly causal, never merely subsequent.
72
- // An ordinary approval (no release, attempt-cap, recheck, review-upheld) all buy a WORKER, so the
73
- // proof is a dispatch. Generic terminal events are deliberately NOT proof: an approved human-gate
72
+ // Ordinary, attempt-cap and review-upheld approvals buy a WORKER, so their proof is a dispatch;
73
+ // recheck has its own battery enactment below. Generic terminal events are deliberately NOT proof: an approved human-gate
74
74
  // task can fail in routing before task-dispatch, and the catch appends task-failed — treating that
75
75
  // as enactment reports "complete" over an approval that never ran (the exact silent-completion this
76
76
  // task exists to kill).
77
77
  const DISPATCH_ENACTMENT = new Set(["task-dispatch", "repair-dispatch"]);
78
+ const RECHECK_ENACTMENT = "recheck-battery";
78
79
  // The ONE approval that enacts without buying a worker: GATE_SATISFIED_RELEASE resumes from the
79
80
  // persisted task branch after the approved gate (execTask's satisfiedGate branch), whose first act
80
81
  // for the task is worktree-recreation. That event is causal for this path, not incidental.
@@ -97,9 +98,13 @@ export function outstandingApprovals(events) {
97
98
  newest.set(e.taskId, i); });
98
99
  return [...newest]
99
100
  .filter(([taskId, i]) => {
100
- const noWorker = events[i].data.release === GATE_SATISFIED_RELEASE;
101
+ const release = events[i].data.release;
102
+ const noWorker = release === GATE_SATISFIED_RELEASE;
103
+ const recheck = release === RECHECK_RELEASE;
101
104
  return !events.slice(i + 1).some((e) => e.taskId === taskId
102
- && (DISPATCH_ENACTMENT.has(e.event) || (noWorker && e.event === GATE_SATISFIED_ENACTMENT)));
105
+ && (DISPATCH_ENACTMENT.has(e.event)
106
+ || (noWorker && e.event === GATE_SATISFIED_ENACTMENT)
107
+ || (recheck && e.event === RECHECK_ENACTMENT)));
103
108
  })
104
109
  .map(([taskId]) => taskId)
105
110
  .sort();
@@ -165,6 +170,19 @@ const isOracleFailure = (g) => g.details.startsWith("oracle failed:");
165
170
  */
166
171
  export const gateSatisfied = (g) => (g.pass || g.meta?.skipped === true) && g.meta?.infra !== true;
167
172
  const gateFailed = (g) => !gateSatisfied(g);
173
+ const SIGNAL_EXIT_RE = /\b(?:SIGTERM|SIGKILL|signal\s+(?:9|15)|exit(?:s|ed|\s+code)?\s+(?:137|143))\b/i;
174
+ const FAILURE_IDENTITY_RE = /\b(?:AssertionError|FAIL\s+\S|Tests?\s+\d+\s+failed|expected\s+.+\s+to\s+)\b/i;
175
+ /** A signalled test runner with no failure identity produced no verdict about HEAD. Baseline owns the
176
+ * ordinary infra vocabulary; this daemon-only rider handles the signal-shaped non-verdict before its
177
+ * journal row and repair accounting are written. */
178
+ function classifySignalOnlyTest(g) {
179
+ if (g.gate !== "test" || g.pass || g.meta?.infra === true || !SIGNAL_EXIT_RE.test(g.details))
180
+ return;
181
+ const named = Array.isArray(g.meta?.failingTests) && g.meta.failingTests.length > 0;
182
+ if (named || FAILURE_IDENTITY_RE.test(g.details))
183
+ return;
184
+ g.meta = { ...g.meta, classification: "infra", infra: true, retryable: false, kind: "signal-exit" };
185
+ }
168
186
  // v1.85 T3: the gates whose failure IS a deterministic measurement — a machine re-ran a command over a
169
187
  // tree and printed the same bytes. Those are the failures the fingerprint cap governs (the ruling names
170
188
  // it a "deterministic-gate" cap): a third identical answer to a question already answered twice is the
@@ -270,6 +288,8 @@ function approvedReviewRoundCeiling(events, taskId) {
270
288
  return undefined;
271
289
  }
272
290
  const BLOCKED_POLL_MS = 30_000; // between trailer-wait slices, check whether the pane is blocked on a prompt
291
+ export const SUITE_POLL_MS = 250;
292
+ export const APPROVAL_POLL_MS = 250;
273
293
  const PROVIDER_DEATH_REQUEUE_CAP = 2; // v1.46 T1: requeue same assignment twice, then fall through to the normal ladder
274
294
  const PROVIDER_DEATH_BACKOFF_MS = 500; // short backoff before provider-death requeue
275
295
  const NO_TRAILER_DEMOTION_STREAK = 2; // OBS-57: consecutive no-trailer windows demote a channel for the rest of the run
@@ -558,6 +578,106 @@ async function observeWorkerProcessTree(marker, cwd) {
558
578
  }
559
579
  return tree.size === 0 ? "empty" : "running";
560
580
  }
581
+ const SUITE_COMMAND_RE = /(?:^|[\s/])(vitest(?:\.mjs)?|jest|mocha)(?:[\s/]|$)|\bnpm(?:\s+run)?\s+test\b/i;
582
+ function processCwd(pid) {
583
+ try {
584
+ return realpathSync(readlinkSync(`/proc/${pid}/cwd`));
585
+ }
586
+ catch { /* Darwin has no /proc */ }
587
+ try {
588
+ const out = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
589
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
590
+ });
591
+ const path = out.split("\n").find((line) => line.startsWith("n"))?.slice(1);
592
+ return path ? realpathSync(path) : undefined;
593
+ }
594
+ catch {
595
+ return undefined;
596
+ }
597
+ }
598
+ function processSuiteParent(pid) {
599
+ try {
600
+ const env = readFileSync(`/proc/${pid}/environ`, "utf8").split("\0");
601
+ const value = env.find((entry) => entry.startsWith(`${SUITE_PARENT_ENV}=`))?.slice(SUITE_PARENT_ENV.length + 1);
602
+ return value && /^\d+$/.test(value) ? Number(value) : undefined;
603
+ }
604
+ catch { /* Darwin has no /proc process environments */ }
605
+ try {
606
+ const out = execFileSync("ps", ["eww", "-p", String(pid), "-o", "command="], {
607
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
608
+ });
609
+ const value = new RegExp(`(?:^|\\s)${SUITE_PARENT_ENV}=(\\d+)(?:\\s|$)`).exec(out)?.[1];
610
+ return value ? Number(value) : undefined;
611
+ }
612
+ catch {
613
+ return undefined;
614
+ }
615
+ }
616
+ const pathAtOrBelow = (root, candidate) => {
617
+ const rel = relative(root, candidate);
618
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
619
+ };
620
+ /** Count full-suite roots in one process-table snapshot. The probes are arguments so the ownership
621
+ * rules remain testable on hosts that forbid process inspection; production supplies cwd and the
622
+ * inherited TICKMARKR_SUITE_PARENT marker from the process itself. */
623
+ export function countLiveSuites(snapshot, repoRoot, daemonPid = process.pid, cwdForPid = processCwd, suiteParentForPid = processSuiteParent) {
624
+ const rows = [];
625
+ for (const line of snapshot.split("\n")) {
626
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
627
+ if (match && !match[3].startsWith("Z")) {
628
+ rows.push({ pid: Number(match[1]), ppid: Number(match[2]), command: match[4] });
629
+ }
630
+ }
631
+ const byPid = new Map(rows.map((row) => [row.pid, row]));
632
+ const ancestors = new Set();
633
+ for (let pid = daemonPid; pid && !ancestors.has(pid); pid = byPid.get(pid)?.ppid ?? 0)
634
+ ancestors.add(pid);
635
+ const descendants = new Set([daemonPid]);
636
+ for (let grew = true; grew;) {
637
+ grew = false;
638
+ for (const row of rows)
639
+ if (!descendants.has(row.pid) && descendants.has(row.ppid)) {
640
+ descendants.add(row.pid);
641
+ grew = true;
642
+ }
643
+ }
644
+ const root = realpathSync(repoRoot);
645
+ const candidates = rows.filter((row) => !ancestors.has(row.pid) && SUITE_COMMAND_RE.test(row.command));
646
+ const attributable = new Set(candidates.filter((row) => {
647
+ if (descendants.has(row.pid))
648
+ return true;
649
+ const cwd = cwdForPid(row.pid);
650
+ if (cwd !== undefined && pathAtOrBelow(root, cwd))
651
+ return true;
652
+ const suiteParent = suiteParentForPid(row.pid);
653
+ if (suiteParent === daemonPid)
654
+ return true;
655
+ const parentCwd = suiteParent === undefined ? undefined : cwdForPid(suiteParent);
656
+ return parentCwd !== undefined && pathAtOrBelow(root, parentCwd);
657
+ }).map((row) => row.pid));
658
+ // npm/npx + vitest + pool workers are one suite. Count only attributable suite processes with no
659
+ // attributable suite ancestor, while still following ordinary non-suite parents between them.
660
+ return [...attributable].filter((pid) => {
661
+ for (let parent = byPid.get(pid)?.ppid; parent; parent = byPid.get(parent)?.ppid) {
662
+ if (attributable.has(parent))
663
+ return false;
664
+ }
665
+ return true;
666
+ }).length;
667
+ }
668
+ let liveSuiteCountForTests;
669
+ export const setLiveSuiteCountForTests = (probe) => {
670
+ liveSuiteCountForTests = probe;
671
+ };
672
+ export const resetLiveSuiteCountForTests = () => { liveSuiteCountForTests = undefined; };
673
+ /** Live full-suite roots attributable to this repository or this daemon. Ancestors are excluded so
674
+ * a daemon invoked by vitest does not wait on its own test harness forever. */
675
+ export async function liveSuiteCount(repoRoot) {
676
+ if (liveSuiteCountForTests)
677
+ return liveSuiteCountForTests(repoRoot);
678
+ const snapshot = await shGit("ps -Aww -o pid=,ppid=,state=,command=", repoRoot, 15_000);
679
+ return snapshot.code === 0 ? countLiveSuites(snapshot.stdout, repoRoot) : 0;
680
+ }
561
681
  const OBSERVE_CHUNK_BYTES = 64 * 1024;
562
682
  const OBSERVE_BUDGET_BYTES = 256 * 1024 * 1024;
563
683
  let observeBudgetBytes = OBSERVE_BUDGET_BYTES;
@@ -1386,6 +1506,41 @@ export async function runDaemon(repoRoot, opts = {}) {
1386
1506
  mergeChain = next.catch(() => undefined);
1387
1507
  return next;
1388
1508
  };
1509
+ // OBS-829/OBS-854: one full-suite verdict round at a time in this run, and do not begin beside an
1510
+ // externally live suite attributable to this repository. The process scan catches nested scratch
1511
+ // suites through daemon parentage even after cwd stops naming a worktree.
1512
+ let suiteChain = Promise.resolve();
1513
+ let suitePending = 0;
1514
+ const withSuiteWindow = async (taskId, enabled, run) => {
1515
+ if (!enabled)
1516
+ return run();
1517
+ const previous = suiteChain;
1518
+ let release;
1519
+ suiteChain = new Promise((resolve) => { release = resolve; });
1520
+ const queued = suitePending++ > 0;
1521
+ if (queued) {
1522
+ const count = Math.max(1, await liveSuiteCount(repoRoot));
1523
+ journal.append("suite-wait", taskId, { count });
1524
+ }
1525
+ await previous;
1526
+ try {
1527
+ let lastCount = -1;
1528
+ for (;;) {
1529
+ const count = await liveSuiteCount(repoRoot);
1530
+ if (count === 0)
1531
+ break;
1532
+ if (count !== lastCount)
1533
+ journal.append("suite-wait", taskId, { count });
1534
+ lastCount = count;
1535
+ await new Promise((wake) => setTimeout(wake, SUITE_POLL_MS));
1536
+ }
1537
+ return await run();
1538
+ }
1539
+ finally {
1540
+ suitePending--;
1541
+ release();
1542
+ }
1543
+ };
1389
1544
  // gateFails/consults are execTask-scoped counters passed in so a park row is a rich verified-failure
1390
1545
  // observation (e.g. ladder-exhausted + gateFails:4); every task-human row has a closed kind, never prose alone.
1391
1546
  const gateFailApprovalReason = (taskId, identity, includeUphold = false) => {
@@ -1853,21 +2008,24 @@ export async function runDaemon(repoRoot, opts = {}) {
1853
2008
  // contiguous green prefix whose recorded commit is still the task branch tip.
1854
2009
  const satisfiedGate = satisfiedGates.get(t.id);
1855
2010
  const replayedGates = replayedGateResults.get(t.id);
1856
- resumeGateReplay: if (satisfiedGate || replayedGates) {
2011
+ const recheck = pendingRechecks(journal.read()).has(t.id);
2012
+ resumeGateReplay: if (satisfiedGate || replayedGates || recheck) {
1857
2013
  const taskBase = await integrationHead(intWt);
1858
2014
  const taskBranch = `${branch}--${t.id}`;
1859
2015
  const priorWt = worktreePath(repoRoot, taskBranch);
1860
2016
  if (!existsSync(priorWt)) {
1861
- if (satisfiedGate)
1862
- throw new Error(`approved gate ${satisfiedGate} cannot resume: task worktree is missing`);
2017
+ if (satisfiedGate || recheck)
2018
+ throw new Error(`${recheck ? "recheck" : `approved gate ${satisfiedGate}`} cannot resume: task worktree is missing`);
1863
2019
  // Observed passes are an optimization, never authority: without the task worktree there is no
1864
2020
  // commit to compare and no landed work to gate, so fall through to the ordinary worker path.
1865
2021
  replayedGateResults.delete(t.id);
1866
2022
  break resumeGateReplay;
1867
2023
  }
1868
- const resumeReason = satisfiedGate
1869
- ? `approved gate ${satisfiedGate}`
1870
- : `recorded gates on ${replayedGates.commit.slice(0, 10)}`;
2024
+ const resumeReason = recheck
2025
+ ? "operator recheck"
2026
+ : satisfiedGate
2027
+ ? `approved gate ${satisfiedGate}`
2028
+ : `recorded gates on ${replayedGates.commit.slice(0, 10)}`;
1871
2029
  const priorTaskTip = await gitHead(priorWt);
1872
2030
  const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, priorWt);
1873
2031
  const commitsToCarry = await commitsAheadOf(taskBase, priorWt);
@@ -1919,7 +2077,10 @@ export async function runDaemon(repoRoot, opts = {}) {
1919
2077
  : [],
1920
2078
  raw: "",
1921
2079
  };
1922
- const gateAuthor = rs?.lastAssignment ?? assignment;
2080
+ const parkedAuthor = recheck
2081
+ ? [...journal.read()].reverse().find((e) => e.event === "task-dispatch" && e.taskId === t.id)?.data.assignment
2082
+ : undefined;
2083
+ const gateAuthor = parkedAuthor ?? rs?.lastAssignment ?? assignment;
1923
2084
  const satisfiedIndex = satisfiedGate ? GATE_NAMES.indexOf(satisfiedGate) : -1;
1924
2085
  // The serial pipeline could have at most one blocking result, so "everything after the
1925
2086
  // approved gate" was enough. v1.85 can record both verdict siblings red in one round, and a
@@ -1943,7 +2104,10 @@ export async function runDaemon(repoRoot, opts = {}) {
1943
2104
  priorResults.set(e.data.gate, e);
1944
2105
  }
1945
2106
  let remainingGates;
1946
- if (satisfiedGate) {
2107
+ if (recheck) {
2108
+ remainingGates = GATE_NAMES.filter((gate) => t.gates.includes(gate));
2109
+ }
2110
+ else if (satisfiedGate) {
1947
2111
  remainingGates = t.gates.filter((gate) => {
1948
2112
  if (gate === satisfiedGate)
1949
2113
  return false;
@@ -2001,10 +2165,10 @@ export async function runDaemon(repoRoot, opts = {}) {
2001
2165
  // This suffix is re-measured to decide whether resume may advance, but the interrupted
2002
2166
  // attempt already paid for its red result. The next worker-backed round remains the next
2003
2167
  // deterministic-fingerprint occurrence/review round for budget accounting.
2004
- ...(!satisfiedGate ? { replayMeasurement: true } : {}),
2168
+ ...(!satisfiedGate && !recheck ? { replayMeasurement: true } : {}),
2005
2169
  };
2006
2170
  journal.phaseStart(t.id, "gates");
2007
- const { results } = await runGates(resumedTask, {
2171
+ const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runGates(resumedTask, {
2008
2172
  worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
2009
2173
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
2010
2174
  collateral: collateral.get(t.id) ?? [],
@@ -2031,6 +2195,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2031
2195
  return;
2032
2196
  }
2033
2197
  const g = e.result;
2198
+ classifySignalOnlyTest(g);
2034
2199
  inParallelOrder(g.gate, () => {
2035
2200
  journalGateResult(g);
2036
2201
  noteReviewRetry(g);
@@ -2040,7 +2205,15 @@ export async function runDaemon(repoRoot, opts = {}) {
2040
2205
  }
2041
2206
  });
2042
2207
  },
2043
- });
2208
+ }));
2209
+ results.forEach(classifySignalOnlyTest);
2210
+ if (pendingRechecks(journal.read()).has(t.id)) {
2211
+ journal.append("recheck-battery", t.id, {
2212
+ commit: gateSubject.commit,
2213
+ gates: resumedTask.gates,
2214
+ pass: results.every(gateSatisfied),
2215
+ });
2216
+ }
2044
2217
  const approvedCommits = await commitsAheadOf(taskBase, wt);
2045
2218
  graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
2046
2219
  saveGraph(repoRoot, graph);
@@ -2375,7 +2548,14 @@ export async function runDaemon(repoRoot, opts = {}) {
2375
2548
  const adapter = getAdapter(assignment.adapter, adapters);
2376
2549
  // VIS-04: workers share one role tab. T2: `owned` names the pane canonically (ownership contract);
2377
2550
  // the legacy name stays the fallback for drivers without owned handling (subprocess spies).
2378
- const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, { group: "workers", owned: { role: "worker", taskId: t.id, attempt, runId } });
2551
+ const workerSlotOpts = {
2552
+ group: "workers",
2553
+ owned: { role: "worker", taskId: t.id, attempt, runId },
2554
+ };
2555
+ // Keep the established enumerable slot-options shape consumed by legacy drivers while making
2556
+ // the adapter hook available as an own request field to execution surfaces such as Orca.
2557
+ Object.defineProperty(workerSlotOpts, "agent", { value: assignment.adapter });
2558
+ const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, workerSlotOpts);
2379
2559
  const sessionId = retryMode === "resume" ? priorSession.id : slot.name;
2380
2560
  const icmd = retryMode === "resume"
2381
2561
  ? adapter.resumeCommand(sessionId, promptFile, assignment.model)
@@ -3478,6 +3658,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3478
3658
  return;
3479
3659
  }
3480
3660
  const g = e.result;
3661
+ classifySignalOnlyTest(g);
3481
3662
  inParallelOrder(g.gate, () => {
3482
3663
  // GATE-09 (ROADMAP SC-4): journal every judge retry as an attributable event — which gate flaked,
3483
3664
  // which channel flaked, which channel retried — so `tickmarkr journal`/report can distinguish "judge
@@ -3510,7 +3691,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3510
3691
  const gated = await gitHead(wt);
3511
3692
  gateSubject = { commit: await gateCommitSubject(taskBase, gated, wt), attempt };
3512
3693
  journal.phaseStart(t.id, "gates");
3513
- ({ results, commits } = await runGates(t, {
3694
+ ({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runGates(t, {
3514
3695
  worktree: wt, baseRef: taskBase, result, author: assignment,
3515
3696
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
3516
3697
  collateral: collateral.get(t.id) ?? [],
@@ -3533,7 +3714,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3533
3714
  : undefined,
3534
3715
  excludeReviewers: badReviewers,
3535
3716
  onGate,
3536
- }));
3717
+ })));
3718
+ results.forEach(classifySignalOnlyTest);
3537
3719
  graph = addEvidence(graph, t.id, { commits, gateResults: results, artifacts: [promptFile] });
3538
3720
  saveGraph(repoRoot, graph);
3539
3721
  if (results.some((g) => g.gate === "test" && !g.pass))
@@ -3630,6 +3812,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3630
3812
  // failing gate, and `feedback` still carries every one of them to the next attempt.
3631
3813
  const repairBattery = failing.some((g) => g.gate !== "review") ? failing.filter((g) => g.gate !== "review") : failing;
3632
3814
  const repairable = narrowRepairBattery(repairBattery) && lostCommits.length === 0 && landed.length > 0;
3815
+ const repairHistory = repairReachSinceApproval(journal.read(), t.id);
3633
3816
  const repairsDrawn = repairsSinceApproval(journal.read(), t.id);
3634
3817
  const repair = repairable && repairsDrawn < MAX_REPAIRS;
3635
3818
  // v1.85 T3: the fingerprint cap. Two normalized-identical failures of one DETERMINISTIC gate on
@@ -3699,13 +3882,17 @@ export async function runDaemon(repoRoot, opts = {}) {
3699
3882
  const repairExhausted = repairable && !repair;
3700
3883
  if (repair && !capStep) {
3701
3884
  journal.append("repair-attempt", t.id, {
3702
- repair: repairsDrawn + 1, of: MAX_REPAIRS, gates: failing.map((g) => g.gate),
3885
+ repair: repairHistory.length + 1, charge: repairsDrawn + 1, of: MAX_REPAIRS,
3886
+ gates: failing.map((g) => g.gate),
3703
3887
  commits: landed.length,
3704
3888
  findings: feedback, // the failure bytes this repair must carry, replayable across a resume
3705
3889
  });
3706
3890
  }
3707
3891
  else if (repairExhausted && !capStep) {
3708
- journal.append("repair-exhausted", t.id, { repairs: repairsDrawn, of: MAX_REPAIRS, gates: failing.map((g) => g.gate) });
3892
+ journal.append("repair-exhausted", t.id, {
3893
+ repairs: repairsDrawn, of: MAX_REPAIRS, gates: failing.map((g) => g.gate),
3894
+ reached: repairHistory,
3895
+ });
3709
3896
  }
3710
3897
  const step = capStep ?? (repair || (reviewFixRetry && !repairExhausted)
3711
3898
  ? "retry"
@@ -3714,7 +3901,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3714
3901
  journal.append("escalation", t.id, {
3715
3902
  step, attempt: attempt + 1,
3716
3903
  ...(reviewFixRetry && !repairExhausted ? { reviewFix: true } : {}),
3717
- ...(repair ? { repair: repairsDrawn + 1 } : {}),
3904
+ ...(repair ? { repair: repairHistory.length + 1, repairCharge: repairsDrawn + 1 } : {}),
3718
3905
  });
3719
3906
  await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${step}`, { tier: "attention" });
3720
3907
  }
@@ -3769,7 +3956,13 @@ export async function runDaemon(repoRoot, opts = {}) {
3769
3956
  }
3770
3957
  if (inflight.size === 0)
3771
3958
  break;
3772
- await Promise.race([...inflight.values(), aborted]); // aborted rejects on termination — unwinds the run
3959
+ const waiters = [...inflight.values(), aborted];
3960
+ // A free slot is itself a scheduling boundary: poll the append-only approval stream instead of
3961
+ // sleeping until an unrelated long-running task settles.
3962
+ if (inflight.size < concurrency) {
3963
+ waiters.push(new Promise((wake) => setTimeout(wake, APPROVAL_POLL_MS)));
3964
+ }
3965
+ await Promise.race(waiters); // aborted rejects on termination — unwinds the run
3773
3966
  }
3774
3967
  // D-07: the sweep now closes only what's LEFT in keptSlots — done-closed worker slots were removed
3775
3968
  // (no double-close) and self-cleaned LLM/consult panes were never added under keepLlm:false. This
@@ -3794,7 +3987,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3794
3987
  // OBS-34: post-merge integration-tip verify — strict exit codes, no baseline forgiveness.
3795
3988
  const lastMergedTask = [...journal.read()].reverse().find((e) => e.event === "merge" && e.taskId)?.taskId;
3796
3989
  if (summary.done.length > 0 && Object.keys(commands).length > 0) {
3797
- const tipFailed = await verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline });
3990
+ const tipFailed = await withSuiteWindow(undefined, commands.test !== undefined, () => verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline }));
3798
3991
  summary.tipVerify = tipFailed ? "failed" : "passed";
3799
3992
  if (tipFailed && lastMergedTask)
3800
3993
  summary.lastMergedTask = lastMergedTask;
package/dist/run/git.d.ts CHANGED
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
3
3
  export { ROUTING_ENV_SEAMS };
4
4
  export declare const FORK_CAP_ENV = "VITEST_MAX_FORKS";
5
+ export declare const SUITE_PARENT_ENV = "TICKMARKR_SUITE_PARENT";
5
6
  export declare const DEFAULT_FORK_CAP = "6";
6
7
  /**
7
8
  * OBS-618: how many PROCESSES one vitest fork can hold at its peak — the fork, a daemon it spawns,
package/dist/run/git.js CHANGED
@@ -13,6 +13,7 @@ export { ROUTING_ENV_SEAMS };
13
13
  // Default to a modest cap through the environment so vitest honors it natively; never pass it
14
14
  // as argv (OBS-55) so child test oracles stay intact. The operator's own export wins.
15
15
  export const FORK_CAP_ENV = "VITEST_MAX_FORKS";
16
+ export const SUITE_PARENT_ENV = "TICKMARKR_SUITE_PARENT";
16
17
  export const DEFAULT_FORK_CAP = "6";
17
18
  /**
18
19
  * The fork budget belongs to ONE run: how many gate suites can be in flight at once is exactly that
@@ -150,6 +151,9 @@ function shell(cmd, cwd, timeoutMs, login) {
150
151
  // OBS-110: apply the run's own fork cap only when the operator has not already set one.
151
152
  if (!(FORK_CAP_ENV in env))
152
153
  env[FORK_CAP_ENV] = resolvedForkCap();
154
+ // OBS-854: descendants can leave the checkout (nested fixture suites do), so cwd alone cannot
155
+ // attribute them. Every daemon shell exports the daemon pid as their durable parentage marker.
156
+ env[SUITE_PARENT_ENV] = String(process.pid);
153
157
  // T7: the capacity every result of this shell carries, read HERE — off the environment the child
154
158
  // is about to receive, after the precedence above has settled. An operator export is already in
155
159
  // `env`, so what gets recorded is the operator's number, which is the case a release was re-taken
@@ -116,8 +116,21 @@ export declare function formatPriorFindingEvidence(evidence: PriorFindingEvidenc
116
116
  export declare function normalizeGateFailure(details: string): string;
117
117
  export declare const GATE_FINGERPRINT_CAP = 2;
118
118
  export declare function identicalGateFailures(events: JournalEvent[], taskId: string, gate: string, normalized: string): number;
119
- /** Repair attempts this engagement has already funded — journal-derived, so a resume inherits it. */
119
+ /** What each funded repair's next battery actually reached. A repair spends its budget only
120
+ * when that battery reaches one of the gates it was funded to fix; an earlier red is evidence that
121
+ * this repair never got its funded turn, not a charge against the repair ladder. */
122
+ export interface RepairReach {
123
+ repair: number;
124
+ funded: string[];
125
+ reached: string[];
126
+ diedAt?: string;
127
+ charged: boolean;
128
+ }
129
+ export declare function repairReachSinceApproval(events: JournalEvent[], taskId: string): RepairReach[];
130
+ /** Repair slots actually spent this engagement — journal-derived, so a resume inherits it. */
120
131
  export declare function repairsSinceApproval(events: JournalEvent[], taskId: string): number;
132
+ /** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
133
+ export declare function pendingRechecks(events: JournalEvent[]): Set<string>;
121
134
  /**
122
135
  * Why the last attempt failed, one row per journaled cause, in the daemon's own `source: details`
123
136
  * shape. The daemon builds that brief in a loop-local variable, which dies with the process: a resumed
@@ -242,9 +255,9 @@ export declare const TelemetryRowSchema: z.ZodObject<{
242
255
  quotaFailover: z.ZodOptional<z.ZodLiteral<true>>;
243
256
  overrun: z.ZodOptional<z.ZodLiteral<true>>;
244
257
  retryMode: z.ZodOptional<z.ZodEnum<{
258
+ repair: "repair";
245
259
  resume: "resume";
246
260
  fresh: "fresh";
247
- repair: "repair";
248
261
  }>>;
249
262
  signalQuality: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<0.25>, z.ZodLiteral<0.5>, z.ZodLiteral<0.75>, z.ZodLiteral<1>]>>;
250
263
  signalBasis: z.ZodOptional<z.ZodEnum<{
@@ -72,8 +72,8 @@ export const REVIEW_UPHELD_RELEASE = "review-upheld";
72
72
  // OBS-203: the third decision a gate-fail park needs. Plain approve WAIVES the failed gate (and every
73
73
  // gate before it — daemon.ts remainingGates), which is wrong when the gate failed against a stale task
74
74
  // DECLARATION rather than a bad diff: amend the spec's files[], recompile, and the gate now passes
75
- // honestly. This release re-dispatches with the whole gate suite intact and no gate marked satisfied,
76
- // so the corrected declaration is the thing that earns the green. Budget semantics match attempt-cap
75
+ // honestly. This release runs the whole gate suite over the parked commit before any worker and
76
+ // marks no gate satisfied: green merges directly, red returns to the ladder. Budget semantics match attempt-cap
77
77
  // (fresh attempts, tried survives) because the park cost the task its remaining budget.
78
78
  export const RECHECK_RELEASE = "recheck";
79
79
  // OBS-738: one authority for every recovery surface. The ref is accepted only from the row that
@@ -547,18 +547,65 @@ export function identicalGateFailures(events, taskId, gate, normalized) {
547
547
  }
548
548
  return n;
549
549
  }
550
- /** Repair attempts this engagement has already funded — journal-derived, so a resume inherits it. */
551
- export function repairsSinceApproval(events, taskId) {
552
- let n = 0;
550
+ export function repairReachSinceApproval(events, taskId) {
551
+ const repairs = [];
553
552
  for (const e of events) {
554
553
  if (e.taskId !== taskId)
555
554
  continue;
556
- if (e.event === "task-approved")
557
- n = 0;
558
- else if (e.event === "repair-attempt")
559
- n++;
555
+ if (e.event === "task-approved") {
556
+ repairs.length = 0;
557
+ }
558
+ else if (e.event === "repair-attempt") {
559
+ const funded = Array.isArray(e.data.gates)
560
+ ? e.data.gates.filter((gate) => typeof gate === "string")
561
+ : [];
562
+ repairs.push({
563
+ repair: typeof e.data.repair === "number" ? e.data.repair : repairs.length + 1,
564
+ funded,
565
+ reached: [],
566
+ charged: false,
567
+ launched: false,
568
+ closed: false,
569
+ });
570
+ }
571
+ else {
572
+ const repair = repairs.at(-1);
573
+ if (!repair || repair.closed)
574
+ continue;
575
+ if (e.event === "worker-launch") {
576
+ if (repair.launched)
577
+ repair.closed = true;
578
+ else
579
+ repair.launched = true;
580
+ }
581
+ else if (repair.launched && e.event === "gate-result" && typeof e.data.gate === "string") {
582
+ if (!repair.reached.includes(e.data.gate))
583
+ repair.reached.push(e.data.gate);
584
+ if (e.data.pass === false && repair.diedAt === undefined)
585
+ repair.diedAt = e.data.gate;
586
+ if (repair.funded.includes(e.data.gate))
587
+ repair.charged = true;
588
+ }
589
+ }
560
590
  }
561
- return n;
591
+ return repairs.map(({ launched: _launched, closed: _closed, ...repair }) => repair);
592
+ }
593
+ /** Repair slots actually spent this engagement — journal-derived, so a resume inherits it. */
594
+ export function repairsSinceApproval(events, taskId) {
595
+ return repairReachSinceApproval(events, taskId).filter((repair) => repair.charged).length;
596
+ }
597
+ /** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
598
+ export function pendingRechecks(events) {
599
+ const pending = new Set();
600
+ for (const e of events) {
601
+ if (!e.taskId)
602
+ continue;
603
+ if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE)
604
+ pending.add(e.taskId);
605
+ else if (e.event === "recheck-battery" || e.event === "worker-launch")
606
+ pending.delete(e.taskId);
607
+ }
608
+ return pending;
562
609
  }
563
610
  // Both retry decisions below govern exactly ONE dispatch: the next one. So both are read back from the
564
611
  // journal at the moment that dispatch is built, never carried in a process variable — a stop between
@@ -695,8 +742,19 @@ export function pendingRepairFindings(events, taskId) {
695
742
  * a channel it is no longer using, and a later unrelated failure is not parked under a stale reason.
696
743
  */
697
744
  export function activeRetryBan(events, taskId, channel) {
698
- const e = decisionForNextDispatch(events, taskId, "gate-fingerprint-cap");
699
- return e && e.data.channel === channel && typeof e.data.gate === "string" ? e.data.gate : undefined;
745
+ let pending;
746
+ for (const e of events) {
747
+ if (e.taskId !== taskId)
748
+ continue;
749
+ if (e.event === "gate-fingerprint-cap")
750
+ pending = e;
751
+ else if (e.event === DECISION_SPENT
752
+ || (e.event === "task-approved" && e.data.release === RECHECK_RELEASE))
753
+ pending = undefined;
754
+ }
755
+ return pending && pending.data.channel === channel && typeof pending.data.gate === "string"
756
+ ? pending.data.gate
757
+ : undefined;
700
758
  }
701
759
  // Fail-closed shape for a dispatched assignment (journal.ts:75-90 posture): a malformed assignment in
702
760
  // one dispatch degrades that single task toward today's behavior — counts toward attempts, contributes
@@ -7,6 +7,12 @@ export declare const SUPERVISION_FUTURE_GRACE_MS = 1000;
7
7
  export declare const SUPERVISION_DEFAULT_THRESHOLD_PCT = 75;
8
8
  export declare const SUPERVISION_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch"];
9
9
  export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
10
+ /**
11
+ * Resolve a one-shot supervision writer to the repository state it is claiming to supervise.
12
+ * The nearest state directory wins; reaching a git root without one is a refusal, not permission
13
+ * to create fresh state there or to wander into an enclosing repository. This function is read-only.
14
+ */
15
+ export declare function resolveSupervisionRoot(cwd: string): string;
10
16
  export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context"];
11
17
  export type SeatTier = (typeof SUPERVISION_SEAT_TIERS)[number];
12
18
  /** Does this tier's record have to name the seat it speaks for? */
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { stateDirName, tickmarkrDir } from "../graph/graph.js";
5
5
  // SUP-01: supervision liveness as FILE STATE, not as a report — lock.ts's proven shape, one file per
6
6
  // tier. A watcher beats its OWN file; every other seat derives that tier's state by stat()ing it. So
@@ -41,6 +41,34 @@ export const SUPERVISION_DEFAULT_THRESHOLD_PCT = 75;
41
41
  export const SUPERVISION_TIERS = [
42
42
  "orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch",
43
43
  ];
44
+ const pathKind = (path) => {
45
+ try {
46
+ return statSync(path).isDirectory() ? "directory" : "present";
47
+ }
48
+ catch {
49
+ return "absent";
50
+ }
51
+ };
52
+ /**
53
+ * Resolve a one-shot supervision writer to the repository state it is claiming to supervise.
54
+ * The nearest state directory wins; reaching a git root without one is a refusal, not permission
55
+ * to create fresh state there or to wander into an enclosing repository. This function is read-only.
56
+ */
57
+ export function resolveSupervisionRoot(cwd) {
58
+ let current = resolve(cwd);
59
+ for (;;) {
60
+ if (pathKind(join(current, stateDirName(current))) === "directory")
61
+ return current;
62
+ if (pathKind(join(current, ".git")) !== "absent") {
63
+ throw new Error(`missing ${stateDirName(current)}/ state dir at repository root ${current}`);
64
+ }
65
+ const parent = dirname(current);
66
+ if (parent === current) {
67
+ throw new Error(`missing ${stateDirName(current)}/ state dir — no tickmarkr repository found from ${resolve(cwd)}`);
68
+ }
69
+ current = parent;
70
+ }
71
+ }
44
72
  // The tiers whose records must NAME the seat behind them. A one-shot `tickmarkr beat` records a pid
45
73
  // that has already exited by the time anyone reads it, and an instant — nothing a reader can attribute
46
74
  // to a seat. Measured 2026-08-26: a consult seat of another tier ran the documented beat loop and the