@getpipher/armory-fleet 0.3.0 → 0.5.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/engine/run-registry.ts +13 -1
  3. package/src/engine/spawnSubagent.ts +33 -16
  4. package/src/index.ts +155 -2
  5. package/src/lifecycle/artifacts-parser.ts +57 -0
  6. package/src/lifecycle/default.ts +74 -0
  7. package/src/lifecycle/lifecycle-todo.ts +84 -0
  8. package/src/lifecycle/lifecycle-types.ts +66 -0
  9. package/src/lifecycle/port.ts +7 -0
  10. package/src/lifecycle/prompt-template.ts +40 -0
  11. package/src/lifecycle/registry.ts +169 -0
  12. package/src/lifecycle/run-lifecycle.ts +251 -0
  13. package/src/panel/bg-runs-store.ts +36 -0
  14. package/src/panel/fleet-items.ts +36 -0
  15. package/src/panel/fleet-panel.ts +334 -18
  16. package/src/panel/rows.ts +90 -0
  17. package/src/runtime/async-runner.ts +123 -0
  18. package/src/runtime/concurrency-pool.ts +27 -0
  19. package/src/runtime/results-inbox.ts +45 -0
  20. package/src/runtime/resume.ts +48 -0
  21. package/src/runtime/run-journal.ts +61 -0
  22. package/src/scheduling/expressions.ts +60 -0
  23. package/src/scheduling/pid-lock.ts +43 -0
  24. package/src/scheduling/scheduler.ts +147 -0
  25. package/src/todo-sync/adapter.ts +6 -0
  26. package/src/todo-sync/port.ts +2 -0
  27. package/src/tools/fleet-results.ts +35 -0
  28. package/src/tools/subagent.ts +58 -0
  29. package/src/vendor/cron-parser/NOTICE.md +23 -0
  30. package/src/vendor/cron-parser/lib/date.js +79 -0
  31. package/src/vendor/cron-parser/lib/expression.js +614 -0
  32. package/src/vendor/cron-parser/lib/number.js +8 -0
  33. package/src/vendor/cron-parser/lib/parser.js +103 -0
  34. package/src/vendor/cron-parser/types.d.ts +12 -0
  35. package/src/worktree/diff-service.ts +40 -0
  36. package/src/worktree/worktree-service.ts +92 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
6
6
  "license": "MIT",
@@ -23,11 +23,15 @@ export function genRunId(): string {
23
23
  return "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
24
24
  }
25
25
 
