infinity-harness 2.0.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/LICENSE +21 -0
  3. package/README.md +266 -0
  4. package/extensions/infinity-harness/index.ts +870 -0
  5. package/harness/docs/ARCHITECTURE.md +159 -0
  6. package/harness/docs/CONSTRAINTS.md +19 -0
  7. package/harness/docs/DECISIONS.md +107 -0
  8. package/harness/docs/DOMAIN.md +13 -0
  9. package/harness/docs/agents/evaluator.md +14 -0
  10. package/harness/docs/agents/generator.md +13 -0
  11. package/harness/docs/agents/planner.md +13 -0
  12. package/harness/docs/agents/simplifier.md +13 -0
  13. package/harness/docs/api-patterns.md +23 -0
  14. package/harness/docs/phases/build.md +47 -0
  15. package/harness/docs/phases/define.md +58 -0
  16. package/harness/docs/phases/plan.md +50 -0
  17. package/harness/docs/phases/review.md +47 -0
  18. package/harness/docs/phases/ship.md +43 -0
  19. package/harness/docs/phases/simplify.md +45 -0
  20. package/harness/docs/phases/verify.md +46 -0
  21. package/harness/model-router.json +28 -0
  22. package/harness/skills/README.md +60 -0
  23. package/harness/skills/auth-security.md +56 -0
  24. package/harness/skills/building-mcp-servers.md +70 -0
  25. package/harness/skills/building-tools.md +60 -0
  26. package/harness/skills/capability-acquisition.md +72 -0
  27. package/harness/skills/cli-design.md +55 -0
  28. package/harness/skills/code-review.md +57 -0
  29. package/harness/skills/codebase-design.md +70 -0
  30. package/harness/skills/concurrency-async.md +61 -0
  31. package/harness/skills/config-and-secrets.md +52 -0
  32. package/harness/skills/context-hygiene.md +51 -0
  33. package/harness/skills/databases.md +63 -0
  34. package/harness/skills/diagnosing-bugs.md +84 -0
  35. package/harness/skills/domain-modeling.md +65 -0
  36. package/harness/skills/error-handling-logging.md +56 -0
  37. package/harness/skills/frontend-ui.md +56 -0
  38. package/harness/skills/grilling.md +48 -0
  39. package/harness/skills/http-apis.md +60 -0
  40. package/harness/skills/performance.md +53 -0
  41. package/harness/skills/pi-todo-adapted.md +41 -0
  42. package/harness/skills/planning-tasks.md +86 -0
  43. package/harness/skills/prototype.md +39 -0
  44. package/harness/skills/research.md +32 -0
  45. package/harness/skills/resolving-merge-conflicts.md +30 -0
  46. package/harness/skills/scope-discipline.md +49 -0
  47. package/harness/skills/self-review.md +45 -0
  48. package/harness/skills/stuck-protocol.md +51 -0
  49. package/harness/skills/tdd.md +80 -0
  50. package/harness/skills/testing-infra.md +57 -0
  51. package/harness/skills/writing-skills.md +60 -0
  52. package/package.json +61 -0
  53. package/src/core/brief.ts +242 -0
  54. package/src/core/config.ts +265 -0
  55. package/src/core/exec.ts +130 -0
  56. package/src/core/featureList.ts +286 -0
  57. package/src/core/fsx.ts +119 -0
  58. package/src/core/gates.ts +444 -0
  59. package/src/core/lock.ts +192 -0
  60. package/src/core/paths.ts +95 -0
  61. package/src/core/phases.ts +143 -0
  62. package/src/core/settings.ts +445 -0
  63. package/src/core/types.ts +245 -0
  64. package/src/goalLoop.ts +628 -0
  65. package/src/goalSpec.ts +679 -0
  66. package/src/goalState.ts +338 -0
  67. package/src/loop.ts +355 -0
  68. package/src/modelRouter.ts +184 -0
  69. package/src/remote.ts +244 -0
  70. package/src/replan.ts +300 -0
  71. package/src/review.ts +53 -0
  72. package/src/rework.ts +274 -0
  73. package/src/taskList.ts +355 -0
  74. package/src/ui/config.ts +286 -0
  75. package/src/ui/dashboard.ts +1066 -0
  76. package/src/ui/theme.ts +317 -0
  77. package/src/ui/widget.ts +370 -0
  78. package/src/unstuck.ts +214 -0
  79. package/src/worker.ts +351 -0
  80. package/types/proper-lockfile.d.ts +19 -0
