tickmarkr 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/adapters/claude-code.js +19 -6
  2. package/dist/adapters/prompt.js +1 -1
  3. package/dist/adapters/types.d.ts +3 -0
  4. package/dist/cli/commands/init.js +1 -1
  5. package/dist/cli/commands/report.js +11 -1
  6. package/dist/cli/commands/verify.js +2 -0
  7. package/dist/compile/native.js +39 -4
  8. package/dist/drivers/orca.d.ts +1 -1
  9. package/dist/drivers/orca.js +21 -4
  10. package/dist/gates/baseline.d.ts +2 -0
  11. package/dist/gates/baseline.js +9 -1
  12. package/dist/gates/cache.d.ts +8 -0
  13. package/dist/gates/cache.js +26 -13
  14. package/dist/gates/review.d.ts +2 -3
  15. package/dist/gates/review.js +21 -24
  16. package/dist/gates/run-gates.js +11 -1
  17. package/dist/gates/test-manifest.d.ts +5 -0
  18. package/dist/gates/test-manifest.js +87 -7
  19. package/dist/gates/test-reporter.js +4 -0
  20. package/dist/graph/graph.d.ts +1 -1
  21. package/dist/graph/graph.js +6 -1
  22. package/dist/graph/schema.d.ts +2 -0
  23. package/dist/graph/schema.js +2 -0
  24. package/dist/route/preference.d.ts +1 -1
  25. package/dist/route/preference.js +10 -39
  26. package/dist/run/daemon.d.ts +7 -1
  27. package/dist/run/daemon.js +160 -19
  28. package/dist/run/git.d.ts +10 -0
  29. package/dist/run/git.js +49 -1
  30. package/dist/run/journal.d.ts +1 -1
  31. package/dist/run/journal.js +42 -6
  32. package/dist/run/merge.d.ts +1 -1
  33. package/dist/run/merge.js +30 -6
  34. package/dist/tui/cockpit/live-runtime.d.ts +4 -0
  35. package/dist/tui/cockpit/live-runtime.js +37 -3
  36. package/dist/tui/cockpit/live-store.d.ts +2 -0
  37. package/package.json +1 -1
  38. package/schema/rungraph.schema.json +7 -0
  39. package/skills/tickmarkr-overseer/SKILL.md +33 -8
