tickmarkr 2.1.9 → 2.2.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.
@@ -1,8 +1,9 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { shq } from "../adapters/types.js";
3
- import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
5
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
6
7
  import { stringify } from "yaml";
7
8
  import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
8
9
  import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
@@ -15,13 +16,14 @@ import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js
15
16
  import { formatOwnedName } from "../drivers/types.js";
16
17
  import { captureBaseline, detectGateCommands, detectVacuousOracles } from "../gates/baseline.js";
17
18
  import { runGates } from "../gates/run-gates.js";
19
+ import { filesGlob } from "../graph/files-glob.js";
18
20
  import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
19
21
  import { GATE_NAMES } from "../graph/schema.js";
20
22
  import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
21
23
  import { runEnvironment } from "./environment.js";
22
24
  import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
23
25
  import { runInteractiveSeed } from "./interactive-seed.js";
24
- import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingReviewFindings, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, renderStructuredReviewFinding, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
26
+ 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";
25
27
  import { isDiffCapPark } from "../gates/review.js";
26
28
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
27
29
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
@@ -76,12 +78,16 @@ const DISPATCH_ENACTMENT = new Set(["task-dispatch", "repair-dispatch"]);
76
78
  // for the task is worktree-recreation. That event is causal for this path, not incidental.
77
79
  const GATE_SATISFIED_ENACTMENT = "worktree-recreation";
78
80
  /**
79
- * T14: approvals the run accepted and never acted on. `approved` above is built ONCE at startup —
80
- * deliberately, replay determinism depends on it — so an approval written while the daemon is live is
81
- * inert for that run. Without this the run-end record stated only buckets and tipVerify, both
82
- * accurate, over a milestone that was silently incomplete: run …230 ended tipVerify "passed" with two
83
- * upheld approvals and zero subsequent dispatches. Scored per task on its NEWEST approval: a later
84
- * approval is the live decision, and the events that answer it are the ones after it.
81
+ * T14, amended by v2.2 T3: approvals the run accepted and never acted on. `approved` above is still
82
+ * built ONCE at startup — replay determinism depends on it — but a live approval is no longer inert:
83
+ * the boundary sweep in the task loop releases what lands while the daemon runs, so an approval
84
+ * written mid-run is normally enacted by this run. ONE window survives, and it is the reason this
85
+ * fold still exists: an approval accepted after the task loop exits — during tip verify, before the
86
+ * run-end sample below — meets no further boundary, so nothing can release it before this run ends.
87
+ * Without this the run-end record stated only buckets and tipVerify, both accurate, over a milestone
88
+ * that was silently incomplete: run …230 ended tipVerify "passed" with two upheld approvals and zero
89
+ * subsequent dispatches. Scored per task on its NEWEST approval: a later approval is the live
90
+ * decision, and the events that answer it are the ones after it.
85
91
  */
