tickmarkr 1.51.0 → 1.52.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/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 +1 -0
- package/dist/cli/commands/fleet.js +10 -11
- package/dist/config/config.d.ts +9 -10
- package/dist/config/config.js +78 -36
- package/dist/route/router.d.ts +1 -0
- package/dist/route/router.js +6 -6
- 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+)
|
|
@@ -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,6 +73,7 @@ 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;
|
|
@@ -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";
|
|
@@ -357,7 +357,8 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
357
357
|
return `${shape} → ${now}${auto}`;
|
|
358
358
|
});
|
|
359
359
|
const shapesTitle = "step 5/5 · shape routing";
|
|
360
|
-
|
|
360
|
+
// v1.52 T5: no map-tier editing action — routing.floors is the only band authority now.
|
|
361
|
+
const shapesLegend = "↑↓/jk move · a auto · p pin · f prefer · enter next · esc/q quit";
|
|
361
362
|
let sCursor = 0;
|
|
362
363
|
term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
|
|
363
364
|
for (;;) {
|
|
@@ -388,15 +389,6 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
388
389
|
editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
|
|
389
390
|
changed = true;
|
|
390
391
|
}
|
|
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
392
|
else if (k.name === "f") {
|
|
401
393
|
const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
|
|
402
394
|
editable.map[shape] = { ...entry, prefer: pref.split(",").map((s) => s.trim()).filter(Boolean) };
|
|
@@ -424,6 +416,13 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
424
416
|
if (!/^y(?:es)?$/i.test(confirm))
|
|
425
417
|
return "fleet: discarded overlay changes";
|
|
426
418
|
const path = repoOverlayPath(cwd);
|
|
419
|
+
// v1.52 T2 reload guard: the exact bytes about to land must reload through the production
|
|
420
|
+
// loader path, or the write is refused and the existing overlay stays untouched (OBS-75 class:
|
|
421
|
+
// fleet must never persist a config that bricks every later command).
|
|
422
|
+
const loadError = overlayBytesLoadError(cwd, after, { globalDir });
|
|
423
|
+
if (loadError) {
|
|
424
|
+
return { out: `fleet: refusing to write ${path} — the config loader rejects the proposed overlay:\n${loadError}`, code: 1 };
|
|
425
|
+
}
|
|
427
426
|
mkdirSync(dirname(path), { recursive: true });
|
|
428
427
|
writeFileSync(path, after);
|
|
429
428
|
return `fleet: wrote ${path}`;
|
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>>;
|
|
@@ -219,6 +211,7 @@ export type ModeResolution = {
|
|
|
219
211
|
* already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
|
|
220
212
|
export declare function loadConfigWithMode(repoRoot: string, opts?: {
|
|
221
213
|
globalDir?: string;
|
|
214
|
+
repoOverlayText?: string;
|
|
222
215
|
}): {
|
|
223
216
|
cfg: TickmarkrConfig;
|
|
224
217
|
mode: ModeResolution;
|
|
@@ -226,6 +219,12 @@ export declare function loadConfigWithMode(repoRoot: string, opts?: {
|
|
|
226
219
|
export declare function loadConfig(repoRoot: string, opts?: {
|
|
227
220
|
globalDir?: string;
|
|
228
221
|
}): TickmarkrConfig;
|
|
222
|
+
/** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
|
|
223
|
+
* loader path every later command uses (parse → merge → schema → mode resolution). Returns null
|
|
224
|
+
* when the bytes load, else the loader's failure message — the caller must refuse the write. */
|
|
225
|
+
export declare function overlayBytesLoadError(repoRoot: string, bytes: string, opts?: {
|
|
226
|
+
globalDir?: string;
|
|
227
|
+
}): string | null;
|
|
229
228
|
export type InitConfigOverlay = {
|
|
230
229
|
concurrency?: number;
|
|
231
230
|
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
|
});
|
|
@@ -167,11 +179,10 @@ export const DEFAULT_CONFIG = {
|
|
|
167
179
|
taskTimeoutMinutes: 30,
|
|
168
180
|
contextWarnTokens: 170_000, // v1.23 T2: overseer ctx-watch.sh proven threshold
|
|
169
181
|
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).
|
|
182
|
+
// v1.51 T3 (round-2 consult) / v1.52 T5: map entries carry preferences/pins only — band
|
|
183
|
+
// policy lives in routing.floors, the single tier authority. A map entry can no longer carry
|
|
184
|
+
// a real tier value at all: MapEntrySchema fails config load fail-closed on one, naming
|
|
185
|
+
// routing.floors as the move target; only the legacy `tier: null` tombstone still parses.
|
|
175
186
|
map: {
|
|
176
187
|
plan: { pin: { via: "claude-code", model: "fable" } },
|
|
177
188
|
spec: { pin: { via: "claude-code", model: "fable" } },
|
|
@@ -294,7 +305,9 @@ function deepMerge(base, over) {
|
|
|
294
305
|
delete out[k]; // v1.1 tombstone: an explicit null in an overlay removes the key (e.g. stale tiers model ids)
|
|
295
306
|
continue;
|
|
296
307
|
}
|
|
297
|
-
|
|
308
|
+
// OBS-75 class: merge fresh-landing objects onto {} so nested tombstone nulls are pruned even when
|
|
309
|
+
// no lower layer set the key (a fleet-cleared deny writes {adapters: null} — the schema rejects raw null)
|
|
310
|
+
out[k] = k in out ? deepMerge(out[k], v) : deepMerge({}, v);
|
|
298
311
|
}
|
|
299
312
|
return out;
|
|
300
313
|
}
|
|
@@ -393,7 +406,11 @@ function resolveRoutingMode(cfg, layers) {
|
|
|
393
406
|
* already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
|
|
394
407
|
export function loadConfigWithMode(repoRoot, opts = {}) {
|
|
395
408
|
const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
|
|
396
|
-
|
|
409
|
+
// v1.52 T2: repoOverlayText substitutes candidate bytes for the on-disk repo layer — the fleet
|
|
410
|
+
// write guard validates EXACTLY what it is about to write through this one loader path.
|
|
411
|
+
const repoCfg = opts.repoOverlayText === undefined
|
|
412
|
+
? readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))
|
|
413
|
+
: parse(opts.repoOverlayText);
|
|
397
414
|
const merged = deepMerge(deepMerge(structuredClone(DEFAULT_CONFIG), globalCfg), repoCfg);
|
|
398
415
|
const r = TickmarkrConfigSchema.safeParse(merged);
|
|
399
416
|
if (!r.success)
|
|
@@ -403,6 +420,18 @@ export function loadConfigWithMode(repoRoot, opts = {}) {
|
|
|
403
420
|
export function loadConfig(repoRoot, opts = {}) {
|
|
404
421
|
return loadConfigWithMode(repoRoot, opts).cfg;
|
|
405
422
|
}
|
|
423
|
+
/** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
|
|
424
|
+
* loader path every later command uses (parse → merge → schema → mode resolution). Returns null
|
|
425
|
+
* when the bytes load, else the loader's failure message — the caller must refuse the write. */
|
|
426
|
+
export function overlayBytesLoadError(repoRoot, bytes, opts = {}) {
|
|
427
|
+
try {
|
|
428
|
+
loadConfigWithMode(repoRoot, { ...opts, repoOverlayText: bytes });
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
return e.message;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
406
435
|
export function configTemplate(overlay) {
|
|
407
436
|
const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
|
|
408
437
|
# concurrency: 3
|
|
@@ -418,7 +447,7 @@ export function configTemplate(overlay) {
|
|
|
418
447
|
# map: # per-shape preferences/pins; band policy lives in routing.floors
|
|
419
448
|
# implement: { prefer: [cursor-agent, codex] }
|
|
420
449
|
# migration: { pin: { via: claude-code, model: fable } }
|
|
421
|
-
# tests: { tier: null } # tombstone: null removes a legacy map tier (
|
|
450
|
+
# 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
451
|
# floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
|
|
423
452
|
# migration: frontier
|
|
424
453
|
# 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'
|
|
@@ -656,7 +685,9 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
|
656
685
|
}
|
|
657
686
|
}
|
|
658
687
|
if (Object.keys(modelDelta).length) {
|
|
659
|
-
|
|
688
|
+
// spread the existing entry so vendor/channel/windows survive the rewrite — dropping them
|
|
689
|
+
// makes the overlay unloadable for any adapter without a default seed (reload-guard class)
|
|
690
|
+
tiersOut[adapter] = { ...tiersOut[adapter], models: { ...tiersOut[adapter]?.models, ...modelDelta } };
|
|
660
691
|
tiersTouched = true;
|
|
661
692
|
}
|
|
662
693
|
}
|
|
@@ -682,47 +713,58 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
|
682
713
|
return "";
|
|
683
714
|
const lines = [];
|
|
684
715
|
const today = new Date().toISOString().slice(0, 10);
|
|
716
|
+
// OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
|
|
717
|
+
// re-indent the whole emitted block, so sequences/nested maps nest correctly and null
|
|
718
|
+
// tombstones/empty collections survive the serialize→parse round-trip.
|
|
719
|
+
const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
|
|
685
720
|
const routing = overlay.routing;
|
|
686
721
|
if (routing) {
|
|
687
722
|
lines.push("routing:");
|
|
688
723
|
const deny = routing.deny;
|
|
689
|
-
if (deny) {
|
|
724
|
+
if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
|
|
690
725
|
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
|
-
}
|
|
726
|
+
if (deny.adapters !== undefined)
|
|
727
|
+
lines.push(...block({ adapters: deny.adapters }, " "));
|
|
728
|
+
if (deny.models !== undefined)
|
|
729
|
+
lines.push(...block({ models: deny.models }, " "));
|
|
703
730
|
}
|
|
731
|
+
if (routing.map)
|
|
732
|
+
lines.push(...block({ map: routing.map }, " "));
|
|
704
733
|
if (routing.floors)
|
|
705
|
-
lines.push(
|
|
734
|
+
lines.push(...block({ floors: routing.floors }, " "));
|
|
706
735
|
}
|
|
707
736
|
const tiers = overlay.tiers;
|
|
708
|
-
if (tiers) {
|
|
737
|
+
if (tiers && Object.keys(tiers).length) {
|
|
709
738
|
lines.push("tiers:");
|
|
710
739
|
for (const [adapter, entry] of Object.entries(tiers)) {
|
|
711
|
-
|
|
740
|
+
const body = [];
|
|
712
741
|
if (entry.vendor)
|
|
713
|
-
|
|
742
|
+
body.push(` vendor: ${entry.vendor}`);
|
|
714
743
|
if (entry.channel)
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
744
|
+
body.push(` channel: ${entry.channel}`);
|
|
745
|
+
if (entry.windows)
|
|
746
|
+
body.push(...block({ windows: entry.windows }, " "));
|
|
747
|
+
const models = Object.entries(entry.models ?? {});
|
|
748
|
+
if (models.length) {
|
|
749
|
+
body.push(" models:");
|
|
750
|
+
for (const [model, tier] of models) {
|
|
751
|
+
if (tier === null)
|
|
752
|
+
body.push(` ${model}: null`);
|
|
753
|
+
else {
|
|
754
|
+
const note = provenance[adapter]?.[model];
|
|
755
|
+
const suffix = note ? ` # ${note} — fleet ${today}` : "";
|
|
756
|
+
body.push(` ${model}: ${tier}${suffix}`);
|
|
757
|
+
}
|
|
724
758
|
}
|
|
725
759
|
}
|
|
760
|
+
else if (entry.models) {
|
|
761
|
+
body.push(" models: {}"); // present-but-empty: explicit {} — a childless header parses as null and the loader rejects it
|
|
762
|
+
}
|
|
763
|
+
// a bare `adapter:` line parses as a null tombstone and would DELETE the adapter's default seeds on merge
|
|
764
|
+
if (body.length)
|
|
765
|
+
lines.push(` ${adapter}:`, ...body);
|
|
766
|
+
else
|
|
767
|
+
lines.push(` ${adapter}: {}`);
|
|
726
768
|
}
|
|
727
769
|
}
|
|
728
770
|
return `${lines.join("\n")}\n`;
|
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/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) => {
|