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.
- package/dist/cli/commands/doctor.d.ts +4 -0
- package/dist/cli/commands/plan.js +144 -3
- package/dist/cli/commands/resume.js +2 -0
- package/dist/cli/commands/run.js +10 -0
- package/dist/compile/collateral.d.ts +2 -0
- package/dist/compile/collateral.js +50 -9
- package/dist/compile/ownership.d.ts +7 -0
- package/dist/compile/ownership.js +59 -22
- package/dist/config/config.d.ts +9 -0
- package/dist/config/config.js +13 -2
- package/dist/drivers/herdr.d.ts +5 -0
- package/dist/drivers/herdr.js +14 -3
- package/dist/drivers/types.d.ts +1 -0
- package/dist/gates/review.d.ts +35 -2
- package/dist/gates/review.js +64 -16
- package/dist/gates/run-gates.d.ts +2 -0
- package/dist/gates/run-gates.js +17 -8
- package/dist/route/router.d.ts +13 -0
- package/dist/route/router.js +64 -11
- package/dist/run/daemon.js +241 -29
- package/dist/run/git.d.ts +25 -0
- package/dist/run/git.js +68 -1
- package/dist/tui/cockpit/live-runtime.js +12 -10
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +21 -0
|
@@ -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,
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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) => {
|
package/dist/config/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/config.js
CHANGED
|
@@ -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] }
|
package/dist/drivers/herdr.d.ts
CHANGED
|
@@ -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>;
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -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
|
-
|
|
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");
|
package/dist/drivers/types.d.ts
CHANGED
|
@@ -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>;
|