infinity-harness 2.3.1 → 2.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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * infinity-harness — workflows: which phases run, and who signs each one.
3
+ *
4
+ * "copilot" and "autopilot" were a single switch, and a single switch is the
5
+ * wrong shape for the question. What people actually want is per-phase: let
6
+ * the model define and plan on its own but show me the review; or grill me on
7
+ * the definition and then leave me alone until it ships. Two words cannot say
8
+ * that.
9
+ *
10
+ * So the setting is a **mode per phase**, and copilot and autopilot become two
11
+ * named points in that space rather than the only two points in it:
12
+ *
13
+ * copilot you sign RESEARCH, DEFINE and PLAN
14
+ * autopilot you sign nothing
15
+ * custom you decide, phase by phase, and can name and keep it
16
+ *
17
+ * A named workflow is worth as much on the next project as on this one, so
18
+ * saved ones live with the *person* (`~/.pi/agent/infinity-harness/`), not
19
+ * under a project's `harness/`.
20
+ *
21
+ * Built-ins are read-only on purpose. "copilot" has to mean the same thing in
22
+ * every conversation about this tool; someone who wants a different copilot
23
+ * makes a custom workflow and gives it their own name.
24
+ */
25
+
26
+ import type { HarnessConfig, Phase } from "./core/types.ts";
27
+ import { PHASE_ORDER, DEFAULT_ENABLED_PHASES } from "./core/types.ts";
28
+ import { userWorkflowsPath } from "./core/paths.ts";
29
+ import { readJsonSafe, writeJsonAtomic, ensureDir } from "./core/fsx.ts";
30
+ import { dirname } from "node:path";
31
+
32
+ /**
33
+ * What happens when a phase's gate passes.
34
+ *
35
+ * `copilot` stop and ask the human to sign it off before advancing
36
+ * `autopilot` advance
37
+ */
38
+ export type PhaseMode = "copilot" | "autopilot";
39
+
40
+ export type PhaseModes = Partial<Record<Phase, PhaseMode>>;
41
+
42
+ export type Workflow = {
43
+ /** Stable key. Built-ins own the ones below; saved ones are slugs of their name. */
44
+ id: string;
45
+ name: string;
46
+ /** One line the human reads while choosing. */
47
+ description: string;
48
+ /** True for the four that ship with the package and cannot be edited. */
49
+ builtIn: boolean;
50
+ /** The pipeline this workflow runs, in canonical order. */
51
+ phases: Phase[];
52
+ /** Mode per phase. A phase absent from the map runs in autopilot. */
53
+ modes: PhaseModes;
54
+ /** When a saved workflow was written. Absent on built-ins. */
55
+ savedAt?: string;
56
+ };
57
+
58
+ /** Phases a human can be asked to sign. Everything except INIT, which is plumbing. */
59
+ export const SIGNABLE_PHASES: Phase[] = PHASE_ORDER.filter((p) => p !== "init");
60
+
61
+ const BASE: Phase[] = [...DEFAULT_ENABLED_PHASES];
62
+ const WITH_RESEARCH: Phase[] = PHASE_ORDER.filter(
63
+ (p) => p === "research" || DEFAULT_ENABLED_PHASES.includes(p),
64
+ );
65
+
66
+ function modesFrom(phases: Phase[], copilotPhases: Phase[]): PhaseModes {
67
+ const out: PhaseModes = {};
68
+ for (const p of phases) out[p] = copilotPhases.includes(p) ? "copilot" : "autopilot";
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * The workflows that ship with the package.
74
+ *
75
+ * Four, not one per taste: enough that most people find themselves in the
76
+ * list, few enough that reading the list is faster than building one.
77
+ */
78
+ export const BUILTIN_WORKFLOWS: Workflow[] = [
79
+ {
80
+ id: "copilot",
81
+ name: "copilot",
82
+ description: "You approve the research, the definition and the plan. Then it builds.",
83
+ builtIn: true,
84
+ phases: BASE,
85
+ modes: modesFrom(BASE, ["research", "define", "plan"]),
86
+ },
87
+ {
88
+ id: "autopilot",
89
+ name: "autopilot",
90
+ description: "You approve nothing. Say what you want, walk away, read the result.",
91
+ builtIn: true,
92
+ phases: BASE,
93
+ modes: modesFrom(BASE, []),
94
+ },
95
+ {
96
+ id: "spec-and-ship",
97
+ name: "spec and ship",
98
+ description: "You sign the scope going in and the release coming out. The middle is its own.",
99
+ builtIn: true,
100
+ phases: BASE,
101
+ modes: modesFrom(BASE, ["define", "ship"]),
102
+ },
103
+ {
104
+ id: "research-first",
105
+ name: "research first",
106
+ description: "Adds a RESEARCH phase and stops on all three thinking phases before any code.",
107
+ builtIn: true,
108
+ phases: WITH_RESEARCH,
109
+ modes: modesFrom(WITH_RESEARCH, ["research", "define", "plan"]),
110
+ },
111
+ {
112
+ id: "every-gate",
113
+ name: "every gate",
114
+ description: "It stops at every phase. Slowest, and the one you want on something that matters.",
115
+ builtIn: true,
116
+ phases: BASE,
117
+ modes: modesFrom(BASE, BASE),
118
+ },
119
+ ];
120
+
121
+ export function builtInWorkflow(id: string): Workflow | null {
122
+ return BUILTIN_WORKFLOWS.find((w) => w.id === id) ?? null;
123
+ }
124
+
125
+ // ── the saved store ─────────────────────────────────────────────────────────
126
+
127
+ type SavedStore = { version: string; workflows: Workflow[] };
128
+
129
+ /** Turn a name into a stable id. Two workflows cannot share one. */
130
+ export function slugify(name: string): string {
131
+ const slug = String(name ?? "")
132
+ .trim()
133
+ .toLowerCase()
134
+ .replace(/[^a-z0-9]+/g, "-")
135
+ .replace(/^-+|-+$/g, "")
136
+ .slice(0, 48);
137
+ return slug;
138
+ }
139
+
140
+ export function loadSavedWorkflows(env?: NodeJS.ProcessEnv): Workflow[] {
141
+ const store = readJsonSafe<SavedStore | null>(userWorkflowsPath(env), null);
142
+ const list = Array.isArray(store?.workflows) ? store.workflows : [];
143
+ return list
144
+ .filter((w): w is Workflow => typeof w?.id === "string" && typeof w?.name === "string")
145
+ .map((w) => ({
146
+ ...w,
147
+ builtIn: false,
148
+ phases: normalizePhases(w.phases),
149
+ modes: normalizeModes(w.modes, normalizePhases(w.phases)),
150
+ }));
151
+ }
152
+
153
+ /** Built-ins first, then the person's own. */
154
+ export function listWorkflows(env?: NodeJS.ProcessEnv): Workflow[] {
155
+ return [...BUILTIN_WORKFLOWS, ...loadSavedWorkflows(env)];
156
+ }
157
+
158
+ export function findWorkflow(id: string, env?: NodeJS.ProcessEnv): Workflow | null {
159
+ return listWorkflows(env).find((w) => w.id === id) ?? null;
160
+ }
161
+
162
+ export type SaveResult = { ok: boolean; error: string | null; workflow?: Workflow };
163
+
164
+ /**
165
+ * Save a workflow under a name the person chose.
166
+ *
167
+ * Overwriting one of their own is fine — that is editing. Overwriting a
168
+ * built-in is refused: "copilot" has to mean the same thing everywhere, and a
169
+ * shadowed built-in is a support conversation nobody enjoys.
170
+ */
171
+ export function saveWorkflow(
172
+ input: { name: string; description?: string; phases: Phase[]; modes: PhaseModes },
173
+ env?: NodeJS.ProcessEnv,
174
+ ): SaveResult {
175
+ const name = String(input.name ?? "").trim();
176
+ if (!name) return { ok: false, error: "A workflow needs a name." };
177
+
178
+ const id = slugify(name);
179
+ if (!id) return { ok: false, error: `"${name}" has no letters or digits in it.` };
180
+ if (builtInWorkflow(id)) {
181
+ return { ok: false, error: `"${name}" is a built-in workflow. Pick another name.` };
182
+ }
183
+
184
+ const phases = normalizePhases(input.phases);
185
+ const workflow: Workflow = {
186
+ id,
187
+ name,
188
+ description: (input.description ?? "").trim() || describeModes(phases, normalizeModes(input.modes, phases)),
189
+ builtIn: false,
190
+ phases,
191
+ modes: normalizeModes(input.modes, phases),
192
+ savedAt: new Date().toISOString(),
193
+ };
194
+
195
+ const existing = loadSavedWorkflows(env).filter((w) => w.id !== id);
196
+ const path = userWorkflowsPath(env);
197
+ try {
198
+ ensureDir(dirname(path));
199
+ writeJsonAtomic(path, { version: "1", workflows: [...existing, workflow] } satisfies SavedStore);
200
+ } catch (e) {
201
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
202
+ }
203
+ return { ok: true, error: null, workflow };
204
+ }
205
+
206
+ export function deleteWorkflow(id: string, env?: NodeJS.ProcessEnv): SaveResult {
207
+ if (builtInWorkflow(id)) return { ok: false, error: "Built-in workflows cannot be deleted." };
208
+ const remaining = loadSavedWorkflows(env).filter((w) => w.id !== id);
209
+ const path = userWorkflowsPath(env);
210
+ try {
211
+ ensureDir(dirname(path));
212
+ writeJsonAtomic(path, { version: "1", workflows: remaining } satisfies SavedStore);
213
+ } catch (e) {
214
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
215
+ }
216
+ return { ok: true, error: null };
217
+ }
218
+
219
+ // ── normalising ─────────────────────────────────────────────────────────────
220
+
221
+ export function normalizePhases(requested: readonly Phase[] | undefined): Phase[] {
222
+ if (!Array.isArray(requested) || requested.length === 0) return [...DEFAULT_ENABLED_PHASES];
223
+ const wanted = new Set(requested.filter((p) => (PHASE_ORDER as readonly string[]).includes(p)));
224
+ wanted.delete("init");
225
+ const ordered = PHASE_ORDER.filter((p) => wanted.has(p));
226
+ return ordered.length ? [...ordered] : [...DEFAULT_ENABLED_PHASES];
227
+ }
228
+
229
+ /** Every enabled phase gets an explicit mode; anything else is dropped. */
230
+ export function normalizeModes(modes: PhaseModes | undefined, phases: Phase[]): PhaseModes {
231
+ const out: PhaseModes = {};
232
+ for (const p of phases) out[p] = modes?.[p] === "copilot" ? "copilot" : "autopilot";
233
+ return out;
234
+ }
235
+
236
+ // ── applying, and reading back ──────────────────────────────────────────────
237
+
238
+ /** Fold a workflow into a config. Returns the config for chaining. */
239
+ export function applyWorkflow(config: HarnessConfig, workflow: Workflow): HarnessConfig {
240
+ const phases = normalizePhases(workflow.phases);
241
+ config.phases = { ...(config.phases ?? { enabled: phases }), enabled: phases };
242
+ config.phaseModes = normalizeModes(workflow.modes, phases);
243
+ config.workflow = { id: workflow.id, name: workflow.name };
244
+ return config;
245
+ }
246
+
247
+ /** The modes a config is actually running, whatever shape it was written in. */
248
+ export function modesOf(config: HarnessConfig): PhaseModes {
249
+ const phases = normalizePhases(config.phases?.enabled as Phase[] | undefined);
250
+ return normalizeModes(config.phaseModes as PhaseModes | undefined, phases);
251
+ }
252
+
253
+ export function modeFor(config: HarnessConfig, phase: Phase | null): PhaseMode {
254
+ if (!phase) return "autopilot";
255
+ return modesOf(config)[phase] === "copilot" ? "copilot" : "autopilot";
256
+ }
257
+
258
+ /** The phases this config stops on, in pipeline order. */
259
+ export function signedPhases(config: HarnessConfig): Phase[] {
260
+ const modes = modesOf(config);
261
+ return PHASE_ORDER.filter((p) => modes[p] === "copilot");
262
+ }
263
+
264
+ /**
265
+ * Which built-in or saved workflow a config currently matches, if any.
266
+ *
267
+ * Used to tell someone their settings have drifted off the preset they picked
268
+ * — otherwise a config edited one setting at a time still claims to be
269
+ * "copilot", and the word stops meaning anything.
270
+ */
271
+ export function matchWorkflow(config: HarnessConfig, env?: NodeJS.ProcessEnv): Workflow | null {
272
+ const phases = normalizePhases(config.phases?.enabled as Phase[] | undefined);
273
+ const modes = modesOf(config);
274
+ return (
275
+ listWorkflows(env).find(
276
+ (w) =>
277
+ normalizePhases(w.phases).join(",") === phases.join(",") &&
278
+ SIGNABLE_PHASES.every((p) => normalizeModes(w.modes, phases)[p] === modes[p]),
279
+ ) ?? null
280
+ );
281
+ }
282
+
283
+ // ── describing ──────────────────────────────────────────────────────────────
284
+
285
+ export function describeModes(phases: Phase[], modes: PhaseModes): string {
286
+ const signed = phases.filter((p) => modes[p] === "copilot");
287
+ if (signed.length === 0) return "runs the whole pipeline without stopping";
288
+ if (signed.length === phases.length) return "stops at every phase";
289
+ return `stops at ${signed.map((p) => p.toUpperCase()).join(", ")}`;
290
+ }
291
+
292
+ /** The pipeline with each phase's mode, for a menu row or a notification. */
293
+ export function renderWorkflow(workflow: Workflow): string {
294
+ const modes = normalizeModes(workflow.modes, normalizePhases(workflow.phases));
295
+ const rail = normalizePhases(workflow.phases)
296
+ .map((p) => (modes[p] === "copilot" ? `[${p}]` : p))
297
+ .join(" → ");
298
+ return `${workflow.name}\n ${workflow.description}\n ${rail}\n (a phase in [brackets] stops for you)`;
299
+ }
300
+
301
+ /** One line: `copilot · stops at RESEARCH, DEFINE, PLAN`. */
302
+ export function summarizeWorkflow(config: HarnessConfig, env?: NodeJS.ProcessEnv): string {
303
+ const phases = normalizePhases(config.phases?.enabled as Phase[] | undefined);
304
+ const modes = modesOf(config);
305
+ const named = matchWorkflow(config, env);
306
+ const recorded = (config.workflow as { name?: string } | undefined)?.name;
307
+ const label = named ? named.name : recorded ? `${recorded} (edited)` : "custom";
308
+ return `${label} · ${describeModes(phases, modes)}`;
309
+ }