@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.10.3",
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
- // SPEC-5a (Q3=A): isolated run worktree-diff artifact discovery instead of the prompt-baked block.
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, parentCwd: opts.worktreePath, runLog: deps.runLog,
209
- tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
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
  }
@@ -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
- return out;
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
- return { name: pname, skills, agent, backend: pbackend, checkpoint };
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.