@jwilger/pi-development-system 0.3.7 → 0.4.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/package.json +1 -1
- package/src/core/git-intent.ts +4 -1
- package/src/core/models.ts +205 -0
- package/src/core/test-paths.ts +10 -1
- package/src/core/test-weakening.ts +8 -2
- package/src/state/config.ts +313 -0
package/package.json
CHANGED
package/src/core/git-intent.ts
CHANGED
|
@@ -30,7 +30,10 @@ function splitLines(command: string): string[] {
|
|
|
30
30
|
let quote: "'" | '"' | undefined;
|
|
31
31
|
for (let i = 0; i < command.length; i++) {
|
|
32
32
|
const ch = command.charAt(i);
|
|
33
|
-
if (quote
|
|
33
|
+
if (quote === '"' && ch === "\\") {
|
|
34
|
+
current += ch + command.charAt(i + 1);
|
|
35
|
+
i++;
|
|
36
|
+
} else if (quote !== undefined) {
|
|
34
37
|
if (ch === quote) quote = undefined;
|
|
35
38
|
current += ch;
|
|
36
39
|
} else if (ch === "\\" && command.charAt(i + 1) === "\n") {
|
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
}
|
package/src/core/test-paths.ts
CHANGED
|
@@ -49,6 +49,15 @@ export function isTestPath(path: string, profile?: TestPathProfile): boolean {
|
|
|
49
49
|
return (profile?.testGlobs ?? []).some((g) => matchGlob(g, normalized));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** A malformed `file://` URL is treated as the raw path rather than throwing out of a guard. */
|
|
53
|
+
function decodeFileUrl(url: string): string {
|
|
54
|
+
try {
|
|
55
|
+
return fileURLToPath(url);
|
|
56
|
+
} catch {
|
|
57
|
+
return url;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
/**
|
|
53
62
|
* Repo-relative form of a path exactly as pi would resolve it: a leading `@` is dropped, `~` expands
|
|
54
63
|
* to the home directory and `file://` URLs are decoded, so guards see the file that is really touched.
|
|
@@ -57,6 +66,6 @@ export function normalizeRepoPath(cwd: string, input: string, home: string): str
|
|
|
57
66
|
let path = input.startsWith("@") ? input.slice(1) : input;
|
|
58
67
|
if (path === "~") path = home;
|
|
59
68
|
else if (path.startsWith("~/")) path = join(home, path.slice(2));
|
|
60
|
-
else if (/^file:\/\//.test(path)) path =
|
|
69
|
+
else if (/^file:\/\//.test(path)) path = decodeFileUrl(path);
|
|
61
70
|
return isAbsolute(path) ? relative(cwd, path) : relative(cwd, resolve(cwd, path)) || ".";
|
|
62
71
|
}
|
|
@@ -6,7 +6,7 @@ export type WeakeningSignals = { addsSkip: boolean; emptied: boolean; commentedO
|
|
|
6
6
|
const SKIP_MARKERS: readonly RegExp[] = [
|
|
7
7
|
/\b(?:it|test|describe|context)\.(?:skip|only)\b/g,
|
|
8
8
|
/\b(?:this|t|self|ctx)\.skip\(/g,
|
|
9
|
-
/\bskip:\s*true\b/g,
|
|
9
|
+
/\bskip:\s*(?:true\b|["'`])/g,
|
|
10
10
|
/\bskipTest\(/g,
|
|
11
11
|
/(?<![.\w])f(?:it|describe)\(/g,
|
|
12
12
|
/(?<![.\w])x(?:it|describe|test)\(/g,
|
|
@@ -130,7 +130,13 @@ function splitLines(command: string): string {
|
|
|
130
130
|
let quote: "'" | '"' | undefined;
|
|
131
131
|
for (let i = 0; i < command.length; i++) {
|
|
132
132
|
const c = command.charAt(i);
|
|
133
|
-
if (quote === undefined && c === "
|
|
133
|
+
if (quote === undefined && c === "#" && (out === "" || /[\s;&|(]$/.test(out))) {
|
|
134
|
+
// A comment runs to the end of the line; keep the newline so the next command is still seen.
|
|
135
|
+
while (i + 1 < command.length && command.charAt(i + 1) !== "\n") i++;
|
|
136
|
+
} else if (quote === '"' && c === "\\") {
|
|
137
|
+
out += c + command.charAt(i + 1);
|
|
138
|
+
i++;
|
|
139
|
+
} else if (quote === undefined && c === "\\" && command.charAt(i + 1) === "\n") {
|
|
134
140
|
out += " ";
|
|
135
141
|
i++;
|
|
136
142
|
} else if (quote === undefined && c === "\\") {
|
|
@@ -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
|
+
}
|