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.
- package/dist/adapters/qwen.d.ts +1 -0
- package/dist/adapters/qwen.js +8 -0
- package/dist/adapters/types.d.ts +1 -0
- package/dist/cli/commands/doctor.d.ts +10 -0
- package/dist/cli/commands/doctor.js +50 -1
- package/dist/cli/commands/plan.js +144 -3
- package/dist/cli/commands/resume.js +2 -0
- package/dist/cli/commands/run.js +12 -0
- package/dist/cli/commands/status.js +8 -14
- package/dist/compile/collateral.d.ts +2 -0
- package/dist/compile/collateral.js +50 -9
- package/dist/compile/ownership.d.ts +16 -0
- package/dist/compile/ownership.js +113 -13
- 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/baseline.d.ts +5 -1
- package/dist/gates/baseline.js +18 -5
- package/dist/gates/llm.d.ts +1 -1
- package/dist/gates/llm.js +34 -12
- package/dist/gates/review.d.ts +46 -2
- package/dist/gates/review.js +111 -21
- package/dist/gates/run-gates.d.ts +2 -0
- package/dist/gates/run-gates.js +19 -10
- package/dist/route/router.d.ts +13 -0
- package/dist/route/router.js +64 -11
- package/dist/run/daemon.d.ts +3 -1
- package/dist/run/daemon.js +332 -53
- package/dist/run/git.d.ts +25 -0
- package/dist/run/git.js +68 -1
- package/dist/run/journal.js +16 -7
- package/dist/run/operator-state.d.ts +1 -1
- package/dist/run/operator-state.js +5 -9
- package/dist/tui/cockpit/live-runtime.js +12 -10
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +1 -1
- package/skills/tickmarkr-loop/SKILL.md +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +82 -4
- package/skills/tickmarkr-overseer/scripts/watch-launch.sh +38 -0
package/dist/adapters/qwen.d.ts
CHANGED
|
@@ -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;
|
package/dist/adapters/qwen.js
CHANGED
|
@@ -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.
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -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,
|
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
|
|
@@ -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
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
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
|
-
|
|
832
|
+
if (!engagementComparable(events, loadedHash).comparable)
|
|
833
|
+
return { comparable: false };
|
|
833
834
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
834
|
-
|
|
835
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|
|
@@ -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.
|