86
92
  export function outstandingApprovals(events) {
87
93
  const newest = new Map();
@@ -687,6 +693,202 @@ function compareWorktrees(before, after) {
687
693
  return "unreadable";
688
694
  return before.signature === after.signature ? "unchanged" : "changed";
689
695
  }
696
+ const CONTEXT_GLOB_CHARS = /[*?{[]/;
697
+ const MATERIALIZED_CONTEXT_BYTES = 256 * 1024 * 1024;
698
+ function containedIn(root, path) {
699
+ const fromRoot = relative(resolve(root), resolve(path));
700
+ return fromRoot === "" || (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot));
701
+ }
702
+ function normalizeContextPath(entry) {
703
+ if (entry.includes("\0"))
704
+ return undefined;
705
+ const repositoryPath = entry.replace(/\\/g, "/");
706
+ if (posix.isAbsolute(repositoryPath) || repositoryPath.split("/").includes(".."))
707
+ return undefined;
708
+ const normalized = posix.normalize(repositoryPath).replace(/\/+$/, "");
709
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../"))
710
+ return undefined;
711
+ return normalized;
712
+ }
713
+ function workerFilesystemEntries(worktree) {
714
+ const entries = [];
715
+ const visit = (dir, prefix) => {
716
+ let dirents;
717
+ try {
718
+ dirents = readdirSync(dir, { withFileTypes: true });
719
+ }
720
+ catch (error) {
721
+ const rel = relative(worktree, dir).split(sep).join(posix.sep) || ".";
722
+ const message = error instanceof Error ? error.message : String(error);
723
+ throw new Error(`context filesystem observation failed at ${rel}: ${message}`);
724
+ }
725
+ for (const dirent of dirents) {
726
+ if (dirent.name === ".git")
727
+ continue;
728
+ const rel = prefix ? `${prefix}/${dirent.name}` : dirent.name;
729
+ entries.push(rel);
730
+ if (dirent.isDirectory())
731
+ visit(join(dir, dirent.name), rel);
732
+ }
733
+ };
734
+ visit(worktree, "");
735
+ return entries;
736
+ }
737
+ function exactContextResolvesInWorktree(entry, normalized, worktree) {
738
+ const fullPath = join(worktree, normalized);
739
+ try {
740
+ const stat = lstatSync(fullPath);
741
+ if (stat.isSymbolicLink())
742
+ return false;
743
+ if (stat.isDirectory())
744
+ return true;
745
+ return !/\/+$/.test(entry) && stat.isFile() && worktreeHeadHasRegularBlob(worktree, normalized);
746
+ }
747
+ catch (error) {
748
+ if (["EACCES", "ELOOP", "ENOENT", "ENOTDIR", "EPERM"].includes(error.code ?? ""))
749
+ return false;
750
+ throw error;
751
+ }
752
+ }
753
+ function patternContextResolvesInWorktree(normalized, worktree) {
754
+ const match = filesGlob(normalized);
755
+ return workerFilesystemEntries(worktree).some((path) => match(path));
756
+ }
757
+ function regularBlobObjectId(repoRoot, ref, normalized) {
758
+ let tree = "";
759
+ try {
760
+ tree = execFileSync("git", ["-C", repoRoot, "ls-tree", "-z", ref, "--", normalized], {
761
+ encoding: "utf8",
762
+ maxBuffer: 1024 * 1024,
763
+ });
764
+ }
765
+ catch {
766
+ return undefined;
767
+ }
768
+ const row = tree.split("\0")
769
+ .filter(Boolean)
770
+ .map((line) => /^(\d{6}) (\w+) ([0-9a-f]+)\t([\s\S]*)$/.exec(line))
771
+ .find((match) => match?.[4] === normalized);
772
+ if (!row || row[2] !== "blob" || (row[1] !== "100644" && row[1] !== "100755"))
773
+ return undefined;
774
+ return row[3];
775
+ }
776
+ function worktreeHeadHasRegularBlob(worktree, normalized) {
777
+ return regularBlobObjectId(worktree, "HEAD", normalized) !== undefined;
778
+ }
779
+ function committedRegularBlob(repoRoot, normalized) {
780
+ const objectId = regularBlobObjectId(repoRoot, "HEAD", normalized);
781
+ if (!objectId)
782
+ return undefined;
783
+ try {
784
+ const bytes = execFileSync("git", ["-C", repoRoot, "cat-file", "blob", objectId], {
785
+ encoding: "buffer",
786
+ maxBuffer: MATERIALIZED_CONTEXT_BYTES,
787
+ });
788
+ return { bytes };
789
+ }
790
+ catch {
791
+ return undefined;
792
+ }
793
+ }
794
+ function materializedContextDestination(root, task, attempt, normalized) {
795
+ const key = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
796
+ const leaf = basename(normalized).replace(/[^A-Za-z0-9._-]/g, "_") || "context";
797
+ const destination = join(root, task.id, `a${attempt}`, `${key}-${leaf}`);
798
+ if (!containedIn(root, destination))
799
+ throw new Error(`materialized context destination escapes context subtree: ${normalized}`);
800
+ return destination;
801
+ }
802
+ function ensureDirectoryWithoutSymlink(path) {
803
+ let stat;
804
+ try {
805
+ stat = lstatSync(path);
806
+ }
807
+ catch (error) {
808
+ if (error.code !== "ENOENT")
809
+ throw error;
810
+ mkdirSync(path, { mode: 0o700 });
811
+ stat = lstatSync(path);
812
+ }
813
+ if (stat.isSymbolicLink())
814
+ throw new Error(`materialized context destination ancestor is a symlink: ${path}`);
815
+ if (!stat.isDirectory())
816
+ throw new Error(`materialized context destination ancestor is not a directory: ${path}`);
817
+ }
818
+ function ensureContainedContextParent(root, destination) {
819
+ const rootAbs = resolve(root);
820
+ const destAbs = resolve(destination);
821
+ if (!containedIn(rootAbs, destAbs))
822
+ throw new Error("materialized context destination escapes context subtree");
823
+ const parent = dirname(destAbs);
824
+ ensureDirectoryWithoutSymlink(rootAbs);
825
+ const relParent = relative(rootAbs, parent);
826
+ let current = rootAbs;
827
+ for (const segment of relParent.split(sep).filter(Boolean)) {
828
+ current = join(current, segment);
829
+ ensureDirectoryWithoutSymlink(current);
830
+ }
831
+ if (!containedIn(realpathSync(rootAbs), realpathSync(parent))) {
832
+ throw new Error("materialized context destination realpath escapes context subtree");
833
+ }
834
+ }
835
+ function writeContainedContextFile(root, destination, bytes) {
836
+ if (!containedIn(root, destination))
837
+ throw new Error("materialized context destination escapes context subtree");
838
+ ensureContainedContextParent(root, destination);
839
+ const fd = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
840
+ try {
841
+ writeFileSync(fd, bytes);
842
+ }
843
+ finally {
844
+ closeSync(fd);
845
+ }
846
+ }
847
+ async function materializeContextEntry(repoRoot, worktree, contextRoot, task, attempt, entry) {
848
+ const normalized = normalizeContextPath(entry);
849
+ if (!normalized)
850
+ return { kind: "missing", path: entry, reason: "invalid-path" };
851
+ if (exactContextResolvesInWorktree(entry, normalized, worktree))
852
+ return { kind: "keep", entry };
853
+ const blob = committedRegularBlob(repoRoot, normalized);
854
+ if (blob) {
855
+ const destination = materializedContextDestination(contextRoot, task, attempt, normalized);
856
+ writeContainedContextFile(contextRoot, destination, blob.bytes);
857
+ return { kind: "materialized", entry: destination, normalized, destination, bytes: blob.bytes.length };
858
+ }
859
+ if (CONTEXT_GLOB_CHARS.test(entry)) {
860
+ if (patternContextResolvesInWorktree(normalized, worktree))
861
+ return { kind: "keep", entry };
862
+ return { kind: "missing", path: entry, reason: "unresolved-pattern" };
863
+ }
864
+ if (/\/+$/.test(entry))
865
+ return { kind: "missing", path: entry, reason: "unresolved-pattern" };
866
+ return { kind: "missing", path: entry, reason: "no-committed-regular-blob" };
867
+ }
868
+ async function taskWithMaterializedContext(repoRoot, journal, worktree, task, attempt) {
869
+ if (task.context.length === 0)
870
+ return task;
871
+ const contextRoot = resolve(journal.dir, "context");
872
+ const context = [];
873
+ for (const entry of task.context) {
874
+ const prepared = await materializeContextEntry(repoRoot, worktree, contextRoot, task, attempt, entry);
875
+ if (prepared.kind === "missing") {
876
+ journal.append("context-missing", task.id, { path: prepared.path, reason: prepared.reason });
877
+ }
878
+ else {
879
+ context.push(prepared.entry);
880
+ if (prepared.kind === "materialized") {
881
+ journal.append("context-materialized", task.id, {
882
+ path: entry,
883
+ normalized: prepared.normalized,
884
+ destination: prepared.destination,
885
+ bytes: prepared.bytes,
886
+ });
887
+ }
888
+ }
889
+ }
890
+ return { ...task, context };
891
+ }
690
892
  async function cherryPickCommits(wt, commits) {
691
893
  const carried = [];
692
894
  for (const hash of commits) {
@@ -975,10 +1177,11 @@ export async function runDaemon(repoRoot, opts = {}) {
975
1177
  journal.append("lock-reclaimed", undefined, lock.reclaimed); // HARD-02 audit trail
976
1178
  }
977
1179
  // GATE-08 (v1.12): the humanGate guard consults this run's journaled approvals, not just the compiled
978
- // flag. Built ONCE at startup from the journal; a fresh run's journal is empty ⇒ empty set ⇒ unapproved
979
- // gates park exactly as today. (D-02 step 3 — the load-bearing change: a command + event WITHOUT this
980
- // guard change ships a no-op; the task replays to pending, re-enters execTask, and re-parks.)
981
- const approved = new Set(journal.read().filter((e) => e.event === "task-approved" && e.taskId).map((e) => e.taskId));
1180
+ // flag. Startup approvals seed the first scheduling pass; live approvals are folded at task
1181
+ // boundaries below so a sibling can release parked work without waiting for run-end + resume.
1182
+ const approvalStartupEvents = journal.read();
1183
+ const approved = new Set(approvalStartupEvents.filter((e) => e.event === "task-approved" && e.taskId).map((e) => e.taskId));
1184
+ let approvalSweepCursor = approvalStartupEvents.length;
982
1185
  const commands = detectGateCommands(repoRoot, cfg);
983
1186
  let baseRef;
984
1187
  let baseline;
@@ -986,9 +1189,9 @@ export async function runDaemon(repoRoot, opts = {}) {
986
1189
  // Empty Map on fresh runs — every seed below conditions on resume.get(t.id), never on opts.resume (the
987
1190
  // GATE-08 lesson at the humanGate guard: condition on the data, not the code path). Dead-code
988
1191
  // equivalence to the router.ts:194 profile⇒undefined pattern: no map entry ⇒ today's literal.
989
- const resume = opts.resume ? journal.replayResumeState() : new Map();
990
- const satisfiedGates = opts.resume ? journal.replaySatisfiedGates() : new Map();
991
- const replayedGateResults = resumeLifecycleOpen
1192
+ let resume = opts.resume ? journal.replayResumeState() : new Map();
1193
+ let satisfiedGates = opts.resume ? journal.replaySatisfiedGates() : new Map();
1194
+ let replayedGateResults = resumeLifecycleOpen
992
1195
  ? journal.replayCurrentAttemptGateResults()
993
1196
  : new Map();
994
1197
  // T7: the capacity THIS session resolved — read inside the run's fork budget, so it is the number
@@ -1093,8 +1296,8 @@ export async function runDaemon(repoRoot, opts = {}) {
1093
1296
  try {
1094
1297
  watchSlot = await driver.narrator?.(repoRoot, watchCommand(runId), runId);
1095
1298
  }
1096
- catch {
1097
- /* cosmetic-only — the run proceeds without a live surface */
1299
+ catch (error) {
1300
+ journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
1098
1301
  }
1099
1302
  const intWt = await ensureIntegration(repoRoot, branch, baseRef);
1100
1303
  // v1.1 visibility: role-named slots; panes persist per keepPanes (attempt = v1 close-after-harvest)
@@ -1169,6 +1372,14 @@ export async function runDaemon(repoRoot, opts = {}) {
1169
1372
  };
1170
1373
  // gateFails/consults are execTask-scoped counters passed in so a park row is a rich verified-failure
1171
1374
  // observation (e.g. ladder-exhausted + gateFails:4); every task-human row has a closed kind, never prose alone.
1375
+ const gateFailApprovalReason = (taskId, identity, includeUphold = false) => {
1376
+ const commands = [
1377
+ `tickmarkr approve ${runId} ${taskId} --waive`,
1378
+ `tickmarkr approve ${runId} ${taskId} --recheck`,
1379
+ ...(includeUphold ? [`tickmarkr approve ${runId} ${taskId} --uphold`] : []),
1380
+ ];
1381
+ return `${identity} — release with ${commands.map((command) => `\`${command}\``).join(" or ")}`;
1382
+ };
1172
1383
  const park = async (t, reason, kind, assignment, attempts, startMs, gateFails = 0, consults = 0, tokens, metered = 0, retryMode = "fresh", details = {}) => {
1173
1384
  graph = setStatus(graph, t.id, "human");
1174
1385
  saveGraph(repoRoot, graph);
@@ -1228,6 +1439,38 @@ export async function runDaemon(repoRoot, opts = {}) {
1228
1439
  }
1229
1440
  return false;
1230
1441
  };
1442
+ const sweepLiveApprovals = () => {
1443
+ const events = journal.read();
1444
+ const approvals = events.slice(approvalSweepCursor)
1445
+ .filter((e) => e.event === "task-approved" && e.taskId);
1446
+ approvalSweepCursor = events.length;
1447
+ if (approvals.length === 0)
1448
+ return;
1449
+ resume = journal.replayResumeState();
1450
+ satisfiedGates = journal.replaySatisfiedGates();
1451
+ replayedGateResults = resumeLifecycleOpen
1452
+ ? journal.replayCurrentAttemptGateResults()
1453
+ : new Map();
1454
+ let changed = false;
1455
+ for (const e of approvals) {
1456
+ const taskId = e.taskId;
1457
+ approved.add(taskId);
1458
+ if (e.data.release !== GATE_SATISFIED_RELEASE)
1459
+ replayedGateResults.delete(taskId);
1460
+ try {
1461
+ const task = getTask(graph, taskId);
1462
+ if (task.status === "human" || task.status === "failed") {
1463
+ graph = setStatus(graph, taskId, "pending");
1464
+ changed = true;
1465
+ }
1466
+ }
1467
+ catch {
1468
+ // Unknown task in a stale journal: approval replay is inert, like the other journal folds.
1469
+ }
1470
+ }
1471
+ if (changed)
1472
+ saveGraph(repoRoot, graph);
1473
+ };
1231
1474
  const execTask = async (t) => {
1232
1475
  const startMs = Date.now();
1233
1476
  const taskTimeoutMinutes = t.timeoutMinutes ?? cfg.taskTimeoutMinutes;
@@ -1801,7 +2044,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1801
2044
  // feedback from those rows. Only an operator-authorized gate release parks on a new red.
1802
2045
  if (!satisfiedGate)
1803
2046
  break gateLoop;
1804
- await park(t, "post-approval gate failed", "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
2047
+ await park(t, gateFailApprovalReason(t.id, "post-approval gate failed", results.some((g) => g.gate === "review" && gateFailed(g))), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
1805
2048
  return;
1806
2049
  }
1807
2050
  const m = await mergeSerial(taskBranch, t, gated);
@@ -1859,7 +2102,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1859
2102
  // survives the reset and governs only those further rounds. Absence preserves the module default.
1860
2103
  const reviewRoundCap = approvedReviewRoundCeiling(journal.read(), t.id) ?? REVIEW_ROUND_CAP;
1861
2104
  if (attempt > 0 && reviewRoundsDrawn() >= reviewRoundCap) {
1862
- await park(t, `review round cap (${reviewRoundCap}) reached this engagement — \`tickmarkr approve\` accepts the diff past review; \`tickmarkr approve --uphold\` funds one fixed attempt carrying the findings`, "gate-fail", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
2105
+ await park(t, gateFailApprovalReason(t.id, `review round cap (${reviewRoundCap}) reached this engagement`, true), "gate-fail", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
1863
2106
  return;
1864
2107
  }
1865
2108
  // OBS-57: a demoted channel must not be re-dispatched on consult retry or provider requeue.
@@ -1895,7 +2138,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1895
2138
  gate: banned, from: channelKey(assignment), to: next ? channelKey(next) : null,
1896
2139
  });
1897
2140
  if (!next) {
1898
- await park(t, `identical ${banned} failure twice this engagement — an identical retry is banned and no untried channel is left`, "gate-fail", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
2141
+ await park(t, gateFailApprovalReason(t.id, `identical ${banned} failure twice this engagement — an identical retry is banned and no untried channel is left`), "gate-fail", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
1899
2142
  return;
1900
2143
  }
1901
2144
  assignment = next;
@@ -1964,6 +2207,13 @@ export async function runDaemon(repoRoot, opts = {}) {
1964
2207
  })].join("\n");
1965
2208
  feedback = feedback ? `${feedback}\n\n${brief}` : brief;
1966
2209
  }
2210
+ const carriedConsultGuidance = outstandingConsultGuidance(journaledSoFar, t.id);
2211
+ if (carriedConsultGuidance) {
2212
+ const consultBrief = renderRetryGuidance({ ...carriedConsultGuidance, notes: "" });
2213
+ if (consultBrief && !feedback.includes(consultBrief)) {
2214
+ feedback = feedback ? `${feedback}\n\n${consultBrief}` : consultBrief;
2215
+ }
2216
+ }
1967
2217
  retryMode = repairFindings
1968
2218
  ? "repair"
1969
2219
  : priorSession
@@ -1992,6 +2242,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1992
2242
  journal.append("task-dispatch", t.id, {
1993
2243
  assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
1994
2244
  ...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
2245
+ ...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
1995
2246
  });
1996
2247
  journal.phaseStart(t.id, "worker", { attempt, assignment });
1997
2248
  const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
@@ -2091,7 +2342,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2091
2342
  return;
2092
2343
  }
2093
2344
  }
2094
- const promptFile = writePrompt(journal.dir, t, attempt, feedback, nonce);
2345
+ const promptTask = await taskWithMaterializedContext(repoRoot, journal, wt, t, attempt);
2346
+ const promptFile = writePrompt(journal.dir, promptTask, attempt, feedback, nonce);
2095
2347
  // OBS-56: state the non-interactive, one-pass finish contract and the OBS-54 stall budget in every
2096
2348
  // worker prompt, not only consult retry guidance. Prepended so prompt.ts's completion trailer stays last.
2097
2349
  const workerContract = `## Harness contract\n- This harness is non-interactive: make one continuous pass; do not stop for questions or follow-up input.\n- You have a ${taskTimeoutMinutes} minute stall window. The gates run the full suite for you — never spend this window on one; commit your work and emit the completion trailer inside it.\n- Each test: acceptance criterion must exist as a vitest test whose OWN title (the leaf, not counting enclosing describe titles) is the criterion string verbatim — never shortened, never decorated. Nesting under describe() is allowed.`; // OBS-64; OBS-511: leaf-title rule stated where the worker reads it; OBS-548: the suite is the GATES' job — this repo's own suite outlasts the output-silence windows the daemon polices, so a worker obeying "budget the full suite" was killed by construction
@@ -3465,6 +3717,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3465
3717
  // must still stop the run before it can dispatch more work or write run-end.
3466
3718
  if (termSignal)
3467
3719
  throw new Error(`terminated by ${termSignal}`);
3720
+ sweepLiveApprovals();
3468
3721
  const ready = readyTasks(graph)
3469
3722
  .filter((t) => !inflight.has(t.id))
3470
3723
  .slice(0, Math.max(0, concurrency - inflight.size));
@@ -44,6 +44,21 @@ export interface StructuredFinding {
44
44
  rationale?: string;
45
45
  fingerprint: string;
46
46
  }
47
+ declare const CONSULT_ACTIONS: readonly ["retry", "reroute", "decompose", "human"];
48
+ export type ConsultGuidanceAction = (typeof CONSULT_ACTIONS)[number];
49
+ export interface ConsultGuidanceCarry {
50
+ action: ConsultGuidanceAction;
51
+ reason?: string;
52
+ guidance?: string;
53
+ }
54
+ /**
55
+ * The newest structured consult prescription still open for this task. A consult park is only a
56
+ * pause: graph-changed revival can dispatch a fresh worker without a live `applyVerdict()` frame, so
57
+ * prompt-build re-derives the worker-facing bullets from the journal. Notes are deliberately excluded:
58
+ * they are audit prose, and OBS-37a says workers get only structured reason/guidance rendered by
59
+ * `renderRetryGuidance()`.
60
+ */
61
+ export declare function outstandingConsultGuidance(events: JournalEvent[], taskId: string): ConsultGuidanceCarry | undefined;
47
62
  export declare const UNIDENTIFIED = "<unidentified>";
48
63
  /**
49
64
  * Structured findings for a BLOCKING review/judge gate result, parsed from the details the gate
@@ -335,3 +350,4 @@ export declare class Journal {
335
350
  readTelemetry(): TelemetryRow[];
336
351
  readJudgeTelemetry(): TelemetryRow[];
337
352
  }
353
+ export {};
@@ -148,6 +148,40 @@ export function upheldFeedbackByTask(events) {
148
148
  }
149
149
  return upheld;
150
150
  }
151
+ const CONSULT_ACTIONS = ["retry", "reroute", "decompose", "human"];
152
+ const consultAction = (action) => typeof action === "string" && CONSULT_ACTIONS.includes(action)
153
+ ? action
154
+ : undefined;
155
+ /**
156
+ * The newest structured consult prescription still open for this task. A consult park is only a
157
+ * pause: graph-changed revival can dispatch a fresh worker without a live `applyVerdict()` frame, so
158
+ * prompt-build re-derives the worker-facing bullets from the journal. Notes are deliberately excluded:
159
+ * they are audit prose, and OBS-37a says workers get only structured reason/guidance rendered by
160
+ * `renderRetryGuidance()`.
161
+ */
162
+ export function outstandingConsultGuidance(events, taskId) {
163
+ let carry;
164
+ for (const e of events) {
165
+ if (e.taskId !== taskId)
166
+ continue;
167
+ if (e.event === "consult-verdict") {
168
+ const action = consultAction(e.data.action);
169
+ const reason = typeof e.data.reason === "string" && e.data.reason.trim() ? e.data.reason : undefined;
170
+ const guidance = typeof e.data.guidance === "string" && e.data.guidance.trim() ? e.data.guidance : undefined;
171
+ carry = action && (reason || guidance)
172
+ ? {
173
+ action,
174
+ ...(reason ? { reason } : {}),
175
+ ...(guidance ? { guidance } : {}),
176
+ }
177
+ : undefined;
178
+ }
179
+ else if (e.event === "task-done" || e.event === "merge") {
180
+ carry = undefined;
181
+ }
182
+ }
183
+ return carry;
184
+ }
151
185
  // Reserved for a finding whose OWN evidence names no path. Reporting a blank path a reader would take
152
186
  // for a resolved one is the silent-lie shape the gates exist to refuse, so the field says so outright.
153
187
  export const UNIDENTIFIED = "<unidentified>";
@@ -1360,16 +1394,27 @@ export class Journal {
1360
1394
  }
1361
1395
  return m;
1362
1396
  }
