tickmarkr 2.1.9 → 2.2.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 (40) hide show
  1. package/dist/adapters/model-lints.js +9 -0
  2. package/dist/adapters/pi.d.ts +1 -0
  3. package/dist/adapters/pi.js +15 -1
  4. package/dist/adapters/prompt.js +11 -3
  5. package/dist/adapters/registry.js +13 -4
  6. package/dist/adapters/types.d.ts +3 -0
  7. package/dist/adapters/types.js +1 -0
  8. package/dist/cli/commands/approve.d.ts +20 -0
  9. package/dist/cli/commands/approve.js +127 -35
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +14 -4
  12. package/dist/cli/commands/plan.js +4 -1
  13. package/dist/cli/commands/verify.js +2 -0
  14. package/dist/cli/commands/version.d.ts +2 -1
  15. package/dist/cli/commands/version.js +25 -4
  16. package/dist/compile/collateral.js +149 -4
  17. package/dist/compile/native.js +2 -0
  18. package/dist/drivers/index.d.ts +1 -0
  19. package/dist/drivers/index.js +25 -10
  20. package/dist/drivers/orca.d.ts +19 -0
  21. package/dist/drivers/orca.js +45 -9
  22. package/dist/gates/baseline.d.ts +2 -0
  23. package/dist/gates/baseline.js +49 -14
  24. package/dist/gates/llm.d.ts +1 -1
  25. package/dist/gates/llm.js +41 -26
  26. package/dist/gates/review.js +2 -0
  27. package/dist/gates/run-gates.d.ts +6 -0
  28. package/dist/gates/run-gates.js +9 -1
  29. package/dist/run/consult.d.ts +7 -0
  30. package/dist/run/consult.js +16 -2
  31. package/dist/run/daemon.d.ts +12 -6
  32. package/dist/run/daemon.js +306 -24
  33. package/dist/run/journal.d.ts +16 -0
  34. package/dist/run/journal.js +51 -6
  35. package/dist/tui/cockpit/setup-cockpit.d.ts +8 -10
  36. package/dist/tui/cockpit/setup-cockpit.js +92 -53
  37. package/package.json +1 -1
  38. package/skills/tickmarkr-loop/SKILL.md +1 -0
  39. package/skills/tickmarkr-overseer/SKILL.md +17 -0
  40. package/skills/tickmarkr-overseer/scripts/watch-context.sh +2 -2
