tickmarkr 2.5.2 → 2.5.4

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 (41) hide show
  1. package/dist/adapters/qwen.d.ts +1 -0
  2. package/dist/adapters/qwen.js +8 -0
  3. package/dist/adapters/types.d.ts +1 -0
  4. package/dist/cli/commands/doctor.d.ts +10 -0
  5. package/dist/cli/commands/doctor.js +50 -1
  6. package/dist/cli/commands/plan.js +144 -3
  7. package/dist/cli/commands/resume.js +2 -0
  8. package/dist/cli/commands/run.js +12 -0
  9. package/dist/cli/commands/status.js +8 -14
  10. package/dist/compile/collateral.d.ts +2 -0
  11. package/dist/compile/collateral.js +50 -9
  12. package/dist/compile/ownership.d.ts +16 -0
  13. package/dist/compile/ownership.js +113 -13
  14. package/dist/config/config.d.ts +9 -0
  15. package/dist/config/config.js +13 -2
  16. package/dist/drivers/herdr.d.ts +5 -0
  17. package/dist/drivers/herdr.js +14 -3
  18. package/dist/drivers/types.d.ts +1 -0
  19. package/dist/gates/baseline.d.ts +5 -1
  20. package/dist/gates/baseline.js +18 -5
  21. package/dist/gates/llm.d.ts +1 -1
  22. package/dist/gates/llm.js +34 -12
  23. package/dist/gates/review.d.ts +46 -2
  24. package/dist/gates/review.js +111 -21
  25. package/dist/gates/run-gates.d.ts +2 -0
  26. package/dist/gates/run-gates.js +19 -10
  27. package/dist/route/router.d.ts +13 -0
  28. package/dist/route/router.js +64 -11
  29. package/dist/run/daemon.d.ts +3 -1
  30. package/dist/run/daemon.js +332 -53
  31. package/dist/run/git.d.ts +25 -0
  32. package/dist/run/git.js +68 -1
  33. package/dist/run/journal.js +16 -7
  34. package/dist/run/operator-state.d.ts +1 -1
  35. package/dist/run/operator-state.js +5 -9
  36. package/dist/tui/cockpit/live-runtime.js +12 -10
  37. package/package.json +1 -1
  38. package/skills/tickmarkr-auto/SKILL.md +1 -1
  39. package/skills/tickmarkr-loop/SKILL.md +1 -1
  40. package/skills/tickmarkr-overseer/SKILL.md +82 -4
  41. package/skills/tickmarkr-overseer/scripts/watch-launch.sh +38 -0
@@ -1,5 +1,6 @@
1
1
  import { type ClassifiedWorkerResult } from "./prompt.js";
2
2
  import { type WorkerAdapter } from "./types.js";
3
3
  export declare const QWEN_VERSION_IDENTITY: RegExp;
4
+ export declare const QWEN_HARNESS_BANNER_ROWS: readonly ["⚠ SAFE MODE — all customizations disabled (hooks, extensions, skills, MCP servers, QWEN.md). Restart without --safe-mode to resume normal operation.", "Warning: running headless with --yolo / approval-mode=yolo and no sandbox. All tool calls (shell, write, edit) auto-execute at this process's privilege level. Enable a sandbox via --sandbox / QWEN_SANDBOX, or set QWEN_CODE_SUPPRESS_YOLO_WARNING=1 to silence this notice."];
4
5
  export declare function parseQwenResult(raw: string, nonce: string): ClassifiedWorkerResult;
5
6
  export declare const qwen: WorkerAdapter;
@@ -3,6 +3,13 @@ import { parseWorkerResult } from "./prompt.js";
3
3
  import { channelsFromConfig, shq, } from "./types.js";
4
4
  export const QWEN_VERSION_IDENTITY = /^\d+\.\d+\.\d+/;
5
5
  const QWEN_SKIP_UPDATE = "QWEN_CODE_SKIP_UPDATE_CHECK_ONCE=true";