@@ -101,6 +101,14 @@ export const CLAUDE_INPUT_BOX = declareInputBox("claude-code", {
101
101
  export function claudeSlug(real) {
102
102
  return real.replace(/[^A-Za-z0-9]/g, "-");
103
103
  }
104
+ // Shared resolution for context and resume identity: never scan a neighbouring session.
105
+ function sessionTranscriptPath(session) {
106
+ const real = realpathSync(session.cwd);
107
+ const sid = session.id.replace(/\.jsonl$/i, "");
108
+ if (!sid || sid.includes("/") || sid.includes("\\") || sid.includes(".."))
109
+ return null;
110
+ return join(homedir(), ".claude", "projects", claudeSlug(real), `${sid}.jsonl`);
111
+ }
104
112
  // newest-first by mtime, bounded — mtime picks WHICH files to scan, never a record's cursor.
105
113
  // Shared by collectUsage (spend) and readClaudeAliasIdentity (OBS-145): same store, same bounds.
106
114
  function newestSessionFiles(dir) {
@@ -318,19 +326,24 @@ export const claudeCode = {
318
326
  return undefined; // missing dir / any throw ⇒ fail open
319
327
  }
320
328
  },
329
+ readSessionTranscript(session) {
330
+ try {
331
+ const file = sessionTranscriptPath(session);
332
+ return file ? { bytes: readFileSync(file).byteLength } : null;
333
+ }
334
+ catch {
335
+ return null;
336
+ }
337
+ },
321
338
  // v1.23 T1: last-turn context fill from ~/.claude/projects/<slug>/<sessionId>.jsonl ONLY.
322
339
  // tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens of the LAST
323
340
  // assistant usage record (not a sum over turns — ctx-watch.sh class; overseer wake signal).
324
341
  // Disk read only: no claude spawn, no pane, no network. null = unknown.
325
342
  contextUsage(session) {
326
343
  try {
327
- const real = realpathSync(session.cwd);
328
- const slug = claudeSlug(real);
329
- // session id is a filename stem (herdr agent_session.value); refuse path traversal.
330
- const sid = session.id.replace(/\.jsonl$/i, "");
331
- if (!sid || sid.includes("/") || sid.includes("\\") || sid.includes(".."))
344
+ const file = sessionTranscriptPath(session);
345
+ if (!file)
332
346
  return null;
333
- const file = join(homedir(), ".claude", "projects", slug, `${sid}.jsonl`);
334
347
  let text;
335
348
  try {
336
349
  text = readFileSync(file, "utf8").slice(0, MAX_SESSION_BYTES);
@@ -18,7 +18,7 @@ Goal: ${task.goal}
18
18
 
19
19
  ## Acceptance criteria (you will be judged against these, verbatim)
20
20
  ${list(task.acceptance.map(renderAcceptanceItem))}
21
- ${task.files.length ? `\n## File scope — touch ONLY paths matching:\n${list(task.files)}\n` : ""}${task.context.length ? `\n## Context (read these first)\n${list(task.context)}\n` : ""}
21
+ ${task.files.length ? `\n## File scope — touch ONLY paths matching:\n${list(task.files)}\n` : ""}${task.context.length ? `\n## Context (read these first)\n${list(task.context)}\n` : ""}${task.outOfScope?.length ? `\n## Out of scope\nStay within the task’s declared bounds; do not pursue these items:\n${list(task.outOfScope)}\n` : ""}
22
22
  ## Rules
23
23
  - Work only inside the current directory (your isolated worktree). Never push. Never switch branches.
24
24
  - Make small atomic git commits as you go (git add + git commit, conventional messages).
@@ -188,6 +188,9 @@ export interface WorkerAdapter {
188
188
  listModelsFetchedAt?(): string | undefined;
189
189
  collectUsage?(cwd: string, sinceMs: number): TokenUsage | undefined;
190
190
  contextUsage?(session: SessionRef): ContextUsage | null;
191
+ readSessionTranscript?(session: SessionRef): {
192
+ bytes: number;
193
+ } | null;
191
194
  trust?(repoRoot: string): TrustVerdict;
192
195
  trustDialog: TrustDialog;
193
196
  inputBox?: InputBox;
@@ -115,7 +115,7 @@ A run is green only when ALL of these hold: the run-end event exists in the jour
115
115
 
116
116
  ### Verified handoffs
117
117
 
118
- When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the TARGET composer afterward; if the sent text still sits unsubmitted after ~15 s, send-keys enter to that pane once and read again (OBS-975); never report "relayed" without read-back.
118
+ When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the TARGET composer afterward; if the sent text still sits unsubmitted after ~15 s, retry the Enter only when that composer read shows the staged text and no permission prompt or numbered choice holds focus — an Enter sent onto an active prompt approves it or picks a choice instead of submitting the message (OBS-1119). Under that guard, send-keys enter to that pane once and read again (OBS-975); when a prompt or choice holds focus instead, resolve it first and re-read before retrying. Never report "relayed" without read-back.
119
119
 
120
120
  ### Orient before you act — this block may be the ONLY guidance your host loaded
121
121
 
@@ -11,6 +11,7 @@ import { estimateCosts } from "../../report/cost.js";
11
11
  import { assignmentChannel, buildOperatorRecord, formatChannelMoney, formatChannelTokens, formatTokenUsage as fields, totalTokens as total, } from "../../report/operator-record.js";
12
12
  import { cellsOf, cellSummary } from "../../route/profile.js";
13
13
  import { Journal, loadRoutingProfile } from "../../run/journal.js";
14
+ import { formatTipProof, runEndTipProof } from "../../run/daemon.js";
14
15
  import { deriveRunCockpitData } from "../../tui/cockpit/derive.js";
15
16
  const n = (x) => x.toLocaleString("en-US"); // explicit locale — CI/darwin flake guard
16
17
  const EM = "—";
@@ -169,6 +170,15 @@ const VERIFICATION_READING = {
169
170
  failed: "FAILED — the run did not verify its own tip",
170
171
  absent: "absent — no tip verification recorded: neither passed nor failed",
171
172
  };
173
+ function verificationReading(runId, events) {
174
+ const state = verificationOf(runId, events);
175
+ const proof = runEndTipProof(closedCycle(events));
176
+ if ((state === "passed" || state === "cached") && proof.gates?.length) {
177
+ const allCarried = proof.gates.every(({ kind }) => kind === "reused");
178
+ return `${allCarried ? "cached" : "passed"} — ${formatTipProof(proof)}`;
179
+ }
180
+ return VERIFICATION_READING[state];
181
+ }
172
182
  // VIS-07 / REC-01: derived only from the run journal, telemetry, and local configuration.
173
183
  export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
174
184
  const runStart = events.find((e) => e.event === "run-start");
@@ -205,7 +215,7 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
205
215
  `- **done:** ${count("done")}`,
206
216
  `- **failed:** ${count("failed")}`,
207
217
  `- **human:** ${count("human")}`,
208
- `- **verification:** ${VERIFICATION_READING[verificationOf(runId, events)]}`,
218
+ `- **verification:** ${verificationReading(runId, events)}`,
209
219
  "",
210
220
  "## Usage & efficiency",
211
221
  "",
@@ -307,6 +307,8 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
307
307
  try {
308
308
  linkNodeModules(cwd, baseDir, { force: true });
309
309
  baseline = await captureBaseline(baseDir, commands);
310
+ if (baseline.refusal)
311
+ return { out: baseline.refusal, code: 1 };
310
312
  const missing = verdictlessCommands(baseline, commands);
311
313
  if (missing.length) {
312
314
  writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
@@ -69,8 +69,11 @@ const NESTED_RE = /^\s+- (.+)$/;
69
69
  // v1.19: a typed acceptance oracle line — "command: ...", "test: ...", or "judge: ...". Anything
70
70
  // without one of these prefixes is a plain-string judge criterion (compat path, emits a warning).
71
71
  const ORACLE_RE = new RegExp(`^(${ORACLES.join("|")}):\\s*(.*)$`);
72
- const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout", "pins"]);
73
- const listItems = (draft) => draft.list === "acceptance" ? draft.acceptanceRaw : draft.list === "pins" ? draft.pinsRaw : draft.gates;
72
+ const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout", "pins", "outofscope"]);
73
+ const listItems = (draft) => draft.list === "acceptance" ? draft.acceptanceRaw
74
+ : draft.list === "pins" ? draft.pinsRaw
75
+ : draft.list === "outofscope" ? draft.outOfScope
76
+ : draft.gates;
74
77
  // v2.5.8 T7: one declared pin item — "literal: <exact text> | glob: <search glob>" (split at the LAST
75
78
  // " | glob: " so the text may itself hold a bar) or "fixture: <path set>". A declaration missing the
76
79
  // payload its own kind requires refuses the compile naming the task and the item.
@@ -420,7 +423,7 @@ export function compileNative(file, options = {}) {
420
423
  for (const [index, line] of content.split("\n").entries()) {
421
424
  const heading = line.match(HEAD_RE);
422
425
  if (heading) {
423
- drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptanceRaw: [], acceptance: [], gates: [], hasGates: false, pinsRaw: [], list: null, itemOpen: false, continuationField: null });
426
+ drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptanceRaw: [], acceptance: [], gates: [], hasGates: false, pinsRaw: [], outOfScope: [], list: null, itemOpen: false, continuationField: null });
424
427
  continue;
425
428
  }
426
429
  const draft = drafts.at(-1);
@@ -455,7 +458,7 @@ export function compileNative(file, options = {}) {
455
458
  invalid(draft.id, field[1], "is unknown");
456
459
  const value = field[2].trim();
457
460
  draft.itemOpen = false;
458
- if (name === "acceptance" || name === "gates" || name === "pins") {
461
+ if (name === "acceptance" || name === "gates" || name === "pins" || name === "outofscope") {
459
462
  if (value)
460
463
  invalid(draft.id, field[1], "must be a nested list");
461
464
  draft.list = name;
@@ -725,6 +728,9 @@ export function compileNative(file, options = {}) {
725
728
  ...(routingHints ? { routingHints } : {}),
726
729
  ...(draft.hasGates ? { gates: draft.gates } : {}),
727
730
  ...(draft.pinsRaw.length ? { pins: draft.pinsRaw.map((raw, i) => parsePin(draft.id, raw, i, pinPaths)) } : {}),
731
+ // OBS-1126: an absent list emits no key — a sealed graph's bytes must not move for tasks that
732
+ // never declared a bound.
733
+ ...(draft.outOfScope.length ? { outOfScope: draft.outOfScope } : {}),
728
734
  };
729
735
  });
730
736
  const result = validateGraph({
@@ -841,6 +847,23 @@ acceptance is required on every task (a nested list of observable outcomes).
841
847
  it carries no literal text)
842
848
  LAW: a pins declaration is a LIMITED AUTHORING CONTRACT, not an assertion analyzer —
843
849
  tickmarkr holds you to the obligations you DECLARE; it does not discover the pins you forgot.
850
+ THE PIN SWEEP IS BY CALLERS OF EVERY RESHAPED TYPE, NOT BY THE LITERAL (OBS-1127): for each
851
+ type, field or return shape the task changes, enumerate EVERY CALLER and EVERY BRIDGE — the
852
+ command layer that re-serialises it, the live cockpit that renders it, the fixture that
853
+ byte-pins it — and declare each one. A grep for the retired literal finds the sites that
854
+ SPELL the old shape; it misses the sites that CONSUME it under another name. Measured in
855
+ v2.5.9, twice: a deny-scope reshape swept by literal missed the Fleet command bridge that
856
+ re-emitted the old scope set; a pane-status reshape missed the live cockpit caller that
857
+ still read the retired field. Both compiled, both passed the declared pins, both shipped.
858
+ outOfScope: nested list (optional) of bounds the task must NOT cross — carried onto the graph task
859
+ and into its content identity (OBS-1126): a changed list retires prior-run findings.
860
+ It is the DECLARED BOUND the reviewer and the repair worker are HELD TO, stated as EXPLICIT
861
+ EXCLUSIONS ALONGSIDE the authoritative goal and acceptance criteria — it never displaces
862
+ them. The review brief carries the goal (authoritative), the criteria and this list, so a
863
+ finding inside the list is NOT MATERIAL and must not block approval (the reviewer may
864
+ still raise it as minor); the repair worker receives the complete task prompt — goal,
865
+ criteria and this list — not only the finding, so a repair that crosses the list fails
866
+ even when it would silence the finding.
844
867
 
845
868
  HARD BOUNDS — these FAIL the compile, they do not warn:
846
869
  - at most 6 acceptance items per task (no exception path)
@@ -876,6 +899,15 @@ acceptance is required on every task (a nested list of observable outcomes).
876
899
  A criterion naming a CAPABILITY is satisfiable by a stub whose only caller is its own test, and the
877
900
  judge cannot catch it — it reads the DIFF, and a stub's hunks are real. Name the PRODUCTION CALLER
878
901
  that must exercise the capability, and the path it runs on; "X is supported" ships as dead code.
902
+ - A CRITERION RUNS END-TO-END THROUGH ITS PRODUCTION CALLER, STATED ONCE. Pin the observable at the
903
+ outermost production entry point — the CLI bridge, the rendered frame, the journal line — and let the
904
+ layers beneath it be exercised by that one claim. A criterion pinned at a helper is satisfied by the
905
+ helper alone while every layer above it is still wrong, and the fix then CLIMBS: each review round
906
+ moves the pin up one layer and burns a round. Measured in v2.5.9 (OBS-1127): a deny-scope criterion
907
+ first pinned the helper, then the production shape, then the Fleet state, then the CLI bridge — four
908
+ review rounds for one claim, "tkr fleet deny lists every scope the schema declares", that stated once
909
+ at the bridge would have failed at round one. Ask: WHAT DOES THE OPERATOR INVOKE, AND WHAT DO THEY
910
+ SEE? Pin that. A criterion satisfied one layer short of the operator is a criterion about plumbing.
879
911
  - Enumerating one axis exhaustively is what hides the others. A spec that guards PARTIAL coverage
880
912
  site-by-site, member-by-member, can be defeated wholesale by CONDITIONAL coverage, which leaves
881
913
  every enumeration satisfied. After you enumerate, ask what a single flag would do to the whole set.
@@ -922,6 +954,9 @@ acceptance is required on every task (a nested list of observable outcomes).
922
954
  PRE-SCOPE BY TEXT, ENUMERATE BLOCKERS BY EXECUTION:
923
955
  - Before assigning files[], sweep text across the repository tree for names, callers, tests and prose.
924
956
  A text sweep produces a candidate list; only running the change enumerates the real blocker set.
957
+ For every type the task RESHAPES, the sweep is by CALLER, not by literal: list each consumer and each
958
+ bridge of the type (command layer, cockpit, fixtures) and put every one in files[] or in a dep's —
959
+ the two v2.5.9 misses above were consumers that never spelled the retired literal (OBS-1127).
925
960
  Keep the candidates for scope, then execute the production path and full gates before declaring the
926
961
  set closed — this milestone paid a halted run to learn that the two populations are not identical.
927
962
  - SPIKE-THE-CONTRACT-THEN-SCOPE trigger question: COULD A TEST THIS TASK DOES NOT OWN BE ASSERTING THE
@@ -81,7 +81,7 @@ export declare function terminalWorktree(term: Record<string, unknown>): string
81
81
  * it keeps its resolved spelling: deterministic, and still comparable to another spelling of itself.
82
82
  */
83
83
  export declare function canonicalWorktreePath(path: string): string;
84
- /** The proof line a worker terminal prints first: recovery and focus read it back (FX-N01). */
84
+ /** The proof line a worker terminal prints first: create, recovery and focus read it back. */
85
85
  export declare const CHECKOUT_MARK = "TICKMARKR_CHECKOUT";
86
86
  /** The exact bytes the create command prints as its first line. */
87
87
  export declare function checkoutProofLine(checkout: string): string;
@@ -2,7 +2,7 @@ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, readlinkSyn
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { shq } from "../adapters/types.js";
5
- import { createWorktree, sh } from "../run/git.js";
5
+ import { createWorktree, FORK_CAP_ENV, resolvedForkCap, sh } from "../run/git.js";
6
6
  import { Journal, parseRunId } from "../run/journal.js";
7
7
  import { stateDirName } from "../graph/graph.js";
8
8
  import { readWatchBoard, requestWatchBoardStop, stopWatchBoard, WATCH_OWNER_ENV } from "../run/supervision.js";
@@ -201,7 +201,7 @@ function enclosesCheckout(reported, checkout) {
201
201
  const tracked = canonicalWorktreePath(reported);
202
202
  return tracked === checkout || checkout.startsWith(`${tracked}/`);
203
203
  }
204
- /** The proof line a worker terminal prints first: recovery and focus read it back (FX-N01). */
204
+ /** The proof line a worker terminal prints first: create, recovery and focus read it back. */
205
205
  export const CHECKOUT_MARK = "TICKMARKR_CHECKOUT";
206
206
  // FX-N05: the proof is a FRAMED value — `TICKMARKR_CHECKOUT <byteLength>:<utf8 bytes as hex>;` — so it
207
207
  // carries no whitespace or quotes, survives renderer wrapping (hex rows re-join losslessly), and is
@@ -209,6 +209,7 @@ export const CHECKOUT_MARK = "TICKMARKR_CHECKOUT";
209
209
  // never decode to this one; `A` versus `A B`, `…--T1` versus `…--T10` are different frames.
210
210
  const CHECKOUT_FRAME_RE = /TICKMARKR_CHECKOUT (\d+):([0-9a-f]*)(;?)/g;
211
211
  const PROOF_PAGES = 16; // pages read from the oldest cursor before the proof is declared absent
212
+ const CHECKOUT_PROOF_TIMEOUT_MS = 2_000; // allow the wrapper shell to emit its startup proof
212
213
  /** The exact bytes the create command prints as its first line. */
213
214
  export function checkoutProofLine(checkout) {
214
215
  const bytes = Buffer.from(checkout, "utf8");
@@ -764,9 +765,10 @@ export class OrcaDriver {
764
765
  // The selector names the TRACKED worktree enclosing this slot's checkout outright — never the
765
766
  // UI's active worktree (`active`/`current`) nor the daemon's cwd — and the command itself moves
766
767
  // into the checkout, because Orca cannot be asked to place a terminal in a path it does not
767
- // track (OBS-1004). `--command` runs inside Orca's wrapper shell, so a `cd` prefix is honoured.
768
+ // track (OBS-1004). Shell startup can swallow that prefix; prove it from scrollback below.
769
+ const payload = `export ${FORK_CAP_ENV}=${shq(process.env[FORK_CAP_ENV] ?? resolvedForkCap())}; ${cmd}`;
768
770
  const env = await this.call("create", [
769
- "terminal", "create", "--worktree", `path:${tracked}`, "--title", st.title, "--command", inCheckout(st.cwd, cmd),
771
+ "terminal", "create", "--worktree", `path:${tracked}`, "--title", st.title, "--command", inCheckout(st.cwd, payload),
770
772
  ], this.cliCwd(st));
771
773
  const term = requireTerminal("create", env);
772
774
  const handle = str(term.handle);
@@ -788,6 +790,21 @@ export class OrcaDriver {
788
790
  catch { /* already gone, or no longer provably ours — never a blind retry */ }
789
791
  throw this.latched("create", st, `create receipt bound to ${worktree ?? "no worktree"}, not the tracked ${tracked} enclosing ${st.cwd}`, env.raw);
790
792
  }
793
+ // A receipt only proves placement on the enclosing tracked checkout. Wait briefly for the
794
+ // startup command's proof before run() can resolve and the daemon can count a worker launch.
795
+ const deadline = this.time.now() + CHECKOUT_PROOF_TIMEOUT_MS;
796
+ let proof = await this.checkoutProven(handle, st.cwd, this.cliCwd(st), env.runtimeId);
797
+ while (!proof.proven && this.time.now() < deadline) {
798
+ await this.time.sleep(POLL_MS);
799
+ proof = await this.checkoutProven(handle, st.cwd, this.cliCwd(st), env.runtimeId);
800
+ }
801
+ if (!proof.proven) {
802
+ try {
803
+ await this.closeTerminal({ ...st, handle, runtimeId: env.runtimeId });
804
+ }
805
+ catch { /* best effort under the answering runtime; the latch prevents another launch */ }
806
+ throw this.latched("create", st, `terminal ${handle} does not prove checkout ${st.cwd} (${proof.reason})`, env.raw);
807
+ }
791
808
  st.handle = handle;
792
809
  // The handle is bound to the runtime identity that ANSWERED its create.
793
810
  st.runtimeId = env.runtimeId;
@@ -128,6 +128,8 @@ export interface BaselineFileDuration {
128
128
  durationMs: number;
129
129
  }
130
130
  export interface Baseline {
131
+ /** No command ran because the dependency inventory could not establish isolation. */
132
+ refusal?: string;
131
133
  /** Observational capture receipts stay outside the forgiveness-bearing command entries. */
132
134
  evidenceReceipts?: Record<string, GateEvidenceReceipt>;
133
135
  commands: Record<string, BaselineCommand>;
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { existsSync, readFileSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync, rmdirSync } from "node:fs";
4
4
  import { availableParallelism, loadavg, tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
6
+ import { dependencyLinkRefusal, DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
7
7
  import { executionSignal } from "../run/execution-budget.js";
8
8
  import { isVitestTestCommand, manifestFileCount } from "./test-manifest.js";
9
9
  export const EVIDENCE_TAIL_BYTES = 16 * 1024;
@@ -620,6 +620,14 @@ export function staleFileCountCommands(baseline, commands, cwd) {
620
620
  }
621
621
  export async function captureBaseline(cwd, commands, opts = {}) {
622
622
  const base = { commands: {} };
623
+ const refusal = dependencyLinkRefusal(cwd);
624
+ if (refusal) {
625
+ base.refusal = refusal;
626
+ for (const name of Object.keys(commands)) {
627
+ base.commands[name] = { infra: true, invalidCause: "infra", fingerprints: [], invalidatingLines: [refusal] };
628
+ }
629
+ return base;
630
+ }
623
631
  for (const [name, cmd] of Object.entries(commands)) {
624
632
  if (name === "tipTest" && commands.test !== undefined && cmd === commands.test) {
625
633
  continue;
@@ -59,7 +59,15 @@ export declare function computeVerificationIdentity(params: {
59
59
  }): Promise<VerificationIdentity | undefined>;
60
60
  export declare function verificationIdentityKey(id: VerificationIdentity): string;
61
61
  export declare function formatReusedDetails(originalDetails: string, id: VerificationIdentity): string;
62
+ declare module "./types.js" {
63
+ interface GateResult {
64
+ originRunRoot?: string;
65
+ }
66
+ }
62
67
  export interface CachedVerdict {
68
+ evidenceReceipt?: GateResult["evidenceReceipt"];
69
+ evidenceReceipts?: GateResult["evidenceReceipts"];
70
+ originRunRoot?: string;
63
71
  gate: string;
64
72
  pass: boolean;
65
73
  details: string;
@@ -1,9 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
- import { describeCapacity, resolvedCapacity, shGit, verificationProtocol } from "../run/git.js";
6
+ import { describeCapacity, inventoryDependencyLinks, resolvedCapacity, shGit, verificationProtocol } from "../run/git.js";
7
7
  import { shq } from "../adapters/types.js";
8
8
  export const DEFAULT_VERDICT_CACHE_BOUND = 128;
9
9
  let testCacheBound;
@@ -84,6 +84,13 @@ export function environmentFingerprint(env) {
84
84
  const capacity = { forkCap: cap.forkCap, cores: cap.cores };
85
85
  const selectedSet = env.selectedSet ? [...env.selectedSet].sort() : undefined;
86
86
  const verification = env.verification ?? verificationProtocol(process.env, env.worktree ?? process.cwd());
87
+ // Admitted links still affect resolution. Normalize against the classified root so relocating
88
+ // an otherwise identical checkout (including its dependency store) preserves the identity.
89
+ const resolution = env.worktree ? inventoryDependencyLinks(env.worktree).map(({ link, target, classification }) => ({
90
+ link,
91
+ classification,
92
+ target: classification === "outside" ? target : relative(realpathSync(classification === "worktree" ? env.worktree : join(env.worktree, "node_modules")), target),
93
+ })).sort((a, b) => a.link < b.link ? -1 : a.link > b.link ? 1 : 0) : [];
87
94
  // R41: the protocol and the EFFECTIVE lifecycle are IN the hashed payload, so every entry written
88
95
  // before this stamp — green or red — keys differently and is never answered; no store surgery is
89
96
  // needed. `source` is provenance (kept in parts, printed on the row) and never enters the key: an
@@ -92,6 +99,7 @@ export function environmentFingerprint(env) {
92
99
  nodeRuntime,
93
100
  lockfile,
94
101
  capacity,
102
+ resolution,
95
103
  selectedSet: selectedSet ?? null,
96
104
  scope: env.scope ?? "battery",
97
105
  verification: { protocol: verification.protocol, lifecycle: verification.lifecycle },
@@ -155,6 +163,9 @@ export function formatReusedRow(cached, id) {
155
163
  pass: cached.pass,
156
164
  details: cached.pass ? reusedDetails : cached.details,
157
165
  capacity: cached.capacity,
166
+ ...(cached.evidenceReceipt ? { evidenceReceipt: cached.evidenceReceipt } : {}),
167
+ ...(cached.evidenceReceipts ? { evidenceReceipts: cached.evidenceReceipts } : {}),
168
+ ...(cached.originRunRoot ? { originRunRoot: cached.originRunRoot } : {}),
158
169
  meta: {
159
170
  ...cached.meta,
160
171
  reused: true,
@@ -306,6 +317,10 @@ export class VerdictStore {
306
317
  ...("exitCode" in verdict ? { exitCode: verdict.exitCode } : {}),
307
318
  ...(verdict.capacity ? { capacity: verdict.capacity } : {}),
308
319
  ...(verdict.meta ? { meta: verdict.meta } : {}),
320
+ ...(verdict.evidenceReceipt ? { evidenceReceipt: verdict.evidenceReceipt } : {}),
321
+ ...(verdict.evidenceReceipts ? { evidenceReceipts: verdict.evidenceReceipts } : {}),
322
+ ...(verdict.originRunRoot ? { originRunRoot: verdict.originRunRoot }
323
+ : typeof verdict.meta?.runDir === "string" ? { originRunRoot: resolve(verdict.meta.runDir) } : {}),
309
324
  },
310
325
  timestamp: Date.now(),
311
326
  sequence: ++globalStoreSequence,
@@ -314,17 +329,15 @@ export class VerdictStore {
314
329
  const tmpPath = join(this.dir, `verdict-${key}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
315
330
  writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
316
331
  try {
317
- unlinkSync(finalPath);
332
+ renameSync(tmpPath, finalPath);
318
333
  }
319
- catch {
320
- // ignore
321
- }
322
- writeFileSync(finalPath, readFileSync(tmpPath));
323
- try {
324
- unlinkSync(tmpPath);
325
- }
326
- catch {
327
- // ignore
334
+ finally {
335
+ try {
336
+ unlinkSync(tmpPath);
337
+ }
338
+ catch {
339
+ // Already renamed, or best-effort cleanup after a failed replacement.
340
+ }
328
341
  }
329
342
  this.evictOldest(bound);
330
343
  return true;
@@ -110,7 +110,7 @@ export declare function pickReviewer(author: Assignment, channels: BillingChanne
110
110
  prefer?: string[], // v1.53 T2: review.prefer — reorders eligible channels, never changes eligibility
111
111
  floor?: Tier, // task/config/prior floor from the caller; the author's own tier is ALWAYS applied here (RF-1)
112
112
  history?: string[], // run-scoped picks, oldest to newest; empty preserves the established ranking
113
- onSeat?: (seat: number, count: number) => void, demoted?: ReadonlySet<string>, excludeVendors?: ReadonlySet<string>): BillingChannel | null;
113
+ onSeat?: (seat: number, count: number) => void, demoted?: ReadonlySet<string>, excludeVendors?: ReadonlySet<string>, authors?: readonly string[]): BillingChannel | null;
114
114
  export type ReviewUnparseableCause = VerdictUnparseableCause | "launch-never-started" | "truncated" | "silent" | "closure-mismatch";
115
115
  /**
116
116
  * This shows the reviewer what the task DECLARED, never what the diff may actually reach. The diff
@@ -120,8 +120,7 @@ export type ReviewUnparseableCause = VerdictUnparseableCause | "launch-never-sta
120
120
  export declare function renderDeclaredWriteScope(files: ReadonlyArray<string>): string;
121
121
  /**
122
122
  * OBS-1033: the vendors of the seats that authored commits inside the accumulated diff. A seat is
123
- * never handed its own earlier work to approve. A prior author not resolvable in the pool excludes
124
- * its adapter's vendors instead (fail closed: the seat is known, its vendor is whatever it bills as).
123
+ * never handed its own earlier work to approve. Unresolved channels are refused by reviewGate.
125
124
  */
126
125
  export declare function carriedAuthorVendors(channels: BillingChannel[], carriedAuthors?: readonly string[]): Set<string>;
127
126
  /**
@@ -283,15 +283,11 @@ floor, // task/config/prior floor from the caller; the author's own tier is ALWA
283
283
  history = [], // run-scoped picks, oldest to newest; empty preserves the established ranking
284
284
  onSeat, demoted = new Set(),
285
285
  // OBS-1033: vendors that authored a carried commit inside the accumulated diff — excluded for the round.
286
- excludeVendors = new Set()) {
287
- // FLEET-05 success criterion 2: an author not resolvable in the channel list yields NO reviewer.
288
- // The old `?? author.adapter` fallback compared an adapter id to vendor names, matched nothing, and
289
- // admitted every reviewer — including the author's own channel (fail-OPEN). null lands on reviewGate's
290
- // fail-closed branch under review.required.
291
- const authorChannel = channels.find((c) => c.adapter === author.adapter && c.model === author.model);
292
- if (!authorChannel)
286
+ excludeVendors = new Set(), authors = [channelKey(author)]) {
287
+ // Resolve every actual author, never guess a vendor from an adapter id.
288
+ const authorChannels = authors.map((key) => channels.find((c) => channelKey(c) === key));
289
+ if (authorChannels.some((c) => !c))
293
290
  return null;
294
- const authorProvider = modelProvider(author.model, authorChannel.vendor);
295
291
  // RF-1: every caller inherits the author-tier floor — a reviewer is never seated below its author.
296
292
  const effectiveFloor = resolveReviewerFloor(author.tier, floor).floor;
297
293
  const ranked = channels
@@ -299,9 +295,9 @@ excludeVendors = new Set()) {
299
295
  // as well as failover, so an aggregator channel stamped "mixed" never seats the author's own provider),
300
296
  // and different base-model identity (ADDED TO the vendor rule, never replacing it). The diversity
301
297
  // filter runs BEFORE preference ranking, so prefer cannot resurrect an excluded channel.
302
- .filter((c) => c.vendor !== authorChannel.vendor
303
- && modelProvider(c.model, c.vendor) !== authorProvider
304
- && modelId(c.model) !== modelId(author.model)
298
+ .filter((c) => authorChannels.every((a) => a && c.vendor !== a.vendor
299
+ && modelProvider(c.model, c.vendor) !== modelProvider(a.model, a.vendor)
300
+ && modelId(c.model) !== modelId(a.model))
305
301
  && !exclude.includes(channelKey(c))
306
302
  && !excludeVendors.has(c.vendor)
307
303
  && TIER_RANK[c.tier] >= TIER_RANK[effectiveFloor])
@@ -328,15 +324,12 @@ ${files.map((path) => `- ${path}`).join("\n")}`;
328
324
  }
329
325
  /**
330
326
  * OBS-1033: the vendors of the seats that authored commits inside the accumulated diff. A seat is
331
- * never handed its own earlier work to approve. A prior author not resolvable in the pool excludes
332
- * its adapter's vendors instead (fail closed: the seat is known, its vendor is whatever it bills as).
327
+ * never handed its own earlier work to approve. Unresolved channels are refused by reviewGate.
333
328
  */
334
329
  export function carriedAuthorVendors(channels, carriedAuthors = []) {
335
330
  const vendors = new Set();
336
331
  for (const key of carriedAuthors) {
337
- const adapter = key.split(":")[0];
338
- const exact = channels.filter((c) => channelKey(c) === key);
339
- for (const c of exact.length ? exact : channels.filter((c) => c.adapter === adapter))
332
+ for (const c of channels.filter((c) => channelKey(c) === key))
340
333
  vendors.add(c.vendor);
341
334
  }
342
335
  return vendors;
@@ -391,7 +384,7 @@ artifactDir, reviewHistory, demotedReviewers, carriedFindings = [],
391
384
  // RF-1: channel keys of THIS task's prior reviewers (earlier rounds, a flaked seat) — task-scoped,
392
385
  // never the run-wide rotation history nor excludeReviewers; the seat holds the highest of their tiers.
393
386
  priorReviewers = [],
394
- // OBS-1033: channel keys of the seats that authored the carried commits (the daemon's tried list).
387
+ // Closed list of actual subject authors; empty preserves legacy callers' single-author contract.
395
388
  carriedAuthors = [], operatorContext) {
396
389
  // R3 (OBS-186): participation is keyed on PATHS. The compiler's assignment comes from the DECLARED
397
390
  // files[]; the operator's floor may RAISE it to full and can never lower it. `complexityThreshold` is
@@ -466,15 +459,15 @@ carriedAuthors = [], operatorContext) {
466
459
  // review.floor is read from config — cfg.routing.floors governs workers and never moves review seats.
467
460
  const { floor: reviewerFloor, cause: reviewerFloorCause } = gateReviewerFloor(task, cfg, author, channels, priorReviewers);
468
461
  const floorMeta = { reviewerFloor, reviewerFloorCause };
462
+ const authors = carriedAuthors.length ? carriedAuthors : [channelKey(author)];
463
+ const authorVendors = carriedAuthorVendors(channels, authors);
464
+ const unresolvedAuthors = authors.filter((key) => !channels.some((c) => channelKey(c) === key));
469
465
  let rotationSeat;
470
- const reviewer = pickReviewer(author, channels, excludeReviewers ?? [], cfg.review.prefer ?? [], reviewerFloor, reviewHistory, reviewHistory ? (seat) => { rotationSeat = seat; } : undefined, demotedReviewers, carriedAuthorVendors(channels, carriedAuthors));
466
+ const reviewer = pickReviewer(author, channels, excludeReviewers ?? [], cfg.review.prefer ?? [], reviewerFloor, reviewHistory, reviewHistory ? (seat) => { rotationSeat = seat; } : undefined, demotedReviewers, authorVendors, authors);
471
467
  if (!reviewer) {
472
- // meta.noEligibleReviewer lets run-gates' review-retry keep the ORIGINAL unparseable result when
473
- // the retry finds no second seat — a truthful cause beats a synthetic no-reviewer failure.
474
- const reason = `no cross-vendor reviewer available at or above ${reviewerFloor} floor (${reviewerFloorCause}; diversity rule)`;
475
- return cfg.review.required || priorMaterials.length > 0
476
- ? { gate: "review", pass: false, details: `unreadable — ${reason}; ${priorMaterials.length ? "carried materials require a review verdict" : "set review.required:false to waive"}`, meta: { noEligibleReviewer: true, unreadable: true, ...floorMeta } }
477
- : { gate: "review", pass: true, details: `WARNING: ${reason} — review waived by config`, meta: { noEligibleReviewer: true, ...floorMeta } };
468
+ const reason = `no cross-vendor reviewer available at or above ${reviewerFloor} floor (${reviewerFloorCause}; diversity rule); author vendors: ${[...authorVendors].sort().join(", ") || "unknown"}${unresolvedAuthors.length ? `; unresolved author channels: ${unresolvedAuthors.join(", ")}` : ""}`;
469
+ return { gate: "review", pass: false, details: `unreadable — ${reason}; operator approve --waive required to proceed`,
470
+ meta: { noEligibleReviewer: true, unreadable: true, findings: [], authorVendors: [...authorVendors].sort(), unresolvedAuthors, ...floorMeta } };
478
471
  }
479
472
  reviewHistory?.push(channelKey(reviewer));
480
473
  const rotationMeta = rotationSeat === undefined ? {} : { rotationSeat };
@@ -515,6 +508,10 @@ ${priorMaterials.length ? `${renderPriorMaterials(priorMaterials)}
515
508
  Context only: this never substitutes for an acceptance criterion or closes a prior material.
516
509
  ${operatorContext.trim()}
517
510
 
511
+ ` : ""}${task.outOfScope?.length ? `## Out of scope
512
+ The task declares these items out of scope. A finding inside these declared bounds is not material and must not block approval.
513
+ ${task.outOfScope.map((item) => `- ${item}`).join("\n")}
514
+
518
515
  ` : ""}## Diff
519
516
  \`\`\`diff
520
517
  ${diff}
@@ -17,7 +17,7 @@ import { scopeGate } from "./scope.js";
17
17
  import { evaluateManifestedTest, isVitestTestCommand } from "./test-manifest.js";
18
18
  import { executionSignal } from "../run/execution-budget.js";
19
19
  import { failureDisposition } from "../run/recovery.js";
20
- import { preserveWorktree, shGit, resolvedCapacity, verificationProtocol } from "../run/git.js";
20
+ import { dependencyLinkRefusal, preserveWorktree, shGit, resolvedCapacity, verificationProtocol } from "../run/git.js";
21
21
  import { withJudgeInvocationEvidence } from "../run/journal.js";
22
22
  import { computeVerificationIdentity, verificationIdentityKey, formatReusedRow, getVerdictStore, isInfraResult, resolveStateDir, reusedIdentity, } from "./cache.js";
23
23
  const productionLoadProvider = () => loadavg()[0] ?? 0;
@@ -304,6 +304,16 @@ export async function runGates(task, ctx) {
304
304
  };
305
305
  let selectionDecision;
306
306
  let commits = [];
307
+ // Check before cache identity, npm policy probes, or any gate command.
308
+ const dependencyRefusal = dependencyLinkRefusal(ctx.worktree);
309
+ if (dependencyRefusal) {
310
+ const gate = GATE_NAMES.find(g => task.gates.includes(g)) ?? "build";
311
+ const result = { gate, pass: false, details: dependencyRefusal,
312
+ meta: { infra: true, classification: "infra", retryable: false, kind: "workspace-dependency" } };
313
+ await noBuild("refused", dependencyRefusal);
314
+ await ctx.onGate?.({ phase: "end", gate, result });
315
+ return { results: [result], commits: [] };
316
+ }
307
317
  const stateDir = ctx.stateDir ?? resolveStateDir(ctx.worktree, ctx.artifactDir);
308
318
  const verdictStore = getVerdictStore(stateDir);
309
319
  // R41: the verification protocol and the EFFECTIVE npm lifecycle policy measured for THIS
@@ -37,6 +37,11 @@ export interface TestReport {
37
37
  requested: string[];
38
38
  started: Record<string, number>;
39
39
  completed: Record<string, TestReportCompletion>;
40
+ /** Resolved scheduling observed at run start; absent/partial means unknown, never inferred. */
41
+ scheduling?: Record<string, {
42
+ pool: string;
43
+ singleFork: boolean;
44
+ }>;
40
45
  /** Files the reporter observed complete MORE than once — `completed`'s object keys cannot show
41
46
  * this themselves (a second write silently overwrites the first), so the reporter records the
42
47
  * evidence separately before it is lost. */