@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.
- package/package.json +1 -1
- package/src/engine/run-registry.ts +13 -1
- package/src/engine/spawnSubagent.ts +33 -16
- package/src/index.ts +155 -2
- 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 +251 -0
- package/src/panel/bg-runs-store.ts +36 -0
- package/src/panel/fleet-items.ts +36 -0
- package/src/panel/fleet-panel.ts +334 -18
- package/src/panel/rows.ts +90 -0
- package/src/runtime/async-runner.ts +123 -0
- package/src/runtime/concurrency-pool.ts +27 -0
- package/src/runtime/results-inbox.ts +45 -0
- package/src/runtime/resume.ts +48 -0
- package/src/runtime/run-journal.ts +61 -0
- package/src/scheduling/expressions.ts +60 -0
- package/src/scheduling/pid-lock.ts +43 -0
- package/src/scheduling/scheduler.ts +147 -0
- package/src/todo-sync/adapter.ts +6 -0
- package/src/todo-sync/port.ts +2 -0
- package/src/tools/fleet-results.ts +35 -0
- package/src/tools/subagent.ts +58 -0
- package/src/vendor/cron-parser/NOTICE.md +23 -0
- package/src/vendor/cron-parser/lib/date.js +79 -0
- package/src/vendor/cron-parser/lib/expression.js +614 -0
- package/src/vendor/cron-parser/lib/number.js +8 -0
- package/src/vendor/cron-parser/lib/parser.js +103 -0
- package/src/vendor/cron-parser/types.d.ts +12 -0
- package/src/worktree/diff-service.ts +40 -0
- 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
|
+
"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
|
-
|
|
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 '${
|
|
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 =
|
|
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:
|
|
168
|
+
thinkingLevel: childAgent.thinkingLevel,
|
|
155
169
|
tools,
|
|
156
|
-
rolePrompt:
|
|
157
|
-
skills:
|
|
170
|
+
rolePrompt: childAgent.rolePrompt,
|
|
171
|
+
skills: childAgent.skills ?? [],
|
|
158
172
|
task: opts.task,
|
|
159
|
-
agent:
|
|
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
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
+
}
|