Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,14 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.

---

## Migration (v0.13.0 — SPEC-6-5 cwd isolation)

- **`cwd` param on the `subagent` tool** (default = the session cwd, backward-compat). Pass it to scope a child's working dir + context (AGENTS.md cascade, skills, memory) to a dispatch target outside the session cwd — the #20 confabulation fix. Cross-cwd dispatches surface a `↗<basename>` glyph in the fleet widget + a spawn-time notify.
- **`userMemory` default flip:** the global cross-project user memory scope (`/__armory-fleet-user__`) is no longer hydrated by default. If you populated that dir + relied on it, add `userMemory: true` to the agent frontmatter (only meaningful with `memoryHydrate: true`). TS consumers constructing `AgentDef` literals must now include `userMemory: boolean` (required field; use `false` for the old default behavior).
- **Lifecycle `cwd` field:** lifecycles accept an optional `cwd` frontmatter field to pin a target repo; absent → the entry-point cwd (the panel's chosen cwd, or the dispatching `subagent` tool's cwd/session cwd). When present, it overrides the entry-point cwd for all phases.
- **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
- **Deferred:** bg/scheduled + worktree cwd-isolation (the `cwd` param is honored by foreground dispatches only for now) — tracked in #62.

## Roadmap

armory-fleet follows a PRD → SPEC-N (brainstorm → spec → plan → implementation) pipeline. **16/16 phases done through v0.12.0.**
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@getpipher/armory-fleet",
"version": "0.12.5",
"version": "0.13.0",
"private": false,
"description": "The armory suite's subagent orchestrator for the pi coding agent \u2014 a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion src/backend/claude-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function createClaudeChildFactory(
if (!detector?.schemaOk) {
throw new Error(`claude backend unavailable: ${detector?.note ?? "schema not ok"}`);
}
const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(memoryScopesFor(opts.cwd)) : "";
const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(memoryScopesFor(opts.cwd, { includeUser: opts.agent.userMemory ?? false })) : "";
const sys = memoryBlock ? `${opts.rolePrompt}\n\n${memoryBlock}` : opts.rolePrompt;
const resumeId = resumeStore.get("claude", opts.agent.sessionKey);

