@getpipher/armory-fleet 0.10.3 → 0.11.1
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/backend/claude-session.ts +5 -0
- package/src/engine/run-registry.ts +7 -0
- package/src/engine/spawnSubagent.ts +23 -4
- package/src/index.ts +38 -5
- package/src/lifecycle/default.ts +3 -0
- package/src/lifecycle/gates/builtin.ts +14 -0
- package/src/lifecycle/gates/chain-runner.ts +37 -0
- package/src/lifecycle/gates/completeness-check.ts +36 -0
- package/src/lifecycle/gates/gate.ts +41 -0
- package/src/lifecycle/gates/registry.ts +75 -0
- package/src/lifecycle/gates/verification-before-completion.ts +50 -0
- package/src/lifecycle/gates/verify.ts +47 -0
- package/src/lifecycle/lifecycle-types.ts +7 -0
- package/src/lifecycle/prompt-template.ts +13 -1
- package/src/lifecycle/registry.ts +16 -1
- package/src/lifecycle/run-lifecycle.ts +56 -3
- package/src/panel/fleet-panel.ts +37 -2
- package/src/panel/fleet-widget.ts +20 -1
- package/src/panel/gate-line.ts +22 -0
- package/src/panel/rows.ts +3 -0
- package/src/runtime/async-runner.ts +82 -26
- package/src/runtime/reconcile.ts +27 -18
- package/src/runtime/resume.ts +21 -15
- package/src/runtime/run-journal.ts +2 -2
- package/src/runtime/run-log.ts +9 -1
- package/src/scheduling/scheduler.ts +4 -0
- package/src/tools/subagent.ts +8 -2
- package/src/worktree/worktree-service.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -72,4 +72,9 @@ export class ClaudeChildSession implements ChildSession {
|
|
|
72
72
|
isDisposed(): boolean {
|
|
73
73
|
return this.disposed;
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/** SPEC-6-2: cross-process liveness probe — is the claude child proc still running? */
|
|
77
|
+
isAlive(): boolean {
|
|
78
|
+
return !this.disposed && this.proc.killed === false && this.proc.exitCode === null && this.proc.signalCode === null;
|
|
79
|
+
}
|
|
75
80
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/engine/run-registry.ts
|
|
2
2
|
import type { FleetRunStatus } from "../todo-sync/port.ts";
|
|
3
3
|
import type { LiveSessionHandle } from "./spawnSubagent.ts";
|
|
4
|
+
import type { BackendId } from "../lifecycle/lifecycle-types.ts";
|
|
4
5
|
|
|
5
6
|
export interface RunRecord {
|
|
6
7
|
runId: string;
|
|
@@ -29,6 +30,12 @@ export interface RunRecord {
|
|
|
29
30
|
contextTokens?: number;
|
|
30
31
|
/** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */
|
|
31
32
|
tier?: string;
|
|
33
|
+
/** SPEC-6-2: the cwd this run belongs to (widget cross-cwd filter + reconcile ownership). */
|
|
34
|
+
cwd: string;
|
|
35
|
+
/** SPEC-6-2: the backend (probe dispatch: pi→handle, claude→pid). */
|
|
36
|
+
backend: BackendId;
|
|
37
|
+
/** SPEC-6-2: claude-backend child PID (cross-process liveness probe). */
|
|
38
|
+
pid?: number;
|
|
32
39
|
/** SPEC-5b-4: live session handle while status === "running"; cleared by finishRun.
|
|
33
40
|
* Transient, in-memory only — never written to RunLog (the journal append constructs
|
|
34
41
|
* a plain object, not RunRecord). */
|
|
@@ -54,6 +54,8 @@ export interface ChildSession {
|
|
|
54
54
|
steer?(text: string): Promise<void>;
|
|
55
55
|
/** SPEC-5b-4: optional live streaming flag. Pi backend forwards the native SDK getter; claude omits. */
|
|
56
56
|
readonly isStreaming?: boolean;
|
|
57
|
+
/** SPEC-6-2: is the underlying session still active (not disposed/ended/killed)? */
|
|
58
|
+
isAlive?(): boolean;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/** SPEC-5b-4: narrow live-session handle retained on RunRecord while status === "running".
|
|
@@ -65,6 +67,8 @@ export interface LiveSessionHandle {
|
|
|
65
67
|
subscribe(handler: (e: ChildSessionEvent) => void): () => void;
|
|
66
68
|
readonly isStreaming: boolean;
|
|
67
69
|
readonly supportsSteer: boolean;
|
|
70
|
+
/** SPEC-6-2: is the underlying session still active (not disposed/ended/killed)? */
|
|
71
|
+
isAlive(): boolean;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
/** SPEC-5b-4: wrap a ChildSession into a narrow LiveSessionHandle for the panel.
|
|
@@ -76,6 +80,8 @@ export function toLiveHandle(session: ChildSession): LiveSessionHandle {
|
|
|
76
80
|
subscribe: (h) => session.subscribe(h),
|
|
77
81
|
get isStreaming() { return session.isStreaming ?? false; },
|
|
78
82
|
get supportsSteer() { return typeof session.steer === "function"; },
|
|
83
|
+
isAlive: () => typeof (session as { isAlive?: () => boolean }).isAlive === "function"
|
|
84
|
+
? (session as { isAlive: () => boolean }).isAlive() : true,
|
|
79
85
|
};
|
|
80
86
|
}
|
|
81
87
|
|
|
@@ -212,10 +218,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
212
218
|
runId, agent: agentDef.name, model, task: opts.task, track,
|
|
213
219
|
todoId: null, status: "running", startedAt,
|
|
214
220
|
tier: tier?.name, costTotal: 0, contextTokens: 0,
|
|
221
|
+
cwd: opts.parentCwd, backend: backendId,
|
|
215
222
|
});
|
|
216
|
-
try {
|
|
217
|
-
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId: null });
|
|
218
|
-
} catch { /* best-effort: journal is the index, not the product */ }
|
|
219
223
|
|
|
220
224
|
// todo-sync (before) — only when both caller tracks AND agent allows todoSync
|
|
221
225
|
let priorStatus: string | undefined;
|
|
@@ -280,7 +284,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
280
284
|
if (e.type === "session_init" && e.backendSessionId) {
|
|
281
285
|
opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
|
|
282
286
|
try {
|
|
283
|
-
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
|
|
287
|
+
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey, cwd: opts.parentCwd, pid: (session as { proc?: { pid?: number } }).proc?.pid });
|
|
284
288
|
} catch { /* best-effort */ }
|
|
285
289
|
} else if (e.type === "turn_start") {
|
|
286
290
|
turnIdx++;
|
|
@@ -355,11 +359,26 @@ function fail(runId: string, startedAt: number, message: string, agent: string):
|
|
|
355
359
|
};
|
|
356
360
|
}
|
|
357
361
|
|
|
362
|
+
/** SPEC-6-2: guard against double-finishRun (abort-then-complete). */
|
|
363
|
+
const finalizedRunIds = new Set<string>();
|
|
364
|
+
|
|
358
365
|
async function finishRun(
|
|
359
366
|
opts: SpawnOptions, runId: string, startedAt: number,
|
|
360
367
|
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
|
|
361
368
|
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
|
|
362
369
|
): Promise<SpawnResult> {
|
|
370
|
+
if (finalizedRunIds.has(runId)) {
|
|
371
|
+
// Already finalized — return the existing registry record's result without re-appending.
|
|
372
|
+
const existing = opts.runRegistry.get(runId);
|
|
373
|
+
return {
|
|
374
|
+
status: existing?.status ?? status, finalText: existing?.resultSummary ?? finalText,
|
|
375
|
+
runId, todoId, agent: agentName, model,
|
|
376
|
+
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
|
|
377
|
+
tokenTotal, costTotal, contextTokens, error,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
finalizedRunIds.add(runId);
|
|
381
|
+
|
|
363
382
|
const endedAt = Date.now();
|
|
364
383
|
opts.runRegistry.update(runId, {
|
|
365
384
|
status, endedAt, resultSummary: finalText.slice(0, 120),
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,8 @@ import { reconcileRuns } from "./runtime/reconcile.ts";
|
|
|
42
42
|
import { Scheduler } from "./scheduling/scheduler.ts";
|
|
43
43
|
import { createFleetResultsTool } from "./tools/fleet-results.ts";
|
|
44
44
|
import { BgRunsStore } from "./panel/bg-runs-store.ts";
|
|
45
|
+
import { GateRegistry } from "./lifecycle/gates/registry.ts";
|
|
46
|
+
import { registerBuiltinGates } from "./lifecycle/gates/builtin.ts";
|
|
45
47
|
import { FleetWidgetController } from "./panel/fleet-widget.ts";
|
|
46
48
|
import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
|
|
47
49
|
import { BUILTIN_TIERS } from "./tiers/builtin.ts";
|
|
@@ -177,6 +179,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
177
179
|
// Builtin-only placeholder tier registry so spawn works before session_start rebuilds with merged tiers.
|
|
178
180
|
deps.tierRegistry = new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry });
|
|
179
181
|
|
|
182
|
+
// SPEC-6-2: gate registry + builtin gate registration.
|
|
183
|
+
const gateRegistry = new GateRegistry();
|
|
184
|
+
registerBuiltinGates(gateRegistry);
|
|
185
|
+
// Wire gate deps into lifecycleDeps (both the async + foreground lifecycle sites spread from lifecycleDeps).
|
|
186
|
+
deps.lifecycleDeps.gateRegistry = gateRegistry;
|
|
187
|
+
deps.lifecycleDeps.getGateCtxState = (todoId: string, _agentName: string) => {
|
|
188
|
+
const runs = deps.runRegistry.list().filter((r) => r.todoId === todoId);
|
|
189
|
+
const lifecycleCost = runs.reduce((s, r) => s + (r.costTotal ?? 0), 0);
|
|
190
|
+
const contextTokens = runs.reduce((max, r) => Math.max(max, r.contextTokens ?? 0), 0);
|
|
191
|
+
const tierName = runs.find((r) => r.tier)?.tier;
|
|
192
|
+
const tier = tierName ? deps.tierRegistry?.get(tierName) : undefined;
|
|
193
|
+
return { lifecycleCost, contextTokens, tier };
|
|
194
|
+
};
|
|
195
|
+
|
|
180
196
|
// ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ──
|
|
181
197
|
const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet");
|
|
182
198
|
const bgRuns = new BgRunsStore();
|
|
@@ -196,17 +212,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
196
212
|
// foreground subagent holding deps.lock made every bg run's first-phase spawn fail fast
|
|
197
213
|
// (tryAcquire → "concurrency lock unexpectedly unavailable" → 6ms run:aborted).
|
|
198
214
|
const bgLock = createSingleSlotLock();
|
|
215
|
+
// v0.11.1: isolated runs use worktree-diff artifact discovery + the worktree as spawn cwd;
|
|
216
|
+
// in-place runs (worktreePath undefined) use the prompt-baked parser + the session cwd.
|
|
217
|
+
const isolated = !!opts.worktreePath;
|
|
199
218
|
const lifecycleFullDeps: LifecycleRunDeps = {
|
|
200
219
|
...deps.lifecycleDeps,
|
|
201
220
|
genRunId: () => opts.runId, // override: use the async runner's runId
|
|
202
|
-
|
|
203
|
-
artifactDiscovery: ({ finalText, cwd, baseRef }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText),
|
|
221
|
+
...(isolated ? { artifactDiscovery: ({ finalText, cwd, baseRef }: { finalText: string; cwd: string; baseRef: string }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText) } : {}),
|
|
204
222
|
spawn: async (o) => spawnSubagent({
|
|
205
223
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
206
224
|
skillsOverride: o.skills, backendOverride: o.backend,
|
|
207
225
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
|
|
208
|
-
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
|
|
209
|
-
|
|
226
|
+
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
|
|
227
|
+
parentCwd: isolated ? opts.worktreePath! : deps.parentCwd,
|
|
228
|
+
runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
210
229
|
}),
|
|
211
230
|
};
|
|
212
231
|
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" } });
|
|
@@ -274,13 +293,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
274
293
|
notify: (m, lvl) => ctx.ui.notify(m, lvl),
|
|
275
294
|
genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
|
|
276
295
|
onProgress: (runId, status) => { bgRuns.set(runId, status); },
|
|
296
|
+
runRegistry: deps.runRegistry,
|
|
277
297
|
};
|
|
278
298
|
deps.scheduler = new Scheduler({
|
|
279
299
|
storePath: join(dir, "schedules.json"),
|
|
280
300
|
lockPath: join(dir, "schedules.lock"),
|
|
281
301
|
onFire: (spec) => {
|
|
282
302
|
if (!deps.asyncRunner) return;
|
|
283
|
-
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed" });
|
|
303
|
+
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation });
|
|
284
304
|
},
|
|
285
305
|
});
|
|
286
306
|
deps.scheduler.start();
|
|
@@ -295,12 +315,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
295
315
|
return sharedModelRegistry.find(provider, id)?.contextWindow;
|
|
296
316
|
};
|
|
297
317
|
deps.getModelContextWindow = getModelContextWindow;
|
|
318
|
+
// SPEC-6-2: thread getModelContextWindow into lifecycle deps for gate ctx.
|
|
319
|
+
deps.lifecycleDeps.getModelContextWindow = getModelContextWindow;
|
|
298
320
|
fleetWidget = new FleetWidgetController({
|
|
299
321
|
runRegistry: deps.runRegistry,
|
|
300
322
|
bgRuns,
|
|
301
323
|
ui: ctx.ui as never,
|
|
302
324
|
getTheme: () => ctx.ui.theme,
|
|
303
325
|
getModelContextWindow,
|
|
326
|
+
cwd: ctx.cwd,
|
|
327
|
+
runLog: deps.runLog,
|
|
304
328
|
});
|
|
305
329
|
fleetWidget.start();
|
|
306
330
|
|
|
@@ -390,4 +414,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
390
414
|
ctx.ui.notify(`lifecycle ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning");
|
|
391
415
|
},
|
|
392
416
|
});
|
|
417
|
+
|
|
418
|
+
// SPEC-6-2: gate extensibility — pi doesn't expose registerGate, so we provide a command
|
|
419
|
+
// so other extensions can register custom gates at runtime.
|
|
420
|
+
pi.registerCommand("fleet-register-gate", {
|
|
421
|
+
description: "Register a custom gate on the fleet gate registry (extensibility path).",
|
|
422
|
+
handler: async (_args, ctx) => {
|
|
423
|
+
ctx.ui.notify("fleet-register-gate: custom gates must be registered via the GateRegistry module export (see src/lifecycle/gates/registry.ts).", "info");
|
|
424
|
+
},
|
|
425
|
+
});
|
|
393
426
|
}
|
package/src/lifecycle/default.ts
CHANGED
|
@@ -24,10 +24,12 @@ phases:
|
|
|
24
24
|
skills: [writing-plans]
|
|
25
25
|
agent: general-purpose
|
|
26
26
|
checkpoint: true
|
|
27
|
+
gates: [completenessCheck]
|
|
27
28
|
- name: implement
|
|
28
29
|
skills: [executing-plans, test-driven-development, verification-before-completion]
|
|
29
30
|
agent: general-purpose
|
|
30
31
|
checkpoint: false
|
|
32
|
+
gates: [verification-before-completion, completenessCheck, gate]
|
|
31
33
|
- name: review
|
|
32
34
|
skills: [requesting-code-review, receiving-code-review]
|
|
33
35
|
agent: general-purpose
|
|
@@ -35,6 +37,7 @@ phases:
|
|
|
35
37
|
- name: finish
|
|
36
38
|
skills: [finishing-a-development-branch]
|
|
37
39
|
agent: general-purpose
|
|
40
|
+
challengeStep: false
|
|
38
41
|
---
|
|
39
42
|
|
|
40
43
|
## brainstorm
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/lifecycle/gates/builtin.ts
|
|
2
|
+
import type { GateRegistry } from "./registry.ts";
|
|
3
|
+
import { verificationBeforeCompletionGate } from "./verification-before-completion.ts";
|
|
4
|
+
import { completenessCheckGate } from "./completeness-check.ts";
|
|
5
|
+
import { gateGate } from "./gate.ts";
|
|
6
|
+
import { verifyGate } from "./verify.ts";
|
|
7
|
+
|
|
8
|
+
/** Register the 4 builtin gates on a GateRegistry. */
|
|
9
|
+
export function registerBuiltinGates(reg: GateRegistry): void {
|
|
10
|
+
reg.register(verificationBeforeCompletionGate);
|
|
11
|
+
reg.register(completenessCheckGate);
|
|
12
|
+
reg.register(gateGate);
|
|
13
|
+
reg.register(verifyGate);
|
|
14
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { GateDef, GateCtx, GateResult } from "./registry.ts";
|
|
2
|
+
|
|
3
|
+
export interface GateChainOutcome {
|
|
4
|
+
results: GateResult[];
|
|
5
|
+
shortCircuit?: { action: "revise" | "abort"; feedback?: string; reason?: string };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Run the gate chain left-to-right. Advise-failures continue; revise/abort short-circuit. */
|
|
9
|
+
export async function runGateChain(opts: { gates: GateDef[]; ctx: GateCtx }): Promise<GateChainOutcome> {
|
|
10
|
+
const results: GateResult[] = [];
|
|
11
|
+
for (const gate of opts.gates) {
|
|
12
|
+
// Each gate sees its own params on ctx.gateParams (set per-gate by the caller/run-lifecycle).
|
|
13
|
+
const gateCtx: GateCtx = { ...opts.ctx, gateParams: gate.params };
|
|
14
|
+
const started = Date.now();
|
|
15
|
+
let result: GateResult;
|
|
16
|
+
let crashed = false;
|
|
17
|
+
try {
|
|
18
|
+
result = await gate.run(gateCtx);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
// A throwing gate is treated as an advise-failure (never auto-revise on a crash).
|
|
21
|
+
crashed = true;
|
|
22
|
+
result = { gate: gate.name, kind: gate.kind, passed: false, evidence: `gate '${gate.name}' threw: ${(e as Error).message}`, onFail: "advise" };
|
|
23
|
+
}
|
|
24
|
+
result.durationMs = Date.now() - started;
|
|
25
|
+
results.push(result);
|
|
26
|
+
if (!crashed && !result.passed) {
|
|
27
|
+
if (gate.onFail === "revise") {
|
|
28
|
+
return { results, shortCircuit: { action: "revise", feedback: result.evidence } };
|
|
29
|
+
}
|
|
30
|
+
if (gate.onFail === "abort") {
|
|
31
|
+
return { results, shortCircuit: { action: "abort", reason: result.evidence } };
|
|
32
|
+
}
|
|
33
|
+
// advise → continue
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { results };
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import type { GateDef, GateCtx, GateResult } from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
export interface CompletenessResult { passed: boolean; evidence: string; }
|
|
6
|
+
|
|
7
|
+
/** Pure-ish: stat every claimed path. Relative paths resolve against baseDir. */
|
|
8
|
+
export function checkCompleteness(paths: string[], baseDir: string): CompletenessResult {
|
|
9
|
+
if (paths.length === 0) return { passed: true, evidence: "no claimed artifacts (terminal-phase exemption)" };
|
|
10
|
+
const missing: string[] = [];
|
|
11
|
+
let found = 0;
|
|
12
|
+
for (const p of paths) {
|
|
13
|
+
const abs = isAbsolute(p) ? p : resolve(baseDir, p);
|
|
14
|
+
try {
|
|
15
|
+
statSync(abs);
|
|
16
|
+
found++;
|
|
17
|
+
} catch {
|
|
18
|
+
missing.push(p);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (missing.length > 0) {
|
|
22
|
+
return { passed: false, evidence: `missing: ${missing.join(", ")} (${found}/${paths.length} exist)` };
|
|
23
|
+
}
|
|
24
|
+
return { passed: true, evidence: `${paths.length}/${paths.length} artifacts exist` };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const completenessCheckGate: GateDef = {
|
|
28
|
+
name: "completenessCheck",
|
|
29
|
+
kind: "predicate",
|
|
30
|
+
onFail: "revise",
|
|
31
|
+
run: async (ctx: GateCtx): Promise<GateResult> => {
|
|
32
|
+
const base = ctx.worktreePath ?? process.cwd();
|
|
33
|
+
const r = checkCompleteness(ctx.phaseRec.paths, base);
|
|
34
|
+
return { gate: "completenessCheck", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "revise" };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { GateDef, GateCtx, GateResult } from "./registry.ts";
|
|
2
|
+
import type { Tier } from "../../tiers/tier-registry.ts";
|
|
3
|
+
|
|
4
|
+
export interface BudgetInput {
|
|
5
|
+
lifecycleCost: number;
|
|
6
|
+
contextTokens: number;
|
|
7
|
+
tier?: Tier;
|
|
8
|
+
params?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface BudgetResult { passed: boolean; evidence: string; }
|
|
11
|
+
|
|
12
|
+
/** Pure: assert cost < cap and context < floor. Missing tier/caps → skip (pass). */
|
|
13
|
+
export function assertBudget(input: BudgetInput): BudgetResult {
|
|
14
|
+
const { lifecycleCost, contextTokens, tier, params } = input;
|
|
15
|
+
if (!tier) return { passed: true, evidence: "no tier → no caps to assert (skip)" };
|
|
16
|
+
const costCap = typeof (params as { costCap?: number } | undefined)?.costCap === "number"
|
|
17
|
+
? (params as { costCap: number }).costCap : tier.costCap;
|
|
18
|
+
const contextFloor = typeof (params as { contextFloor?: number } | undefined)?.contextFloor === "number"
|
|
19
|
+
? (params as { contextFloor: number }).contextFloor : tier.contextFloor;
|
|
20
|
+
const parts: string[] = [];
|
|
21
|
+
if (typeof costCap === "number") {
|
|
22
|
+
if (lifecycleCost > costCap) return { passed: false, evidence: `cost $${lifecycleCost.toFixed(2)} > cap $${costCap.toFixed(2)}` };
|
|
23
|
+
parts.push(`cost $${lifecycleCost.toFixed(2)} < cap $${costCap.toFixed(2)}`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof contextFloor === "number") {
|
|
26
|
+
if (contextTokens > contextFloor) return { passed: false, evidence: `context ${contextTokens} > floor ${contextFloor}` };
|
|
27
|
+
parts.push(`ctx ${contextTokens} < floor ${contextFloor}`);
|
|
28
|
+
}
|
|
29
|
+
if (parts.length === 0) return { passed: true, evidence: "tier has no costCap/contextFloor → nothing to assert" };
|
|
30
|
+
return { passed: true, evidence: parts.join("; ") };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const gateGate: GateDef = {
|
|
34
|
+
name: "gate",
|
|
35
|
+
kind: "predicate",
|
|
36
|
+
onFail: "abort",
|
|
37
|
+
run: async (ctx: GateCtx): Promise<GateResult> => {
|
|
38
|
+
const r = assertBudget({ lifecycleCost: ctx.lifecycleCost, contextTokens: ctx.contextTokens, tier: ctx.tier, params: ctx.gateParams });
|
|
39
|
+
return { gate: "gate", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "abort" };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { PhaseRecord } from "../lifecycle-types.ts";
|
|
2
|
+
import type { SpawnResult } from "../../engine/spawnSubagent.ts";
|
|
3
|
+
import type { SpawnFn } from "../run-lifecycle.ts";
|
|
4
|
+
import type { BackendId } from "../lifecycle-types.ts";
|
|
5
|
+
import type { Tier } from "../../tiers/tier-registry.ts";
|
|
6
|
+
|
|
7
|
+
export type GateKind = "agent" | "predicate";
|
|
8
|
+
export type GateOnFail = "advise" | "revise" | "abort";
|
|
9
|
+
|
|
10
|
+
/** What a phase declares in frontmatter. String = name only; object = name + overrides. */
|
|
11
|
+
export type GateRef = string | { name: string; onFail?: GateOnFail; params?: Record<string, unknown> };
|
|
12
|
+
|
|
13
|
+
/** A resolved gate definition (registry entry + phase overrides applied). */
|
|
14
|
+
export interface GateDef {
|
|
15
|
+
name: string;
|
|
16
|
+
kind: GateKind;
|
|
17
|
+
onFail: GateOnFail;
|
|
18
|
+
params?: Record<string, unknown>;
|
|
19
|
+
run: (ctx: GateCtx) => Promise<GateResult>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GateCtx {
|
|
23
|
+
phaseRec: PhaseRecord;
|
|
24
|
+
spawnRes: SpawnResult;
|
|
25
|
+
lifecycle: { name: string; task: string; todoId: string; backend: BackendId };
|
|
26
|
+
tier?: Tier;
|
|
27
|
+
/** Sum of costTotal across all runs linked to this lifecycle's todoId. */
|
|
28
|
+
lifecycleCost: number;
|
|
29
|
+
contextTokens: number;
|
|
30
|
+
worktreePath?: string;
|
|
31
|
+
/** Agent gates use this to spawn the reviewer subagent. */
|
|
32
|
+
spawn: SpawnFn;
|
|
33
|
+
getModelContextWindow: (model: string) => number | undefined;
|
|
34
|
+
/** Per-gate params from the resolved GateDef (set by the chain runner). */
|
|
35
|
+
gateParams?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GateResult {
|
|
39
|
+
gate: string;
|
|
40
|
+
kind: GateKind;
|
|
41
|
+
passed: boolean;
|
|
42
|
+
evidence: string;
|
|
43
|
+
onFail: GateOnFail;
|
|
44
|
+
/** Agent gates only — the spawned run's costTotal. */
|
|
45
|
+
cost?: number;
|
|
46
|
+
/** Agent gates only — links to the /fleet row. */
|
|
47
|
+
runId?: string;
|
|
48
|
+
durationMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class GateRegistry {
|
|
52
|
+
private readonly byName = new Map<string, GateDef>();
|
|
53
|
+
register(def: GateDef): void {
|
|
54
|
+
if (this.byName.has(def.name)) throw new Error(`duplicate gate name '${def.name}'`);
|
|
55
|
+
this.byName.set(def.name, def);
|
|
56
|
+
}
|
|
57
|
+
get(name: string): GateDef | undefined { return this.byName.get(name); }
|
|
58
|
+
list(): GateDef[] { return [...this.byName.values()]; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve phase-declared GateRefs into GateDefs, applying per-phase onFail/params overrides. */
|
|
62
|
+
export function resolveGates(refs: GateRef[] | undefined, reg: GateRegistry): GateDef[] {
|
|
63
|
+
if (!refs || refs.length === 0) return [];
|
|
64
|
+
return refs.map((ref) => {
|
|
65
|
+
const name = typeof ref === "string" ? ref : ref.name;
|
|
66
|
+
const base = reg.get(name);
|
|
67
|
+
if (!base) throw new Error(`unknown gate '${name}' (not in registry)`);
|
|
68
|
+
if (typeof ref === "string") return base;
|
|
69
|
+
return {
|
|
70
|
+
...base,
|
|
71
|
+
...(ref.onFail ? { onFail: ref.onFail } : {}),
|
|
72
|
+
...(ref.params ? { params: { ...base.params, ...ref.params } } : {}),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { GateDef, GateCtx, GateResult } from "./registry.ts";
|
|
2
|
+
|
|
3
|
+
/** Default patterns: a verification command invocation AND a result signal.
|
|
4
|
+
* A command alone (no result) is a claim, not evidence. */
|
|
5
|
+
const DEFAULT_COMMAND_PATTERNS: RegExp[] = [
|
|
6
|
+
/\b(pnpm|npm|yarn)\s+(test|test:run|typecheck|lint|build)\b/i,
|
|
7
|
+
/\b(typecheck|tsc|eslint|prettier)\b/i,
|
|
8
|
+
/\bgo\s+(test|build)\b/i, /\bcargo\s+(test|build)\b/i, /\brustc\b/i,
|
|
9
|
+
/\bpytest\b/i, /\bmvn\s+test\b/i,
|
|
10
|
+
];
|
|
11
|
+
const DEFAULT_RESULT_PATTERNS: RegExp[] = [
|
|
12
|
+
/\b\d+\s*(\/|of)?\s*\d*\s*(pass|passing)\b/i,
|
|
13
|
+
/\b0\s*(fail|failing|errors?|error)\b/i,
|
|
14
|
+
/\bexit\s*(code\s*)?(:|=|→)?\s*0\b/i,
|
|
15
|
+
/\bclean\b/i, /\bgreen\b/i, /\bok\b/i,
|
|
16
|
+
/\b\d+\s*pass(?:ing)?(?:[,\s]+0\s*fail)?\b/i,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export interface ScanResult { passed: boolean; evidence: string; }
|
|
20
|
+
|
|
21
|
+
/** Pure: scan phase output for verification evidence (command + result). */
|
|
22
|
+
export function scanVerificationEvidence(
|
|
23
|
+
text: string,
|
|
24
|
+
opts: { patterns?: RegExp[] } = {},
|
|
25
|
+
): ScanResult {
|
|
26
|
+
const commands = opts.patterns ?? DEFAULT_COMMAND_PATTERNS;
|
|
27
|
+
// Custom patterns replace the command set; result detection stays the default unless
|
|
28
|
+
// the caller wants full control (they pass patterns that already encode the result).
|
|
29
|
+
const cmdMatch = commands.find((p) => p.test(text));
|
|
30
|
+
if (!cmdMatch) return { passed: false, evidence: "no verification command output found in phase output" };
|
|
31
|
+
// If custom patterns are provided, treat a command match as sufficient (the pattern encodes the result).
|
|
32
|
+
if (opts.patterns) return { passed: true, evidence: `found evidence matching ${cmdMatch}` };
|
|
33
|
+
const resultMatch = DEFAULT_RESULT_PATTERNS.find((p) => p.test(text));
|
|
34
|
+
if (!resultMatch) return { passed: false, evidence: `verification command found (${cmdMatch}) but no pass/exit result signal — show the command output` };
|
|
35
|
+
// Extract a compact snippet around the command.
|
|
36
|
+
const idx = text.search(cmdMatch);
|
|
37
|
+
const snippet = text.slice(Math.max(0, idx - 10), Math.min(text.length, idx + 80)).replace(/\s+/g, " ").trim();
|
|
38
|
+
return { passed: true, evidence: `found: ${snippet}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const verificationBeforeCompletionGate: GateDef = {
|
|
42
|
+
name: "verification-before-completion",
|
|
43
|
+
kind: "predicate",
|
|
44
|
+
onFail: "revise",
|
|
45
|
+
run: async (ctx: GateCtx): Promise<GateResult> => {
|
|
46
|
+
const patterns = (ctx.gateParams as { patterns?: RegExp[] } | undefined)?.patterns;
|
|
47
|
+
const r = scanVerificationEvidence(ctx.spawnRes.finalText, patterns ? { patterns } : {});
|
|
48
|
+
return { gate: "verification-before-completion", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "revise" };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { GateDef, GateCtx, GateResult } from "./registry.ts";
|
|
2
|
+
import type { SpawnResult } from "../../engine/spawnSubagent.ts";
|
|
3
|
+
|
|
4
|
+
const FAILURE_MARKERS = /\b(does not meet|not meet|missing|incomplete|not addressed|fails?|broken|incorrect)\b/i;
|
|
5
|
+
|
|
6
|
+
/** Pure: judge a reviewer's text for a passed/failed verdict. */
|
|
7
|
+
export function judgeReview(text: string): { passed: boolean } {
|
|
8
|
+
return { passed: !FAILURE_MARKERS.test(text) };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Pure: build the reviewer subagent prompt. */
|
|
12
|
+
export function buildVerifyPrompt(ctx: GateCtx): string {
|
|
13
|
+
return [
|
|
14
|
+
"You are an independent reviewer. Review this phase's output against the task + plan.",
|
|
15
|
+
`Task: ${ctx.lifecycle.task}`,
|
|
16
|
+
`Phase: ${ctx.phaseRec.name}`,
|
|
17
|
+
`Phase summary: ${ctx.phaseRec.summary}`,
|
|
18
|
+
`Artifacts: ${ctx.phaseRec.paths.join(", ") || "(none)"}`,
|
|
19
|
+
"Did it meet the requirement? What's missing? Be specific. End with a verdict: 'meets the requirement' or 'does not meet the requirement'.",
|
|
20
|
+
].join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const verifyGate: GateDef = {
|
|
24
|
+
name: "verify",
|
|
25
|
+
kind: "agent",
|
|
26
|
+
onFail: "advise",
|
|
27
|
+
run: async (ctx: GateCtx): Promise<GateResult> => {
|
|
28
|
+
const reviewerAgent = (ctx.gateParams as { agent?: string } | undefined)?.agent ?? "reviewer";
|
|
29
|
+
const prompt = buildVerifyPrompt(ctx);
|
|
30
|
+
let spawnRes: SpawnResult;
|
|
31
|
+
try {
|
|
32
|
+
spawnRes = await ctx.spawn({ agent: reviewerAgent, task: prompt, lifecycleTodoId: ctx.lifecycle.todoId, skills: [], backend: ctx.lifecycle.backend });
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { gate: "verify", kind: "agent", passed: false, evidence: `reviewer spawn failed: ${(e as Error).message}`, onFail: "advise" };
|
|
35
|
+
}
|
|
36
|
+
if (spawnRes.status === "failed") {
|
|
37
|
+
return { gate: "verify", kind: "agent", passed: false, evidence: `reviewer spawn failed: ${spawnRes.error ?? spawnRes.finalText.slice(0, 120)}`, onFail: "advise" };
|
|
38
|
+
}
|
|
39
|
+
const verdict = judgeReview(spawnRes.finalText);
|
|
40
|
+
return {
|
|
41
|
+
gate: "verify", kind: "agent", passed: verdict.passed,
|
|
42
|
+
evidence: spawnRes.finalText.slice(0, 2000), onFail: "advise",
|
|
43
|
+
...(spawnRes.costTotal != null ? { cost: spawnRes.costTotal } : {}),
|
|
44
|
+
runId: spawnRes.runId,
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/lifecycle/lifecycle-types.ts
|
|
2
2
|
import type { FleetRunStatus } from "../todo-sync/port.ts";
|
|
3
3
|
import type { AgentSource } from "../registry/frontmatter.ts";
|
|
4
|
+
import type { GateRef, GateResult } from "./gates/registry.ts";
|
|
4
5
|
|
|
5
6
|
/** Backend id (mirrors SPEC-3 AgentDef.backend). */
|
|
6
7
|
export type BackendId = "pi" | "claude";
|
|
@@ -22,6 +23,10 @@ export interface PhaseDef {
|
|
|
22
23
|
checkpoint?: boolean;
|
|
23
24
|
/** The phase prompt template (parsed from the `## <name>` body section). */
|
|
24
25
|
promptTemplate: string;
|
|
26
|
+
/** SPEC-6-2: opt out of the lifecycle-wide challenge-step prompt injection. Default true. */
|
|
27
|
+
challengeStep?: boolean;
|
|
28
|
+
/** SPEC-6-2: gates to run after this phase (before checkpoint). Array of GateRef. */
|
|
29
|
+
gates?: GateRef[];
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export interface LifecycleDef {
|
|
@@ -41,6 +46,8 @@ export interface PhaseRecord {
|
|
|
41
46
|
paths: string[];
|
|
42
47
|
status: FleetRunStatus;
|
|
43
48
|
reviseCount: number;
|
|
49
|
+
/** SPEC-6-2: gate results from this phase's gate chain (for panel rendering). */
|
|
50
|
+
gateResults?: GateResult[];
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
export interface LifecycleRunRecord {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
// src/lifecycle/prompt-template.ts
|
|
2
2
|
import type { PhaseRecord } from "./lifecycle-types.ts";
|
|
3
3
|
|
|
4
|
+
export const CHALLENGE_STEP_BLOCK = [
|
|
5
|
+
"",
|
|
6
|
+
"## Challenge Step",
|
|
7
|
+
"After completing significant work, actively challenge your own output before presenting it.",
|
|
8
|
+
'Ask: "What could break? What did I miss? What would a critical reviewer flag?"',
|
|
9
|
+
"Fix what you find — don't just note it. Small single-line changes are exempt.",
|
|
10
|
+
"",
|
|
11
|
+
].join("\n");
|
|
12
|
+
|
|
4
13
|
export interface PromptVars {
|
|
5
14
|
task: string;
|
|
6
15
|
lifecycle: string;
|
|
@@ -9,6 +18,8 @@ export interface PromptVars {
|
|
|
9
18
|
prev?: { name: string; summary: string; paths: string[] };
|
|
10
19
|
/** On Revise only: human feedback + prior-attempt digest. */
|
|
11
20
|
feedback?: string;
|
|
21
|
+
/** SPEC-6-2: opt out of challenge-step prompt injection. Default true (append). */
|
|
22
|
+
challengeStep?: boolean;
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
/** Render a phase prompt template. Supports {{task}}, {{lifecycle}}, {{phase}},
|
|
@@ -36,5 +47,6 @@ export function renderPhasePrompt(template: string, vars: PromptVars): string {
|
|
|
36
47
|
.replace(/{{\s*prev\.paths\s*}}/g, pathsStr)
|
|
37
48
|
.replace(/{{\s*feedback\s*}}/g, vars.feedback ?? "");
|
|
38
49
|
|
|
39
|
-
|
|
50
|
+
const challengeStep = vars.challengeStep !== false; // default true
|
|
51
|
+
return challengeStep ? out + CHALLENGE_STEP_BLOCK : out;
|
|
40
52
|
}
|
|
@@ -70,7 +70,22 @@ export function parseLifecycleFile(content: string, filePath: string, source: Ag
|
|
|
70
70
|
pbackend = b as BackendId;
|
|
71
71
|
}
|
|
72
72
|
const checkpoint = po.checkpoint === undefined ? true : Boolean(po.checkpoint);
|
|
73
|
-
|
|
73
|
+
const challengeStep = po.challengeStep === undefined ? undefined : !po.challengeStep ? false : true;
|
|
74
|
+
let gates: import("./gates/registry.ts").GateRef[] | undefined;
|
|
75
|
+
if (po.gates !== undefined) {
|
|
76
|
+
if (!Array.isArray(po.gates)) {
|
|
77
|
+
throw new LifecycleParseError(`${filePath}: phase '${pname}' gates must be an array`);
|
|
78
|
+
}
|
|
79
|
+
gates = po.gates.map((g: unknown) => {
|
|
80
|
+
if (typeof g === "string") return g;
|
|
81
|
+
if (g && typeof g === "object") {
|
|
82
|
+
const go = g as Record<string, unknown>;
|
|
83
|
+
return { name: String(go.name), ...(go.onFail ? { onFail: String(go.onFail) as import("./gates/registry.ts").GateOnFail } : {}), ...(go.params ? { params: go.params as Record<string, unknown> } : {}) };
|
|
84
|
+
}
|
|
85
|
+
throw new LifecycleParseError(`${filePath}: phase '${pname}' gate entry must be string or object`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return { name: pname, skills, agent, backend: pbackend, checkpoint, ...(challengeStep !== undefined ? { challengeStep } : {}), ...(gates ? { gates } : {}) };
|
|
74
89
|
});
|
|
75
90
|
|
|
76
91
|
// Split body into `## <phase>` sections. A phase with no matching section = error.
|