tickmarkr 1.51.0 → 1.53.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/codex.js +3 -0
- package/dist/adapters/kimi.d.ts +1 -0
- package/dist/adapters/kimi.js +24 -0
- package/dist/adapters/model-lints.d.ts +0 -1
- package/dist/adapters/model-lints.js +2 -11
- package/dist/adapters/registry.js +56 -37
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/fleet.js +13 -11
- package/dist/cli/commands/plan.js +3 -3
- 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 +10 -10
- package/dist/config/config.js +84 -38
- package/dist/gates/review.d.ts +2 -1
- package/dist/gates/review.js +13 -4
- package/dist/route/router.d.ts +1 -0
- package/dist/route/router.js +6 -6
- package/dist/run/daemon.d.ts +1 -0
- package/dist/run/daemon.js +24 -5
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +10 -1
- package/package.json +1 -1
package/dist/adapters/codex.js
CHANGED
|
@@ -126,6 +126,9 @@ export function hasCodexTrustedProject(text, root) {
|
|
|
126
126
|
export const codex = {
|
|
127
127
|
id: "codex",
|
|
128
128
|
vendor: "openai",
|
|
129
|
+
// OBS-72: codex probes self-contend in one repo (v1.33.5: 4 concurrent → all 4 timed out;
|
|
130
|
+
// 2026-07-18: gpt-5.5 lost the concurrent pair every doctor sweep) — probe one model at a time.
|
|
131
|
+
probeConcurrency: 1,
|
|
129
132
|
probe: async () => probeVersion("codex"),
|
|
130
133
|
channels: (cfg) => channelsFromConfig("codex", cfg),
|
|
131
134
|
// --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.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,7 +17,6 @@ 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 mapTierLints(cfg: TickmarkrConfig): string[];
|
|
21
20
|
export declare function modelLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
|
|
22
21
|
tty?: boolean;
|
|
23
22
|
stateDir?: string;
|
|
@@ -68,8 +68,8 @@ export function contextWindowLints(tasks, assignments, cfg, repoRoot) {
|
|
|
68
68
|
return lints;
|
|
69
69
|
}
|
|
70
70
|
const adapterHasAuthedChannel = (adapterId, shape, cfg, health, adapters) => {
|
|
71
|
-
|
|
72
|
-
const minTier =
|
|
71
|
+
// v1.52 T5: routing.floors is the only band authority — a map entry can no longer carry a tier.
|
|
72
|
+
const minTier = cfg.routing.floors[shape] ?? "cheap";
|
|
73
73
|
const a = adapters.find((x) => x.id === adapterId);
|
|
74
74
|
const h = health[adapterId];
|
|
75
75
|
if (!a || !h?.installed || typeof a.channels !== "function")
|
|
@@ -95,14 +95,6 @@ export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new
|
|
|
95
95
|
}
|
|
96
96
|
return lints;
|
|
97
97
|
}
|
|
98
|
-
// v1.51 T3: built-in defaults no longer carry map tiers, so any tier surviving the merge came from an
|
|
99
|
-
// overlay. Deprecated — routing.floors is the single band authority; MapEntrySchema.tier removal itself
|
|
100
|
-
// is deferred to the next version. One lint per shape, identical text on every surface (plan + doctor).
|
|
101
|
-
export function mapTierLints(cfg) {
|
|
102
|
-
return Object.entries(cfg.routing.map)
|
|
103
|
-
.filter(([, entry]) => entry.tier !== undefined)
|
|
104
|
-
.map(([shape, entry]) => `routing.map.${shape}.tier: ${entry.tier} is deprecated — move this value to routing.floors.${shape} (single band authority; tier removal deferred to next version)`);
|
|
105
|
-
}
|
|
106
98
|
// Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
|
|
107
99
|
// No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
|
|
108
100
|
// modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
|
|
@@ -145,7 +137,6 @@ export function modelLints(cfg, health, adapters, opts) {
|
|
|
145
137
|
}
|
|
146
138
|
}
|
|
147
139
|
lints.push(...seedPreferLints(cfg, health, adapters, opts?.overlayPreferShapes));
|
|
148
|
-
lints.push(...mapTierLints(cfg)); // v1.51 T3: shared here so plan and doctor draw the exact same message
|
|
149
140
|
return lints;
|
|
150
141
|
}
|
|
151
142
|
// T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
|
|
@@ -82,8 +82,10 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
|
|
|
82
82
|
if (timedOut)
|
|
83
83
|
return `probe timed out after ${timeoutMs}ms`;
|
|
84
84
|
const output = `${stderr}\n${stdout}`.trim().replace(/\s+/g, " ");
|
|
85
|
+
// OBS-72: TAIL, not head — the error lands at the END of CLI output; a head slice stores only the
|
|
86
|
+
// startup banner and hid the real "Not inside a trusted directory" failure for a day.
|
|
85
87
|
return code !== 0 || QUOTA_RE.test(output) || AUTH_FAILURE_RE.test(output)
|
|
86
|
-
? output.slice(
|
|
88
|
+
? output.slice(-240) || `probe exited ${code}`
|
|
87
89
|
: undefined;
|
|
88
90
|
}
|
|
89
91
|
export const pendingAutoPreferKey = Symbol.for("tickmarkr.pendingAutoPrefer");
|
|
@@ -97,8 +99,9 @@ export const DOCTOR_ROUTING_STALE_MS = 24 * 60 * 60 * 1000;
|
|
|
97
99
|
export function deriveAutoPrefer(cfg, adapters, health, profile) {
|
|
98
100
|
const derivedAt = new Date().toISOString();
|
|
99
101
|
const out = { derivedAt };
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
// v1.52 T5: routing.floors is the only band authority — a map entry can no longer carry a tier.
|
|
103
|
+
for (const shape of Object.keys(cfg.routing.map)) {
|
|
104
|
+
const minTier = cfg.routing.floors[shape] ?? "cheap";
|
|
102
105
|
const ranked = [];
|
|
103
106
|
for (const a of adapters) {
|
|
104
107
|
const h = health[a.id];
|
|
@@ -153,54 +156,70 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
|
|
|
153
156
|
const verdicts = {};
|
|
154
157
|
const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
|
|
155
158
|
const probeRoot = a.probeCwd === "neutral" ? mkdtempSync(join(tmpdir(), "tickmarkr-probe-")) : repoRoot;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
const store = (model, verdict) => {
|
|
160
|
+
verdicts[model] = verdict;
|
|
161
|
+
onProgress?.(a.id, model, probeModelStatus(verdict), verdict.durationMs);
|
|
162
|
+
};
|
|
163
|
+
// One bounded probe call. verdict:null = a first-pass failure that earns the one serial retry
|
|
164
|
+
// (OBS-72: a failure inside the concurrent batch is indistinguishable from adapter self-contention
|
|
165
|
+
// until re-probed alone); a retry attempt always returns a final verdict.
|
|
166
|
+
const attempt = async (model, retry) => {
|
|
161
167
|
const t0 = Date.now();
|
|
162
168
|
const probedAt = new Date().toISOString();
|
|
163
|
-
const
|
|
164
|
-
let verdict;
|
|
169
|
+
const v = (authed, reason) => ({ authed, ...(reason !== undefined ? { reason } : {}), probedAt, durationMs: Date.now() - t0 });
|
|
165
170
|
try {
|
|
166
|
-
if (typeof a.headlessCommand !== "function")
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
else if (r.timedOut) {
|
|
177
|
-
const r2 = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
|
|
178
|
-
if (r2.timedOut) {
|
|
179
|
-
verdict = { authed: false, reason: `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
const reason = probeFailure(r2.code, r2.stdout, r2.stderr, r2.timedOut, MODEL_PROBE_TIMEOUT_MS);
|
|
183
|
-
verdict = reason ? { authed: false, reason, probedAt, durationMs: Date.now() - t0 } : { authed: true, probedAt, durationMs: Date.now() - t0 };
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
|
|
188
|
-
verdict = reason ? { authed: false, reason, probedAt, durationMs: Date.now() - t0 } : { authed: true, probedAt, durationMs: Date.now() - t0 };
|
|
189
|
-
}
|
|
171
|
+
if (typeof a.headlessCommand !== "function")
|
|
172
|
+
return { verdict: v(false, "headless probe unavailable"), timedOut: false };
|
|
173
|
+
const promptFile = join(mkdtempSync(join(tmpdir(), "tickmarkr-auth-")), "probe.md");
|
|
174
|
+
writeFileSync(promptFile, MODEL_PROBE_PROMPT);
|
|
175
|
+
const r = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
|
|
176
|
+
// T2 rule unchanged: a prior doctor.json timeout skips the retry — a persistently dead
|
|
177
|
+
// model (e.g. opencode glm-5.2) costs one attempt instead of two every run.
|
|
178
|
+
if (r.timedOut && !retry && priorModelAuth?.[model]?.reason?.includes("timed out") === true) {
|
|
179
|
+
return { verdict: v(false, `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`), timedOut: true };
|
|
190
180
|
}
|
|
181
|
+
const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
|
|
182
|
+
if (!reason)
|
|
183
|
+
return { verdict: v(true), timedOut: false };
|
|
184
|
+
if (!retry)
|
|
185
|
+
return { verdict: null, timedOut: r.timedOut === true };
|
|
186
|
+
return {
|
|
187
|
+
verdict: r.timedOut && retry.firstTimedOut ? v(false, `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`) : v(false, reason),
|
|
188
|
+
timedOut: r.timedOut === true,
|
|
189
|
+
};
|
|
191
190
|
}
|
|
192
191
|
catch (e) {
|
|
193
|
-
|
|
192
|
+
return { verdict: retry ? v(false, String(e)) : null, timedOut: false };
|
|
194
193
|
}
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
};
|
|
195
|
+
// models probe concurrently too (v1.33.5) — sequential chains made init wall time Σ(models×60s)
|
|
196
|
+
// on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
|
|
197
|
+
// Capped at MODEL_PROBE_CONCURRENCY per adapter unless the adapter declares its own cap
|
|
198
|
+
// (codex: 1 — OBS-72, its concurrent probes self-contend in one repo).
|
|
199
|
+
const retries = [];
|
|
200
|
+
await mapLimit(Object.keys(cfg.tiers[a.id]?.models ?? {}), a.probeConcurrency ?? MODEL_PROBE_CONCURRENCY, async (model) => {
|
|
201
|
+
const first = await attempt(model);
|
|
202
|
+
if (first.verdict)
|
|
203
|
+
store(model, first.verdict);
|
|
204
|
+
else
|
|
205
|
+
retries.push({ model, firstTimedOut: first.timedOut });
|
|
197
206
|
});
|
|
207
|
+
// OBS-72: re-probe each first-pass failure once with ONE probe in flight, only after the
|
|
208
|
+
// concurrent batch drained — an in-slot retry still races its concurrency partner. Success here
|
|
209
|
+
// was contention; a second failure is the real verdict. Successful first-pass probes stored
|
|
210
|
+
// above and never wait; cost is one extra call per genuinely dead model only.
|
|
211
|
+
for (const { model, firstTimedOut } of retries) {
|
|
212
|
+
const second = await attempt(model, { firstTimedOut });
|
|
213
|
+
if (second.verdict)
|
|
214
|
+
store(model, second.verdict);
|
|
215
|
+
}
|
|
198
216
|
h.modelAuth = verdicts;
|
|
199
217
|
}));
|
|
200
218
|
health[pendingAutoPreferKey] = deriveAutoPrefer(cfg, adapters, health);
|
|
201
219
|
}
|
|
202
220
|
// T2: caps concurrent probes per adapter at 2 — v1.33.5 regression, 4 concurrent codex exec in one
|
|
203
221
|
// repo made ALL 4 time out where sequential passed 2/4 (suspected CLI self-contention).
|
|
222
|
+
// v1.52 T4: default only — an adapter's own probeConcurrency declaration wins (codex: 1).
|
|
204
223
|
const MODEL_PROBE_CONCURRENCY = 2;
|
|
205
224
|
async function mapLimit(items, limit, fn) {
|
|
206
225
|
let i = 0;
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -73,11 +73,14 @@ export interface WorkerAdapter {
|
|
|
73
73
|
id: string;
|
|
74
74
|
vendor: string;
|
|
75
75
|
probeCwd?: "repo" | "neutral";
|
|
76
|
+
probeConcurrency?: number;
|
|
76
77
|
probe(): Promise<AuthHealth>;
|
|
77
78
|
channels(cfg: TickmarkrConfig): BillingChannel[];
|
|
78
79
|
headlessCommand(promptFile: string, model: string): string;
|
|
79
80
|
interactiveCommand(promptFile: string, model: string): string | null;
|
|
80
81
|
resumeCommand?(sessionId: string, promptFile: string, model: string): string;
|
|
82
|
+
sessionIdFrom?(output: string): string | undefined;
|
|
83
|
+
resumeUnknownContext?: boolean;
|
|
81
84
|
invoke(task: Task, cwd: string, a: Assignment, ctx: {
|
|
82
85
|
promptFile: string;
|
|
83
86
|
}): Invocation;
|
|
@@ -6,7 +6,7 @@ import { parseArgs } from "node:util";
|
|
|
6
6
|
import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
|
|
7
7
|
import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
|
|
8
8
|
import { GLYPHS, bold, legend as legendText, toggleActive, toggleInactive } from "../../brand.js";
|
|
9
|
-
import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
|
|
9
|
+
import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
|
|
10
10
|
import { SHAPES, TIERS } from "../../graph/schema.js";
|
|
11
11
|
import { route } from "../../route/router.js";
|
|
12
12
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
@@ -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
|
}
|
|
@@ -357,7 +360,8 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
357
360
|
return `${shape} → ${now}${auto}`;
|
|
358
361
|
});
|
|
359
362
|
const shapesTitle = "step 5/5 · shape routing";
|
|
360
|
-
|
|
363
|
+
// v1.52 T5: no map-tier editing action — routing.floors is the only band authority now.
|
|
364
|
+
const shapesLegend = "↑↓/jk move · a auto · p pin · f prefer · enter next · esc/q quit";
|
|
361
365
|
let sCursor = 0;
|
|
362
366
|
term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
|
|
363
367
|
for (;;) {
|
|
@@ -388,15 +392,6 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
388
392
|
editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
|
|
389
393
|
changed = true;
|
|
390
394
|
}
|
|
391
|
-
else if (k.name === "t") {
|
|
392
|
-
const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
|
|
393
|
-
if (!TIERS.includes(tier))
|
|
394
|
-
return { out: `fleet: invalid tier ${tier}`, code: 1 };
|
|
395
|
-
const next = { ...entry, tier };
|
|
396
|
-
delete next.pin;
|
|
397
|
-
editable.map[shape] = next;
|
|
398
|
-
changed = true;
|
|
399
|
-
}
|
|
400
395
|
else if (k.name === "f") {
|
|
401
396
|
const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
|
|
402
397
|
editable.map[shape] = { ...entry, prefer: pref.split(",").map((s) => s.trim()).filter(Boolean) };
|
|
@@ -424,6 +419,13 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
424
419
|
if (!/^y(?:es)?$/i.test(confirm))
|
|
425
420
|
return "fleet: discarded overlay changes";
|
|
426
421
|
const path = repoOverlayPath(cwd);
|
|
422
|
+
// v1.52 T2 reload guard: the exact bytes about to land must reload through the production
|
|
423
|
+
// loader path, or the write is refused and the existing overlay stays untouched (OBS-75 class:
|
|
424
|
+
// fleet must never persist a config that bricks every later command).
|
|
425
|
+
const loadError = overlayBytesLoadError(cwd, after, { globalDir });
|
|
426
|
+
if (loadError) {
|
|
427
|
+
return { out: `fleet: refusing to write ${path} — the config loader rejects the proposed overlay:\n${loadError}`, code: 1 };
|
|
428
|
+
}
|
|
427
429
|
mkdirSync(dirname(path), { recursive: true });
|
|
428
430
|
writeFileSync(path, after);
|
|
429
431
|
return `fleet: wrote ${path}`;
|
|
@@ -2,7 +2,7 @@ import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeA
|
|
|
2
2
|
import { formatModelAuthLine, contextWindowLints, modelLints, 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";
|
|
5
|
+
import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
|
|
6
6
|
import { DEFAULT_CONFIG, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
7
7
|
import { loadGraph } from "../../graph/graph.js";
|
|
8
8
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
@@ -170,8 +170,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
170
170
|
const windowLints = contextWindowLints(g.tasks, routed, cfg, cwd);
|
|
171
171
|
if (windowLints.length)
|
|
172
172
|
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);
|
|
173
|
+
// OBS-12/13/14/21 + OBS-76: advisory only — never blocks --route-strict (run refuses on routing lints only)
|
|
174
|
+
const scopeLints = [...collateralLints(g.tasks, cwd), ...sourceScopeLints(g.tasks, cwd)];
|
|
175
175
|
if (scopeLints.length)
|
|
176
176
|
lines.push("", "scope lints:", ...scopeLints.map((l) => ` ! ${l}`));
|
|
177
177
|
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
|
@@ -20,11 +20,7 @@ export declare const MapEntrySchema: z.ZodObject<{
|
|
|
20
20
|
via: z.ZodString;
|
|
21
21
|
model: z.ZodString;
|
|
22
22
|
}, z.core.$strip>>;
|
|
23
|
-
tier: z.ZodOptional<z.
|
|
24
|
-
cheap: "cheap";
|
|
25
|
-
mid: "mid";
|
|
26
|
-
frontier: "frontier";
|
|
27
|
-
}>>;
|
|
23
|
+
tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
|
|
28
24
|
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
29
25
|
escalate: z.ZodOptional<z.ZodBoolean>;
|
|
30
26
|
}, z.core.$strip>;
|
|
@@ -78,11 +74,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
78
74
|
via: z.ZodString;
|
|
79
75
|
model: z.ZodString;
|
|
80
76
|
}, z.core.$strip>>;
|
|
81
|
-
tier: z.ZodOptional<z.
|
|
82
|
-
cheap: "cheap";
|
|
83
|
-
mid: "mid";
|
|
84
|
-
frontier: "frontier";
|
|
85
|
-
}>>;
|
|
77
|
+
tier: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodNull>, z.ZodTransform<undefined, null | undefined>>>;
|
|
86
78
|
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
87
79
|
escalate: z.ZodOptional<z.ZodBoolean>;
|
|
88
80
|
}, z.core.$strip>>;
|
|
@@ -176,6 +168,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
176
168
|
review: z.ZodObject<{
|
|
177
169
|
complexityThreshold: z.ZodNumber;
|
|
178
170
|
required: z.ZodBoolean;
|
|
171
|
+
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
179
172
|
}, z.core.$strip>;
|
|
180
173
|
consult: z.ZodObject<{
|
|
181
174
|
adapter: z.ZodString;
|
|
@@ -219,6 +212,7 @@ export type ModeResolution = {
|
|
|
219
212
|
* already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
|
|
220
213
|
export declare function loadConfigWithMode(repoRoot: string, opts?: {
|
|
221
214
|
globalDir?: string;
|
|
215
|
+
repoOverlayText?: string;
|
|
222
216
|
}): {
|
|
223
217
|
cfg: TickmarkrConfig;
|
|
224
218
|
mode: ModeResolution;
|
|
@@ -226,6 +220,12 @@ export declare function loadConfigWithMode(repoRoot: string, opts?: {
|
|
|
226
220
|
export declare function loadConfig(repoRoot: string, opts?: {
|
|
227
221
|
globalDir?: string;
|
|
228
222
|
}): TickmarkrConfig;
|
|
223
|
+
/** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
|
|
224
|
+
* loader path every later command uses (parse → merge → schema → mode resolution). Returns null
|
|
225
|
+
* when the bytes load, else the loader's failure message — the caller must refuse the write. */
|
|
226
|
+
export declare function overlayBytesLoadError(repoRoot: string, bytes: string, opts?: {
|
|
227
|
+
globalDir?: string;
|
|
228
|
+
}): string | null;
|
|
229
229
|
export type InitConfigOverlay = {
|
|
230
230
|
concurrency?: number;
|
|
231
231
|
driver?: TickmarkrConfig["driver"];
|
package/dist/config/config.js
CHANGED
|
@@ -20,9 +20,21 @@ const ModeEnum = z.enum(ROUTING_MODES, {
|
|
|
20
20
|
// Integrity set (weak-oracle shapes, OBS-68..70 class): no mode may resolve these below frontier.
|
|
21
21
|
// Only an explicit operator floor line can — and it draws a standing plan lint every load.
|
|
22
22
|
export const INTEGRITY_FLOOR_SHAPES = ["plan", "spec", "migration", "ui"];
|
|
23
|
+
// v1.52 T5: tier is no longer a map-entry band authority — routing.floors is the only tier
|
|
24
|
+
// source left (v1.51 T3 deprecated this field with a promise to remove it here). Only the legacy
|
|
25
|
+
// `tier: null` tombstone still parses (and normalizes to absent); any real value fails config
|
|
26
|
+
// load fail-closed, naming routing.floors as the move target, so a band intent set here can never
|
|
27
|
+
// silently vanish. The field stays in the schema/type as an always-undefined marker (not deleted
|
|
28
|
+
// outright) so a map entry can never carry a real tier value again, structurally.
|
|
23
29
|
export const MapEntrySchema = z.object({
|
|
24
30
|
pin: z.object({ via: z.string(), model: z.string() }).optional(),
|
|
25
|
-
tier:
|
|
31
|
+
tier: z
|
|
32
|
+
.null({
|
|
33
|
+
error: (iss) => `routing.map entry tier ${JSON.stringify(iss.input)} is no longer a band authority — move this value to routing.floors.<shape> (only "tier: null" tombstones still parse)`,
|
|
34
|
+
})
|
|
35
|
+
.optional()
|
|
36
|
+
.transform(() => undefined)
|
|
37
|
+
.optional(),
|
|
26
38
|
prefer: z.array(z.string()).optional(),
|
|
27
39
|
escalate: z.boolean().optional(),
|
|
28
40
|
});
|
|
@@ -140,7 +152,9 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
140
152
|
allowDeviations: z.array(z.string()),
|
|
141
153
|
}).partial().optional(),
|
|
142
154
|
judge: z.object({ adapter: z.string(), model: z.string() }),
|
|
143
|
-
|
|
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() }),
|
|
144
158
|
consult: z.object({ adapter: z.string(), model: z.string(), stallMinutes: z.number().positive() }),
|
|
145
159
|
visibility: z.object({
|
|
146
160
|
llm: z.enum(["pane", "headless"]),
|
|
@@ -167,11 +181,10 @@ export const DEFAULT_CONFIG = {
|
|
|
167
181
|
taskTimeoutMinutes: 30,
|
|
168
182
|
contextWarnTokens: 170_000, // v1.23 T2: overseer ctx-watch.sh proven threshold
|
|
169
183
|
routing: {
|
|
170
|
-
// v1.51 T3 (round-2 consult): map entries carry preferences/pins only — band
|
|
171
|
-
// routing.floors, the single tier authority.
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
// is deferred to the next version (tombstone grammar: tier: null).
|
|
184
|
+
// v1.51 T3 (round-2 consult) / v1.52 T5: map entries carry preferences/pins only — band
|
|
185
|
+
// policy lives in routing.floors, the single tier authority. A map entry can no longer carry
|
|
186
|
+
// a real tier value at all: MapEntrySchema fails config load fail-closed on one, naming
|
|
187
|
+
// routing.floors as the move target; only the legacy `tier: null` tombstone still parses.
|
|
175
188
|
map: {
|
|
176
189
|
plan: { pin: { via: "claude-code", model: "fable" } },
|
|
177
190
|
spec: { pin: { via: "claude-code", model: "fable" } },
|
|
@@ -294,7 +307,9 @@ function deepMerge(base, over) {
|
|
|
294
307
|
delete out[k]; // v1.1 tombstone: an explicit null in an overlay removes the key (e.g. stale tiers model ids)
|
|
295
308
|
continue;
|
|
296
309
|
}
|
|
297
|
-
|
|
310
|
+
// OBS-75 class: merge fresh-landing objects onto {} so nested tombstone nulls are pruned even when
|
|
311
|
+
// no lower layer set the key (a fleet-cleared deny writes {adapters: null} — the schema rejects raw null)
|
|
312
|
+
out[k] = k in out ? deepMerge(out[k], v) : deepMerge({}, v);
|
|
298
313
|
}
|
|
299
314
|
return out;
|
|
300
315
|
}
|
|
@@ -393,7 +408,11 @@ function resolveRoutingMode(cfg, layers) {
|
|
|
393
408
|
* already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
|
|
394
409
|
export function loadConfigWithMode(repoRoot, opts = {}) {
|
|
395
410
|
const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
|
|
396
|
-
|
|
411
|
+
// v1.52 T2: repoOverlayText substitutes candidate bytes for the on-disk repo layer — the fleet
|
|
412
|
+
// write guard validates EXACTLY what it is about to write through this one loader path.
|
|
413
|
+
const repoCfg = opts.repoOverlayText === undefined
|
|
414
|
+
? readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))
|
|
415
|
+
: parse(opts.repoOverlayText);
|
|
397
416
|
const merged = deepMerge(deepMerge(structuredClone(DEFAULT_CONFIG), globalCfg), repoCfg);
|
|
398
417
|
const r = TickmarkrConfigSchema.safeParse(merged);
|
|
399
418
|
if (!r.success)
|
|
@@ -403,6 +422,18 @@ export function loadConfigWithMode(repoRoot, opts = {}) {
|
|
|
403
422
|
export function loadConfig(repoRoot, opts = {}) {
|
|
404
423
|
return loadConfigWithMode(repoRoot, opts).cfg;
|
|
405
424
|
}
|
|
425
|
+
/** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
|
|
426
|
+
* loader path every later command uses (parse → merge → schema → mode resolution). Returns null
|
|
427
|
+
* when the bytes load, else the loader's failure message — the caller must refuse the write. */
|
|
428
|
+
export function overlayBytesLoadError(repoRoot, bytes, opts = {}) {
|
|
429
|
+
try {
|
|
430
|
+
loadConfigWithMode(repoRoot, { ...opts, repoOverlayText: bytes });
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
catch (e) {
|
|
434
|
+
return e.message;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
406
437
|
export function configTemplate(overlay) {
|
|
407
438
|
const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
|
|
408
439
|
# concurrency: 3
|
|
@@ -418,7 +449,7 @@ export function configTemplate(overlay) {
|
|
|
418
449
|
# map: # per-shape preferences/pins; band policy lives in routing.floors
|
|
419
450
|
# implement: { prefer: [cursor-agent, codex] }
|
|
420
451
|
# migration: { pin: { via: claude-code, model: fable } }
|
|
421
|
-
# tests: { tier: null } # tombstone: null removes a legacy map tier (
|
|
452
|
+
# tests: { tier: null } # tombstone: null removes a legacy map tier (a real tier value now fails config load — move the band to routing.floors.tests)
|
|
422
453
|
# floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
|
|
423
454
|
# migration: frontier
|
|
424
455
|
# learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'tickmarkr plan'
|
|
@@ -445,7 +476,9 @@ export function configTemplate(overlay) {
|
|
|
445
476
|
# test: npm test
|
|
446
477
|
# byShape:
|
|
447
478
|
# docs: { acceptance: false, review: false } # baseline, evidence, and scope are mandatory
|
|
448
|
-
# review: { complexityThreshold: 7, required: true }
|
|
479
|
+
# review: { complexityThreshold: 7, required: true, prefer: [codex:gpt-5.6-sol, kimi] }
|
|
480
|
+
# # prefer: ordered reviewer seat preference (adapter | adapter:model); ranks
|
|
481
|
+
# # diversity-eligible channels only — never admits a same-vendor/same-model reviewer
|
|
449
482
|
# consult: { adapter: claude-code, model: fable, stallMinutes: 15 }
|
|
450
483
|
# cost: # v1.20 REC-02 — OPTIONAL price table for the usage/cost report. Absent ⇒ every
|
|
451
484
|
# # channel reports "not measurable" (never a crash or a fake $0). Two economics,
|
|
@@ -656,7 +689,9 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
|
656
689
|
}
|
|
657
690
|
}
|
|
658
691
|
if (Object.keys(modelDelta).length) {
|
|
659
|
-
|
|
692
|
+
// spread the existing entry so vendor/channel/windows survive the rewrite — dropping them
|
|
693
|
+
// makes the overlay unloadable for any adapter without a default seed (reload-guard class)
|
|
694
|
+
tiersOut[adapter] = { ...tiersOut[adapter], models: { ...tiersOut[adapter]?.models, ...modelDelta } };
|
|
660
695
|
tiersTouched = true;
|
|
661
696
|
}
|
|
662
697
|
}
|
|
@@ -682,47 +717,58 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
|
682
717
|
return "";
|
|
683
718
|
const lines = [];
|
|
684
719
|
const today = new Date().toISOString().slice(0, 10);
|
|
720
|
+
// OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
|
|
721
|
+
// re-indent the whole emitted block, so sequences/nested maps nest correctly and null
|
|
722
|
+
// tombstones/empty collections survive the serialize→parse round-trip.
|
|
723
|
+
const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
|
|
685
724
|
const routing = overlay.routing;
|
|
686
725
|
if (routing) {
|
|
687
726
|
lines.push("routing:");
|
|
688
727
|
const deny = routing.deny;
|
|
689
|
-
if (deny) {
|
|
728
|
+
if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
|
|
690
729
|
lines.push(" deny:");
|
|
691
|
-
if (deny.adapters)
|
|
692
|
-
lines.push(
|
|
693
|
-
if (deny.models)
|
|
694
|
-
lines.push(
|
|
695
|
-
}
|
|
696
|
-
if (routing.map) {
|
|
697
|
-
lines.push(" map:");
|
|
698
|
-
for (const [shape, entry] of Object.entries(routing.map)) {
|
|
699
|
-
lines.push(` ${shape}:`);
|
|
700
|
-
const body = stringify(entry, { indent: 2 }).trim().split("\n");
|
|
701
|
-
lines.push(...body.map((l) => ` ${l}`));
|
|
702
|
-
}
|
|
730
|
+
if (deny.adapters !== undefined)
|
|
731
|
+
lines.push(...block({ adapters: deny.adapters }, " "));
|
|
732
|
+
if (deny.models !== undefined)
|
|
733
|
+
lines.push(...block({ models: deny.models }, " "));
|
|
703
734
|
}
|
|
735
|
+
if (routing.map)
|
|
736
|
+
lines.push(...block({ map: routing.map }, " "));
|
|
704
737
|
if (routing.floors)
|
|
705
|
-
lines.push(
|
|
738
|
+
lines.push(...block({ floors: routing.floors }, " "));
|
|
706
739
|
}
|
|
707
740
|
const tiers = overlay.tiers;
|
|
708
|
-
if (tiers) {
|
|
741
|
+
if (tiers && Object.keys(tiers).length) {
|
|
709
742
|
lines.push("tiers:");
|
|
710
743
|
for (const [adapter, entry] of Object.entries(tiers)) {
|
|
711
|
-
|
|
744
|
+
const body = [];
|
|
712
745
|
if (entry.vendor)
|
|
713
|
-
|
|
746
|
+
body.push(` vendor: ${entry.vendor}`);
|
|
714
747
|
if (entry.channel)
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
748
|
+
body.push(` channel: ${entry.channel}`);
|
|
749
|
+
if (entry.windows)
|
|
750
|
+
body.push(...block({ windows: entry.windows }, " "));
|
|
751
|
+
const models = Object.entries(entry.models ?? {});
|
|
752
|
+
if (models.length) {
|
|
753
|
+
body.push(" models:");
|
|
754
|
+
for (const [model, tier] of models) {
|
|
755
|
+
if (tier === null)
|
|
756
|
+
body.push(` ${model}: null`);
|
|
757
|
+
else {
|
|
758
|
+
const note = provenance[adapter]?.[model];
|
|
759
|
+
const suffix = note ? ` # ${note} — fleet ${today}` : "";
|
|
760
|
+
body.push(` ${model}: ${tier}${suffix}`);
|
|
761
|
+
}
|
|
724
762
|
}
|
|
725
763
|
}
|
|
764
|
+
else if (entry.models) {
|
|
765
|
+
body.push(" models: {}"); // present-but-empty: explicit {} — a childless header parses as null and the loader rejects it
|
|
766
|
+
}
|
|
767
|
+
// a bare `adapter:` line parses as a null tombstone and would DELETE the adapter's default seeds on merge
|
|
768
|
+
if (body.length)
|
|
769
|
+
lines.push(` ${adapter}:`, ...body);
|
|
770
|
+
else
|
|
771
|
+
lines.push(` ${adapter}: {}`);
|
|
726
772
|
}
|
|
727
773
|
}
|
|
728
774
|
return `${lines.join("\n")}\n`;
|
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/route/router.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface ExploreContext {
|
|
|
32
32
|
}
|
|
33
33
|
export declare const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
|
|
34
34
|
export declare const QUALITY_ENV = "TICKMARKR_QUALITY";
|
|
35
|
+
export declare const ROUTING_ENV_SEAMS: readonly ["TICKMARKR_QUALITY", "TICKMARKR_NO_EXPLORE"];
|
|
35
36
|
export declare const raiseTier: (tier: Tier) => Tier;
|
|
36
37
|
export declare class RoutingError extends Error {
|
|
37
38
|
constructor(msg: string);
|
package/dist/route/router.js
CHANGED
|
@@ -4,6 +4,9 @@ import { disallowedBy } from "./preference.js";
|
|
|
4
4
|
import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
|
|
5
5
|
export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
|
|
6
6
|
export const QUALITY_ENV = "TICKMARKR_QUALITY";
|
|
7
|
+
// OBS-74: every routing env seam, in one list — the spawn seam (src/run/git.ts) scrubs exactly
|
|
8
|
+
// these from child env so gate/baseline/tip-verify children are hermetic by construction.
|
|
9
|
+
export const ROUTING_ENV_SEAMS = [QUALITY_ENV, NO_EXPLORE_ENV];
|
|
7
10
|
const exploreCap = (cfg) => cfg.routing.explore?.cap ?? EXPLORE_CAP;
|
|
8
11
|
const qualityOn = (exploreCtx) => !!exploreCtx?.quality || process.env[QUALITY_ENV] === "1";
|
|
9
12
|
export const raiseTier = (tier) => tier === "cheap" ? "mid" : tier === "mid" ? "frontier" : "frontier";
|
|
@@ -160,15 +163,13 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
160
163
|
maybeSlaLint(lints, task, profile, slaMinutes, c);
|
|
161
164
|
return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
|
|
162
165
|
}
|
|
163
|
-
const baseTier =
|
|
166
|
+
const baseTier = floor ?? "cheap";
|
|
164
167
|
let minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
|
|
165
168
|
if (quality && advisoryFloor) {
|
|
166
169
|
const raised = raiseTier(advisoryFloor);
|
|
167
170
|
if (TIER_RANK[raised] > TIER_RANK[minTier])
|
|
168
171
|
minTier = raised;
|
|
169
172
|
}
|
|
170
|
-
if (entry?.tier)
|
|
171
|
-
lintFloor(entry.tier, "map tier");
|
|
172
173
|
if (prefActive)
|
|
173
174
|
for (const p of prefer ?? [])
|
|
174
175
|
preflightPrefer(p);
|
|
@@ -253,9 +254,8 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
253
254
|
}
|
|
254
255
|
const bound = qualityBound(quality, advisoryFloor, taskFloorRaw) ??
|
|
255
256
|
(taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
"tier cheap (default)");
|
|
257
|
+
floor ? `floor ${floor} (config floors)` :
|
|
258
|
+
"tier cheap (default)");
|
|
259
259
|
// name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
|
|
260
260
|
// winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
|
|
261
261
|
const preferVia = preferFromAuto(task.shape, preferCtx)
|
package/dist/run/daemon.d.ts
CHANGED
package/dist/run/daemon.js
CHANGED
|
@@ -98,6 +98,10 @@ 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.
|
|
@@ -140,6 +144,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
140
144
|
// equivalence to the router.ts:194 profile⇒undefined pattern: no map entry ⇒ today's literal.
|
|
141
145
|
const resume = opts.resume ? journal.replayResumeState() : new Map();
|
|
142
146
|
if (opts.resume) {
|
|
147
|
+
// v1.53 T5: a superseded run is dead — resuming it beside its successor is the exact
|
|
148
|
+
// two-concurrent-runs hazard supersession exists to prevent. Fail closed, naming the successor.
|
|
149
|
+
const superseded = [...journal.read()].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
|
|
150
|
+
if (superseded)
|
|
151
|
+
throw new Error(`refusing to resume ${runId}: superseded by ${superseded.data.by}`);
|
|
143
152
|
const start = journal.read().find((e) => e.event === "run-start");
|
|
144
153
|
if (!start)
|
|
145
154
|
throw new Error(`journal for ${runId} has no run-start event`);
|
|
@@ -175,7 +184,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
175
184
|
baseRef = await gitHead(repoRoot);
|
|
176
185
|
baseline = await captureBaseline(repoRoot, commands);
|
|
177
186
|
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
|
|
187
|
+
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
|
|
188
|
+
// v1.53 T5: mark the prior run AFTER this run's run-start exists, so the prior journal never
|
|
189
|
+
// names a successor that has no journal. Append-only — the prior journal is never rewritten.
|
|
190
|
+
prior?.append("superseded", undefined, { by: runId });
|
|
179
191
|
}
|
|
180
192
|
// T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
|
|
181
193
|
// show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
|
|
@@ -412,13 +424,18 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
412
424
|
}
|
|
413
425
|
// v1.29: consume the prior gate-failed session once. Same channel + known under-threshold context
|
|
414
426
|
// + adapter capability resumes; every other path is today's fresh dispatch.
|
|
427
|
+
// v1.53 T3: an adapter with no context surface at all (kimi, KIMI-03) may declare
|
|
428
|
+
// resumeUnknownContext to loosen ONLY the contextTokens-known requirement — a KNOWN
|
|
429
|
+
// over-threshold context still forces fresh, and the escalation ladder bounds the chain.
|
|
415
430
|
const priorSession = retrySession;
|
|
416
431
|
retrySession = undefined;
|
|
432
|
+
const retryAdapter = adapters.find((a) => a.id === assignment.adapter);
|
|
417
433
|
retryMode = priorSession
|
|
418
434
|
&& priorSession.channel === channelKey(assignment)
|
|
419
|
-
&& priorSession.contextTokens !== undefined
|
|
420
|
-
|
|
421
|
-
|
|
435
|
+
&& (priorSession.contextTokens !== undefined
|
|
436
|
+
? priorSession.contextTokens < cfg.contextWarnTokens
|
|
437
|
+
: retryAdapter?.resumeUnknownContext === true)
|
|
438
|
+
&& retryAdapter?.resumeCommand
|
|
422
439
|
? "resume"
|
|
423
440
|
: "fresh";
|
|
424
441
|
// v1.23 T3: over-threshold context still forces fresh at the retry boundary; never interrupt a
|
|
@@ -854,7 +871,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
854
871
|
break gateLoop;
|
|
855
872
|
}
|
|
856
873
|
gateFails++; // this attempt's gates failed — the one place quality degradation is verified (never inferred from attempts)
|
|
857
|
-
|
|
874
|
+
// v1.53 T3: prefer the CLI's own session id captured from this attempt's output (kimi's resume
|
|
875
|
+
// trailer) over the harness slot name; absent hook or no capture keeps today's slot-name id.
|
|
876
|
+
retrySession = { channel: channelKey(assignment), id: adapter.sessionIdFrom?.(output) ?? sessionId, contextTokens };
|
|
858
877
|
feedback = results.filter((g) => !g.pass).map((g) => `${g.gate}: ${g.details}`).join("\n\n");
|
|
859
878
|
const step = r.ladder[Math.min(ladderIdx++, r.ladder.length - 1)];
|
|
860
879
|
journal.append("escalation", t.id, { step, attempt: attempt + 1 });
|
package/dist/run/git.d.ts
CHANGED
package/dist/run/git.js
CHANGED
|
@@ -3,14 +3,23 @@ import { existsSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:f
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { shq } from "../adapters/types.js";
|
|
5
5
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
6
|
+
import { ROUTING_ENV_SEAMS } from "../route/router.js";
|
|
7
|
+
export { ROUTING_ENV_SEAMS };
|
|
6
8
|
// stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
|
|
7
9
|
// (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
|
|
8
10
|
function shell(cmd, cwd, timeoutMs, login) {
|
|
11
|
+
// OBS-74: scrub tickmarkr's own routing env seams from every child — a daemon carrying
|
|
12
|
+
// TICKMARKR_QUALITY leaked it into baseline/gate/tip-verify children, turning a dogfood
|
|
13
|
+
// repo's route() tests red inside the gates. Scrub a copy at this one choke point so
|
|
14
|
+
// children are hermetic by construction; the daemon's own process.env stays unchanged.
|
|
15
|
+
const env = { ...process.env };
|
|
16
|
+
for (const k of ROUTING_ENV_SEAMS)
|
|
17
|
+
delete env[k];
|
|
9
18
|
return new Promise((resolve) => {
|
|
10
19
|
// detached: bash gets its own process group so a timeout can kill the whole tree —
|
|
11
20
|
// SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
|
|
12
21
|
// open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
|
|
13
|
-
const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
22
|
+
const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
14
23
|
let stdout = "", stderr = "";
|
|
15
24
|
let timedOut = false, done = false;
|
|
16
25
|
const finish = (code, err) => {
|