1363
- // OBS-130: gate satisfaction is authority, not an inferred daemon state. Only an explicit
1364
- // task-approved event with the typed release marker and a known gate enters this fold. A daemon-made
1365
- // gate-satisfied event, a prior pass/fail result, malformed data, or another task's approval is inert.
1397
+ // OBS-130/OBS-571: gate satisfaction is authority, not an inferred daemon state. Only an explicit
1398
+ // task-approved event with the typed release marker and a known gate enters this fold. Authority is
1399
+ // scoped to that approval: any later approval supersedes it, and the worktree-recreation row written
1400
+ // before the approved gate suffix runs consumes it at enactment. A daemon-made gate-satisfied event,
1401
+ // a prior pass/fail result, malformed data, or another task's approval is inert.
1366
1402
  replaySatisfiedGates() {
1367
1403
  const satisfied = new Map();
1368
1404
  for (const e of this.read()) {
1369
- if (e.event !== "task-approved" || !e.taskId || e.data.release !== GATE_SATISFIED_RELEASE)
1405
+ if (!e.taskId)
1370
1406
  continue;
1371
- if (typeof e.data.gate === "string" && GATE_NAMES.includes(e.data.gate)) {
1372
- satisfied.set(e.taskId, e.data.gate);
1407
+ if (e.event === "worktree-recreation") {
1408
+ satisfied.delete(e.taskId);
1409
+ continue;
1410
+ }
1411
+ if (e.event === "task-approved") {
1412
+ satisfied.delete(e.taskId);
1413
+ if (e.data.release === GATE_SATISFIED_RELEASE
1414
+ && typeof e.data.gate === "string"
1415
+ && GATE_NAMES.includes(e.data.gate)) {
1416
+ satisfied.set(e.taskId, e.data.gate);
1417
+ }
1373
1418
  }
1374
1419
  }
1375
1420
  return satisfied;
@@ -1,4 +1,5 @@
1
1
  import { type ReactElement } from "react";
2
+ import { type ApprovalDisposition, type ApprovalRunOwner } from "../../cli/commands/approve.js";
2
3
  import { Journal } from "../../run/journal.js";
3
4
  import { type TickmarkrConfig } from "../../config/config.js";
4
5
  import { type ComponentState, type JournalRow } from "./components.js";
@@ -73,15 +74,8 @@ export type ParkedDecision = {
73
74
  };
74
75
  /** The parks a verb can release — the rows the surface draws live verbs on. */
75
76
  export declare function actionableDecisions(decisions: readonly ParkedDecision[]): readonly ParkedDecision[];
76
- /**
77
- * The decisions section's rows: one per task the journal's own status replay
78
- * leaves parked on a human, in the order the journal first named them. The
79
- * replay is the production one (`Journal.replayStatuses`) so this surface and
80
- * `tickmarkr status` can never disagree about what is parked.
81
- */
82
77
  export declare function deriveParkedDecisions(journal: Journal): readonly ParkedDecision[];
83
- /** The two decisions a parked task offers — the production command's two verbs. */
84
- export type SetupDecisionVerb = "approve" | "uphold";
78
+ export type SetupDecisionVerb = "approve" | "waive" | "uphold" | "recheck";
85
79
  /** A decision the operator has named but not yet confirmed. */
86
80
  export type SetupDecisionCommand = {
87
81
  readonly verb: SetupDecisionVerb;
@@ -93,6 +87,7 @@ export type SetupDecisionWrite = {
93
87
  readonly verb: SetupDecisionVerb;
94
88
  /** The release marker read back from the recorded task-approved event. */
95
89
  readonly release: string | undefined;
90
+ readonly disposition: ApprovalDisposition;
96
91
  readonly by: string;
97
92
  };
98
93
  /**
@@ -120,6 +115,7 @@ export type SetupDecisionsKeyResult = {
120
115
  * inert and the keybar does not advertise it — the surface never promises a
121
116
  * decision the command must refuse.
122
117
  */
118
+ export declare function setupDecisionVerbs(decision: ParkedDecision): readonly SetupDecisionVerb[];
123
119
  export declare function upholdAvailable(decision: ParkedDecision): boolean;
124
120
  /**
125
121
  * The decisions surface's whole key grammar: ↑↓ move, a approve, u uphold,
@@ -160,7 +156,7 @@ export declare function recordSetupDecisionOutcome(session: SetupDecisionsSessio
160
156
  * The confirm inset's lines: the task, the actor, the effect and the file,
161
157
  * stated before the single key that can write.
162
158
  */
163
- export declare function setupDecisionConfirmLines(command: SetupDecisionCommand, decision: ParkedDecision | undefined, actor: string, journalFile: string): readonly string[];
159
+ export declare function setupDecisionConfirmLines(command: SetupDecisionCommand, decision: ParkedDecision | undefined, actor: string, journalFile: string, run: ApprovalRunOwner): readonly string[];
164
160
  /**
165
161
  * The decisions surface. Every row it draws — decisions, the confirm inset,
166
162
  * the pending writes region — is measured and clipped through the width
@@ -168,12 +164,14 @@ export declare function setupDecisionConfirmLines(command: SetupDecisionCommand,
168
164
  * neither overflow its row nor be cut mid-cluster. The one unclipped element
169
165
  * is `notice`: the production command's own words, verbatim.
170
166
  */
171
- export declare function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, }: {
167
+ export declare function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, run, }: {
172
168
  decisions: readonly ParkedDecision[];
173
169
  session: SetupDecisionsSession;
174
170
  columns: number;
175
171
  actor: string;
176
172
  journalFile: string;
173
+ /** Who enacts a confirmed write — read from the run lock, never assumed. */
174
+ run: ApprovalRunOwner;
177
175
  }): ReactElement;
178
176
  /**
179
177
  * The setup decisions surface on a live engagement. This is the only cockpit