diff --git a/README.md b/README.md index 67e8248..18a9bc1 100644 --- a/README.md +++ b/README.md @@ -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 `↗` 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.** diff --git a/package.json b/package.json index d1802b6..bcc0c23 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/backend/claude-factory.ts b/src/backend/claude-factory.ts index 1dd9626..3a7ef07 100644 --- a/src/backend/claude-factory.ts +++ b/src/backend/claude-factory.ts @@ -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); diff --git a/src/engine/child-loader.ts b/src/engine/child-loader.ts index 2a8110f..b3911b1 100644 --- a/src/engine/child-loader.ts +++ b/src/engine/child-loader.ts @@ -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`. @@ -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, diff --git a/src/engine/run-registry.ts b/src/engine/run-registry.ts index f02be9e..8769dad 100644 --- a/src/engine/run-registry.ts +++ b/src/engine/run-registry.ts @@ -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). */ diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index b8857fe..9b8565d 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -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; @@ -216,6 +220,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { 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 @@ -288,7 +293,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { 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 @@ -312,7 +317,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { for (const cand of candidates) { try { const result = await backend.factory.create({ - cwd: opts.parentCwd, + cwd: childCwd, model: cand, thinkingLevel: childAgent.thinkingLevel, tools, @@ -378,7 +383,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { 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++; diff --git a/src/index.ts b/src/index.ts index 4c6ff07..598b674 100644 --- a/src/index.ts +++ b/src/index.ts @@ -205,6 +205,8 @@ export default async function (pi: ExtensionAPI): Promise { // 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 @@ -308,6 +310,7 @@ export default async function (pi: ExtensionAPI): Promise { 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 diff --git a/src/lifecycle/lifecycle-types.ts b/src/lifecycle/lifecycle-types.ts index 811ff74..8bf2f48 100644 --- a/src/lifecycle/lifecycle-types.ts +++ b/src/lifecycle/lifecycle-types.ts @@ -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; diff --git a/src/lifecycle/registry.ts b/src/lifecycle/registry.ts index 9c533f0..3487d35 100644 --- a/src/lifecycle/registry.ts +++ b/src/lifecycle/registry.ts @@ -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`); @@ -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 `## ` H2 headings. */ diff --git a/src/lifecycle/run-lifecycle.ts b/src/lifecycle/run-lifecycle.ts index 1f79d32..d7171ef 100644 --- a/src/lifecycle/run-lifecycle.ts +++ b/src/lifecycle/run-lifecycle.ts @@ -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; @@ -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 { @@ -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; @@ -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). diff --git a/src/memory-hydrate/adapter.ts b/src/memory-hydrate/adapter.ts index 045507b..67aed6a 100644 --- a/src/memory-hydrate/adapter.ts +++ b/src/memory-hydrate/adapter.ts @@ -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 } diff --git a/src/memory-hydrate/port.ts b/src/memory-hydrate/port.ts index 68fe544..647fd4b 100644 --- a/src/memory-hydrate/port.ts +++ b/src/memory-hydrate/port.ts @@ -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. */ diff --git a/src/panel/fleet-panel.ts b/src/panel/fleet-panel.ts index e88d305..c4c3e9e 100644 --- a/src/panel/fleet-panel.ts +++ b/src/panel/fleet-panel.ts @@ -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; @@ -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; @@ -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; } @@ -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(); @@ -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 { + private async executeLifecycleRun(task: string, lifecycleName: string, cwd: string): Promise { 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((resolve) => { this.pendingCheckpoint = { phase, resolve }; this.renderShell(); @@ -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); diff --git a/src/panel/widget-rows.ts b/src/panel/widget-rows.ts index fb1f124..7916b68 100644 --- a/src/panel/widget-rows.ts +++ b/src/panel/widget-rows.ts @@ -8,6 +8,7 @@ // list below editor" intent was never achievable via pi widgets (editor keeps keyboard focus). // `/fleet` is the navigable action surface; this one above-editor widget is the glance surface. import { fmtDuration, fmtTokens } from "./rows.ts"; +import { basename } from "node:path"; import type { RunRecord } from "../engine/run-registry.ts"; import type { BgRunStatus } from "./rows.ts"; @@ -55,6 +56,10 @@ export interface WidgetRun { lastEventClass?: string; /** #23: liveness — timestamp (ms) of the last event ("events still arriving?"). */ lastEventAt?: number; + /** SPEC-6-5: the run's (child) cwd — from RunRecord.cwd. */ + cwd?: string; + /** SPEC-6-5: the session cwd (parentCwd) — from RunRecord.sessionCwd. When cwd !== sessionCwd the widget shows a ↗ glyph. */ + sessionCwd?: string; } export function toWidgetRun(r: RunRecord): WidgetRun { @@ -64,6 +69,7 @@ export function toWidgetRun(r: RunRecord): WidgetRun { kind: "fg", task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens, substrateBaseline: r.substrateBaseline, turnCount: r.turnCount, turnMax: r.turnMax, lastEventClass: r.lastEventClass, lastEventAt: r.lastEventAt, + cwd: r.cwd, sessionCwd: r.sessionCwd, }; } @@ -111,6 +117,9 @@ function widgetLine(r: WidgetRun, now: number): string { // fg: task excerpt as primary label (fallback to runId if no task) const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId; + // SPEC-6-5: cross-cwd glyph — when the run's cwd differs from the session cwd, mark it so the + // operator sees "this run is scoped to a different project" at a glance. Same-cwd → no glyph. + const crossCwd = (r.cwd && r.sessionCwd && r.cwd !== r.sessionCwd) ? ` ↗${basename(r.cwd)}` : ""; const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : ""; // #23: liveness — only after LIVENESS_THRESHOLD_MS, to keep short runs concise (per acceptance). // turn N/max + last-event class (no prompt content, no args/results — only the tool name) @@ -133,7 +142,7 @@ function widgetLine(r: WidgetRun, now: number): string { const growth = (r.contextTokens - r.substrateBaseline) / r.substrateBaseline; substrate = growth <= SUBSTRATE_GROWTH_THRESHOLD ? " substrate" : " work"; } - return `${glyph} ${label}${agentSeg}${dur}${liveness}${tok}${ctx}${substrate}${cost}`; + return `${glyph} ${label}${crossCwd}${agentSeg}${dur}${liveness}${tok}${ctx}${substrate}${cost}`; } /** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet". diff --git a/src/registry/frontmatter.ts b/src/registry/frontmatter.ts index 9fa1a36..8d6c681 100644 --- a/src/registry/frontmatter.ts +++ b/src/registry/frontmatter.ts @@ -16,6 +16,10 @@ export interface AgentDef { todoSync: boolean; memoryHydrate: boolean; vision: boolean; + /** #20/SPEC-6-5: opt in to the global cross-project user memory scope (`/__armory-fleet-user__`). + * Default false — the user scope is a cross-project bleed by construction; hydrate it only when + * an agent explicitly declares `userMemory: true`. Only meaningful when `memoryHydrate: true`. */ + userMemory: boolean; /** Cross-harness backend routing (SPEC-3). Invalid value → FrontmatterError. */ backend: "pi" | "claude"; /** Stable id for backend-native resume (SPEC-3). Defaults to name. */ @@ -57,6 +61,7 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS const todoSync = raw.todoSync === undefined ? true : Boolean(raw.todoSync); const memoryHydrate = raw.memoryHydrate === undefined ? true : Boolean(raw.memoryHydrate); const vision = raw.vision === undefined ? true : Boolean(raw.vision); + const userMemory = raw.userMemory === undefined ? false : Boolean(raw.userMemory); const rawBackend = typeof raw.backend === "string" ? raw.backend.trim() : "pi"; if (rawBackend !== "pi" && rawBackend !== "claude") { @@ -77,6 +82,7 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS todoSync, memoryHydrate, vision, + userMemory, backend, sessionKey, source, diff --git a/src/runtime/run-log.ts b/src/runtime/run-log.ts index ac1ea3a..ba99a6e 100644 --- a/src/runtime/run-log.ts +++ b/src/runtime/run-log.ts @@ -15,6 +15,8 @@ export interface RunMetaEvent { pid?: number; /** SPEC-6-2: the cwd this run belongs to. */ cwd?: string; + /** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */ + sessionCwd?: string; } export interface MessageEvent { type: "message"; role: string; text: string; @@ -49,6 +51,8 @@ export interface RunMeta { pid?: number; /** SPEC-6-2: the cwd this run belongs to. */ cwd?: string; + /** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */ + sessionCwd?: string; } const ARGS_LIMIT = 200; @@ -104,7 +108,7 @@ export class RunLog { if (!meta) { meta = { runId: e.runId, agent: e.agent, model: e.model, task: e.task, startedAt: e.startedAt, track: e.track, todoId: e.todoId, backendSessionId: e.backendSessionId, sessionKey: e.sessionKey, - status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd }; + status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd, sessionCwd: e.sessionCwd }; } else { // latest binding wins if (e.backendSessionId) meta.backendSessionId = e.backendSessionId; diff --git a/src/tools/subagent.ts b/src/tools/subagent.ts index fbcf180..4b1ee79 100644 --- a/src/tools/subagent.ts +++ b/src/tools/subagent.ts @@ -1,4 +1,6 @@ // src/tools/subagent.ts +import { resolve } from "node:path"; +import { statSync } from "node:fs"; import { Type, type Static } from "typebox"; import type { AgentDef } from "../registry/frontmatter.ts"; import type { TodoSyncPort } from "../todo-sync/port.ts"; @@ -32,6 +34,7 @@ export const subagentParams = Type.Object({ readOnly: Type.Optional(Type.Boolean({ description: 'Default false. Pass true ONLY for dispatches that will NOT mutate the working directory (review/audit, or research that writes no scratch files). A readOnly dispatch bypasses the foreground single-slot lock so multiple readOnly dispatches — and/or a readOnly alongside a write dispatch — can run in parallel. The caller is responsible for the assertion: mislabeling a dispatch that edits as readOnly risks in-place edit conflicts. Has no effect on background/scheduled runs (they use their own locks).' })), skills: Type.Optional(Type.Array(Type.String(), { description: 'Skills to load for this dispatch (opt-in). By default a dispatch loads NO skills (#32 — lean substrate; previously an agent with no skills field loaded ALL ~42 installed skills, ~570K tokens / ~59% of context). Pass skill names from the installed arsenal (e.g. ["executing-plans", "test-driven-development"]) to opt in. For a direct dispatch, this replaces the agent\'s frontmatter skills (pass [] to load zero). For a lifecycle dispatch, this is ADDITIVE — the phase\'s designed skill bundle always loads and these are merged on top (a caller cannot strip a phase\'s required skills).' })), modelFallback: Type.Optional(Type.String({ description: 'Model to retry with if the primary dispatch fails with a retryable provider rate-limit / auth failure (stopReason "error"). The fleet retries ONCE on this model and relinks the same tracked todo. Surface the model that served the retry in the result details (retriedWithModel). Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern. No effect on non-retryable failures (turn budget, agent-not-found, abort). Direct foreground dispatches only — background/scheduled/lifecycle retries are a follow-up.' })), + cwd: Type.Optional(Type.String({ description: 'The dispatch target\'s working directory. Default: the session cwd (backward-compat). Scoped to this path: the child\'s working dir, context-file cascade, skill discovery, and memory scopes. Accepts paths OUTSIDE the session cwd (a sibling repo) — that\'s the #20 fix. Relative paths resolve against the session cwd.' })), }); export type SubagentInput = Static; @@ -44,6 +47,19 @@ export function mergeLifecycleSkills(phaseSkills: string[] | undefined, callerSk return [...new Set([...(phaseSkills ?? []), ...(callerSkills ?? [])])]; } +/** SPEC-6-5: validate + resolve a dispatch cwd. Returns { cwd } on success or { error } on failure. */ +export function resolveDispatchCwd(raw: string | undefined, parentCwd: string): { cwd?: string; error?: string } { + if (raw === undefined || raw === "") return { cwd: undefined }; // default → parentCwd (handled by spawnSubagent) + const abs = resolve(parentCwd, raw); + try { + const st = statSync(abs); + if (!st.isDirectory()) return { error: `cwd is not a directory: ${abs}` }; + return { cwd: abs }; + } catch { + return { error: `cwd does not exist: ${abs}` }; + } +} + export interface SubagentToolDeps { registry: Map; runRegistry: RunRegistry; @@ -80,6 +96,8 @@ export interface SubagentToolDeps { * Per-dispatch `modelFallback` (when passed) takes precedence. Applies to the direct foreground * path, the foreground lifecycle spawn, and the background/scheduled spawn. */ defaultModelFallback?: string; + /** SPEC-6-5: notify hook for cross-cwd dispatch surfacing. Wired from ctx.ui.notify in index.ts. */ + onNotify?: (message: string, kind?: "info" | "warning" | "error") => void; } /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */ @@ -101,6 +119,12 @@ export function createSubagentTool(deps: SubagentToolDeps) { ], parameters: subagentParams, async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, _ctx: any) { + // SPEC-6-5: validate + resolve the dispatch cwd before any routing. + const { cwd: resolvedCwd, error: cwdErr } = resolveDispatchCwd(params.cwd, deps.parentCwd); + if (cwdErr) return { isError: true, content: [{ type: "text" as const, text: cwdErr }] }; + if (resolvedCwd && resolvedCwd !== deps.parentCwd) { + deps.onNotify?.(`scoped to ${resolvedCwd} (≠ session ${deps.parentCwd})`, "info"); + } // SPEC-5a: background + schedule routing (Q1/Q2/Q5). if (params.background && params.schedule) { return { isError: true, content: [{ type: "text" as const, text: "A scheduled run is inherently background — pass only one of `background` or `schedule`, not both." }] }; @@ -132,10 +156,12 @@ export function createSubagentTool(deps: SubagentToolDeps) { maxTurns: params.maxTurns, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, readOnly: params.readOnly, + cwd: o.cwd, }), params.modelFallback ?? deps.defaultModelFallback, signal), }; const res = await runLifecycle(params.task, params.lifecycle, { deps: lifecycleFullDeps, mode: "auto", + entryCwd: resolvedCwd, onCheckpoint: async (phase) => phase.status === "failed" ? { action: "abort" } : { action: "continue" }, }); const isError = res.status === "failed" || res.status === "aborted"; @@ -166,6 +192,7 @@ export function createSubagentTool(deps: SubagentToolDeps) { signal, maxTurns: params.maxTurns, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, + cwd: resolvedCwd, }); // #39: auto-retry on a retryable provider rate-limit / auth failure (stopReason "error"). // The primary run reverted its linked todo to open (finishRun -> markRunTodoReverted), so the @@ -200,6 +227,7 @@ export function createSubagentTool(deps: SubagentToolDeps) { signal, maxTurns: params.maxTurns, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, + cwd: resolvedCwd, }); retriedWithModel = fallback; } diff --git a/test/child-loader.test.mts b/test/child-loader.test.mts index bd0a223..40806de 100644 --- a/test/child-loader.test.mts +++ b/test/child-loader.test.mts @@ -18,11 +18,21 @@ test("USER_PSEUDO_CWD is a stable sentinel", () => { assert.equal(USER_PSEUDO_CWD, "/__armory-fleet-user__"); }); -test("memoryScopesFor: project=cwd, local=parent dir, user=sentinel", () => { +test("memoryScopesFor: project=cwd, local=parent dir, user omitted by default", () => { const s = memoryScopesFor("/Users/x/local-dev/getpipher/armory-fleet"); assert.equal(s.project, "/Users/x/local-dev/getpipher/armory-fleet"); assert.equal(s.local, "/Users/x/local-dev/getpipher"); - assert.equal(s.user, USER_PSEUDO_CWD); + assert.equal(s.user, undefined, "user omitted by default (SPEC-6-5)"); +}); + +test("memoryScopesFor: includes user when includeUser: true", () => { + const s = memoryScopesFor("/Users/x/local-dev/getpipher/armory-fleet", { includeUser: true }); + assert.equal(s.user, USER_PSEUDO_CWD, "user sentinel present when includeUser"); +}); + +test("memoryScopesFor: omits user when includeUser: false", () => { + const s = memoryScopesFor("/Users/x/local-dev/getpipher/armory-fleet", { includeUser: false }); + assert.equal(s.user, undefined, "user omitted when includeUser: false"); }); test("#32 resolveChildSkills: agent with NO skills loads NO skills (not all installed)", () => { diff --git a/test/claude-factory.test.mts b/test/claude-factory.test.mts index 55b3810..860a0f7 100644 --- a/test/claude-factory.test.mts +++ b/test/claude-factory.test.mts @@ -19,7 +19,7 @@ beforeEach(() => { root = mkdtempSync(join(tmpdir(), "fleet-cc-factory-")); proc afterEach(() => { rmSync(root, { recursive: true, force: true }); delete process.env.FLEET_RESUME_ROOT; }); const agent = (over: Partial = {}): AgentDef => ({ - name: "cc", description: "d", rolePrompt: "you are cc", todoSync: true, memoryHydrate: true, vision: true, + name: "cc", description: "d", rolePrompt: "you are cc", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "claude", sessionKey: "cc", source: "builtin", filePath: "/x", ...over, }); diff --git a/test/frontmatter.test.mts b/test/frontmatter.test.mts index 3f005e2..211c834 100644 --- a/test/frontmatter.test.mts +++ b/test/frontmatter.test.mts @@ -82,4 +82,19 @@ test("parses optional tier field (SPEC-6-1)", () => { test("tier absent → undefined (back-compat)", () => { const a = parseAgentFile(BASE, "/x/.pi/agents/scout.md", "project"); strictEqual(a.tier, undefined); +}); + +test("userMemory defaults to false when absent", () => { + const a = parseAgentFile("---\nname: a\ndescription: d\n---\nrole", "/tmp/agent.md", "builtin"); + strictEqual(a.userMemory, false, "userMemory defaults false"); +}); + +test("userMemory: true parses true", () => { + const a = parseAgentFile("---\nname: a\ndescription: d\nuserMemory: true\n---\nrole", "/tmp/agent.md", "builtin"); + strictEqual(a.userMemory, true); +}); + +test("userMemory: false parses false", () => { + const a = parseAgentFile("---\nname: a\ndescription: d\nuserMemory: false\n---\nrole", "/tmp/agent.md", "builtin"); + strictEqual(a.userMemory, false); }); \ No newline at end of file diff --git a/test/lifecycle-registry.test.mts b/test/lifecycle-registry.test.mts index a17389b..9ede5ff 100644 --- a/test/lifecycle-registry.test.mts +++ b/test/lifecycle-registry.test.mts @@ -190,3 +190,14 @@ test("port re-exports the public surface", async () => { ok(typeof port.discoverLifecycles === "function"); ok(port.LifecycleParseError); }); + +test("SPEC-6-5: parses lifecycle cwd field", () => { + const withCwd = GOOD.replace("backend: pi\n", "backend: pi\ncwd: /target-repo\n"); + const def = parseLifecycleFile(withCwd, "/x/default.md", "builtin"); + ok(def.cwd === "/target-repo", `cwd parsed: ${def.cwd}`); +}); + +test("SPEC-6-5: lifecycle cwd optional (absent -> undefined)", () => { + const def = parseLifecycleFile(GOOD, "/x/default.md", "builtin"); + ok(def.cwd === undefined, `absent cwd -> undefined: ${def.cwd}`); +}); diff --git a/test/panel-spec2.test.mts b/test/panel-spec2.test.mts index a9e5b7b..f1db510 100644 --- a/test/panel-spec2.test.mts +++ b/test/panel-spec2.test.mts @@ -7,7 +7,7 @@ import type { AgentDef } from "../src/registry/frontmatter.ts"; const agent: AgentDef = { name: "reviewer", description: "reviews code", model: "anthropic/claude-sonnet-4", tools: ["read", "bash"], skills: ["tdd"], rolePrompt: "You are a reviewer.", - todoSync: true, memoryHydrate: true, vision: false, backend: "pi", sessionKey: "reviewer", source: "project", filePath: "/x/reviewer.md", + todoSync: true, memoryHydrate: true, vision: false, userMemory: false, backend: "pi", sessionKey: "reviewer", source: "project", filePath: "/x/reviewer.md", }; test("agentsRow shows the armory chip [t✓ m✓ v✗]", () => { diff --git a/test/panel-spec3.test.mts b/test/panel-spec3.test.mts index d6c4500..85ea054 100644 --- a/test/panel-spec3.test.mts +++ b/test/panel-spec3.test.mts @@ -34,7 +34,7 @@ test("backendInfo enumerates fields + hook mechanism notes", () => { }); test("agentsRow includes the backend badge", () => { - const a: AgentDef = { name: "g", description: "d", model: "m", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, backend: "claude", sessionKey: "g", source: "builtin", filePath: "/x" }; + const a: AgentDef = { name: "g", description: "d", model: "m", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "claude", sessionKey: "g", source: "builtin", filePath: "/x" }; const r = agentsRow(a); ok(r.includes("[claude]")); ok(r.includes("t✓ m✓ v✓")); // chip still reflects agent toggles (per-hook), backend parity is separate diff --git a/test/pi-factory-resume.test.mts b/test/pi-factory-resume.test.mts index b7af263..75f452b 100644 --- a/test/pi-factory-resume.test.mts +++ b/test/pi-factory-resume.test.mts @@ -23,7 +23,7 @@ afterEach(() => { }); const agent = (over: Partial = {}): AgentDef => ({ - name: "g", description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, + name: "g", description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x", ...over, }); diff --git a/test/resolve-model.test.mts b/test/resolve-model.test.mts index 2e908db..4858e9b 100644 --- a/test/resolve-model.test.mts +++ b/test/resolve-model.test.mts @@ -5,7 +5,7 @@ import { TierRegistry } from "../src/tiers/tier-registry.ts"; import type { AgentDef } from "../src/registry/frontmatter.ts"; const agent = (over: Partial = {}): AgentDef => ({ - name: "g", description: "d", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, + name: "g", description: "d", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x", ...over, }); const PARENT = { provider: "p", id: "m" }; diff --git a/test/rows.test.mts b/test/rows.test.mts index ae451e6..73afe7c 100644 --- a/test/rows.test.mts +++ b/test/rows.test.mts @@ -36,7 +36,7 @@ test("fleetRow ctxPercent", () => { }); test("agentsRow includes name, source, model, armory chip", () => { - const a: AgentDef = { name: "scout", description: "d", model: "anthropic/claude-sonnet-4", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, backend: "pi", sessionKey: "scout", source: "project", filePath: "/x" }; + const a: AgentDef = { name: "scout", description: "d", model: "anthropic/claude-sonnet-4", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "pi", sessionKey: "scout", source: "project", filePath: "/x" }; const r = agentsRow(a); ok(r.includes("scout"), r); ok(r.includes("[project]"), r); @@ -45,7 +45,7 @@ test("agentsRow includes name, source, model, armory chip", () => { }); test("agentsRow default model + tools/skills omitted", () => { - const a: AgentDef = { name: "g", description: "d", rolePrompt: "r", todoSync: false, memoryHydrate: false, vision: false, backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x" }; + const a: AgentDef = { name: "g", description: "d", rolePrompt: "r", todoSync: false, memoryHydrate: false, vision: false, userMemory: false, backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x" }; const r = agentsRow(a); ok(r.includes("(default)"), r); ok(r.includes("armory:[t✗ m✗ v✗]"), r); diff --git a/test/run-lifecycle.test.mts b/test/run-lifecycle.test.mts index 96d0ebb..0c7e394 100644 --- a/test/run-lifecycle.test.mts +++ b/test/run-lifecycle.test.mts @@ -22,7 +22,7 @@ phase c `; const agent: AgentDef = { - name: "general-purpose", description: "x", rolePrompt: "", todoSync: true, memoryHydrate: false, vision: false, + name: "general-purpose", description: "x", rolePrompt: "", todoSync: true, memoryHydrate: false, vision: false, userMemory: false, backend: "pi", sessionKey: "general-purpose", source: "builtin", filePath: "/x.md", }; @@ -434,3 +434,32 @@ test("gate chain: gate (cost) abort → lifecycle failed, no checkpoint", async strictEqual(checkpointCalled, false, "checkpoint NOT called on abort"); ok(res.error?.includes("cost"), "error mentions cost"); }); + +test("SPEC-6-5: lifecycle cwd overrides entryCwd in spawn calls", async () => { + const spawnedCwds: (string | undefined)[] = []; + const lcSrcWithCwd = LC_SRC.replace("backend: pi\n", "backend: pi\ncwd: /lifecycle-target\n"); + const deps: LifecycleRunDeps = { + registry: new Map([["test-lc", parseLifecycleFile(lcSrcWithCwd, "/x/test-lc.md", "builtin")]]), + agentRegistry: new Map([["general-purpose", agent]]), + spawn: async (opts) => { spawnedCwds.push(opts.cwd); return { status: "completed" as const, finalText: "done", runId: "fl-x", todoId: opts.lifecycleTodoId, agent: "general-purpose", model: "m", durationMs: 1, tokenTotal: 0 }; }, + todoPort: { async linkOrCreateRunTodo() { return { todoId: "td" }; }, async markRunTodoDone() {}, async markRunTodoReverted() {}, async updateLifecycleProgress() {} }, + resolveBackend: () => "pi", + genRunId: () => "fl-test", + }; + await runLifecycle("task", "test-lc", { deps, mode: "auto", onCheckpoint: async () => ({ action: "continue" }), entryCwd: "/session" }); + ok(spawnedCwds.length > 0 && spawnedCwds.every((c) => c === "/lifecycle-target"), `phases spawned in lifecycle cwd (overrides entryCwd): ${JSON.stringify(spawnedCwds)}`); +}); + +test("SPEC-6-5: absent lifecycle cwd -> entryCwd used in spawn calls", async () => { + const spawnedCwds: (string | undefined)[] = []; + const deps: LifecycleRunDeps = { + registry: new Map([["test-lc", parseLifecycleFile(LC_SRC, "/x/test-lc.md", "builtin")]]), + agentRegistry: new Map([["general-purpose", agent]]), + spawn: async (opts) => { spawnedCwds.push(opts.cwd); return { status: "completed" as const, finalText: "done", runId: "fl-x", todoId: opts.lifecycleTodoId, agent: "general-purpose", model: "m", durationMs: 1, tokenTotal: 0 }; }, + todoPort: { async linkOrCreateRunTodo() { return { todoId: "td" }; }, async markRunTodoDone() {}, async markRunTodoReverted() {}, async updateLifecycleProgress() {} }, + resolveBackend: () => "pi", + genRunId: () => "fl-test", + }; + await runLifecycle("task", "test-lc", { deps, mode: "auto", onCheckpoint: async () => ({ action: "continue" }), entryCwd: "/session" }); + ok(spawnedCwds.length > 0 && spawnedCwds.every((c) => c === "/session"), `phases spawned in entryCwd: ${JSON.stringify(spawnedCwds)}`); +}); diff --git a/test/run-log.test.mts b/test/run-log.test.mts index 7ca55f9..4de9368 100644 --- a/test/run-log.test.mts +++ b/test/run-log.test.mts @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { RunLog, excerpt, buildToolEvent } from "../src/runtime/run-log.ts"; +import { RunRegistry } from "../src/engine/run-registry.ts"; function makeDir(): string { return mkdtempSync(join(tmpdir(), "runlog-test-")); } @@ -102,3 +103,20 @@ test("run:meta: pid + cwd round-trip through scanMeta", () => { strictEqual((metas[0] as any).cwd, "/repo"); rmSync(tmp, { recursive: true, force: true }); }); + +test("RunRecord + run:meta carry sessionCwd (SPEC-6-5)", () => { + const dir = mkdtempSync(join(tmpdir(), "fleet-rl-")); + const log = new RunLog(dir); + const reg = new RunRegistry(); + reg.add({ runId: "fl-x", agent: "g", model: "m", task: "t", track: false, todoId: null, + status: "running", startedAt: 1, cwd: "/child", sessionCwd: "/session", backend: "pi" }); + const rec = reg.get("fl-x")!; + assert.equal(rec.cwd, "/child", "child cwd"); + assert.equal(rec.sessionCwd, "/session", "session cwd"); + log.append("fl-x", { type: "run:meta", runId: "fl-x", agent: "g", model: "m", task: "t", + startedAt: 1, track: false, todoId: null, cwd: "/child", sessionCwd: "/session" }); + const metas = new RunLog(dir).scanMeta(); + assert.equal(metas[0]!.cwd, "/child"); + assert.equal(metas[0]!.sessionCwd, "/session"); + rmSync(dir, { recursive: true, force: true }); +}); diff --git a/test/spawn-subagent-runlog.test.mts b/test/spawn-subagent-runlog.test.mts index 54063f9..a1f59bf 100644 --- a/test/spawn-subagent-runlog.test.mts +++ b/test/spawn-subagent-runlog.test.mts @@ -33,7 +33,7 @@ afterEach(() => { }); const agent = (name = "g"): AgentDef => ({ - name, description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, + name, description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "pi", sessionKey: name, source: "builtin", filePath: "/x", }); diff --git a/test/spawn-subagent-spec2.test.mts b/test/spawn-subagent-spec2.test.mts index 403d0c7..d673c18 100644 --- a/test/spawn-subagent-spec2.test.mts +++ b/test/spawn-subagent-spec2.test.mts @@ -17,7 +17,7 @@ function regWith(factory: ChildSessionFactory): BackendRegistry { } -const agent: AgentDef = { name: "general-purpose", description: "", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, backend: "pi", sessionKey: "general-purpose", source: "builtin", filePath: "x" }; +const agent: AgentDef = { name: "general-purpose", description: "", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "pi", sessionKey: "general-purpose", source: "builtin", filePath: "x" }; const memPort = { renderScopes: () => "## Memory\nblock" } as any; const visPort = { isMultimodal: () => false, isConfigured: () => true, delegate: async () => ({ ok: true, text: "desc" }) } as any; diff --git a/test/spawn-subagent-spec3.test.mts b/test/spawn-subagent-spec3.test.mts index 0299c3c..ebdf9af 100644 --- a/test/spawn-subagent-spec3.test.mts +++ b/test/spawn-subagent-spec3.test.mts @@ -21,7 +21,7 @@ afterEach(() => { }); const agent = (name = "g", backend: "pi" | "claude" = "pi"): AgentDef => ({ - name, description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, + name, description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend, sessionKey: name, source: "builtin", filePath: "/x", }); diff --git a/test/spawn-subagent-spec4.test.mts b/test/spawn-subagent-spec4.test.mts index e285fa7..f6d7dba 100644 --- a/test/spawn-subagent-spec4.test.mts +++ b/test/spawn-subagent-spec4.test.mts @@ -28,7 +28,7 @@ function fakeBackend(finalText: string): Backend { } const agent: AgentDef = { - name: "general-purpose", description: "x", rolePrompt: "", todoSync: true, memoryHydrate: false, vision: false, + name: "general-purpose", description: "x", rolePrompt: "", todoSync: true, memoryHydrate: false, vision: false, userMemory: false, backend: "pi", sessionKey: "general-purpose", source: "builtin", filePath: "/x.md", }; diff --git a/test/spawn-subagent-steer.test.mts b/test/spawn-subagent-steer.test.mts index ca41845..d5d0a0f 100644 --- a/test/spawn-subagent-steer.test.mts +++ b/test/spawn-subagent-steer.test.mts @@ -29,7 +29,7 @@ afterEach(() => { }); const agent = (name = "g"): AgentDef => ({ - name, description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, + name, description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "pi", sessionKey: name, source: "builtin", filePath: "/x", }); diff --git a/test/spawn-subagent-tier.test.mts b/test/spawn-subagent-tier.test.mts index 08cff12..9958116 100644 --- a/test/spawn-subagent-tier.test.mts +++ b/test/spawn-subagent-tier.test.mts @@ -16,7 +16,7 @@ let tmpDir: string, logDir: string; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "fleet-tier-")); logDir = mkdtempSync(join(tmpdir(), "fleet-tlog-")); process.env.TODO_DIR = tmpDir; }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); rmSync(logDir, { recursive: true, force: true }); delete process.env.TODO_DIR; }); -const agent = (over: Partial = {}): AgentDef => ({ name: "g", description: "d", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x", ...over }); +const agent = (over: Partial = {}): AgentDef => ({ name: "g", description: "d", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x", ...over }); const PARENT = { provider: "p", id: "m" }; const mr = (windows: Record): ModelRegistryLike => ({ find: (pr, id) => { const w = windows[`${pr}/${id}`]; return w != null ? { contextWindow: w } : undefined; } }); function regWith(factory: ChildSessionFactory): BackendRegistry { const r = new BackendRegistry(); r.register({ id: "pi", factory, available: () => true, versionInfo: () => null, hookParity: PI_HOOK_PARITY }); return r; } diff --git a/test/spawnSubagent.test.mts b/test/spawnSubagent.test.mts index 54dadef..de294b3 100644 --- a/test/spawnSubagent.test.mts +++ b/test/spawnSubagent.test.mts @@ -30,7 +30,7 @@ afterEach(() => { }); const agent = (name = "g"): AgentDef => ({ - name, description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, backend: "pi", sessionKey: name, source: "builtin", filePath: "/x", + name, description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "pi", sessionKey: name, source: "builtin", filePath: "/x", }); /** A fake child that emits N turns then finishes with finalText. */ @@ -769,3 +769,61 @@ test("#32 substrate baseline: NOT captured when turn 1 produces no assistant mes ok(rec, "run record exists"); ok(rec!.substrateBaseline === undefined, `no assistant message_end → no substrateBaseline: ${rec!.substrateBaseline}`); }); + +test("SPEC-6-5: cwd param scopes the child (childCwd) and records sessionCwd", async () => { + // Dispatch with cwd: "/child-target", parentCwd: "/session". + // RunRecord.cwd = childCwd, RunRecord.sessionCwd = parentCwd, factory.create gets childCwd. + let createdCwd: string | undefined; + const handlers: Array<(e: ChildSessionEvent) => void> = []; + const child: ChildSession = { + prompt: async () => { + for (const h of handlers) h({ type: "turn_start" }); + for (const h of handlers) h({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }] } }); + }, + subscribe: (h: (e: ChildSessionEvent) => void) => { handlers.push(h); return () => {}; }, abort: async () => {}, dispose: () => {}, + }; + const factory: ChildSessionFactory = { + create: async (o) => { createdCwd = o.cwd; return { session: child, model: "m" }; }, + }; + const h = harness(factory); + const res = await spawnSubagent({ + agent: "g", task: "do", track: false, + registry: h.registry, todoSync: h.todoSync, runRegistry: h.runRegistry, lock: h.lock, backendRegistry: regWith(h.factory), + parentModel: PARENT, parentCwd: "/session", cwd: "/child-target", + }); + strictEqual(res.status, "completed"); + const rec = h.runRegistry.get(res.runId); + ok(rec, "run record exists"); + strictEqual(rec!.cwd, "/child-target", "rec.cwd = childCwd"); + strictEqual(rec!.sessionCwd, "/session", "rec.sessionCwd = parentCwd"); + strictEqual(createdCwd, "/child-target", "factory.create receives childCwd"); +}); + +test("SPEC-6-5: omitted cwd → childCwd = parentCwd (backward-compat)", async () => { + // Dispatch with cwd OMITTED, parentCwd: "/session". + // RunRecord.cwd = parentCwd, RunRecord.sessionCwd = parentCwd, factory.create gets parentCwd. + let createdCwd: string | undefined; + const handlers: Array<(e: ChildSessionEvent) => void> = []; + const child: ChildSession = { + prompt: async () => { + for (const h of handlers) h({ type: "turn_start" }); + for (const h of handlers) h({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }] } }); + }, + subscribe: (h: (e: ChildSessionEvent) => void) => { handlers.push(h); return () => {}; }, abort: async () => {}, dispose: () => {}, + }; + const factory: ChildSessionFactory = { + create: async (o) => { createdCwd = o.cwd; return { session: child, model: "m" }; }, + }; + const h = harness(factory); + const res = await spawnSubagent({ + agent: "g", task: "do", track: false, + registry: h.registry, todoSync: h.todoSync, runRegistry: h.runRegistry, lock: h.lock, backendRegistry: regWith(h.factory), + parentModel: PARENT, parentCwd: "/session", + }); + strictEqual(res.status, "completed"); + const rec = h.runRegistry.get(res.runId); + ok(rec, "run record exists"); + strictEqual(rec!.cwd, "/session", "rec.cwd = parentCwd (backward-compat)"); + strictEqual(rec!.sessionCwd, "/session", "rec.sessionCwd = parentCwd"); + strictEqual(createdCwd, "/session", "factory.create receives parentCwd"); +}); diff --git a/test/subagent-tool.test.mts b/test/subagent-tool.test.mts index 0e0f7f6..d3ea572 100644 --- a/test/subagent-tool.test.mts +++ b/test/subagent-tool.test.mts @@ -48,7 +48,7 @@ afterEach(() => { delete process.env.TODO_DIR; }); -const agent: AgentDef = { name: "g", description: "d", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x" }; +const agent: AgentDef = { name: "g", description: "d", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, userMemory: false, backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x" }; function makeDeps() { return { @@ -383,3 +383,71 @@ test("#39 modelFallback === primary model: NO retry (avoids retrying the same fa strictEqual(createCalls, 1, "no retry when modelFallback === the primary's resolved model"); strictEqual((out.details as any).retriedWithModel, undefined); }); + +// ── SPEC-6-5: cwd param + validation + cross-cwd notify ── + +import { resolveDispatchCwd } from "../src/tools/subagent.ts"; +import { homedir } from "node:os"; + +test("SPEC-6-5: resolveDispatchCwd returns undefined for absent cwd (default)", () => { + const { cwd, error } = resolveDispatchCwd(undefined, "/session"); + strictEqual(cwd, undefined); + strictEqual(error, undefined); +}); + +test("SPEC-6-5: resolveDispatchCwd returns undefined for empty string", () => { + const { cwd, error } = resolveDispatchCwd("", "/session"); + strictEqual(cwd, undefined); + strictEqual(error, undefined); +}); + +test("SPEC-6-5: resolveDispatchCwd rejects a nonexistent cwd", () => { + const { cwd, error } = resolveDispatchCwd("/no/such/dir-xyz-12345", "/session"); + strictEqual(cwd, undefined); + ok(error!.includes("cwd does not exist"), `error mentions 'does not exist': ${error}`); +}); + +test("SPEC-6-5: resolveDispatchCwd resolves a relative cwd against the parent", () => { + // Use a real existing dir + a real existing relative subdir so the existence check passes. + const repoRoot = process.cwd(); + const { cwd, error } = resolveDispatchCwd("src", repoRoot); + strictEqual(error, undefined, `no error for existing relative subdir: ${error}`); + ok(cwd!.endsWith("armory-fleet/src"), `resolved absolute against parent: ${cwd}`); +}); + +test("SPEC-6-5: tool rejects a nonexistent cwd", async () => { + const deps = makeDeps(); + const tool = createSubagentTool(deps as any); + const out = await tool.execute!("c", { agent: "g", task: "do", cwd: "/no/such/dir-xyz-12345" } as any, new AbortController().signal, () => {}, {} as any); + ok(out.isError, "should be an error"); + ok((out.content[0] as any).text.includes("cwd does not exist"), `text mentions 'does not exist': ${(out.content[0] as any).text}`); +}); + +test("SPEC-6-5: cross-cwd dispatch fires onNotify", async () => { + const notified: Array<{ message: string; kind?: string }> = []; + const deps = makeDeps() as any; + deps.onNotify = (message: string, kind?: string) => { notified.push({ message, kind }) }; + // Use the repo root (an existing dir ≠ "/tmp") + const altCwd = process.cwd(); + if (altCwd === "/tmp") { + // Edge case: if cwd IS /tmp, use homedir + } + const tool = createSubagentTool(deps as any); + await tool.execute!("c", { agent: "g", task: "do", cwd: altCwd } as any, new AbortController().signal, () => {}, {} as any); + ok(notified.length > 0, "onNotify fired for cross-cwd dispatch"); + ok(notified[0]!.message.includes("scoped to"), `notify message mentions 'scoped to': ${notified[0]!.message}`); + ok(notified[0]!.message.includes(altCwd), `notify message includes the cwd: ${notified[0]!.message}`); +}); + +test("SPEC-6-5: same-cwd dispatch does NOT fire onNotify", async () => { + const notified: Array<{ message: string; kind?: string }> = []; + const deps = makeDeps() as any; + deps.onNotify = (message: string, kind?: string) => { notified.push({ message, kind }) }; + const tool = createSubagentTool(deps as any); + await tool.execute!("c", { agent: "g", task: "do" } as any, new AbortController().signal, () => {}, {} as any); + strictEqual(notified.length, 0, "onNotify NOT fired when cwd omitted (defaults to parentCwd)"); +}); + +test("SPEC-6-5: subagentParams schema includes cwd", () => { + ok("cwd" in subagentParams.properties, "cwd field in the schema"); +}); diff --git a/test/widget-rows.test.mts b/test/widget-rows.test.mts index 0b9943f..a0de86e 100644 --- a/test/widget-rows.test.mts +++ b/test/widget-rows.test.mts @@ -1,6 +1,7 @@ // test/widget-rows.test.mts import { test } from "node:test"; import { strictEqual, ok, deepStrictEqual } from "node:assert"; +import { basename } from "node:path"; import { toWidgetRun, toWidgetRunFromBg, filterActive, renderWidgetLines, type WidgetRun, @@ -285,3 +286,15 @@ test("#32 substrate label: bg runs never get a substrate/work label", () => { const lines = renderWidgetLines([w], Date.now()); ok(!lines.some((l) => l.includes(" substrate") || l.includes(" work")), `bg run → no substrate/work label: ${lines.join("|")}`); }); + +test("SPEC-6-5: cross-cwd fg run shows the ↗ glyph", () => { + const w = toWidgetRun(fg({ runId: "fl-x", startedAt: 1000, task: "do", cwd: "/Users/r/projB", sessionCwd: "/Users/r/projA" })); + const lines = renderWidgetLines([w], 2000); + ok(lines[0]!.includes(`↗${basename("/Users/r/projB")}`), `cross-cwd glyph: ${lines[0]}`); +}); + +test("SPEC-6-5: same-cwd fg run has no ↗ glyph", () => { + const w = toWidgetRun(fg({ runId: "fl-x", startedAt: 1000, task: "do", cwd: "/session", sessionCwd: "/session" })); + const lines = renderWidgetLines([w], 2000); + ok(!lines[0]!.includes("↗"), `same-cwd → no glyph: ${lines[0]}`); +});