tickmarkr 2.4.2 → 2.5.1

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 (89) hide show
  1. package/README.md +110 -24
  2. package/dist/adapters/fake.d.ts +1 -0
  3. package/dist/adapters/fake.js +2 -1
  4. package/dist/adapters/opencode.d.ts +1 -0
  5. package/dist/adapters/opencode.js +4 -1
  6. package/dist/adapters/types.d.ts +14 -0
  7. package/dist/adapters/types.js +25 -0
  8. package/dist/cli/commands/approve.d.ts +42 -0
  9. package/dist/cli/commands/approve.js +80 -13
  10. package/dist/cli/commands/doctor.d.ts +234 -0
  11. package/dist/cli/commands/doctor.js +139 -5
  12. package/dist/cli/commands/init.d.ts +2 -0
  13. package/dist/cli/commands/init.js +45 -5
  14. package/dist/cli/commands/plan.js +58 -6
  15. package/dist/cli/commands/report.js +18 -45
  16. package/dist/cli/commands/run.js +3 -0
  17. package/dist/cli/commands/scope.js +36 -6
  18. package/dist/cli/commands/stats.d.ts +2 -0
  19. package/dist/cli/commands/stats.js +42 -23
  20. package/dist/cli/commands/status.js +20 -4
  21. package/dist/cli/commands/ui.js +42 -53
  22. package/dist/cli/commands/unlock.d.ts +1 -1
  23. package/dist/cli/commands/unlock.js +59 -9
  24. package/dist/cli/help.d.ts +219 -0
  25. package/dist/cli/help.js +212 -0
  26. package/dist/cli/index.d.ts +42 -2
  27. package/dist/cli/index.js +23 -8
  28. package/dist/drivers/herdr.d.ts +7 -2
  29. package/dist/drivers/herdr.js +78 -48
  30. package/dist/drivers/orca.d.ts +3 -2
  31. package/dist/drivers/orca.js +43 -4
  32. package/dist/drivers/subprocess.d.ts +1 -0
  33. package/dist/drivers/subprocess.js +3 -0
  34. package/dist/drivers/types.d.ts +14 -0
  35. package/dist/gates/artifact-manifest.d.ts +50 -0
  36. package/dist/gates/artifact-manifest.js +23 -0
  37. package/dist/gates/llm.d.ts +5 -0
  38. package/dist/gates/llm.js +188 -3
  39. package/dist/gates/review.d.ts +6 -3
  40. package/dist/gates/review.js +79 -33
  41. package/dist/gates/run-gates.d.ts +3 -0
  42. package/dist/gates/run-gates.js +14 -4
  43. package/dist/plan/scope.d.ts +25 -0
  44. package/dist/plan/scope.js +92 -12
  45. package/dist/report/operator-record.d.ts +49 -0
  46. package/dist/report/operator-record.js +137 -0
  47. package/dist/run/daemon.d.ts +5 -5
  48. package/dist/run/daemon.js +166 -44
  49. package/dist/run/lock.d.ts +59 -2
  50. package/dist/run/lock.js +184 -26
  51. package/dist/run/operator-state.d.ts +86 -0
  52. package/dist/run/operator-state.js +165 -0
  53. package/dist/run/supervision.d.ts +32 -0
  54. package/dist/run/supervision.js +138 -17
  55. package/dist/tui/cockpit/capture.d.ts +19 -0
  56. package/dist/tui/cockpit/capture.js +89 -1
  57. package/dist/tui/cockpit/components.d.ts +15 -1
  58. package/dist/tui/cockpit/components.js +79 -9
  59. package/dist/tui/cockpit/decision-actions.d.ts +147 -0
  60. package/dist/tui/cockpit/decision-actions.js +315 -0
  61. package/dist/tui/cockpit/derive.d.ts +1 -1
  62. package/dist/tui/cockpit/derive.js +2 -0
  63. package/dist/tui/cockpit/evidence-view.d.ts +119 -0
  64. package/dist/tui/cockpit/evidence-view.js +210 -0
  65. package/dist/tui/cockpit/home-view.d.ts +88 -0
  66. package/dist/tui/cockpit/home-view.js +240 -0
  67. package/dist/tui/cockpit/keys.d.ts +125 -0
  68. package/dist/tui/cockpit/keys.js +31 -0
  69. package/dist/tui/cockpit/layout.d.ts +14 -0
  70. package/dist/tui/cockpit/layout.js +15 -0
  71. package/dist/tui/cockpit/live-runtime.d.ts +46 -0
  72. package/dist/tui/cockpit/live-runtime.js +683 -0
  73. package/dist/tui/cockpit/live-store.d.ts +289 -0
  74. package/dist/tui/cockpit/live-store.js +308 -0
  75. package/dist/tui/cockpit/live.d.ts +21 -1
  76. package/dist/tui/cockpit/live.js +12 -1
  77. package/dist/tui/cockpit/run-view.d.ts +100 -0
  78. package/dist/tui/cockpit/run-view.js +202 -0
  79. package/dist/tui/cockpit/shell.d.ts +50 -0
  80. package/dist/tui/cockpit/shell.js +74 -0
  81. package/dist/tui/cockpit/theme.d.ts +27 -0
  82. package/dist/tui/cockpit/theme.js +21 -0
  83. package/dist/tui/ink/init-app.d.ts +4 -0
  84. package/dist/tui/ink/init-app.js +18 -7
  85. package/package.json +1 -1
  86. package/skills/tickmarkr-auto/SKILL.md +10 -1
  87. package/skills/tickmarkr-loop/SKILL.md +72 -1
  88. package/skills/tickmarkr-overseer/SKILL.md +12 -0
  89. package/skills/tickmarkr-overseer/scripts/watch-context.sh +13 -2
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { stringify } from "yaml";
9
9
  import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
