@jwilger/pi-development-system 0.44.0 → 0.44.1

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.
@@ -148,8 +148,9 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
148
148
 
149
149
  pi.registerCommand("devsys-status", {
150
150
  description: "Show development-system phase, sizing, slice, departures and Jev status",
151
- handler: async (_args, ctx) => {
151
+ handler: (_args, ctx) => {
152
152
  ctx.ui.notify(renderStatus(state.get(), jevModel), "info");
153
+ return Promise.resolve();
153
154
  },
154
155
  });
155
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
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"
@@ -56,6 +56,6 @@ export function registerModelAdvice(deps: ModelAdviceDeps): void {
56
56
  deps.state.onChange((state) => {
57
57
  if (state.phase === phase) return;
58
58
  phase = state.phase;
59
- if (last !== undefined) void check(last).catch(() => undefined);
59
+ if (last !== undefined) check(last).catch(() => undefined);
60
60
  });
61
61
  }
@@ -1,3 +1,4 @@
1
+ import { assertNever } from "./exhaustive.ts";
1
2
  import {
2
3
  type Departure,
3
4
  type DepartureId,
@@ -69,6 +70,8 @@ function renderScope(scope: DepartureScope): string {
69
70
  return "session";
70
71
  case "once":
71
72
  return `once (${scope.toolCallId})`;
73
+ default:
74
+ return assertNever(scope);
72
75
  }
73
76
  }
74
77
 
@@ -0,0 +1,4 @@
1
+ /** Compile-time proof that a switch covered every case; a runtime hit means a parse boundary let an impossible value through. */
2
+ export function assertNever(value: never): never {
3
+ throw new Error(`unhandled case: ${JSON.stringify(value)}`);
4
+ }
@@ -24,6 +24,7 @@ const worst = (a: GitIntent, b: GitIntent): GitIntent =>
24
24
  SEVERITY.indexOf(a) <= SEVERITY.indexOf(b) ? a : b;
25
25
 
26
26
  /** Splits a command string on newlines that are outside quotes; backslash-newline is a continuation. */
27
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
27
28
  export function splitLines(command: string): string[] {
28
29
  const lines: string[] = [];
29
30
  let current = "";
@@ -63,7 +64,7 @@ export function segments(line: string): string[][] {
63
64
  const result: string[][] = [[]];
64
65
  let skipOperand = false;
65
66
  for (const token of parseShell(line, (name) => `$${name}`)) {
66
- const current = result[result.length - 1];
67
+ const current = result.at(-1);
67
68
  if (typeof token === "string" || "pattern" in token) {
68
69
  const text = typeof token === "string" ? token : token.pattern;
69
70
  if (!skipOperand) current?.push(text);
@@ -179,26 +180,50 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
179
180
  }
180
181
  }
181
182
 
182
- function classifySegment(tokens: string[]): GitIntent {
183
+ /** Skips leading `NAME=value` words, wrappers and shell keywords; `index` is the command word (-1: none). */
184
+ function skipPrefix(tokens: string[]): { index: number; hookSkip: boolean } {
183
185
  let hookSkip = false;
184
- let i = 0;
186
+ let index = 0;
185
187
  for (;;) {
186
- const t = tokens[i];
187
- if (t === undefined) return "ordinary";
188
+ const t = tokens[index];
189
+ if (t === undefined) return { index: -1, hookSkip };
188
190
  if (isAssignment(t)) {
189
191
  if (HOOK_SKIP_ENV.has(t)) hookSkip = true;
190
- i++;
191
- } else if (WRAPPERS.has(t) || KEYWORDS.has(t)) {
192
- i++;
192
+ } else if (!(WRAPPERS.has(t) || KEYWORDS.has(t))) {
193
+ return { index, hookSkip };
194
+ }
195
+ index++;
196
+ }
197
+ }
198
+
199
+ /** Git's global options (`-c k=v`, `-C dir`, flags) before the subcommand. */
200
+ function splitGlobals(rest: string[]): {
201
+ globals: string[];
202
+ sub: string | undefined;
203
+ args: string[];
204
+ } {
205
+ const globals: string[] = [];
206
+ let j = 0;
207
+ while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
208
+ const opt = rest[j] ?? "";
209
+ if (GLOBAL_WITH_VALUE.has(opt)) {
210
+ globals.push(rest[j + 1] ?? "");
211
+ j += 2;
193
212
  } else {
194
- break;
213
+ j++;
195
214
  }
196
215
  }
216
+ return { globals, sub: rest[j], args: rest.slice(j + 1) };
217
+ }
218
+
219
+ function classifySegment(tokens: string[]): GitIntent {
220
+ const { index: i, hookSkip } = skipPrefix(tokens);
221
+ if (i < 0) return "ordinary";
197
222
  const head = tokens[i] ?? "";
198
223
  const rest = tokens.slice(i + 1);
199
224
  const base = basename(head);
200
- const withHookSkip = (intent: GitIntent): GitIntent =>
201
- hookSkip ? worst(intent, "no-verify") : intent;
225
+ const withHookSkip = (found: GitIntent): GitIntent =>
226
+ hookSkip ? worst(found, "no-verify") : found;
202
227
  if (head.startsWith("$") || base === "$GIT") return "unknown";
203
228
  if (SHELLS.has(base)) {
204
229
  const c = rest.findIndex((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
@@ -213,19 +238,7 @@ function classifySegment(tokens: string[]): GitIntent {
213
238
  const at = rest.findIndex((t) => basename(t) === "git");
214
239
  return at >= 0 ? withHookSkip(classifySegment(rest.slice(at))) : "ordinary";
215
240
  }
216
- const globals: string[] = [];
217
- let j = 0;
218
- while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
219
- const opt = rest[j] ?? "";
220
- if (GLOBAL_WITH_VALUE.has(opt)) {
221
- globals.push(rest[j + 1] ?? "");
222
- j += 2;
223
- } else {
224
- j++;
225
- }
226
- }
227
- const sub = rest[j];
228
- const args = rest.slice(j + 1);
241
+ const { globals, sub, args } = splitGlobals(rest);
229
242
  const dynamic =
230
243
  (sub ?? "").startsWith("$") ||
231
244
  (["push", "reset", "rebase"].includes(sub ?? "") &&
@@ -1,3 +1,4 @@
1
+ import { assertNever } from "./exhaustive.ts";
1
2
  import {
2
3
  cleanStreak,
3
4
  type Finding,
@@ -47,7 +48,8 @@ export function reviewGap(state: DevsysState, slice: SliceRef, now: DiffNow): st
47
48
  if (review === undefined || review.rounds.length === 0) {
48
49
  return `no review has been recorded for slice ${slice}`;
49
50
  }
50
- switch (nextAction(review, now.digest)) {
51
+ const action = nextAction(review, now.digest);
52
+ switch (action) {
51
53
  case "done":
52
54
  return undefined;
53
55
  case "fix-findings":
@@ -58,6 +60,8 @@ export function reviewGap(state: DevsysState, slice: SliceRef, now: DiffNow): st
58
60
  return `the diff has changed since slice ${slice} was last reviewed; review it again`;
59
61
  case "review":
60
62
  return `slice ${slice} has ${cleanStreak(review)}/${review.required} clean review rounds`;
63
+ default:
64
+ return assertNever(action);
61
65
  }
62
66
  }
63
67
 
@@ -141,7 +141,7 @@ function parseRound(input: unknown): ReviewRound | ParseError {
141
141
  if (typeof n !== "number" || typeof reviewedAt !== "string" || typeof diffDigest !== "string") {
142
142
  return parseError("round needs number n and string reviewedAt and diffDigest");
143
143
  }
144
- if (!Array.isArray(lenses) || !lenses.every((l) => typeof l === "string")) {
144
+ if (!(Array.isArray(lenses) && lenses.every((l) => typeof l === "string"))) {
145
145
  return parseError("round lenses must be an array of strings");
146
146
  }
147
147
  if (!Array.isArray(findings)) return parseError("round findings must be an array");
@@ -125,6 +125,7 @@ type Segment = { words: string[]; truncates: string[] };
125
125
  type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
126
126
 
127
127
  /** Turns unquoted newlines into `;` (and joins backslash continuations) so shell-quote sees every command. */
128
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
128
129
  function splitLines(command: string): string {
129
130
  let out = "";
130
131
  let quote: "'" | '"' | undefined;
@@ -26,6 +26,35 @@ export type GitGuardDeps = {
26
26
  now?: () => Date;
27
27
  };
28
28
 
29
+ type Resolved = { intent: GitIntent } | { block: true; reason: string };
30
+
31
+ /** An unclassifiable command: Jev may classify it; otherwise the user decides (headless: blocked). */
32
+ async function resolveUnknown(
33
+ deps: GitGuardDeps,
34
+ ctx: ExtensionContext,
35
+ command: string,
36
+ ): Promise<Resolved> {
37
+ const judged =
38
+ deps.jev === undefined ? undefined : await judgeShellIntent(deps.jev(ctx), command);
39
+ if (judged?.ok === true && judged.value.intent !== "unknown") {
40
+ return { intent: judged.value.intent };
41
+ }
42
+ const why = judged?.ok === false ? `unavailable: ${judged.error.kind}` : "not confident";
43
+ const approved = ctx.hasUI
44
+ ? await ctx.ui.confirm(
45
+ "Development system — unclassified command",
46
+ `${redactSecrets(command)}\nCould not classify this command (Jev ${why}). It may run an irreversible git operation. Approve?`,
47
+ )
48
+ : false;
49
+ if (approved) return { intent: "ordinary" };
50
+ return {
51
+ block: true,
52
+ reason: ctx.hasUI
53
+ ? "the user declined an unclassifiable command; propose a simpler, explicit git command"
54
+ : "could not classify this command and Jev is unavailable or unsure; run interactively or use an explicit git command",
55
+ };
56
+ }
57
+
29
58
  /** Registers the `tool_call` hard stop for irreversible git operations in bash. */
30
59
  export function registerGitGuard(deps: GitGuardDeps): void {
31
60
  deps.pi.on("tool_call", async (event, ctx) => {
@@ -33,26 +62,9 @@ export function registerGitGuard(deps: GitGuardDeps): void {
33
62
  const command = event.input.command;
34
63
  let intent = classifyGitCommand(command);
35
64
  if (intent === "unknown") {
36
- const judged =
37
- deps.jev === undefined ? undefined : await judgeShellIntent(deps.jev(ctx), command);
38
- if (judged?.ok === true && judged.value.intent !== "unknown") {
39
- intent = judged.value.intent;
40
- } else {
41
- const approved = ctx.hasUI
42
- ? await ctx.ui.confirm(
43
- "Development system — unclassified command",
44
- `${redactSecrets(command)}\nCould not classify this command (Jev ${judged?.ok === false ? `unavailable: ${judged.error.kind}` : "not confident"}). It may run an irreversible git operation. Approve?`,
45
- )
46
- : false;
47
- return approved
48
- ? undefined
49
- : {
50
- block: true,
51
- reason: ctx.hasUI
52
- ? "the user declined an unclassifiable command; propose a simpler, explicit git command"
53
- : "could not classify this command and Jev is unavailable or unsure; run interactively or use an explicit git command",
54
- };
55
- }
65
+ const resolved = await resolveUnknown(deps, ctx, command);
66
+ if ("block" in resolved) return resolved;
67
+ intent = resolved.intent;
56
68
  }
57
69
  if (!HARD.has(intent)) return undefined;
58
70
  const gate = parseGateId(`git.${intent}`);
@@ -31,7 +31,8 @@ function resultingText(
31
31
  event: ToolCallEvent,
32
32
  cwd: string,
33
33
  ): { path: string; before: string; after: string } | undefined {
34
- if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) return undefined;
34
+ if (!(isToolCallEventType("write", event) || isToolCallEventType("edit", event)))
35
+ return undefined;
35
36
  const path = normalizeRepoPath(cwd, event.input.path, homedir());
36
37
  const class_ = classifyPath(path);
37
38
  if (class_ !== "source" && class_ !== "test") return undefined;
@@ -74,7 +75,7 @@ export function registerLintSuppressionGuard(deps: LintSuppressionGuardDeps): vo
74
75
  `${GATE_ID}: ${change.path} adds a suppression without a reason: ${list}. A suppression hides a ` +
75
76
  `finding from every later reader, so it must say why (at least ${MIN_RATIONALE} characters, on the ` +
76
77
  `same line or the next, e.g. "// biome-ignore lint/x: boundary parse of foreign JSON"). Add the ` +
77
- `reason, or fix the finding instead. If a bare suppression is truly right, call ` +
78
+ "reason, or fix the finding instead. If a bare suppression is truly right, call " +
78
79
  `devsys_record_departure with gate "${GATE_ID}", what you are doing, why, and the cost if wrong; then retry.`,
79
80
  };
80
81
  });
@@ -69,7 +69,7 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
69
69
  const departure = departureUse(deps.state, gate);
70
70
 
71
71
  deps.pi.on("tool_call", (event, ctx) => {
72
- if (!isToolCallEventType("edit", event) && !isToolCallEventType("write", event))
72
+ if (!(isToolCallEventType("edit", event) || isToolCallEventType("write", event)))
73
73
  return undefined;
74
74
  const { phase, lastTestRun, activeSlice } = deps.state.get();
75
75
  if (phase !== "implementing") return undefined;
@@ -75,8 +75,7 @@ const NO_SIGNALS: WeakeningSignals = { addsSkip: false, emptied: false, commente
75
75
 
76
76
  /** Adding lines never weakens a test unless an added line itself disables something. */
77
77
  const isHarmlessAddition = (before: string, after: string, signals: WeakeningSignals): boolean =>
78
- !signals.addsSkip &&
79
- !signals.commentedOut &&
78
+ !(signals.addsSkip || signals.commentedOut) &&
80
79
  isPureAddition(before, after) &&
81
80
  !addedLines(before, after).some((line) => RISKY_ADDITION.test(line));
82
81
 
@@ -134,19 +133,24 @@ export function registerTestGuard(deps: TestGuardDeps): void {
134
133
  const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
135
134
  if (isToolCallEventType("bash", event)) {
136
135
  const { command } = event.input;
137
- return bashMutations(command).flatMap(({ path: raw, kind }): Change[] => {
138
- const path = repoPath(ctx.cwd, raw);
136
+ return bashMutations(command).flatMap(({ path: written, kind }): Change[] => {
137
+ const target = repoPath(ctx.cwd, written);
139
138
  // Missing plain paths have nothing to weaken; globs and directories may still hide tests.
140
- const touched = GLOB.test(path) || existsSync(join(ctx.cwd, path));
141
- if (!isTestPath(path) || !touched) return [];
139
+ const touched = GLOB.test(target) || existsSync(join(ctx.cwd, target));
140
+ if (!(isTestPath(target) && touched)) return [];
142
141
  const rewritten =
143
142
  kind === "overwrite" ? `(rewritten by shell command) ${command}` : undefined;
144
143
  return [
145
- { path, before: readIfExists(join(ctx.cwd, path)) ?? "", after: undefined, rewritten },
144
+ {
145
+ path: target,
146
+ before: readIfExists(join(ctx.cwd, target)) ?? "",
147
+ after: undefined,
148
+ rewritten,
149
+ },
146
150
  ];
147
151
  });
148
152
  }
149
- if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) return [];
153
+ if (!(isToolCallEventType("write", event) || isToolCallEventType("edit", event))) return [];
150
154
  const path = repoPath(ctx.cwd, event.input.path);
151
155
  const raw = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
152
156
  if (raw === undefined) return [];
@@ -215,7 +219,7 @@ export function registerTestGuard(deps: TestGuardDeps): void {
215
219
  block: true,
216
220
  reason:
217
221
  `${GATE_ID}: ${first.why} (${needing.map((n) => n.change.path).join(", ")}). Tests are the oracle; ` +
218
- `weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure ` +
222
+ "weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure " +
219
223
  `with gate "${GATE_ID}", what you are doing instead, why, and the cost if wrong; then retry. ` +
220
224
  "Otherwise fix the code, not the test.",
221
225
  };
@@ -20,6 +20,23 @@ export type DiffSnapshot = {
20
20
 
21
21
  const MAX_UNTRACKED = 200;
22
22
 
23
+ const PLAIN_DIFF = [
24
+ "-c",
25
+ "core.quotePath=false",
26
+ "diff",
27
+ "--no-ext-diff",
28
+ "--no-color",
29
+ // diff.mnemonicPrefix / diff.noprefix change the `a/` `b/` header prefixes the file keys are cut from.
30
+ "--src-prefix=a/",
31
+ "--dst-prefix=b/",
32
+ // Every changed path gets a `diff --git` section: no submodule log summaries, no textconv that
33
+ // can hide an edit whose converted text is unchanged.
34
+ "--submodule=short",
35
+ "--no-textconv",
36
+ // diff.ignoreSubmodules=all would hide a bump that `git add` still stages.
37
+ "--ignore-submodules=dirty",
38
+ ];
39
+
23
40
  /**
24
41
  * The change in `range` (default `HEAD`: everything not yet committed) with its digest. `git diff`
25
42
  * leaves out untracked files, so for a range that includes the work tree they are listed and
@@ -33,25 +50,9 @@ export async function snapshotDiff(
33
50
  try {
34
51
  // Plain, parseable output whatever the user's git config says: an external diff driver or
35
52
  // forced colour removes the `diff --git` headers the per-file digests are cut from.
36
- const plain = [
37
- "-c",
38
- "core.quotePath=false",
39
- "diff",
40
- "--no-ext-diff",
41
- "--no-color",
42
- // diff.mnemonicPrefix / diff.noprefix change the `a/` `b/` header prefixes the file keys are cut from.
43
- "--src-prefix=a/",
44
- "--dst-prefix=b/",
45
- // Every changed path gets a `diff --git` section: no submodule log summaries, no textconv that
46
- // can hide an edit whose converted text is unchanged.
47
- "--submodule=short",
48
- "--no-textconv",
49
- // diff.ignoreSubmodules=all would hide a bump that `git add` still stages.
50
- "--ignore-submodules=dirty",
51
- ];
52
53
  const [diff, stat, others] = await Promise.all([
53
- exec("git", [...plain, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
54
- exec("git", [...plain, "--stat", range], { cwd, timeout: 15_000 }),
54
+ exec("git", [...PLAIN_DIFF, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
55
+ exec("git", [...PLAIN_DIFF, "--stat", range], { cwd, timeout: 15_000 }),
55
56
  // Any range that ends in the work tree (HEAD, HEAD~1, a branch) leaves untracked files out of git diff.
56
57
  !range.includes("..")
57
58
  ? exec(
@@ -79,30 +80,10 @@ export async function snapshotDiff(
79
80
  if (diff.stdout.trim() !== "" && Object.keys(files).length === 0) {
80
81
  return err("could not read the diff: no per-file sections were found");
81
82
  }
82
- if (untracked.length > 0) {
83
- const links = new Set<string>();
84
- for (const path of untracked) {
85
- const target = await linkTarget(cwd, path);
86
- if (target !== undefined) {
87
- files[path] = `new:${blobOf(target)}`;
88
- links.add(path);
89
- }
90
- }
91
- // `git hash-object <path>` follows symlinks (and fails on dangling ones), so links are hashed above.
92
- const regular = untracked.filter((p) => !links.has(p));
93
- if (regular.length > 0) {
94
- const hashes = await exec("git", ["hash-object", "--", ...regular], {
95
- cwd,
96
- timeout: 15_000,
97
- });
98
- const lines = hashes.stdout.split("\n").filter((l) => l !== "");
99
- if (hashes.code !== 0 || lines.length !== regular.length) {
100
- return err(hashes.stderr.trim() || "git hash-object could not hash every untracked file");
101
- }
102
- for (const [i, path] of regular.entries()) files[path] = `new:${lines[i]}`;
103
- }
104
- }
105
- const names = Object.keys(files).sort();
83
+ const hashed = await hashUntracked(exec, cwd, untracked);
84
+ if (!hashed.ok) return hashed;
85
+ Object.assign(files, hashed.value);
86
+ const names = Object.keys(files).sort((a, b) => a.localeCompare(b));
106
87
  const untrackedStat = [
107
88
  ...untracked.map((p) => ` ${p} (untracked)`),
108
89
  ...nested.map((p) => ` ${p} (nested repository, not reviewable)`),
@@ -118,6 +99,33 @@ export async function snapshotDiff(
118
99
  }
119
100
  }
120
101
 
102
+ /** Content digests (`new:<blob>`) of untracked files; symlinks are hashed by target, not followed. */
103
+ async function hashUntracked(
104
+ exec: Exec,
105
+ cwd: string,
106
+ untracked: string[],
107
+ ): Promise<Result<Record<string, string>, string>> {
108
+ const files: Record<string, string> = {};
109
+ const links = new Set<string>();
110
+ for (const path of untracked) {
111
+ const target = await linkTarget(cwd, path);
112
+ if (target !== undefined) {
113
+ files[path] = `new:${blobOf(target)}`;
114
+ links.add(path);
115
+ }
116
+ }
117
+ // `git hash-object <path>` follows symlinks (and fails on dangling ones), so links are hashed above.
118
+ const regular = untracked.filter((p) => !links.has(p));
119
+ if (regular.length === 0) return ok(files);
120
+ const hashes = await exec("git", ["hash-object", "--", ...regular], { cwd, timeout: 15_000 });
121
+ const lines = hashes.stdout.split("\n").filter((l) => l !== "");
122
+ if (hashes.code !== 0 || lines.length !== regular.length) {
123
+ return err(hashes.stderr.trim() || "git hash-object could not hash every untracked file");
124
+ }
125
+ for (const [i, path] of regular.entries()) files[path] = `new:${lines[i]}`;
126
+ return ok(files);
127
+ }
128
+
121
129
  const NEW_FILE = /^new file mode .*\nindex 0+\.\.([0-9a-f]+)/m;
122
130
 
123
131
  /** A new file digests as its blob, the same form an untracked file gets, so `git add` changes nothing. */
@@ -2,6 +2,7 @@ import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding
2
2
  import { type Static, Type } from "typebox";
3
3
  import type { Exec } from "../core/exec.ts";
4
4
  import { resolveSlot } from "../core/models.ts";
5
+ import type { Lens } from "../core/review.ts";
5
6
  import {
6
7
  addRound,
7
8
  DEFAULT_LENSES,
@@ -72,6 +73,51 @@ const pathSafe = (slice: string): string =>
72
73
  .replace(/^-+|-+$/g, "")
73
74
  .slice(0, 32);
74
75
 
76
+ type LensJudgement = Awaited<ReturnType<typeof judgeLenses>>;
77
+
78
+ /** Jev's lenses for the diff, or the defaults (with the reason) when Jev is offline or names none. */
79
+ function chooseLenses(judged: LensJudgement): { lenses: Lens[]; basis: string } {
80
+ if (!judged.ok) {
81
+ return {
82
+ lenses: [...DEFAULT_LENSES],
83
+ basis: `Jev unavailable (${judged.error.kind}); using the default lenses.`,
84
+ };
85
+ }
86
+ const selected = selectLenses(judged.value);
87
+ if (selected.length === 0) {
88
+ return {
89
+ lenses: [...DEFAULT_LENSES],
90
+ basis: "Jev found no specific lens; using the defaults.",
91
+ };
92
+ }
93
+ return { lenses: selected, basis: "Jev chose the lenses." };
94
+ }
95
+
96
+ /** The agent_spawn arguments for one reviewer round. */
97
+ function spawnPayload(input: {
98
+ slice: SliceRef;
99
+ round: number;
100
+ lenses: Lens[];
101
+ range: string;
102
+ model: ReturnType<typeof resolveSlot>;
103
+ now: Date;
104
+ }): Record<string, unknown> {
105
+ return {
106
+ // A fresh path per attempt: a failed spawn must not leave a thread that blocks the retry.
107
+ path: `/review-${pathSafe(input.slice)}-r${input.round}-${input.now.getTime().toString(36)}`,
108
+ type: "reviewer",
109
+ task: reviewerTask({
110
+ slice: input.slice,
111
+ round: input.round,
112
+ lenses: input.lenses,
113
+ diffRange: input.range,
114
+ }),
115
+ ...(input.model.ok ? { model: input.model.value.model } : {}),
116
+ thinkingLevel: "high",
117
+ wait: true,
118
+ };
119
+ }
120
+
75
121
  /** `devsys_review_start`: Jev picks lenses for the diff; the reply carries the agent_spawn payload for a fresh reviewer. */
76
122
  export function createReviewStartTool(
77
123
  deps: ReviewToolDeps,
@@ -106,13 +152,7 @@ export function createReviewStartTool(
106
152
  diffSample: snap.value.sample,
107
153
  profiles: deps.state.get().profiles ?? [],
108
154
  });
109
- const selected = judged.ok ? selectLenses(judged.value) : [];
110
- const lenses = selected.length > 0 ? selected : [...DEFAULT_LENSES];
111
- const basis = judged.ok
112
- ? selected.length > 0
113
- ? "Jev chose the lenses."
114
- : "Jev found no specific lens; using the defaults."
115
- : `Jev unavailable (${judged.error.kind}); using the default lenses.`;
155
+ const { lenses, basis } = chooseLenses(judged);
116
156
 
117
157
  const required = Math.max(
118
158
  config.value.review.requiredCleanRounds,
@@ -137,20 +177,14 @@ export function createReviewStartTool(
137
177
  }
138
178
 
139
179
  const round = review.rounds.length + 1;
140
- const resolved = resolveSlot(
141
- config.value.models,
142
- "reviewer",
143
- availableModels(ctx.modelRegistry),
144
- );
145
- const spawn = {
146
- // A fresh path per attempt: a failed spawn must not leave a thread that blocks the retry.
147
- path: `/review-${pathSafe(slice)}-r${round}-${(deps.now?.() ?? new Date()).getTime().toString(36)}`,
148
- type: "reviewer",
149
- task: reviewerTask({ slice, round, lenses, diffRange: range }),
150
- ...(resolved.ok ? { model: resolved.value.model } : {}),
151
- thinkingLevel: "high",
152
- wait: true,
153
- };
180
+ const spawn = spawnPayload({
181
+ slice,
182
+ round,
183
+ lenses,
184
+ range,
185
+ model: resolveSlot(config.value.models, "reviewer", availableModels(ctx.modelRegistry)),
186
+ now: deps.now?.() ?? new Date(),
187
+ });
154
188
  return reply(
155
189
  [
156
190
  `${reviewLabel(review)}; round ${round}; diff ${snap.value.digest}. ${basis}`,
@@ -68,7 +68,7 @@ export async function runCiWatch(
68
68
  export function registerCiCommand(deps: CiCommandDeps): void {
69
69
  deps.pi.registerCommand("devsys-ci", {
70
70
  description: "Watch CI on the trunk and show its state in the status line",
71
- handler: async (_args, ctx) => {
71
+ handler: (_args, ctx) => {
72
72
  ctx.ui.notify("watching CI on the trunk…", "info");
73
73
  runCiWatch(deps, ctx).catch((cause: unknown) => {
74
74
  // ctx goes stale if the session is replaced or reloaded mid-watch; there is nobody left to tell.
@@ -81,6 +81,7 @@ export function registerCiCommand(deps: CiCommandDeps): void {
81
81
  // stale context
82
82
  }
83
83
  });
84
+ return Promise.resolve();
84
85
  },
85
86
  });
86
87
  }
@@ -280,31 +280,32 @@ export function parseConfig(source: string): Result<DevsysConfig, ConfigError> {
280
280
  const delivery = deliveryAndReview(root);
281
281
  const lim = limits(root);
282
282
  const matrix = models(root);
283
- const route = routing(root);
283
+ const routes = routing(root);
284
284
  const prof = profiles(root);
285
285
  if (!delivery.ok) return delivery;
286
286
  if (!lim.ok) return lim;
287
287
  if (!matrix.ok) return matrix;
288
- if (!route.ok) return route;
288
+ if (!routes.ok) return routes;
289
289
  if (!prof.ok) return prof;
290
290
  return ok({
291
291
  ...delivery.value,
292
292
  ...lim.value,
293
293
  profiles: { override: prof.value },
294
294
  models: matrix.value,
295
- routing: route.value,
295
+ routing: routes.value,
296
296
  });
297
297
  }
298
298
 
299
+ const isMissingFile = (cause: unknown): boolean =>
300
+ cause instanceof Error && "code" in cause && String(cause.code) === "ENOENT";
301
+
299
302
  /** Reads `<repoRoot>/.development-system.toml`; a missing file means all defaults. */
300
303
  export async function loadConfig(repoRoot: string): Promise<Result<DevsysConfig, ConfigError>> {
301
304
  let source: string;
302
305
  try {
303
306
  source = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
304
307
  } catch (cause) {
305
- if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT") {
306
- return parseConfig("");
307
- }
308
+ if (isMissingFile(cause)) return parseConfig("");
308
309
  return fail(
309
310
  `cannot read ${CONFIG_FILE}: ${cause instanceof Error ? cause.message : String(cause)}`,
310
311
  );
@@ -77,30 +77,45 @@ function parseReviews(input: unknown): ReviewState[] | ParseError {
77
77
  return out;
78
78
  }
79
79
 
80
+ type Nested = Pick<DevsysState, "ci" | "profiles" | "lastTestRun" | "reviews">;
81
+
82
+ /** Parses each optional nested field that is present; the first malformed one is the error. */
83
+ function parseNested(input: Record<string, unknown>): Nested | ParseError {
84
+ const out: Record<string, unknown> = {};
85
+ const fields = [
86
+ ["ci", parseCi],
87
+ ["profiles", parseProfileList],
88
+ ["lastTestRun", parseTestRun],
89
+ ["reviews", parseReviews],
90
+ ] as const;
91
+ for (const [key, parse] of fields) {
92
+ if (input[key] === undefined) continue;
93
+ const parsed = parse(input[key]);
94
+ if (isParseError(parsed)) return parsed;
95
+ out[key] = parsed;
96
+ }
97
+ return out as Nested;
98
+ }
99
+
100
+ function parseDepartures(raw: unknown): Departure[] | ParseError {
101
+ if (!Array.isArray(raw)) return parseError("openDepartures must be an array");
102
+ const departures: Departure[] = [];
103
+ for (const item of raw) {
104
+ const departure = parseDeparture(item);
105
+ if (isParseError(departure)) return departure;
106
+ departures.push(departure);
107
+ }
108
+ return departures;
109
+ }
110
+
80
111
  /** Boundary parse for persisted state: the only place state is cast from `unknown`. */
81
112
  export function parseDevsysState(input: unknown): DevsysState | ParseError {
82
113
  if (!isRecord(input)) return parseError("devsys state must be an object");
83
- const {
84
- phase,
85
- sizing,
86
- activeSlice,
87
- openDepartures,
88
- jev,
89
- lastPushAt,
90
- ci,
91
- profiles,
92
- lastTestRun,
93
- reviews,
94
- } = input;
114
+ const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt } = input;
95
115
  if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
96
116
  if (!JEV.includes(jev as JevStatus)) return parseError(`unknown jev status: ${String(jev)}`);
97
- if (!Array.isArray(openDepartures)) return parseError("openDepartures must be an array");
98
- const departures: Departure[] = [];
99
- for (const raw of openDepartures) {
100
- const departure = parseDeparture(raw);
101
- if (isParseError(departure)) return departure;
102
- departures.push(departure);
103
- }
117
+ const departures = parseDepartures(openDepartures);
118
+ if (isParseError(departures)) return departures;
104
119
  if (sizing !== undefined && !SIZINGS.includes(sizing as Sizing)) {
105
120
  return parseError(`unknown sizing: ${String(sizing)}`);
106
121
  }
@@ -110,14 +125,8 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
110
125
  if (lastPushAt !== undefined && typeof lastPushAt !== "string") {
111
126
  return parseError("lastPushAt must be a string");
112
127
  }
113
- const parsedCi = ci === undefined ? undefined : parseCi(ci);
114
- if (isParseError(parsedCi)) return parsedCi;
115
- const parsedProfiles = profiles === undefined ? undefined : parseProfileList(profiles);
116
- if (isParseError(parsedProfiles)) return parsedProfiles;
117
- const parsedRun = lastTestRun === undefined ? undefined : parseTestRun(lastTestRun);
118
- if (isParseError(parsedRun)) return parsedRun;
119
- const parsedReviews = reviews === undefined ? undefined : parseReviews(reviews);
120
- if (isParseError(parsedReviews)) return parsedReviews;
128
+ const nested = parseNested(input);
129
+ if (isParseError(nested)) return nested;
121
130
  return {
122
131
  phase: phase as Phase,
123
132
  jev: jev as JevStatus,
@@ -125,10 +134,7 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
125
134
  ...(sizing !== undefined ? { sizing: sizing as Sizing } : {}),
126
135
  ...(activeSlice !== undefined ? { activeSlice: activeSlice as SliceRef } : {}),
127
136
  ...(lastPushAt !== undefined ? { lastPushAt } : {}),
128
- ...(parsedCi !== undefined ? { ci: parsedCi } : {}),
129
- ...(parsedProfiles !== undefined ? { profiles: parsedProfiles } : {}),
130
- ...(parsedRun !== undefined ? { lastTestRun: parsedRun } : {}),
131
- ...(parsedReviews !== undefined ? { reviews: parsedReviews } : {}),
137
+ ...nested,
132
138
  };
133
139
  }
134
140