@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -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,10 @@ 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";
29
33
 
30
34
  /** The package builtin agents/ dir, resolved relative to this module. */
31
35
  function builtinAgentsDir(): string {
@@ -126,7 +130,26 @@ export default async function (pi: ExtensionAPI): Promise<void> {
126
130
  backendRegistry: await buildDefaultBackendRegistry(modelRuntime),
127
131
  parentModel: { provider: "", id: "" },
128
132
  parentCwd: "",
133
+ lifecycleRegistry: new Map<string, LifecycleDef>(),
134
+ lifecycleRuns: new Map<string, import("./lifecycle/lifecycle-types.ts").LifecycleRunRecord>(),
135
+ lifecycleDeps: {
136
+ registry: new Map(), // wired to the real agent registry in refreshLifecycles (Task 12)
137
+ agentRegistry: new Map(), // ditto
138
+ todoPort: new ArmoryTodoAdapter(),
139
+ resolveBackend: (phaseBackend, lifecycleBackend) => {
140
+ const id = phaseBackend ?? lifecycleBackend;
141
+ if (id === "claude" && !deps.backendRegistry.get("claude")?.available()) {
142
+ 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");
143
+ }
144
+ return id;
145
+ },
146
+ genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
147
+ },
129
148
  };
149
+ // Seed the builtin `default` lifecycle + wire lifecycleDeps to the live registries.
150
+ deps.lifecycleRegistry.set(DEFAULT_LIFECYCLE.name, DEFAULT_LIFECYCLE);
151
+ deps.lifecycleDeps.registry = deps.lifecycleRegistry;
152
+ deps.lifecycleDeps.agentRegistry = deps.registry;
130
153
 
131
154
  const refresh = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => {
132
155
  const r = discoverAgents({
@@ -136,18 +159,35 @@ export default async function (pi: ExtensionAPI): Promise<void> {
136
159
  });
137
160
  for (const e of r.errors) ctx.ui.notify(e, "error");
138
161
  for (const w of r.warnings) ctx.ui.notify(w, "warning");
139
- deps.registry = r.agents;
162
+ // Mutate in place (not replace the reference) so lifecycleDeps.agentRegistry which is bound
163
+ // to this same Map once at init — stays live across refreshes (SPEC-4 fix).
164
+ deps.registry.clear();
165
+ for (const [k, v] of r.agents) deps.registry.set(k, v);
166
+ };
167
+
168
+ const refreshLifecycles = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => {
169
+ const r = discoverLifecycles({
170
+ projectDir: join(ctx.cwd, ".pi", "lifecycles"),
171
+ globalDir: join(process.env.HOME ?? "", ".pi", "agent", "lifecycles"),
172
+ builtinDir: builtinLifecyclesDir(),
173
+ });
174
+ for (const e of r.errors) ctx.ui.notify(e, "error");
175
+ for (const w of r.warnings) ctx.ui.notify(w, "warning");
176
+ deps.lifecycleRegistry.clear();
177
+ deps.lifecycleRegistry.set(DEFAULT_LIFECYCLE.name, DEFAULT_LIFECYCLE);
178
+ for (const [name, def] of r.lifecycles) deps.lifecycleRegistry.set(name, def);
140
179
  };
141
180
 
142
181
  pi.on("session_start", (_event, ctx) => {
143
182
  refresh(ctx);
183
+ refreshLifecycles(ctx);
144
184
  const m = ctx.model;
145
185
  deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
146
186
  deps.parentCwd = ctx.cwd;
147
187
  });
148
188
 
149
189
  pi.on("resources_discover", (event, ctx) => {
150
- if (event.reason === "reload") refresh(ctx);
190
+ if (event.reason === "reload") { refresh(ctx); refreshLifecycles(ctx); }
151
191
  return undefined;
152
192
  });
153
193
 
@@ -163,4 +203,50 @@ export default async function (pi: ExtensionAPI): Promise<void> {
163
203
  openFleetPanel(deps, ctx as never);
164
204
  },
165
205
  });
206
+
207
+ // SPEC-4: /fleet-implement <task> [--lifecycle <name>] [--auto] — the done-bar slash.
208
+ const parseImplementArgs = (args: string): { task: string; lifecycle?: string; auto?: boolean } => {
209
+ const parts = String(args ?? "").trim().split(/\s+/);
210
+ let lifecycle: string | undefined; let auto = false; const taskParts: string[] = [];
211
+ for (let i = 0; i < parts.length; i++) {
212
+ if (parts[i] === "--lifecycle") { lifecycle = parts[++i]; continue; }
213
+ if (parts[i] === "--auto") { auto = true; continue; }
214
+ taskParts.push(parts[i]!);
215
+ }
216
+ return { task: taskParts.join(" ").trim(), lifecycle, auto };
217
+ };
218
+
219
+ pi.registerCommand("fleet-implement", {
220
+ description: "Run a task through the superpowers lifecycle (default). Flags: --lifecycle <name>, --auto.",
221
+ handler: async (args, ctx) => {
222
+ const parsed = parseImplementArgs(args);
223
+ const lcName = parsed.lifecycle ?? "default";
224
+ if (!parsed.task) { ctx.ui.notify("usage: /fleet-implement <task> [--lifecycle <name>] [--auto]", "warning"); return; }
225
+ if (!deps.lifecycleRegistry.has(lcName)) {
226
+ ctx.ui.notify(`lifecycle '${lcName}' not found; available: ${[...deps.lifecycleRegistry.keys()].sort().join(", ")}`, "error");
227
+ return;
228
+ }
229
+ const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
230
+ const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
231
+ const onCheckpoint: import("./lifecycle/run-lifecycle.ts").CheckpointFn = parsed.auto
232
+ ? async (_phase) => ({ action: "continue" })
233
+ : async (phase) => {
234
+ // Non-TUI / non-auto: can't prompt interactively → auto-continue + notify (open /fleet for interactive).
235
+ ctx.ui.notify(`lifecycle checkpoint at '${phase.name}' — open /fleet Lifecycle view to Continue/Revise/Abort (auto-continuing)`, "info");
236
+ return { action: "continue" };
237
+ };
238
+ const lifecycleFullDeps: import("./lifecycle/run-lifecycle.ts").LifecycleRunDeps = {
239
+ ...deps.lifecycleDeps,
240
+ spawn: async (o) => spawnSubagent({
241
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
242
+ skillsOverride: o.skills, backendOverride: o.backend,
243
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
244
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
245
+ }),
246
+ };
247
+ const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint });
248
+ deps.lifecycleRuns.set(res.runId, res);
249
+ ctx.ui.notify(`lifecycle ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning");
250
+ },
251
+ });
166
252
  }
@@ -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
+ }