tickmarkr 2.4.0 → 2.4.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 (45) hide show
  1. package/dist/adapters/catalog-remote.d.ts +11 -0
  2. package/dist/adapters/catalog-remote.js +55 -13
  3. package/dist/adapters/codex.js +6 -7
  4. package/dist/adapters/qwen.js +3 -3
  5. package/dist/adapters/registry.js +13 -1
  6. package/dist/cli/commands/compile.d.ts +3 -0
  7. package/dist/cli/commands/compile.js +77 -46
  8. package/dist/cli/commands/doctor.js +14 -9
  9. package/dist/cli/commands/fleet.js +11 -5
  10. package/dist/cli/commands/init.js +12 -13
  11. package/dist/cli/commands/plan.js +47 -7
  12. package/dist/cli/commands/run.js +20 -1
  13. package/dist/cli/commands/status.js +20 -21
  14. package/dist/cli/commands/version.js +2 -2
  15. package/dist/compile/collateral.d.ts +14 -5
  16. package/dist/compile/collateral.js +32 -26
  17. package/dist/compile/index.js +17 -6
  18. package/dist/compile/native.js +10 -2
  19. package/dist/compile/ownership.js +15 -9
  20. package/dist/drivers/herdr.d.ts +1 -0
  21. package/dist/drivers/herdr.js +32 -3
  22. package/dist/drivers/orca.d.ts +5 -1
  23. package/dist/drivers/orca.js +51 -2
  24. package/dist/drivers/types.d.ts +1 -1
  25. package/dist/gates/baseline.d.ts +26 -2
  26. package/dist/gates/baseline.js +90 -7
  27. package/dist/gates/review.d.ts +2 -2
  28. package/dist/gates/review.js +2 -18
  29. package/dist/graph/graph.d.ts +20 -0
  30. package/dist/graph/graph.js +66 -1
  31. package/dist/route/preference.d.ts +3 -1
  32. package/dist/route/preference.js +4 -4
  33. package/dist/run/consult.js +1 -0
  34. package/dist/run/daemon.d.ts +5 -0
  35. package/dist/run/daemon.js +151 -22
  36. package/dist/run/git.d.ts +2 -0
  37. package/dist/run/git.js +18 -4
  38. package/dist/run/journal.d.ts +1 -1
  39. package/dist/run/journal.js +1 -1
  40. package/dist/run/lock.d.ts +6 -0
  41. package/dist/run/lock.js +41 -1
  42. package/package.json +1 -1
  43. package/skills/tickmarkr-overseer/SKILL.md +39 -4
  44. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +79 -0
  45. package/skills/tickmarkr-overseer/scripts/watch-context.sh +90 -4
