tickmarkr 1.52.0 → 1.54.0
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/kimi.d.ts +1 -0
- package/dist/adapters/kimi.js +24 -0
- package/dist/adapters/model-lints.d.ts +1 -0
- package/dist/adapters/model-lints.js +27 -0
- package/dist/adapters/types.d.ts +2 -0
- package/dist/cli/commands/fleet.js +69 -12
- package/dist/cli/commands/plan.js +7 -5
- package/dist/cli/commands/report.js +14 -0
- package/dist/cli/commands/run.js +2 -0
- package/dist/cli/commands/status.js +5 -2
- package/dist/compile/collateral.d.ts +7 -1
- package/dist/compile/collateral.js +76 -19
- package/dist/config/config.d.ts +2 -0
- package/dist/config/config.js +23 -4
- package/dist/gates/review.d.ts +2 -1
- package/dist/gates/review.js +13 -4
- package/dist/run/consult.d.ts +3 -0
- package/dist/run/consult.js +78 -48
- package/dist/run/daemon.d.ts +2 -0
- package/dist/run/daemon.js +110 -14
- package/dist/run/git.js +31 -2
- package/package.json +1 -1
package/dist/adapters/kimi.d.ts
CHANGED
|
@@ -2,4 +2,5 @@ import { type WorkerAdapter, type WorkerResult } from "./types.js";
|
|
|
2
2
|
export declare function kimiAuthed(credentialsText: string, nowMs: number): boolean;
|
|
3
3
|
export declare function parseKimiModels(raw: string): string[];
|
|
4
4
|
export declare function parseKimiResult(raw: string, nonce: string): WorkerResult;
|
|
5
|
+
export declare function kimiSessionId(output: string): string | undefined;
|
|
5
6
|
export declare const kimi: WorkerAdapter;
|
package/dist/adapters/kimi.js
CHANGED
|
@@ -46,6 +46,21 @@ export function parseKimiResult(raw, nonce) {
|
|
|
46
46
|
const stripped = raw.split("\n").map((l) => l.replace(/^[\s]*[•*-]\s+/, "")).join("\n");
|
|
47
47
|
return parseWorkerResult(stripped, nonce);
|
|
48
48
|
}
|
|
49
|
+
// v1.53 T3: session-id capture from the run-output trailer — every `kimi -p` run (fresh or resumed)
|
|
50
|
+
// ends with `To resume this session: kimi -r session_<uuid>` (live probe 2026-07-18). Anchored full
|
|
51
|
+
// line only: prompt/model prose can contain lookalike text, and the anchored charset keeps a
|
|
52
|
+
// captured id shell-safe by construction (shq in resumeCommand is the second layer). Last valid
|
|
53
|
+
// line wins — a run may echo stale resume lines mid-transcript.
|
|
54
|
+
const RESUME_TRAILER_RE = /^\s*To resume this session: kimi -r (session_[0-9a-f-]+)\s*$/;
|
|
55
|
+
export function kimiSessionId(output) {
|
|
56
|
+
let id;
|
|
57
|
+
for (const line of output.split("\n")) {
|
|
58
|
+
const m = RESUME_TRAILER_RE.exec(line);
|
|
59
|
+
if (m)
|
|
60
|
+
id = m[1];
|
|
61
|
+
}
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
49
64
|
export const kimi = {
|
|
50
65
|
id: "kimi",
|
|
51
66
|
vendor: "moonshot",
|
|
@@ -73,6 +88,15 @@ export const kimi = {
|
|
|
73
88
|
// ("unknown command '…'", live-verified 2026-07-17, OBS-67) and -p is non-interactive-only.
|
|
74
89
|
// null → the daemon's print fallback (types.ts:101) keeps kimi workers visible without a TUI.
|
|
75
90
|
interactiveCommand: () => null,
|
|
91
|
+
// v1.53 T3 resume — live-probed 2026-07-18: `-p` + `-S <id>` compose cleanly (no OBS-67-class
|
|
92
|
+
// flag rejection) and the resumed session carries prior conversation state. `-S <id>` is the
|
|
93
|
+
// deterministic form; `-c` rejected as primary — cwd-keyed, nondeterministic under worktree
|
|
94
|
+
// recreation (probe finding 4). Never bare `-S` (it launches the interactive session picker).
|
|
95
|
+
resumeCommand: (sessionId, promptFile, model) => `kimi -S ${shq(sessionId)} -p "$(cat ${shq(promptFile)})" --model ${shq(model)} --output-format text`,
|
|
96
|
+
sessionIdFrom: kimiSessionId,
|
|
97
|
+
// KIMI-03: no contextUsage surface exists, so a known-context requirement would leave resume
|
|
98
|
+
// dead code — declare the unknown-context opt-in the daemon retry seam enforces.
|
|
99
|
+
resumeUnknownContext: true,
|
|
76
100
|
invoke(task, _cwd, a, ctx) {
|
|
77
101
|
return { command: this.headlessCommand(ctx.promptFile, a.model) };
|
|
78
102
|
},
|
|
@@ -17,6 +17,7 @@ export type RoutedAssignment = {
|
|
|
17
17
|
/** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
|
|
18
18
|
export declare function contextWindowLints(tasks: ReadonlyArray<Task>, assignments: ReadonlyArray<RoutedAssignment>, cfg: TickmarkrConfig, repoRoot: string): string[];
|
|
19
19
|
export declare function seedPreferLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
|
|
20
|
+
export declare function preferEntryLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, mapShapes?: ReadonlySet<string>): string[];
|
|
20
21
|
export declare function modelLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
|
|
21
22
|
tty?: boolean;
|
|
22
23
|
stateDir?: string;
|
|
@@ -95,6 +95,33 @@ export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new
|
|
|
95
95
|
}
|
|
96
96
|
return lints;
|
|
97
97
|
}
|
|
98
|
+
// v1.54 T3: dead-steering sweep — operator prefer entries (routing.map overlay shapes, review.prefer,
|
|
99
|
+
// consult.prefer) that can never match an installed channel are named at plan time; v1.53 T2 pins the
|
|
100
|
+
// no-match case as a silent no-op, which makes a typo invisible. Advisory only: reads config + doctor
|
|
101
|
+
// health (no live probes), never touches routing. Seed map prefers stay seedPreferLints' turf (auto-prefer
|
|
102
|
+
// routes around dead seeds) — mapShapes limits this sweep to operator-authored entries so a bare default
|
|
103
|
+
// fleet isn't double-linted. Entry grammar mirrors preferIndex: adapter | adapter:model (first colon).
|
|
104
|
+
export function preferEntryLints(cfg, health, mapShapes = new Set()) {
|
|
105
|
+
const lints = [];
|
|
106
|
+
const sweep = (surface, entries) => {
|
|
107
|
+
for (const entry of entries ?? []) {
|
|
108
|
+
const i = entry.indexOf(":");
|
|
109
|
+
const adapter = i < 0 ? entry : entry.slice(0, i);
|
|
110
|
+
const model = i < 0 ? undefined : entry.slice(i + 1);
|
|
111
|
+
if (!health[adapter]?.installed) {
|
|
112
|
+
lints.push(`${surface} '${entry}' names uninstalled adapter '${adapter}' — dead steering (entry can never match)`);
|
|
113
|
+
}
|
|
114
|
+
else if (model !== undefined && !(model in (cfg.tiers[adapter]?.models ?? {}))) {
|
|
115
|
+
lints.push(`${surface} '${entry}' names model '${model}' absent from ${adapter}'s configured channels — dead steering (entry can never match)`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
for (const shape of mapShapes)
|
|
120
|
+
sweep(`routing.map.${shape}.prefer`, cfg.routing.map[shape]?.prefer);
|
|
121
|
+
sweep("review.prefer", cfg.review.prefer);
|
|
122
|
+
sweep("consult.prefer", cfg.consult.prefer);
|
|
123
|
+
return lints;
|
|
124
|
+
}
|
|
98
125
|
// Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
|
|
99
126
|
// No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
|
|
100
127
|
// modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -79,6 +79,8 @@ export interface WorkerAdapter {
|
|
|
79
79
|
headlessCommand(promptFile: string, model: string): string;
|
|
80
80
|
interactiveCommand(promptFile: string, model: string): string | null;
|
|
81
81
|
resumeCommand?(sessionId: string, promptFile: string, model: string): string;
|
|
82
|
+
sessionIdFrom?(output: string): string | undefined;
|
|
83
|
+
resumeUnknownContext?: boolean;
|
|
82
84
|
invoke(task: Task, cwd: string, a: Assignment, ctx: {
|
|
83
85
|
promptFile: string;
|
|
84
86
|
}): Invocation;
|
|
@@ -140,6 +140,9 @@ function openTerm(input, output) {
|
|
|
140
140
|
finally {
|
|
141
141
|
rl.close();
|
|
142
142
|
setRaw(true);
|
|
143
|
+
// rl.close() pauses the input stream — without a resume the next key() await has
|
|
144
|
+
// nothing keeping the event loop alive and the process silently exits 0 (OBS-77)
|
|
145
|
+
input.resume();
|
|
143
146
|
queue.splice(typedFrom);
|
|
144
147
|
prevLines = 0;
|
|
145
148
|
}
|
|
@@ -191,9 +194,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
191
194
|
const health = cached;
|
|
192
195
|
const term = openTerm(input, output);
|
|
193
196
|
try {
|
|
194
|
-
// step 1/
|
|
197
|
+
// step 1/6 — probe data
|
|
195
198
|
const ageLine = formatAge(doctorAgeMs(cwd));
|
|
196
|
-
term.frame(listFrame("step 1/
|
|
199
|
+
term.frame(listFrame("step 1/6 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
|
|
197
200
|
for (;;) {
|
|
198
201
|
const k = await term.key();
|
|
199
202
|
if (isAbort(k))
|
|
@@ -204,7 +207,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
204
207
|
if (isNext(k))
|
|
205
208
|
break;
|
|
206
209
|
}
|
|
207
|
-
// step 2/
|
|
210
|
+
// step 2/6 — agent CLIs (space toggles adapter deny)
|
|
208
211
|
const installed = adapters.filter((a) => health[a.id]?.installed);
|
|
209
212
|
const adapterRows = () => installed.map((a) => {
|
|
210
213
|
const h = health[a.id];
|
|
@@ -213,7 +216,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
213
216
|
});
|
|
214
217
|
const listLegend = "↑↓/jk move · space toggle · enter next · esc/q quit";
|
|
215
218
|
let aCursor = 0;
|
|
216
|
-
term.frame(listFrame("step 2/
|
|
219
|
+
term.frame(listFrame("step 2/6 · agent CLIs", listLegend, adapterRows(), aCursor));
|
|
217
220
|
for (;;) {
|
|
218
221
|
const k = await term.key();
|
|
219
222
|
if (isAbort(k))
|
|
@@ -237,9 +240,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
237
240
|
changed = true;
|
|
238
241
|
}
|
|
239
242
|
if (changed)
|
|
240
|
-
term.frame(listFrame("step 2/
|
|
243
|
+
term.frame(listFrame("step 2/6 · agent CLIs", listLegend, adapterRows(), aCursor));
|
|
241
244
|
}
|
|
242
|
-
// step 3/
|
|
245
|
+
// step 3/6 — models per enabled adapter (space toggles deny, t assigns tier from the row)
|
|
243
246
|
const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
|
|
244
247
|
for (const a of enabledAdapters) {
|
|
245
248
|
const unclassifiedAll = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
|
|
@@ -257,7 +260,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
257
260
|
const denied = isDeniedModel(a.id, r.model, editable);
|
|
258
261
|
return `${denied ? toggleInactive() : toggleActive()} ${r.model} ${tier?.tier ?? "???"} ${denied ? "denied" : "allowed"}`;
|
|
259
262
|
});
|
|
260
|
-
const title = `step 3/
|
|
263
|
+
const title = `step 3/6 · models · ${a.id}`;
|
|
261
264
|
const modelsLegend = "↑↓/jk move · space toggle · t tier · enter next · esc/q quit";
|
|
262
265
|
let mCursor = 0;
|
|
263
266
|
term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
|
|
@@ -302,7 +305,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
302
305
|
term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
|
|
303
306
|
}
|
|
304
307
|
}
|
|
305
|
-
// step 4/
|
|
308
|
+
// step 4/6 — routing mode (selection is in-memory; the write happens only through the diff confirm).
|
|
306
309
|
// Candidate floor tables come from the ONE preset compiler via resolveRunMode — no mode math here.
|
|
307
310
|
const modeCfgs = Object.fromEntries(ROUTING_MODES.map((m) => [m, m === rm.mode.mode ? rm : resolveRunMode(cwd, { flag: m, globalDir })]));
|
|
308
311
|
const modeRows = () => ROUTING_MODES.map((m) => `${m === rm.mode.mode ? toggleActive() : " "} ${m.padEnd(11)} ${MODE_GLOSS[m]}`);
|
|
@@ -318,7 +321,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
318
321
|
...(changed.length ? changed.map((s) => ` ${s}: ${cur[s]} → ${next[s]}`) : [" (no floor changes)"]),
|
|
319
322
|
];
|
|
320
323
|
};
|
|
321
|
-
const modeTitle = "step 4/
|
|
324
|
+
const modeTitle = "step 4/6 · routing mode";
|
|
322
325
|
const modeLegend = "↑↓/jk move · enter select · esc/q quit";
|
|
323
326
|
let modeCursor = ROUTING_MODES.indexOf(rm.mode.mode);
|
|
324
327
|
let selectedMode = rm.mode.mode;
|
|
@@ -338,7 +341,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
338
341
|
term.frame(modeFrame());
|
|
339
342
|
}
|
|
340
343
|
}
|
|
341
|
-
// step 5/
|
|
344
|
+
// step 5/6 — shape routing (typed entry for pin/tier/prefer from the highlighted row);
|
|
342
345
|
// previews route under the SELECTED mode's resolved floors, never a floor edit of its own
|
|
343
346
|
const channels = discoverChannels(cfg, adapters, health);
|
|
344
347
|
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
@@ -356,7 +359,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
356
359
|
const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
|
|
357
360
|
return `${shape} → ${now}${auto}`;
|
|
358
361
|
});
|
|
359
|
-
const shapesTitle = "step 5/
|
|
362
|
+
const shapesTitle = "step 5/6 · shape routing";
|
|
360
363
|
// v1.52 T5: no map-tier editing action — routing.floors is the only band authority now.
|
|
361
364
|
const shapesLegend = "↑↓/jk move · a auto · p pin · f prefer · enter next · esc/q quit";
|
|
362
365
|
let sCursor = 0;
|
|
@@ -397,6 +400,43 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
397
400
|
if (changed)
|
|
398
401
|
term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
|
|
399
402
|
}
|
|
403
|
+
// step 6/6 — steering (v1.54 T4): review.prefer + consult.prefer via the same typed-entry
|
|
404
|
+
// pattern as the shape prefer action; edits are in-memory only and land on the merged overlay
|
|
405
|
+
// below, so they ride the ONE diff-confirm + reload-guard write path (never a second writer)
|
|
406
|
+
const STEER_KEYS = ["review", "consult"];
|
|
407
|
+
const initialSteering = {
|
|
408
|
+
review: cfg.review.prefer?.slice(),
|
|
409
|
+
consult: cfg.consult.prefer?.slice(),
|
|
410
|
+
};
|
|
411
|
+
const steering = structuredClone(initialSteering);
|
|
412
|
+
const steerRows = () => STEER_KEYS.map((k) => `${k}.prefer → ${steering[k]?.join(", ") ?? "(none)"}`);
|
|
413
|
+
const steerTitle = "step 6/6 · steering";
|
|
414
|
+
const steerLegend = "↑↓/jk move · f prefer · enter next · esc/q quit";
|
|
415
|
+
let stCursor = 0;
|
|
416
|
+
term.frame(listFrame(steerTitle, steerLegend, steerRows(), stCursor));
|
|
417
|
+
for (;;) {
|
|
418
|
+
const k = await term.key();
|
|
419
|
+
if (isAbort(k))
|
|
420
|
+
return QUIT;
|
|
421
|
+
if (isNext(k))
|
|
422
|
+
break;
|
|
423
|
+
let changed = false;
|
|
424
|
+
const moved = moveCursor(k, stCursor, STEER_KEYS.length);
|
|
425
|
+
if (moved !== stCursor) {
|
|
426
|
+
stCursor = moved;
|
|
427
|
+
changed = true;
|
|
428
|
+
}
|
|
429
|
+
else if (k.name === "f") {
|
|
430
|
+
const which = STEER_KEYS[stCursor];
|
|
431
|
+
const grammar = which === "consult" ? "adapter:model seats" : "adapters or adapter:model";
|
|
432
|
+
const raw = await term.askTyped(`${which}.prefer (comma-separated ${grammar}, empty clears)> `);
|
|
433
|
+
const list = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
434
|
+
steering[which] = list.length ? list : undefined;
|
|
435
|
+
changed = true;
|
|
436
|
+
}
|
|
437
|
+
if (changed)
|
|
438
|
+
term.frame(listFrame(steerTitle, steerLegend, steerRows(), stCursor));
|
|
439
|
+
}
|
|
400
440
|
// review — unified diff + typed confirm (the ONLY write path; the mode screen funnels through here)
|
|
401
441
|
const before = currentRepoOverlayText(cwd);
|
|
402
442
|
const existing = readOverlayFile(repoOverlayPath(cwd));
|
|
@@ -406,8 +446,25 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
406
446
|
const merged = fleetEditableEquals(initial, editable)
|
|
407
447
|
? structuredClone(existing)
|
|
408
448
|
: fleetRepoOverlayFromDelta(initial, editable, existing);
|
|
449
|
+
// steering lands on the merged object and serializes via repoOverlayYaml's non-fleet
|
|
450
|
+
// passthrough; clearing removes the prefer key (and an emptied parent block) from the overlay
|
|
451
|
+
let steeringChanged = false;
|
|
452
|
+
for (const key of STEER_KEYS) {
|
|
453
|
+
if (JSON.stringify(steering[key]) === JSON.stringify(initialSteering[key]))
|
|
454
|
+
continue;
|
|
455
|
+
steeringChanged = true;
|
|
456
|
+
const block = { ...merged[key] };
|
|
457
|
+
if (steering[key])
|
|
458
|
+
block.prefer = steering[key];
|
|
459
|
+
else
|
|
460
|
+
delete block.prefer;
|
|
461
|
+
if (Object.keys(block).length)
|
|
462
|
+
merged[key] = block;
|
|
463
|
+
else
|
|
464
|
+
delete merged[key];
|
|
465
|
+
}
|
|
409
466
|
const after = withModeLine(repoOverlayYaml(merged, provenanceMap(editable)), writeMode);
|
|
410
|
-
if ((!modeChanged && fleetEditableEquals(initial, editable)) || before === after) {
|
|
467
|
+
if ((!modeChanged && !steeringChanged && fleetEditableEquals(initial, editable)) || before === after) {
|
|
411
468
|
return "fleet: no overlay changes (empty diff)";
|
|
412
469
|
}
|
|
413
470
|
const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
|
|
2
|
-
import { formatModelAuthLine, contextWindowLints, modelLints, ttyVisual } from "../../adapters/model-lints.js";
|
|
2
|
+
import { formatModelAuthLine, contextWindowLints, modelLints, preferEntryLints, ttyVisual } from "../../adapters/model-lints.js";
|
|
3
3
|
import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { collateralLints } from "../../compile/collateral.js";
|
|
6
|
-
import { DEFAULT_CONFIG, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
5
|
+
import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
|
|
6
|
+
import { DEFAULT_CONFIG, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
7
7
|
import { loadGraph } from "../../graph/graph.js";
|
|
8
8
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
9
9
|
import { excludedChannels, exclusionLine } from "../../route/preference.js";
|
|
@@ -121,6 +121,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
121
121
|
lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
|
|
122
122
|
}
|
|
123
123
|
lints.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() })); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
|
|
124
|
+
// v1.54 T3: dead-steering sweep — advisory only, renders with the routing lints, never alters routing.
|
|
125
|
+
lints.push(...preferEntryLints(cfg, health, overlayPreferShapes(cwd)));
|
|
124
126
|
if (cfg.review.required && channels.length && !fleetCanCrossVendorReview(channels)) {
|
|
125
127
|
lints.push("review: no cross-vendor reviewer pair in fleet — set review.required: false to waive");
|
|
126
128
|
}
|
|
@@ -170,8 +172,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
170
172
|
const windowLints = contextWindowLints(g.tasks, routed, cfg, cwd);
|
|
171
173
|
if (windowLints.length)
|
|
172
174
|
lines.push("", "context window lints:", ...windowLints.map((l) => ` ! ${l}`));
|
|
173
|
-
// OBS-12/13/14/21: advisory only — never blocks --route-strict (run refuses on routing lints only)
|
|
174
|
-
const scopeLints = collateralLints(g.tasks, cwd);
|
|
175
|
+
// OBS-12/13/14/21 + OBS-76: advisory only — never blocks --route-strict (run refuses on routing lints only)
|
|
176
|
+
const scopeLints = [...collateralLints(g.tasks, cwd), ...sourceScopeLints(g.tasks, cwd)];
|
|
175
177
|
if (scopeLints.length)
|
|
176
178
|
lines.push("", "scope lints:", ...scopeLints.map((l) => ` ! ${l}`));
|
|
177
179
|
return stylizePlan(lines.join("\n"));
|
|
@@ -94,6 +94,14 @@ const wallClock = (start, end) => {
|
|
|
94
94
|
return minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
|
95
95
|
};
|
|
96
96
|
const detail = (value) => typeof value === "string" || typeof value === "number" ? String(value) : EM;
|
|
97
|
+
// v1.53 T5: supersession is derived from the run's OWN journal only — `superseded by` from the
|
|
98
|
+
// appended superseded event (last wins), `supersedes` from the run-start stamp. No cross-run scan.
|
|
99
|
+
const supersession = (events) => {
|
|
100
|
+
const by = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string")?.data.by;
|
|
101
|
+
const start = events.find((e) => e.event === "run-start");
|
|
102
|
+
const supersedes = typeof start?.data.supersedes === "string" ? start.data.supersedes : undefined;
|
|
103
|
+
return { ...(typeof by === "string" ? { supersededBy: by } : {}), ...(supersedes ? { supersedes } : {}) };
|
|
104
|
+
};
|
|
97
105
|
const taskIds = (events) => {
|
|
98
106
|
const seen = new Set();
|
|
99
107
|
const out = [];
|
|
@@ -152,10 +160,13 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
|
|
|
152
160
|
usageLines.push(...journalUsage(events, new Set(prices.map((row) => `${row.channel}:${row.adapter}:${row.model}`))));
|
|
153
161
|
if (!usageLines.length)
|
|
154
162
|
usageLines.push("- **not recorded:** attempts/windows: not measurable; tokens: not measurable; price: not measurable");
|
|
163
|
+
const sup = supersession(events);
|
|
155
164
|
const lines = [
|
|
156
165
|
`# tickmarkr engagement`,
|
|
157
166
|
"",
|
|
158
167
|
`- **runId:** ${runId}`,
|
|
168
|
+
...(sup.supersededBy ? [`- **superseded by:** ${sup.supersededBy}`] : []),
|
|
169
|
+
...(sup.supersedes ? [`- **supersedes:** ${sup.supersedes}`] : []),
|
|
159
170
|
`- **base ref:** ${baseRef}`,
|
|
160
171
|
`- **branch:** ${branch}`,
|
|
161
172
|
`- **done:** ${count("done")}`,
|
|
@@ -269,8 +280,11 @@ function textReport(runId, events, rows, cwd) {
|
|
|
269
280
|
const escalations = events.filter((e) => e.event === "escalation").length;
|
|
270
281
|
const consults = events.filter((e) => e.event === "consult-verdict").length;
|
|
271
282
|
const failovers = events.filter((e) => e.event === "quota-failover").length;
|
|
283
|
+
const sup = supersession(events);
|
|
272
284
|
return [
|
|
273
285
|
`tickmarkr engagement — ${runId}`,
|
|
286
|
+
...(sup.supersededBy ? [`superseded by ${sup.supersededBy}`] : []),
|
|
287
|
+
...(sup.supersedes ? [`supersedes ${sup.supersedes}`] : []),
|
|
274
288
|
"",
|
|
275
289
|
"engagement summary — audit trail:",
|
|
276
290
|
...[...groups.entries()].map(([k, g]) => ` ${k.padEnd(30)} tasks ${g.rows.length}, attempts ${g.rows.reduce((s, r) => s + r.attempts, 0)}, done ${g.rows.filter((r) => r.outcome === "done").length}`),
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -39,6 +39,7 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
39
39
|
"no-explore": { type: "boolean" },
|
|
40
40
|
mode: { type: "string" },
|
|
41
41
|
quality: { type: "boolean" },
|
|
42
|
+
supersedes: { type: "string" },
|
|
42
43
|
},
|
|
43
44
|
});
|
|
44
45
|
if (values.concurrency !== undefined) {
|
|
@@ -83,6 +84,7 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
83
84
|
concurrency: values.concurrency ? Number(values.concurrency) : undefined,
|
|
84
85
|
driver: pickDriver(cfg, values.driver),
|
|
85
86
|
mode: flagMode,
|
|
87
|
+
supersedes: values.supersedes,
|
|
86
88
|
narrate: (event) => console.log(narrationLine(event)),
|
|
87
89
|
});
|
|
88
90
|
const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
|
|
@@ -120,9 +120,12 @@ const renderFrame = (cwd) => {
|
|
|
120
120
|
let events = [];
|
|
121
121
|
const contexts = new Map();
|
|
122
122
|
let comparable = false;
|
|
123
|
+
let supersededBy; // v1.53 T5: this run is dead — a newer run replaced it
|
|
123
124
|
if (runId) {
|
|
124
125
|
const j = Journal.open(cwd, runId);
|
|
125
126
|
events = j.read();
|
|
127
|
+
const sup = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
|
|
128
|
+
supersededBy = sup?.data.by;
|
|
126
129
|
// T3 (Sol #2 / Fable F2): the SAME comparator resume uses (engagementComparable) — one decision,
|
|
127
130
|
// two consumers. graphDefinitionHash (compiled task definitions only) survives status/evidence
|
|
128
131
|
// mutation but changes when a task definition changes, so a recompiled graph is detected here too.
|
|
@@ -164,7 +167,7 @@ const renderFrame = (cwd) => {
|
|
|
164
167
|
return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
|
|
165
168
|
});
|
|
166
169
|
const header = runId
|
|
167
|
-
? `tickmarkr status${divider}run ${runId}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
|
|
170
|
+
? `tickmarkr status${divider}run ${runId}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
|
|
168
171
|
: `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
|
|
169
172
|
const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
|
|
170
173
|
return [header, legendLine, ...rows].join("\n");
|
|
@@ -186,7 +189,7 @@ const renderFrame = (cwd) => {
|
|
|
186
189
|
const tally = `${done}/${g.tasks.length} done`;
|
|
187
190
|
const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}` +
|
|
188
191
|
(runId
|
|
189
|
-
? `${!comparable ? `${warn(NOT_COMPARABLE_NOTICE)}${dot}` : ""}${live}${dot}`
|
|
192
|
+
? `${supersededBy ? `${warn(`superseded by ${supersededBy}`)}${dot}` : ""}${!comparable ? `${warn(NOT_COMPARABLE_NOTICE)}${dot}` : ""}${live}${dot}`
|
|
190
193
|
: `no runs yet${dot}`) +
|
|
191
194
|
`${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
|
|
192
195
|
const hr = rule(Math.min(width, 100));
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Task } from "../graph/schema.js";
|
|
2
2
|
/**
|
|
3
3
|
* Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
|
|
4
4
|
* Each line names the task id and at least one missing collateral test path.
|
|
5
5
|
*/
|
|
6
6
|
export declare function collateralLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
|
|
7
|
+
/**
|
|
8
|
+
* OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
|
|
9
|
+
* criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
|
|
10
|
+
* Advisory plan output only, same contract as collateralLints.
|
|
11
|
+
*/
|
|
12
|
+
export declare function sourceScopeLints(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, repoRoot: string): string[];
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { extname, join, relative } from "node:path";
|
|
3
3
|
import picomatch from "picomatch";
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { renderAcceptanceItem } from "../graph/schema.js";
|
|
5
|
+
// Advisory plan-time scan only (OBS-12/13/14/21, OBS-76). NEVER expands files[], fails compile,
|
|
6
|
+
// or feeds the scope gate — a warning the author acts on. Plain-text (no AST), capped + sorted.
|
|
7
|
+
/** Max files walked per root (sorted walk; rest ignored). */
|
|
8
|
+
const MAX_WALK_FILES = 400;
|
|
8
9
|
/** Max collateral test paths listed per task. */
|
|
9
10
|
const MAX_HITS_PER_TASK = 20;
|
|
10
11
|
/** Skip giant fixtures / snapshots. */
|
|
@@ -22,23 +23,23 @@ function needlesFor(srcPath) {
|
|
|
22
23
|
// repo-relative import forms that appear in tests (ESM often uses .js)
|
|
23
24
|
return [...new Set([noExt, `${noExt}.js`, `${noExt}.ts`, n])].filter((s) => s.length > 0);
|
|
24
25
|
}
|
|
25
|
-
function
|
|
26
|
-
const root = join(repoRoot,
|
|
26
|
+
function walkCode(repoRoot, subdir) {
|
|
27
|
+
const root = join(repoRoot, subdir);
|
|
27
28
|
const out = [];
|
|
28
29
|
const walk = (dir) => {
|
|
29
|
-
if (out.length >=
|
|
30
|
+
if (out.length >= MAX_WALK_FILES)
|
|
30
31
|
return;
|
|
31
32
|
let entries;
|
|
32
33
|
try {
|
|
33
34
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
34
35
|
}
|
|
35
36
|
catch {
|
|
36
|
-
return; // no
|
|
37
|
+
return; // no such root or unreadable — zero lints
|
|
37
38
|
}
|
|
38
39
|
// deterministic order
|
|
39
40
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
40
41
|
for (const e of entries) {
|
|
41
|
-
if (out.length >=
|
|
42
|
+
if (out.length >= MAX_WALK_FILES)
|
|
42
43
|
return;
|
|
43
44
|
if (e.name === "node_modules" || e.name === ".git" || e.name.startsWith("."))
|
|
44
45
|
continue;
|
|
@@ -70,17 +71,10 @@ function mentions(content, needles) {
|
|
|
70
71
|
}
|
|
71
72
|
return false;
|
|
72
73
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
* Each line names the task id and at least one missing collateral test path.
|
|
76
|
-
*/
|
|
77
|
-
export function collateralLints(tasks, repoRoot) {
|
|
78
|
-
const testFiles = walkTests(repoRoot);
|
|
79
|
-
if (!testFiles.length)
|
|
80
|
-
return [];
|
|
81
|
-
// cache file bodies (one read per test path)
|
|
74
|
+
// cache file bodies (one read per path)
|
|
75
|
+
function makeReader(repoRoot) {
|
|
82
76
|
const body = new Map();
|
|
83
|
-
|
|
77
|
+
return (rel) => {
|
|
84
78
|
if (body.has(rel))
|
|
85
79
|
return body.get(rel);
|
|
86
80
|
try {
|
|
@@ -98,6 +92,16 @@ export function collateralLints(tasks, repoRoot) {
|
|
|
98
92
|
return null;
|
|
99
93
|
}
|
|
100
94
|
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
|
|
98
|
+
* Each line names the task id and at least one missing collateral test path.
|
|
99
|
+
*/
|
|
100
|
+
export function collateralLints(tasks, repoRoot) {
|
|
101
|
+
const testFiles = walkCode(repoRoot, "tests");
|
|
102
|
+
if (!testFiles.length)
|
|
103
|
+
return [];
|
|
104
|
+
const read = makeReader(repoRoot);
|
|
101
105
|
const lines = [];
|
|
102
106
|
for (const t of tasks) {
|
|
103
107
|
// OBS-22: scopeGate accepts picomatch globs; advisory collateral warnings must agree.
|
|
@@ -128,3 +132,56 @@ export function collateralLints(tasks, repoRoot) {
|
|
|
128
132
|
}
|
|
129
133
|
return lines;
|
|
130
134
|
}
|
|
135
|
+
// v1.53 T4 (OBS-76): needles are code-shaped tokens only (camelCase / snake_case) — plain prose
|
|
136
|
+
// words never match, so prose-only criteria yield zero needles instead of alarm-fatigue noise.
|
|
137
|
+
// ponytail: token heuristic, not AST symbol resolution — promote after a version of precision data.
|
|
138
|
+
function criteriaSymbols(acceptance) {
|
|
139
|
+
const out = new Set();
|
|
140
|
+
for (const item of acceptance) {
|
|
141
|
+
for (const tok of renderAcceptanceItem(item).match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) ?? []) {
|
|
142
|
+
if (tok.includes("_") || /[a-z][A-Z]/.test(tok))
|
|
143
|
+
out.add(tok);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [...out].sort();
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
|
|
150
|
+
* criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
|
|
151
|
+
* Advisory plan output only, same contract as collateralLints.
|
|
152
|
+
*/
|
|
153
|
+
export function sourceScopeLints(tasks, repoRoot) {
|
|
154
|
+
const perTask = tasks
|
|
155
|
+
.map((t) => ({ t, needles: criteriaSymbols(t.acceptance) }))
|
|
156
|
+
.filter((x) => x.needles.length);
|
|
157
|
+
if (!perTask.length)
|
|
158
|
+
return [];
|
|
159
|
+
const srcFiles = walkCode(repoRoot, "src");
|
|
160
|
+
if (!srcFiles.length)
|
|
161
|
+
return [];
|
|
162
|
+
const read = makeReader(repoRoot);
|
|
163
|
+
const lines = [];
|
|
164
|
+
for (const { t, needles } of perTask) {
|
|
165
|
+
// OBS-22: scopeGate accepts picomatch globs; advisory warnings must agree.
|
|
166
|
+
const scoped = picomatch(t.files.map((f) => f.replace(/^\.\//, "")), { dot: true });
|
|
167
|
+
// needles are word-chars by construction — no regex escaping needed; whole-word match only
|
|
168
|
+
const res = needles.map((n) => new RegExp(`\\b${n}\\b`));
|
|
169
|
+
const hits = [];
|
|
170
|
+
for (const sf of srcFiles) {
|
|
171
|
+
if (scoped(sf))
|
|
172
|
+
continue;
|
|
173
|
+
const text = read(sf);
|
|
174
|
+
if (text === null)
|
|
175
|
+
continue;
|
|
176
|
+
if (res.some((re) => re.test(text)))
|
|
177
|
+
hits.push(sf);
|
|
178
|
+
}
|
|
179
|
+
if (!hits.length)
|
|
180
|
+
continue;
|
|
181
|
+
hits.sort();
|
|
182
|
+
const listed = hits.slice(0, MAX_HITS_PER_TASK).join(", ");
|
|
183
|
+
const tail = hits.length > MAX_HITS_PER_TASK ? " (capped)" : "";
|
|
184
|
+
lines.push(`${t.id}: criteria implicate out-of-scope source not in files[]: ${listed}${tail}`);
|
|
185
|
+
}
|
|
186
|
+
return lines;
|
|
187
|
+
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -168,11 +168,13 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
168
168
|
review: z.ZodObject<{
|
|
169
169
|
complexityThreshold: z.ZodNumber;
|
|
170
170
|
required: z.ZodBoolean;
|
|
171
|
+
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
171
172
|
}, z.core.$strip>;
|
|
172
173
|
consult: z.ZodObject<{
|
|
173
174
|
adapter: z.ZodString;
|
|
174
175
|
model: z.ZodString;
|
|
175
176
|
stallMinutes: z.ZodNumber;
|
|
177
|
+
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
176
178
|
}, z.core.$strip>;
|
|
177
179
|
visibility: z.ZodObject<{
|
|
178
180
|
llm: z.ZodEnum<{
|
package/dist/config/config.js
CHANGED
|
@@ -152,8 +152,21 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
152
152
|
allowDeviations: z.array(z.string()),
|
|
153
153
|
}).partial().optional(),
|
|
154
154
|
judge: z.object({ adapter: z.string(), model: z.string() }),
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
// v1.53 T2: prefer — ordered reviewer preference (entry grammar: adapter | adapter:model, same as
|
|
156
|
+
// routing.map.prefer). Reorders diversity-eligible channels only; never widens or narrows eligibility.
|
|
157
|
+
review: z.object({ complexityThreshold: z.number(), required: z.boolean(), prefer: z.array(z.string()).optional() }),
|
|
158
|
+
// v1.54 T1: prefer — ranked consult seat failover. Entries MUST be adapter:model (unlike
|
|
159
|
+
// review.prefer's adapter|adapter:model grammar): a consult seat has no channel to inherit a
|
|
160
|
+
// model from, so a bare adapter is meaningless and fails config load fail-closed. The pinned
|
|
161
|
+
// adapter/model pair below stays the final fallback seat; an absent list is byte-identical.
|
|
162
|
+
consult: z.object({
|
|
163
|
+
adapter: z.string(),
|
|
164
|
+
model: z.string(),
|
|
165
|
+
stallMinutes: z.number().positive(),
|
|
166
|
+
prefer: z
|
|
167
|
+
.array(z.string().regex(/^[^:]+:.+$/, "consult.prefer entries must be adapter:model — a bare adapter has no model to run the seat with"))
|
|
168
|
+
.optional(),
|
|
169
|
+
}),
|
|
157
170
|
visibility: z.object({
|
|
158
171
|
llm: z.enum(["pane", "headless"]),
|
|
159
172
|
keepPanes: z.enum(["run", "attempt", "forever"]),
|
|
@@ -474,8 +487,14 @@ export function configTemplate(overlay) {
|
|
|
474
487
|
# test: npm test
|
|
475
488
|
# byShape:
|
|
476
489
|
# docs: { acceptance: false, review: false } # baseline, evidence, and scope are mandatory
|
|
477
|
-
# review: { complexityThreshold: 7, required: true }
|
|
478
|
-
#
|
|
490
|
+
# review: { complexityThreshold: 7, required: true, prefer: [codex:gpt-5.6-sol, kimi] }
|
|
491
|
+
# # prefer: ordered reviewer seat preference (adapter | adapter:model); ranks
|
|
492
|
+
# # diversity-eligible channels only — never admits a same-vendor/same-model reviewer
|
|
493
|
+
# consult: { adapter: claude-code, model: fable, stallMinutes: 15, prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] }
|
|
494
|
+
# # prefer: ranked consult seat failover — entries are adapter:model ONLY (a
|
|
495
|
+
# # consult seat has no channel to inherit a model from). The seat walks the
|
|
496
|
+
# # list to the first live adapter; a failed seat or unparseable verdict falls
|
|
497
|
+
# # to the next entry; the pinned adapter/model above is always the final seat
|
|
479
498
|
# cost: # v1.20 REC-02 — OPTIONAL price table for the usage/cost report. Absent ⇒ every
|
|
480
499
|
# # channel reports "not measurable" (never a crash or a fake $0). Two economics,
|
|
481
500
|
# # never conflated: API = tokens × per-Mtok rate; sub = flat plan amortized over a
|
package/dist/gates/review.d.ts
CHANGED
|
@@ -15,5 +15,6 @@ export declare function checkDiffCap(gate: string, measured: number, cap: number
|
|
|
15
15
|
export declare function isDiffCapPark(result: GateResult): boolean;
|
|
16
16
|
export declare function diffCapParkReason(results: GateResult[]): string | null;
|
|
17
17
|
export declare function modelId(model: string): string;
|
|
18
|
-
export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[]
|
|
18
|
+
export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[], // v1.1 failover: reviewer channels that already produced garbage for this task
|
|
19
|
+
prefer?: string[]): BillingChannel | null;
|
|
19
20
|
export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[]): Promise<GateResult>;
|
package/dist/gates/review.js
CHANGED
|
@@ -39,7 +39,15 @@ export function diffCapParkReason(results) {
|
|
|
39
39
|
export function modelId(model) {
|
|
40
40
|
return model.slice(model.lastIndexOf("/") + 1);
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
// v1.53 T2: same entry grammar as routing.map.prefer (router.ts preferIndex — router is out of this
|
|
43
|
+
// module's dependency direction for a private fn, so the 3 lines live here too): `adapter` matches
|
|
44
|
+
// every channel of that adapter, `adapter:model` exactly one; unmatched channels sort after all entries.
|
|
45
|
+
function reviewPreferIndex(c, prefer) {
|
|
46
|
+
const i = prefer.findIndex((p) => p === c.adapter || p === channelKey(c));
|
|
47
|
+
return i === -1 ? prefer.length : i;
|
|
48
|
+
}
|
|
49
|
+
export function pickReviewer(author, channels, exclude = [], // v1.1 failover: reviewer channels that already produced garbage for this task
|
|
50
|
+
prefer = []) {
|
|
43
51
|
// FLEET-05 success criterion 2: an author not resolvable in the channel list yields NO reviewer.
|
|
44
52
|
// The old `?? author.adapter` fallback compared an adapter id to vendor names, matched nothing, and
|
|
45
53
|
// admitted every reviewer — including the author's own channel (fail-OPEN). null lands on reviewGate's
|
|
@@ -49,15 +57,16 @@ export function pickReviewer(author, channels, exclude = []) {
|
|
|
49
57
|
return null;
|
|
50
58
|
return (channels
|
|
51
59
|
// two independent axes: different vendor AND different base-model identity (ADDED TO the vendor
|
|
52
|
-
// rule, never replacing it — a future edit can't silently drop either).
|
|
60
|
+
// rule, never replacing it — a future edit can't silently drop either). The diversity filter runs
|
|
61
|
+
// BEFORE preference ranking: prefer sorts survivors only, so no entry can resurrect an excluded channel.
|
|
53
62
|
.filter((c) => c.vendor !== authorChannel.vendor && modelId(c.model) !== modelId(author.model) && !exclude.includes(channelKey(c)))
|
|
54
|
-
.sort((a, b) => TIER_RANK[b.tier] - TIER_RANK[a.tier] || marginalCostRank(a) - marginalCostRank(b))[0] ?? null);
|
|
63
|
+
.sort((a, b) => reviewPreferIndex(a, prefer) - reviewPreferIndex(b, prefer) || TIER_RANK[b.tier] - TIER_RANK[a.tier] || marginalCostRank(a) - marginalCostRank(b))[0] ?? null);
|
|
55
64
|
}
|
|
56
65
|
export async function reviewGate(task, worktree, baseRef, author, channels, adapters, cfg, via, excludeReviewers) {
|
|
57
66
|
if (task.complexity < cfg.review.complexityThreshold) {
|
|
58
67
|
return { gate: "review", pass: true, details: `skipped — complexity ${task.complexity} < threshold ${cfg.review.complexityThreshold}`, meta: { skipped: true } };
|
|
59
68
|
}
|
|
60
|
-
const reviewer = pickReviewer(author, channels, excludeReviewers ?? []);
|
|
69
|
+
const reviewer = pickReviewer(author, channels, excludeReviewers ?? [], cfg.review.prefer ?? []);
|
|
61
70
|
if (!reviewer) {
|
|
62
71
|
return cfg.review.required
|
|
63
72
|
? { gate: "review", pass: false, details: "no cross-vendor reviewer available (diversity rule); set review.required:false to waive" }
|
package/dist/run/consult.d.ts
CHANGED
package/dist/run/consult.js
CHANGED
|
@@ -92,6 +92,8 @@ let consultSeq = 0;
|
|
|
92
92
|
export async function consult(d, cfg, adapters, driver, cwd, runDir,
|
|
93
93
|
// T2 ownership contract: runId names the consult pane canonically (tickmarkr:consult:<task>:0:<runId>)
|
|
94
94
|
// via SlotOpts.owned; without it the legacy gatePaneName shape survives (non-daemon callers/tests).
|
|
95
|
+
// v1.54 T1: channels — the daemon's doctor-filtered live channel list; prefer-seat liveness is
|
|
96
|
+
// judged against it only (never rebuilt from config, which would select installed-but-unauthed seats).
|
|
95
97
|
opts = {}) {
|
|
96
98
|
const n = ++consultSeq;
|
|
97
99
|
const nonce = generateVerdictNonce();
|
|
@@ -99,54 +101,82 @@ opts = {}) {
|
|
|
99
101
|
mkdirSync(dir, { recursive: true });
|
|
100
102
|
const promptFile = join(dir, `${d.taskId}-${n}.md`);
|
|
101
103
|
writeFileSync(promptFile, buildDossierPrompt(d, nonce));
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
out
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
104
|
+
// One seat = the WHOLE invoke-and-parse unit, both visibility branches (OBS-69 class: a headless-only
|
|
105
|
+
// failover would leave the production pane path hard-failing on seat one). null = no parseable verdict.
|
|
106
|
+
const invokeSeat = async (seatAdapter, seatModel, seatIdx) => {
|
|
107
|
+
const adapter = getAdapter(seatAdapter, adapters);
|
|
108
|
+
let out;
|
|
109
|
+
if (cfg.visibility.llm === "headless") {
|
|
110
|
+
const r = await sh(adapter.headlessCommand(promptFile, seatModel), cwd, cfg.consult.stallMinutes * 60_000);
|
|
111
|
+
out = r.stdout + r.stderr;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// T8: role-first pane name for fleet visibility (consult · T2); consultSeq stays on the dossier artifact only
|
|
115
|
+
const slot = await driver.slot(cwd, gatePaneName("consult", d.taskId), {
|
|
116
|
+
label: `CONSULT ${d.taskId}`,
|
|
117
|
+
...(opts.runId ? { owned: { role: "consult", taskId: d.taskId, attempt: 0, runId: opts.runId } } : {}),
|
|
118
|
+
});
|
|
119
|
+
opts.onSlot?.(slot);
|
|
120
|
+
const scriptPath = join(dir, `${d.taskId}-${n}${seatIdx > 0 ? `-s${seatIdx}` : ""}.sh`);
|
|
121
|
+
// OBS-50: visible consult panes get the brand banner; headless path above stays banner-free (machine-parsed stdout)
|
|
122
|
+
writeFileSync(scriptPath, [
|
|
123
|
+
"export BASH_SILENCE_DEPRECATION_WARNING=1",
|
|
124
|
+
bannerShell(),
|
|
125
|
+
adapter.headlessCommand(promptFile, seatModel),
|
|
126
|
+
gateExitTrailer(nonce),
|
|
127
|
+
].join("\n"));
|
|
128
|
+
try {
|
|
129
|
+
await driver.run(slot, paneDispatchCommand(scriptPath));
|
|
130
|
+
// nonce-suffixed exit only: a dossier quoting tickmarkr's own exit literal must not false-complete.
|
|
131
|
+
await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, cfg.consult.stallMinutes * 60_000, { regex: true });
|
|
132
|
+
out = await driver.read(slot, 300);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
// a failed seat must not leak its pane before the next seat opens under the same name
|
|
136
|
+
if (!opts.keep)
|
|
137
|
+
await driver.close(slot);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
130
140
|
out = augmentFakeVerdictOutput(adapter, out, nonce);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
...(excludeAdapter ? { excludeAdapter } : {}),
|
|
141
|
+
const v = extractVerdictJson(out, nonce);
|
|
142
|
+
if (!v || !ACTIONS.includes(v.action))
|
|
143
|
+
return null;
|
|
144
|
+
// fail-closed exclusion: only a non-empty string survives. Malformed values (number/array/object/
|
|
145
|
+
// empty) are dropped — the verdict stays a normal channel-level reroute/retry/…, never a crash
|
|
146
|
+
// and never silently forced to human. Unknown adapter ids pass through; the daemon treats a
|
|
147
|
+
// zero-match expansion as channel-level reroute.
|
|
148
|
+
const raw = v.excludeAdapter;
|
|
149
|
+
const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
|
150
|
+
const reason = typeof v.reason === "string" ? v.reason : undefined;
|
|
151
|
+
const guidance = typeof v.guidance === "string" ? v.guidance : undefined;
|
|
152
|
+
const notes = String(v.notes ?? guidance ?? reason ?? "");
|
|
153
|
+
return {
|
|
154
|
+
action: v.action,
|
|
155
|
+
notes,
|
|
156
|
+
...(reason ? { reason } : {}),
|
|
157
|
+
...(guidance ? { guidance } : {}),
|
|
158
|
+
...(excludeAdapter ? { excludeAdapter } : {}),
|
|
159
|
+
};
|
|
151
160
|
};
|
|
161
|
+
// v1.54 T1: ranked seat failover. Walk consult.prefer (adapter:model entries) to the first entry
|
|
162
|
+
// whose adapter is in the live channel set; a failed seat or unparseable verdict falls to the next;
|
|
163
|
+
// the pinned consult.adapter/model is always the final seat. No channels provided (non-daemon
|
|
164
|
+
// callers) ⇒ empty live set ⇒ pin only, byte-identical to pre-v1.54 behavior. Failover changes
|
|
165
|
+
// only WHICH seat answers — per-seat parsing and the fail-safe human action below are untouched.
|
|
166
|
+
const live = new Set((opts.channels ?? []).map((c) => c.adapter));
|
|
167
|
+
const seats = (cfg.consult.prefer ?? [])
|
|
168
|
+
.map((entry) => ({ adapter: entry.slice(0, entry.indexOf(":")), model: entry.slice(entry.indexOf(":") + 1) }))
|
|
169
|
+
.filter((s) => live.has(s.adapter));
|
|
170
|
+
seats.push({ adapter: cfg.consult.adapter, model: cfg.consult.model });
|
|
171
|
+
for (const [i, seat] of seats.entries()) {
|
|
172
|
+
try {
|
|
173
|
+
const v = await invokeSeat(seat.adapter, seat.model, i);
|
|
174
|
+
if (v)
|
|
175
|
+
return v;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// failed seat (unknown adapter, dead driver/pane, shell error) — fall to the next entry
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { action: "human", notes: "consult verdict unparseable — failing safe to human" };
|
|
152
182
|
}
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type JournalEvent } from "./journal.js";
|
|
|
5
5
|
export interface RunOptions {
|
|
6
6
|
runId?: string;
|
|
7
7
|
resume?: boolean;
|
|
8
|
+
supersedes?: string;
|
|
8
9
|
graphChanged?: boolean;
|
|
9
10
|
concurrency?: number;
|
|
10
11
|
driver?: ExecutorDriver;
|
|
@@ -12,6 +13,7 @@ export interface RunOptions {
|
|
|
12
13
|
globalDir?: string;
|
|
13
14
|
mode?: RoutingMode;
|
|
14
15
|
narrate?: (event: JournalEvent) => void;
|
|
16
|
+
exit?: (code: number) => void;
|
|
15
17
|
}
|
|
16
18
|
export type ModeSource = "run flag" | "spec" | "repo config" | "global config" | "default";
|
|
17
19
|
export interface ResolvedRunMode {
|
package/dist/run/daemon.js
CHANGED
|
@@ -98,10 +98,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
98
98
|
// HARD-01/02: hold the run lock across the whole read-modify-write of graph.json. Acquire
|
|
99
99
|
// BEFORE loadGraph; release in the finally below (every exit path, incl. throws).
|
|
100
100
|
const runId = opts.runId ?? newRunId();
|
|
101
|
+
// v1.53 T5: an unknown --supersedes id must fail BEFORE any run starts — Journal.open throws and
|
|
102
|
+
// no lock, journal, or baseline for the new run has been created yet. Opened without a narrate
|
|
103
|
+
// sink: the prior journal append below is silent bookkeeping, not this run's narration.
|
|
104
|
+
const prior = opts.supersedes !== undefined ? Journal.open(repoRoot, opts.supersedes) : undefined;
|
|
101
105
|
// T6 narrator: one live status surface per run (herdr only — driver.narrator is undefined on
|
|
102
106
|
// subprocess, so the optional-chain open below is a no-op there). Cosmetic-only: any failure is
|
|
103
107
|
// swallowed (never affects the run); the operator closes a surviving watch pane.
|
|
104
108
|
const lock = acquireRunLock(repoRoot, runId);
|
|
109
|
+
// v1.54 T2: declared before the try so the finally can always deregister (a throw before
|
|
110
|
+
// registration leaves it undefined — the guard below covers that path).
|
|
111
|
+
let onTermination;
|
|
105
112
|
try {
|
|
106
113
|
let graph = loadGraph(repoRoot);
|
|
107
114
|
// v1.51 T2: the routing mode resolves BEFORE any routing input is built — run flag > spec front-matter
|
|
@@ -116,6 +123,67 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
116
123
|
// No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
|
|
117
124
|
// for the run, so this run's own telemetry never feeds back into its own routing.
|
|
118
125
|
const profile = loadRoutingProfile(repoRoot, cfg);
|
|
126
|
+
// v1.54 T2 (OBS-71): signal reaper — a killed daemon closes its own panes and releases the lock.
|
|
127
|
+
// Every slot this run opens stays in liveSlots until closed: the worker path opens through
|
|
128
|
+
// trackedDriver below, and gates/consults receive trackedDriver as THEIR driver, so their pane
|
|
129
|
+
// opens/closes keep the ledger exact. Termination then closes exactly what is still live — and a
|
|
130
|
+
// slot closed once (task-done, quota reroute, gate self-clean) can never be closed twice.
|
|
131
|
+
const liveSlots = new Set();
|
|
132
|
+
const closeSlot = async (s) => {
|
|
133
|
+
if (!liveSlots.delete(s))
|
|
134
|
+
return; // already closed — never twice
|
|
135
|
+
await driver.close(s);
|
|
136
|
+
};
|
|
137
|
+
const trackedDriver = {
|
|
138
|
+
id: driver.id,
|
|
139
|
+
interactive: driver.interactive,
|
|
140
|
+
slot: async (cwd, name, o) => { const s = await driver.slot(cwd, name, o); liveSlots.add(s); return s; },
|
|
141
|
+
run: (s, cmd) => driver.run(s, cmd),
|
|
142
|
+
waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
|
|
143
|
+
waitAgentStatus: (s, st, ms) => driver.waitAgentStatus(s, st, ms),
|
|
144
|
+
status: (s) => driver.status(s),
|
|
145
|
+
read: (s, n) => driver.read(s, n),
|
|
146
|
+
...(driver.sendKey ? { sendKey: driver.sendKey.bind(driver) } : {}),
|
|
147
|
+
notify: (m, o) => driver.notify(m, o),
|
|
148
|
+
close: closeSlot,
|
|
149
|
+
worktree: (r, b, base) => driver.worktree(r, b, base),
|
|
150
|
+
};
|
|
151
|
+
// Termination (SIGINT/SIGTERM): close every live slot, reconcile owned panes against an EMPTY
|
|
152
|
+
// desired set (herdr panes not in memory; panesToClose spares foreign names, watch panes, and
|
|
153
|
+
// other runs' panes by construction), release the run lock, then exit. Journal-silent by design —
|
|
154
|
+
// no run-end/interrupted event, so stop-amend-resume keeps resuming. keepPanes:"forever" (the
|
|
155
|
+
// keep-everything debug override) preserves panes but still releases the lock and exits.
|
|
156
|
+
let termSignal;
|
|
157
|
+
let abortRun = () => { };
|
|
158
|
+
const aborted = new Promise((_, reject) => { abortRun = reject; });
|
|
159
|
+
aborted.catch(() => { });
|
|
160
|
+
let reaping = false;
|
|
161
|
+
const exit = opts.exit ?? ((code) => process.exit(code));
|
|
162
|
+
onTermination = (sig) => {
|
|
163
|
+
termSignal = sig;
|
|
164
|
+
void (async () => {
|
|
165
|
+
if (!reaping) {
|
|
166
|
+
reaping = true;
|
|
167
|
+
if (cfg.visibility.keepPanes !== "forever") {
|
|
168
|
+
for (const s of liveSlots) { // closeSlot only deletes the element being visited — safe during Set iteration
|
|
169
|
+
try {
|
|
170
|
+
await closeSlot(s);
|
|
171
|
+
}
|
|
172
|
+
catch { /* cosmetic — reconcile is the backstop */ }
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
await driver.reconcile?.(new Set(), runId);
|
|
176
|
+
}
|
|
177
|
+
catch { /* cosmetic — visibility is never a gate */ }
|
|
178
|
+
}
|
|
179
|
+
releaseRunLock(repoRoot); // the process dies at exit() below — the finally never runs on this path
|
|
180
|
+
}
|
|
181
|
+
abortRun(new Error(`terminated by ${sig}`));
|
|
182
|
+
exit(sig === "SIGINT" ? 130 : 143);
|
|
183
|
+
})();
|
|
184
|
+
};
|
|
185
|
+
process.on("SIGINT", onTermination);
|
|
186
|
+
process.on("SIGTERM", onTermination);
|
|
119
187
|
const journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
|
|
120
188
|
const branchEvent = opts.resume
|
|
121
189
|
? [...journal.read()].reverse().find((e) => (e.event === "run-start" || e.event === "run-end" || e.event === "merge") && typeof e.data.branch === "string")
|
|
@@ -140,6 +208,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
140
208
|
// equivalence to the router.ts:194 profile⇒undefined pattern: no map entry ⇒ today's literal.
|
|
141
209
|
const resume = opts.resume ? journal.replayResumeState() : new Map();
|
|
142
210
|
if (opts.resume) {
|
|
211
|
+
// v1.53 T5: a superseded run is dead — resuming it beside its successor is the exact
|
|
212
|
+
// two-concurrent-runs hazard supersession exists to prevent. Fail closed, naming the successor.
|
|
213
|
+
const superseded = [...journal.read()].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
|
|
214
|
+
if (superseded)
|
|
215
|
+
throw new Error(`refusing to resume ${runId}: superseded by ${superseded.data.by}`);
|
|
143
216
|
const start = journal.read().find((e) => e.event === "run-start");
|
|
144
217
|
if (!start)
|
|
145
218
|
throw new Error(`journal for ${runId} has no run-start event`);
|
|
@@ -175,7 +248,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
175
248
|
baseRef = await gitHead(repoRoot);
|
|
176
249
|
baseline = await captureBaseline(repoRoot, commands);
|
|
177
250
|
writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
|
|
178
|
-
journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2
|
|
251
|
+
journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source, ...(prior ? { supersedes: prior.runId } : {}) }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2; supersedes: v1.53 T5
|
|
252
|
+
// v1.53 T5: mark the prior run AFTER this run's run-start exists, so the prior journal never
|
|
253
|
+
// names a successor that has no journal. Append-only — the prior journal is never rewritten.
|
|
254
|
+
prior?.append("superseded", undefined, { by: runId });
|
|
179
255
|
}
|
|
180
256
|
// T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
|
|
181
257
|
// show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
|
|
@@ -334,9 +410,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
334
410
|
journalTail: JSON.stringify(journal.read().slice(-20)),
|
|
335
411
|
transcript: transcript.slice(-8000),
|
|
336
412
|
diff: diffOrFeedback, gates,
|
|
337
|
-
}, cfg, adapters,
|
|
413
|
+
}, cfg, adapters, trackedDriver, repoRoot, journal.dir,
|
|
338
414
|
// D-07: consult panes self-clean when the verdict is read (keepLlm) — only "forever" keeps them.
|
|
339
|
-
|
|
415
|
+
// v1.54 T1: channels = this run's doctor-filtered live list — consult.prefer seat liveness
|
|
416
|
+
// is judged against it, never rebuilt from config (installed-but-unauthed seats would stall).
|
|
417
|
+
{ keep: keepLlm, onSlot: keepLlm ? (s) => keptSlots.push(s) : undefined, runId, channels });
|
|
340
418
|
};
|
|
341
419
|
// returns true → continue attempting, false → task is terminal (parked)
|
|
342
420
|
// trigger (why the consult ran) is threaded in so the decompose/human park keeps its cause —
|
|
@@ -412,13 +490,18 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
412
490
|
}
|
|
413
491
|
// v1.29: consume the prior gate-failed session once. Same channel + known under-threshold context
|
|
414
492
|
// + adapter capability resumes; every other path is today's fresh dispatch.
|
|
493
|
+
// v1.53 T3: an adapter with no context surface at all (kimi, KIMI-03) may declare
|
|
494
|
+
// resumeUnknownContext to loosen ONLY the contextTokens-known requirement — a KNOWN
|
|
495
|
+
// over-threshold context still forces fresh, and the escalation ladder bounds the chain.
|
|
415
496
|
const priorSession = retrySession;
|
|
416
497
|
retrySession = undefined;
|
|
498
|
+
const retryAdapter = adapters.find((a) => a.id === assignment.adapter);
|
|
417
499
|
retryMode = priorSession
|
|
418
500
|
&& priorSession.channel === channelKey(assignment)
|
|
419
|
-
&& priorSession.contextTokens !== undefined
|
|
420
|
-
|
|
421
|
-
|
|
501
|
+
&& (priorSession.contextTokens !== undefined
|
|
502
|
+
? priorSession.contextTokens < cfg.contextWarnTokens
|
|
503
|
+
: retryAdapter?.resumeUnknownContext === true)
|
|
504
|
+
&& retryAdapter?.resumeCommand
|
|
422
505
|
? "resume"
|
|
423
506
|
: "fresh";
|
|
424
507
|
// v1.23 T3: over-threshold context still forces fresh at the retry boundary; never interrupt a
|
|
@@ -480,7 +563,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
480
563
|
const adapter = getAdapter(assignment.adapter, adapters);
|
|
481
564
|
// VIS-04: workers share one role tab. T2: `owned` names the pane canonically (ownership contract);
|
|
482
565
|
// the legacy name stays the fallback for drivers without owned handling (subprocess spies).
|
|
483
|
-
const slot = await
|
|
566
|
+
const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, { group: "workers", owned: { role: "worker", taskId: t.id, attempt, runId } });
|
|
484
567
|
const sessionId = retryMode === "resume" ? priorSession.id : slot.name;
|
|
485
568
|
const icmd = retryMode === "resume"
|
|
486
569
|
? adapter.resumeCommand(sessionId, promptFile, assignment.model)
|
|
@@ -660,7 +743,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
660
743
|
if (keepOpen && (finished || driver.id !== "subprocess"))
|
|
661
744
|
keptSlots.push(slot);
|
|
662
745
|
else
|
|
663
|
-
await
|
|
746
|
+
await closeSlot(slot);
|
|
664
747
|
// SPEND-01: usage from the harness's own cwd-keyed structured store, read POST-HOC from disk —
|
|
665
748
|
// `wt` is this task's private worktree, so the path is unique; the read is sliced to records
|
|
666
749
|
// stamped at/after this attempt's dispatch instant. Never the harvested pane text, never the
|
|
@@ -714,7 +797,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
714
797
|
if (idx >= 0) {
|
|
715
798
|
keptSlots.splice(idx, 1);
|
|
716
799
|
try {
|
|
717
|
-
await
|
|
800
|
+
await closeSlot(slot);
|
|
718
801
|
}
|
|
719
802
|
catch { /* cosmetic — reconcile is the backstop */ }
|
|
720
803
|
}
|
|
@@ -793,7 +876,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
793
876
|
commands, baseline, channels, adapters, cfg,
|
|
794
877
|
via: cfg.visibility.llm === "pane"
|
|
795
878
|
? {
|
|
796
|
-
driver,
|
|
879
|
+
driver: trackedDriver,
|
|
797
880
|
// D-07: judge/review panes self-clean when their verdict is read (keepLlm) — only "forever" keeps them.
|
|
798
881
|
keep: keepLlm,
|
|
799
882
|
onSlot: keepLlm ? (s) => keptSlots.push(s) : undefined,
|
|
@@ -845,7 +928,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
845
928
|
const idx = keptSlots.indexOf(slot);
|
|
846
929
|
if (idx >= 0) {
|
|
847
930
|
keptSlots.splice(idx, 1);
|
|
848
|
-
await
|
|
931
|
+
await closeSlot(slot);
|
|
849
932
|
}
|
|
850
933
|
}
|
|
851
934
|
await reconcile({ spareLiveLlm: true }); // task-done is a terminal event — sweep this task's leftovers
|
|
@@ -854,7 +937,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
854
937
|
break gateLoop;
|
|
855
938
|
}
|
|
856
939
|
gateFails++; // this attempt's gates failed — the one place quality degradation is verified (never inferred from attempts)
|
|
857
|
-
|
|
940
|
+
// v1.53 T3: prefer the CLI's own session id captured from this attempt's output (kimi's resume
|
|
941
|
+
// trailer) over the harness slot name; absent hook or no capture keeps today's slot-name id.
|
|
942
|
+
retrySession = { channel: channelKey(assignment), id: adapter.sessionIdFrom?.(output) ?? sessionId, contextTokens };
|
|
858
943
|
feedback = results.filter((g) => !g.pass).map((g) => `${g.gate}: ${g.details}`).join("\n\n");
|
|
859
944
|
const step = r.ladder[Math.min(ladderIdx++, r.ladder.length - 1)];
|
|
860
945
|
journal.append("escalation", t.id, { step, attempt: attempt + 1 });
|
|
@@ -882,6 +967,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
882
967
|
};
|
|
883
968
|
const inflight = new Map();
|
|
884
969
|
while (true) {
|
|
970
|
+
// v1.54 T2: a signal that landed while nothing was racing `aborted` (empty inflight window)
|
|
971
|
+
// must still stop the run before it can dispatch more work or write run-end.
|
|
972
|
+
if (termSignal)
|
|
973
|
+
throw new Error(`terminated by ${termSignal}`);
|
|
885
974
|
const ready = readyTasks(graph)
|
|
886
975
|
.filter((t) => !inflight.has(t.id))
|
|
887
976
|
.slice(0, Math.max(0, concurrency - inflight.size));
|
|
@@ -899,14 +988,14 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
899
988
|
}
|
|
900
989
|
if (inflight.size === 0)
|
|
901
990
|
break;
|
|
902
|
-
await Promise.race(inflight.values());
|
|
991
|
+
await Promise.race([...inflight.values(), aborted]); // aborted rejects on termination — unwinds the run
|
|
903
992
|
}
|
|
904
993
|
// D-07: the sweep now closes only what's LEFT in keptSlots — done-closed worker slots were removed
|
|
905
994
|
// (no double-close) and self-cleaned LLM/consult panes were never added under keepLlm:false. This
|
|
906
995
|
// leaves failed/parked attempts' worker slots, which keep their failure context until run end.
|
|
907
996
|
if (cfg.visibility.keepPanes === "run") {
|
|
908
997
|
for (const s of keptSlots)
|
|
909
|
-
await
|
|
998
|
+
await closeSlot(s); // panes persist for the run's duration, then clean up
|
|
910
999
|
}
|
|
911
1000
|
saveGraph(repoRoot, graph);
|
|
912
1001
|
const byStatus = (s) => graph.tasks.filter((t) => t.status === s).map((t) => t.id);
|
|
@@ -967,6 +1056,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
967
1056
|
return summary;
|
|
968
1057
|
}
|
|
969
1058
|
finally {
|
|
1059
|
+
// v1.54 T2: deregister on EVERY exit (normal run end, throw, termination unwind) — the daemon
|
|
1060
|
+
// test suite runs runDaemon dozens of times in one process; a leaked handler would close a
|
|
1061
|
+
// later run's slots.
|
|
1062
|
+
if (onTermination) {
|
|
1063
|
+
process.removeListener("SIGINT", onTermination);
|
|
1064
|
+
process.removeListener("SIGTERM", onTermination);
|
|
1065
|
+
}
|
|
970
1066
|
releaseRunLock(repoRoot);
|
|
971
1067
|
}
|
|
972
1068
|
}
|
package/dist/run/git.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { shq } from "../adapters/types.js";
|
|
5
5
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
6
6
|
import { ROUTING_ENV_SEAMS } from "../route/router.js";
|
|
@@ -110,11 +110,40 @@ export async function createWorktree(repo, branch, baseRef) {
|
|
|
110
110
|
// (real directory, wrong/broken symlink) is restored to the provisioned link. Idempotent: an
|
|
111
111
|
// already-correct link is a no-op. Returns whether dest is the provisioned link: provisioning ignores
|
|
112
112
|
// the result; the pre-gate caller treats false as a named environmental park, never a masked test red.
|
|
113
|
+
// OBS-78: target repos typically ignore `node_modules/` — a directories-only pattern that does NOT
|
|
114
|
+
// match the provisioned SYMLINK, so a worker staging with `git add -A` commits the link and burns an
|
|
115
|
+
// attempt on the scope gate. Write `node_modules` (no slash: matches the link too) into the exclude
|
|
116
|
+
// file git actually consults for this worktree. Per-worktree `info/` is ignored in linked worktrees
|
|
117
|
+
// (gitrepository-layout redirects it), so resolve through the `.git` gitfile + `commondir` indirection
|
|
118
|
+
// to the common git dir. Local git metadata only — the target repository's .gitignore is never edited.
|
|
119
|
+
// Idempotent (appends only when no entry exists) and best-effort, like the link itself.
|
|
120
|
+
function excludeNodeModules(dir) {
|
|
121
|
+
try {
|
|
122
|
+
let gitDir = join(dir, ".git");
|
|
123
|
+
if (lstatSync(gitDir).isFile()) { // linked worktree: .git is a gitfile naming the real git dir
|
|
124
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(gitDir, "utf8"));
|
|
125
|
+
if (!m)
|
|
126
|
+
return;
|
|
127
|
+
gitDir = resolve(dir, m[1]);
|
|
128
|
+
}
|
|
129
|
+
const commondir = join(gitDir, "commondir");
|
|
130
|
+
if (existsSync(commondir))
|
|
131
|
+
gitDir = resolve(gitDir, readFileSync(commondir, "utf8").trim());
|
|
132
|
+
const exclude = join(gitDir, "info", "exclude");
|
|
133
|
+
const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
|
|
134
|
+
if (/^node_modules$/m.test(current))
|
|
135
|
+
return; // already excluded — repeated re-asserts add nothing
|
|
136
|
+
mkdirSync(join(gitDir, "info"), { recursive: true });
|
|
137
|
+
writeFileSync(exclude, current + (current && !current.endsWith("\n") ? "\n" : "") + "node_modules\n");
|
|
138
|
+
}
|
|
139
|
+
catch { /* not a git checkout (bare tmpdir in tests) — never fail provisioning over the exclude */ }
|
|
140
|
+
}
|
|
113
141
|
export function linkNodeModules(repo, dir, { force = false } = {}) {
|
|
114
142
|
const src = join(repo, "node_modules");
|
|
115
143
|
const dest = join(dir, "node_modules");
|
|
116
144
|
if (!existsSync(src))
|
|
117
145
|
return true; // nothing provisioned to link — correct state is no link (OBS-27 best-effort)
|
|
146
|
+
excludeNodeModules(dir); // OBS-78: the link must never be stageable in this worktree
|
|
118
147
|
try {
|
|
119
148
|
if (lstatSync(dest).isSymbolicLink() && readlinkSync(dest) === src)
|
|
120
149
|
return true; // already the provisioned link
|