@getpipher/armory-fleet 0.3.0 → 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.
@@ -0,0 +1,169 @@
1
+ // src/lifecycle/registry.ts
2
+ import { parse as parseYaml } from "yaml";
3
+ import { basename, extname } from "node:path";
4
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import type { AgentSource } from "../registry/frontmatter.ts";
7
+ import type { BackendId, LifecycleDef, PhaseDef } from "./lifecycle-types.ts";
8
+
9
+ export class LifecycleParseError extends Error {
10
+ override name = "LifecycleParseError" as const;
11
+ }
12
+
13
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
14
+ const VALID_BACKENDS: BackendId[] = ["pi", "claude"];
15
+
16
+ /** Parse a lifecycle markdown file into a LifecycleDef. Throws LifecycleParseError on any malformed input. */
17
+ export function parseLifecycleFile(content: string, filePath: string, source: AgentSource): LifecycleDef {
18
+ const m = FM_RE.exec(content);
19
+ if (!m || m[1] === undefined || m[2] === undefined) {
20
+ throw new LifecycleParseError(`${filePath}: missing --- frontmatter delimiters`);
21
+ }
22
+ let raw: Record<string, unknown>;
23
+ try {
24
+ raw = (parseYaml(m[1]) ?? {}) as Record<string, unknown>;
25
+ } catch (e) {
26
+ throw new LifecycleParseError(`${filePath}: invalid YAML (${(e as Error).message})`);
27
+ }
28
+ const body = m[2];
29
+
30
+ const name = typeof raw.name === "string" && raw.name.trim()
31
+ ? raw.name.trim()
32
+ : basename(filePath, extname(filePath));
33
+ const description = typeof raw.description === "string" ? raw.description.trim() : "";
34
+ if (!description) throw new LifecycleParseError(`${filePath}: description is required`);
35
+
36
+ const rawBackend = typeof raw.backend === "string" ? raw.backend.trim() : "pi";
37
+ if (!VALID_BACKENDS.includes(rawBackend as BackendId)) {
38
+ throw new LifecycleParseError(`${filePath}: invalid backend '${rawBackend}' (must be 'pi' | 'claude')`);
39
+ }
40
+ const backend = rawBackend as BackendId;
41
+
42
+ if (!Array.isArray(raw.phases) || raw.phases.length === 0) {
43
+ throw new LifecycleParseError(`${filePath}: phases must be a non-empty array`);
44
+ }
45
+
46
+ // Parse phase frontmatter entries (name + skills + agent + backend + checkpoint); templates resolved after.
47
+ const phaseNames = new Set<string>();
48
+ const partialPhases = raw.phases.map((p: unknown, i: number) => {
49
+ if (!p || typeof p !== "object") {
50
+ throw new LifecycleParseError(`${filePath}: phases[${i}] must be an object`);
51
+ }
52
+ const po = p as Record<string, unknown>;
53
+ const pname = typeof po.name === "string" && po.name.trim() ? po.name.trim() : "";
54
+ if (!pname) throw new LifecycleParseError(`${filePath}: phases[${i}].name is required`);
55
+ if (phaseNames.has(pname)) {
56
+ throw new LifecycleParseError(`${filePath}: duplicate phase '${pname}'`);
57
+ }
58
+ phaseNames.add(pname);
59
+ if (!Array.isArray(po.skills)) {
60
+ throw new LifecycleParseError(`${filePath}: phase '${pname}' skills must be an array`);
61
+ }
62
+ const skills = po.skills.map((s) => String(s));
63
+ const agent = typeof po.agent === "string" && po.agent.trim() ? po.agent.trim() : undefined;
64
+ let pbackend: BackendId | undefined;
65
+ if (po.backend !== undefined) {
66
+ const b = String(po.backend).trim();
67
+ if (!VALID_BACKENDS.includes(b as BackendId)) {
68
+ throw new LifecycleParseError(`${filePath}: phase '${pname}' invalid backend '${b}'`);
69
+ }
70
+ pbackend = b as BackendId;
71
+ }
72
+ const checkpoint = po.checkpoint === undefined ? true : Boolean(po.checkpoint);
73
+ return { name: pname, skills, agent, backend: pbackend, checkpoint };
74
+ });
75
+
76
+ // Split body into `## <phase>` sections. A phase with no matching section = error.
77
+ const templates = splitPhaseTemplates(body, filePath);
78
+ const phases: PhaseDef[] = partialPhases.map((p) => {
79
+ const promptTemplate = templates.get(p.name);
80
+ if (promptTemplate === undefined) {
81
+ throw new LifecycleParseError(`${filePath}: phase '${p.name}' missing template (no '## ${p.name}' body section)`);
82
+ }
83
+ return { ...p, promptTemplate };
84
+ });
85
+ // The terminal phase never checkpoints after it (the lifecycle is done) — §5.4.
86
+ if (phases.length > 0) phases[phases.length - 1]!.checkpoint = false;
87
+
88
+ return { name, description, backend, phases, source, filePath };
89
+ }
90
+
91
+ /** Split the markdown body into a map of phase-name → prompt-template, by `## <name>` H2 headings. */
92
+ function splitPhaseTemplates(body: string, filePath: string): Map<string, string> {
93
+ const out = new Map<string, string>();
94
+ const lines = body.split(/\r?\n/);
95
+ let current: string | null = null;
96
+ const H2 = /^##\s+(\S[^\r\n]*)$/;
97
+ for (const line of lines) {
98
+ const h = H2.exec(line);
99
+ if (h && h[1] !== undefined) {
100
+ current = h[1].trim();
101
+ if (current !== null && out.has(current)) {
102
+ throw new LifecycleParseError(`${filePath}: duplicate '## ${current}' body section`);
103
+ }
104
+ if (current !== null) out.set(current, "");
105
+ } else if (current !== null) {
106
+ out.set(current, (out.get(current) ?? "") + line + "\n");
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+ export interface LifecycleDiscoverOpts {
112
+ projectDir: string | null;
113
+ globalDir: string | null;
114
+ builtinDir: string | null;
115
+ }
116
+
117
+ export interface LifecycleDiscoverResult {
118
+ lifecycles: Map<string, LifecycleDef>;
119
+ warnings: string[];
120
+ errors: string[];
121
+ }
122
+
123
+ /** Recursively collect *.md file paths (mirror registry/discovery.ts). */
124
+ function collectMarkdown(dir: string): string[] {
125
+ const out: string[] = [];
126
+ const visited = new Set<string>();
127
+ const walk = (d: string): void => {
128
+ if (!existsSync(d)) return;
129
+ let real: string;
130
+ try { real = realpathSync(d); } catch { return; }
131
+ if (visited.has(real)) return;
132
+ visited.add(real);
133
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
134
+ const full = join(d, entry.name);
135
+ if (entry.isDirectory()) walk(full);
136
+ else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
137
+ }
138
+ };
139
+ walk(dir);
140
+ return out;
141
+ }
142
+
143
+ export function discoverLifecycles(opts: LifecycleDiscoverOpts): LifecycleDiscoverResult {
144
+ const lifecycles = new Map<string, LifecycleDef>();
145
+ const warnings: string[] = [];
146
+ const errors: string[] = [];
147
+ const loadScope = (dir: string | null, source: AgentSource): void => {
148
+ if (!dir) return;
149
+ for (const f of collectMarkdown(dir).sort()) {
150
+ let content: string;
151
+ try { content = readFileSync(f, "utf8"); } catch { warnings.push(`${f}: unreadable file, skipped`); continue; }
152
+ try {
153
+ const def = parseLifecycleFile(content, f, source);
154
+ const existing = lifecycles.get(def.name);
155
+ if (existing && existing.source === source) {
156
+ errors.push(`duplicate lifecycle '${def.name}' in ${source} scope (${f}); first kept`);
157
+ continue;
158
+ }
159
+ lifecycles.set(def.name, def); // project over global/builtin (later wins)
160
+ } catch (e) {
161
+ warnings.push(e instanceof LifecycleParseError ? e.message : `${f}: ${String(e)}`);
162
+ }
163
+ }
164
+ };
165
+ loadScope(opts.builtinDir, "builtin");
166
+ loadScope(opts.globalDir, "global");
167
+ loadScope(opts.projectDir, "project");
168
+ return { lifecycles, warnings, errors };
169
+ }
@@ -0,0 +1,235 @@
1
+ // src/lifecycle/run-lifecycle.ts
2
+ import type { AgentDef } from "../registry/frontmatter.ts";
3
+ import type { FleetRunStatus } from "../todo-sync/port.ts";
4
+ import type { SpawnResult } from "../engine/spawnSubagent.ts";
5
+ import type {
6
+ BackendId, LifecycleDef, LifecycleMode, LifecycleStatus, PhaseRecord, CheckpointDecision,
7
+ } from "./lifecycle-types.ts";
8
+ import { renderPhasePrompt } from "./prompt-template.ts";
9
+ import { parseArtifacts, MAX_REVISE } from "./artifacts-parser.ts";
10
+ import {
11
+ createLifecycleTodo, updateProgress, completeLifecycleTodo, revertLifecycleTodo,
12
+ type LifecycleTodoPort, type ProgressPhase,
13
+ } from "./lifecycle-todo.ts";
14
+
15
+ /** A phase spawn is delegated to a `spawn` function (tests inject a fake; production wires spawnSubagent). */
16
+ export interface PhaseSpawnOpts {
17
+ agent: string;
18
+ task: string;
19
+ lifecycleTodoId: string;
20
+ /** The merged skill bundle for this phase (lifecycle phase skills ∪ agent's own). */
21
+ skills: string[];
22
+ /** The resolved backend for this phase (phase.backend → lifecycle.backend → "pi"). */
23
+ backend: BackendId;
24
+ model?: string;
25
+ }
26
+ export type SpawnFn = (opts: PhaseSpawnOpts) => Promise<SpawnResult>;
27
+
28
+ export interface LifecycleRunDeps {
29
+ registry: Map<string, LifecycleDef>;
30
+ agentRegistry: Map<string, AgentDef>;
31
+ spawn: SpawnFn;
32
+ todoPort: LifecycleTodoPort;
33
+ /** Resolve the backend for a phase: phase.backend → lifecycle.backend → "pi" (+ availability check). */
34
+ resolveBackend: (phaseBackend: BackendId | undefined, lifecycleBackend: BackendId) => BackendId;
35
+ genRunId: () => string;
36
+ }
37
+
38
+ export interface LifecycleRunOpts {
39
+ deps: LifecycleRunDeps;
40
+ mode: LifecycleMode;
41
+ onCheckpoint: CheckpointFn;
42
+ }
43
+
44
+ export interface LifecycleRunResult {
45
+ runId: string;
46
+ lifecycleName: string;
47
+ task: string;
48
+ backend: BackendId;
49
+ mode: LifecycleMode;
50
+ status: LifecycleStatus;
51
+ phases: PhaseRecord[];
52
+ startedAt: number;
53
+ endedAt?: number;
54
+ todoId: string | null;
55
+ error?: string;
56
+ }
57
+
58
+ /** Human (or auto) decision at a checkpoint. */
59
+ export type CheckpointFn = (phase: PhaseRecord) => Promise<CheckpointDecision>;
60
+
61
+ export async function runLifecycle(task: string, lifecycleName: string, opts: LifecycleRunOpts): Promise<LifecycleRunResult> {
62
+ const { deps } = opts;
63
+ const startedAt = Date.now();
64
+
65
+ // 1. Resolve lifecycle (resolve-time errors → failed result, no todo touched).
66
+ const lifecycle = deps.registry.get(lifecycleName);
67
+ if (!lifecycle) {
68
+ const available = [...deps.registry.keys()].sort().join(", ");
69
+ return failResult("", startedAt, `lifecycle '${lifecycleName}' not found; available: ${available}`, lifecycleName, task, opts.mode, [], null);
70
+ }
71
+
72
+ const runId = deps.genRunId();
73
+ const lifecycleBackend = lifecycle.backend;
74
+
75
+ // 2. Create the lifecycle TODO (one per lifecycle — Q7=C).
76
+ let todoId: string;
77
+ try {
78
+ todoId = await createLifecycleTodo(deps.todoPort, {
79
+ runId, task, lifecycle: lifecycleName, backend: lifecycleBackend, mode: opts.mode,
80
+ phases: lifecycle.phases.map((p) => p.name),
81
+ });
82
+ } catch (e) {
83
+ return failResult(runId, startedAt, `lifecycle TODO create failed: ${(e as Error).message}`, lifecycleName, task, opts.mode, [], null, lifecycleBackend);
84
+ }
85
+
86
+ // Phase-progress state for the todo notes (single source of truth).
87
+ const progressPhases: ProgressPhase[] = lifecycle.phases.map((p) => ({ name: p.name, done: false }));
88
+ const phaseRecords: PhaseRecord[] = [];
89
+
90
+ // 3. Phase loop.
91
+ for (let idx = 0; idx < lifecycle.phases.length; idx++) {
92
+ const phaseDef = lifecycle.phases[idx]!;
93
+ const isTerminal = idx === lifecycle.phases.length - 1;
94
+
95
+ // a/b: resolve agent + backend
96
+ const agentName = phaseDef.agent ?? "general-purpose";
97
+ if (!deps.agentRegistry.has(agentName)) {
98
+ await revertLifecycleTodo(deps.todoPort, todoId, `agent '${agentName}' not in registry`);
99
+ return failResult(runId, startedAt, `agent '${agentName}' (phase '${phaseDef.name}') not in registry`, lifecycleName, task, opts.mode, phaseRecords, todoId, lifecycleBackend);
100
+ }
101
+ // resolveBackend may throw (e.g. phase requests claude when claude is unavailable) — §12.
102
+ let backend: BackendId;
103
+ try {
104
+ backend = deps.resolveBackend(phaseDef.backend, lifecycleBackend);
105
+ } catch (e) {
106
+ await revertLifecycleTodo(deps.todoPort, todoId, `backend resolve failed: ${(e as Error).message}`);
107
+ return failResult(runId, startedAt, (e as Error).message, lifecycleName, task, opts.mode, phaseRecords, todoId, lifecycleBackend);
108
+ }
109
+ const agentDef = deps.agentRegistry.get(agentName)!;
110
+ const skills = mergeSkills(phaseDef.skills, agentDef.skills ?? []);
111
+
112
+ // Revise loop (runs the phase, then checkpoints; on Revise, re-runs with feedback)
113
+ let reviseCount = 0;
114
+ // Per-phase scratch for the revise feedback: the current phase's own prior attempt summary +
115
+ // the human's feedback. Reset each phase (a phase's revise context is its own, not a prior phase's).
116
+ let priorAttemptSummary = "";
117
+ let lastFeedback: string | undefined;
118
+ // eslint-disable-next-line no-constant-condition
119
+ while (true) {
120
+ const prev = phaseRecords.length > 0 ? phaseRecords[phaseRecords.length - 1] : undefined;
121
+ const feedback = reviseCount > 0
122
+ ? `Prior attempt summary: ${priorAttemptSummary.slice(0, 500)}\n\nHuman feedback: ${lastFeedback ?? ""}`
123
+ : undefined;
124
+ const prompt = renderPhasePrompt(phaseDef.promptTemplate, {
125
+ task, lifecycle: lifecycleName, phase: phaseDef.name,
126
+ prev: prev ? { name: prev.name, summary: prev.summary, paths: prev.paths } : undefined,
127
+ feedback,
128
+ });
129
+
130
+ // e/f: spawn the phase child (links to the lifecycle todo; skips mark-done/revert — Task 8).
131
+ // The merged skill bundle + resolved backend are threaded so the real spawn injects the
132
+ // phase's skills (Q1=B) and routes to the phase's backend (Q4=C) — not the agent's defaults.
133
+ let spawnRes: import("../engine/spawnSubagent.ts").SpawnResult;
134
+ try {
135
+ spawnRes = await deps.spawn({ agent: agentName, task: prompt, lifecycleTodoId: todoId, skills, backend });
136
+ } catch (e) {
137
+ // spawn should return a failed result, not throw — but guard anyway so a throwing spawn
138
+ // can't orphan the lifecycle (treat as a phase failure).
139
+ spawnRes = { status: "failed", finalText: "", runId: "fl-err", todoId, agent: agentName, model: "", durationMs: 0, tokenTotal: 0, error: (e as Error).message };
140
+ }
141
+
142
+ // g: parse artifacts (terminal phase exempts a missing block).
143
+ let phaseRec: PhaseRecord;
144
+ if (spawnRes.status === "failed") {
145
+ phaseRec = { name: phaseDef.name, summary: spawnRes.error ?? spawnRes.finalText.slice(0, 120), paths: [], status: "failed", reviseCount };
146
+ } else {
147
+ const art = parseArtifacts(spawnRes.finalText, { terminal: isTerminal });
148
+ if ("error" in art) {
149
+ phaseRec = { name: phaseDef.name, summary: art.error, paths: [], status: "failed", reviseCount };
150
+ } else {
151
+ phaseRec = { name: phaseDef.name, summary: art.summary, paths: art.paths, status: "completed", reviseCount };
152
+ }
153
+ }
154
+
155
+ // Capture this attempt's summary for the next revise iteration's feedback digest.
156
+ priorAttemptSummary = phaseRec.summary;
157
+
158
+ // h: update the lifecycle todo progress block.
159
+ await updateProgress(deps.todoPort, todoId, {
160
+ phase: phaseDef.name, done: phaseRec.status === "completed",
161
+ last: `${phaseDef.name} ${phaseRec.status}${phaseRec.paths.length ? " — " + phaseRec.paths.join(", ") : ""}`,
162
+ revising: false, attempt: reviseCount,
163
+ }, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases });
164
+
165
+ // i: checkpoint decision.
166
+ const forceCheckpoint = phaseRec.status === "failed"; // failure forces a checkpoint regardless of auto/checkpoint
167
+ const shouldCheckpoint = forceCheckpoint || (phaseDef.checkpoint !== false && opts.mode === "checkpointed" && !isTerminal);
168
+ if (!shouldCheckpoint) {
169
+ phaseRecords.push(phaseRec);
170
+ break; // advance to next phase
171
+ }
172
+
173
+ const decision = await opts.onCheckpoint(phaseRec);
174
+ if (decision.action === "continue") {
175
+ if (forceCheckpoint) {
176
+ // cannot continue past a failure — treat as abort (guard against a misbehaving checkpoint fn)
177
+ await revertLifecycleTodo(deps.todoPort, todoId, `cannot continue past failed phase '${phaseDef.name}'`);
178
+ phaseRecords.push(phaseRec);
179
+ return doneResult(runId, startedAt, "aborted", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId);
180
+ }
181
+ phaseRecords.push(phaseRec);
182
+ break; // advance
183
+ }
184
+ if (decision.action === "abort") {
185
+ await revertLifecycleTodo(deps.todoPort, todoId, `aborted at phase '${phaseDef.name}'`);
186
+ phaseRecords.push(phaseRec);
187
+ // A failed phase that's aborted = lifecycle failed (the work failed); a healthy phase
188
+ // aborted at a checkpoint = user-aborted (§12).
189
+ const status: LifecycleStatus = phaseRec.status === "failed" ? "failed" : "aborted";
190
+ return doneResult(runId, startedAt, status, lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId);
191
+ }
192
+ // decision.action === "revise"
193
+ reviseCount++;
194
+ lastFeedback = decision.feedback;
195
+ if (reviseCount > MAX_REVISE) {
196
+ await updateProgress(deps.todoPort, todoId, {
197
+ phase: phaseDef.name, done: false, last: `revise budget exhausted (${MAX_REVISE})`, revising: false, attempt: reviseCount,
198
+ }, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases });
199
+ phaseRecords.push(phaseRec);
200
+ return doneResult(runId, startedAt, "failed", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId,
201
+ `phase '${phaseDef.name}' revise budget exhausted (${MAX_REVISE})`);
202
+ }
203
+ // mark revising in the progress block, then loop to re-run this phase
204
+ await updateProgress(deps.todoPort, todoId, {
205
+ phase: phaseDef.name, done: false, last: `revising (attempt ${reviseCount}/${MAX_REVISE})`, revising: true, attempt: reviseCount,
206
+ }, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases });
207
+ // loop continues — re-run the phase with feedback
208
+ }
209
+ }
210
+
211
+ // j: terminal phase completed → lifecycle done.
212
+ await completeLifecycleTodo(deps.todoPort, todoId, `lifecycle '${lifecycleName}' completed`);
213
+ return doneResult(runId, startedAt, "completed", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId);
214
+ }
215
+
216
+ /** Merge lifecycle phase skills + agent's own skills (lifecycle first; agent can only add — Q3=B). */
217
+ function mergeSkills(phaseSkills: string[], agentSkills: string[]): string[] {
218
+ const out = [...phaseSkills];
219
+ for (const s of agentSkills) if (!out.includes(s)) out.push(s);
220
+ return out;
221
+ }
222
+
223
+ function failResult(
224
+ runId: string, startedAt: number, error: string, lifecycleName: string, task: string,
225
+ mode: LifecycleMode, phases: PhaseRecord[], todoId: string | null, backend: BackendId = "pi",
226
+ ): LifecycleRunResult {
227
+ return { runId, lifecycleName, task, backend, mode, status: "failed", phases, startedAt, endedAt: Date.now(), todoId, error };
228
+ }
229
+
230
+ function doneResult(
231
+ runId: string, startedAt: number, status: LifecycleStatus, lifecycleName: string, task: string,
232
+ backend: BackendId, mode: LifecycleMode, phases: PhaseRecord[], todoId: string | null, error?: string,
233
+ ): LifecycleRunResult {
234
+ return { runId, lifecycleName, task, backend, mode, status, phases, startedAt, endedAt: Date.now(), todoId, error };
235
+ }