@@ -583,6 +583,14 @@ export async function runGates(task, ctx) {
583
583
  // eligible seat keeps the ORIGINAL result so the recorded cause stays truthful (OBS-196).
584
584
  if (rv.meta?.unparseable === true && typeof rv.meta.reviewer === "string") {
585
585
  const flaked = rv.meta.reviewer;
586
+ const emptyOutput = rv.meta.cause === "empty-output";
587
+ if (emptyOutput) {
588
+ await ctx.onGate?.({
589
+ phase: "note", gate: "review", name: "reviewer-empty-output",
590
+ payload: { reviewer: flaked, bytes: typeof rv.meta.bytes === "number" ? rv.meta.bytes : 0 },
591
+ result: { ...rv, meta: { ...rv.meta, skipped: true } },
592
+ });
593
+ }
586
594
  const retryVia = ctx.via
587
595
  ? { ...ctx.via, nameFor: (role, adapter) => ctx.via.nameFor(role, adapter) + "-r1" }
588
596
  : undefined;
@@ -593,7 +601,7 @@ export async function runGates(task, ctx) {
593
601
  ...second,
594
602
  // `details` is lifted onto the journal's gate-result row; meta.reviewRetry is not. Keep the
595
603
  // re-route visible in the result text a reader actually opens, including on a red retry.
596
- details: `review re-route: ${flaked} produced no parseable verdict; replaced by ${retried}\n${second.details}`,
604
+ details: `review re-route: ${flaked} produced ${emptyOutput ? "EMPTY output" : "no parseable verdict"}; replaced by ${retried}\n${second.details}`,
597
605
  meta: { ...second.meta, reviewRetry: { flaked, retried } },
598
606
  };
599
607
  }
@@ -9,6 +9,9 @@ export interface ConsultVerdict {
9
9
  reason?: string;
10
10
  guidance?: string;
11
11
  excludeAdapter?: string;
12
+ adapter?: string;
13
+ model?: string;
14
+ vendor?: string;
12
15
  }
13
16
  export declare function renderRetryGuidance(v: ConsultVerdict): string;
14
17
  export declare function augmentRetryBrief(feedback: string, opts: {
@@ -36,5 +39,9 @@ export declare function consult(d: Dossier, cfg: TickmarkrConfig, adapters: Work
36
39
  runId?: string;
37
40
  channels?: Array<{
38
41
  adapter: string;
42
+ model?: string;
43
+ vendor?: string;
44
+ channel?: "sub" | "api";
45
+ tier?: string;
39
46
  }>;
40
47
  }): Promise<ConsultVerdict>;
@@ -205,6 +205,17 @@ opts = {}) {
205
205
  // the same rule as every prefer entry — and disallowedBy carries the full deny grammar (adapter,
206
206
  // model, or adapter:model), so a model-scoped deny cannot slip past an adapter-id-only read.
207
207
  const allowedSeats = seats.filter((s) => disallowedBy(s, cfg.routing, "consult") === null);
208
+ const seatIdentity = (seat) => {
209
+ let vendor = opts.channels?.find((candidate) => candidate.adapter === seat.adapter && candidate.model === seat.model)?.vendor;
210
+ if (!vendor) {
211
+ try {
212
+ const adapter = getAdapter(seat.adapter, adapters);
213
+ vendor = adapter.channels(cfg).find((channel) => channel.model === seat.model)?.vendor ?? adapter.vendor;
214
+ }
215
+ catch { /* an unknown adapter still gets explicit unknown provenance */ }
216
+ }
217
+ return { adapter: seat.adapter, model: seat.model, vendor: vendor ?? "unknown" };
218
+ };
208
219
  if (!allowedSeats.length) {
209
220
  const d = disallowedBy(seats[seats.length - 1], cfg.routing, "consult");
210
221
  return {
@@ -216,11 +227,14 @@ opts = {}) {
216
227
  try {
217
228
  const parsed = await invokeSeat(seat.adapter, seat.model, i);
218
229
  if (parsed.verdict)
219
- return parsed.verdict;
230
+ return { ...parsed.verdict, ...seatIdentity(seat) };
220
231
  }
221
232
  catch {
222
233
  // failed seat (unknown adapter, dead driver/pane, shell error) — fall to the next entry
223
234
  }
224
235
  }
225
- return { action: "human", notes: "consult verdict unparseable — failing safe to human" };
236
+ return {
237
+ action: "human",
238
+ notes: "consult verdict unparseable — failing safe to human",
239
+ };
226
240
  }
@@ -1,5 +1,6 @@
1
1
  import { type WorkerAdapter } from "../adapters/types.js";
2
2
  import { type ModeResolution, type RoutingMode, type TickmarkrConfig } from "../config/config.js";
3
+ import { type DriverChoice } from "../drivers/index.js";
3
4
  import { type ExecutorDriver } from "../drivers/types.js";
4
5
  import { type Baseline } from "../gates/baseline.js";
5
6
  import type { GateResult } from "../gates/types.js";
@@ -13,6 +14,7 @@ export interface RunOptions {
13
14
  retryFailed?: boolean;
14
15
  concurrency?: number;
15
16
  driver?: ExecutorDriver;
17
+ driverOverride?: DriverChoice;
16
18
  adapters?: WorkerAdapter[];
17
19
  globalDir?: string;
18
20
  mode?: RoutingMode;
@@ -49,12 +51,16 @@ export interface RunSummary {
49
51
  outstandingApprovals?: string[];
50
52
  }
51
53
  /**
52
- * T14: approvals the run accepted and never acted on. `approved` above is built ONCE at startup —
53
- * deliberately, replay determinism depends on it — so an approval written while the daemon is live is
54
- * inert for that run. Without this the run-end record stated only buckets and tipVerify, both
55
- * accurate, over a milestone that was silently incomplete: run …230 ended tipVerify "passed" with two
56
- * upheld approvals and zero subsequent dispatches. Scored per task on its NEWEST approval: a later
57
- * approval is the live decision, and the events that answer it are the ones after it.
54
+ * T14, amended by v2.2 T3: approvals the run accepted and never acted on. `approved` above is still
55
+ * built ONCE at startup — replay determinism depends on it — but a live approval is no longer inert:
56
+ * the boundary sweep in the task loop releases what lands while the daemon runs, so an approval
57
+ * written mid-run is normally enacted by this run. ONE window survives, and it is the reason this
58
+ * fold still exists: an approval accepted after the task loop exits — during tip verify, before the
59
+ * run-end sample below — meets no further boundary, so nothing can release it before this run ends.
60
+ * Without this the run-end record stated only buckets and tipVerify, both accurate, over a milestone
61
+ * that was silently incomplete: run …230 ended tipVerify "passed" with two upheld approvals and zero
62
+ * subsequent dispatches. Scored per task on its NEWEST approval: a later approval is the live
63
+ * decision, and the events that answer it are the ones after it.
58
64
  */
59
65
  export declare function outstandingApprovals(events: JournalEvent[]): string[];
60
66
  export declare function formatSummary(s: RunSummary): string;
@@ -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";
@@ -11,17 +12,20 @@ import { bannerShell, paneDispatchCommand } from "../brand.js";
11
12
  import { collateralHits } from "../compile/collateral.js";
12
13
  import { DEFAULT_DIFF_CAP, globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
13
14
  import { DeliveryReadinessError } from "../drivers/herdr.js";
15
+ import { driverEvidence } from "../drivers/index.js";
14
16
  import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js";
15
17
  import { formatOwnedName } from "../drivers/types.js";
16
18
  import { captureBaseline, detectGateCommands, detectVacuousOracles } from "../gates/baseline.js";
17
19
  import { runGates } from "../gates/run-gates.js";
20
+ import { filesGlob } from "../graph/files-glob.js";
18
21
  import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
19
22
  import { GATE_NAMES } from "../graph/schema.js";
23
+ import { distFingerprint } from "../cli/commands/version.js";
20
24
  import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
21
25
  import { runEnvironment } from "./environment.js";
22
26
  import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
23
27
  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";
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";
25
29
  import { isDiffCapPark } from "../gates/review.js";
26
30
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
27
31
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
@@ -76,12 +80,16 @@ const DISPATCH_ENACTMENT = new Set(["task-dispatch", "repair-dispatch"]);
76
80
  // for the task is worktree-recreation. That event is causal for this path, not incidental.
77
81
  const GATE_SATISFIED_ENACTMENT = "worktree-recreation";
78
82
  /**
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.
83
+ * T14, amended by v2.2 T3: approvals the run accepted and never acted on. `approved` above is still
84
+ * built ONCE at startup — replay determinism depends on it — but a live approval is no longer inert:
85
+ * the boundary sweep in the task loop releases what lands while the daemon runs, so an approval
86
+ * written mid-run is normally enacted by this run. ONE window survives, and it is the reason this
87
+ * fold still exists: an approval accepted after the task loop exits — during tip verify, before the
88
+ * run-end sample below — meets no further boundary, so nothing can release it before this run ends.
89
+ * Without this the run-end record stated only buckets and tipVerify, both accurate, over a milestone
90
+ * that was silently incomplete: run …230 ended tipVerify "passed" with two upheld approvals and zero
91
+ * subsequent dispatches. Scored per task on its NEWEST approval: a later approval is the live
92
+ * decision, and the events that answer it are the ones after it.
85
93
  */
86
94
  export function outstandingApprovals(events) {
87
95
  const newest = new Map();
@@ -687,6 +695,202 @@ function compareWorktrees(before, after) {
687
695
  return "unreadable";
688
696
  return before.signature === after.signature ? "unchanged" : "changed";
689
697
  }
698
+ const CONTEXT_GLOB_CHARS = /[*?{[]/;
699
+ const MATERIALIZED_CONTEXT_BYTES = 256 * 1024 * 1024;
700
+ function containedIn(root, path) {
701
+ const fromRoot = relative(resolve(root), resolve(path));
702
+ return fromRoot === "" || (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot));
703
+ }
704
+ function normalizeContextPath(entry) {
705
+ if (entry.includes("\0"))
706
+ return undefined;
707
+ const repositoryPath = entry.replace(/\\/g, "/");
708
+ if (posix.isAbsolute(repositoryPath) || repositoryPath.split("/").includes(".."))
709
+ return undefined;
710
+ const normalized = posix.normalize(repositoryPath).replace(/\/+$/, "");
711
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../"))
712
+ return undefined;
713
+ return normalized;
714
+ }
715
+ function workerFilesystemEntries(worktree) {
716
+ const entries = [];
717
+ const visit = (dir, prefix) => {
718
+ let dirents;
719
+ try {
720
+ dirents = readdirSync(dir, { withFileTypes: true });
721
+ }
722
+ catch (error) {
723
+ const rel = relative(worktree, dir).split(sep).join(posix.sep) || ".";
724
+ const message = error instanceof Error ? error.message : String(error);
725
+ throw new Error(`context filesystem observation failed at ${rel}: ${message}`);
726
+ }
727
+ for (const dirent of dirents) {
728
+ if (dirent.name === ".git")
729
+ continue;
730
+ const rel = prefix ? `${prefix}/${dirent.name}` : dirent.name;
731
+ entries.push(rel);
732
+ if (dirent.isDirectory())
733
+ visit(join(dir, dirent.name), rel);
734
+ }
735
+ };
736
+ visit(worktree, "");
737
+ return entries;
738
+ }
739
+ function exactContextResolvesInWorktree(entry, normalized, worktree) {
740
+ const fullPath = join(worktree, normalized);
741
+ try {
742
+ const stat = lstatSync(fullPath);
743
+ if (stat.isSymbolicLink())
744
+ return false;
745
+ if (stat.isDirectory())
746
+ return true;
747
+ return !/\/+$/.test(entry) && stat.isFile() && worktreeHeadHasRegularBlob(worktree, normalized);
748
+ }
749
+ catch (error) {
750
+ if (["EACCES", "ELOOP", "ENOENT", "ENOTDIR", "EPERM"].includes(error.code ?? ""))
751
+ return false;
752
+ throw error;
753
+ }
754
+ }
755
+ function patternContextResolvesInWorktree(normalized, worktree) {
756
+ const match = filesGlob(normalized);
757
+ return workerFilesystemEntries(worktree).some((path) => match(path));
758
+ }
759
+ function regularBlobObjectId(repoRoot, ref, normalized) {
760
+ let tree = "";
761
+ try {
762
+ tree = execFileSync("git", ["-C", repoRoot, "ls-tree", "-z", ref, "--", normalized], {
763
+ encoding: "utf8",
764
+ maxBuffer: 1024 * 1024,
765
+ });
766
+ }
767
+ catch {
768
+ return undefined;
769
+ }
770
+ const row = tree.split("\0")
771
+ .filter(Boolean)
772
+ .map((line) => /^(\d{6}) (\w+) ([0-9a-f]+)\t([\s\S]*)$/.exec(line))
773
+ .find((match) => match?.[4] === normalized);
774
+ if (!row || row[2] !== "blob" || (row[1] !== "100644" && row[1] !== "100755"))
775
+ return undefined;
776
+ return row[3];
777
+ }
778
+ function worktreeHeadHasRegularBlob(worktree, normalized) {
779
+ return regularBlobObjectId(worktree, "HEAD", normalized) !== undefined;
780
+ }
781
+ function committedRegularBlob(repoRoot, normalized) {
782
+ const objectId = regularBlobObjectId(repoRoot, "HEAD", normalized);
783
+ if (!objectId)
784
+ return undefined;
785
+ try {
786
+ const bytes = execFileSync("git", ["-C", repoRoot, "cat-file", "blob", objectId], {
787
+ encoding: "buffer",
788
+ maxBuffer: MATERIALIZED_CONTEXT_BYTES,
789
+ });
790
+ return { bytes };
791
+ }
792
+ catch {
793
+ return undefined;
794
+ }
795
+ }
796
+ function materializedContextDestination(root, task, attempt, normalized) {
797
+ const key = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
798
+ const leaf = basename(normalized).replace(/[^A-Za-z0-9._-]/g, "_") || "context";
799
+ const destination = join(root, task.id, `a${attempt}`, `${key}-${leaf}`);
800
+ if (!containedIn(root, destination))
801
+ throw new Error(`materialized context destination escapes context subtree: ${normalized}`);
802
+ return destination;
803
+ }
804
+ function ensureDirectoryWithoutSymlink(path) {
805
+ let stat;
806
+ try {
807
+ stat = lstatSync(path);
808
+ }
809
+ catch (error) {
810
+ if (error.code !== "ENOENT")
811
+ throw error;
812
+ mkdirSync(path, { mode: 0o700 });
813
+ stat = lstatSync(path);
814
+ }
815
+ if (stat.isSymbolicLink())
816
+ throw new Error(`materialized context destination ancestor is a symlink: ${path}`);
817
+ if (!stat.isDirectory())
818
+ throw new Error(`materialized context destination ancestor is not a directory: ${path}`);
819
+ }
820
+ function ensureContainedContextParent(root, destination) {
821
+ const rootAbs = resolve(root);
822
+ const destAbs = resolve(destination);
823
+ if (!containedIn(rootAbs, destAbs))
824
+ throw new Error("materialized context destination escapes context subtree");
825
+ const parent = dirname(destAbs);
826
+ ensureDirectoryWithoutSymlink(rootAbs);
827
+ const relParent = relative(rootAbs, parent);
828
+ let current = rootAbs;
829
+ for (const segment of relParent.split(sep).filter(Boolean)) {
830
+ current = join(current, segment);
831
+ ensureDirectoryWithoutSymlink(current);
832
+ }
833
+ if (!containedIn(realpathSync(rootAbs), realpathSync(parent))) {
834
+ throw new Error("materialized context destination realpath escapes context subtree");
835
+ }
836
+ }
837
+ function writeContainedContextFile(root, destination, bytes) {
838
+ if (!containedIn(root, destination))
839
+ throw new Error("materialized context destination escapes context subtree");
840
+ ensureContainedContextParent(root, destination);
841
+ const fd = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
842
+ try {
843
+ writeFileSync(fd, bytes);
844
+ }
845
+ finally {
846
+ closeSync(fd);
847
+ }
848
+ }
849
+ async function materializeContextEntry(repoRoot, worktree, contextRoot, task, attempt, entry) {
850
+ const normalized = normalizeContextPath(entry);
851
+ if (!normalized)
852
+ return { kind: "missing", path: entry, reason: "invalid-path" };
853
+ if (exactContextResolvesInWorktree(entry, normalized, worktree))
854
+ return { kind: "keep", entry };
855
+ const blob = committedRegularBlob(repoRoot, normalized);
856
+ if (blob) {
857
+ const destination = materializedContextDestination(contextRoot, task, attempt, normalized);
858
+ writeContainedContextFile(contextRoot, destination, blob.bytes);
859
+ return { kind: "materialized", entry: destination, normalized, destination, bytes: blob.bytes.length };
860
+ }
861
+ if (CONTEXT_GLOB_CHARS.test(entry)) {
862
+ if (patternContextResolvesInWorktree(normalized, worktree))
863
+ return { kind: "keep", entry };
864
+ return { kind: "missing", path: entry, reason: "unresolved-pattern" };
865
+ }
866
+ if (/\/+$/.test(entry))
867
+ return { kind: "missing", path: entry, reason: "unresolved-pattern" };
868
+ return { kind: "missing", path: entry, reason: "no-committed-regular-blob" };
869
+ }
870
+ async function taskWithMaterializedContext(repoRoot, journal, worktree, task, attempt) {
871
+ if (task.context.length === 0)
872
+ return task;
873
+ const contextRoot = resolve(journal.dir, "context");
874
+ const context = [];
875
+ for (const entry of task.context) {
876
+ const prepared = await materializeContextEntry(repoRoot, worktree, contextRoot, task, attempt, entry);
877
+ if (prepared.kind === "missing") {
878
+ journal.append("context-missing", task.id, { path: prepared.path, reason: prepared.reason });
879
+ }
880
+ else {
881
+ context.push(prepared.entry);
882
+ if (prepared.kind === "materialized") {
883
+ journal.append("context-materialized", task.id, {
884
+ path: entry,
885
+ normalized: prepared.normalized,
886
+ destination: prepared.destination,
887
+ bytes: prepared.bytes,
888
+ });
889
+ }
890
+ }
891
+ }
892
+ return { ...task, context };
893
+ }
690
894
  async function cherryPickCommits(wt, commits) {
691
895
  const carried = [];
692
896
  for (const hash of commits) {
@@ -975,10 +1179,11 @@ export async function runDaemon(repoRoot, opts = {}) {
975
1179
  journal.append("lock-reclaimed", undefined, lock.reclaimed); // HARD-02 audit trail
976
1180
  }
977
1181
  // 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));
1182
+ // flag. Startup approvals seed the first scheduling pass; live approvals are folded at task
1183
+ // boundaries below so a sibling can release parked work without waiting for run-end + resume.
1184
+ const approvalStartupEvents = journal.read();
1185
+ const approved = new Set(approvalStartupEvents.filter((e) => e.event === "task-approved" && e.taskId).map((e) => e.taskId));
1186
+ let approvalSweepCursor = approvalStartupEvents.length;
982
1187
  const commands = detectGateCommands(repoRoot, cfg);
983
1188
  let baseRef;
984
1189
  let baseline;
@@ -986,9 +1191,9 @@ export async function runDaemon(repoRoot, opts = {}) {
986
1191
  // Empty Map on fresh runs — every seed below conditions on resume.get(t.id), never on opts.resume (the
987
1192
  // GATE-08 lesson at the humanGate guard: condition on the data, not the code path). Dead-code
988
1193
  // 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
1194
+ let resume = opts.resume ? journal.replayResumeState() : new Map();
1195
+ let satisfiedGates = opts.resume ? journal.replaySatisfiedGates() : new Map();
1196
+ let replayedGateResults = resumeLifecycleOpen
992
1197
  ? journal.replayCurrentAttemptGateResults()
993
1198
  : new Map();
994
1199
  // T7: the capacity THIS session resolved — read inside the run's fork budget, so it is the number
@@ -1052,11 +1257,25 @@ export async function runDaemon(repoRoot, opts = {}) {
1052
1257
  baseRef = await gitHead(repoRoot);
1053
1258
  baseline = await captureBaseline(repoRoot, commands);
1054
1259
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
1260
+ writeFileSync(join(journal.dir, "graph.json"), readFileSync(join(tickmarkrDir(repoRoot), "graph.json")));
1055
1261
  // v1.70 T2: environment identity beside the graph/branch identity — running tickmarkr version,
1056
1262
  // loaded-config hash, and the probed CLI version of each adapter holding a channel in the run,
1057
1263
  // gathered through the existing probe/config-load paths (no second mechanism).
1058
1264
  const environment = runEnvironment(cfg, channels, health);
1059
- journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source, environment, ...(prior ? { supersedes: prior.runId } : {}) }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2; supersedes: v1.53 T5
1265
+ journal.append("run-start", undefined, {
1266
+ pid: process.pid, baseRef, commands, channels: channels.map(channelKey),
1267
+ channelsByRole: {
1268
+ worker: pools.worker.map(channelKey),
1269
+ judge: pools.judge.map(channelKey),
1270
+ review: pools.review.map(channelKey),
1271
+ consult: pools.consult.map(channelKey),
1272
+ },
1273
+ driver: driver.id,
1274
+ driverEvidence: driverEvidence(cfg, driver, opts.driverOverride),
1275
+ distFingerprint: distFingerprint(),
1276
+ branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source,
1277
+ environment, ...(prior ? { supersedes: prior.runId } : {}),
1278
+ }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2; supersedes: v1.53 T5
1060
1279
  runStarted = true;
1061
1280
  // v1.53 T5: mark the prior run AFTER this run's run-start exists, so the prior journal never
1062
1281
  // names a successor that has no journal. Append-only — the prior journal is never rewritten.
@@ -1093,8 +1312,8 @@ export async function runDaemon(repoRoot, opts = {}) {
1093
1312
  try {
1094
1313
  watchSlot = await driver.narrator?.(repoRoot, watchCommand(runId), runId);
1095
1314
  }
1096
- catch {
1097
- /* cosmetic-only — the run proceeds without a live surface */
1315
+ catch (error) {
1316
+ journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
1098
1317
  }
1099
1318
  const intWt = await ensureIntegration(repoRoot, branch, baseRef);
1100
1319
  // v1.1 visibility: role-named slots; panes persist per keepPanes (attempt = v1 close-after-harvest)
@@ -1169,6 +1388,14 @@ export async function runDaemon(repoRoot, opts = {}) {
1169
1388
  };
1170
1389
  // gateFails/consults are execTask-scoped counters passed in so a park row is a rich verified-failure
1171
1390
  // observation (e.g. ladder-exhausted + gateFails:4); every task-human row has a closed kind, never prose alone.
1391
+ const gateFailApprovalReason = (taskId, identity, includeUphold = false) => {
1392
+ const commands = [
1393
+ `tickmarkr approve ${runId} ${taskId} --waive`,
1394
+ `tickmarkr approve ${runId} ${taskId} --recheck`,
1395
+ ...(includeUphold ? [`tickmarkr approve ${runId} ${taskId} --uphold`] : []),
1396
+ ];
1397
+ return `${identity} — release with ${commands.map((command) => `\`${command}\``).join(" or ")}`;
1398
+ };
1172
1399
  const park = async (t, reason, kind, assignment, attempts, startMs, gateFails = 0, consults = 0, tokens, metered = 0, retryMode = "fresh", details = {}) => {
1173
1400
  graph = setStatus(graph, t.id, "human");
1174
1401
  saveGraph(repoRoot, graph);
@@ -1228,6 +1455,38 @@ export async function runDaemon(repoRoot, opts = {}) {
1228
1455
  }
1229
1456
  return false;
1230
1457
  };
1458
+ const sweepLiveApprovals = () => {
1459
+ const events = journal.read();
1460
+ const approvals = events.slice(approvalSweepCursor)
1461
+ .filter((e) => e.event === "task-approved" && e.taskId);
1462
+ approvalSweepCursor = events.length;
1463
+ if (approvals.length === 0)
1464
+ return;
1465
+ resume = journal.replayResumeState();
1466
+ satisfiedGates = journal.replaySatisfiedGates();
1467
+ replayedGateResults = resumeLifecycleOpen
1468
+ ? journal.replayCurrentAttemptGateResults()
1469
+ : new Map();
1470
+ let changed = false;
1471
+ for (const e of approvals) {
1472
+ const taskId = e.taskId;
1473
+ approved.add(taskId);
1474
+ if (e.data.release !== GATE_SATISFIED_RELEASE)
1475
+ replayedGateResults.delete(taskId);
1476
+ try {
1477
+ const task = getTask(graph, taskId);
1478
+ if (task.status === "human" || task.status === "failed") {
1479
+ graph = setStatus(graph, taskId, "pending");
1480
+ changed = true;
1481
+ }
1482
+ }
1483
+ catch {
1484
+ // Unknown task in a stale journal: approval replay is inert, like the other journal folds.
1485
+ }
1486
+ }
1487
+ if (changed)
1488
+ saveGraph(repoRoot, graph);
1489
+ };
1231
1490
  const execTask = async (t) => {
1232
1491
  const startMs = Date.now();
1233
1492
  const taskTimeoutMinutes = t.timeoutMinutes ?? cfg.taskTimeoutMinutes;
@@ -1543,6 +1802,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1543
1802
  const applyVerdict = async (v, attempts, trigger) => {
1544
1803
  journal.append("consult-verdict", t.id, {
1545
1804
  action: v.action, notes: v.notes,
1805
+ adapter: v.adapter ?? "unknown", model: v.model ?? "unknown", vendor: v.vendor ?? "unknown",
1546
1806
  ...(v.reason ? { reason: v.reason } : {}),
1547
1807
  ...(v.guidance ? { guidance: v.guidance } : {}),
1548
1808
  ...(v.excludeAdapter ? { excludeAdapter: v.excludeAdapter } : {}),
@@ -1766,6 +2026,10 @@ export async function runDaemon(repoRoot, opts = {}) {
1766
2026
  journal.phaseStart(t.id, phaseForGate(e.gate), { gate: e.gate, index: e.index, total: e.total, ...(e.parentAt === undefined ? {} : { parallel: true }) });
1767
2027
  return;
1768
2028
  }
2029
+ if (e.phase === "note") {
2030
+ journal.append(e.name, t.id, e.payload);
2031
+ return;
2032
+ }
1769
2033
  const g = e.result;
1770
2034
  inParallelOrder(g.gate, () => {
1771
2035
  journalGateResult(g);
@@ -1801,7 +2065,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1801
2065
  // feedback from those rows. Only an operator-authorized gate release parks on a new red.
1802
2066
  if (!satisfiedGate)
1803
2067
  break gateLoop;
1804
- await park(t, "post-approval gate failed", "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
2068
+ 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
2069
  return;
1806
2070
  }
1807
2071
  const m = await mergeSerial(taskBranch, t, gated);
@@ -1859,7 +2123,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1859
2123
  // survives the reset and governs only those further rounds. Absence preserves the module default.
1860
2124
  const reviewRoundCap = approvedReviewRoundCeiling(journal.read(), t.id) ?? REVIEW_ROUND_CAP;
1861
2125
  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);
2126
+ 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
2127
  return;
1864
2128
  }
1865
2129
  // OBS-57: a demoted channel must not be re-dispatched on consult retry or provider requeue.
@@ -1895,7 +2159,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1895
2159
  gate: banned, from: channelKey(assignment), to: next ? channelKey(next) : null,
1896
2160
  });
1897
2161
  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);
2162
+ 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
2163
  return;
1900
2164
  }
1901
2165
  assignment = next;
@@ -1964,6 +2228,13 @@ export async function runDaemon(repoRoot, opts = {}) {
1964
2228
  })].join("\n");
1965
2229
  feedback = feedback ? `${feedback}\n\n${brief}` : brief;
1966
2230
  }
2231
+ const carriedConsultGuidance = outstandingConsultGuidance(journaledSoFar, t.id);
2232
+ if (carriedConsultGuidance) {
2233
+ const consultBrief = renderRetryGuidance({ ...carriedConsultGuidance, notes: "" });
2234
+ if (consultBrief && !feedback.includes(consultBrief)) {
2235
+ feedback = feedback ? `${feedback}\n\n${consultBrief}` : consultBrief;
2236
+ }
2237
+ }
1967
2238
  retryMode = repairFindings
1968
2239
  ? "repair"
1969
2240
  : priorSession
@@ -1992,6 +2263,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1992
2263
  journal.append("task-dispatch", t.id, {
1993
2264
  assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
1994
2265
  ...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
2266
+ ...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
1995
2267
  });
1996
2268
  journal.phaseStart(t.id, "worker", { attempt, assignment });
1997
2269
  const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
@@ -2091,7 +2363,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2091
2363
  return;
2092
2364
  }
2093
2365
  }
2094
- const promptFile = writePrompt(journal.dir, t, attempt, feedback, nonce);
2366
+ const promptTask = await taskWithMaterializedContext(repoRoot, journal, wt, t, attempt);
2367
+ const promptFile = writePrompt(journal.dir, promptTask, attempt, feedback, nonce);
2095
2368
  // OBS-56: state the non-interactive, one-pass finish contract and the OBS-54 stall budget in every
2096
2369
  // worker prompt, not only consult retry guidance. Prepended so prompt.ts's completion trailer stays last.
2097
2370
  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
@@ -3200,6 +3473,10 @@ export async function runDaemon(repoRoot, opts = {}) {
3200
3473
  journal.phaseStart(t.id, phaseForGate(e.gate), { gate: e.gate, index: e.index, total: e.total, ...(e.parentAt === undefined ? {} : { parallel: true }) });
3201
3474
  return;
3202
3475
  }
3476
+ if (e.phase === "note") {
3477
+ journal.append(e.name, t.id, e.payload);
3478
+ return;
3479
+ }
3203
3480
  const g = e.result;
3204
3481
  inParallelOrder(g.gate, () => {
3205
3482
  // GATE-09 (ROADMAP SC-4): journal every judge retry as an attributable event — which gate flaked,
@@ -3402,7 +3679,11 @@ export async function runDaemon(repoRoot, opts = {}) {
3402
3679
  continue;
3403
3680
  return;
3404
3681
  }
3405
- journal.append("consult-verdict", t.id, { action: v.action, notes: v.notes, capAdvisory: true });
3682
+ journal.append("consult-verdict", t.id, {
3683
+ action: v.action, notes: v.notes,
3684
+ adapter: v.adapter ?? "unknown", model: v.model ?? "unknown", vendor: v.vendor ?? "unknown",
3685
+ capAdvisory: true,
3686
+ });
3406
3687
  }
3407
3688
  // v1.85 T3: a narrow battery over fully carried commits earns a REPAIR (decided above) — the
3408
3689
  // next dispatch carries the findings verbatim and the diff content instead of re-onboarding a
@@ -3465,6 +3746,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3465
3746
  // must still stop the run before it can dispatch more work or write run-end.
3466
3747
  if (termSignal)
3467
3748
  throw new Error(`terminated by ${termSignal}`);
3749
+ sweepLiveApprovals();
3468
3750
  const ready = readyTasks(graph)
3469
3751
  .filter((t) => !inflight.has(t.id))
3470
3752
  .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 {};