tickmarkr 1.50.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.js +2 -2
- package/dist/adapters/registry.js +56 -37
- package/dist/adapters/types.d.ts +1 -0
- package/dist/cli/commands/fleet.js +93 -33
- package/dist/cli/commands/plan.d.ts +1 -1
- package/dist/cli/commands/plan.js +49 -6
- package/dist/cli/commands/run.js +31 -12
- package/dist/compile/native.js +18 -2
- package/dist/config/config.d.ts +37 -10
- package/dist/config/config.js +175 -38
- package/dist/graph/schema.d.ts +6 -0
- package/dist/graph/schema.js +6 -0
- package/dist/route/profile.d.ts +12 -0
- package/dist/route/profile.js +31 -0
- package/dist/route/router.d.ts +1 -0
- package/dist/route/router.js +6 -6
- package/dist/run/daemon.d.ts +16 -0
- package/dist/run/daemon.js +55 -11
- 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+)
|
|
@@ -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")
|
|
@@ -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,9 +6,10 @@ 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,
|
|
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
|
+
import { resolveRunMode } from "../../run/daemon.js";
|
|
12
13
|
import { loadRoutingProfile } from "../../run/journal.js";
|
|
13
14
|
const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
|
|
14
15
|
const QUIT = "fleet: quit without writing";
|
|
@@ -55,10 +56,21 @@ function provenanceMap(editable) {
|
|
|
55
56
|
}
|
|
56
57
|
return out;
|
|
57
58
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// v1.51 T4: serializeFleetOverlay predates routing.mode — splice the mode line under routing:
|
|
60
|
+
// so a repo-declared mode survives fleet writes and a mode selection lands as routing.mode.
|
|
61
|
+
function withModeLine(yaml, mode) {
|
|
62
|
+
if (!mode)
|
|
63
|
+
return yaml;
|
|
64
|
+
if (/^routing:$/m.test(yaml))
|
|
65
|
+
return yaml.replace(/^routing:$/m, `routing:\n mode: ${mode}`);
|
|
66
|
+
return `routing:\n mode: ${mode}\n${yaml}`;
|
|
61
67
|
}
|
|
68
|
+
// v1.51 T4: one gloss per routing mode on the fleet mode screen — mirrors the preset compiler.
|
|
69
|
+
const MODE_GLOSS = {
|
|
70
|
+
"partner-led": "every shape frontier · explore off",
|
|
71
|
+
"risk-based": "risk-tiered default floors",
|
|
72
|
+
"staff-led": "implement/refactor one band down · integrity shapes hold frontier",
|
|
73
|
+
};
|
|
62
74
|
const isAbort = (k) => k.name === "escape" || k.name === "q" || (k.ctrl === true && k.name === "c");
|
|
63
75
|
const isNext = (k) => k.name === "return" || k.name === "enter";
|
|
64
76
|
const isToggle = (k) => k.name === "space";
|
|
@@ -154,8 +166,14 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
154
166
|
const input = io.input ?? process.stdin;
|
|
155
167
|
const output = io.output ?? process.stdout;
|
|
156
168
|
const interactive = input.isTTY === true && output.isTTY === true;
|
|
157
|
-
if (print)
|
|
158
|
-
|
|
169
|
+
if (print) {
|
|
170
|
+
// v1.51 T4: the print surface names the mode and its source layer right under the header —
|
|
171
|
+
// comment-prefixed so the YAML body stays machine-parseable and regex-stable.
|
|
172
|
+
const rm = resolveRunMode(cwd, { globalDir });
|
|
173
|
+
const body = formatFleetPrint(cwd, { globalDir });
|
|
174
|
+
const nl = body.indexOf("\n");
|
|
175
|
+
return `${body.slice(0, nl)}\n# mode: ${rm.mode.mode} (${rm.source})${body.slice(nl)}`;
|
|
176
|
+
}
|
|
159
177
|
if (!interactive)
|
|
160
178
|
return { out: NON_TTY_MSG, code: 1 };
|
|
161
179
|
const fresh = values.fresh ?? false;
|
|
@@ -166,15 +184,16 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
166
184
|
code: 1,
|
|
167
185
|
};
|
|
168
186
|
}
|
|
169
|
-
const
|
|
187
|
+
const rm = resolveRunMode(cwd, { globalDir });
|
|
188
|
+
const cfg = rm.cfg;
|
|
170
189
|
const initial = fleetEditableFromConfig(cfg);
|
|
171
190
|
const editable = structuredClone(initial);
|
|
172
191
|
const health = cached;
|
|
173
192
|
const term = openTerm(input, output);
|
|
174
193
|
try {
|
|
175
|
-
// step 1/
|
|
194
|
+
// step 1/5 — probe data
|
|
176
195
|
const ageLine = formatAge(doctorAgeMs(cwd));
|
|
177
|
-
term.frame(listFrame("step 1/
|
|
196
|
+
term.frame(listFrame("step 1/5 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
|
|
178
197
|
for (;;) {
|
|
179
198
|
const k = await term.key();
|
|
180
199
|
if (isAbort(k))
|
|
@@ -185,7 +204,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
185
204
|
if (isNext(k))
|
|
186
205
|
break;
|
|
187
206
|
}
|
|
188
|
-
// step 2/
|
|
207
|
+
// step 2/5 — agent CLIs (space toggles adapter deny)
|
|
189
208
|
const installed = adapters.filter((a) => health[a.id]?.installed);
|
|
190
209
|
const adapterRows = () => installed.map((a) => {
|
|
191
210
|
const h = health[a.id];
|
|
@@ -194,7 +213,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
194
213
|
});
|
|
195
214
|
const listLegend = "↑↓/jk move · space toggle · enter next · esc/q quit";
|
|
196
215
|
let aCursor = 0;
|
|
197
|
-
term.frame(listFrame("step 2/
|
|
216
|
+
term.frame(listFrame("step 2/5 · agent CLIs", listLegend, adapterRows(), aCursor));
|
|
198
217
|
for (;;) {
|
|
199
218
|
const k = await term.key();
|
|
200
219
|
if (isAbort(k))
|
|
@@ -218,9 +237,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
218
237
|
changed = true;
|
|
219
238
|
}
|
|
220
239
|
if (changed)
|
|
221
|
-
term.frame(listFrame("step 2/
|
|
240
|
+
term.frame(listFrame("step 2/5 · agent CLIs", listLegend, adapterRows(), aCursor));
|
|
222
241
|
}
|
|
223
|
-
// step 3/
|
|
242
|
+
// step 3/5 — models per enabled adapter (space toggles deny, t assigns tier from the row)
|
|
224
243
|
const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
|
|
225
244
|
for (const a of enabledAdapters) {
|
|
226
245
|
const unclassifiedAll = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
|
|
@@ -238,7 +257,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
238
257
|
const denied = isDeniedModel(a.id, r.model, editable);
|
|
239
258
|
return `${denied ? toggleInactive() : toggleActive()} ${r.model} ${tier?.tier ?? "???"} ${denied ? "denied" : "allowed"}`;
|
|
240
259
|
});
|
|
241
|
-
const title = `step 3/
|
|
260
|
+
const title = `step 3/5 · models · ${a.id}`;
|
|
242
261
|
const modelsLegend = "↑↓/jk move · space toggle · t tier · enter next · esc/q quit";
|
|
243
262
|
let mCursor = 0;
|
|
244
263
|
term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
|
|
@@ -283,7 +302,44 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
283
302
|
term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
|
|
284
303
|
}
|
|
285
304
|
}
|
|
286
|
-
// step 4/
|
|
305
|
+
// step 4/5 — routing mode (selection is in-memory; the write happens only through the diff confirm).
|
|
306
|
+
// Candidate floor tables come from the ONE preset compiler via resolveRunMode — no mode math here.
|
|
307
|
+
const modeCfgs = Object.fromEntries(ROUTING_MODES.map((m) => [m, m === rm.mode.mode ? rm : resolveRunMode(cwd, { flag: m, globalDir })]));
|
|
308
|
+
const modeRows = () => ROUTING_MODES.map((m) => `${m === rm.mode.mode ? toggleActive() : " "} ${m.padEnd(11)} ${MODE_GLOSS[m]}`);
|
|
309
|
+
// preview: the highlighted mode's resolved floor table diffed against the current mode
|
|
310
|
+
const floorPreview = (cand) => {
|
|
311
|
+
if (cand === rm.mode.mode)
|
|
312
|
+
return [];
|
|
313
|
+
const cur = cfg.routing.floors;
|
|
314
|
+
const next = modeCfgs[cand].cfg.routing.floors;
|
|
315
|
+
const changed = SHAPES.filter((s) => cur[s] !== next[s]);
|
|
316
|
+
return [
|
|
317
|
+
` floors vs ${rm.mode.mode}:`,
|
|
318
|
+
...(changed.length ? changed.map((s) => ` ${s}: ${cur[s]} → ${next[s]}`) : [" (no floor changes)"]),
|
|
319
|
+
];
|
|
320
|
+
};
|
|
321
|
+
const modeTitle = "step 4/5 · routing mode";
|
|
322
|
+
const modeLegend = "↑↓/jk move · enter select · esc/q quit";
|
|
323
|
+
let modeCursor = ROUTING_MODES.indexOf(rm.mode.mode);
|
|
324
|
+
let selectedMode = rm.mode.mode;
|
|
325
|
+
const modeFrame = () => [...listFrame(modeTitle, modeLegend, modeRows(), modeCursor), ...floorPreview(ROUTING_MODES[modeCursor])];
|
|
326
|
+
term.frame(modeFrame());
|
|
327
|
+
for (;;) {
|
|
328
|
+
const k = await term.key();
|
|
329
|
+
if (isAbort(k))
|
|
330
|
+
return QUIT;
|
|
331
|
+
if (isNext(k)) {
|
|
332
|
+
selectedMode = ROUTING_MODES[modeCursor];
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
const moved = moveCursor(k, modeCursor, ROUTING_MODES.length);
|
|
336
|
+
if (moved !== modeCursor) {
|
|
337
|
+
modeCursor = moved;
|
|
338
|
+
term.frame(modeFrame());
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// step 5/5 — shape routing (typed entry for pin/tier/prefer from the highlighted row);
|
|
342
|
+
// previews route under the SELECTED mode's resolved floors, never a floor edit of its own
|
|
287
343
|
const channels = discoverChannels(cfg, adapters, health);
|
|
288
344
|
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
289
345
|
const autoPrefer = readAutoPrefer(cwd);
|
|
@@ -291,7 +347,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
291
347
|
const shapeRows = () => SHAPES.map((shape) => {
|
|
292
348
|
let now;
|
|
293
349
|
try {
|
|
294
|
-
const r = route(previewTask(shape), { ...cfg, routing: { ...cfg.routing, map: editable.map, floors:
|
|
350
|
+
const r = route(previewTask(shape), { ...cfg, routing: { ...cfg.routing, map: editable.map, floors: modeCfgs[selectedMode].cfg.routing.floors } }, channels, profile);
|
|
295
351
|
now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
|
|
296
352
|
}
|
|
297
353
|
catch (e) {
|
|
@@ -300,8 +356,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
300
356
|
const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
|
|
301
357
|
return `${shape} → ${now}${auto}`;
|
|
302
358
|
});
|
|
303
|
-
const shapesTitle = "step
|
|
304
|
-
|
|
359
|
+
const shapesTitle = "step 5/5 · shape routing";
|
|
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";
|
|
305
362
|
let sCursor = 0;
|
|
306
363
|
term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
|
|
307
364
|
for (;;) {
|
|
@@ -332,15 +389,6 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
332
389
|
editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
|
|
333
390
|
changed = true;
|
|
334
391
|
}
|
|
335
|
-
else if (k.name === "t") {
|
|
336
|
-
const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
|
|
337
|
-
if (!TIERS.includes(tier))
|
|
338
|
-
return { out: `fleet: invalid tier ${tier}`, code: 1 };
|
|
339
|
-
const next = { ...entry, tier };
|
|
340
|
-
delete next.pin;
|
|
341
|
-
editable.map[shape] = next;
|
|
342
|
-
changed = true;
|
|
343
|
-
}
|
|
344
392
|
else if (k.name === "f") {
|
|
345
393
|
const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
|
|
346
394
|
editable.map[shape] = { ...entry, prefer: pref.split(",").map((s) => s.trim()).filter(Boolean) };
|
|
@@ -349,10 +397,17 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
349
397
|
if (changed)
|
|
350
398
|
term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
|
|
351
399
|
}
|
|
352
|
-
// review — unified diff + typed confirm
|
|
400
|
+
// review — unified diff + typed confirm (the ONLY write path; the mode screen funnels through here)
|
|
353
401
|
const before = currentRepoOverlayText(cwd);
|
|
354
|
-
const
|
|
355
|
-
|
|
402
|
+
const existing = readOverlayFile(repoOverlayPath(cwd));
|
|
403
|
+
const modeChanged = selectedMode !== rm.mode.mode;
|
|
404
|
+
// preserve a repo-declared mode line verbatim; write a new one only when the selection changed it
|
|
405
|
+
const writeMode = modeChanged ? selectedMode : existing.routing?.mode;
|
|
406
|
+
const merged = fleetEditableEquals(initial, editable)
|
|
407
|
+
? structuredClone(existing)
|
|
408
|
+
: fleetRepoOverlayFromDelta(initial, editable, existing);
|
|
409
|
+
const after = withModeLine(repoOverlayYaml(merged, provenanceMap(editable)), writeMode);
|
|
410
|
+
if ((!modeChanged && fleetEditableEquals(initial, editable)) || before === after) {
|
|
356
411
|
return "fleet: no overlay changes (empty diff)";
|
|
357
412
|
}
|
|
358
413
|
const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
|
|
@@ -360,11 +415,16 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
360
415
|
const confirm = await term.askTyped("write overlay? [y/N] ");
|
|
361
416
|
if (!/^y(?:es)?$/i.test(confirm))
|
|
362
417
|
return "fleet: discarded overlay changes";
|
|
363
|
-
const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(cwd)));
|
|
364
|
-
const body = repoOverlayYaml(merged, provenanceMap(editable));
|
|
365
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
|
+
}
|
|
366
426
|
mkdirSync(dirname(path), { recursive: true });
|
|
367
|
-
writeFileSync(path,
|
|
427
|
+
writeFileSync(path, after);
|
|
368
428
|
return `fleet: wrote ${path}`;
|
|
369
429
|
}
|
|
370
430
|
finally {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { WorkerAdapter } from "../../adapters/types.js";
|
|
2
|
-
export declare function plan(
|
|
2
|
+
export declare function plan(argv: string[], cwd?: string, adapters?: WorkerAdapter[]): Promise<string>;
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
|
|
2
2
|
import { formatModelAuthLine, contextWindowLints, modelLints, ttyVisual } from "../../adapters/model-lints.js";
|
|
3
3
|
import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
4
5
|
import { collateralLints } from "../../compile/collateral.js";
|
|
5
|
-
import {
|
|
6
|
+
import { DEFAULT_CONFIG, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
6
7
|
import { loadGraph } from "../../graph/graph.js";
|
|
8
|
+
import { resolveRunMode } from "../../run/daemon.js";
|
|
7
9
|
import { excludedChannels, exclusionLine } from "../../route/preference.js";
|
|
8
|
-
import {
|
|
10
|
+
import { staffLedEvidence } from "../../route/profile.js";
|
|
11
|
+
import { QUALITY_ENV, route, RoutingError } from "../../route/router.js";
|
|
9
12
|
import { modelId } from "../../gates/review.js";
|
|
10
13
|
import { loadRoutingProfile } from "../../run/journal.js";
|
|
11
14
|
// T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
|
|
@@ -31,12 +34,19 @@ const fleetCanCrossVendorReview = (channels) => {
|
|
|
31
34
|
return true;
|
|
32
35
|
return false;
|
|
33
36
|
};
|
|
34
|
-
export async function plan(
|
|
37
|
+
export async function plan(argv, cwd = process.cwd(), adapters = allAdapters()) {
|
|
35
38
|
// ponytail: hardcoded 24h TTL — promote to config when an operator asks. mtime is the signal because
|
|
36
39
|
// doctor.json has no probe timestamp and a schema field would break the existing-files compat invariant.
|
|
37
40
|
const DOCTOR_STALE_MS = 24 * 60 * 60 * 1000;
|
|
38
|
-
|
|
41
|
+
// v1.51 T2: plan previews any mode without a config edit — same source precedence as run
|
|
42
|
+
// (flag > spec front-matter > repo > global > default), same preset compiler.
|
|
43
|
+
const { values } = parseArgs({ args: argv, options: { mode: { type: "string" } }, allowPositionals: true });
|
|
44
|
+
if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
|
|
45
|
+
throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
|
|
46
|
+
}
|
|
47
|
+
delete process.env[QUALITY_ENV];
|
|
39
48
|
const g = loadGraph(cwd);
|
|
49
|
+
const { cfg, mode, source } = resolveRunMode(cwd, { flag: values.mode, spec: g.mode });
|
|
40
50
|
// readDoctor cache path: staleness line only fires here (probeAll fallback is fresh by construction).
|
|
41
51
|
const cached = readDoctor(cwd);
|
|
42
52
|
const health = cached ?? (await probeAll(adapters));
|
|
@@ -46,7 +56,17 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
46
56
|
// output byte-identical to today.
|
|
47
57
|
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
48
58
|
let deviations = 0;
|
|
49
|
-
|
|
59
|
+
// v1.51 T4: the mode is never invisible — the header names the resolved mode, its winning
|
|
60
|
+
// source, and the explore posture; each task row carries a floor-derivation line below.
|
|
61
|
+
const lines = [
|
|
62
|
+
`tickmarkr plan — dry run (${channels.length} channels available)`,
|
|
63
|
+
`mode: ${mode.mode} (${source}) · explore ${cfg.routing.explore?.mode ?? "on"}`,
|
|
64
|
+
"",
|
|
65
|
+
];
|
|
66
|
+
const derivation = (shape) => {
|
|
67
|
+
const floor = cfg.routing.floors[shape];
|
|
68
|
+
return floor ? ` floor ${floor} ← ${mode.provenance[shape] ?? "config floors"}` : null;
|
|
69
|
+
};
|
|
50
70
|
const excluded = excludedChannels(cfg, adapters, health);
|
|
51
71
|
if (excluded.length)
|
|
52
72
|
lines.push(exclusionLine(excluded), "");
|
|
@@ -78,7 +98,24 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
78
98
|
return ` — ${m.key} is unauthed (${m.reason}, probed ${m.probedAt.split("T")[0]})`;
|
|
79
99
|
return "";
|
|
80
100
|
};
|
|
81
|
-
|
|
101
|
+
// v1.51 T1: mode-resolution lints (shadowed deltas, below-integrity operator floors) surface here every run.
|
|
102
|
+
const lints = [...mode.lints];
|
|
103
|
+
// v1.51 T5: staff-led economics guard — when the MODE (never an explicit operator line) lowered a
|
|
104
|
+
// shape's floor and warm evidence shows the cheap band materially under the mid incumbent, say so.
|
|
105
|
+
// Advisory only: no floor is raised here or anywhere on prediction — raises stay evidence-triggered
|
|
106
|
+
// (task-hint floors, the in-run retry ladder), each journaled with provenance.
|
|
107
|
+
if (mode.mode === "staff-led") {
|
|
108
|
+
const fmtScore = (s) => `${s < 0 ? "" : "+"}${s.toFixed(3)}`;
|
|
109
|
+
for (const [shape, floor] of Object.entries(cfg.routing.floors)) {
|
|
110
|
+
const dflt = DEFAULT_CONFIG.routing.floors[shape];
|
|
111
|
+
if (mode.provenance[shape] !== "mode staff-led" || !dflt || TIER_RANK[floor] >= TIER_RANK[dflt])
|
|
112
|
+
continue;
|
|
113
|
+
const ev = staffLedEvidence(profile, shape, channels);
|
|
114
|
+
if (ev) {
|
|
115
|
+
lints.push(`staff-led may cost more than risk-based on ${shape} (cheap best ${fmtScore(ev.cheapBest)} vs mid ${fmtScore(ev.midBest)}, n=${ev.n}) — advisory only, floor stays ${floor}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
82
119
|
for (const [role, sel] of [["judge", cfg.judge], ["consult", cfg.consult]]) {
|
|
83
120
|
if (!health[sel.adapter]?.installed)
|
|
84
121
|
lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
|
|
@@ -100,6 +137,9 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
100
137
|
const est = r.assignment.channel === "sub" ? 0 : cfg.pricing[r.assignment.tier];
|
|
101
138
|
cost += est;
|
|
102
139
|
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`);
|
|
140
|
+
const d = derivation(t.shape);
|
|
141
|
+
if (d)
|
|
142
|
+
lines.push(d);
|
|
103
143
|
if (r.deviation) {
|
|
104
144
|
deviations++;
|
|
105
145
|
const d = r.deviation;
|
|
@@ -111,6 +151,9 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
111
151
|
throw e;
|
|
112
152
|
const msg = `${e.message}${exclusionReason(e.message)}`;
|
|
113
153
|
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! ${msg}`);
|
|
154
|
+
const d = derivation(t.shape);
|
|
155
|
+
if (d)
|
|
156
|
+
lines.push(d);
|
|
114
157
|
lints.push(`${t.id}: unroutable — ${msg}`);
|
|
115
158
|
}
|
|
116
159
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { allAdapters, discoverChannels, probeAll, readDoctor } from "../../adapters/registry.js";
|
|
3
|
-
import {
|
|
3
|
+
import { ROUTING_MODES } from "../../config/config.js";
|
|
4
4
|
import { pickDriver } from "../../drivers/index.js";
|
|
5
5
|
import { loadGraph } from "../../graph/graph.js";
|
|
6
|
-
import { formatSummary, runDaemon } from "../../run/daemon.js";
|
|
7
|
-
import { route,
|
|
6
|
+
import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
|
|
7
|
+
import { route, NO_EXPLORE_ENV, QUALITY_ENV } from "../../route/router.js";
|
|
8
8
|
import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
|
|
9
9
|
import { ttyVisual } from "../../adapters/model-lints.js";
|
|
10
10
|
import { statusRow } from "../../brand.js";
|
|
@@ -24,6 +24,11 @@ export const narrationLine = (event) => {
|
|
|
24
24
|
};
|
|
25
25
|
const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
|
|
26
26
|
&& s.tipVerify !== "failed";
|
|
27
|
+
// v1.51 T2: --quality is a pure compatibility alias for `--mode partner-led` (this run only). It
|
|
28
|
+
// carries no one-band floor raise of its own: the flag never sets ExploreContext.quality or the
|
|
29
|
+
// TICKMARKR_QUALITY env, so no downstream code raises a floor on its behalf (proven in mode-sources).
|
|
30
|
+
const QUALITY_ALIAS_NOTICE = "tickmarkr: --quality is a compatibility alias for --mode partner-led (this run only) — "
|
|
31
|
+
+ "the v1.47 one-band floor raise is retired (deprecated); use --mode partner-led";
|
|
27
32
|
export async function run(argv, cwd = process.cwd()) {
|
|
28
33
|
const { values } = parseArgs({
|
|
29
34
|
args: argv,
|
|
@@ -32,6 +37,7 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
32
37
|
driver: { type: "string" },
|
|
33
38
|
"route-strict": { type: "boolean" },
|
|
34
39
|
"no-explore": { type: "boolean" },
|
|
40
|
+
mode: { type: "string" },
|
|
35
41
|
quality: { type: "boolean" },
|
|
36
42
|
},
|
|
37
43
|
});
|
|
@@ -40,14 +46,28 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
40
46
|
if (!Number.isInteger(n) || n <= 0)
|
|
41
47
|
throw new Error(`--concurrency must be a positive integer (got ${values.concurrency})`);
|
|
42
48
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
if (values.quality && values.mode !== undefined) {
|
|
50
|
+
throw new Error("--quality is a compatibility alias for --mode partner-led and cannot be combined with an explicit --mode — pass one or the other");
|
|
51
|
+
}
|
|
52
|
+
if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
|
|
53
|
+
throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
|
|
54
|
+
}
|
|
55
|
+
if (values.quality)
|
|
56
|
+
console.warn(QUALITY_ALIAS_NOTICE);
|
|
57
|
+
const flagMode = values.mode ?? (values.quality ? "partner-led" : undefined);
|
|
58
|
+
delete process.env[QUALITY_ENV];
|
|
59
|
+
const graph = loadGraph(cwd);
|
|
60
|
+
const { cfg, conflict } = resolveRunMode(cwd, { flag: flagMode, spec: graph.mode });
|
|
61
|
+
if (conflict) {
|
|
62
|
+
// Loud, never silent: live intent (the flag) may override compiled intent (the spec) — strict refuses.
|
|
63
|
+
if (values["route-strict"])
|
|
64
|
+
throw new Error(`--route-strict: refusing to dispatch — ${conflict}`);
|
|
65
|
+
console.warn(`tickmarkr: !! ${conflict}`);
|
|
66
|
+
}
|
|
67
|
+
const noExplore = !!values["no-explore"];
|
|
68
|
+
const exploreCtx = noExplore ? { noExplore } : undefined;
|
|
47
69
|
if (noExplore)
|
|
48
70
|
process.env[NO_EXPLORE_ENV] = "1";
|
|
49
|
-
if (quality)
|
|
50
|
-
process.env[QUALITY_ENV] = "1";
|
|
51
71
|
try {
|
|
52
72
|
if (values["route-strict"]) {
|
|
53
73
|
const adapters = allAdapters();
|
|
@@ -55,13 +75,14 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
55
75
|
const channels = discoverChannels(cfg, adapters, health);
|
|
56
76
|
// no preview: the strict pre-flight routes through exactly what the daemon will use (honors the switch)
|
|
57
77
|
const profile = loadRoutingProfile(cwd, cfg);
|
|
58
|
-
const lints =
|
|
78
|
+
const lints = graph.tasks.flatMap((t) => route(t, cfg, channels, profile, undefined, undefined, exploreCtx).lints);
|
|
59
79
|
if (lints.length)
|
|
60
80
|
throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
|
|
61
81
|
}
|
|
62
82
|
const s = await runDaemon(cwd, {
|
|
63
83
|
concurrency: values.concurrency ? Number(values.concurrency) : undefined,
|
|
64
84
|
driver: pickDriver(cfg, values.driver),
|
|
85
|
+
mode: flagMode,
|
|
65
86
|
narrate: (event) => console.log(narrationLine(event)),
|
|
66
87
|
});
|
|
67
88
|
const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
|
|
@@ -70,7 +91,5 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
70
91
|
finally {
|
|
71
92
|
if (noExplore)
|
|
72
93
|
delete process.env[NO_EXPLORE_ENV];
|
|
73
|
-
if (quality)
|
|
74
|
-
delete process.env[QUALITY_ENV];
|
|
75
94
|
}
|
|
76
95
|
}
|