@@ -6,7 +6,7 @@ import { formatModelAuthLine, contextWindowLints, modelLints, preferEntryLints,
6
6
  import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
7
7
  import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
8
8
  import { classifyContextPath } from "../../compile/native.js";
9
- import { DEFAULT_CONFIG, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
9
+ import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
10
10
  import { loadGraph } from "../../graph/graph.js";
11
11
  import { renderAcceptanceItem } from "../../graph/schema.js";
12
12
  import { resolveRunMode } from "../../run/daemon.js";
@@ -14,11 +14,12 @@ import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines }
14
14
  import { staffLedEvidence } from "../../route/profile.js";
15
15
  import { route, RoutingError } from "../../route/router.js";
16
16
  import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.js";
17
- import { modelId, modelProvider } from "../../gates/review.js";
18
- import { loadRoutingProfile } from "../../run/journal.js";
17
+ import { modelId, modelProvider, pickReviewer } from "../../gates/review.js";
18
+ import { Journal, loadRoutingProfile } from "../../run/journal.js";
19
19
  import { harnessLine, resolveHarness } from "../harness.js";
20
- import { shq } from "../../adapters/types.js";
20
+ import { channelKey, shq } from "../../adapters/types.js";
21
21
  import { shGit } from "../../run/git.js";
22
+ import { runLockRunId, runStatusLine } from "../../run/lock.js";
22
23
  import { driverEvidence, pickDriver } from "../../drivers/index.js";
23
24
  // T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
24
25
  // markers carry the attention glyph, section labels dim to chrome (the doctor/status system).
@@ -44,6 +45,19 @@ const fleetCanCrossVendorReview = (channels) => {
44
45
  return false;
45
46
  };
46
47
  const NAMED_CRITERION_PATH = /(?:^|[\s("'`])((?:src|tests|fixtures|scripts)\/[A-Za-z0-9_@{}*?.,/+-]+\.(?:tsx|ts|jsx|json|js|mjs|cjs|md|txt))(?![A-Za-z0-9])/g;
48
+ const judgeOracle = (task) => task.acceptance.some((item) => typeof item === "string" || (typeof item === "object" && item.oracle === "judge"));
49
+ const latestRunStatusLine = (cwd) => {
50
+ const locked = runLockRunId(cwd);
51
+ const runId = locked ?? Journal.latestRunId(cwd, { withJournal: true });
52
+ if (!runId)
53
+ return null;
54
+ let events = [];
55
+ try {
56
+ events = Journal.open(cwd, runId).read();
57
+ }
58
+ catch { /* lock may exist before first journal row */ }
59
+ return runStatusLine(cwd, runId, events);
60
+ };
47
61
  async function taskInputFindings(tasks, cwd) {
48
62
  const pathsByTask = tasks.map((task) => {
49
63
  const paths = new Set(task.files.map((path) => path.replace(/^\.\//, "")));
@@ -169,11 +183,13 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
169
183
  // source, and the explore posture; each task row carries a floor-derivation line below.
170
184
  // v1.89 T4: the harness names itself ABOVE the routing table — the table is only as trustworthy as the
171
185
  // binary that produced it, and version equality cannot tell an installed package from a checkout.
186
+ const runLine = latestRunStatusLine(cwd);
172
187
  const lines = [
173
188
  `tickmarkr plan — dry run (${channels.length} channels available)`,
174
189
  `mode: ${mode.mode} (${source}) · explore ${cfg.routing.explore?.mode ?? "on"}`,
175
190
  `driver: ${driverEvidence(cfg, selectedDriver, values.driver)}`,
176
191
  harnessLine(resolveHarness(harnessFrom)),
192
+ ...(runLine ? [runLine] : []),
177
193
  "",
178
194
  ];
179
195
  const judgeDeny = disallowedBy(cfg.judge, cfg.routing, "judge");
@@ -273,6 +289,28 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
273
289
  lints.push("review: no cross-vendor reviewer pair in fleet — set review.required: false to waive");
274
290
  }
275
291
  let cost = 0;
292
+ const seatCost = (seat) => seat?.channel === "api" ? cfg.pricing[seat.tier] : 0;
293
+ const judgeSeat = (task) => {
294
+ const shapeGates = cfg.gates.byShape?.[task.shape];
295
+ if (!task.gates.includes("acceptance") || shapeGates?.acceptance === false)
296
+ return { line: "none — acceptance gate disabled", cost: 0 };
297
+ if (!judgeOracle(task))
298
+ return { line: "none — no judge oracle", cost: 0 };
299
+ const seat = pools.judge.find((c) => c.adapter === cfg.judge.adapter && c.model === cfg.judge.model);
300
+ return { line: `${cfg.judge.adapter}:${cfg.judge.model}`, cost: seatCost(seat) };
301
+ };
302
+ const reviewSeat = (task, author) => {
303
+ const shapeGates = cfg.gates.byShape?.[task.shape];
304
+ if (!task.gates.includes("review") || shapeGates?.review === false)
305
+ return { line: "none — review gate disabled", cost: 0 };
306
+ const policy = effectiveReviewPolicy(task.files, cfg.review);
307
+ if (policy === "judge-only")
308
+ return { line: "none — reviewPolicy judge-only: declared leaf paths", cost: 0 };
309
+ const reviewer = pickReviewer(author, pools.review, [], cfg.review.prefer ?? [], task.routingHints?.floor);
310
+ if (!reviewer)
311
+ return { line: "none — no cross-vendor reviewer available", cost: 0 };
312
+ return { line: `${channelKey(reviewer)} — reviewPolicy full`, cost: seatCost(reviewer) };
313
+ };
276
314
  const routed = [];
277
315
  for (const t of g.tasks) {
278
316
  const refusals = [...(oracleRefusals.get(t.id) ?? []), ...(inputRefusals.get(t.id) ?? [])];
@@ -288,8 +326,10 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
288
326
  lints.push(suf ? `${l}${suf}` : l);
289
327
  }
290
328
  const est = r.assignment.channel === "sub" ? 0 : cfg.pricing[r.assignment.tier];
291
- cost += est;
292
- lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`);
329
+ const judge = judgeSeat(t);
330
+ const review = reviewSeat(t, r.assignment);
331
+ cost += est + judge.cost + review.cost;
332
+ lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`, ` judge: ${judge.line}${judge.cost ? ` ~$${judge.cost.toFixed(2)}` : ""}`, ` review: ${review.line}${review.cost ? ` ~$${review.cost.toFixed(2)}` : ""}`);
293
333
  const d = derivation(t.shape);
294
334
  if (d)
295
335
  lines.push(d);
@@ -326,7 +366,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
326
366
  if (inputWarnings.length) {
327
367
  lines.push("", "input warnings:", ...inputWarnings.map((finding) => ` ! ${finding.taskId}: ${finding.detail}`));
328
368
  }
329
- lines.push("", `est. cost (API channels only, rough): ~$${cost.toFixed(2)} + judge/review/consult calls`);
369
+ lines.push("", `est. cost (API LLM seats, rough): ~$${cost.toFixed(2)} + consult calls`);
330
370
  // VIS-04: summary only when a profile is active AND something deviates. Labeled by the switch — off = preview.
331
371
  if (profile && deviations) {
332
372
  lines.push(cfg.routing.learned === "off"
@@ -2,7 +2,7 @@ import { parseArgs } from "node:util";
2
2
  import { allAdapters, discoverChannels, probeAll, readDoctor } from "../../adapters/registry.js";
3
3
  import { ROUTING_MODES } from "../../config/config.js";
4
4
  import { parseDriverOverride, pickDriver } from "../../drivers/index.js";
5
- import { loadGraph } from "../../graph/graph.js";
5
+ import { loadGraph, onDiskSpecHash, readCompileRefusal } from "../../graph/graph.js";
6
6
  import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
7
7
  import { isRunLockLive } from "../../run/lock.js";
8
8
  import { route, NO_EXPLORE_ENV } from "../../route/router.js";
@@ -424,7 +424,26 @@ export async function run(argv, cwd = process.cwd()) {
424
424
  if (values.quality)
425
425
  console.warn(QUALITY_ALIAS_NOTICE);
426
426
  const flagMode = values.mode ?? (values.quality ? "partner-led" : undefined);
427
+ const refusal = (() => {
428
+ try {
429
+ return readCompileRefusal(cwd);
430
+ }
431
+ catch (error) {
432
+ throw new Error(`refusing to run: ${error instanceof Error ? error.message : String(error)}. `
433
+ + "Recompile successfully with `tickmarkr compile <src>` before running.");
434
+ }
435
+ })();
436
+ if (refusal) {
437
+ throw new Error(`refusing to run the prior graph: the last compile of ${refusal.source} was refused at ${refusal.refusedAt}:\n`
438
+ + `${refusal.error}\n`
439
+ + "Recompile successfully with `tickmarkr compile <src>` to clear the refusal record before running.");
440
+ }
427
441
  const graph = loadGraph(cwd);
442
+ const diskSpec = onDiskSpecHash(cwd, graph);
443
+ if (diskSpec && diskSpec.hash !== graph.spec.hash) {
444
+ throw new Error(`refusing to run a stale graph: ${diskSpec.path} content hash ${diskSpec.hash} differs from `
445
+ + `the graph's recorded spec hash ${graph.spec.hash}. Recompile with \`tickmarkr compile <src>\` before running.`);
446
+ }
428
447
  const { cfg, conflict } = resolveRunMode(cwd, { flag: flagMode, spec: graph.mode });
429
448
  if (conflict) {
430
449
  // Loud, never silent: live intent (the flag) may override compiled intent (the spec) — strict refuses.
@@ -9,7 +9,7 @@ import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../.
9
9
  import { GATE_NAMES } from "../../graph/schema.js";
10
10
  import { foldActivity } from "../../run/activity.js";
11
11
  import { Journal, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, } from "../../run/journal.js";
12
- import { isPidLive, runLockOwner } from "../../run/lock.js";
12
+ import { isPidLive, runLockRunId, runStatusLine } from "../../run/lock.js";
13
13
  import { normalizeGateOutcome } from "../../run/outcome.js";
14
14
  import { desiredPanes } from "../../run/reconcile.js";
15
15
  import { normalizeStallSnapshot } from "../../run/stall.js";
@@ -860,17 +860,6 @@ const parseJournalSnapshot = (raw) => raw.split("\n").flatMap((line) => {
860
860
  // .tickmarkr metadata; an engine-written lock necessarily passed through that initializer already, so
861
861
  // status avoids invoking it in stripped reader-purity fixtures where no engine lock can exist.
862
862
  const canReadRunLockOwner = (cwd) => existsSync(join(cwd, stateDirName(cwd), ".gitignore"));
863
- const reportableLockRunId = (owner) => {
864
- if (!owner?.live || owner.runId === undefined)
865
- return undefined;
866
- try {
867
- return parseRunId(owner.runId);
868
- }
869
- catch {
870
- // Repository-wide locks can be held for non-run work such as compile; those are not a status run.
871
- return undefined;
872
- }
873
- };
874
863
  const readRunJournalRaw = (cwd, runId, allowMissingJournal) => {
875
864
  const dir = join(cwd, stateDirName(cwd), "runs", runId);
876
865
  try {
@@ -900,7 +889,7 @@ const readRunJournalRaw = (cwd, runId, allowMissingJournal) => {
900
889
  const readRunRecord = (cwd, graph, namedRunId) => {
901
890
  const explicitRunId = namedRunId === undefined ? undefined : parseRunId(namedRunId);
902
891
  const lockedRunId = explicitRunId === undefined && canReadRunLockOwner(cwd)
903
- ? reportableLockRunId(runLockOwner(cwd))
892
+ ? runLockRunId(cwd)
904
893
  : undefined;
905
894
  const runId = explicitRunId ?? lockedRunId ?? Journal.latestRunId(cwd, { withJournal: true });
906
895
  if (!runId)
@@ -947,6 +936,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
947
936
  const record = readRunRecord(cwd, g, namedRunId);
948
937
  const runId = record?.runId;
949
938
  const events = record?.events ?? [];
939
+ const runLine = runId && record ? runStatusLine(cwd, runId, events) : null;
950
940
  // The header and every live-phase consumer share this exact process reading. Proved death retires
951
941
  // stale phase markers; unknown identity deliberately does not, preserving pre-identity journals.
952
942
  const daemon = daemonLiveness(events);
@@ -1091,7 +1081,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1091
1081
  });
1092
1082
  const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
1093
1083
  const header = runId
1094
- ? `tickmarkr status${divider}run ${runId}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, daemon, now).replaceAll(" · ", divider)}${verify ? `${divider}${verify}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
1084
+ ? `tickmarkr status${divider}${runLine ?? `run ${runId}`}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, daemon, now).replaceAll(" · ", divider)}${verify ? `${divider}${verify}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
1095
1085
  : `tickmarkr status${zone}${divider}no runs yet${divider}${done}/${total} done`;
1096
1086
  const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
1097
1087
  return {
@@ -1115,7 +1105,8 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1115
1105
  const tally = tipPhase
1116
1106
  ? `${progressTone(`${progressGlyph} ${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
1117
1107
  : done === total && total > 0 ? ok(`${done}/${total} done`) : information(`${done}/${total} done`);
1118
- const live = `${liveness(events, daemon, now)} · load1 ${os.loadavg()[0].toFixed(2)}`
1108
+ const load1 = os.loadavg()[0] ?? 0;
1109
+ const live = `${liveness(events, daemon, now)} · load1 ${load1.toFixed(2)}`
1119
1110
  .replace(/\bdead\b/u, fail("dead"))
1120
1111
  .replace(/\bfinished\b/u, dim("finished"))
1121
1112
  .replace(/\balive\b/u, running("alive"))
@@ -1133,6 +1124,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1133
1124
  ? ok(verify)
1134
1125
  : tipFailed ? fail(`${GLYPHS.fail} ${verify}`) : warn(`${GLYPHS.attention} ${verify}`)]),
1135
1126
  ...(runId ? [live] : []),
1127
+ ...(runLine && runLine !== `run ${runId}` ? [runLine] : []),
1136
1128
  ...(journalRowsOnly ? [`zone ${localZoneLabel(zoneReference)}`] : []),
1137
1129
  ].join(separator);
1138
1130
  // The brand lockup is two rows and stays two rows at every width: the muted chip, and the running
@@ -1427,7 +1419,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1427
1419
  // newer runs start. The no-argument form tracks the live lock first, then latest journal.
1428
1420
  const explicitRunId = namedRunId === undefined ? undefined : parseRunId(namedRunId);
1429
1421
  const lockedRunId = explicitRunId === undefined && canReadRunLockOwner(cwd)
1430
- ? reportableLockRunId(runLockOwner(cwd))
1422
+ ? runLockRunId(cwd)
1431
1423
  : undefined;
1432
1424
  const runId = explicitRunId ?? lockedRunId ?? Journal.latestRunId(cwd, { withJournal: true });
1433
1425
  if (!runId)
@@ -1494,16 +1486,22 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1494
1486
  process.stdout.write(`\x1b]0;⏳ ${hotPhase.taskId} ${phaseLabel(hotPhase)} ${fmtElapsed(nowMs - hotPhase.startedAt)}\x07`);
1495
1487
  };
1496
1488
  const signalHandlers = [];
1489
+ let exitingFromSignal = false;
1497
1490
  const exitFromSignal = (code) => () => {
1491
+ if (exitingFromSignal)
1492
+ return;
1493
+ exitingFromSignal = true;
1498
1494
  if (titleSaved) {
1499
1495
  process.removeListener("exit", restoreTitle);
1500
1496
  restoreTitle();
1501
1497
  }
1502
- if (alternateScreen) {
1503
- process.removeListener("exit", leaveAlternateScreen);
1504
- leaveAlternateScreen();
1498
+ if (!alternateScreen) {
1499
+ process.exit(code);
1500
+ return;
1505
1501
  }
1506
- process.exit(code);
1502
+ process.removeListener("exit", leaveAlternateScreen);
1503
+ alternateScreen = false;
1504
+ process.stdout.write(LEAVE_ALTERNATE_SCREEN, () => process.exit(code));
1507
1505
  };
1508
1506
  if (!bounded && !eventStream && tty) {
1509
1507
  process.stdout.write(ENTER_ALTERNATE_SCREEN);
@@ -1527,7 +1525,8 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1527
1525
  if (bounded)
1528
1526
  eventLines.push(line);
1529
1527
  }
1530
- process.stderr.write("status: keepalive\n");
1528
+ if (decisionEvents.length === 0)
1529
+ process.stderr.write("status: keepalive\n");
1531
1530
  }
1532
1531
  else {
1533
1532
  frame = renderFrame(cwd, binaryVersion, nowMs, i, workerLiveness, true, namedRunId);
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
- import { dirname, join, relative } from "node:path";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../../package.json");
6
6
  const distDir = join(dirname(pkgPath), "dist");
@@ -25,5 +25,5 @@ export function distFingerprint(dir = distDir) {
25
25
  }
26
26
  export async function version(argv = [], dir = distDir) {
27
27
  const { version: v } = JSON.parse(readFileSync(pkgPath, "utf8"));
28
- return argv.includes("--dist") ? `${v} dist:${distFingerprint(dir)}` : v;
28
+ return argv.includes("--dist") ? `${v} dist:${resolve(dir)} fingerprint:${distFingerprint(dir)}` : v;
29
29
  }
@@ -47,12 +47,21 @@ export declare function collateralLints(tasks: ReadonlyArray<Pick<Task, "id" | "
47
47
  */
48
48
  export declare function criterionReadsOnly(text: string, target: string): boolean;
49
49
  export declare function newDirectoryLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
50
+ export interface SourceScopeFinding {
51
+ taskId: string;
52
+ /** Complete sorted hit set. Rendering may cap it; compile-time enforcement never does. */
53
+ paths: string[];
54
+ }
55
+ /** Structured OBS-76 findings shared by human rendering and native compile enforcement. */
56
+ export declare function sourceScopeFindings(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, repoRoot: string): SourceScopeFinding[];
57
+ /** Human display is derived from the structured finding; no enforcement path parses this text. */
58
+ export declare function renderSourceScopeFinding(finding: SourceScopeFinding): string;
50
59
  /**
51
60
  * OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
52
61
  * criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
53
62
  * Advisory plan output only, same contract as collateralLints.
54
63
  */
55
- export declare function sourceScopeLints(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, repoRoot: string): string[];
64
+ export declare function sourceScopeLints(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, repoRoot: string, findings?: readonly SourceScopeFinding[]): string[];
56
65
  /** Max acceptance items per task. Consult-converged (copus/csol/cfable R2). */
57
66
  export declare const MAX_ACCEPTANCE_ITEMS = 6;
58
67
  /** Max files[] patterns per task. */
@@ -141,15 +150,15 @@ export type ReviewParticipation = TickmarkrConfig["review"] & {
141
150
  export declare function reviewParticipationErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "shape">>, review: ReviewParticipation): string[];
142
151
  /**
143
152
  * Every Task Unit Contract violation in one pass, ready to throw. `repoRoot` backs the
144
- * symbol-ownership lint and the participation config, and defaults to the invocation directory —
145
- * correct for the CLI/daemon, which compile from inside the target repo.
153
+ * symbol-ownership lint and participation overlay, and defaults to the invocation directory for
154
+ * programmatic compiles. The CLI passes its target repository explicitly.
146
155
  *
147
156
  * The read declaration (`context:`) rides on this parameter, on the task objects themselves — the
148
157
  * aggregator never infers it from the repo, the config, or a sibling task. It is OPTIONAL: a caller
149
158
  * whose task objects carry no `context` gets byte-identical verdicts to the ones it got before the
150
159
  * field existed, because an absent declaration grants no authority at all.
151
160
  *
152
- * The compile seam in src/compile/index.ts threads `compileSource`'s repo root here, so CLI tests
153
- * and programmatic compiles check the same repository overlay the later run will use.
161
+ * The compile seam in src/compile/index.ts threads `compileSource`'s repo root here when supplied;
162
+ * otherwise this function deliberately checks process.cwd().
154
163
  */
155
164
  export declare function taskUnitContractErrors(tasks: ReadonlyArray<Pick<Task, "id" | "goal" | "files" | "deps" | "acceptance" | "shape"> & Partial<Pick<Task, "context">>>, repoRoot?: string, review?: ReviewParticipation): string[];
@@ -460,23 +460,18 @@ export function newDirectoryLints(tasks, repoRoot) {
460
460
  }
461
461
  return lines;
462
462
  }
463
- /**
464
- * OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
465
- * criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
466
- * Advisory plan output only, same contract as collateralLints.
467
- */
468
- export function sourceScopeLints(tasks, repoRoot) {
469
- const newDirLints = newDirectoryLints(tasks, repoRoot);
463
+ /** Structured OBS-76 findings shared by human rendering and native compile enforcement. */
464
+ export function sourceScopeFindings(tasks, repoRoot) {
470
465
  const perTask = tasks
471
466
  .map((t) => ({ t, needles: criteriaSymbols(t.acceptance) }))
472
467
  .filter((x) => x.needles.length);
473
468
  if (!perTask.length)
474
- return newDirLints;
469
+ return [];
475
470
  const srcFiles = walkCode(repoRoot, "src");
476
471
  if (!srcFiles.length)
477
- return newDirLints;
472
+ return [];
478
473
  const read = makeReader(repoRoot);
479
- const lines = [];
474
+ const findings = [];
480
475
  for (const { t, needles } of perTask) {
481
476
  // OBS-22: scopeGate accepts picomatch globs; advisory warnings must agree.
482
477
  const scoped = filesGlob(t.files.map((f) => f.replace(/^\.\//, "")));
@@ -495,11 +490,23 @@ export function sourceScopeLints(tasks, repoRoot) {
495
490
  if (!hits.length)
496
491
  continue;
497
492
  hits.sort();
498
- const listed = hits.slice(0, MAX_HITS_PER_TASK).join(", ");
499
- const tail = hits.length > MAX_HITS_PER_TASK ? " (capped)" : "";
500
- lines.push(`${t.id}: criteria implicate out-of-scope source not in files[]: ${listed}${tail}`);
493
+ findings.push({ taskId: t.id, paths: hits });
501
494
  }
502
- return [...newDirLints, ...lines];
495
+ return findings;
496
+ }
497
+ /** Human display is derived from the structured finding; no enforcement path parses this text. */
498
+ export function renderSourceScopeFinding(finding) {
499
+ const listed = finding.paths.slice(0, MAX_HITS_PER_TASK).join(", ");
500
+ const tail = finding.paths.length > MAX_HITS_PER_TASK ? " (capped)" : "";
501
+ return `${finding.taskId}: criteria implicate out-of-scope source not in files[]: ${listed}${tail}`;
502
+ }
503
+ /**
504
+ * OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
505
+ * criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
506
+ * Advisory plan output only, same contract as collateralLints.
507
+ */
508
+ export function sourceScopeLints(tasks, repoRoot, findings = sourceScopeFindings(tasks, repoRoot)) {
509
+ return [...newDirectoryLints(tasks, repoRoot), ...findings.map(renderSourceScopeFinding)];
503
510
  }
504
511
  // ── Task Unit Contract (OBS-212 / OBS-214) ────────────────────────────────────────────────────
505
512
  // These are ERRORS, not advisories. Everything above this line is a plan-time warning the author
@@ -835,9 +842,9 @@ export function symbolOwnershipErrors(tasks, repoRoot) {
835
842
  * cannot be a warning, because nothing downstream would stop it — the gate would honour the policy.
836
843
  */
837
844
  export function reviewParticipationErrors(tasks, review) {
838
- // The shipped critical paths are a FLOOR, not a default that a config replaces: this lint resolves
839
- // its config from a repo root the compile seam cannot always name (see taskUnitContractErrors), and
840
- // the one direction that must never happen is a wrong root lowering enforcement below what tickmarkr
845
+ // The shipped critical paths are a FLOOR, not a default that a config replaces: a programmatic
846
+ // compile may rely on taskUnitContractErrors' process.cwd() default, and the one direction that
847
+ // must never happen is a mismatched invocation root lowering enforcement below what tickmarkr
841
848
  // ships. Union is monotone — a repo-local list can only add.
842
849
  const critical = [...new Set([...DEFAULT_REVIEW_CRITICAL_PATHS, ...(review.criticalPaths ?? [])])];
843
850
  const errors = [];
@@ -871,11 +878,10 @@ export function reviewParticipationErrors(tasks, review) {
871
878
  return errors;
872
879
  }
873
880
  /**
874
- * The ACTIVE participation config for a compile rooted at `repoRoot`. Same default-argument contract
875
- * as `repoRoot` itself: the CLI and daemon compile from inside the target repo, so the repo's own
876
- * config (plus the global overlay) is the config the run will gate under. A config the loader cannot
877
- * read degrades to the shipped defaults rather than crashing the compile — every command that reaches
878
- * this seam loads the same config itself and reports a malformed one loudly.
881
+ * The ACTIVE participation config for a compile rooted at `repoRoot`. CLI compilation supplies its
882
+ * target root; a programmatic caller that omits it uses taskUnitContractErrors' process.cwd() default,
883
+ * so its active overlay is the invocation repository. Native CLI compiles independently read the
884
+ * overlay fail-closed at the finalization seam, so malformed YAML is loud.
879
885
  */
880
886
  function activeReviewParticipation(repoRoot) {
881
887
  try {
@@ -888,16 +894,16 @@ function activeReviewParticipation(repoRoot) {
888
894
  }
889
895
  /**
890
896
  * Every Task Unit Contract violation in one pass, ready to throw. `repoRoot` backs the
891
- * symbol-ownership lint and the participation config, and defaults to the invocation directory —
892
- * correct for the CLI/daemon, which compile from inside the target repo.
897
+ * symbol-ownership lint and participation overlay, and defaults to the invocation directory for
898
+ * programmatic compiles. The CLI passes its target repository explicitly.
893
899
  *
894
900
  * The read declaration (`context:`) rides on this parameter, on the task objects themselves — the
895
901
  * aggregator never infers it from the repo, the config, or a sibling task. It is OPTIONAL: a caller
896
902
  * whose task objects carry no `context` gets byte-identical verdicts to the ones it got before the
897
903
  * field existed, because an absent declaration grants no authority at all.
898
904
  *
899
- * The compile seam in src/compile/index.ts threads `compileSource`'s repo root here, so CLI tests
900
- * and programmatic compiles check the same repository overlay the later run will use.
905
+ * The compile seam in src/compile/index.ts threads `compileSource`'s repo root here when supplied;
906
+ * otherwise this function deliberately checks process.cwd().
901
907
  */
902
908
  export function taskUnitContractErrors(tasks, repoRoot = process.cwd(), review = activeReviewParticipation(repoRoot)) {
903
909
  return [
@@ -37,12 +37,16 @@ function detect(src) {
37
37
  function repoOverlayMode(repoRoot) {
38
38
  if (!repoRoot)
39
39
  return undefined;
40
+ const path = join(repoRoot, ".tickmarkr", "config.yaml");
41
+ if (!existsSync(path))
42
+ return undefined;
40
43
  try {
41
- const cfg = parse(readFileSync(join(repoRoot, ".tickmarkr", "config.yaml"), "utf8"));
44
+ const cfg = parse(readFileSync(path, "utf8"));
42
45
  return typeof cfg?.routing?.mode === "string" ? cfg.routing.mode : undefined;
43
46
  }
44
- catch {
45
- return undefined;
47
+ catch (error) {
48
+ throw new CompileError(`${path} does not parse, so compile cannot prove the repository routing overlay: `
49
+ + `${error instanceof Error ? error.message : String(error)}`);
46
50
  }
47
51
  }
48
52
  function hasModeOverride(src) {
@@ -58,9 +62,13 @@ function hasModeOverride(src) {
58
62
  return false;
59
63
  }
60
64
  function enforceModeOverlay(graph, src, repoRoot) {
61
- if (graph.spec.source !== "native" || graph.mode === undefined)
65
+ if (graph.spec.source !== "native")
62
66
  return;
67
+ // Read every native compile's overlay before checking whether either side declares a mode. A
68
+ // corrupt file cannot masquerade as an absent mode and silently skip the disagreement gate.
63
69
  const overlayMode = repoOverlayMode(repoRoot);
70
+ if (graph.mode === undefined)
71
+ return;
64
72
  if (overlayMode === undefined || overlayMode === graph.mode || hasModeOverride(src))
65
73
  return;
66
74
  throw new CompileError(`${src} front-matter mode ${graph.mode} disagrees with repository routing.mode ${overlayMode}; `
@@ -75,7 +83,7 @@ function enforceTaskUnitContract(g, src, repoRoot) {
75
83
  return g;
76
84
  }
77
85
  export function finalizePlan(plan, src, repoRoot) {
78
- const graph = enforceTaskUnitContract(validateGraph({
86
+ const graph = validateGraph({
79
87
  version: plan.version,
80
88
  ...(plan.mode !== undefined ? { mode: plan.mode } : {}),
81
89
  spec: {
@@ -85,8 +93,11 @@ export function finalizePlan(plan, src, repoRoot) {
85
93
  ...(plan.base !== undefined ? { base: plan.base } : {}),
86
94
  },
87
95
  tasks: plan.tasks,
88
- }), src, repoRoot);
96
+ });
97
+ // Overlay readability is compile truth, not a mode-disagreement optimization. Check it before
98
+ // other repo-dependent lints so malformed native config cannot be hidden by an earlier finding.
89
99
  enforceModeOverlay(graph, src, repoRoot);
100
+ enforceTaskUnitContract(graph, src, repoRoot);
90
101
  // overseer-217 removal condition, now paid: on this milestone's authored graph the conventional
91
102
  // name map emitted 21 raw unowned-test findings; review found 1 real and 20 false, while intersecting
92
103
  // with a direct import or command-entry spawn retained the real one and left 0 false positives. That
@@ -286,11 +286,19 @@ export function authoringLintFindings(tasks, file) {
286
286
  if (scope)
287
287
  findings.push(scope);
288
288
  for (const symbol of fencedIdentifiers(text)) {
289
- if (!taskTexts.size || [...taskTexts.values()].some((body) => body.includes(symbol)))
289
+ // Empty files[] is the deliberately unrestricted scope and a non-repository programmatic
290
+ // compile has no disk corpus to prove against. But a declared files[] that expands to zero
291
+ // repository files is evidence, not absence: its preservation fence cannot be satisfied.
292
+ if (!root || task.files.length === 0)
290
293
  continue;
294
+ if ([...taskTexts.values()].some((body) => body.includes(symbol)))
295
+ continue;
296
+ const matchDetail = taskFiles.length === 0
297
+ ? `this task's files[] matched zero files on disk (${task.files.join(", ")})`
298
+ : `that symbol has zero hits in this task's files[] (${taskFiles.join(", ")})`;
291
299
  findings.push({
292
300
  code: "fence-symbol-absent", fixtureId: id, taskId: task.id, criterion: index + 1,
293
- detail: `preservation fence cites \`${symbol}\` but that symbol has zero hits in this task's files[] (${taskFiles.join(", ")}) — OBS-604`,
301
+ detail: `preservation fence cites \`${symbol}\` but ${matchDetail} — OBS-604`,
294
302
  });
295
303
  }
296
304
  if (/line[- ]count|physical line|not greater than (?:the|\d)|no larger than|at most \d+ (?:lines|bytes)/i.test(text)) {
@@ -34,24 +34,29 @@ function sourceFiles(repoRoot) {
34
34
  return [];
35
35
  }
36
36
  }
37
- // OBS-898: `allSources` is indexed once per ownership pass by the caller, never per test source.
38
- function namedSources(test, tasks, allSources) {
39
- const stem = basename(test).replace(/\.test\.ts$/, "");
37
+ // OBS-898: expand the task's source declarations against ONE source-tree listing, once per compile.
38
+ // Tests then query this in-memory index; neither a test file nor a second corroboration pass can
39
+ // trigger another source walk or glob expansion.
40
+ function namedSourceIndex(tasks, allSources) {
40
41
  const matches = new Map();
41
42
  for (const task of tasks) {
42
43
  for (const entry of task.files.map(normalize).filter((path) => path.startsWith("src/"))) {
43
44
  const fromGlob = /[*?{[]/.test(entry);
44
45
  const entries = fromGlob ? allSources.filter(filesGlob(entry)) : [entry];
45
46
  for (const sourcePath of entries) {
46
- const source = basename(sourcePath, extname(sourcePath));
47
- if (stem === source || stem.startsWith(`${source}-`)) {
48
- matches.set(`${task.id}:${sourcePath}`, { taskId: task.id, source: sourcePath, fromGlob });
49
- }
47
+ matches.set(`${task.id}:${sourcePath}`, { taskId: task.id, source: sourcePath, fromGlob });
50
48
  }
51
49
  }
52
50
  }
53
51
  return [...matches.values()];
54
52
  }
53
+ function namedSources(test, index) {
54
+ const stem = basename(test).replace(/\.test\.ts$/, "");
55
+ return index.filter(({ source }) => {
56
+ const sourceStem = basename(source, extname(source));
57
+ return stem === sourceStem || stem.startsWith(`${sourceStem}-`);
58
+ });
59
+ }
55
60
  const moduleKey = (path) => normalize(path).replace(/\.(?:[cm]?[jt]sx?)$/, "");
56
61
  function directImportSpecifiers(text) {
57
62
  // Comments cannot create an edge. Keep strings intact because they are the import target.
@@ -167,9 +172,10 @@ export function ownershipFindings(tasks, repoRoot) {
167
172
  }
168
173
  const globOwnedTests = new Map();
169
174
  const allSources = sourceFiles(repoRoot);
175
+ const namedIndex = namedSourceIndex(tasks, allSources);
170
176
  for (const source of sources) {
171
177
  const ids = predictedBy.get(source.path) ?? new Set();
172
- for (const named of namedSources(source.path, tasks, allSources)) {
178
+ for (const named of namedSources(source.path, namedIndex)) {
173
179
  ids.add(named.taskId);
174
180
  if (named.fromGlob) {
175
181
  const owners = globOwnedTests.get(source.path) ?? new Set();
@@ -185,7 +191,7 @@ export function ownershipFindings(tasks, repoRoot) {
185
191
  if (owners(test).length === 0 && !(globOwnedTests.get(test)?.size)) {
186
192
  const ids = [...taskIds].sort();
187
193
  const source = sourceByPath.get(test);
188
- const evidence = corroboration(source, namedSources(test, tasks, allSources));
194
+ const evidence = corroboration(source, namedSources(test, namedIndex));
189
195
  findings.push({
190
196
  code: "unowned-test",
191
197
  test,
@@ -71,6 +71,7 @@ export declare class HerdrDriver implements ExecutorDriver {
71
71
  private watches;
72
72
  constructor(bin?: string, workersPerTab?: number, time?: HerdrTimeSource, journal?: DriverJournal | undefined);
73
73
  private appendDispatchRetry;
74
+ private appendPaneClose;
74
75
  private openRunJournal;
75
76
  private liveSupervisionSeats;
76
77
  private journalReconcile;
@@ -196,6 +196,29 @@ export class HerdrDriver {
196
196
  // recovery to the file and the pipe while the operator's rail stays silent about it.
197
197
  Journal.open(repoRoot, owned.runId, this.narrate).append("dispatch-retry", owned.taskId, data);
198
198
  }
199
+ // OBS-906: close() is the harvest path, not a reconcile path, so the sweep's close row cannot
200
+ // describe it. Persist the worker slot identity beside the exact pane/tab address this path used.
201
+ // A driver used outside a daemon has no bound run journal; the injectable sink remains the unit
202
+ // seam there, while every daemon-created worktree is bound by worktree() below.
203
+ appendPaneClose(slot, placement) {
204
+ const owned = parseOwnedName(slot.name);
205
+ if (owned?.role !== "worker")
206
+ return;
207
+ try {
208
+ const data = { slot: slot.name, ...placement };
209
+ if (this.journal) {
210
+ this.journal("pane-close", slot.name, data);
211
+ return;
212
+ }
213
+ const repoRoot = this.journalRoots.get(slot.cwd);
214
+ if (!repoRoot)
215
+ return;
216
+ Journal.open(repoRoot, owned.runId, this.narrate).append("pane-close", owned.taskId, data);
217
+ }
218
+ catch {
219
+ /* close is best-effort; a journal failure must not make a successful pane reap fatal */
220
+ }
221
+ }
199
222
  openRunJournal(runId) {
200
223
  const roots = new Set(this.journalRoots.values());
201
224
  roots.add(process.cwd());
@@ -1101,11 +1124,15 @@ export class HerdrDriver {
1101
1124
  return this.serial(() => this.closeGrouped(slot));
1102
1125
  }
1103
1126
  if (slot.tabId) {
1104
- await this.herdr(`tab close ${shq(slot.tabId)}`); // reaps the slot's whole tab, best-effort
1127
+ const closed = await this.herdr(`tab close ${shq(slot.tabId)}`); // reaps the slot's whole tab, best-effort
1128
+ if (closed.code === 0)
1129
+ this.appendPaneClose(slot, { tabId: slot.tabId });
1105
1130
  return;
1106
1131
  }
1107
1132
  const pane = await this.paneId(slot);
1108
- await this.herdr(`pane close ${shq(pane)}`); // best-effort
1133
+ const closed = await this.herdr(`pane close ${shq(pane)}`); // best-effort
1134
+ if (closed.code === 0)
1135
+ this.appendPaneClose(slot, { paneId: pane });
1109
1136
  }
1110
1137
  // D-08 ref-counted teardown, PER GENERATION (VIS-09 item 2): pane close per member; a generation's
1111
1138
  // tab closes only when ITS OWN last member leaves; the group entry dies when all generations are gone.
@@ -1119,7 +1146,9 @@ export class HerdrDriver {
1119
1146
  if (!gen)
1120
1147
  return; // generation already torn down — its tab is gone
1121
1148
  const pane = await this.paneId(slot);
1122
- await this.herdr(`pane close ${shq(pane)}`); // best-effort
1149
+ const closed = await this.herdr(`pane close ${shq(pane)}`); // best-effort
1150
+ if (closed.code === 0)
1151
+ this.appendPaneClose(slot, { paneId: pane, tabId: gen.tabId });
1123
1152
  gen.members = gen.members.filter((m) => m.name !== slot.name);
1124
1153
  await this.renameGroupTab(gen);
1125
1154
  if (gen.members.length === 0) {