@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/agents/general-purpose-cc.md +11 -0
- package/package.json +1 -1
- package/src/backend/claude-detector.ts +75 -0
- package/src/backend/claude-events.ts +39 -0
- package/src/backend/claude-factory.ts +50 -0
- package/src/backend/claude-session.ts +75 -0
- package/src/backend/hook-parity.ts +19 -0
- package/src/backend/port.ts +5 -0
- package/src/backend/registry.ts +36 -0
- package/src/backend/resume-store.ts +44 -0
- package/src/engine/run-registry.ts +4 -0
- package/src/engine/spawnSubagent.ts +46 -17
- package/src/index.ts +141 -7
- package/src/lifecycle/artifacts-parser.ts +57 -0
- package/src/lifecycle/default.ts +74 -0
- package/src/lifecycle/lifecycle-todo.ts +84 -0
- package/src/lifecycle/lifecycle-types.ts +66 -0
- package/src/lifecycle/port.ts +7 -0
- package/src/lifecycle/prompt-template.ts +40 -0
- package/src/lifecycle/registry.ts +169 -0
- package/src/lifecycle/run-lifecycle.ts +235 -0
- package/src/panel/fleet-panel.ts +205 -13
- package/src/panel/rows.ts +67 -2
- package/src/registry/frontmatter.ts +13 -0
- package/src/todo-sync/adapter.ts +6 -0
- package/src/todo-sync/port.ts +2 -0
- package/src/tools/subagent.ts +36 -3
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
|
-
|
|
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
|
-
|
|
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
|
|
111
|
+
sessionManager,
|
|
64
112
|
modelRuntime,
|
|
65
113
|
});
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|