@getpipher/armory-fleet 0.2.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/src/index.ts CHANGED
@@ -21,7 +21,15 @@ import { createDescribeImageTool } from "./vision/describe-image-tool.ts";
21
21
  import type { MemoryHydratePort } from "./memory-hydrate/port.ts";
22
22
  import type { VisionPort } from "./vision/port.ts";
23
23
  import type { ChildSessionFactory, ChildSession } from "./engine/spawnSubagent.ts";
24
+ import { BackendRegistry, PI_HOOK_PARITY, CLAUDE_HOOK_PARITY, type Backend } from "./backend/port.ts";
25
+ import { ResumeStore } from "./backend/resume-store.ts";
26
+ import { detectClaude } from "./backend/claude-detector.ts";
27
+ import { createClaudeChildFactory } from "./backend/claude-factory.ts";
24
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";
25
33
 
26
34
  /** The package builtin agents/ dir, resolved relative to this module. */
27
35
  function builtinAgentsDir(): string {
@@ -30,7 +38,44 @@ function builtinAgentsDir(): string {
30
38
 
31
39
  /** Build the real (SDK-backed) child-session factory. memoryPort is shared (cwd-agnostic);
32
40
  * the vision adapter is constructed per-spawn (needs the child cwd). */
33
- function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort): ChildSessionFactory {
41
+ /** SPEC-3: wrap a pi SDK session so it emits session_init on subscribe + forwards the rest. */
42
+ function wrapPiSession(inner: ChildSession, backendSessionId: string): ChildSession {
43
+ return {
44
+ prompt: (t) => inner.prompt(t),
45
+ abort: () => inner.abort(),
46
+ dispose: () => inner.dispose(),
47
+ subscribe: (handler) => {
48
+ handler({ type: "session_init", backendSessionId });
49
+ return inner.subscribe(handler);
50
+ },
51
+ };
52
+ }
53
+
54
+ /** SPEC-3: build the BackendRegistry — pi always; claude registered with availability reflecting detectClaude(). */
55
+ async function buildDefaultBackendRegistry(modelRuntime: ModelRuntime): Promise<BackendRegistry> {
56
+ const resumeStore = new ResumeStore();
57
+ const claudeInfo = await detectClaude();
58
+ const reg = new BackendRegistry();
59
+ const pi: Backend = {
60
+ id: "pi",
61
+ factory: createChildSessionFactory(modelRuntime, new ArmoryMemoryAdapter(), resumeStore),
62
+ available: () => true,
63
+ versionInfo: () => null,
64
+ hookParity: PI_HOOK_PARITY,
65
+ };
66
+ reg.register(pi);
67
+ // claude is registered regardless of availability so the Backends view can show it; available reflects detection.
68
+ reg.register({
69
+ id: "claude",
70
+ factory: createClaudeChildFactory(claudeInfo, resumeStore),
71
+ available: () => claudeInfo?.schemaOk === true,
72
+ versionInfo: () => claudeInfo,
73
+ hookParity: CLAUDE_HOOK_PARITY,
74
+ });
75
+ return reg;
76
+ }
77
+
78
+ export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort, resumeStore: ResumeStore): ChildSessionFactory {
34
79
  return {
35
80
  async create(opts) {
36
81
  let model: Model<any> | undefined;
@@ -52,7 +97,10 @@ function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: Memor
52
97
  agentDir: getAgentDir(),
53
98
  });
54
99
  const injectVision = opts.agent.vision && !visionPort.isMultimodal(model);
55
- const { session } = await createAgentSession({
100
+ // SPEC-3 §3.1: file-backed SessionManager so resume works. Resume a prior session when the store has a path.
101
+ const resumePath = resumeStore.get("pi", opts.agent.sessionKey);
102
+ const sessionManager = resumePath ? SessionManager.open(resumePath) : SessionManager.create(opts.cwd);
103
+ const { session: piSession } = await createAgentSession({
56
104
  cwd: opts.cwd,
57
105
  model,
58
106
  thinkingLevel: opts.thinkingLevel,
@@ -60,10 +108,14 @@ function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: Memor
60
108
  excludeTools: ["todo"], // SPEC-2 §9.1 hardened single-writer guard
61
109
  customTools: injectVision ? [createDescribeImageTool(visionPort) as never] : [],
62
110
  resourceLoader: loader,
63
- sessionManager: SessionManager.inMemory(),
111
+ sessionManager,
64
112
  modelRuntime,
65
113
  });
66
- return { session: session as unknown as ChildSession, model: opts.model ?? "" };
114
+ // SPEC-3 §4.3: wrap + emit session_init + persist the session file path for resume.
115
+ const backendSessionId = piSession.sessionFile ?? piSession.sessionId;
116
+ if (piSession.sessionFile) resumeStore.set("pi", opts.agent.sessionKey, piSession.sessionFile);
117
+ const session: ChildSession = wrapPiSession(piSession as unknown as ChildSession, backendSessionId);
118
+ return { session, model: opts.model ?? "" };
67
119
  },
68
120
  };
69
121
  }
@@ -75,10 +127,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
75
127
  runRegistry: new RunRegistry(),
76
128
  lock: createSingleSlotLock(),
77
129
  todoSync: new ArmoryTodoAdapter(),
78
- childFactory: createChildSessionFactory(modelRuntime, new ArmoryMemoryAdapter()),
130
+ backendRegistry: await buildDefaultBackendRegistry(modelRuntime),
79
131
  parentModel: { provider: "", id: "" },
80
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
+ },
81
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;
82
153
 
83
154
  const refresh = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => {
84
155
  const r = discoverAgents({
@@ -88,18 +159,35 @@ export default async function (pi: ExtensionAPI): Promise<void> {
88
159
  });
89
160
  for (const e of r.errors) ctx.ui.notify(e, "error");
90
161
  for (const w of r.warnings) ctx.ui.notify(w, "warning");
91
- 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);
92
179
  };
93
180
 
94
181
  pi.on("session_start", (_event, ctx) => {
95
182
  refresh(ctx);
183
+ refreshLifecycles(ctx);
96
184
  const m = ctx.model;
97
185
  deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
98
186
  deps.parentCwd = ctx.cwd;
99
187
  });
100
188
 
101
189
  pi.on("resources_discover", (event, ctx) => {
102
- if (event.reason === "reload") refresh(ctx);
190
+ if (event.reason === "reload") { refresh(ctx); refreshLifecycles(ctx); }
103
191
  return undefined;
104
192
  });
105
193
 
@@ -115,4 +203,50 @@ export default async function (pi: ExtensionAPI): Promise<void> {
115
203
  openFleetPanel(deps, ctx as never);
116
204
  },
117
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
+ });
118
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
+ }