26
+ export type RunRegistryChangeListener = () => void;
27
+
26
28
  export class RunRegistry {
27
29
  private readonly runs = new Map<string, RunRecord>();
30
+ private readonly listeners = new Set<RunRegistryChangeListener>();
28
31
 
29
32
  add(r: RunRecord): void {
30
33
  this.runs.set(r.runId, r);
34
+ this.emit();
31
35
  }
32
36
  get(id: string): RunRecord | undefined {
33
37
  return this.runs.get(id);
@@ -38,6 +42,14 @@ export class RunRegistry {
38
42
  }
39
43
  update(id: string, patch: Partial<Omit<RunRecord, "runId">>): void {
40
44
  const r = this.runs.get(id);
41
- if (r) this.runs.set(id, { ...r, ...patch });
45
+ if (r) { this.runs.set(id, { ...r, ...patch }); this.emit(); }
46
+ }
47
+ /** SPEC-5a proper-fix: subscribe to add/update mutations. Returns an unsubscribe fn. */
48
+ subscribe(fn: RunRegistryChangeListener): () => void {
49
+ this.listeners.add(fn);
50
+ return () => { this.listeners.delete(fn); };
51
+ }
52
+ private emit(): void {
53
+ for (const fn of this.listeners) fn();
42
54
  }
43
55
  }
@@ -72,6 +72,15 @@ export interface SpawnOptions {
72
72
  visionPort?: VisionPort;
73
73
  signal?: AbortSignal;
74
74
  onEvent?: (e: ChildSessionEvent) => void;
75
+ /** SPEC-4: when set, this spawn is a lifecycle phase child. It links to this lifecycle todo
76
+ * (not creates a new one) and finishRun skips mark-done/revert — the lifecycle engine owns
77
+ * the lifecycle todo's status + progress block. */
78
+ lifecycleTodoId?: string;
79
+ /** SPEC-4: when set (lifecycle phase child), override the agent's `skills` frontmatter with
80
+ * the lifecycle's merged phase skill bundle (Q1=B) + route to the phase's resolved backend
81
+ * (Q4=C) instead of the agent's `backend`. */
82
+ skillsOverride?: string[];
83
+ backendOverride?: "pi" | "claude";
75
84
  }
76
85
 
77
86
  export interface SpawnResult {
@@ -111,18 +120,23 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
111
120
  }
112
121
 
113
122
  // SPEC-3: route via the backend registry; fail fast if the backend is missing/unavailable.
114
- const backend = opts.backendRegistry.get(agentDef.backend);
123
+ // SPEC-4: a lifecycle phase child may override the backend (Q4=C) + the skill bundle (Q1=B).
124
+ const backendId = opts.backendOverride ?? agentDef.backend;
125
+ const backend = opts.backendRegistry.get(backendId);
115
126
  if (!backend || !backend.available()) {
116
127
  const note = backend?.versionInfo()?.note ?? "not registered";
117
- return fail(runId, startedAt, `backend '${agentDef.backend}' unavailable: ${note}`, opts.agent);
128
+ return fail(runId, startedAt, `backend '${backendId}' unavailable: ${note}`, opts.agent);
118
129
  }
130
+ // SPEC-4: when a lifecycle provides a skills override, clone the agentDef so the factory's
131
+ // skillsOverride (buildChildLoader reads agent.skills) loads the phase's bundle, not the agent's.
132
+ const childAgent = opts.skillsOverride ? { ...agentDef, skills: opts.skillsOverride } : agentDef;
119
133
 
120
134
  // resolve model
121
135
  const model = opts.model ?? agentDef.model ?? `${opts.parentModel.provider}/${opts.parentModel.id}`;
122
136
 
123
137
  // child tools pass through UNFILTERED — the single-writer `todo`-exclusion is enforced
124
138
  // downstream by the child factory's `excludeTools: ["todo"]` (SPEC-2 §9.1 hardening).
125
- const tools = agentDef.tools ?? PI_DEFAULT_TOOLS;
139
+ const tools = childAgent.tools ?? PI_DEFAULT_TOOLS;
126
140
  const memoryPort = opts.memoryPort ?? NOOP_MEMORY_PORT;
127
141
  const visionPort = opts.visionPort ?? NOOP_VISION_PORT;
128
142
 
@@ -138,7 +152,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
138
152
  try {
139
153
  const link = await opts.todoSync.linkOrCreateRunTodo({
140
154
  runId, agent: agentDef.name, task: opts.task,
141
- todoId: opts.todoId, track: track && agentDef.todoSync,
155
+ todoId: opts.lifecycleTodoId ?? opts.todoId, track: track && agentDef.todoSync,
142
156
  });
143
157
  todoId = link.todoId;
144
158
  priorStatus = link.priorStatus;
@@ -151,12 +165,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
151
165
  const { session } = await backend.factory.create({
152
166
  cwd: opts.parentCwd,
153
167
  model,
154
- thinkingLevel: agentDef.thinkingLevel,
168
+ thinkingLevel: childAgent.thinkingLevel,
155
169
  tools,
156
- rolePrompt: agentDef.rolePrompt,
157
- skills: agentDef.skills ?? [],
170
+ rolePrompt: childAgent.rolePrompt,
171
+ skills: childAgent.skills ?? [],
158
172
  task: opts.task,
159
- agent: agentDef,
173
+ agent: childAgent,
160
174
  memoryPort,
161
175
  visionPort,
162
176
  });
@@ -229,15 +243,18 @@ async function finishRun(
229
243
  ): Promise<SpawnResult> {
230
244
  const endedAt = Date.now();
231
245
  opts.runRegistry.update(runId, { status, endedAt, resultSummary: finalText.slice(0, 120) });
232
- // todo-sync reconciliation must not mask the run result
233
- try {
234
- if (status === "completed") {
235
- await opts.todoSync.markRunTodoDone(todoId, priorStatus, finalText.slice(0, 500));
236
- } else {
237
- await opts.todoSync.markRunTodoReverted(todoId, priorStatus, error ?? status);
246
+ // SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle
247
+ // engine owns the lifecycle todo's status + progress block (Q7=C).
248
+ if (!opts.lifecycleTodoId) {
249
+ try {
250
+ if (status === "completed") {
251
+ await opts.todoSync.markRunTodoDone(todoId, priorStatus, finalText.slice(0, 500));
252
+ } else {
253
+ await opts.todoSync.markRunTodoReverted(todoId, priorStatus, error ?? status);
254
+ }
255
+ } catch {
256
+ // swallow — the run result is authoritative; the finally in spawnSubagent releases the lock
238
257
  }
239
- } catch {
240
- // swallow — the run result is authoritative; the finally in spawnSubagent releases the lock
241
258
  }
242
259
  return {
243
260
  status, finalText, runId, todoId, agent: agentName, model,
package/src/index.ts CHANGED
@@ -26,6 +26,20 @@ import { ResumeStore } from "./backend/resume-store.ts";
26
26
  import { detectClaude } from "./backend/claude-detector.ts";
27
27
  import { createClaudeChildFactory } from "./backend/claude-factory.ts";
28
28
  import { join } from "node:path";
29
+ import { discoverLifecycles } from "./lifecycle/registry.ts";
30
+ import { DEFAULT_LIFECYCLE, builtinLifecyclesDir } from "./lifecycle/default.ts";
31
+ import type { LifecycleDef } from "./lifecycle/lifecycle-types.ts";
32
+ import type { LifecycleRunDeps } from "./lifecycle/run-lifecycle.ts";
33
+ import { WorktreeService } from "./worktree/worktree-service.ts";
34
+ import { DiffService } from "./worktree/diff-service.ts";
35
+ import { RunJournal } from "./runtime/run-journal.ts";
36
+ import { ConcurrencyPool } from "./runtime/concurrency-pool.ts";
37
+ import { ResultsInbox } from "./runtime/results-inbox.ts";
38
+ import { runBackground, type AsyncRunnerDeps } from "./runtime/async-runner.ts";
39
+ import { scanResumeCandidates } from "./runtime/resume.ts";
40
+ import { Scheduler } from "./scheduling/scheduler.ts";
41
+ import { createFleetResultsTool } from "./tools/fleet-results.ts";
42
+ import { BgRunsStore } from "./panel/bg-runs-store.ts";
29
43
 
30
44
  /** The package builtin agents/ dir, resolved relative to this module. */
31
45
  function builtinAgentsDir(): string {
@@ -126,7 +140,55 @@ export default async function (pi: ExtensionAPI): Promise<void> {
126
140
  backendRegistry: await buildDefaultBackendRegistry(modelRuntime),
127
141
  parentModel: { provider: "", id: "" },
128
142
  parentCwd: "",
143
+ lifecycleRegistry: new Map<string, LifecycleDef>(),
144
+ lifecycleRuns: new Map<string, import("./lifecycle/lifecycle-types.ts").LifecycleRunRecord>(),
145
+ lifecycleDeps: {
146
+ registry: new Map(), // wired to the real agent registry in refreshLifecycles (Task 12)
147
+ agentRegistry: new Map(), // ditto
148
+ todoPort: new ArmoryTodoAdapter(),
149
+ resolveBackend: (phaseBackend, lifecycleBackend) => {
150
+ const id = phaseBackend ?? lifecycleBackend;
151
+ if (id === "claude" && !deps.backendRegistry.get("claude")?.available()) {
152
+ throw new Error("phase requests backend 'claude' but claude is not installed; run 'claude' to set up, or change the phase backend in the lifecycle file");
153
+ }
154
+ return id;
155
+ },
156
+ genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
157
+ },
158
+ };
159
+ // Seed the builtin `default` lifecycle + wire lifecycleDeps to the live registries.
160
+ deps.lifecycleRegistry.set(DEFAULT_LIFECYCLE.name, DEFAULT_LIFECYCLE);
161
+ deps.lifecycleDeps.registry = deps.lifecycleRegistry;
162
+ deps.lifecycleDeps.agentRegistry = deps.registry;
163
+
164
+ // ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ──
165
+ const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet");
166
+ const bgRuns = new BgRunsStore();
167
+ const resultsInbox = new ResultsInbox();
168
+ // The async runner's runLifecycle adapter: call the real runLifecycle with the worktree as the
169
+ // spawn cwd + override genRunId so the lifecycle runId IS the async runner's runId (Q1=B seam).
170
+ const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => {
171
+ const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
172
+ const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
173
+ const lifecycleFullDeps: LifecycleRunDeps = {
174
+ ...deps.lifecycleDeps,
175
+ genRunId: () => opts.runId, // override: use the async runner's runId
176
+ // SPEC-5a (Q3=A): isolated run — worktree-diff artifact discovery instead of the prompt-baked block.
177
+ artifactDiscovery: ({ finalText, cwd, baseRef }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText),
178
+ spawn: async (o) => spawnSubagent({
179
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
180
+ skillsOverride: o.skills, backendOverride: o.backend,
181
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
182
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, // child runs in the worktree
183
+ }),
184
+ };
185
+ const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
186
+ return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
129
187
  };
188
+ // asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start.
189
+ // At init they're undefined — the subagent tool's `if (!deps.asyncRunner)` guard returns an
190
+ // actionable "not configured" error if called before session_start (can't happen in practice).
191
+ deps.bgRuns = bgRuns;
130
192
 
131
193
  const refresh = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => {
132
194
  const r = discoverAgents({
@@ -136,22 +198,67 @@ export default async function (pi: ExtensionAPI): Promise<void> {
136
198
  });
137
199
  for (const e of r.errors) ctx.ui.notify(e, "error");
138
200
  for (const w of r.warnings) ctx.ui.notify(w, "warning");
139
- deps.registry = r.agents;
201
+ // Mutate in place (not replace the reference) so lifecycleDeps.agentRegistry which is bound
202
+ // to this same Map once at init — stays live across refreshes (SPEC-4 fix).
203
+ deps.registry.clear();
204
+ for (const [k, v] of r.agents) deps.registry.set(k, v);
205
+ };
206
+
207
+ const refreshLifecycles = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => {
208
+ const r = discoverLifecycles({
209
+ projectDir: join(ctx.cwd, ".pi", "lifecycles"),
210
+ globalDir: join(process.env.HOME ?? "", ".pi", "agent", "lifecycles"),
211
+ builtinDir: builtinLifecyclesDir(),
212
+ });
213
+ for (const e of r.errors) ctx.ui.notify(e, "error");
214
+ for (const w of r.warnings) ctx.ui.notify(w, "warning");
215
+ deps.lifecycleRegistry.clear();
216
+ deps.lifecycleRegistry.set(DEFAULT_LIFECYCLE.name, DEFAULT_LIFECYCLE);
217
+ for (const [name, def] of r.lifecycles) deps.lifecycleRegistry.set(name, def);
140
218
  };
141
219
 
142
220
  pi.on("session_start", (_event, ctx) => {
143
221
  refresh(ctx);
222
+ refreshLifecycles(ctx);
144
223
  const m = ctx.model;
145
224
  deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
146
225
  deps.parentCwd = ctx.cwd;
226
+ // SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
227
+ const dir = fleetDir(ctx.cwd);
228
+ deps.asyncRunner = {
229
+ worktree: new WorktreeService({ rootDir: ctx.cwd }),
230
+ diff: new DiffService(),
231
+ journal: new RunJournal(join(dir, "runs")),
232
+ pool: new ConcurrencyPool(3),
233
+ inbox: resultsInbox,
234
+ runLifecycle: asyncRunLifecycle,
235
+ notify: (m, lvl) => ctx.ui.notify(m, lvl),
236
+ genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
237
+ onProgress: (runId, status) => { bgRuns.set(runId, status); },
238
+ };
239
+ deps.scheduler = new Scheduler({
240
+ storePath: join(dir, "schedules.json"),
241
+ lockPath: join(dir, "schedules.lock"),
242
+ onFire: (spec) => {
243
+ if (!deps.asyncRunner) return;
244
+ runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed" });
245
+ },
246
+ });
247
+ deps.scheduler.start();
248
+ const cands = scanResumeCandidates(ctx.cwd, { runsDir: join(dir, "runs"), worktree: deps.asyncRunner.worktree });
249
+ if (cands.length > 0) {
250
+ ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info");
251
+ }
147
252
  });
148
253
 
149
254
  pi.on("resources_discover", (event, ctx) => {
150
- if (event.reason === "reload") refresh(ctx);
255
+ if (event.reason === "reload") { refresh(ctx); refreshLifecycles(ctx); }
151
256
  return undefined;
152
257
  });
153
258
 
154
259
  pi.registerTool(createSubagentTool(deps) as never);
260
+ // SPEC-5a: fleet.results — the agent pulls completed bg-run results from the inbox (Q6=C).
261
+ pi.registerTool(createFleetResultsTool({ inbox: resultsInbox }) as never);
155
262
 
156
263
  pi.registerCommand("fleet", {
157
264
  description: "Open the armory-fleet panel (running + recent subagents + agent registry).",
@@ -163,4 +270,50 @@ export default async function (pi: ExtensionAPI): Promise<void> {
163
270
  openFleetPanel(deps, ctx as never);
164
271
  },
165
272
  });
273
+
274
+ // SPEC-4: /fleet-implement <task> [--lifecycle <name>] [--auto] — the done-bar slash.
275
+ const parseImplementArgs = (args: string): { task: string; lifecycle?: string; auto?: boolean } => {
276
+ const parts = String(args ?? "").trim().split(/\s+/);
277
+ let lifecycle: string | undefined; let auto = false; const taskParts: string[] = [];
278
+ for (let i = 0; i < parts.length; i++) {
279
+ if (parts[i] === "--lifecycle") { lifecycle = parts[++i]; continue; }
280
+ if (parts[i] === "--auto") { auto = true; continue; }
281
+ taskParts.push(parts[i]!);
282
+ }
283
+ return { task: taskParts.join(" ").trim(), lifecycle, auto };
284
+ };
285
+
286
+ pi.registerCommand("fleet-implement", {
287
+ description: "Run a task through the superpowers lifecycle (default). Flags: --lifecycle <name>, --auto.",
288
+ handler: async (args, ctx) => {
289
+ const parsed = parseImplementArgs(args);
290
+ const lcName = parsed.lifecycle ?? "default";
291
+ if (!parsed.task) { ctx.ui.notify("usage: /fleet-implement <task> [--lifecycle <name>] [--auto]", "warning"); return; }
292
+ if (!deps.lifecycleRegistry.has(lcName)) {
293
+ ctx.ui.notify(`lifecycle '${lcName}' not found; available: ${[...deps.lifecycleRegistry.keys()].sort().join(", ")}`, "error");
294
+ return;
295
+ }
296
+ const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
297
+ const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
298
+ const onCheckpoint: import("./lifecycle/run-lifecycle.ts").CheckpointFn = parsed.auto
299
+ ? async (_phase) => ({ action: "continue" })
300
+ : async (phase) => {
301
+ // Non-TUI / non-auto: can't prompt interactively → auto-continue + notify (open /fleet for interactive).
302
+ ctx.ui.notify(`lifecycle checkpoint at '${phase.name}' — open /fleet Lifecycle view to Continue/Revise/Abort (auto-continuing)`, "info");
303
+ return { action: "continue" };
304
+ };
305
+ const lifecycleFullDeps: import("./lifecycle/run-lifecycle.ts").LifecycleRunDeps = {
306
+ ...deps.lifecycleDeps,
307
+ spawn: async (o) => spawnSubagent({
308
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
309
+ skillsOverride: o.skills, backendOverride: o.backend,
310
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
311
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
312
+ }),
313
+ };
314
+ const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint });
315
+ deps.lifecycleRuns.set(res.runId, res);
316
+ ctx.ui.notify(`lifecycle ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning");
317
+ },
318
+ });
166
319
  }
@@ -0,0 +1,57 @@
1
+ // src/lifecycle/artifacts-parser.ts
2
+ import { parse as parseYaml } from "yaml";
3
+
4
+ export const MAX_REVISE = 3;
5
+
6
+ export interface ArtifactsOk { summary: string; paths: string[] }
7
+ export interface ArtifactsErr { error: string }
8
+ export type ArtifactsResult = ArtifactsOk | ArtifactsErr;
9
+
10
+ /** Parse the trailing `Artifacts:` YAML block from a child's finalText.
11
+ * Returns {summary, paths} on success, or {error} on failure.
12
+ * terminal=true exempts a missing block (the finish phase may have no file artifact). */
13
+ export function parseArtifacts(finalText: string, opts: { terminal?: boolean } = {}): ArtifactsResult {
14
+ // Strip a trailing prompt-echo trailer (e.g. CIPHER's "📌 YOUR PROMPT: ..." bordered by `---`),
15
+ // which the child may inherit from the base system prompt. The echo can contain the literal
16
+ // "Artifacts:" (quoting the phase instruction) and would fool lastIndexOf below.
17
+ let text = finalText;
18
+ const echoIdx = text.lastIndexOf("📌 YOUR PROMPT:");
19
+ if (echoIdx >= 0) {
20
+ text = text.slice(0, echoIdx).replace(/\n---\s*$/, "");
21
+ }
22
+ const marker = "Artifacts:";
23
+ const idx = text.lastIndexOf(marker);
24
+ if (idx < 0) {
25
+ if (opts.terminal) return { summary: text.trim(), paths: [] };
26
+ return { error: "missing Artifacts block (child did not list produced file paths)" };
27
+ }
28
+ const summary = text.slice(0, idx).trim();
29
+ let block = text.slice(idx + marker.length);
30
+ // Robustness: models often wrap the YAML in a fenced code block (the `Artifacts:` marker sits
31
+ // inside the fence) and/or trail a prompt-echo / signature. Strip a leading opening fence line
32
+ // (```yaml) if present, then truncate at the FIRST closing fence (```) or markdown thematic
33
+ // break (`\n---\n`) — whichever comes first — so trailing content can't break the YAML parse.
34
+ block = block.replace(/^\s*```[a-zA-Z]*\s*\n/, "");
35
+ const fenceClose = block.indexOf("```");
36
+ const brk = block.search(/\n---\s*\n/);
37
+ let cut = block.length;
38
+ if (fenceClose >= 0) cut = Math.min(cut, fenceClose);
39
+ if (brk >= 0) cut = Math.min(cut, brk);
40
+ block = block.slice(0, cut);
41
+ let parsed: unknown;
42
+ try {
43
+ parsed = parseYaml(block) ?? [];
44
+ } catch (e) {
45
+ return { error: `malformed Artifacts block: ${(e as Error).message}` };
46
+ }
47
+ if (!Array.isArray(parsed)) return { error: "Artifacts block must be a list of {path, kind}" };
48
+ const entries = parsed as Array<Record<string, unknown>>;
49
+ const paths: string[] = [];
50
+ for (const e of entries) {
51
+ if (typeof e.path === "string" && e.path.trim()) paths.push(e.path.trim());
52
+ }
53
+ if (paths.length === 0 && !opts.terminal) {
54
+ return { error: "Artifacts block has no paths (non-terminal phase must produce at least one file)" };
55
+ }
56
+ return { summary, paths };
57
+ }
@@ -0,0 +1,74 @@
1
+ // src/lifecycle/default.ts
2
+ import { join } from "node:path";
3
+ import { parseLifecycleFile } from "./registry.ts";
4
+ import type { LifecycleDef } from "./lifecycle-types.ts";
5
+
6
+ /** The package builtin lifecycles/ dir, resolved relative to this module. */
7
+ export function builtinLifecyclesDir(): string {
8
+ return join(new URL(".", import.meta.url).pathname, "..", "..", "lifecycles");
9
+ }
10
+
11
+ /** The shipped `default` lifecycle as a markdown string (frontmatter + ## phase templates).
12
+ * This is the single source of truth — it is both written to lifecycles/default.md
13
+ * (for human reading) AND parsed here at runtime so the builtin is always in sync. */
14
+ export const DEFAULT_LIFECYCLE_SOURCE = `---
15
+ name: default
16
+ description: The superpowers-native 5-phase lifecycle (brainstorm→plan→implement→review→finish).
17
+ backend: pi
18
+ phases:
19
+ - name: brainstorm
20
+ skills: [brainstorming]
21
+ agent: general-purpose
22
+ checkpoint: true
23
+ - name: plan
24
+ skills: [writing-plans]
25
+ agent: general-purpose
26
+ checkpoint: true
27
+ - name: implement
28
+ skills: [executing-plans, test-driven-development, verification-before-completion]
29
+ agent: general-purpose
30
+ checkpoint: false
31
+ - name: review
32
+ skills: [requesting-code-review, receiving-code-review]
33
+ agent: general-purpose
34
+ checkpoint: true
35
+ - name: finish
36
+ skills: [finishing-a-development-branch]
37
+ agent: general-purpose
38
+ ---
39
+
40
+ ## brainstorm
41
+ You are the **brainstorm** phase of a superpowers lifecycle. Use the brainstorming skill.
42
+ Task: {{task}}
43
+ {% if prev %}Previous phase ({{prev.name}}) produced: {{prev.summary}}
44
+ Artifacts to read: {{prev.paths}}{% endif %}
45
+ Explore the task, produce a design doc per the brainstorming skill. End your response with an
46
+ \`Artifacts:\` block (YAML) listing the produced file paths + a kind.
47
+
48
+ ## plan
49
+ You are the **plan** phase. Use writing-plans. Read the brainstorm phase's design artifact.
50
+ {% if prev %}Previous phase: {{prev.summary}} | Artifacts: {{prev.paths}}{% endif %}
51
+ {% if feedback %}Human feedback on a prior attempt: {{feedback}}{% endif %}
52
+ Write the implementation plan per writing-plans. End with an \`Artifacts:\` block.
53
+
54
+ ## implement
55
+ You are the **implement** phase. Use executing-plans + test-driven-development + verification-before-completion.
56
+ Read the plan artifact. Implement it, run tests, verify before claiming done.
57
+ End with an \`Artifacts:\` block (files changed).
58
+
59
+ ## review
60
+ You are the **review** phase. Use requesting-code-review + receiving-code-review.
61
+ Review the implementation against the plan + design. Produce review findings.
62
+ End with an \`Artifacts:\` block (review notes path).
63
+
64
+ ## finish
65
+ You are the **finish** phase. Use finishing-a-development-branch.
66
+ Decide merge/PR/cleanup per the skill and execute it. End with an \`Artifacts:\` block
67
+ (or omit on a merge/PR with no further file artifact — terminal-phase exemption).
68
+ `;
69
+
70
+ export const DEFAULT_LIFECYCLE: LifecycleDef = parseLifecycleFile(
71
+ DEFAULT_LIFECYCLE_SOURCE,
72
+ "<builtin:default>",
73
+ "builtin",
74
+ );
@@ -0,0 +1,84 @@
1
+ // src/lifecycle/lifecycle-todo.ts
2
+ import type { BackendId, LifecycleMode } from "./lifecycle-types.ts";
3
+
4
+ /** Minimal port shape the lifecycle-todo helpers need (so unit tests can pass a fake
5
+ * without depending on the full TodoSyncPort). */
6
+ export interface LifecycleTodoPort {
7
+ linkOrCreateRunTodo(run: { runId: string; agent: string; task: string; todoId?: string; track: boolean }): Promise<{ todoId: string | null; priorStatus?: string }>;
8
+ markRunTodoDone(todoId: string | null, priorStatus: string | undefined, result: string): Promise<void>;
9
+ markRunTodoReverted(todoId: string | null, priorStatus: string | undefined, reason: string): Promise<void>;
10
+ updateLifecycleProgress(todoId: string, progressBlock: string): Promise<void>;
11
+ }
12
+
13
+ /** Test fake helper type (re-exported so tests don't hand-roll the shape). */
14
+ export interface FakeTodoPort extends LifecycleTodoPort {
15
+ _state: Map<string, { id: string; notes: string; status: string }>;
16
+ }
17
+
18
+ export interface LifecycleTodoMeta {
19
+ runId: string; task: string; lifecycle: string; backend: BackendId; mode: LifecycleMode;
20
+ phases: string[];
21
+ }
22
+
23
+ export interface ProgressPhase {
24
+ name: string;
25
+ done: boolean;
26
+ revising?: boolean;
27
+ attempt?: number;
28
+ }
29
+
30
+ export function buildProgressBlock(opts: {
31
+ lifecycle: string; task: string; backend: BackendId; mode: LifecycleMode;
32
+ phases: ProgressPhase[]; last: string;
33
+ }): string {
34
+ const marks = opts.phases.map((p) => {
35
+ if (p.done) return `[x] ${p.name}`;
36
+ if (p.revising) return `[~] ${p.name} (revising, attempt ${p.attempt ?? 1}/3)`;
37
+ return `[ ] ${p.name}`;
38
+ }).join(" ");
39
+ return [
40
+ `Lifecycle: ${opts.lifecycle} · task: "${opts.task}"`,
41
+ `Backend: ${opts.backend} · Mode: ${opts.mode}`,
42
+ `Phases: ${marks}`,
43
+ `Last: ${opts.last}`,
44
+ ].join("\n");
45
+ }
46
+
47
+ export async function createLifecycleTodo(port: LifecycleTodoPort, meta: LifecycleTodoMeta): Promise<string> {
48
+ const link = await port.linkOrCreateRunTodo({
49
+ runId: meta.runId, agent: meta.lifecycle, task: meta.task, track: true,
50
+ });
51
+ if (!link.todoId) throw new Error("lifecycle TODO link-or-create returned null (armory-todo port error)");
52
+ await port.updateLifecycleProgress(link.todoId, buildProgressBlock({
53
+ lifecycle: meta.lifecycle, task: meta.task, backend: meta.backend, mode: meta.mode,
54
+ phases: meta.phases.map((n) => ({ name: n, done: false })), last: "started",
55
+ }));
56
+ return link.todoId;
57
+ }
58
+
59
+ export async function updateProgress(
60
+ port: LifecycleTodoPort, todoId: string,
61
+ upd: { phase: string; done: boolean; last: string; revising: boolean; attempt: number },
62
+ ctx: { lifecycle: string; task: string; backend: BackendId; mode: LifecycleMode; phases: ProgressPhase[] },
63
+ ): Promise<void> {
64
+ // Mutate the shared progressPhases in place so completion accumulates across phases
65
+ // (a new array here would discard prior phases' [x] state — the block is the single source of truth).
66
+ for (const ph of ctx.phases) {
67
+ if (ph.name === upd.phase) {
68
+ ph.done = upd.done;
69
+ ph.revising = upd.revising;
70
+ ph.attempt = upd.attempt;
71
+ }
72
+ }
73
+ await port.updateLifecycleProgress(todoId, buildProgressBlock({
74
+ lifecycle: ctx.lifecycle, task: ctx.task, backend: ctx.backend, mode: ctx.mode, phases: ctx.phases, last: upd.last,
75
+ }));
76
+ }
77
+
78
+ export async function completeLifecycleTodo(port: LifecycleTodoPort, todoId: string, result: string): Promise<void> {
79
+ await port.markRunTodoDone(todoId, undefined, result);
80
+ }
81
+
82
+ export async function revertLifecycleTodo(port: LifecycleTodoPort, todoId: string, reason: string): Promise<void> {
83
+ await port.markRunTodoReverted(todoId, undefined, reason);
84
+ }
@@ -0,0 +1,66 @@
1
+ // src/lifecycle/lifecycle-types.ts
2
+ import type { FleetRunStatus } from "../todo-sync/port.ts";
3
+ import type { AgentSource } from "../registry/frontmatter.ts";
4
+
5
+ /** Backend id (mirrors SPEC-3 AgentDef.backend). */
6
+ export type BackendId = "pi" | "claude";
7
+
8
+ /** Lifecycle-wide status (richer than FleetRunStatus: adds checkpoint + revising). */
9
+ export type LifecycleStatus = "running" | "checkpoint" | "completed" | "failed" | "aborted";
10
+
11
+ export type LifecycleMode = "checkpointed" | "auto";
12
+
13
+ /** A phase definition (parsed from a lifecycle file's frontmatter). */
14
+ export interface PhaseDef {
15
+ name: string;
16
+ skills: string[];
17
+ /** Per-phase default agent pin; absent → general-purpose. */
18
+ agent?: string;
19
+ /** Per-phase backend override (Q4=C); absent → lifecycle.backend. */
20
+ backend?: BackendId;
21
+ /** Pause for human review after this phase; default true. Terminal phase omits (no checkpoint). */
22
+ checkpoint?: boolean;
23
+ /** The phase prompt template (parsed from the `## <name>` body section). */
24
+ promptTemplate: string;
25
+ }
26
+
27
+ export interface LifecycleDef {
28
+ name: string;
29
+ description: string;
30
+ /** Lifecycle-wide default backend; absent → "pi". */
31
+ backend: BackendId;
32
+ phases: PhaseDef[];
33
+ source: AgentSource;
34
+ filePath: string;
35
+ }
36
+
37
+ /** The record of one phase's execution (stored on the LifecycleRunRecord). */
38
+ export interface PhaseRecord {
39
+ name: string;
40
+ summary: string;
41
+ paths: string[];
42
+ status: FleetRunStatus;
43
+ reviseCount: number;
44
+ }
45
+
46
+ export interface LifecycleRunRecord {
47
+ runId: string;
48
+ lifecycleName: string;
49
+ task: string;
50
+ backend: BackendId;
51
+ mode: LifecycleMode;
52
+ status: LifecycleStatus;
53
+ phases: PhaseRecord[];
54
+ startedAt: number;
55
+ /** Set when the lifecycle reaches a terminal status (completed/failed/aborted). */
56
+ endedAt?: number;
57
+ todoId: string | null;
58
+ }
59
+
60
+ /** Human (or auto) decision at a checkpoint. */
61
+ export type CheckpointAction = "continue" | "revise" | "abort";
62
+ export interface CheckpointDecision {
63
+ action: CheckpointAction;
64
+ /** Present only when action === "revise". */
65
+ feedback?: string;
66
+ }
@@ -0,0 +1,7 @@
1
+ // src/lifecycle/port.ts
2
+ export { parseLifecycleFile, discoverLifecycles, LifecycleParseError } from "./registry.ts";
3
+ export type { LifecycleDiscoverOpts, LifecycleDiscoverResult } from "./registry.ts";
4
+ export type {
5
+ LifecycleStatus, LifecycleMode, BackendId, PhaseDef, LifecycleDef, PhaseRecord,
6
+ LifecycleRunRecord, CheckpointAction, CheckpointDecision,
7
+ } from "./lifecycle-types.ts";
@@ -0,0 +1,40 @@
1
+ // src/lifecycle/prompt-template.ts
2
+ import type { PhaseRecord } from "./lifecycle-types.ts";
3
+
4
+ export interface PromptVars {
5
+ task: string;
6
+ lifecycle: string;
7
+ phase: string;
8
+ /** Previous phase record (absent on phase 1). */
9
+ prev?: { name: string; summary: string; paths: string[] };
10
+ /** On Revise only: human feedback + prior-attempt digest. */
11
+ feedback?: string;
12
+ }
13
+
14
+ /** Render a phase prompt template. Supports {{task}}, {{lifecycle}}, {{phase}},
15
+ * {{prev.name}}, {{prev.summary}}, {{prev.paths}}, {{feedback}}, and
16
+ * {% if prev %}…{% endif %} / {% if feedback %}…{% endif %} conditional blocks. */
17
+ export function renderPhasePrompt(template: string, vars: PromptVars): string {
18
+ let out = template;
19
+
20
+ // {% if prev %}…{% endif %}
21
+ out = out.replace(/{%\s*if\s*prev\s*%}([\s\S]*?){%\s*endif\s*%}/g,
22
+ vars.prev ? "$1" : "");
23
+ // {% if feedback %}…{% endif %}
24
+ out = out.replace(/{%\s*if\s*feedback\s*%}([\s\S]*?){%\s*endif\s*%}/g,
25
+ vars.feedback ? "$1" : "");
26
+
27
+ // {{prev.paths}} → newline-separated "- path" list (or empty)
28
+ const pathsStr = vars.prev ? vars.prev.paths.map((p) => `- ${p}`).join("\n") : "";
29
+
30
+ out = out
31
+ .replace(/{{\s*task\s*}}/g, vars.task)
32
+ .replace(/{{\s*lifecycle\s*}}/g, vars.lifecycle)
33
+ .replace(/{{\s*phase\s*}}/g, vars.phase)
34
+ .replace(/{{\s*prev\.name\s*}}/g, vars.prev?.name ?? "")
35
+ .replace(/{{\s*prev\.summary\s*}}/g, vars.prev?.summary ?? "")
36
+ .replace(/{{\s*prev\.paths\s*}}/g, pathsStr)
37
+ .replace(/{{\s*feedback\s*}}/g, vars.feedback ?? "");
38
+
39
+ return out;
40
+ }