tickmarkr 2.4.0 → 2.4.2

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 (49) 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/claude-code.js +7 -2
  4. package/dist/adapters/codex.d.ts +1 -0
  5. package/dist/adapters/codex.js +68 -8
  6. package/dist/adapters/qwen.js +3 -3
  7. package/dist/adapters/registry.js +13 -1
  8. package/dist/adapters/types.d.ts +4 -0
  9. package/dist/adapters/types.js +33 -0
  10. package/dist/cli/commands/compile.d.ts +3 -0
  11. package/dist/cli/commands/compile.js +77 -46
  12. package/dist/cli/commands/doctor.js +14 -9
  13. package/dist/cli/commands/fleet.js +11 -5
  14. package/dist/cli/commands/init.js +12 -13
  15. package/dist/cli/commands/plan.js +47 -7
  16. package/dist/cli/commands/run.js +20 -1
  17. package/dist/cli/commands/status.js +20 -21
  18. package/dist/cli/commands/version.js +2 -2
  19. package/dist/compile/collateral.d.ts +14 -5
  20. package/dist/compile/collateral.js +32 -26
  21. package/dist/compile/index.js +17 -6
  22. package/dist/compile/native.js +10 -2
  23. package/dist/compile/ownership.js +15 -9
  24. package/dist/drivers/herdr.d.ts +1 -0
  25. package/dist/drivers/herdr.js +32 -3
  26. package/dist/drivers/orca.d.ts +5 -1
  27. package/dist/drivers/orca.js +51 -2
  28. package/dist/drivers/types.d.ts +1 -1
  29. package/dist/gates/baseline.d.ts +26 -2
  30. package/dist/gates/baseline.js +90 -7
  31. package/dist/gates/review.d.ts +2 -2
  32. package/dist/gates/review.js +2 -18
  33. package/dist/graph/graph.d.ts +20 -0
  34. package/dist/graph/graph.js +66 -1
  35. package/dist/route/preference.d.ts +3 -1
  36. package/dist/route/preference.js +4 -4
  37. package/dist/run/consult.js +1 -0
  38. package/dist/run/daemon.d.ts +5 -0
  39. package/dist/run/daemon.js +151 -22
  40. package/dist/run/git.d.ts +2 -0
  41. package/dist/run/git.js +18 -4
  42. package/dist/run/journal.d.ts +1 -1
  43. package/dist/run/journal.js +1 -1
  44. package/dist/run/lock.d.ts +6 -0
  45. package/dist/run/lock.js +41 -1
  46. package/package.json +1 -1
  47. package/skills/tickmarkr-overseer/SKILL.md +39 -4
  48. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +79 -0
  49. package/skills/tickmarkr-overseer/scripts/watch-context.sh +90 -4
@@ -15,13 +15,14 @@ import { HerdrDriver } from "../../drivers/herdr.js";
15
15
  import { ORCA_FIXTURE_VERSION, parseEnvelope, resolveOrcaCliBinary } from "../../drivers/orca.js";
16
16
  import { kimi, probeKimiDoctorTurn } from "../../adapters/kimi.js";
17
17
  import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