10
10
  import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
11
- import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
11
+ import { SettledTrailerTracker, addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
12
12
  import { bannerShell, paneDispatchCommand } from "../brand.js";
13
13
  import { collateralHits } from "../compile/collateral.js";
14
14
  import { DEFAULT_DIFF_CAP, globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
@@ -27,14 +27,25 @@ import { runEnvironment } from "./environment.js";
27
27
  import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, SUITE_PARENT_ENV, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
28
28
  import { runInteractiveSeed } from "./interactive-seed.js";
29
29
  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";
30
- import { isDiffCapPark } from "../gates/review.js";
30
+ import { isDiffCapPark, pickReviewer } from "../gates/review.js";
31
31
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
32
32
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
33
33
  import { nextChannel, route } from "../route/router.js";
34
34
  import { desiredPanes } from "./reconcile.js";
35
35
  import { harvestCpuFlatWindowMs, NUDGEABLE_ADAPTERS, PANE_READ_ROWS, StallProgressTracker, stallSnapshotBannerRows, WorkerTreeCpuAccountant, } from "./stall.js";
36
- // Compatibility exports for the daemon liveness tests and existing consumers. The implementation
37
- // lives in stall.ts so gate dispatch can depend on it without importing the daemon.
36
+ // The live set is also the ownership claim shared by task cleanup and termination.
37
+ export async function closeLiveSlot(liveSlots, driver, slot) {
38
+ if (!liveSlots.delete(slot))
39
+ return;
40
+ try {
41
+ await driver.close(slot);
42
+ }
43
+ catch (error) {
44
+ liveSlots.add(slot);
45
+ throw error;
46
+ }
47
+ }
48
+ // Compatibility exports: gate dispatch can import stall.ts without importing the daemon.
38
49
  export { harvestCpuFlatWindowMs, resetHarvestCpuFlatMsForTests, setHarvestCpuFlatMsForTests, workerTreeCpuMs, } from "./stall.js";
39
50
  const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
40
51
  // An override (flag/spec) re-resolves through loadConfigWithMode itself, via a synthesized repo overlay
@@ -134,12 +145,11 @@ export function formatSummary(s) {
134
145
  : "";
135
146
  return `done: ${s.done.length}, failed: ${s.failed.length}, human: ${s.human.length}, blocked: ${s.blocked.length}, pending: ${s.pending.length}\nintegration branch: ${s.branch}${tip}${outstanding}`;
136
147
  }
137
- /** The narrator's command, bound to THIS run. `status` takes exactly one positional and it is the
138
- * run id (cli/commands/status.ts positionalRunId), so naming it here is what stops the board from
139
- * following the newest journal in a repo that already carries a second, newer run — a board showing
140
- * the wrong run is a recorded incident (skills/tickmarkr-overseer/SKILL.md). */
148
+ /** The narrator enters the production Run cockpit for this exact run. The
149
+ * completed static/growing four-hour records precede this default cutover;
150
+ * an explicit ID prevents a newer journal from redirecting the owned board. */
141
151
  export const daemonEntrypoint = fileURLToPath(new URL("../cli/index.js", import.meta.url));
142
- export const watchCommand = (runId) => `${shq(process.execPath)} ${shq(daemonEntrypoint)} status --watch ${shq(runId)}`;
152
+ export const watchCommand = (runId) => `${shq(process.execPath)} ${shq(daemonEntrypoint)} ui ${shq(runId)} --view run`;
143
153
  const MAX_ATTEMPTS = 10; // ponytail: hard cap so a pathological ladder can never loop forever
144
154
  // v1.85 T3 (retry economics): two repairs per engagement, then the fresh ladder. A repair re-uses the
145
155
  // findings and the landed diff instead of re-buying onboarding; when two of them have not closed the
@@ -1265,16 +1275,13 @@ export async function runDaemon(repoRoot, opts = {}) {
1265
1275
  // opens/closes keep the ledger exact. Termination then closes exactly what is still live — and a
1266
1276
  // slot closed once (task-done, quota reroute, gate self-clean) can never be closed twice.
1267
1277
  const liveSlots = new Set();
1268
- const closeSlot = async (s) => {
1269
- if (!liveSlots.delete(s))
1270
- return; // already closed — never twice
1271
- await driver.close(s);
1272
- };
1278
+ const closeSlot = (s) => closeLiveSlot(liveSlots, driver, s);
1273
1279
  const trackedDriver = {
1274
1280
  id: driver.id,
1275
1281
  interactive: driver.interactive,
1276
1282
  ...(driver.readSource ? { readSource: driver.readSource } : {}),
1277
1283
  ...(driver.describe ? { describe: driver.describe.bind(driver) } : {}),
1284
+ ...(driver.focus ? { focus: driver.focus.bind(driver) } : {}),
1278
1285
  slot: async (cwd, name, o) => { const s = await driver.slot(cwd, name, o); liveSlots.add(s); return s; },
1279
1286
  run: (s, cmd) => driver.run(s, cmd),
1280
1287
  waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
@@ -1330,7 +1337,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1330
1337
  // new attempt while this reaper is still closing the old ones.
1331
1338
  abortRun(new Error(`terminated by ${sig}`));
1332
1339
  if (cfg.visibility.keepPanes !== "forever") {
1333
- for (const s of liveSlots) { // closeSlot only deletes the element being visited — safe during Set iteration
1340
+ for (const s of Array.from(liveSlots)) { // snapshot: failed closes restore membership for the next sweep
1334
1341
  try {
1335
1342
  await closeSlot(s);
1336
1343
  }
@@ -1349,6 +1356,9 @@ export async function runDaemon(repoRoot, opts = {}) {
1349
1356
  process.on("SIGINT", onTermination);
1350
1357
  process.on("SIGTERM", onTermination);
1351
1358
  journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
1359
+ const demotedReviewers = new Set(journal.read()
1360
+ .filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string")
1361
+ .map((event) => event.data.reviewer));
1352
1362
  const reviewHistory = journal.read()
1353
1363
  .filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
1354
1364
  .map((event) => event.data.reviewer);
@@ -1392,7 +1402,9 @@ export async function runDaemon(repoRoot, opts = {}) {
1392
1402
  watchSlot = await trackedDriver.narrator(repoRoot, watchCommand(runId), runId);
1393
1403
  }
1394
1404
  catch (error) {
1395
- journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
1405
+ const reason = error instanceof Error ? error.message : String(error);
1406
+ journal.append("watch-placement-failed", undefined, { error: reason });
1407
+ console.error(`tickmarkr: narrator not opened: ${reason}`);
1396
1408
  }
1397
1409
  };
1398
1410
  let baseRef;
@@ -1550,12 +1562,14 @@ export async function runDaemon(repoRoot, opts = {}) {
1550
1562
  // (no run identity) is never this run's to sweep.
1551
1563
  if (watchSlot && watchSlot.name === watchName && !desired.has(watchName)) {
1552
1564
  const w = watchSlot;
1553
- watchSlot = undefined;
1554
1565
  await trackedDriver.close(w);
1566
+ watchSlot = undefined;
1555
1567
  }
1556
1568
  }
1557
- catch {
1558
- /* cosmetic — visibility is never a gate */
1569
+ catch (error) {
1570
+ const reason = error instanceof Error ? error.message : String(error);
1571
+ journal.append("watch-cleanup-failed", undefined, { error: reason });
1572
+ console.error(`tickmarkr: ${reason}`);
1559
1573
  }
1560
1574
  };
1561
1575
  // run start/resume boundary: nothing in flight, so the sweep takes this run's judge/review/consult
@@ -1811,6 +1825,35 @@ export async function runDaemon(repoRoot, opts = {}) {
1811
1825
  if (rs)
1812
1826
  journal.append("resume-restore", t.id, { attempts: rs.attempts, tried: [...tried], assignment });
1813
1827
  const badReviewers = []; // v1.1: reviewer channels that produced unparseable output for this task
1828
+ const noteReviewEvent = (e) => {
1829
+ journal.append(e.name, t.id, e.payload);
1830
+ if (e.name === "review-no-verdict" && typeof e.payload.reviewer === "string") {
1831
+ if (!badReviewers.includes(e.payload.reviewer))
1832
+ badReviewers.push(e.payload.reviewer);
1833
+ }
1834
+ };
1835
+ // Keep the measured siblings, including a red judge, while replacing only a missing review.
1836
+ // Every dispatched seat is excluded for this task, so the loop is bounded by the eligible pool.
1837
+ const runReviewRecovery = async (task, ctx) => {
1838
+ const round = await runGates(task, ctx);
1839
+ let review = round.results.find((g) => g.gate === "review");
1840
+ while (review?.meta?.noVerdict === true) {
1841
+ const next = pickReviewer(ctx.author, ctx.channels, badReviewers, cfg.review.prefer ?? [], task.routingHints?.floor, reviewHistory, undefined, demotedReviewers);
1842
+ if (!next)
1843
+ break;
1844
+ journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
1845
+ const retried = await runGates({ ...task, gates: ["review"] }, ctx);
1846
+ const replacement = retried.results.find((g) => g.gate === "review");
1847
+ // A dirty tree or another pre-dispatch refusal must still fail closed.
1848
+ if (!replacement) {
1849
+ round.results.push(...retried.results.filter((g) => !gateSatisfied(g)));
1850
+ break;
1851
+ }
1852
+ round.results = round.results.map((g) => g.gate === "review" ? replacement : g);
1853
+ review = replacement;
1854
+ }
1855
+ return round;
1856
+ };
1814
1857
  // v1.70 T5 (review-convergence): failed review rounds this task has drawn, counted from the per-task
1815
1858
  // review history already in the journal — the SAME review gate-result stream onGate reads to grow the
1816
1859
  // reviewer-exclusion list (badReviewers), never a second parallel counter. OBS-189: scoped to the
@@ -1835,7 +1878,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1835
1878
  // One helper, both onGate sites (satisfied-gate resume + main attempt loop).
1836
1879
  let gateSubject;
1837
1880
  const journalGateResult = (g) => {
1838
- const blocking = gateFailed(g) && (g.gate === "review" || g.gate === "acceptance");
1881
+ const blocking = g.meta?.infra !== true && gateFailed(g) && (g.gate === "review" || g.gate === "acceptance");
1839
1882
  // T2: a review that PASSED while DEFERRING a concern still recorded a defect — the prompt
1840
1883
  // promises the deferral is recorded and never dropped, and a details string is not a record a
1841
1884
  // later round can match. The blocking projection above is the only writer of structured
@@ -1856,12 +1899,17 @@ export async function runDaemon(repoRoot, opts = {}) {
1856
1899
  // The legacy baseline declines (a build command the repo never configured) have always written
1857
1900
  // `pass: true` beside `skipped: true` and every consumer already reads them right, so their row
1858
1901
  // is untouched: only a decline that would otherwise be recorded RED changes shape here.
1859
- const unverdicted = g.meta?.skipped === true && !g.pass;
1902
+ // A no-verdict review is journaled `infra` + `skipped` with NO `pass`: the canonical reader
1903
+ // (outcome.ts) files it as infra regardless, the `pass === false` readers (rounds, briefs,
1904
+ // park kind) never charge it, and outstandingReviewFindings (journal.ts:816) skips a `skipped`
1905
+ // row instead of reading a missing `pass` as a PASS that settles every open blocking finding.
1906
+ const noVerdictReview = g.gate === "review" && g.meta?.noVerdict === true;
1907
+ const unverdicted = (g.meta?.skipped === true && !g.pass) || noVerdictReview;
1860
1908
  journal.append("gate-result", t.id, {
1861
1909
  gate: g.gate, ...(unverdicted ? {} : { pass: g.pass }), details: g.details,
1862
1910
  ...(gateSubject ? { commit: gateSubject.commit, attempt: gateSubject.attempt } : {}),
1863
1911
  ...(gateSubject?.replayMeasurement ? { replayMeasurement: true } : {}),
1864
- ...(g.meta?.skipped === true ? { skipped: true } : {}),
1912
+ ...(g.meta?.skipped === true || noVerdictReview ? { skipped: true } : {}),
1865
1913
  // T9: an infra-only exit is journaled AS one. The operator reading a red `test` row has to
1866
1914
  // be able to tell "the suite found a defect" from "the runner never ran", and the merge
1867
1915
  // predicate's reason for refusing has to be legible in the ledger it refused from.
@@ -1887,6 +1935,8 @@ export async function runDaemon(repoRoot, opts = {}) {
1887
1935
  ...(g.meta?.reapedGroup === true ? { reapedGroup: true } : {}),
1888
1936
  ...(g.gate === "review" && typeof g.meta?.reviewer === "string" ? {
1889
1937
  reviewer: g.meta.reviewer,
1938
+ ...Object.fromEntries(["cause", "seatAuthoredBytes", "bytes", "rawPath", "briefPath", "timeoutMs", "unparseable", "noVerdict", "resolved", "reraised"]
1939
+ .filter((key) => g.meta?.[key] !== undefined).map((key) => [key, g.meta[key]])),
1890
1940
  ...(typeof g.meta.vendor === "string" ? { vendor: g.meta.vendor } : {}),
1891
1941
  ...(typeof g.meta.provider === "string" ? { provider: g.meta.provider } : {}),
1892
1942
  ...(typeof g.meta.rotationSeat === "number" ? { rotationSeat: g.meta.rotationSeat } : {}),
@@ -1896,7 +1946,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1896
1946
  // where work is allowed, not where this verdict found the defect.
1897
1947
  ...(blocking ? {
1898
1948
  taskContentDigest: contentDigest,
1899
- findings: structuredFindings(g.gate, g.details),
1949
+ findings: Array.isArray(g.meta?.findings) ? g.meta.findings : structuredFindings(g.gate, g.details),
1900
1950
  } : deferred.length > 0 ? {
1901
1951
  taskContentDigest: contentDigest,
1902
1952
  findings: deferred,
@@ -2262,7 +2312,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2262
2312
  };
2263
2313
  await trackedDriver.project?.(t.id, "in-review");
2264
2314
  journal.phaseStart(t.id, "gates");
2265
- const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runGates(resumedTask, {
2315
+ const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runReviewRecovery(resumedTask, {
2316
+ carriedFindings: outstandingReviewFindings(journal.read(), t.id),
2266
2317
  worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
2267
2318
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
2268
2319
  collateral: collateral.get(t.id) ?? [],
@@ -2278,7 +2329,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2278
2329
  }
2279
2330
  : undefined,
2280
2331
  excludeReviewers: badReviewers,
2281
- reviewHistory,
2332
+ reviewHistory, demotedReviewers,
2282
2333
  onGate: async (e) => {
2283
2334
  if (e.phase === "start") {
2284
2335
  notePhaseStart(e);
@@ -2286,7 +2337,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2286
2337
  return;
2287
2338
  }
2288
2339
  if (e.phase === "note") {
2289
- journal.append(e.name, t.id, e.payload);
2340
+ noteReviewEvent(e);
2290
2341
  return;
2291
2342
  }
2292
2343
  const g = e.result;
@@ -2313,6 +2364,11 @@ export async function runDaemon(repoRoot, opts = {}) {
2313
2364
  graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
2314
2365
  saveGraph(repoRoot, graph);
2315
2366
  if (!results.every(gateSatisfied)) {
2367
+ const infra = results.find((g) => g.meta?.infra === true);
2368
+ if (infra) {
2369
+ await park(t, `${infra.gate}: ${infra.details}`, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
2370
+ return;
2371
+ }
2316
2372
  // OBS-547: disposition FIRST, and through the same helper the ordinary attempt path uses. A
2317
2373
  // crash between a journaled scope red and its classification must not turn a predicted red
2318
2374
  // into a charged one just because a resume is what observed it.
@@ -2725,6 +2781,9 @@ export async function runDaemon(repoRoot, opts = {}) {
2725
2781
  journal.append("worker-launch", t.id, {
2726
2782
  attempt,
2727
2783
  retryMode,
2784
+ driver: trackedDriver.id,
2785
+ slot: { ...slot },
2786
+ workspace: driver.id === "herdr" ? process.env.HERDR_WORKSPACE_ID : undefined,
2728
2787
  ...(trackedDriver.describe ? await trackedDriver.describe(slot) : {}),
2729
2788
  });
2730
2789
  };
@@ -2863,6 +2922,12 @@ export async function runDaemon(repoRoot, opts = {}) {
2863
2922
  let startupFailure = false;
2864
2923
  let deadWorkerPark;
2865
2924
  let settleParsed;
2925
+ const trailerFrames = interactive && adapter.busyFrameMarkers
2926
+ ? new SettledTrailerTracker(adapter.busyFrameMarkers) : undefined;
2927
+ const sampleTrailer = (frame) => {
2928
+ const trailer = new RegExp(trailerPattern(nonce)).test(frame);
2929
+ return trailerFrames ? trailerFrames.sample(frame, trailer) : trailer;
2930
+ };
2866
2931
  let seedResult;
2867
2932
  // v1.22 T5 / OBS-19: auto-answer a fingerprint-matched trust dialog exactly once per slot.
2868
2933
  // Any other blocked/idle dialog pages the operator (unlatched since T1 — see below).
@@ -3028,14 +3093,22 @@ export async function runDaemon(repoRoot, opts = {}) {
3028
3093
  // own source, where "TICKMARKR_EXIT:" is a string literal) must not end the wait. Only a
3029
3094
  // parseable trailer or a digit-suffixed exit marker in the harvest is completion.
3030
3095
  output = await driver.read(slot, PANE_READ_ROWS); // TUI transcripts carry chrome — read deeper than print's 500
3031
- finished = new RegExp(trailerPattern(nonce)).test(output);
3096
+ finished = sampleTrailer(output);
3032
3097
  const exit = exitRe.exec(output);
3033
- if (finished || exit) {
3098
+ if (finished || (exit && !(trailerFrames && new RegExp(trailerPattern(nonce)).test(output)))) {
3034
3099
  exitCode = exit ? Number(exit[1]) : null; // null ⇔ the TUI is still alive
3035
3100
  processExited = exit !== null;
3036
3101
  await sampleContext(); // final poll-seam sample before leaving the wait
3037
3102
  break;
3038
3103
  }
3104
+ if (trailerFrames && new RegExp(trailerPattern(nonce)).test(output)) {
3105
+ // A matching busy frame is still a live turn. Preserve the rolling progress budget.
3106
+ await sampleContext();
3107
+ if (stallProgress.observe({ paneText: output, contextTokens }))
3108
+ lastProgressAt = Date.now();
3109
+ await new Promise((resolve) => setTimeout(resolve, Math.min(250, remaining)));
3110
+ continue;
3111
+ }
3039
3112
  }
3040
3113
  // A failed pane read is absence of evidence, never evidence of an absent pane. Keep the
3041
3114
  // rolling taskTimeoutMinutes window as the backstop and name the held probe once.
@@ -3070,6 +3143,16 @@ export async function runDaemon(repoRoot, opts = {}) {
3070
3143
  await new Promise((r) => setTimeout(r, Math.min(slice - spent, 1_000)));
3071
3144
  continue;
3072
3145
  }
3146
+ // waitOutput is only a wake hint; a driver may miss a trailer already painted.
3147
+ if (trailerFrames && sampleTrailer(paneText)) {
3148
+ output = paneText;
3149
+ finished = true;
3150
+ const exit = exitRe.exec(output);
3151
+ exitCode = exit ? Number(exit[1]) : null;
3152
+ processExited = exit !== null;
3153
+ await sampleContext();
3154
+ break;
3155
+ }
3073
3156
  if (paneText.length > 0)
3074
3157
  everHadOutput = true;
3075
3158
  if (startupFailureInWindow(paneText, workerLaunchedAt)) {
@@ -3421,9 +3504,9 @@ export async function runDaemon(repoRoot, opts = {}) {
3421
3504
  // un-answered nudge instead of the remainder of the window.
3422
3505
  nudgeDeadline = undefined;
3423
3506
  output = await driver.read(slot, PANE_READ_ROWS);
3424
- finished = new RegExp(trailerPattern(nonce)).test(output);
3507
+ finished = sampleTrailer(output);
3425
3508
  const exit = exitRe.exec(output);
3426
- if (finished || exit) {
3509
+ if (finished || (exit && !(trailerFrames && new RegExp(trailerPattern(nonce)).test(output)))) {
3427
3510
  exitCode = exit ? Number(exit[1]) : null;
3428
3511
  processExited = exit !== null;
3429
3512
  await sampleContext();
@@ -3468,12 +3551,12 @@ export async function runDaemon(repoRoot, opts = {}) {
3468
3551
  // The poll loop already recorded the unreadable pane. Retain the last readable bytes
3469
3552
  // so this ambiguous path still reaches the ordinary timeout/consult backstop.
3470
3553
  }
3471
- finished = new RegExp(trailerPattern(nonce)).test(output);
3554
+ finished = trailerFrames ? trailerFrames.settled : new RegExp(trailerPattern(nonce)).test(output);
3472
3555
  const exit = exitRe.exec(output);
3473
3556
  exitCode = exit ? Number(exit[1]) : null;
3474
3557
  processExited = exit !== null;
3475
3558
  }
3476
- if (finished) {
3559
+ if (finished && !trailerFrames) {
3477
3560
  await driver.waitAgentStatus(slot, "idle", 5_000); // settle, then re-harvest the final render
3478
3561
  output = await driver.read(slot, PANE_READ_ROWS);
3479
3562
  }
@@ -3493,11 +3576,12 @@ export async function runDaemon(repoRoot, opts = {}) {
3493
3576
  break;
3494
3577
  await new Promise((r) => setTimeout(r, Math.min(settleDelayMs, remaining)));
3495
3578
  output = await driver.read(slot, PANE_READ_ROWS);
3579
+ trailerFrames?.sample(output, new RegExp(trailerPattern(nonce)).test(output));
3496
3580
  settleParsed = adapter.parse(output, nonce);
3497
3581
  settleTries++;
3498
3582
  }
3499
3583
  if (settleParsed.summary !== UNPARSEABLE_TRAILER_SUMMARY) {
3500
- finished = settleParsed.summary !== NO_TRAILER_SUMMARY;
3584
+ finished = settleParsed.summary !== NO_TRAILER_SUMMARY && (!trailerFrames || trailerFrames.settled);
3501
3585
  }
3502
3586
  }
3503
3587
  }
@@ -3605,13 +3689,18 @@ export async function runDaemon(repoRoot, opts = {}) {
3605
3689
  // encoded and therefore invisible to the raw wait regex. Keep `output` bounded: every text
3606
3690
  // classifier below retains its pre-artifact tail surface.
3607
3691
  let result = settleParsed ?? adapter.parse(capturedStream.output, nonce);
3692
+ const unsettledTrailer = trailerFrames && !trailerFrames.settled
3693
+ && (trailerFrames.busyMarkersSeen.size > 0 || new RegExp(trailerPattern(nonce)).test(capturedStream.output));
3694
+ if (unsettledTrailer) {
3695
+ result = { ok: false, summary: `premature-idle: trailer did not settle; busy markers: ${[...trailerFrames.busyMarkersSeen].join(", ") || "awaiting two idle samples"}`, deviations: [], raw: output };
3696
+ }
3608
3697
  const adapterCause = result.cause;
3609
3698
  // Law 46: there is one completion fact. A classified adapter result with no parse-failure cause
3610
3699
  // means its decoder found the nonce-bound trailer even when the raw JSON envelope escaped it.
3611
- const workerFinished = finished || adapterCause === undefined;
3700
+ const workerFinished = !unsettledTrailer && (finished || adapterCause === undefined);
3612
3701
  finished = workerFinished;
3613
3702
  const adapterStartupFailure = adapterCause === "startup-failure";
3614
- const workerCause = adapterStartupFailure
3703
+ const workerCause = unsettledTrailer ? "premature-idle" : adapterStartupFailure
3615
3704
  ? "startup-failure"
3616
3705
  : startupFailure
3617
3706
  ? "startup-failure"
@@ -3700,9 +3789,24 @@ export async function runDaemon(repoRoot, opts = {}) {
3700
3789
  // never certifies the channel. Reading the synthesized `finished`/`ok` here reset the streak
3701
3790
  // on every harvest, so a CLI that produces commits and swallows every trailer was immune to
3702
3791
  // the two-window demotion and stayed first pick for the rest of the run.
3792
+ // Q-1 (OBS-926): BOTH branches read the same tail — a print stream tested whole failed a
3793
+ // pinned codex worker over for a diff line number (`429:`) 41 985 bytes before EOF and for
3794
+ // this repository's own fixture prose. The matched bytes and their stream offset ride the
3795
+ // row so a false positive is legible from the record alone.
3796
+ const quotaMatch = (interactive ? !workerFinished : exitCode !== 0)
3797
+ ? QUOTA_RE.exec(stallSnapshotBannerRows(output))
3798
+ : null;
3799
+ // Q-1: a graph pin is an operator instruction — a quota match ALONE never overrides it; only a
3800
+ // channel-attributed error (auth/setup/outage/timeout, the typed dead-channel classes) may.
3801
+ const pin = t.routingHints?.pin;
3802
+ const onPin = !!pin && assignment.adapter === pin.via && assignment.model === pin.model;
3803
+ const channelError = quotaMatch && onPin ? classifyDeadChannel({ ...preHarvestResult, raw: output }) : undefined;
3804
+ const pinRefusesQuota = !!quotaMatch && onPin && !channelError;
3805
+ // a refused quota window is not a no-trailer verdict on the pinned channel: counting it would
3806
+ // demote the pin two tails in and the demotion re-dispatch would move the task off it.
3703
3807
  if (preHarvestResult.ok && workerFinished)
3704
3808
  noTrailerStreak.set(channelKey(assignment), 0);
3705
- else if (!workerFinished && cause !== "provider-death") {
3809
+ else if (!workerFinished && cause !== "provider-death" && !pinRefusesQuota) {
3706
3810
  const ck = channelKey(assignment);
3707
3811
  const streak = (noTrailerStreak.get(ck) ?? 0) + 1;
3708
3812
  noTrailerStreak.set(ck, streak);
@@ -3732,12 +3836,29 @@ export async function runDaemon(repoRoot, opts = {}) {
3732
3836
  // T2 review: the gate is `workerFinished`, not the harvest-synthesized `finished` — a
3733
3837
  // committed-but-quota-walled attempt routes here FIRST (its commits ride the carry-forward
3734
3838
  // into the next attempt's recreated worktree), it never buys a gate run on throttled work.
3735
- const quotaHit = interactive
3736
- ? !workerFinished && QUOTA_RE.test(stallSnapshotBannerRows(output))
3737
- : exitCode !== 0 && QUOTA_RE.test(output);
3738
- if (quotaHit) {
3839
+ // Q-1 review: NO cap and NO fall-through to the consult ladder (failoverOrRecycle ignores the
3840
+ // pin) — the attempt is counted, so a pin that never clears parks at MAX_ATTEMPTS on its own
3841
+ // channel; only a channel-attributed error below may leave it.
3842
+ if (pinRefusesQuota) {
3843
+ journal.append("quota-failover-refused", t.id, {
3844
+ pin: `${pin.via}:${pin.model}`, channel: channelKey(assignment), matched: quotaMatch[0],
3845
+ reason: "pinned channel; no channel-attributed error", attempt,
3846
+ });
3847
+ await new Promise((r) => setTimeout(r, PROVIDER_DEATH_BACKOFF_MS));
3848
+ continue;
3849
+ }
3850
+ if (quotaMatch && (!onPin || channelError)) {
3739
3851
  const next = failover("quota-failover");
3740
- journal.append("quota-failover", t.id, { from: channelKey(assignment), to: next ? channelKey(next) : null });
3852
+ // byte offset inside the PERSISTED stream (the journaled `stream` file) — the capture keeps
3853
+ // only its last MAX_BUF bytes, so the file's origin may sit past the buffer's.
3854
+ const streamBytes = Buffer.byteLength(capturedStream.output);
3855
+ const idx = capturedStream.output.lastIndexOf(quotaMatch[0]);
3856
+ const offset = idx >= 0 ? Buffer.byteLength(capturedStream.output.slice(0, idx)) - Math.max(0, streamBytes - MAX_BUF) : null;
3857
+ journal.append("quota-failover", t.id, {
3858
+ from: channelKey(assignment), to: next ? channelKey(next) : null,
3859
+ matched: quotaMatch[0], offset, stream: capturedStream.path, streamBytes: Math.min(streamBytes, MAX_BUF),
3860
+ ...(onPin ? { pin: `${pin.via}:${pin.model}`, channelError } : {}),
3861
+ });
3741
3862
  if (next) {
3742
3863
  await driver.notify(`tickmarkr ${runId}: ${t.id} quota failover`, { tier: "attention" });
3743
3864
  // OBS-17 T2: the superseded slot's pane closes AT REROUTE TIME — it holds a throttled
@@ -3858,7 +3979,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3858
3979
  return;
3859
3980
  }
3860
3981
  if (e.phase === "note") {
3861
- journal.append(e.name, t.id, e.payload);
3982
+ noteReviewEvent(e);
3862
3983
  return;
3863
3984
  }
3864
3985
  const g = e.result;
@@ -3896,7 +4017,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3896
4017
  gateSubject = { commit: await gateCommitSubject(taskBase, gated, wt), attempt };
3897
4018
  await trackedDriver.project?.(t.id, "in-review");
3898
4019
  journal.phaseStart(t.id, "gates");
3899
- ({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runGates(t, {
4020
+ ({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runReviewRecovery(t, {
4021
+ carriedFindings: outstandingFindings,
3900
4022
  worktree: wt, baseRef: taskBase, result, author: assignment,
3901
4023
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
3902
4024
  collateral: collateral.get(t.id) ?? [],
@@ -3918,7 +4040,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3918
4040
  }
3919
4041
  : undefined,
3920
4042
  excludeReviewers: badReviewers,
3921
- reviewHistory,
4043
+ reviewHistory, demotedReviewers,
3922
4044
  onGate,
3923
4045
  })));
3924
4046
  results.forEach(classifySignalOnlyTest);
@@ -36,12 +36,69 @@ export declare function runLockOwner(repoRoot: string): {
36
36
  export declare function runLockRunId(repoRoot: string): string | undefined;
37
37
  export declare function runStatusLine(repoRoot: string, runId: string, events: readonly RunLineEvent[]): string | null;
38
38
  export declare function isRunLockLive(repoRoot: string): boolean;
39
- export declare function unlockRun(repoRoot: string): {
39
+ export interface LockSnapshot {
40
+ pid?: number;
41
+ runId?: string;
42
+ garbage: boolean;
43
+ unreadable: boolean;
44
+ dead: boolean;
45
+ ino: number;
46
+ mtimeMs: number;
47
+ raw: Buffer;
48
+ }
49
+ export type UnlockPreview = {
40
50
  held: false;
41
51
  } | {
42
52
  held: true;
53
+ eligible: false;
54
+ reason: string;
55
+ pid?: number;
56
+ runId?: string;
57
+ } | {
58
+ held: true;
59
+ eligible: true;
60
+ pid: number;
61
+ runId: string;
62
+ ino: number;
63
+ mtimeMs: number;
64
+ raw: Buffer;
65
+ };
66
+ export declare function previewUnlock(repoRoot: string, runId: string): UnlockPreview;
67
+ export declare function commitUnlock(repoRoot: string, target: {
68
+ pid: number;
69
+ runId: string;
70
+ ino: number;
71
+ mtimeMs: number;
72
+ raw: Buffer;
73
+ }): {
43
74
  removed: true;
75
+ pid: number;
76
+ runId: string;
77
+ } | {
78
+ removed: false;
79
+ reason: string;
80
+ };
81
+ export type UnlockGarbagePreview = {
82
+ held: false;
83
+ } | {
84
+ held: true;
85
+ eligible: false;
86
+ reason: string;
44
87
  pid?: number;
45
88
  runId?: string;
46
- garbage: boolean;
89
+ } | {
90
+ held: true;
91
+ eligible: true;
92
+ ino: number;
93
+ raw: Buffer;
94
+ };
95
+ export declare function previewGarbageUnlock(repoRoot: string): UnlockGarbagePreview;
96
+ export declare function commitGarbageUnlock(repoRoot: string, target: {
97
+ ino: number;
98
+ raw: Buffer;
99
+ }): {
100
+ removed: true;
101
+ } | {
102
+ removed: false;
103
+ reason: string;
47
104
  };