6
+ // Verbatim stderr from tests/fixtures/qwen/safe-mode.stderr (qwen 0.21.15). These bytes are launch
7
+ // harness, not evidence that a reviewer took a turn. Keep the declaration closed and row-shaped so
8
+ // llm.ts can sweep partial terminal paints without a broad SAFE MODE/warning regex.
9
+ export const QWEN_HARNESS_BANNER_ROWS = [
10
+ "⚠ SAFE MODE — all customizations disabled (hooks, extensions, skills, MCP servers, QWEN.md). Restart without --safe-mode to resume normal operation.",
11
+ "Warning: running headless with --yolo / approval-mode=yolo and no sandbox. All tool calls (shell, write, edit) auto-execute at this process's privilege level. Enable a sandbox via --sandbox / QWEN_SANDBOX, or set QWEN_CODE_SUPPRESS_YOLO_WARNING=1 to silence this notice.",
12
+ ];
6
13
  function decodeQwenEvents(events) {
7
14
  const text = [];
8
15
  let failed = false;
@@ -136,6 +143,7 @@ export const qwen = {
136
143
  channels: (cfg) => channelsFromConfig("qwen", cfg),
137
144
  hardcodedFlags: { binary: "qwen", flags: ["--safe-mode", "--approval-mode", "-m", "-o", "-p"] },
138
145
  headlessCommand: (promptFile, model) => `${QWEN_SKIP_UPDATE} qwen --safe-mode --approval-mode yolo -m ${shq(model)} -o json -p '' < ${shq(promptFile)}`,
146
+ harnessBannerRows: QWEN_HARNESS_BANNER_ROWS,
139
147
  // OBS-905: qwen has NO interactive form. The `-i "$(cat prompt)"` TUI launch put the whole prompt in
140
148
  // argv (the OBS-889 leak-and-census shape) and produced a rendered transcript the JSON decoder above
141
149
  // can never read — under the herdr driver every qwen task read "unparseable" and merged only by harvest.
@@ -171,6 +171,7 @@ export interface WorkerAdapter {
171
171
  probe(): Promise<AuthHealth>;
172
172
  channels(cfg: TickmarkrConfig): BillingChannel[];
173
173
  headlessCommand(promptFile: string, model: string): string;
174
+ harnessBannerRows?: readonly string[];
174
175
  interactiveCommand(promptFile: string, model: string): string | null;
175
176
  interactiveSeed?: InteractiveSeed;
176
177
  resumeCommand?(sessionId: string, promptFile: string, model: string): string;
@@ -8,6 +8,12 @@ import { type VitestListResult } from "../../gates/acceptance.js";
8
8
  * concatenation and publishes no index, so the release listing is the only enumerable surface. */
9
9
  export declare const LIVEBENCH_RELEASES_URL = "https://api.github.com/repos/LiveBench/livebench.github.io/contents/public";
10
10
  export declare const LIVEBENCH_TABLE_MAX_AGE_DAYS = 90;
11
+ type ReviewDemotionSummary = {
12
+ reviewer: string;
13
+ count: number;
14
+ causes: Record<string, number>;
15
+ };
16
+ export declare function recentReviewDemotions(cwd: string, lastRuns?: number): ReviewDemotionSummary[];
11
17
  export type DoctorOpts = {
12
18
  banner?: boolean;
13
19
  kimiTurnProbe?: (cwd: string) => Promise<KimiDoctorTurnResult>;
@@ -122,6 +128,7 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
122
128
  }>;
123
129
  floors: Record<string, "cheap" | "mid" | "frontier">;
124
130
  learned: "on" | "off";
131
+ escalateTier: "on" | "off";
125
132
  allowUnverifiedModels: boolean;
126
133
  mode?: "partner-led" | "risk-based" | "staff-led" | undefined;
127
134
  learnedTuning?: {
@@ -179,6 +186,7 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
179
186
  complexityThreshold: number;
180
187
  timeoutMs: number;
181
188
  required: boolean;
189
+ floor: "cheap" | "mid" | "frontier" | "worker";
182
190
  prefer?: string[] | undefined;
183
191
  policy?: "full" | "judge-only" | undefined;
184
192
  criticalPaths?: string[] | undefined;
@@ -239,6 +247,7 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
239
247
  }>;
240
248
  floors: Record<string, "cheap" | "mid" | "frontier">;
241
249
  learned: "on" | "off";
250
+ escalateTier: "on" | "off";
242
251
  allowUnverifiedModels: boolean;
243
252
  mode?: "partner-led" | "risk-based" | "staff-led" | undefined;
244
253
  learnedTuning?: {
@@ -296,6 +305,7 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
296
305
  complexityThreshold: number;
297
306
  timeoutMs: number;
298
307
  required: boolean;
308
+ floor: "cheap" | "mid" | "frontier" | "worker";
299
309
  prefer?: string[] | undefined;
300
310
  policy?: "full" | "judge-only" | undefined;
301
311
  criticalPaths?: string[] | undefined;
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, readFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { detectPackageManager, turboContinueFindings } from "../../gates/baseline.js";
6
6
  import { version } from "./version.js";
@@ -26,6 +26,46 @@ const initialFetch = globalThis.fetch;
26
26
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
27
27
  const alignedStatusRow = (verdict, key, value) => ` ${statusRow(verdict, kvRow(key, value).slice(2))}`;
28
28
  const attentionRow = (text) => ` ${statusRow("warn", text)}`;
29
+ // Use the routing profile's established 50-run recency horizon without importing journal machinery
30
+ // into doctor. Torn/malformed rows are ignored one at a time: diagnostics must survive a killed write.
31
+ export function recentReviewDemotions(cwd, lastRuns = 50) {
32
+ const runs = join(cwd, stateDirName(cwd), "runs");
33
+ if (!existsSync(runs))
34
+ return [];
35
+ const grouped = new Map();
36
+ const ids = readdirSync(runs, { withFileTypes: true })
37
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-")
38
+ && existsSync(join(runs, entry.name, "journal.jsonl")))
39
+ .map((entry) => entry.name).sort().slice(-lastRuns);
40
+ for (const id of ids) {
41
+ const lines = readFileSync(join(runs, id, "journal.jsonl"), "utf8").split("\n");
42
+ for (const line of lines) {
43
+ if (!line.trim())
44
+ continue;
45
+ let row;
46
+ try {
47
+ row = JSON.parse(line);
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ if (!row || typeof row !== "object")
53
+ continue;
54
+ const event = row;
55
+ if (event.event !== "review-pool-demotion" || !event.data || typeof event.data !== "object")
56
+ continue;
57
+ const data = event.data;
58
+ if (typeof data.reviewer !== "string" || !data.reviewer.trim())
59
+ continue;
60
+ const cause = typeof data.cause === "string" && data.cause.trim() ? data.cause : "unknown";
61
+ const summary = grouped.get(data.reviewer) ?? { reviewer: data.reviewer, count: 0, causes: {} };
62
+ summary.count++;
63
+ summary.causes[cause] = (summary.causes[cause] ?? 0) + 1;
64
+ grouped.set(data.reviewer, summary);
65
+ }
66
+ }
67
+ return [...grouped.values()].sort((a, b) => a.reviewer.localeCompare(b.reviewer));
68
+ }
29
69
  const ORCA_HOOK_ADAPTERS = {
30
70
  claude: "claude-code",
31
71
  "claude-code": "claude-code",
@@ -581,6 +621,15 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
581
621
  const healthy = h.installed && (a.id !== kimi.id || h.authed);
582
622
  return alignedStatusRow(healthy ? "pass" : "fail", a.id, state);
583
623
  });
624
+ const reviewDemotions = recentReviewDemotions(cwd);
625
+ if (reviewDemotions.length) {
626
+ rows.push(legend("recent review-seat demotions:"));
627
+ for (const demotion of reviewDemotions) {
628
+ const causes = Object.entries(demotion.causes)
629
+ .map(([cause, count]) => `${cause}${count === demotion.count ? "" : ` ×${count}`}`).join(", ");
630
+ rows.push(alignedStatusRow("warn", demotion.reviewer, `${demotion.count} review seat${demotion.count === 1 ? "" : "s"} demoted · cause ${causes}`));
631
+ }
632
+ }
584
633
  if (existsSync(graphPath(cwd))) {
585
634
  try {
586
635
  const graph = loadGraph(cwd);
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
4
  import { allAdapters, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, rolePools, servableExclusions, servabilityLine } from "../../adapters/registry.js";
@@ -11,11 +11,11 @@ import { graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.
11
11
  import { renderAcceptanceItem } from "../../graph/schema.js";
12
12
  import { resolveRunMode } from "../../run/daemon.js";
13
13
  import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
14
- import { staffLedEvidence } from "../../route/profile.js";
14
+ import { decayWeight, HALF_LIFE_RUNS, staffLedEvidence } from "../../route/profile.js";
15
15
  import { route, RoutingError } from "../../route/router.js";
16
16
  import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.js";
17
17
  import { modelId, modelProvider, pickReviewer } from "../../gates/review.js";
18
- import { Journal, loadRoutingProfile, recordedGraphDefinitionHash } from "../../run/journal.js";
18
+ import { Journal, loadRoutingProfile, readProfileCursor, recordedGraphDefinitionHash, RUNS_WINDOW } from "../../run/journal.js";
19
19
  import { harnessLine, resolveHarness } from "../harness.js";
20
20
  import { channelKey, shq } from "../../adapters/types.js";
21
21
  import { shGit } from "../../run/git.js";
@@ -292,6 +292,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
292
292
  }
293
293
  }
294
294
  }
295
+ lints.push(...tierEscalationAdvisories(cwd, cfg, g.tasks));
295
296
  for (const [role, sel] of [["judge", cfg.judge], ["consult", cfg.consult]]) {
296
297
  if (!health[sel.adapter]?.installed)
297
298
  lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
@@ -437,3 +438,143 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
437
438
  lines.push("", "scope lints:", ...scopeLints.map((l) => ` ! ${l}`));
438
439
  return stylizePlan(lines.join("\n"));
439
440
  }
441
+ function extractClimb(event, taskShapeMap) {
442
+ if (event.event !== "tier-escalated")
443
+ return undefined;
444
+ const d = (event.data && typeof event.data === "object") ? event.data : {};
445
+ const shape = (typeof d.shape === "string" && d.shape)
446
+ || (typeof event.shape === "string" && event.shape)
447
+ || (typeof event.taskId === "string" ? taskShapeMap.get(event.taskId) : undefined)
448
+ || (typeof d.taskId === "string" ? taskShapeMap.get(d.taskId) : undefined);
449
+ if (!shape)
450
+ return undefined;
451
+ const beforeAssignment = (d.beforeAssignment && typeof d.beforeAssignment === "object") ? d.beforeAssignment : undefined;
452
+ const afterAssignment = (d.afterAssignment && typeof d.afterAssignment === "object") ? d.afterAssignment : undefined;
453
+ let fromTier = (typeof d.fromTier === "string" && d.fromTier)
454
+ || (typeof d.from === "string" && d.from)
455
+ || (typeof d.tierBefore === "string" && d.tierBefore)
456
+ || (typeof d.before === "string" && d.before)
457
+ || (typeof d.previousTier === "string" && d.previousTier)
458
+ || (typeof event.fromTier === "string" && event.fromTier)
459
+ || (typeof event.from === "string" && event.from)
460
+ || (typeof beforeAssignment?.tier === "string" && beforeAssignment.tier);
461
+ let toTier = (typeof d.toTier === "string" && d.toTier)
462
+ || (typeof d.to === "string" && d.to)
463
+ || (typeof d.tierAfter === "string" && d.tierAfter)
464
+ || (typeof d.after === "string" && d.after)
465
+ || (typeof d.nextTier === "string" && d.nextTier)
466
+ || (typeof d.tier === "string" && d.tier)
467
+ || (typeof event.toTier === "string" && event.toTier)
468
+ || (typeof event.to === "string" && event.to)
469
+ || (typeof afterAssignment?.tier === "string" && afterAssignment.tier);
470
+ if (!fromTier || !toTier) {
471
+ const text = [d.details, d.message, d.reason, d.summary, d.provenance].filter((s) => typeof s === "string").join(" ");
472
+ const m = /\b(cheap|mid|frontier)\s+to\s+(cheap|mid|frontier)\b/i.exec(text);
473
+ if (m) {
474
+ fromTier = fromTier || m[1].toLowerCase();
475
+ toTier = toTier || m[2].toLowerCase();
476
+ }
477
+ }
478
+ if (!fromTier || !toTier)
479
+ return undefined;
480
+ return { shape, fromTier, toTier };
481
+ }
482
+ function tierEscalationAdvisories(cwd, cfg, graphTasks) {
483
+ const runsRoot = join(cwd, stateDirName(cwd), "runs");
484
+ if (!existsSync(runsRoot))
485
+ return [];
486
+ let candidateRunIds;
487
+ try {
488
+ candidateRunIds = readdirSync(runsRoot)
489
+ .filter((d) => d.startsWith("run-") && existsSync(join(runsRoot, d, "journal.jsonl")))
490
+ .sort();
491
+ }
492
+ catch {
493
+ return [];
494
+ }
495
+ const cursor = readProfileCursor(cwd);
496
+ if (cursor)
497
+ candidateRunIds = candidateRunIds.filter((id) => id > cursor);
498
+ const windowRunIds = candidateRunIds.slice(-RUNS_WINDOW);
499
+ if (!windowRunIds.length)
500
+ return [];
501
+ // Leg-2 T5 (OBS-971): task ids restart at T1 every spec, so a shape lookup is only valid against the
502
+ // graph the run actually executed. Fallback to the current graph only when the run left no snapshot.
503
+ const currentShapeMap = new Map(graphTasks.map((t) => [t.id, t.shape]));
504
+ const numDistinct = windowRunIds.length;
505
+ const ascIndex = new Map(windowRunIds.map((id, i) => [id, i]));
506
+ const halfLife = cfg.routing.learnedTuning?.halfLifeRuns ?? HALF_LIFE_RUNS;
507
+ const shapeClimbs = new Map();
508
+ for (const runId of windowRunIds) {
509
+ const journalPath = join(runsRoot, runId, "journal.jsonl");
510
+ let lines;
511
+ try {
512
+ lines = readFileSync(journalPath, "utf8").split("\n");
513
+ }
514
+ catch {
515
+ continue;
516
+ }
517
+ let taskShapeMap = currentShapeMap;
518
+ const runGraphPath = join(runsRoot, runId, "graph.json");
519
+ if (existsSync(runGraphPath)) {
520
+ try {
521
+ const runGraph = JSON.parse(readFileSync(runGraphPath, "utf8"));
522
+ if (Array.isArray(runGraph.tasks)) {
523
+ taskShapeMap = new Map();
524
+ for (const rt of runGraph.tasks) {
525
+ if (typeof rt?.id === "string" && typeof rt?.shape === "string")
526
+ taskShapeMap.set(rt.id, rt.shape);
527
+ }
528
+ }
529
+ }
530
+ catch {
531
+ // unreadable snapshot: fall back to the current graph
532
+ }
533
+ }
534
+ const runClimbedShapes = new Map();
535
+ for (const line of lines) {
536
+ if (!line.trim())
537
+ continue;
538
+ let parsed;
539
+ try {
540
+ parsed = JSON.parse(line);
541
+ }
542
+ catch {
543
+ continue;
544
+ }
545
+ if (!parsed || typeof parsed !== "object")
546
+ continue;
547
+ const event = parsed;
548
+ if (event.event !== "tier-escalated")
549
+ continue;
550
+ const climb = extractClimb(event, taskShapeMap);
551
+ if (climb) {
552
+ runClimbedShapes.set(climb.shape, { fromTier: climb.fromTier, toTier: climb.toTier });
553
+ }
554
+ }
555
+ const age = numDistinct - 1 - ascIndex.get(runId);
556
+ const w = decayWeight(age, halfLife);
557
+ for (const [shape, { fromTier, toTier }] of runClimbedShapes) {
558
+ let entry = shapeClimbs.get(shape);
559
+ if (!entry) {
560
+ entry = { fromTier, toTier, runs: new Set(), weighted: 0 };
561
+ shapeClimbs.set(shape, entry);
562
+ }
563
+ const tierRank = (t) => (t in TIER_RANK ? TIER_RANK[t] : 0);
564
+ if (tierRank(toTier) > tierRank(entry.toTier)) {
565
+ entry.toTier = toTier;
566
+ }
567
+ entry.runs.add(runId);
568
+ entry.weighted += w;
569
+ }
570
+ }
571
+ const advisories = [];
572
+ for (const shape of [...shapeClimbs.keys()].sort()) {
573
+ const info = shapeClimbs.get(shape);
574
+ const climbedRuns = info.runs.size;
575
+ const totalRuns = numDistinct;
576
+ const weightVal = Number.isInteger(info.weighted) ? info.weighted : Number(info.weighted.toFixed(2));
577
+ advisories.push(`${shape}: climbed from ${info.fromTier} to ${info.toTier} in ${climbedRuns} of ${totalRuns} runs (weighted ${weightVal}) — consider routing.floors.${shape}: ${info.toTier}`);
578
+ }
579
+ return advisories;
580
+ }
@@ -5,6 +5,7 @@ import { loadGraph } from "../../graph/graph.js";
5
5
  import { formatSummary, runDaemon } from "../../run/daemon.js";
6
6
  import { denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
7
7
  import { narrationSink, bindNarration } from "./run.js";
8
+ import { assertRefsWritable } from "../../run/git.js";
8
9
  const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
9
10
  && s.tipVerify !== "failed";
10
11
  export async function resume(argv, cwd = process.cwd()) {
@@ -36,6 +37,7 @@ export async function resume(argv, cwd = process.cwd()) {
36
37
  if (collisions.length) {
37
38
  throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
38
39
  }
40
+ await assertRefsWritable(cwd, "resume");
39
41
  const narrate = narrationSink(runId);
40
42
  const s = await runDaemon(cwd, {
41
43
  runId,
@@ -10,6 +10,7 @@ import { formatJournalNarration, loadRoutingProfile, newRunId } from "../../run/
10
10
  import { normalizeGateOutcome } from "../../run/outcome.js";
11
11
  import { GLYPHS, LIVE } from "../../brand.js";
12
12
  import { cellWidth, fitCells } from "../../tui/cockpit/width.js";
13
+ import { assertRefsWritable } from "../../run/git.js";
13
14
  // ── the operator event rail (v1.99 T2) ──────────────────────────────────────────────────────────
14
15
  // A run's TTY narration is an operator EVENT RAIL, not the journal dump it used to echo. The
15
16
  // repetitive worker-contact / worker-status polls and the ungated phase-start rows are the bulk of
@@ -35,6 +36,7 @@ const RAIL_TONES = {
35
36
  };
36
37
  /** Approval-close lifecycle labels extend the established closed rail vocabulary. */
37
38
  export const APPROVAL_RAIL_ROWS = {
39
+ "end-condition-held": { label: "close held", tone: "attention" },
38
40
  "approval-window-start": { label: "approval window", tone: "attention" },
39
41
  "approval-window-expired": { label: "approval window expired", tone: "attention" },
40
42
  "tip-verify-cancelled": { label: "tip verify cancelled", tone: "attention" },
@@ -60,10 +62,16 @@ export const RAIL_ROWS = {
60
62
  // run lifecycle
61
63
  "run-start": { label: "started", tone: "active" },
62
64
  "run-resume": { label: "resumed", tone: "active" },
65
+ "restore-rerouted": { label: "restore rerouted", tone: "attention" },
66
+ "tier-escalated": { label: "tier climbed", tone: "attention" },
63
67
  "resume-restore": { label: "restored", tone: "neutral" },
64
68
  // the operator's audited --graph-changed release: the resumed run journals it through this sink
65
69
  "graph-rehash": { label: "graph rehashed", tone: "attention" },
66
70
  "lock-reclaimed": { label: "lock reclaimed", tone: "neutral" },
71
+ // WB-1 (OBS-988): the daemon noticed its own cockpit die and what it did about it
72
+ "watch-board-lost": { label: "board lost", tone: "attention" },
73
+ "watch-board-reopened": { label: "board reopened", tone: "pass" },
74
+ "watch-board-reopen-failed": { label: "board reopen failed", tone: "fail" },
67
75
  "run-end": { label: "finished", tone: "neutral" },
68
76
  "tip-verify": { label: "tip verify", tone: "pass" },
69
77
  "tip-verify-failed": { label: "tip verify", tone: "fail" },
@@ -124,6 +132,7 @@ export const RAIL_ROWS = {
124
132
  "gate-result": { label: "gate", tone: "pass" },
125
133
  "baseline-wait": { label: "baseline wait", tone: "active" },
126
134
  "suite-budget": { label: "suite budget", tone: "attention" },
135
+ "gate-replayed": { label: "gate replayed", tone: "attention" },
127
136
  "gate-reused": { label: "gate reused", tone: "neutral" },
128
137
  "judge-retry": { label: "judge retry", tone: "attention" },
129
138
  "review-no-verdict": { label: "review unavailable", tone: "attention" },
@@ -213,6 +222,8 @@ const RAIL_SALIENT = [
213
222
  // both journal `gates: string[]` and neither states a scalar the ladder can pick up
214
223
  { key: "gates", render: (v) => (Array.isArray(v) && v.length > 0 ? `gates ${v.join(", ")}` : undefined) },
215
224
  { key: "channel", render: (v) => (typeof v === "string" ? `channel ${v}` : undefined) },
225
+ // the pane a board was lost on or reopened in — nothing on the ladder names it
226
+ { key: "pane", render: (v) => (typeof v === "string" ? `pane ${v}` : undefined) },
216
227
  { key: "status", render: (v) => (typeof v === "string" ? `status ${v}` : undefined) },
217
228
  { key: "cause", render: (v) => (typeof v === "string" ? `cause ${v}` : undefined) },
218
229
  { key: "silentMs", render: (v) => (typeof v === "number" ? `silent ${Math.round(v / 1000)}s` : undefined) },
@@ -491,6 +502,7 @@ export async function run(argv, cwd = process.cwd()) {
491
502
  if (lints.length)
492
503
  throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
493
504
  }
505
+ await assertRefsWritable(cwd, "run");
494
506
  // The run id is minted HERE rather than inside the daemon, because the narration sink has to know
495
507
  // which run it is narrating before the first event arrives (the daemon's `narrate` callback is
496
508
  // handed an event and nothing else, and `run-start` carries no run id). `runDaemon` uses the id
@@ -825,23 +825,17 @@ const liveness = (events, daemon, now = Date.now()) => {
825
825
  const state = daemon.state === "alive" ? "alive" : ended ? "finished" : "dead";
826
826
  return `last event ${age} ago · daemon pid ${daemon.pid} ${state}${cause ? ` · ${cause}` : ""}`;
827
827
  };
828
- // Resume's shared comparator remains the fail-closed baseline. Status may additionally accept the
829
- // daemon's audited graph-rehash release, but only when its `from` names that baseline identity (or
830
- // null for an explicitly released legacy journal) and its `to` names the graph loaded now.
828
+ // OBS-978: the one comparator resume, plan and the operator fold read decides the join — it already
829
+ // audits the rehash chain. When it binds and the journal holds a graph-rehash, the newest row is the
830
+ // audit that bound the loaded graph; rehashAt marks the facts recorded against a prior graph.
831
831
  const statusEngagement = (events, loadedHash) => {
832
- const baseline = engagementComparable(events, loadedHash);
832
+ if (!engagementComparable(events, loadedHash).comparable)
833
+ return { comparable: false };
833
834
  for (let i = events.length - 1; i >= 0; i--) {
834
- const event = events[i];
835
- if (event.event !== "graph-rehash")
836
- continue;
837
- const auditsBaseline = baseline.comparable || baseline.reason === "mismatch"
838
- ? event.data.from === baseline.recorded
839
- : event.data.from === null;
840
- return event.data.to === loadedHash && auditsBaseline
841
- ? { comparable: true, rehashAt: i }
842
- : { comparable: false };
835
+ if (events[i].event === "graph-rehash")
836
+ return { comparable: true, rehashAt: i };
843
837
  }
844
- return { comparable: baseline.comparable };
838
+ return { comparable: true };
845
839
  };
846
840
  // The journal's own reader rule (src/run/journal.ts readJsonl), applied to bytes already in hand:
847
841
  // skip blanks, drop a line that will not parse (a torn trailing write after a crash), keep the rest.
@@ -28,6 +28,8 @@ export declare function classifyScopeOffenders(taskId: string, hard: ReadonlyArr
28
28
  /**
29
29
  * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
30
30
  * Each line names the task id and at least one missing collateral test path.
31
+ * OBS-971: uncapped — every predicted path is listed, with direct importers of an owned source
32
+ * sorted first, followed by the total count. The runtime map (collateralHits) remains unchanged.
31
33
  */
32
34
  export declare function collateralLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
33
35
  /**
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
- import { extname, join, relative } from "node:path";
2
+ import { extname, join, posix, relative } from "node:path";
3
3
  import { filesGlob } from "../graph/files-glob.js";
4
4
  import { criticalPathHits, DEFAULT_CONFIG, DEFAULT_REVIEW_CRITICAL_PATHS, effectiveReviewPolicy, loadConfig, } from "../config/config.js";
5
5
  import { renderAcceptanceItem } from "../graph/schema.js";
@@ -156,20 +156,61 @@ export function classifyScopeOffenders(taskId, hard, predicted) {
156
156
  const missed = hard.filter((f) => !named.has(f));
157
157
  return { authoring: hard.length > 0 && missed.length === 0, predicted: hit, missed, repair: filesRepair(taskId, hit) };
158
158
  }
159
+ const moduleKey = (path) => path.replace(/\\/g, "/").replace(/\.(?:[cm]?[jt]sx?)$/, "");
160
+ function directImportSpecifiers(text) {
161
+ // Comments cannot create an edge. Keep strings intact because they are the import target.
162
+ const source = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
163
+ const specifiers = new Set();
164
+ for (const match of source.matchAll(/\bimport\s+(?:type\s+)?(?:[\w$*{},\s]+?\s+from\s+)?["']([^"']+)["']/g)) {
165
+ specifiers.add(match[1]);
166
+ }
167
+ for (const match of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) {
168
+ specifiers.add(match[1]);
169
+ }
170
+ return [...specifiers];
171
+ }
172
+ function directlyImports(testPath, testText, sourcePath) {
173
+ const target = moduleKey(sourcePath);
174
+ const testDir = posix.dirname(testPath.replace(/\\/g, "/"));
175
+ return directImportSpecifiers(testText).some((specifier) => {
176
+ const imported = specifier.startsWith(".")
177
+ ? posix.normalize(posix.join(testDir, specifier))
178
+ : specifier.startsWith("src/") ? specifier : "";
179
+ return imported !== "" && moduleKey(imported) === target;
180
+ });
181
+ }
159
182
  /**
160
183
  * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
161
184
  * Each line names the task id and at least one missing collateral test path.
185
+ * OBS-971: uncapped — every predicted path is listed, with direct importers of an owned source
186
+ * sorted first, followed by the total count. The runtime map (collateralHits) remains unchanged.
162
187
  */
163
188
  export function collateralLints(tasks, repoRoot) {
164
189
  const lines = [];
165
- for (const [id, hits] of collateralHits(tasks, repoRoot)) {
166
- const listed = hits.slice(0, MAX_HITS_PER_TASK).join(", ");
167
- // OBS-547: the cap hides names, never predictions. Say so, and say where the hidden ones surface —
168
- // a count with no route is exactly what left one run's victim unreadable.
169
- const tail = hits.length > MAX_HITS_PER_TASK
170
- ? ` (${hits.length} total; ${MAX_HITS_PER_TASK} shown, ${hits.length - MAX_HITS_PER_TASK} capped out of view`
171
- + ` but RETAINED for the scope gate — a matching scope red prints the hidden path with its files[] repair)`
172
- : "";
190
+ const hitsMap = collateralHits(tasks, repoRoot);
191
+ const read = makeReader(repoRoot);
192
+ const tasksById = new Map(tasks.map((t) => [t.id, t]));
193
+ for (const [id, hits] of hitsMap) {
194
+ const t = tasksById.get(id);
195
+ const files = (t?.files ?? []).map((f) => f.replace(/^\.\//, ""));
196
+ const ownedSources = files.filter(isSrcPath);
197
+ const direct = [];
198
+ const indirect = [];
199
+ for (const h of hits) {
200
+ const text = read(h) ?? "";
201
+ const isDirect = ownedSources.some((src) => directlyImports(h, text, src));
202
+ if (isDirect) {
203
+ direct.push(h);
204
+ }
205
+ else {
206
+ indirect.push(h);
207
+ }
208
+ }
209
+ direct.sort((a, b) => a.localeCompare(b));
210
+ indirect.sort((a, b) => a.localeCompare(b));
211
+ const ordered = [...direct, ...indirect];
212
+ const listed = ordered.join(", ");
213
+ const tail = hits.length > MAX_HITS_PER_TASK ? ` (${hits.length} total)` : "";
173
214
  lines.push(`${id}: likely collateral tests not in files[]: ${listed}${tail}`);
174
215
  }
175
216
  return lines;
@@ -13,6 +13,12 @@ export type OwnershipFinding = {
13
13
  taskIds: string[];
14
14
  corroboration?: OwnershipCorroboration;
15
15
  detail: string;
16
+ } | {
17
+ code: "unowned-shape-oracle";
18
+ taskId: string;
19
+ source: string;
20
+ oracle: string;
21
+ detail: string;
16
22
  } | {
17
23
  code: "test-path-outside-allowlist";
18
24
  taskId: string;
@@ -25,7 +31,17 @@ export type OwnershipFinding = {
25
31
  ownerTaskId: string;
26
32
  path: string;
27
33
  detail: string;
34
+ } | {
35
+ code: "unowned-source-of-owned-test";
36
+ taskId: string;
37
+ test: string;
38
+ source: string;
39
+ corroboration?: OwnershipCorroboration;
40
+ detail: string;
28
41
  };
42
+ export declare const SHAPE_ORACLES: readonly ["tests/run/narration.test.ts", "tests/cli/brand-surfaces.test.ts", "tests/run/notify-identity.test.ts", "tests/run/outcome-projections.test.ts", "tests/cockpit/setup.test.ts"];
43
+ export declare const SHAPE_ORACLE_SOURCES: readonly ["src/run/daemon.ts", "src/cli/commands/run.ts"];
44
+ export declare const SHAPE_ORACLE_MAP: Record<string, readonly string[]>;
29
45
  /**
30
46
  * Cross-task ownership evidence. Findings are data: this checker never throws or changes the graph;
31
47
  * the compile seam promotes only a corroborated unowned-test finding and reports every other shape.