18
- import { CATALOG_REFRESH_TIMEOUT_MS, LIVEBENCH_TABLE_DATE, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
18
+ import { CATALOG_REFRESH_TIMEOUT_MS, LIVEBENCH_TABLE_DATE, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
19
19
  import { sh } from "../../run/git.js";
20
20
  import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.js";
21
21
  /** Where a newer `table_<date>.csv` is discovered — the deployed site builds filenames by
22
22
  * concatenation and publishes no index, so the release listing is the only enumerable surface. */
23
23
  export const LIVEBENCH_RELEASES_URL = "https://api.github.com/repos/LiveBench/livebench.github.io/contents/public";
24
24
  export const LIVEBENCH_TABLE_MAX_AGE_DAYS = 90;
25
+ const initialFetch = globalThis.fetch;
25
26
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
26
27
  const alignedStatusRow = (verdict, key, value) => ` ${statusRow(verdict, kvRow(key, value).slice(2))}`;
27
28
  const attentionRow = (text) => ` ${statusRow("warn", text)}`;
@@ -348,19 +349,23 @@ export function liveBenchStalenessFinding(now) {
348
349
  }
349
350
  export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(), opts = {}) {
350
351
  if (_argv.length === 1 && _argv[0] === "--refresh-catalog") {
351
- const refreshed = await refreshCatalogCommand({ repoRoot: cwd, now: opts.catalogNow });
352
+ const refreshed = await refreshCatalogCommand({ repoRoot: cwd, fetcher: opts.catalogFetcher, now: opts.catalogNow });
352
353
  return refreshed.updated
353
- ? `tickmarkr doctor --refresh-catalog: model catalog refreshed${refreshed.warning ? `; ${refreshed.warning}` : ""}`
354
- : `tickmarkr doctor --refresh-catalog: catalog refresh unavailable — ${refreshed.warning ?? "unknown failure"}; retained ${refreshed.catalog.source} catalog`;
354
+ ? `tickmarkr doctor --refresh-catalog: model catalog refreshed — ${formatCatalogRefreshLegs(refreshed.legs)}${refreshed.warning ? `; ${refreshed.warning}` : ""}`
355
+ : `tickmarkr doctor --refresh-catalog: catalog refresh unavailable — ${formatCatalogRefreshLegs(refreshed.legs)}${refreshed.warning ? `; ${refreshed.warning}` : ""}`;
355
356
  }
356
357
  // Operator directive 2026-08-12 (declutter): long per-model lists render only when asked for.
357
358
  const listAllModels = _argv.includes("--models");
358
359
  const cfg = loadConfig(cwd);
359
360
  let catalog = opts.catalog ?? readCachedCatalog(cwd, { now: opts.catalogNow });
360
- let catalogRefreshWarning;
361
+ let catalogRefreshLine;
361
362
  // RULING-222-17 reverses cache-only for these two operator-facing commands only. The refresh
362
363
  // remains age-guarded; compile, plan, and run never import this path.
363
- if (!opts.catalog && catalog.source === "cache" && catalog.stale) {
364
+ const catalogRefreshAllowed = process.env.VITEST !== "true"
365
+ || opts.catalogFetcher !== undefined
366
+ || opts.catalogNow !== undefined
367
+ || globalThis.fetch !== initialFetch;
368
+ if (!opts.catalog && catalog.stale && catalogRefreshAllowed) {
364
369
  const refreshed = await refreshCatalogCommand({
365
370
  repoRoot: cwd,
366
371
  fetcher: opts.catalogFetcher,
@@ -368,7 +373,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
368
373
  now: opts.catalogNow,
369
374
  });
370
375
  catalog = refreshed.catalog;
371
- catalogRefreshWarning = refreshed.warning;
376
+ catalogRefreshLine = formatCatalogRefreshLegs(refreshed.legs);
372
377
  }
373
378
  // banner at START — the logo greets the operator before the ~60s probe wait, never trailing it (operator report 2026-07-17)
374
379
  if (opts.banner !== false && visual())
@@ -466,8 +471,8 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
466
471
  if (catalog.warning) {
467
472
  rows.push(attentionRow(`model catalog cache unreadable — ${catalog.warning}; using vendored fallback (advisory — routing unchanged)`));
468
473
  }
469
- if (catalogRefreshWarning) {
470
- rows.push(attentionRow(`model catalog auto-refresh failed open — ${catalogRefreshWarning}; retained ${catalog.source} catalog`));
474
+ if (catalogRefreshLine) {
475
+ rows.push(attentionRow(`model catalog auto-refresh — ${catalogRefreshLine}`));
471
476
  }
472
477
  // v1.48 T1 / v1.86 T12: advisory sweep for known agent CLIs with no drive contract. Presence is
473
478
  // resolved through the worker's login shell; advisory targets are never executed or written to health.
@@ -3,7 +3,7 @@ import { dirname } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
4
  import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthExclusions } from "../../adapters/registry.js";
5
5
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, fleetUnclassifiedModels } from "../../adapters/model-lints.js";
6
- import { CATALOG_REFRESH_TIMEOUT_MS, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
6
+ import { CATALOG_REFRESH_TIMEOUT_MS, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
7
7
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
8
8
  import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, overlayBytesLoadError, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
9
9
  import { projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
@@ -14,6 +14,7 @@ import { route } from "../../route/router.js";
14
14
  import { disallowedBy } from "../../route/preference.js";
15
15
  import { resolveRunMode } from "../../run/daemon.js";
16
16
  import { loadRoutingProfile } from "../../run/journal.js";
17
+ const initialFetch = globalThis.fetch;
17
18
  const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
18
19
  const QUIT = "fleet: quit without writing";
19
20
  // v1.60 T3: every preview surface ranks with the SAME exploration setting as the candidate picker
@@ -98,16 +99,21 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
98
99
  const interactive = input.isTTY === true && output.isTTY === true;
99
100
  let catalog = readCachedCatalog(cwd, { now: io.catalogNow });
100
101
  let refreshReason = "";
101
- if (catalog.source === "cache" && catalog.stale) {
102
+ let catalogRefreshAttempted = false;
103
+ const catalogRefreshAllowed = process.env.VITEST !== "true"
104
+ || io.catalogFetcher !== undefined
105
+ || io.catalogNow !== undefined
106
+ || globalThis.fetch !== initialFetch;
107
+ if (catalog.stale && catalogRefreshAllowed) {
102
108
  const refreshed = await refreshCatalogCommand({
103
109
  repoRoot: cwd,
104
110
  fetcher: io.catalogFetcher,
105
111
  timeoutMs: CATALOG_REFRESH_TIMEOUT_MS,
106
112
  now: io.catalogNow,
107
113
  });
114
+ catalogRefreshAttempted = true;
108
115
  catalog = refreshed.catalog;
109
- if (refreshed.warning)
110
- refreshReason = `fleet: catalog auto-refresh failed open — ${refreshed.warning}; retained ${catalog.source} catalog`;
116
+ refreshReason = `fleet: catalog auto-refresh — ${formatCatalogRefreshLegs(refreshed.legs)}`;
111
117
  }
112
118
  if (print) {
113
119
  // v1.51 T4: the print surface names the mode and its source layer right under the header —
@@ -129,7 +135,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
129
135
  if (!why && interactive) {
130
136
  const { reuse } = initDoctorReuse(cwd, values.fresh ?? false);
131
137
  if (!reuse) {
132
- output.write(`${await doctor([], cwd, adapters, { banner: false, compact: true })}\n`);
138
+ output.write(`${await doctor([], cwd, adapters, { banner: false, compact: true, ...(catalogRefreshAttempted ? { catalog } : {}), catalogFetcher: io.catalogFetcher, catalogNow: io.catalogNow })}\n`);
133
139
  }
134
140
  }
135
141
  const assembled = await assembleFleetEditor(cwd, adapters, io, { globalDir, catalog });
@@ -11,6 +11,7 @@ import { BANNER, kvRow, legend, rule, statusRow, title } from "../../brand.js";
11
11
  import { tickmarkrDir } from "../../graph/graph.js";
12
12
  import { orcaHostDetected } from "../../drivers/index.js";
13
13
  import { Journal } from "../../run/journal.js";
14
+ import { runLockRunId, runStatusLine } from "../../run/lock.js";
14
15
  import { doctor } from "./doctor.js";
15
16
  import { assembleFleetEditor } from "./fleet.js";
16
17
  const SCAFFOLD_SPEC = "tickmarkr.spec.md";
@@ -24,20 +25,18 @@ const ENVIRONMENTS_FOOTER = [
24
25
  " claude code — tickmarkr init --agent installs the /tkr skills + AGENTS.md so Claude Code (or any agent CLI) drives the loop natively",
25
26
  " anywhere — no herdr or Orca terminal? same fail-closed gates, headless subprocess driver (legacy: no herdr? same fail-closed gates, headless subprocess driver when Orca markers are absent)",
26
27
  ].join("\n");
27
- /** Latest journal without a run-end event, if any. */
28
- function activeRunId(cwd) {
29
- const runId = Journal.latestRunId(cwd, { withJournal: true });
28
+ /** Current run truth, lock first; falls back to an abandoned journal. */
29
+ function activeRunLine(cwd) {
30
+ const locked = runLockRunId(cwd);
31
+ const runId = locked ?? Journal.latestRunId(cwd, { withJournal: true });
30
32
  if (!runId)
31
33
  return null;
34
+ let events = [];
32
35
  try {
33
- const events = Journal.open(cwd, runId).read();
34
- if (events.some((e) => e.event === "run-end"))
35
- return null;
36
- return runId;
37
- }
38
- catch {
39
- return null;
36
+ events = Journal.open(cwd, runId).read();
40
37
  }
38
+ catch { /* lock may exist before first journal row */ }
39
+ return runStatusLine(cwd, runId, events);
41
40
  }
42
41
  /** Relative paths of specs/*.spec.md already in the repo. */
43
42
  function existingSpecs(cwd) {
@@ -56,9 +55,9 @@ function existingSpecs(cwd) {
56
55
  }
57
56
  /** Context-aware next-steps line (operator-approved 2026-07-17). */
58
57
  function nextSteps(cwd, scaffoldedSpec) {
59
- const runId = activeRunId(cwd);
60
- if (runId)
61
- return `run ${runId} active — tickmarkr status`;
58
+ const runLine = activeRunLine(cwd);
59
+ if (runLine)
60
+ return `${runLine} — tickmarkr status`;
62
61
  const specs = existingSpecs(cwd);
63
62
  if (specs.length > 0) {
64
63
  const listed = specs.length <= 3 ? specs.join(", ") : `${specs.slice(0, 3).join(", ")}, …`;
@@ -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 [