@jwilger/pi-development-system 0.3.8 → 0.5.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.
@@ -9,6 +9,7 @@ import { createRecordDepartureTool } from "../src/gates/record-departure-tool.ts
9
9
  import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
10
10
  import { registerTestGuard } from "../src/gates/test-guard.ts";
11
11
  import { createJevHolder } from "../src/jev/holder.ts";
12
+ import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
12
13
  import { createSessionState } from "../src/state/session-state.ts";
13
14
 
14
15
  const nonNegotiables = readFileSync(
@@ -60,6 +61,8 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
60
61
  registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
61
62
  pi.registerTool(createRecordDepartureTool({ pi, state }));
62
63
  pi.registerTool(createRequestApprovalTool({ pi, approvals }));
64
+ pi.registerTool(createModelsTool());
65
+ registerModelsCommand(pi);
63
66
 
64
67
  pi.registerCommand("devsys-status", {
65
68
  description: "Show development-system phase, sizing, slice, departures and Jev status",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.3.8",
3
+ "version": "0.5.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,221 @@
1
+ import { err, ok, type Result } from "./result.ts";
2
+
3
+ /** Capability tiers and roles the system asks models for (plan Appendix B `[models]`). */
4
+ export const SLOTS = [
5
+ "frontier",
6
+ "strong",
7
+ "fast",
8
+ "planning",
9
+ "advisor",
10
+ "implementer",
11
+ "reviewer",
12
+ "lens",
13
+ "researcher",
14
+ "jev",
15
+ ] as const;
16
+
17
+ export type Slot = (typeof SLOTS)[number];
18
+
19
+ export const isSlot = (value: string): value is Slot => SLOTS.some((s) => s === value);
20
+
21
+ /** `provider/id`; the id may contain `/` (gateway providers) and `*` (family pattern). */
22
+ export type ModelRef = `${string}/${string}`;
23
+
24
+ /** Ordered candidates per slot: `provider/id`, `provider/family-*`, or `@slot` indirection. */
25
+ export type ModelMatrix = Readonly<Record<Slot, readonly string[]>>;
26
+
27
+ export type Available = { readonly provider: string; readonly id: string };
28
+
29
+ export type ModelError =
30
+ | { readonly kind: "unresolvable"; readonly slot: Slot }
31
+ | { readonly kind: "cycle"; readonly path: readonly Slot[] };
32
+
33
+ export type Resolution = { readonly model: ModelRef; readonly via: string };
34
+
35
+ type VersionKey = { readonly numbers: readonly number[]; readonly date: number | undefined };
36
+
37
+ /** Date-like segments (YYYYMMDD) are snapshot suffixes, not versions. */
38
+ const MIN_DATE_DIGITS = 6;
39
+
40
+ function versionKey(id: string): VersionKey {
41
+ const numbers: number[] = [];
42
+ let date: number | undefined;
43
+ for (const run of id.match(/\d+/g) ?? []) {
44
+ if (run.length >= MIN_DATE_DIGITS) date = Number(run);
45
+ else numbers.push(Number(run));
46
+ }
47
+ return { numbers, date };
48
+ }
49
+
50
+ function compareNumbers(a: readonly number[], b: readonly number[]): number {
51
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
52
+ const x = a[i];
53
+ const y = b[i];
54
+ if (x === undefined) return -1;
55
+ if (y === undefined) return 1;
56
+ if (x !== y) return x < y ? -1 : 1;
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ /**
62
+ * Natural version order over the numeric segments of two model ids. Dated snapshot suffixes rank
63
+ * below the undated alias with the same version, so `x-4-5` beats `x-4-5-20251001`.
64
+ */
65
+ export function compareModelVersions(a: string, b: string): number {
66
+ const ka = versionKey(a);
67
+ const kb = versionKey(b);
68
+ const byNumbers = compareNumbers(ka.numbers, kb.numbers);
69
+ if (byNumbers !== 0) return byNumbers;
70
+ if (ka.date !== kb.date) {
71
+ if (ka.date === undefined) return 1;
72
+ if (kb.date === undefined) return -1;
73
+ return ka.date < kb.date ? -1 : 1;
74
+ }
75
+ if (a === b) return 0;
76
+ return a < b ? -1 : 1;
77
+ }
78
+
79
+ /** `*`-only glob over a model id (no regular expressions built from configuration). */
80
+ function matchesPattern(pattern: string, id: string): boolean {
81
+ const parts = pattern.split("*");
82
+ if (parts.length === 1) return pattern === id;
83
+ const first = parts[0] ?? "";
84
+ const last = parts.at(-1) ?? "";
85
+ if (id.length < first.length + last.length || !id.startsWith(first) || !id.endsWith(last)) {
86
+ return false;
87
+ }
88
+ let at = first.length;
89
+ const limit = id.length - last.length;
90
+ for (const middle of parts.slice(1, -1)) {
91
+ const found = id.indexOf(middle, at);
92
+ if (found === -1 || found + middle.length > limit) return false;
93
+ at = found + middle.length;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ function splitRef(candidate: string): { provider: string; id: string } | undefined {
99
+ const slash = candidate.indexOf("/");
100
+ if (slash <= 0 || slash === candidate.length - 1) return undefined;
101
+ return { provider: candidate.slice(0, slash), id: candidate.slice(slash + 1) };
102
+ }
103
+
104
+ /** The exact model, or the newest available id matching the family pattern; `@slot` is not handled here. */
105
+ export function resolveCandidate(
106
+ candidate: string,
107
+ available: readonly Available[],
108
+ ): ModelRef | undefined {
109
+ const ref = splitRef(candidate);
110
+ if (ref === undefined) return undefined;
111
+ const matches = available
112
+ .flatMap((m) => (m.provider === ref.provider && matchesPattern(ref.id, m.id) ? [m.id] : []))
113
+ .sort(compareModelVersions);
114
+ const best = matches.at(-1);
115
+ return best === undefined ? undefined : `${ref.provider}/${best}`;
116
+ }
117
+
118
+ function resolveFrom(
119
+ matrix: ModelMatrix,
120
+ slot: Slot,
121
+ available: readonly Available[],
122
+ trail: readonly Slot[],
123
+ ): Result<Resolution, ModelError> {
124
+ if (trail.includes(slot)) return err({ kind: "cycle", path: [...trail, slot] });
125
+ for (const candidate of matrix[slot]) {
126
+ if (candidate.startsWith("@")) {
127
+ const target = candidate.slice(1);
128
+ if (!isSlot(target)) continue;
129
+ const inner = resolveFrom(matrix, target, available, [...trail, slot]);
130
+ if (inner.ok || inner.error.kind === "cycle") return inner;
131
+ continue;
132
+ }
133
+ const model = resolveCandidate(candidate, available);
134
+ if (model !== undefined) return ok({ model, via: candidate });
135
+ }
136
+ return err({ kind: "unresolvable", slot });
137
+ }
138
+
139
+ /** First candidate of the slot that resolves against what this machine can use; `via` is the matching candidate. */
140
+ export function resolveSlot(
141
+ matrix: ModelMatrix,
142
+ slot: Slot,
143
+ available: readonly Available[],
144
+ ): Result<Resolution, ModelError> {
145
+ return resolveFrom(matrix, slot, available, []);
146
+ }
147
+
148
+ /** Shipped defaults: model families only (no dated ids), so they roll forward as providers ship. */
149
+ export function defaultMatrix(): ModelMatrix {
150
+ return {
151
+ frontier: [
152
+ "openai-codex/gpt-*-astra",
153
+ "openai/gpt-*-astra",
154
+ "anthropic/claude-fable-*",
155
+ "anthropic/claude-opus-*",
156
+ "openai-codex/gpt-*-sol",
157
+ "openai/gpt-*-sol",
158
+ ],
159
+ strong: [
160
+ "openai-codex/gpt-*-sol",
161
+ "openai/gpt-*-sol",
162
+ "anthropic/claude-sonnet-*",
163
+ "anthropic/claude-opus-*",
164
+ "openai-codex/gpt-*-terra",
165
+ "openai/gpt-*-terra",
166
+ ],
167
+ fast: [
168
+ "openai-codex/gpt-*-luna",
169
+ "openai/gpt-*-luna",
170
+ "anthropic/claude-haiku-*",
171
+ "openai-codex/gpt-*-terra",
172
+ "openai/gpt-*-terra",
173
+ "@strong",
174
+ ],
175
+ planning: ["@frontier"],
176
+ advisor: ["@frontier"],
177
+ implementer: ["@strong"],
178
+ reviewer: [
179
+ "anthropic/claude-opus-*",
180
+ "anthropic/claude-sonnet-*",
181
+ "openai-codex/gpt-*-sol",
182
+ "openai/gpt-*-sol",
183
+ "@strong",
184
+ ],
185
+ lens: ["@strong"],
186
+ researcher: ["@fast"],
187
+ jev: [
188
+ "typesafe/jev-latest",
189
+ "openrouter/typesafe/jev-latest",
190
+ "opencode/jev-1.13",
191
+ "cloudflare-workers-ai/typesafe/jev",
192
+ "vercel-ai-gateway/typesafe-ai/jev",
193
+ ],
194
+ };
195
+ }
196
+
197
+ const WIDTH = Math.max(...SLOTS.map((s) => s.length));
198
+
199
+ /** The `[models]` TOML table for a matrix, one slot per line in declaration order. */
200
+ export function renderMatrixToml(matrix: ModelMatrix): string {
201
+ const lines = SLOTS.map(
202
+ (slot) => `${slot.padEnd(WIDTH)} = [${matrix[slot].map((c) => JSON.stringify(c)).join(", ")}]`,
203
+ );
204
+ return `[models]\n${lines.join("\n")}\n`;
205
+ }
206
+
207
+ /** `text` with its `[models]` table replaced by `matrix` (appended when absent); other tables untouched. */
208
+ export function upsertModelsTable(text: string, matrix: ModelMatrix): string {
209
+ const table = renderMatrixToml(matrix);
210
+ const lines = text.split("\n");
211
+ const start = lines.findIndex((l) => l.trim() === "[models]");
212
+ if (start === -1) {
213
+ const body = text.trimEnd();
214
+ return body === "" ? table : `${body}\n\n${table}`;
215
+ }
216
+ const nextHeader = lines.findIndex((l, i) => i > start && l.startsWith("["));
217
+ const head = lines.slice(0, start).join("\n");
218
+ const tail = nextHeader === -1 ? "" : lines.slice(nextHeader).join("\n");
219
+ const before = head === "" ? "" : `${head}\n`;
220
+ return tail === "" ? `${before}${table}` : `${before}${table}\n${tail}`;
221
+ }
@@ -0,0 +1,313 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parse as parseToml } from "smol-toml";
4
+ import { defaultMatrix, isSlot, type ModelMatrix, SLOTS, type Slot } from "../core/models.ts";
5
+ import { err, ok, type Result } from "../core/result.ts";
6
+
7
+ export const CONFIG_FILE = ".development-system.toml";
8
+
9
+ const DELIVERY_MODES = ["trunk", "pull-request", "local-only"] as const;
10
+ const TRACKER_KINDS = ["repo-files", "github", "jira", "linear"] as const;
11
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
12
+
13
+ export type DeliveryMode = (typeof DELIVERY_MODES)[number];
14
+ export type TrackerKind = (typeof TRACKER_KINDS)[number];
15
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
16
+
17
+ export type ConfigError = {
18
+ readonly kind: "config-error";
19
+ readonly message: string;
20
+ readonly key?: string;
21
+ };
22
+
23
+ export type Route = { readonly slot: Slot; readonly thinkingLevel: ThinkingLevel };
24
+
25
+ export type DevsysConfig = {
26
+ readonly delivery: {
27
+ readonly mode: DeliveryMode;
28
+ readonly trunk: string;
29
+ readonly remote: string;
30
+ };
31
+ readonly review: { readonly requiredCleanRounds: number; readonly minRounds: number };
32
+ readonly tracker: { readonly kind: TrackerKind; readonly repo?: string };
33
+ readonly profiles: { readonly override: readonly string[] };
34
+ readonly models: ModelMatrix;
35
+ readonly routing: Readonly<Record<string, Route>>;
36
+ readonly jev: { readonly timeoutMs: number; readonly confidenceFloor: number };
37
+ readonly verifier: { readonly maxPerSession: number };
38
+ readonly cadence: { readonly pushMinutes: number };
39
+ readonly eventModel: { readonly provider: string };
40
+ };
41
+
42
+ const DEFAULT_ROUTING: Readonly<Record<string, Route>> = {
43
+ "trivial/low": { slot: "fast", thinkingLevel: "low" },
44
+ "trivial/*": { slot: "implementer", thinkingLevel: "medium" },
45
+ "routine/low": { slot: "implementer", thinkingLevel: "medium" },
46
+ "routine/*": { slot: "strong", thinkingLevel: "high" },
47
+ "complex/*": { slot: "strong", thinkingLevel: "high" },
48
+ "expert/*": { slot: "frontier", thinkingLevel: "high" },
49
+ };
50
+
51
+ const fail = (message: string, key?: string): Result<never, ConfigError> =>
52
+ err(
53
+ key === undefined ? { kind: "config-error", message } : { kind: "config-error", message, key },
54
+ );
55
+
56
+ type Table = Readonly<Record<string, unknown>>;
57
+
58
+ const isTable = (value: unknown): value is Table =>
59
+ typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
60
+
61
+ const qualified = (path: string, key: string): string => (path === "" ? key : `${path}.${key}`);
62
+
63
+ /** The `[path]` table (or defaults when absent), rejecting any key outside `allowed`. */
64
+ function section(
65
+ root: Table,
66
+ name: string,
67
+ allowed: readonly string[],
68
+ ): Result<Table, ConfigError> {
69
+ const raw = root[name];
70
+ if (raw === undefined) return ok({});
71
+ if (!isTable(raw)) return fail(`${name} must be a table`, name);
72
+ const unknown = Object.keys(raw).find((k) => !allowed.includes(k));
73
+ return unknown === undefined
74
+ ? ok(raw)
75
+ : fail(`unknown key ${qualified(name, unknown)}`, qualified(name, unknown));
76
+ }
77
+
78
+ function text(t: Table, path: string, key: string, fallback: string): Result<string, ConfigError> {
79
+ const v = t[key];
80
+ if (v === undefined) return ok(fallback);
81
+ return typeof v === "string" && v !== ""
82
+ ? ok(v)
83
+ : fail(`${qualified(path, key)} must be a non-empty string`, qualified(path, key));
84
+ }
85
+
86
+ function choice<T extends string>(
87
+ t: Table,
88
+ path: string,
89
+ key: string,
90
+ options: readonly T[],
91
+ fallback: T,
92
+ ): Result<T, ConfigError> {
93
+ const v = t[key];
94
+ if (v === undefined) return ok(fallback);
95
+ const found = options.find((o) => o === v);
96
+ return found !== undefined
97
+ ? ok(found)
98
+ : fail(`${qualified(path, key)} must be one of ${options.join(" | ")}`, qualified(path, key));
99
+ }
100
+
101
+ function number(
102
+ t: Table,
103
+ path: string,
104
+ key: string,
105
+ fallback: number,
106
+ bounds: { min: number; max: number; integer: boolean },
107
+ ): Result<number, ConfigError> {
108
+ const v = t[key];
109
+ if (v === undefined) return ok(fallback);
110
+ const valid =
111
+ typeof v === "number" &&
112
+ v >= bounds.min &&
113
+ v <= bounds.max &&
114
+ (!bounds.integer || Number.isInteger(v));
115
+ if (valid) return ok(v);
116
+ const kind = bounds.integer ? "an integer" : "a number";
117
+ return fail(
118
+ `${qualified(path, key)} must be ${kind} between ${bounds.min} and ${bounds.max}`,
119
+ qualified(path, key),
120
+ );
121
+ }
122
+
123
+ const POSITIVE = { min: 1, max: 1_000_000, integer: true };
124
+
125
+ const MODEL_CANDIDATE = /^(@[a-z]+|[^/\s]+\/\S+)$/;
126
+
127
+ function candidates(raw: unknown, key: string): Result<readonly string[], ConfigError> {
128
+ const valid =
129
+ Array.isArray(raw) &&
130
+ raw.length > 0 &&
131
+ raw.every((c) => typeof c === "string" && MODEL_CANDIDATE.test(c));
132
+ return valid
133
+ ? ok(raw.map(String))
134
+ : fail(
135
+ `${key} must be a non-empty array of "provider/id", "provider/family-*" or "@slot" strings`,
136
+ key,
137
+ );
138
+ }
139
+
140
+ function models(root: Table): Result<ModelMatrix, ConfigError> {
141
+ const table = section(root, "models", SLOTS);
142
+ if (!table.ok) return table;
143
+ const merged: Record<Slot, readonly string[]> = { ...defaultMatrix() };
144
+ for (const [slot, raw] of Object.entries(table.value)) {
145
+ if (!isSlot(slot)) continue;
146
+ const parsed = candidates(raw, `models.${slot}`);
147
+ if (!parsed.ok) return parsed;
148
+ merged[slot] = parsed.value;
149
+ }
150
+ return ok(merged);
151
+ }
152
+
153
+ function route(raw: unknown, key: string): Result<Route, ConfigError> {
154
+ const [slot, level] = Array.isArray(raw) ? raw : [];
155
+ const valid =
156
+ Array.isArray(raw) &&
157
+ raw.length === 2 &&
158
+ typeof slot === "string" &&
159
+ isSlot(slot) &&
160
+ THINKING_LEVELS.some((l) => l === level);
161
+ if (!valid || typeof slot !== "string" || !isSlot(slot)) {
162
+ return fail(
163
+ `${key} must be [slot, thinkingLevel] (levels: ${THINKING_LEVELS.join(", ")})`,
164
+ key,
165
+ );
166
+ }
167
+ const thinkingLevel = THINKING_LEVELS.find((l) => l === level);
168
+ return thinkingLevel === undefined
169
+ ? fail(`${key} has an unknown thinking level`, key)
170
+ : ok({ slot, thinkingLevel });
171
+ }
172
+
173
+ function routing(root: Table): Result<Readonly<Record<string, Route>>, ConfigError> {
174
+ const raw = root.routing;
175
+ if (raw === undefined) return ok(DEFAULT_ROUTING);
176
+ if (!isTable(raw)) return fail("routing must be a table", "routing");
177
+ const result: Record<string, Route> = {};
178
+ for (const [pattern, value] of Object.entries(raw)) {
179
+ const parsed = route(value, `routing.${pattern}`);
180
+ if (!parsed.ok) return parsed;
181
+ result[pattern] = parsed.value;
182
+ }
183
+ return ok(result);
184
+ }
185
+
186
+ function profiles(root: Table): Result<readonly string[], ConfigError> {
187
+ const table = section(root, "profiles", ["override"]);
188
+ if (!table.ok) return table;
189
+ const raw = table.value.override;
190
+ if (raw === undefined) return ok([]);
191
+ return Array.isArray(raw) && raw.every((p) => typeof p === "string")
192
+ ? ok(raw.map(String))
193
+ : fail("profiles.override must be an array of strings", "profiles.override");
194
+ }
195
+
196
+ const TOP_LEVEL = [
197
+ "version",
198
+ "delivery",
199
+ "review",
200
+ "tracker",
201
+ "profiles",
202
+ "models",
203
+ "routing",
204
+ "jev",
205
+ "verifier",
206
+ "cadence",
207
+ "event_model",
208
+ ];
209
+
210
+ function deliveryAndReview(
211
+ root: Table,
212
+ ): Result<Pick<DevsysConfig, "delivery" | "review" | "tracker">, ConfigError> {
213
+ const d = section(root, "delivery", ["mode", "trunk", "remote"]);
214
+ const r = section(root, "review", ["required_clean_rounds", "min_rounds"]);
215
+ const t = section(root, "tracker", ["kind", "repo"]);
216
+ if (!d.ok) return d;
217
+ if (!r.ok) return r;
218
+ if (!t.ok) return t;
219
+ const mode = choice(d.value, "delivery", "mode", DELIVERY_MODES, "trunk");
220
+ const trunk = text(d.value, "delivery", "trunk", "main");
221
+ const remote = text(d.value, "delivery", "remote", "origin");
222
+ const required = number(r.value, "review", "required_clean_rounds", 3, POSITIVE);
223
+ const min = number(r.value, "review", "min_rounds", 1, POSITIVE);
224
+ const kind = choice(t.value, "tracker", "kind", TRACKER_KINDS, "repo-files");
225
+ const repo = t.value.repo === undefined ? ok(undefined) : text(t.value, "tracker", "repo", "");
226
+ for (const field of [mode, trunk, remote, required, min, kind, repo]) if (!field.ok) return field;
227
+ if (!(mode.ok && trunk.ok && remote.ok && required.ok && min.ok && kind.ok && repo.ok)) {
228
+ return fail("unreachable");
229
+ }
230
+ return ok({
231
+ delivery: { mode: mode.value, trunk: trunk.value, remote: remote.value },
232
+ review: { requiredCleanRounds: required.value, minRounds: min.value },
233
+ tracker:
234
+ repo.value === undefined ? { kind: kind.value } : { kind: kind.value, repo: repo.value },
235
+ });
236
+ }
237
+
238
+ function limits(
239
+ root: Table,
240
+ ): Result<Pick<DevsysConfig, "jev" | "verifier" | "cadence" | "eventModel">, ConfigError> {
241
+ const j = section(root, "jev", ["timeout_ms", "confidence_floor"]);
242
+ const v = section(root, "verifier", ["max_per_session"]);
243
+ const c = section(root, "cadence", ["push_minutes"]);
244
+ const e = section(root, "event_model", ["provider"]);
245
+ if (!j.ok) return j;
246
+ if (!v.ok) return v;
247
+ if (!c.ok) return c;
248
+ if (!e.ok) return e;
249
+ const timeout = number(j.value, "jev", "timeout_ms", 4000, POSITIVE);
250
+ const floor = number(j.value, "jev", "confidence_floor", 0.6, { min: 0, max: 1, integer: false });
251
+ const max = number(v.value, "verifier", "max_per_session", 6, POSITIVE);
252
+ const push = number(c.value, "cadence", "push_minutes", 60, POSITIVE);
253
+ const provider = text(e.value, "event_model", "provider", "builtin");
254
+ if (!timeout.ok) return timeout;
255
+ if (!floor.ok) return floor;
256
+ if (!max.ok) return max;
257
+ if (!push.ok) return push;
258
+ if (!provider.ok) return provider;
259
+ return ok({
260
+ jev: { timeoutMs: timeout.value, confidenceFloor: floor.value },
261
+ verifier: { maxPerSession: max.value },
262
+ cadence: { pushMinutes: push.value },
263
+ eventModel: { provider: provider.value },
264
+ });
265
+ }
266
+
267
+ /** Parses the text of `.development-system.toml` (plan Appendix B); absent keys take defaults. */
268
+ export function parseConfig(source: string): Result<DevsysConfig, ConfigError> {
269
+ let root: Table;
270
+ try {
271
+ root = parseToml(source);
272
+ } catch (cause) {
273
+ return fail(`invalid TOML: ${cause instanceof Error ? cause.message : String(cause)}`);
274
+ }
275
+ const unknown = Object.keys(root).find((k) => !TOP_LEVEL.includes(k));
276
+ if (unknown !== undefined) return fail(`unknown key ${unknown}`, unknown);
277
+ if (root.version !== undefined && root.version !== 1) {
278
+ return fail("version must be 1", "version");
279
+ }
280
+ const delivery = deliveryAndReview(root);
281
+ const lim = limits(root);
282
+ const matrix = models(root);
283
+ const route = routing(root);
284
+ const prof = profiles(root);
285
+ if (!delivery.ok) return delivery;
286
+ if (!lim.ok) return lim;
287
+ if (!matrix.ok) return matrix;
288
+ if (!route.ok) return route;
289
+ if (!prof.ok) return prof;
290
+ return ok({
291
+ ...delivery.value,
292
+ ...lim.value,
293
+ profiles: { override: prof.value },
294
+ models: matrix.value,
295
+ routing: route.value,
296
+ });
297
+ }
298
+
299
+ /** Reads `<repoRoot>/.development-system.toml`; a missing file means all defaults. */
300
+ export async function loadConfig(repoRoot: string): Promise<Result<DevsysConfig, ConfigError>> {
301
+ let source: string;
302
+ try {
303
+ source = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
304
+ } catch (cause) {
305
+ if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT") {
306
+ return parseConfig("");
307
+ }
308
+ return fail(
309
+ `cannot read ${CONFIG_FILE}: ${cause instanceof Error ? cause.message : String(cause)}`,
310
+ );
311
+ }
312
+ return parseConfig(source);
313
+ }
@@ -0,0 +1,234 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ ToolDefinition,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { type Static, Type } from "typebox";
10
+ import {
11
+ type Available,
12
+ isSlot,
13
+ type ModelMatrix,
14
+ type Resolution,
15
+ resolveSlot,
16
+ SLOTS,
17
+ type Slot,
18
+ upsertModelsTable,
19
+ } from "../core/models.ts";
20
+ import { CONFIG_FILE, loadConfig } from "./config.ts";
21
+
22
+ export const CONFIG_ENTRY_TYPE = "devsys-config";
23
+
24
+ export type CatalogModel = Available & {
25
+ readonly cost?: { readonly input: number; readonly output: number };
26
+ };
27
+
28
+ /** The slice of pi's `ctx.modelRegistry` this command needs; tests pass a fake. */
29
+ export interface ModelCatalog {
30
+ getAvailable(): readonly CatalogModel[];
31
+ getModelsOfType(type: "classifier"): readonly CatalogModel[];
32
+ hasConfiguredAuth(model: CatalogModel): boolean;
33
+ }
34
+
35
+ export type SlotRow = {
36
+ readonly slot: Slot;
37
+ readonly resolution: Resolution | undefined;
38
+ };
39
+
40
+ /** Chat models with credentials plus classifier models with credentials: everything a slot may name. */
41
+ export function availableModels(catalog: ModelCatalog): CatalogModel[] {
42
+ const classifiers = catalog
43
+ .getModelsOfType("classifier")
44
+ .filter((m) => catalog.hasConfiguredAuth(m));
45
+ return [...catalog.getAvailable(), ...classifiers];
46
+ }
47
+
48
+ export function resolveRows(matrix: ModelMatrix, available: readonly Available[]): SlotRow[] {
49
+ return SLOTS.map((slot) => {
50
+ const r = resolveSlot(matrix, slot, available);
51
+ return { slot, resolution: r.ok ? r.value : undefined };
52
+ });
53
+ }
54
+
55
+ const perMillion = (n: number): string => `$${Number(n.toFixed(2))}`;
56
+
57
+ function priceOf(model: string, available: readonly CatalogModel[]): string {
58
+ const hit = available.find((m) => `${m.provider}/${m.id}` === model);
59
+ return hit?.cost === undefined
60
+ ? ""
61
+ : ` · ${perMillion(hit.cost.input)}/${perMillion(hit.cost.output)} per M`;
62
+ }
63
+
64
+ /** One line per slot: `slot → provider/id (via candidate) · $in/$out per M`, or `UNRESOLVED`. */
65
+ export function renderModelsTable(
66
+ rows: readonly SlotRow[],
67
+ available: readonly CatalogModel[],
68
+ ): string {
69
+ const width = Math.max(...SLOTS.map((s) => s.length));
70
+ return rows
71
+ .map(({ slot, resolution }) =>
72
+ resolution === undefined
73
+ ? `${slot.padEnd(width)} → UNRESOLVED (no candidate matches a model with credentials)`
74
+ : `${slot.padEnd(width)} → ${resolution.model} (via ${resolution.via})${priceOf(resolution.model, available)}`,
75
+ )
76
+ .join("\n");
77
+ }
78
+
79
+ const DEFAULT_FILE = 'version = 1\n\n[delivery]\nmode = "trunk"\n';
80
+
81
+ export type ModelsOutcome = {
82
+ readonly ok: boolean;
83
+ readonly written: boolean;
84
+ readonly table: string;
85
+ };
86
+
87
+ type Choice = "accept" | "accept-rest" | "pick" | "pin";
88
+
89
+ const CHOICE_LABELS: Record<Choice, string> = {
90
+ accept: "accept",
91
+ "accept-rest": "accept this and all remaining slots",
92
+ pick: "pick another available model",
93
+ pin: "pin the current exact id (no auto-roll)",
94
+ };
95
+
96
+ async function chooseSlot(
97
+ ctx: ExtensionCommandContext,
98
+ slot: Slot,
99
+ row: SlotRow,
100
+ available: readonly CatalogModel[],
101
+ matrix: ModelMatrix,
102
+ ): Promise<{ matrix: ModelMatrix; acceptRest: boolean }> {
103
+ const current = row.resolution?.model;
104
+ const labels = current === undefined ? [CHOICE_LABELS.pick] : Object.values(CHOICE_LABELS);
105
+ const picked = await ctx.ui.select(`${slot}: ${current ?? "unresolved"}`, labels);
106
+ const choice = (Object.keys(CHOICE_LABELS) as Choice[]).find((k) => CHOICE_LABELS[k] === picked);
107
+ if (choice === "accept-rest") return { matrix, acceptRest: true };
108
+ if (choice === "pin" && current !== undefined) {
109
+ return { matrix: { ...matrix, [slot]: [current, ...matrix[slot]] }, acceptRest: false };
110
+ }
111
+ if (choice === "pick") {
112
+ const refs = available.map((m) => `${m.provider}/${m.id}`);
113
+ const model = await ctx.ui.select(`${slot}: choose a model`, refs);
114
+ if (model !== undefined) {
115
+ return { matrix: { ...matrix, [slot]: [model, ...matrix[slot]] }, acceptRest: false };
116
+ }
117
+ }
118
+ return { matrix, acceptRest: false };
119
+ }
120
+
121
+ async function readConfigText(repoRoot: string): Promise<string> {
122
+ try {
123
+ return await readFile(join(repoRoot, CONFIG_FILE), "utf8");
124
+ } catch {
125
+ return DEFAULT_FILE;
126
+ }
127
+ }
128
+
129
+ /** Core of `/devsys-models`: resolve, optionally adjust interactively, and write the `[models]` table. */
130
+ export async function runModelsCommand(
131
+ pi: Pick<ExtensionAPI, "appendEntry">,
132
+ ctx: ExtensionCommandContext,
133
+ args: string,
134
+ catalog: ModelCatalog,
135
+ ): Promise<ModelsOutcome> {
136
+ const config = await loadConfig(ctx.cwd);
137
+ if (!config.ok) {
138
+ ctx.ui.notify(`${CONFIG_FILE}: ${config.error.message}`, "error");
139
+ return { ok: false, written: false, table: "" };
140
+ }
141
+ const available = availableModels(catalog);
142
+ let matrix = config.value.models;
143
+ let rows = resolveRows(matrix, available);
144
+ const check = args.split(/\s+/).includes("--check");
145
+ if (!check && ctx.hasUI) {
146
+ let acceptRest = false;
147
+ for (const slot of SLOTS) {
148
+ if (acceptRest) break;
149
+ const row = rows.find((r) => r.slot === slot);
150
+ if (row === undefined) continue;
151
+ const next = await chooseSlot(ctx, slot, row, available, matrix);
152
+ matrix = next.matrix;
153
+ acceptRest = next.acceptRest;
154
+ rows = resolveRows(matrix, available);
155
+ }
156
+ }
157
+ const table = renderModelsTable(rows, available);
158
+ const unresolved = rows.flatMap((r) => (r.resolution === undefined ? [r.slot] : []));
159
+ if (check || unresolved.length > 0) {
160
+ const ok = unresolved.length === 0;
161
+ ctx.ui.notify(
162
+ ok ? table : `${table}\n\nUnresolvable slots: ${unresolved.join(", ")}. Nothing written.`,
163
+ ok ? "info" : "error",
164
+ );
165
+ return { ok, written: false, table };
166
+ }
167
+ const text = upsertModelsTable(await readConfigText(ctx.cwd), matrix);
168
+ await writeFile(join(ctx.cwd, CONFIG_FILE), text);
169
+ pi.appendEntry(CONFIG_ENTRY_TYPE, { models: matrix });
170
+ ctx.ui.notify(`${table}\n\nWrote [models] to ${CONFIG_FILE}.`, "info");
171
+ return { ok: true, written: true, table };
172
+ }
173
+
174
+ export function registerModelsCommand(pi: ExtensionAPI): void {
175
+ pi.registerCommand("devsys-models", {
176
+ description:
177
+ "Resolve the per-project model matrix against the models you can use and write it to .development-system.toml (--check: report only)",
178
+ handler: async (args, ctx) => {
179
+ await runModelsCommand(pi, ctx, args, ctx.modelRegistry);
180
+ },
181
+ });
182
+ }
183
+
184
+ const Parameters = Type.Object({
185
+ slot: Type.Optional(
186
+ Type.String({ description: `One of: ${SLOTS.join(", ")}. Omit for every slot.` }),
187
+ ),
188
+ });
189
+
190
+ const text = (t: string, isError = false) => ({
191
+ content: [{ type: "text" as const, text: t }],
192
+ details: undefined,
193
+ isError,
194
+ });
195
+
196
+ /** `devsys_models`: the resolved model for a slot (or all slots), from the project's matrix. */
197
+ export function createModelsTool(): ToolDefinition<typeof Parameters> {
198
+ return {
199
+ name: "devsys_models",
200
+ label: "Resolve models",
201
+ description:
202
+ "Return the model this project's matrix resolves each slot to (frontier, strong, fast, planning, advisor, implementer, reviewer, lens, researcher, jev) given the credentials on this machine.",
203
+ promptSnippet: "Look up which model a development-system slot resolves to",
204
+ parameters: Parameters,
205
+ exposure: "direct",
206
+ async execute(
207
+ _id,
208
+ params: Static<typeof Parameters>,
209
+ _signal,
210
+ _onUpdate,
211
+ ctx: ExtensionContext,
212
+ ) {
213
+ if (params.slot !== undefined && !isSlot(params.slot)) {
214
+ return text(`unknown slot "${params.slot}". Slots: ${SLOTS.join(", ")}`, true);
215
+ }
216
+ const config = await loadConfig(ctx.cwd);
217
+ if (!config.ok) return text(`${CONFIG_FILE}: ${config.error.message}`, true);
218
+ const available = availableModels(ctx.modelRegistry);
219
+ const rows = resolveRows(config.value.models, available).filter(
220
+ (r) => params.slot === undefined || r.slot === params.slot,
221
+ );
222
+ return text(
223
+ rows
224
+ .map((r) =>
225
+ r.resolution === undefined
226
+ ? `${r.slot}: unresolved`
227
+ : `${r.slot}: ${r.resolution.model} (via ${r.resolution.via})`,
228
+ )
229
+ .join("\n"),
230
+ rows.some((r) => r.resolution === undefined),
231
+ );
232
+ },
233
+ };
234
+ }