tickmarkr 2.5.3 → 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.
@@ -128,6 +128,7 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
128
128
  }>;
129
129
  floors: Record<string, "cheap" | "mid" | "frontier">;
130
130
  learned: "on" | "off";
131
+ escalateTier: "on" | "off";
131
132
  allowUnverifiedModels: boolean;
132
133
  mode?: "partner-led" | "risk-based" | "staff-led" | undefined;
133
134
  learnedTuning?: {
@@ -185,6 +186,7 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
185
186
  complexityThreshold: number;
186
187
  timeoutMs: number;
187
188
  required: boolean;
189
+ floor: "cheap" | "mid" | "frontier" | "worker";
188
190
  prefer?: string[] | undefined;
189
191
  policy?: "full" | "judge-only" | undefined;
190
192
  criticalPaths?: string[] | undefined;
@@ -245,6 +247,7 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
245
247
  }>;
246
248
  floors: Record<string, "cheap" | "mid" | "frontier">;
247
249
  learned: "on" | "off";
250
+ escalateTier: "on" | "off";
248
251
  allowUnverifiedModels: boolean;
249
252
  mode?: "partner-led" | "risk-based" | "staff-led" | undefined;
250
253
  learnedTuning?: {
@@ -302,6 +305,7 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
302
305
  complexityThreshold: number;
303
306
  timeoutMs: number;
304
307
  required: boolean;
308
+ floor: "cheap" | "mid" | "frontier" | "worker";
305
309
  prefer?: string[] | undefined;
306
310
  policy?: "full" | "judge-only" | undefined;
307
311
  criticalPaths?: string[] | undefined;
@@ -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
@@ -61,10 +62,16 @@ export const RAIL_ROWS = {
61
62
  // run lifecycle
62
63
  "run-start": { label: "started", tone: "active" },
63
64
  "run-resume": { label: "resumed", tone: "active" },
65
+ "restore-rerouted": { label: "restore rerouted", tone: "attention" },
66
+ "tier-escalated": { label: "tier climbed", tone: "attention" },
64
67
  "resume-restore": { label: "restored", tone: "neutral" },
65
68
  // the operator's audited --graph-changed release: the resumed run journals it through this sink
66
69
  "graph-rehash": { label: "graph rehashed", tone: "attention" },
67
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" },
68
75
  "run-end": { label: "finished", tone: "neutral" },
69
76
  "tip-verify": { label: "tip verify", tone: "pass" },
70
77
  "tip-verify-failed": { label: "tip verify", tone: "fail" },
@@ -215,6 +222,8 @@ const RAIL_SALIENT = [
215
222
  // both journal `gates: string[]` and neither states a scalar the ladder can pick up
216
223
  { key: "gates", render: (v) => (Array.isArray(v) && v.length > 0 ? `gates ${v.join(", ")}` : undefined) },
217
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) },
218
227
  { key: "status", render: (v) => (typeof v === "string" ? `status ${v}` : undefined) },
219
228
  { key: "cause", render: (v) => (typeof v === "string" ? `cause ${v}` : undefined) },
220
229
  { key: "silentMs", render: (v) => (typeof v === "number" ? `silent ${Math.round(v / 1000)}s` : undefined) },
@@ -493,6 +502,7 @@ export async function run(argv, cwd = process.cwd()) {
493
502
  if (lints.length)
494
503
  throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
495
504
  }
505
+ await assertRefsWritable(cwd, "run");
496
506
  // The run id is minted HERE rather than inside the daemon, because the narration sink has to know
497
507
  // which run it is narrating before the first event arrives (the daemon's `narrate` callback is
498
508
  // handed an event and nothing else, and `run-start` carries no run id). `runDaemon` uses the id
@@ -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;
@@ -31,6 +31,13 @@ export type OwnershipFinding = {
31
31
  ownerTaskId: string;
32
32
  path: string;
33
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;
34
41
  };
35
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"];
36
43
  export declare const SHAPE_ORACLE_SOURCES: readonly ["src/run/daemon.ts", "src/cli/commands/run.ts"];
@@ -1,4 +1,4 @@
1
- import { readFileSync, readdirSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { basename, extname, join, posix } from "node:path";
3
3
  import { filesGlob } from "../graph/files-glob.js";
4
4
  import { collateralHits } from "./collateral.js";
@@ -17,21 +17,24 @@ export const SHAPE_ORACLE_MAP = {
17
17
  "src/run/daemon.ts": SHAPE_ORACLES,
18
18
  "src/cli/commands/run.ts": SHAPE_ORACLES,
19
19
  };
20
- // Anchored-glob only: a files[] entry touches a mapped source when it names the source (or its
21
- // extensionless stem) exactly, or when its glob's literal head — everything before the first
22
- // wildcard — is the stem plus a literal dot, i.e. the wildcard only ever spans the extension
23
- // ("src/run/daemon.*"). A broad multi-file glob like "src/**" that merely happens to cover the
24
- // source is an unrelated task casting a wide net, not one touching daemon narration — that
25
- // distinction is what broke every fixture using tests/fixtures/sample.prd.md's files: src/** task.
26
- function touchesSource(files, source) {
20
+ // A files[] entry touches a mapped shape-oracle source when it names it literally or by stem, or when
21
+ // the entry's glob — anchored or broad — matches the source AND the source exists in the repository
22
+ // being compiled: "src/run/**" owning this repository's daemon owns the five oracles, while the
23
+ // shipped sample PRD's "src/**" task in a repository holding no mapped source compiles clean.
24
+ // Anchored globs like "src/run/daemon.*" target the source specifically and touch it regardless.
25
+ function touchesSource(files, source, repoRoot) {
27
26
  const stem = source.replace(/\.(?:[cm]?[jt]sx?)$/, "");
28
27
  return files.some((entry) => {
29
28
  if (entry === source || entry === stem)
30
29
  return true;
31
30
  const special = entry.search(/[*?{[]/);
32
- // Leg-2 v2.5.3: the anchored head is necessary, not sufficient — the pattern must also MATCH the
33
- // source under the shared matcher, or "src/run/daemon.{js,jsx}" (never daemon.ts) would count as a touch.
34
- return special !== -1 && entry.slice(0, special) === `${stem}.` && filesGlob([entry])(source);
31
+ if (special === -1)
32
+ return false;
33
+ if (!filesGlob([entry])(source))
34
+ return false;
35
+ if (entry.slice(0, special) === `${stem}.`)
36
+ return true;
37
+ return repoRoot !== undefined && existsSync(join(repoRoot, source));
35
38
  });
36
39
  }
37
40
  const normalize = (path) => path.replace(/^\.\//, "").split("\\").join("/");
@@ -133,20 +136,24 @@ function mentionsCommandEntry(text) {
133
136
  return /(?:^|\/)src\/cli\/index\.(?:ts|js)\b/.test(text)
134
137
  || /["'`]src["'`]\s*,\s*["'`]cli["'`]\s*,\s*["'`]index\.(?:ts|js)["'`]/.test(text);
135
138
  }
136
- function corroboration(test, matches) {
137
- // A .test.ts-shaped collateral fixture is not by itself a dedicated test. Requiring a runner leaf
138
- // keeps import-only scan fixtures advisory while every executable subject in the measured union stays.
139
+ function corroborateSource(test, sourcePath) {
139
140
  const executable = test.text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
140
141
  if (!/\b(?:test|it)(?:\.(?:concurrent|each|fails|only|skip|todo))*\s*\(/.test(executable))
141
142
  return undefined;
142
- for (const match of matches) {
143
- if (directlyImports(test, match.source))
144
- return { kind: "direct-import", source: match.source };
145
- }
143
+ if (directlyImports(test, sourcePath))
144
+ return { kind: "direct-import", source: sourcePath };
146
145
  if (invokesChildProcessSpawn(test.text) && mentionsCommandEntry(test.text)) {
147
- const command = matches.find(({ source }) => /^src\/cli\/commands\/[^/]+\.(?:[cm]?[jt]sx?)$/.test(source));
148
- if (command)
149
- return { kind: "command-entry-spawn", source: command.source, entry: "src/cli/index.ts" };
146
+ if (/^src\/cli\/commands\/[^/]+\.(?:[cm]?[jt]sx?)$/.test(sourcePath)) {
147
+ return { kind: "command-entry-spawn", source: sourcePath, entry: "src/cli/index.ts" };
148
+ }
149
+ }
150
+ return undefined;
151
+ }
152
+ function corroboration(test, matches) {
153
+ for (const match of matches) {
154
+ const evidence = corroborateSource(test, match.source);
155
+ if (evidence)
156
+ return evidence;
150
157
  }
151
158
  return undefined;
152
159
  }
@@ -239,7 +246,7 @@ export function ownershipFindings(tasks, repoRoot) {
239
246
  }
240
247
  for (const entry of indexed) {
241
248
  for (const [source, oracles] of Object.entries(SHAPE_ORACLE_MAP)) {
242
- if (!touchesSource(entry.files, source))
249
+ if (!touchesSource(entry.files, source, repoRoot))
243
250
  continue;
244
251
  for (const oracle of oracles) {
245
252
  if (!entry.owns(oracle)) {
@@ -254,6 +261,35 @@ export function ownershipFindings(tasks, repoRoot) {
254
261
  }
255
262
  }
256
263
  }
264
+ for (const test of sources) {
265
+ const testOwners = owners(test.path);
266
+ if (testOwners.length === 0)
267
+ continue;
268
+ const testStem = basename(test.path).replace(/\.test\.ts$/, "");
269
+ for (const sourcePath of allSources) {
270
+ if (owners(sourcePath).length > 0)
271
+ continue;
272
+ const sourceStem = basename(sourcePath, extname(sourcePath));
273
+ if (testStem !== sourceStem && !testStem.startsWith(`${sourceStem}-`))
274
+ continue;
275
+ const evidence = corroborateSource(test, sourcePath);
276
+ if (!evidence)
277
+ continue;
278
+ for (const owner of testOwners) {
279
+ findings.push({
280
+ code: "unowned-source-of-owned-test",
281
+ taskId: owner.task.id,
282
+ test: test.path,
283
+ source: sourcePath,
284
+ corroboration: evidence,
285
+ detail: `${test.path} owned by ${owner.task.id} is a dedicated test of ${sourcePath} but no task owns ${sourcePath}`
286
+ + (evidence.kind === "direct-import" ? `; it imports ${evidence.source} directly`
287
+ : evidence.kind === "command-entry-spawn"
288
+ ? `; it spawns ${evidence.entry} to exercise ${evidence.source}` : ""),
289
+ });
290
+ }
291
+ }
292
+ }
257
293
  for (const source of sources) {
258
294
  for (const owner of owners(source.path)) {
259
295
  for (const path of repositoryPaths(source.text)) {
@@ -290,6 +326,7 @@ export function ownershipFindings(tasks, repoRoot) {
290
326
  case "unowned-shape-oracle": return f.oracle;
291
327
  case "test-path-outside-allowlist": return f.test;
292
328
  case "unordered-context-write": return f.path;
329
+ case "unowned-source-of-owned-test": return f.test;
293
330
  }
294
331
  };
295
332
  return findings.sort((a, b) => {
@@ -175,6 +175,10 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
175
175
  on: "on";
176
176
  off: "off";
177
177
  }>;
178
+ escalateTier: z.ZodDefault<z.ZodEnum<{
179
+ on: "on";
180
+ off: "off";
181
+ }>>;
178
182
  learnedTuning: z.ZodOptional<z.ZodObject<{
179
183
  halfLifeRuns: z.ZodOptional<z.ZodNumber>;
180
184
  availWeight: z.ZodOptional<z.ZodNumber>;
@@ -275,6 +279,11 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
275
279
  "judge-only": "judge-only";
276
280
  }>>;
277
281
  criticalPaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
282
+ floor: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<"worker">, z.ZodEnum<{
283
+ cheap: "cheap";
284
+ mid: "mid";
285
+ frontier: "frontier";
286
+ }>]>>;
278
287
  }, z.core.$strip>;
279
288
  consult: z.ZodObject<{
280
289
  adapter: z.ZodString;
@@ -302,6 +302,8 @@ export const TickmarkrConfigSchema = z.object({
302
302
  }),
303
303
  floors: z.record(z.string(), TierEnum),
304
304
  learned: z.enum(["on", "off"]), // v1.6 ROUTE-09 kill switch; a typo (offf) fails loud via safeParse
305
+ // OBS-986 (ES-1): climb one tier on request when untried channels exist in higher tiers.
306
+ escalateTier: z.enum(["on", "off"]).default("on"),
305
307
  // v1.9 ROUTE-15 — optional overrides for profile.ts HALF_LIFE_RUNS/AVAIL_WEIGHT; absent ⇒ byte-identical defaults.
306
308
  // SIBLING of learned (not nested): routing.learned is the on/off enum switch.
307
309
  learnedTuning: z.object({
@@ -366,6 +368,11 @@ export const TickmarkrConfigSchema = z.object({
366
368
  // R3: globs no task may skip review on (input/demux/lifecycle, gates/run/drivers/adapters, anything
367
369
  // reaching a shell). A judge-only task intersecting one of these fails COMPILE, never silently skips.
368
370
  criticalPaths: z.array(z.string()).optional(),
371
+ // RF-1 (OBS-922 add.2/3): review.floor — `worker` (default) names no tier and leaves the seat
372
+ // ranking to the author's routed seat; a tier is folded in as max(task floor, this) by reviewGate
373
+ // and its retry — it can raise a seat and never lower one. Not in the template: the cockpit
374
+ // fixture pins the fresh-install bytes (see d0e89a9a).
375
+ floor: z.union([z.literal("worker"), TierEnum]).default("worker"),
369
376
  }),
370
377
  // v1.54 T1: prefer — ranked consult seat failover. Entries MUST be adapter:model (unlike
371
378
  // review.prefer's adapter|adapter:model grammar): a consult seat has no channel to inherit a
@@ -445,6 +452,7 @@ export const DEFAULT_CONFIG = {
445
452
  // a workspace that has accumulated ≥MIN_SAMPLES warm telemetry per cell. Preview any workspace's effect
446
453
  // first with `tickmarkr plan` / `tickmarkr report`; flip to "off" to pin exact static routing (the kill switch stands).
447
454
  learned: "on",
455
+ escalateTier: "on",
448
456
  allowUnverifiedModels: false,
449
457
  },
450
458
  // Seed table (spec §13). New models = edit this (or your config.yaml), never code.
@@ -590,7 +598,7 @@ export const DEFAULT_CONFIG = {
590
598
  judge: { adapter: "claude-code", model: "fable" },
591
599
  // R3: no `policy` floor — the neutral floor leaves the compiler's per-task assignment standing, so
592
600
  // the path-keyed rule is reachable out of the box rather than raised to full by construction.
593
- review: { complexityThreshold: 7, timeoutMs: 900_000, required: true, criticalPaths: [...DEFAULT_REVIEW_CRITICAL_PATHS] },
601
+ review: { complexityThreshold: 7, timeoutMs: 900_000, required: true, floor: "worker", criticalPaths: [...DEFAULT_REVIEW_CRITICAL_PATHS] },
594
602
  consult: { adapter: "claude-code", model: "fable", stallMinutes: 15 },
595
603
  // v1.4: gate LLM calls (judge/review/consult) run headless by default; pane opts back into visible agents.
596
604
  // v1.2: workers are the real agent TUI in the pane; "print" restores the -p-rendered-in-pane path.
@@ -784,6 +792,7 @@ export function configTemplate(overlay) {
784
792
  # floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
785
793
  # migration: frontier
786
794
  # learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'tickmarkr plan'
795
+ # escalateTier: on # on | off (default on): climb one tier on request when untried channels exist in higher tiers
787
796
  # learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
788
797
  # explore: { mode: on, excludeShapes: [], excludeComplexityAtOrAbove: null, cap: 5 } # optional; absent ⇒ byte-identical
789
798
  # sla: { implement: 15 } # optional per-shape minutes — advisory plan lint only; absent ⇒ no lint
@@ -817,7 +826,9 @@ export function configTemplate(overlay) {
817
826
  # test: npm test
818
827
  # byShape:
819
828
  # docs: { acceptance: false, review: false } # baseline, evidence, and scope are mandatory
820
- # review: { complexityThreshold: 7, timeoutMs: 900000, required: true, prefer: [codex:gpt-5.6-sol, kimi] }
829
+ # review: { complexityThreshold: 7, timeoutMs: 900000, required: true, floor: worker, prefer: [codex:gpt-5.6-sol, kimi] }
830
+ # # floor: worker (default) | cheap | mid | frontier — the reviewer seats at or above
831
+ # # max(author tier, task floor, this tier, prior reviewer); a tier here only raises it
821
832
  # # prefer: ordered reviewer seat preference (adapter | adapter:model); ranks
822
833
  # # diversity-eligible channels only — never admits a same-vendor/same-model reviewer
823
834
  # consult: { adapter: claude-code, model: fable, stallMinutes: 15, prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] }
@@ -128,6 +128,11 @@ export declare class HerdrDriver implements ExecutorDriver {
128
128
  close(slot: Slot): Promise<void>;
129
129
  private closeGrouped;
130
130
  private watchPanes;
131
+ /** WB-1 (OBS-988): the daemon proved this run's own board lost. Forget the cached slot FIRST — even a
132
+ * retire that fails must never let the narrator answer with the ghost again — then take the pane
133
+ * back on ownership alone: the dead UI can never acknowledge, so the stop request is left for a
134
+ * merely stuck one to find. Serialized with the narrator so no split races the close. */
135
+ retireLostWatch(slot: Slot): Promise<void>;
131
136
  /** Name collisions never confer repository ownership. Unknown boards stay protected. */
132
137
  private retireWatch;
133
138
  focus(target: FocusTarget): Promise<FocusResult>;
@@ -6,7 +6,7 @@ import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, m
6
6
  import { consumePaneLaunchIntent, PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
7
7
  import { createWorktree, sh } from "../run/git.js";
8
8
  import { Journal } from "../run/journal.js";
9
- import { readSupervision, readWatchBoard, reserveWatchBoard, stopWatchBoard, WATCH_OWNER_ENV } from "../run/supervision.js";
9
+ import { readSupervision, readWatchBoard, requestWatchBoardStop, reserveWatchBoard, stopWatchBoard, WATCH_OWNER_ENV } from "../run/supervision.js";
10
10
  import { herdrSealShellPrefix } from "./subprocess.js";
11
11
  import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
12
12
  // VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
@@ -1169,8 +1169,16 @@ export class HerdrDriver {
1169
1169
  throw new Error("herdr pane list returned no panes");
1170
1170
  return panes.filter(p => p.workspace_id === this.ws && p.label === name);
1171
1171
  }
1172
+ /** WB-1 (OBS-988): the daemon proved this run's own board lost. Forget the cached slot FIRST — even a
1173
+ * retire that fails must never let the narrator answer with the ghost again — then take the pane
1174
+ * back on ownership alone: the dead UI can never acknowledge, so the stop request is left for a
1175
+ * merely stuck one to find. Serialized with the narrator so no split races the close. */
1176
+ async retireLostWatch(slot) {
1177
+ this.watches.delete(slot.name);
1178
+ await this.serial(() => this.retireWatch(slot, true));
1179
+ }
1172
1180
  /** Name collisions never confer repository ownership. Unknown boards stay protected. */
1173
- async retireWatch(slot) {
1181
+ async retireWatch(slot, lost = false) {
1174
1182
  const runId = parseOwnedName(slot.name)?.runId;
1175
1183
  const owner = runId ? readWatchBoard(slot.cwd, runId) : undefined;
1176
1184
  const matches = await this.watchPanes(slot.name);
@@ -1181,7 +1189,10 @@ export class HerdrDriver {
1181
1189
  owner.pane !== slot.id || matches.length !== 1 || matches[0]?.pane_id !== owner.pane) {
1182
1190
  throw new Error(`watch ownership unknown or foreign for ${slot.name}; existing board protected`);
1183
1191
  }
1184
- await stopWatchBoard(owner, this.time);
1192
+ if (lost)
1193
+ requestWatchBoardStop(owner); // a lost owner never answers; only a live one still gets the ack window
1194
+ else
1195
+ await stopWatchBoard(owner, this.time);
1185
1196
  const verified = await this.watchPanes(slot.name);
1186
1197
  if (verified.length !== 1 || verified[0]?.pane_id !== owner.pane)
1187
1198
  throw new Error("watch target changed after acknowledgement; pane protected");
@@ -106,6 +106,7 @@ export interface ExecutorDriver {
106
106
  narrateWith?(narrate: (event: JournalEvent) => void): void;
107
107
  worktree(repo: string, branch: string, baseRef: string): Promise<string>;
108
108
  narrator?: (cwd: string, command: string, runId?: string) => Promise<Slot>;
109
+ retireLostWatch?: (slot: Slot) => Promise<void>;
109
110
  /** Best-effort projection of a task's lifecycle onto the execution host. */
110
111
  project?: (taskId: string, state: "in-progress" | "in-review" | "completed") => Promise<void>;
111
112
  reconcile?: (desired: Set<string>, runId: string, opts?: PanesToCloseOpts) => Promise<void>;