@@ -0,0 +1,355 @@
1
+ /**
2
+ * infinity-harness — the atomic plan editor.
3
+ *
4
+ * The agent edits the plan by submitting the *whole* task list. That sounds
5
+ * wasteful until you watch a model try to do incremental edits over a long
6
+ * run: it loses track of what exists, re-adds deleted tasks, and drifts from
7
+ * the file. Submitting the full list makes every write self-correcting, and
8
+ * makes omission mean deletion — a single unambiguous rule.
9
+ *
10
+ * Three invariants hold on every write:
11
+ *
12
+ * 1. `baseRevision` must match, or the write is rejected. Two workers
13
+ * cannot silently clobber each other during a parallel run.
14
+ * 2. Unknown fields on a stored task survive. An update merges onto the
15
+ * task on disk rather than replacing it, so `difficulty`, `modelHint`,
16
+ * `criteria` and anything a later version adds are never dropped.
17
+ * 3. The dependency graph stays acyclic and every reference resolves.
18
+ */
19
+
20
+ import type { FeatureList, Task, TaskStatus, Subtask } from "./core/types.ts";
21
+ import { ValidationError } from "./core/types.ts";
22
+ import {
23
+ MAX_TASKS,
24
+ MAX_DEPENDS_ON,
25
+ MAX_SUBJECT_LEN,
26
+ MAX_DESCRIPTION_LEN,
27
+ detectCycle,
28
+ flattenTasks,
29
+ loadFeatureList,
30
+ normalizeStatus,
31
+ normalizeSubtaskStatus,
32
+ saveFeatureList,
33
+ validateKey,
34
+ type FlatTask,
35
+ } from "./core/featureList.ts";
36
+ import { featureListPath } from "./core/paths.ts";
37
+ import { withLockSync } from "./core/lock.ts";
38
+
39
+ /** One task as submitted by the agent. Only `key` is mandatory. */
40
+ export type TaskInput = {
41
+ key: string;
42
+ subject?: string;
43
+ description?: string;
44
+ status?: string;
45
+ dependsOn?: string[];
46
+ subtasks?: Array<{ title: string; status?: string }>;
47
+ difficulty?: string;
48
+ modelHint?: string;
49
+ criteria?: string[];
50
+ };
51
+
52
+ export type ApplyInput = {
53
+ baseRevision?: number;
54
+ tasks: TaskInput[];
55
+ };
56
+
57
+ export type Change = {
58
+ added: string[];
59
+ updated: string[];
60
+ removed: string[];
61
+ reordered: boolean;
62
+ };
63
+
64
+ export type ApplyResult = {
65
+ revision: number;
66
+ list: FeatureList;
67
+ tasks: FlatTask[];
68
+ change: Change;
69
+ /** False when the submission was a no-op; the revision did not move. */
70
+ changed: boolean;
71
+ };
72
+
73
+ const DEFAULT_FEATURE_ID = "feature-001";
74
+
75
+ function splitKey(key: string): { featureId: string | null; taskId: string } {
76
+ const i = key.indexOf("/");
77
+ if (i === -1) return { featureId: null, taskId: key };
78
+ return { featureId: key.slice(0, i), taskId: key.slice(i + 1) };
79
+ }
80
+
81
+ function validateSubtasks(raw: TaskInput["subtasks"], path: string): Subtask[] {
82
+ if (!Array.isArray(raw)) return [];
83
+ return raw.map((st, i) => {
84
+ const title = String(st?.title ?? "").trim();
85
+ if (!title) throw new ValidationError(`${path}.subtasks[${i}].title is required`);
86
+ if (title.length > MAX_SUBJECT_LEN) {
87
+ throw new ValidationError(`${path}.subtasks[${i}].title exceeds ${MAX_SUBJECT_LEN} characters`);
88
+ }
89
+ let status: Subtask["status"];
90
+ try {
91
+ status = normalizeSubtaskStatus(st?.status ?? "pending");
92
+ } catch {
93
+ throw new ValidationError(`${path}.subtasks[${i}].status is invalid: ${String(st?.status)}`);
94
+ }
95
+ return { id: `${path.replace(/\W+/g, "-")}-st${i}`, title, status };
96
+ });
97
+ }
98
+
99
+ function validateDependsOn(raw: string[] | undefined, path: string): string[] {
100
+ if (!Array.isArray(raw)) return [];
101
+ if (raw.length > MAX_DEPENDS_ON) {
102
+ throw new ValidationError(`${path}.dependsOn supports at most ${MAX_DEPENDS_ON} entries`);
103
+ }
104
+ const seen = new Set<string>();
105
+ const out: string[] = [];
106
+ for (const dep of raw) {
107
+ const k = validateKey(String(dep ?? ""), `${path}.dependsOn`);
108
+ if (!seen.has(k)) {
109
+ seen.add(k);
110
+ out.push(k);
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Apply a full task list to the plan on disk.
118
+ *
119
+ * Pure with respect to the filesystem: it takes the current list and returns
120
+ * the next one. `writeTaskList` is what persists it.
121
+ */
122
+ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyResult {
123
+ if (input.baseRevision !== undefined && input.baseRevision !== current.baseRevision) {
124
+ throw new ValidationError(
125
+ `stale baseRevision: you sent ${input.baseRevision}, the plan is at ${current.baseRevision}. ` +
126
+ `Re-read the plan and resubmit.`,
127
+ );
128
+ }
129
+ if (!Array.isArray(input.tasks)) {
130
+ throw new ValidationError("tasks must be an array");
131
+ }
132
+ if (input.tasks.length > MAX_TASKS) {
133
+ throw new ValidationError(`tasks supports at most ${MAX_TASKS} items, got ${input.tasks.length}`);
134
+ }
135
+
136
+ const before = flattenTasks(current);
137
+ const storedByKey = new Map<string, FlatTask>();
138
+ for (const t of before) {
139
+ storedByKey.set(t.compositeKey, t);
140
+ storedByKey.set(t.id, t);
141
+ if (t.key) storedByKey.set(t.key, t);
142
+ }
143
+
144
+ // -- validate and merge each submitted task ------------------------------
145
+ type Staged = { featureId: string; task: Task; compositeKey: string };
146
+ const staged: Staged[] = [];
147
+ const seen = new Set<string>();
148
+
149
+ for (let i = 0; i < input.tasks.length; i++) {
150
+ const raw = input.tasks[i]!;
151
+ const path = `tasks[${i}]`;
152
+ const key = validateKey(String(raw?.key ?? ""), `${path}.key`);
153
+ if (seen.has(key)) throw new ValidationError(`${path}.key is duplicated: ${key}`);
154
+ seen.add(key);
155
+
156
+ const existing = storedByKey.get(key);
157
+ const { featureId: keyFeature, taskId } = splitKey(key);
158
+ const featureId = keyFeature ?? existing?.featureId ?? current.features[0]?.id ?? DEFAULT_FEATURE_ID;
159
+
160
+ const subject = raw.subject ?? raw.description ?? existing?.description;
161
+ if (subject === undefined) {
162
+ throw new ValidationError(`${path}.subject is required for new task ${key}`);
163
+ }
164
+ const description = String(subject).trim();
165
+ if (!description) throw new ValidationError(`${path}.subject must not be empty`);
166
+ if (description.length > MAX_DESCRIPTION_LEN) {
167
+ throw new ValidationError(`${path}.subject exceeds ${MAX_DESCRIPTION_LEN} characters`);
168
+ }
169
+
170
+ const statusRaw = raw.status ?? existing?.status;
171
+ if (statusRaw === undefined) {
172
+ throw new ValidationError(`${path}.status is required for new task ${key}`);
173
+ }
174
+ let status: TaskStatus;
175
+ try {
176
+ status = normalizeStatus(statusRaw);
177
+ } catch {
178
+ throw new ValidationError(`${path}.status is invalid: ${String(statusRaw)}`);
179
+ }
180
+
181
+ const dependsOn =
182
+ raw.dependsOn !== undefined ? validateDependsOn(raw.dependsOn, path) : [...(existing?.dependsOn ?? [])];
183
+
184
+ const subtasks =
185
+ raw.subtasks !== undefined
186
+ ? validateSubtasks(raw.subtasks, path)
187
+ : (existing?.subtasks ?? []).map((s) => ({ ...s }));
188
+
189
+ // Merge onto the stored task so unknown fields survive. `index`,
190
+ // `compositeKey`, `featureId` and `featureName` are view-only additions
191
+ // from flattenTasks and must not be persisted.
192
+ const base: Record<string, unknown> = existing ? { ...existing } : {};
193
+ delete base.index;
194
+ delete base.compositeKey;
195
+ delete base.featureId;
196
+ delete base.featureName;
197
+
198
+ const task: Task = {
199
+ ...(base as Partial<Task>),
200
+ id: taskId,
201
+ key,
202
+ description,
203
+ status,
204
+ dependsOn,
205
+ subtasks,
206
+ } as Task;
207
+
208
+ if (raw.difficulty !== undefined) task.difficulty = raw.difficulty as Task["difficulty"];
209
+ if (raw.modelHint !== undefined) task.modelHint = raw.modelHint;
210
+ if (raw.criteria !== undefined) task.criteria = raw.criteria;
211
+
212
+ staged.push({ featureId, task, compositeKey: key });
213
+ }
214
+
215
+ // -- dependency integrity -------------------------------------------------
216
+ const stagedKeys = new Set<string>();
217
+ for (const s of staged) {
218
+ stagedKeys.add(s.compositeKey);
219
+ stagedKeys.add(s.task.id);
220
+ }
221
+
222
+ const removedKeys = before.filter((t) => !stagedKeys.has(t.compositeKey) && !stagedKeys.has(t.id));
223
+ const removedComplete = new Set(
224
+ removedKeys.filter((t) => t.status === "complete").flatMap((t) => [t.compositeKey, t.id]),
225
+ );
226
+
227
+ for (const s of staged) {
228
+ // A dependency on a task that was deleted *because it was finished* is
229
+ // satisfied, not dangling — drop it rather than failing the write.
230
+ s.task.dependsOn = (s.task.dependsOn ?? []).filter((d) => !removedComplete.has(d));
231
+ }
232
+
233
+ for (let i = 0; i < staged.length; i++) {
234
+ const s = staged[i]!;
235
+ for (const dep of s.task.dependsOn ?? []) {
236
+ if (!stagedKeys.has(dep)) {
237
+ throw new ValidationError(`tasks[${i}].dependsOn references unknown task "${dep}"`);
238
+ }
239
+ }
240
+ }
241
+
242
+ detectCycle(staged.map((s) => ({ compositeKey: s.compositeKey, dependsOn: s.task.dependsOn })));
243
+
244
+ const statusByKey = new Map<string, TaskStatus>();
245
+ for (const s of staged) {
246
+ statusByKey.set(s.compositeKey, s.task.status);
247
+ statusByKey.set(s.task.id, s.task.status);
248
+ }
249
+ for (let i = 0; i < staged.length; i++) {
250
+ const s = staged[i]!;
251
+ if (s.task.status !== "in_progress" && s.task.status !== "complete") continue;
252
+ const unmet = (s.task.dependsOn ?? []).filter((d) => statusByKey.get(d) !== "complete");
253
+ if (unmet.length > 0) {
254
+ throw new ValidationError(
255
+ `tasks[${i}] (${s.compositeKey}) cannot be "${s.task.status}" while these are incomplete: ${unmet.join(", ")}`,
256
+ );
257
+ }
258
+ }
259
+
260
+ // -- rebuild the list, preserving feature metadata ------------------------
261
+ const next: FeatureList = structuredClone(current);
262
+ const featureById = new Map(next.features.map((f) => [f.id, f]));
263
+ for (const f of next.features) f.tasks = [];
264
+
265
+ for (const s of staged) {
266
+ let feature = featureById.get(s.featureId);
267
+ if (!feature) {
268
+ feature = { id: s.featureId, name: s.featureId, passes: false, tasks: [] };
269
+ next.features.push(feature);
270
+ featureById.set(s.featureId, feature);
271
+ }
272
+ feature.tasks.push(s.task);
273
+ }
274
+
275
+ // A feature passes when it has tasks and all of them are complete.
276
+ for (const f of next.features) {
277
+ f.passes = f.tasks.length > 0 && f.tasks.every((t) => t.status === "complete");
278
+ }
279
+
280
+ // -- diff -----------------------------------------------------------------
281
+ const added: string[] = [];
282
+ const updated: string[] = [];
283
+ for (const s of staged) {
284
+ const prev = storedByKey.get(s.compositeKey);
285
+ if (!prev) {
286
+ added.push(s.compositeKey);
287
+ continue;
288
+ }
289
+ const prevComparable = stripView(prev);
290
+ if (JSON.stringify(prevComparable) !== JSON.stringify(s.task)) updated.push(s.compositeKey);
291
+ }
292
+ const removed = removedKeys.map((t) => t.compositeKey);
293
+ const oldOrder = before.map((t) => t.compositeKey);
294
+ const newOrder = staged.map((s) => s.compositeKey);
295
+ const reordered =
296
+ oldOrder.length !== newOrder.length || oldOrder.some((k, i) => k !== newOrder[i]);
297
+
298
+ const changed = added.length > 0 || updated.length > 0 || removed.length > 0 || reordered;
299
+ next.baseRevision = changed ? current.baseRevision + 1 : current.baseRevision;
300
+
301
+ return {
302
+ revision: next.baseRevision,
303
+ list: next,
304
+ tasks: flattenTasks(next),
305
+ change: { added, updated, removed, reordered },
306
+ changed,
307
+ };
308
+ }
309
+
310
+ function stripView(t: FlatTask): Task {
311
+ const copy: Record<string, unknown> = { ...t };
312
+ delete copy.index;
313
+ delete copy.compositeKey;
314
+ delete copy.featureId;
315
+ delete copy.featureName;
316
+ return copy as Task;
317
+ }
318
+
319
+ /**
320
+ * Read the plan, apply the submission, and persist it — as one atomic section.
321
+ *
322
+ * The lock is not optional and not an optimisation. Read, check `baseRevision`,
323
+ * write is a classic check-then-act: two processes that both read revision N
324
+ * both pass the check and both write N+1, and one set of edits is gone. The
325
+ * revision guard catches a stale *read*; only mutual exclusion serialises the
326
+ * whole sequence.
327
+ *
328
+ * Fails closed. If the lock cannot be taken the write is refused with a
329
+ * `LockTimeoutError` the caller can retry, rather than racing and silently
330
+ * losing an edit.
331
+ */
332
+ export function writeTaskList(targetDir: string, input: ApplyInput): ApplyResult {
333
+ return withLockSync(featureListPath(targetDir), () => {
334
+ const { list } = loadFeatureList(targetDir);
335
+ const result = applyTaskList(list, input);
336
+ if (result.changed) saveFeatureList(targetDir, result.list);
337
+ return result;
338
+ });
339
+ }
340
+
341
+ /** One-line summary of a write, for the tool result the model reads back. */
342
+ export function summarizeApply(result: ApplyResult): string {
343
+ if (result.tasks.length === 0) return `Plan cleared (revision ${result.revision}).`;
344
+ const { added, updated, removed, reordered } = result.change;
345
+ const bits: string[] = [];
346
+ if (added.length) bits.push(`+${added.length}`);
347
+ if (updated.length) bits.push(`~${updated.length}`);
348
+ if (removed.length) bits.push(`-${removed.length}`);
349
+ if (reordered) bits.push("reordered");
350
+ const diff = bits.length ? ` (${bits.join(" ")})` : " (no change)";
351
+ const rows = result.tasks
352
+ .map((t) => `${t.index}. [${t.status}] ${t.compositeKey}: ${t.description}`)
353
+ .join("\n");
354
+ return `Plan revision ${result.revision}${diff}\n${rows}`;
355
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * infinity-harness — the settings TUI.
3
+ *
4
+ * Every option is reachable and editable from inside pi. Editing the JSON by
5
+ * hand still works and always will, but nobody should *have* to: a harness you
6
+ * can only configure by opening a file in another window is a harness people
7
+ * configure once, wrongly, and never touch again.
8
+ *
9
+ * The menu is generated from `core/settings.ts`, so adding an option there
10
+ * makes it appear here automatically.
11
+ *
12
+ * This module never imports pi. It talks to a `Prompter`, which the extension
13
+ * satisfies with `ctx.ui` and a test satisfies with a scripted fake — which is
14
+ * why the whole flow is testable without a terminal.
15
+ */
16
+
17
+ import type { Setting, SettingsGroup } from "../core/settings.ts";
18
+ import {
19
+ SETTINGS,
20
+ coerce,
21
+ formatValue,
22
+ readAll,
23
+ readSetting,
24
+ writeSetting,
25
+ } from "../core/settings.ts";
26
+
27
+ /** The subset of pi's UI this flow needs. */
28
+ export type Prompter = {
29
+ select(title: string, options: string[]): Promise<string | undefined>;
30
+ input(title: string, placeholder?: string): Promise<string | undefined>;
31
+ notify(message: string, level?: "info" | "warning" | "error"): void;
32
+ };
33
+
34
+ /** A model pi has configured and can actually authenticate. */
35
+ export type ModelChoice = {
36
+ /** How the harness stores it: `provider/id`. */
37
+ ref: string;
38
+ /** What the user sees. */
39
+ label: string;
40
+ };
41
+
42
+ export type ConfigMenuOptions = {
43
+ targetDir: string;
44
+ prompt: Prompter;
45
+ /** Models offered for any `model`-typed setting. */
46
+ models: () => ModelChoice[] | Promise<ModelChoice[]>;
47
+ /** Stop after one edit instead of returning to the menu. Used by tests. */
48
+ once?: boolean;
49
+ };
50
+
51
+ const BACK = "← back";
52
+ const DONE = "✓ done";
53
+ const INHERIT = "(use pi's current model)";
54
+ const CUSTOM = "type a model id…";
55
+
56
+ /**
57
+ * Run the settings menu until the user leaves.
58
+ *
59
+ * Returns the paths that were changed, so the caller can report what happened
60
+ * and refresh anything that reads config.
61
+ */
62
+ export async function runConfigMenu(options: ConfigMenuOptions): Promise<string[]> {
63
+ const { targetDir, prompt } = options;
64
+ const changed: string[] = [];
65
+
66
+ for (;;) {
67
+ const io = readAll(targetDir);
68
+ const labels = SETTINGS.map((g) => `${g.label} · ${summarize(g, io)}`);
69
+ const choice = await prompt.select("infinity-harness settings", [...labels, DONE]);
70
+ if (choice === undefined || choice === DONE) break;
71
+
72
+ const group = SETTINGS[labels.indexOf(choice)];
73
+ if (!group) break;
74
+
75
+ const edited = await runGroup(group, options, changed);
76
+ if (options.once && edited) break;
77
+ }
78
+
79
+ return changed;
80
+ }
81
+
82
+ /** One line per group so the top menu says something at a glance. */
83
+ function summarize(group: SettingsGroup, io: ReturnType<typeof readAll>): string {
84
+ switch (group.id) {
85
+ case "models": {
86
+ const enabled = io.router.enabled;
87
+ if (!enabled) return "routing off";
88
+ const tiers = ["easy", "moderate", "difficult"]
89
+ .map((t) => (io.router.byDifficulty as Record<string, string> | undefined)?.[t])
90
+ .filter((v): v is string => Boolean(v && v.trim()));
91
+ return tiers.length ? `${tiers.length}/3 tiers set` : "routing on, no tiers set";
92
+ }
93
+ case "pipeline":
94
+ return (io.config.phases?.enabled ?? []).join(" → ") || "(none)";
95
+ case "commands": {
96
+ const set = Object.entries(io.config.commands ?? {}).filter(([, v]) => Boolean(v));
97
+ return set.length ? set.map(([k]) => k).join(", ") : "none set";
98
+ }
99
+ case "gates":
100
+ return io.config.gates?.enabled === false ? "DISABLED" : "on";
101
+ case "loop":
102
+ return `${io.config.loop?.maxIterations ?? "?"} turns · ${formatValue(
103
+ { type: { kind: "number", unit: "ms" } } as Setting,
104
+ io.config.loop?.maxWallClockMs,
105
+ )}`;
106
+ case "retries":
107
+ return `${io.config.maxRetries ?? "?"} per task`;
108
+ default:
109
+ return "";
110
+ }
111
+ }
112
+
113
+ async function runGroup(
114
+ group: SettingsGroup,
115
+ options: ConfigMenuOptions,
116
+ changed: string[],
117
+ ): Promise<boolean> {
118
+ const { targetDir, prompt } = options;
119
+ let edited = false;
120
+
121
+ for (;;) {
122
+ const io = readAll(targetDir);
123
+ const rows = group.settings.map((s) => `${s.label}: ${formatValue(s, readSetting(io, s))}`);
124
+ const choice = await prompt.select(`${group.label} — ${group.help}`, [...rows, BACK]);
125
+ if (choice === undefined || choice === BACK) return edited;
126
+
127
+ const setting = group.settings[rows.indexOf(choice)];
128
+ if (!setting) return edited;
129
+
130
+ const applied = await editSetting(setting, options);
131
+ if (applied) {
132
+ changed.push(setting.path);
133
+ edited = true;
134
+ if (options.once) return true;
135
+ }
136
+ }
137
+ }
138
+
139
+ /** Prompt for one setting and persist it. Returns whether anything changed. */
140
+ async function editSetting(setting: Setting, options: ConfigMenuOptions): Promise<boolean> {
141
+ const { targetDir, prompt } = options;
142
+ const io = readAll(targetDir);
143
+ const current = readSetting(io, setting);
144
+
145
+ let raw: string | undefined;
146
+
147
+ switch (setting.type.kind) {
148
+ case "boolean": {
149
+ const picked = await prompt.select(`${setting.label} — ${setting.help}`, ["on", "off", BACK]);
150
+ if (picked === undefined || picked === BACK) return false;
151
+ raw = picked;
152
+ break;
153
+ }
154
+ case "choice": {
155
+ const picked = await prompt.select(`${setting.label} — ${setting.help}`, [
156
+ ...setting.type.choices,
157
+ BACK,
158
+ ]);
159
+ if (picked === undefined || picked === BACK) return false;
160
+ raw = picked;
161
+ break;
162
+ }
163
+ case "model": {
164
+ raw = await pickModel(setting, options, typeof current === "string" ? current : "");
165
+ if (raw === undefined) return false;
166
+ break;
167
+ }
168
+ case "multi": {
169
+ // Toggling one at a time beats asking someone to retype a whole list.
170
+ const selected = new Set(Array.isArray(current) ? (current as string[]) : []);
171
+ for (;;) {
172
+ const rows = setting.type.choices.map((c) => `${selected.has(c) ? "[x]" : "[ ]"} ${c}`);
173
+ const picked = await prompt.select(
174
+ `${setting.label} — ${setting.help}`,
175
+ [...rows, DONE, BACK],
176
+ );
177
+ if (picked === undefined || picked === BACK) return false;
178
+ if (picked === DONE) break;
179
+ const idx = rows.indexOf(picked);
180
+ const key = setting.type.choices[idx];
181
+ if (key === undefined) return false;
182
+ if (selected.has(key)) selected.delete(key);
183
+ else selected.add(key);
184
+ }
185
+ raw = [...selected].join(",");
186
+ break;
187
+ }
188
+ default: {
189
+ const shown = formatValue(setting, current);
190
+ const answer = await prompt.input(
191
+ `${setting.label} — ${setting.help} [now: ${shown}]`,
192
+ setting.type.kind === "text" ? setting.type.placeholder : undefined,
193
+ );
194
+ if (answer === undefined) return false;
195
+ raw = answer;
196
+ break;
197
+ }
198
+ }
199
+
200
+ const result = coerce(setting, raw);
201
+ if (!result.ok) {
202
+ prompt.notify(`${setting.label}: ${result.error}`, "warning");
203
+ return false;
204
+ }
205
+
206
+ if (JSON.stringify(result.value) === JSON.stringify(current)) return false;
207
+
208
+ try {
209
+ writeSetting(targetDir, setting, result.value);
210
+ } catch (e) {
211
+ prompt.notify(
212
+ `could not save ${setting.label}: ${e instanceof Error ? e.message : String(e)}`,
213
+ "error",
214
+ );
215
+ return false;
216
+ }
217
+
218
+ prompt.notify(`${setting.label} → ${formatValue(setting, result.value)}`, "info");
219
+ return true;
220
+ }
221
+
222
+ /**
223
+ * Offer the models pi actually has, rather than asking someone to remember an
224
+ * id. Inheriting pi's current model is the first option because it is the
225
+ * right answer for most tiers most of the time.
226
+ */
227
+ async function pickModel(
228
+ setting: Setting,
229
+ options: ConfigMenuOptions,
230
+ current: string,
231
+ ): Promise<string | undefined> {
232
+ const { prompt } = options;
233
+ let models: ModelChoice[] = [];
234
+ try {
235
+ models = await options.models();
236
+ } catch {
237
+ models = [];
238
+ }
239
+
240
+ if (models.length === 0) {
241
+ prompt.notify(
242
+ "pi reports no configured models — falling back to typing an id. Check `pi models` / your provider auth.",
243
+ "warning",
244
+ );
245
+ const typed = await prompt.input(`${setting.label} — ${setting.help}`, current || "provider/model-id");
246
+ return typed;
247
+ }
248
+
249
+ const rows = models.map((m) => (m.ref === current ? `${m.label} ← current` : m.label));
250
+ const picked = await prompt.select(`${setting.label} — ${setting.help}`, [
251
+ INHERIT,
252
+ ...rows,
253
+ CUSTOM,
254
+ BACK,
255
+ ]);
256
+
257
+ if (picked === undefined || picked === BACK) return undefined;
258
+ if (picked === INHERIT) return "";
259
+ if (picked === CUSTOM) {
260
+ const typed = await prompt.input(`${setting.label} — model id`, current || "provider/model-id");
261
+ return typed;
262
+ }
263
+ const model = models[rows.indexOf(picked)];
264
+ return model?.ref;
265
+ }
266
+
267
+ /**
268
+ * A plain-text report of the whole configuration.
269
+ *
270
+ * Used by `/infinity:config show` and worth having on its own: it is the
271
+ * fastest way to answer "what is this run actually going to do?".
272
+ */
273
+ export function renderSettings(targetDir: string): string {
274
+ const io = readAll(targetDir);
275
+ const lines: string[] = ["infinity-harness settings", ""];
276
+ for (const group of SETTINGS) {
277
+ lines.push(`${group.label}`);
278
+ for (const s of group.settings) {
279
+ const value = formatValue(s, readSetting(io, s));
280
+ lines.push(` ${s.label.padEnd(28)} ${value}`);
281
+ }
282
+ lines.push("");
283
+ }
284
+ lines.push("Stored in harness/config.json and harness/model-router.json — both safe to edit by hand.");
285
+ return lines.join("\n");
286
+ }