Expand Down
10 changes: 6 additions & 4 deletions src/engine/child-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ export function composeChildPrompt(args: { rolePrompt: string; memoryBlock: stri
return [args.rolePrompt, args.memoryBlock, args.base].filter((s) => s && s.trim().length > 0).join("\n\n");
}

/** Build the three memory scopes for a child: project=cwd, local=parent dir, user=sentinel. */
export function memoryScopesFor(cwd: string): { project: string; local: string; user: string } {
return { project: cwd, local: dirname(cwd) || cwd, user: USER_PSEUDO_CWD };
/** Build the memory scopes for a child: project=cwd, local=parent dir; user only when opted in.
* #20/SPEC-6-5: the user pseudo-scope (`/__armory-fleet-user__`) is a cross-project bleed by
* construction — omit it unless the agent declares `userMemory: true`. */
export function memoryScopesFor(cwd: string, opts?: { includeUser?: boolean }): { project: string; local: string; user?: string } {
return { project: cwd, local: dirname(cwd) || cwd, ...(opts?.includeUser ? { user: USER_PSEUDO_CWD } : {}) };
}

/** #40: resolve extra skill dirs to scan for the child, beyond the default `~/.pi/agent/skills`.
Expand Down Expand Up @@ -73,7 +75,7 @@ export interface ChildLoaderOpts {

/** Build the fleet CustomResourceLoader for a child session. */
export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader {
const scopes = memoryScopesFor(opts.cwd);
const scopes = memoryScopesFor(opts.cwd, { includeUser: opts.agent.userMemory ?? false });
const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(scopes) : "";
return new DefaultResourceLoader({
cwd: opts.cwd,
Expand Down
4 changes: 4 additions & 0 deletions src/engine/run-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export interface RunRecord {
tier?: string;
/** SPEC-6-2: the cwd this run belongs to (widget cross-cwd filter + reconcile ownership). */
cwd: string;
/** SPEC-6-5: the session cwd the dispatch originated from (live; = parentCwd). Set at spawn.
* Lets the widget compute cross-cwd (`cwd !== sessionCwd`) for the ↗ glyph without re-reading
* the journal. Live-only counterpart to RunMetaEvent.sessionCwd. */
sessionCwd?: string;
/** SPEC-6-2: the backend (probe dispatch: pi→handle, claude→pid). */
backend: BackendId;
/** SPEC-6-2: claude-backend child PID (cross-process liveness probe). */
Expand Down
11 changes: 8 additions & 3 deletions src/engine/spawnSubagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ export interface SpawnOptions {
backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory — engine looks up by agentDef.backend
parentModel: { provider: string; id: string };
parentCwd: string;
/** SPEC-6-5: the dispatch target's working directory. Default = parentCwd (the session cwd, backward-compat).
* When set, all child-scoped sites use this cwd (factory.create, RunRecord.cwd, run:meta cwd);
* session-scoped audit (`sessionCwd`) keeps parentCwd. */
cwd?: string;
memoryPort?: MemoryHydratePort;
visionPort?: VisionPort;
signal?: AbortSignal;
Expand Down Expand Up @@ -216,6 +220,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
const track = opts.track ?? true;
const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
const startedAt = Date.now();
const childCwd = opts.cwd ?? opts.parentCwd;

// #31: read-only dispatches (review/audit/research) bypass the foreground single-slot lock —
// the caller asserts no cwd mutation, so the in-place edit-conflict guard doesn't apply and
Expand Down Expand Up @@ -288,7 +293,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
runId, agent: agentDef.name, model, task: opts.task, track,
todoId: null, status: "running", startedAt,
tier: tier?.name, costTotal: 0, contextTokens: 0,
cwd: opts.parentCwd, backend: backendId,
cwd: childCwd, sessionCwd: opts.parentCwd, backend: backendId,
});

// todo-sync (before) — only when both caller tracks AND agent allows todoSync
Expand All @@ -312,7 +317,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
for (const cand of candidates) {
try {
const result = await backend.factory.create({
cwd: opts.parentCwd,
cwd: childCwd,
model: cand,
thinkingLevel: childAgent.thinkingLevel,
tools,
Expand Down Expand Up @@ -378,7 +383,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
if (e.type === "session_init" && e.backendSessionId) {
opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
try {
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 });
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: childCwd, sessionCwd: opts.parentCwd, pid: (session as { proc?: { pid?: number } }).proc?.pid });
} catch { /* best-effort */ }
} else if (e.type === "turn_start") {
turnIdx++;
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
// A retryable provider failure (stopReason "error") retries once on this model even without a
// per-dispatch `modelFallback`. Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern.
deps.defaultModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
// SPEC-6-5: cross-cwd dispatch notify hook (wired per-session in session_start below).
// Placeholder; the real wiring happens in session_start where ctx is in scope.

// #31 tail: foreground concurrency is SESSION-LEVEL (a shared lock can't be re-sized per
// dispatch). cap=1 (default) is fail-fast (backward-compat); cap>1 enables a queueing pool so
Expand Down Expand Up @@ -308,6 +310,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
const m = ctx.model;
deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
deps.parentCwd = ctx.cwd;
deps.onNotify = (m, k) => ctx.ui.notify(m, k ?? "info");
// SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
const dir = fleetDir(ctx.cwd);
// SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
Expand Down
4 changes: 4 additions & 0 deletions src/lifecycle/lifecycle-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export interface LifecycleDef {
description: string;
/** Lifecycle-wide default backend; absent → "pi". */
backend: BackendId;
/** SPEC-6-5: pin this lifecycle to a target working directory. Absent → the entry-point cwd
* (the panel's chosen cwd, or the dispatching `subagent` tool's cwd/session cwd). When present,
* overrides the entry-point cwd for all phases. */
cwd?: string;
phases: PhaseDef[];
source: AgentSource;
filePath: string;
Expand Down
3 changes: 2 additions & 1 deletion src/lifecycle/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function parseLifecycleFile(content: string, filePath: string, source: Ag
throw new LifecycleParseError(`${filePath}: invalid backend '${rawBackend}' (must be 'pi' | 'claude')`);
}
const backend = rawBackend as BackendId;
const cwd = typeof raw.cwd === "string" && raw.cwd.trim() ? raw.cwd.trim() : undefined;

if (!Array.isArray(raw.phases) || raw.phases.length === 0) {
throw new LifecycleParseError(`${filePath}: phases must be a non-empty array`);
Expand Down Expand Up @@ -100,7 +101,7 @@ export function parseLifecycleFile(content: string, filePath: string, source: Ag
// The terminal phase never checkpoints after it (the lifecycle is done) — §5.4.
if (phases.length > 0) phases[phases.length - 1]!.checkpoint = false;

return { name, description, backend, phases, source, filePath };
return { name, description, backend, phases, source, filePath, ...(cwd ? { cwd } : {}) };
}

/** Split the markdown body into a map of phase-name → prompt-template, by `## <name>` H2 headings. */
Expand Down
9 changes: 8 additions & 1 deletion src/lifecycle/run-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface PhaseSpawnOpts {
skills: string[];
/** The resolved backend for this phase (phase.backend → lifecycle.backend → "pi"). */
backend: BackendId;
/** SPEC-6-5: the resolved lifecycle cwd (lifecycle.cwd ?? entryCwd) the phase child runs in. */
cwd?: string;
model?: string;
}
export type SpawnFn = (opts: PhaseSpawnOpts) => Promise<SpawnResult>;
Expand Down Expand Up @@ -57,6 +59,9 @@ export interface LifecycleRunOpts {
worktreePath?: string;
/** SPEC-5a: the base ref to diff against (default "HEAD"). */
baseRef?: string;
/** SPEC-6-5: the entry-point cwd (the panel's chosen cwd, or the dispatching subagent tool's
* cwd/session cwd). The lifecycle's `cwd` field, if present, overrides this. */
entryCwd?: string;
}

export interface LifecycleRunResult {
Expand Down Expand Up @@ -86,6 +91,8 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
const available = [...deps.registry.keys()].sort().join(", ");
return failResult("", startedAt, `lifecycle '${lifecycleName}' not found; available: ${available}`, lifecycleName, task, opts.mode, [], null);
}
// SPEC-6-5: lifecycle cwd field overrides the entry-point cwd (entryCwd).
const lifecycleCwd = lifecycle.cwd ?? opts.entryCwd;

const runId = deps.genRunId();
const lifecycleBackend = lifecycle.backend;
Expand Down Expand Up @@ -151,7 +158,7 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
// phase's skills (Q1=B) and routes to the phase's backend (Q4=C) — not the agent's defaults.
let spawnRes: import("../engine/spawnSubagent.ts").SpawnResult;
try {
spawnRes = await deps.spawn({ agent: agentName, task: prompt, lifecycleTodoId: todoId, skills, backend });
spawnRes = await deps.spawn({ agent: agentName, task: prompt, lifecycleTodoId: todoId, skills, backend, cwd: lifecycleCwd });
} catch (e) {
// spawn should return a failed result, not throw — but guard anyway so a throwing spawn
// can't orphan the lifecycle (treat as a phase failure).
Expand Down
2 changes: 1 addition & 1 deletion src/memory-hydrate/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { MemoryHydratePort, MemoryScopes } from "./port.ts";
export class ArmoryMemoryAdapter implements MemoryHydratePort {
renderScopes(scopes: MemoryScopes): string {
return [scopes.project, scopes.local, scopes.user]
.filter((cwd) => listMemory(cwd).length > 0) // skip empty scopes cleanly (no placeholder to render)
.filter((cwd): cwd is string => cwd != null && listMemory(cwd).length > 0) // skip empty scopes cleanly (no placeholder to render)
.map((cwd) => renderMemoryBlock(cwd)) // armory-memory's existing cwd-keyed primitive
.join("\n\n"); // → "" when all three empty
}
Expand Down
5 changes: 3 additions & 2 deletions src/memory-hydrate/port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ export interface MemoryScopes {
project: string;
/** Immediate parent directory of the project cwd (workspace/org level). */
local: string;
/** Fixed pseudo-cwd for global cross-project user memory. */
user: string;
/** Optional — only present when the agent opted in via `userMemory: true` (SPEC-6-5).
* The user scope is a cross-project memory bleed by construction; omitted unless explicitly enabled. */
user?: string;
}
export interface MemoryHydratePort {
/** Render the three-scope memory block (project → local → user), concatenated. Empty string when all scopes empty. */
Expand Down
40 changes: 30 additions & 10 deletions src/panel/fleet-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ export class FleetPanel extends Container {
private lcRunMode = false;
private lcTaskInput: Input | null = null;
private lcNameInput: Input | null = null;
private lcPhase: "task" | "name" = "task";
private lcCwdInput: Input | null = null;
private lcPhase: "task" | "name" | "cwd" = "task";
// SPEC-4: pending checkpoint (interactive Continue/Revise/Abort)
private pendingCheckpoint: { phase: PhaseRecord; resolve: (d: CheckpointDecision) => void } | null = null;
private lcReviseInput: Input | null = null;
Expand Down Expand Up @@ -359,10 +360,10 @@ export class FleetPanel extends Container {
this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
this.addChild(this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!);
this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
} else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
const prompt = this.lcPhase === "task" ? " task> " : " lifecycle name (blank=default)> ";
} else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput || this.lcCwdInput)) {
const prompt = this.lcPhase === "task" ? " task> " : this.lcPhase === "name" ? " lifecycle name (blank=default)> " : " cwd (blank=session cwd)> ";
this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
this.addChild(this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!);
this.addChild(this.lcPhase === "task" ? this.lcTaskInput! : this.lcPhase === "name" ? this.lcNameInput! : this.lcCwdInput!);
this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
} else if (this.pendingCheckpoint && !this.lcRevising) {
const pc = this.pendingCheckpoint;
Expand Down Expand Up @@ -586,9 +587,9 @@ export class FleetPanel extends Container {
this.invalidate();
return;
}
if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput || this.lcCwdInput)) {
if (matchesKey(data, "escape")) { this.cancelLifecycleRun(); return; }
(this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!).handleInput(data);
(this.lcPhase === "task" ? this.lcTaskInput! : this.lcPhase === "name" ? this.lcNameInput! : this.lcCwdInput!).handleInput(data);
this.invalidate();
return;
}
Expand Down Expand Up @@ -1131,9 +1132,18 @@ export class FleetPanel extends Container {
this.lcNameInput = new Input();
this.lcNameInput.onSubmit = (name: string) => {
const lcName = name.trim() || "default";
void this.executeLifecycleRun(task.trim(), lcName);
this.lcPhase = "cwd";
this.lcCwdInput = new Input();
// SPEC-6-5: 3rd input step — the dispatch cwd. Prefilled with the session cwd; Enter
// accepts it, Escape accepts the default (mirrors the name step's Escape-accepts-default).
this.lcCwdInput.onSubmit = (cwd: string) => {
const picked = cwd.trim() || this.deps.parentCwd;
void this.executeLifecycleRun(task.trim(), lcName, picked);
};
this.lcCwdInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), lcName, this.deps.parentCwd); };
this.renderShell();
};
this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default"); };
this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default", this.deps.parentCwd); };
this.renderShell();
};
this.lcTaskInput.onEscape = () => this.cancelLifecycleRun();
Expand All @@ -1145,18 +1155,27 @@ export class FleetPanel extends Container {
this.lcRunMode = false;
this.lcTaskInput = null;
this.lcNameInput = null;
this.lcCwdInput = null;
this.renderShell();
}

private async executeLifecycleRun(task: string, lifecycleName: string): Promise<void> {
private async executeLifecycleRun(task: string, lifecycleName: string, cwd: string): Promise<void> {
this.lcRunMode = false;
this.lcTaskInput = null;
this.lcNameInput = null;
this.lcCwdInput = null;
this.renderShell();
if (!this.deps.lifecycleRegistry.has(lifecycleName)) {
this.onNotify(`lifecycle '${lifecycleName}' not found; available: ${[...this.deps.lifecycleRegistry.keys()].sort().join(", ")}`, "error");
return;
}
// SPEC-6-5: validate the chosen cwd (exists + is a dir) before spawning; surface cross-cwd.
const { resolveDispatchCwd } = await import("../tools/subagent.ts");
const { cwd: resolvedCwd, error: cwdErr } = resolveDispatchCwd(cwd, this.deps.parentCwd);
if (cwdErr) { this.onNotify(cwdErr, "error"); return; }
if (resolvedCwd && resolvedCwd !== this.deps.parentCwd) {
this.onNotify("scoped to " + resolvedCwd + " (≠ session " + this.deps.parentCwd + ")", "info");
}
const onCheckpoint: CheckpointFn = (phase) => new Promise<CheckpointDecision>((resolve) => {
this.pendingCheckpoint = { phase, resolve };
this.renderShell();
Expand All @@ -1171,10 +1190,11 @@ export class FleetPanel extends Container {
registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry, lock: this.deps.lock,
backendRegistry: this.deps.backendRegistry, parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
runLog: this.deps.runLog,
cwd: o.cwd,
});
},
};
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: "checkpointed", onCheckpoint });
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: "checkpointed", onCheckpoint, entryCwd: resolvedCwd });
this.pendingCheckpoint = null;
// record the run so the Lifecycle view shows it
this.deps.lifecycleRuns.set(res.runId, res);
Expand Down
Loading
Loading