@getpipher/armory-fleet 0.12.5 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -308,6 +308,14 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
308
308
 
309
309
  ---
310
310
 
311
+ ## Migration (v0.13.0 — SPEC-6-5 cwd isolation)
312
+
313
+ - **`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.
314
+ - **`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).
315
+ - **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.
316
+ - **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
317
+ - **Deferred:** bg/scheduled + worktree cwd-isolation (the `cwd` param is honored by foreground dispatches only for now) — tracked in #62.
318
+
311
319
  ## Roadmap
312
320
 
313
321
  armory-fleet follows a PRD → SPEC-N (brainstorm → spec → plan → implementation) pipeline. **16/16 phases done through v0.12.0.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.12.5",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
6
6
  "license": "MIT",
@@ -22,7 +22,7 @@ export function createClaudeChildFactory(
22
22
  if (!detector?.schemaOk) {
23
23
  throw new Error(`claude backend unavailable: ${detector?.note ?? "schema not ok"}`);
24
24
  }
25
- const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(memoryScopesFor(opts.cwd)) : "";
25
+ const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(memoryScopesFor(opts.cwd, { includeUser: opts.agent.userMemory ?? false })) : "";
26
26
  const sys = memoryBlock ? `${opts.rolePrompt}\n\n${memoryBlock}` : opts.rolePrompt;
27
27
  const resumeId = resumeStore.get("claude", opts.agent.sessionKey);
28
28
 
@@ -41,9 +41,11 @@ export function composeChildPrompt(args: { rolePrompt: string; memoryBlock: stri
41
41
  return [args.rolePrompt, args.memoryBlock, args.base].filter((s) => s && s.trim().length > 0).join("\n\n");
42
42
  }
43
43
 
44
- /** Build the three memory scopes for a child: project=cwd, local=parent dir, user=sentinel. */
45
- export function memoryScopesFor(cwd: string): { project: string; local: string; user: string } {
46
- return { project: cwd, local: dirname(cwd) || cwd, user: USER_PSEUDO_CWD };
44
+ /** Build the memory scopes for a child: project=cwd, local=parent dir; user only when opted in.
45
+ * #20/SPEC-6-5: the user pseudo-scope (`/__armory-fleet-user__`) is a cross-project bleed by
46
+ * construction omit it unless the agent declares `userMemory: true`. */
47
+ export function memoryScopesFor(cwd: string, opts?: { includeUser?: boolean }): { project: string; local: string; user?: string } {
48
+ return { project: cwd, local: dirname(cwd) || cwd, ...(opts?.includeUser ? { user: USER_PSEUDO_CWD } : {}) };
47
49
  }
48
50
 
49
51
  /** #40: resolve extra skill dirs to scan for the child, beyond the default `~/.pi/agent/skills`.
@@ -73,7 +75,7 @@ export interface ChildLoaderOpts {
73
75
 
74
76
  /** Build the fleet CustomResourceLoader for a child session. */
75
77
  export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader {
76
- const scopes = memoryScopesFor(opts.cwd);
78
+ const scopes = memoryScopesFor(opts.cwd, { includeUser: opts.agent.userMemory ?? false });
77
79
  const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(scopes) : "";
78
80
  return new DefaultResourceLoader({
79
81
  cwd: opts.cwd,
@@ -28,10 +28,19 @@ export interface RunRecord {
28
28
  costTotal?: number;
29
29
  /** SPEC-6-1: latest context tokens (calcContextTokens(usage)) — live snapshot. */
30
30
  contextTokens?: number;
31
+ /** #32: context-token snapshot at the end of turn 1 (the armory substrate baseline).
32
+ * Set once on the first assistant message_end; live-only (not journaled). The widget
33
+ * compares current contextTokens against this to label the tok/ctx% segment as
34
+ * "substrate" (flat across turns) vs "work" (growing) — see src/panel/widget-rows.ts. */
35
+ substrateBaseline?: number;
31
36
  /** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */
32
37
  tier?: string;
33
38
  /** SPEC-6-2: the cwd this run belongs to (widget cross-cwd filter + reconcile ownership). */
34
39
  cwd: string;
40
+ /** SPEC-6-5: the session cwd the dispatch originated from (live; = parentCwd). Set at spawn.
41
+ * Lets the widget compute cross-cwd (`cwd !== sessionCwd`) for the ↗ glyph without re-reading
42
+ * the journal. Live-only counterpart to RunMetaEvent.sessionCwd. */
43
+ sessionCwd?: string;
35
44
  /** SPEC-6-2: the backend (probe dispatch: pi→handle, claude→pid). */
36
45
  backend: BackendId;
37
46
  /** SPEC-6-2: claude-backend child PID (cross-process liveness probe). */
@@ -5,6 +5,7 @@ import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
5
5
  import type { VisionPort } from "../vision/port.ts";
6
6
  import type { BackendRegistry } from "../backend/port.ts";
7
7
  import { genRunId, RunRegistry } from "./run-registry.ts";
8
+ import type { RunRecord } from "./run-registry.ts";
8
9
  import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
9
10
  import type { ForegroundLock } from "./concurrency-lock.ts";
10
11
  import type { RunLog } from "../runtime/run-log.ts";
@@ -116,6 +117,10 @@ export interface SpawnOptions {
116
117
  backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory — engine looks up by agentDef.backend
117
118
  parentModel: { provider: string; id: string };
118
119
  parentCwd: string;
120
+ /** SPEC-6-5: the dispatch target's working directory. Default = parentCwd (the session cwd, backward-compat).
121
+ * When set, all child-scoped sites use this cwd (factory.create, RunRecord.cwd, run:meta cwd);
122
+ * session-scoped audit (`sessionCwd`) keeps parentCwd. */
123
+ cwd?: string;
119
124
  memoryPort?: MemoryHydratePort;
120
125
  visionPort?: VisionPort;
121
126
  signal?: AbortSignal;
@@ -215,6 +220,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
215
220
  const track = opts.track ?? true;
216
221
  const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
217
222
  const startedAt = Date.now();
223
+ const childCwd = opts.cwd ?? opts.parentCwd;
218
224
 
219
225
  // #31: read-only dispatches (review/audit/research) bypass the foreground single-slot lock —
220
226
  // the caller asserts no cwd mutation, so the in-place edit-conflict guard doesn't apply and
@@ -287,7 +293,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
287
293
  runId, agent: agentDef.name, model, task: opts.task, track,
288
294
  todoId: null, status: "running", startedAt,
289
295
  tier: tier?.name, costTotal: 0, contextTokens: 0,
290
- cwd: opts.parentCwd, backend: backendId,
296
+ cwd: childCwd, sessionCwd: opts.parentCwd, backend: backendId,
291
297
  });
292
298
 
293
299
  // todo-sync (before) — only when both caller tracks AND agent allows todoSync
@@ -311,7 +317,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
311
317
  for (const cand of candidates) {
312
318
  try {
313
319
  const result = await backend.factory.create({
314
- cwd: opts.parentCwd,
320
+ cwd: childCwd,
315
321
  model: cand,
316
322
  thinkingLevel: childAgent.thinkingLevel,
317
323
  tools,
@@ -345,6 +351,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
345
351
  let costTotal = 0;
346
352
  let contextTokens = 0;
347
353
  let turnIdx = -1;
354
+ // #32: substrate baseline — the turn-1 context-token snapshot (armory substrate overhead).
355
+ // Captured once on the first assistant message_end (turnIdx === 0); threaded to the RunRecord
356
+ // so the widget can classify the tok/ctx% segment as "substrate" (flat) vs "work" (growing).
357
+ let substrateBaseline: number | undefined;
348
358
  // #26/#22: declared before subscribe() because some child sessions emit events
349
359
  // synchronously inside subscribe() (temporal-dead-zone guard).
350
360
  let modelError: string | undefined; // model-call failure surfaced via stopReason "error"
@@ -373,7 +383,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
373
383
  if (e.type === "session_init" && e.backendSessionId) {
374
384
  opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
375
385
  try {
376
- 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 });
386
+ 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 });
377
387
  } catch { /* best-effort */ }
378
388
  } else if (e.type === "turn_start") {
379
389
  turnIdx++;
@@ -403,7 +413,15 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
403
413
  const cost = u?.cost?.total ?? 0;
404
414
  costTotal += cost;
405
415
  contextTokens = calcContextTokens(u ?? {});
406
- opts.runRegistry.update(runId, { costTotal, contextTokens, tokenTotal });
416
+ // #32: capture the substrate baseline at the end of turn 1 (the first assistant
417
+ // message_end). The turn-1 context is dominated by the armory substrate (system prompt +
418
+ // skills + memory); subsequent turns barely grow it unless real work adds tool results.
419
+ const patch: Partial<RunRecord> = { costTotal, contextTokens, tokenTotal };
420
+ if (substrateBaseline === undefined && turnIdx === 0 && contextTokens > 0) {
421
+ substrateBaseline = contextTokens;
422
+ patch.substrateBaseline = substrateBaseline;
423
+ }
424
+ opts.runRegistry.update(runId, patch);
407
425
  try {
408
426
  opts.runLog?.append(runId, { type: "message", role: "assistant", text, usage: { total: turnTokens, input: u?.input, output: u?.output, cacheRead: u?.cacheRead, cacheWrite: u?.cacheWrite, cost: u?.cost }, turnIndex: turnIdx });
409
427
  } catch { /* best-effort */ }
package/src/index.ts CHANGED
@@ -205,6 +205,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
205
205
  // A retryable provider failure (stopReason "error") retries once on this model even without a
206
206
  // per-dispatch `modelFallback`. Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern.
207
207
  deps.defaultModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
208
+ // SPEC-6-5: cross-cwd dispatch notify hook (wired per-session in session_start below).
209
+ // Placeholder; the real wiring happens in session_start where ctx is in scope.
208
210
 
209
211
  // #31 tail: foreground concurrency is SESSION-LEVEL (a shared lock can't be re-sized per
210
212
  // 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<void> {
308
310
  const m = ctx.model;
309
311
  deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
310
312
  deps.parentCwd = ctx.cwd;
313
+ deps.onNotify = (m, k) => ctx.ui.notify(m, k ?? "info");
311
314
  // SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
312
315
  const dir = fleetDir(ctx.cwd);
313
316
  // SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
@@ -34,6 +34,10 @@ export interface LifecycleDef {
34
34
  description: string;
35
35
  /** Lifecycle-wide default backend; absent → "pi". */
36
36
  backend: BackendId;
37
+ /** SPEC-6-5: pin this lifecycle to a target working directory. Absent → the entry-point cwd
38
+ * (the panel's chosen cwd, or the dispatching `subagent` tool's cwd/session cwd). When present,
39
+ * overrides the entry-point cwd for all phases. */
40
+ cwd?: string;
37
41
  phases: PhaseDef[];
38
42
  source: AgentSource;
39
43
  filePath: string;
@@ -38,6 +38,7 @@ export function parseLifecycleFile(content: string, filePath: string, source: Ag
38
38
  throw new LifecycleParseError(`${filePath}: invalid backend '${rawBackend}' (must be 'pi' | 'claude')`);
39
39
  }
40
40
  const backend = rawBackend as BackendId;
41
+ const cwd = typeof raw.cwd === "string" && raw.cwd.trim() ? raw.cwd.trim() : undefined;
41
42
 
42
43
  if (!Array.isArray(raw.phases) || raw.phases.length === 0) {
43
44
  throw new LifecycleParseError(`${filePath}: phases must be a non-empty array`);
@@ -100,7 +101,7 @@ export function parseLifecycleFile(content: string, filePath: string, source: Ag
100
101
  // The terminal phase never checkpoints after it (the lifecycle is done) — §5.4.
101
102
  if (phases.length > 0) phases[phases.length - 1]!.checkpoint = false;
102
103
 
103
- return { name, description, backend, phases, source, filePath };
104
+ return { name, description, backend, phases, source, filePath, ...(cwd ? { cwd } : {}) };
104
105
  }
105
106
 
106
107
  /** Split the markdown body into a map of phase-name → prompt-template, by `## <name>` H2 headings. */
@@ -25,6 +25,8 @@ export interface PhaseSpawnOpts {
25
25
  skills: string[];
26
26
  /** The resolved backend for this phase (phase.backend → lifecycle.backend → "pi"). */
27
27
  backend: BackendId;
28
+ /** SPEC-6-5: the resolved lifecycle cwd (lifecycle.cwd ?? entryCwd) the phase child runs in. */
29
+ cwd?: string;
28
30
  model?: string;
29
31
  }
30
32
  export type SpawnFn = (opts: PhaseSpawnOpts) => Promise<SpawnResult>;
@@ -57,6 +59,9 @@ export interface LifecycleRunOpts {
57
59
  worktreePath?: string;
58
60
  /** SPEC-5a: the base ref to diff against (default "HEAD"). */
59
61
  baseRef?: string;
62
+ /** SPEC-6-5: the entry-point cwd (the panel's chosen cwd, or the dispatching subagent tool's
63
+ * cwd/session cwd). The lifecycle's `cwd` field, if present, overrides this. */
64
+ entryCwd?: string;
60
65
  }
61
66
 
62
67
  export interface LifecycleRunResult {
@@ -86,6 +91,8 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
86
91
  const available = [...deps.registry.keys()].sort().join(", ");
87
92
  return failResult("", startedAt, `lifecycle '${lifecycleName}' not found; available: ${available}`, lifecycleName, task, opts.mode, [], null);
88
93
  }
94
+ // SPEC-6-5: lifecycle cwd field overrides the entry-point cwd (entryCwd).
95
+ const lifecycleCwd = lifecycle.cwd ?? opts.entryCwd;
89
96
 
90
97
  const runId = deps.genRunId();
91
98
  const lifecycleBackend = lifecycle.backend;
@@ -151,7 +158,7 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
151
158
  // phase's skills (Q1=B) and routes to the phase's backend (Q4=C) — not the agent's defaults.
152
159
  let spawnRes: import("../engine/spawnSubagent.ts").SpawnResult;
153
160
  try {
154
- spawnRes = await deps.spawn({ agent: agentName, task: prompt, lifecycleTodoId: todoId, skills, backend });
161
+ spawnRes = await deps.spawn({ agent: agentName, task: prompt, lifecycleTodoId: todoId, skills, backend, cwd: lifecycleCwd });
155
162
  } catch (e) {
156
163
  // spawn should return a failed result, not throw — but guard anyway so a throwing spawn
157
164
  // can't orphan the lifecycle (treat as a phase failure).
@@ -5,7 +5,7 @@ import type { MemoryHydratePort, MemoryScopes } from "./port.ts";
5
5
  export class ArmoryMemoryAdapter implements MemoryHydratePort {
6
6
  renderScopes(scopes: MemoryScopes): string {
7
7
  return [scopes.project, scopes.local, scopes.user]
8
- .filter((cwd) => listMemory(cwd).length > 0) // skip empty scopes cleanly (no placeholder to render)
8
+ .filter((cwd): cwd is string => cwd != null && listMemory(cwd).length > 0) // skip empty scopes cleanly (no placeholder to render)
9
9
  .map((cwd) => renderMemoryBlock(cwd)) // armory-memory's existing cwd-keyed primitive
10
10
  .join("\n\n"); // → "" when all three empty
11
11
  }
@@ -4,8 +4,9 @@ export interface MemoryScopes {
4
4
  project: string;
5
5
  /** Immediate parent directory of the project cwd (workspace/org level). */
6
6
  local: string;
7
- /** Fixed pseudo-cwd for global cross-project user memory. */
8
- user: string;
7
+ /** Optional only present when the agent opted in via `userMemory: true` (SPEC-6-5).
8
+ * The user scope is a cross-project memory bleed by construction; omitted unless explicitly enabled. */
9
+ user?: string;
9
10
  }
10
11
  export interface MemoryHydratePort {
11
12
  /** Render the three-scope memory block (project → local → user), concatenated. Empty string when all scopes empty. */
@@ -96,7 +96,8 @@ export class FleetPanel extends Container {
96
96
  private lcRunMode = false;
97
97
  private lcTaskInput: Input | null = null;
98
98
  private lcNameInput: Input | null = null;
99
- private lcPhase: "task" | "name" = "task";
99
+ private lcCwdInput: Input | null = null;
100
+ private lcPhase: "task" | "name" | "cwd" = "task";
100
101
  // SPEC-4: pending checkpoint (interactive Continue/Revise/Abort)
101
102
  private pendingCheckpoint: { phase: PhaseRecord; resolve: (d: CheckpointDecision) => void } | null = null;
102
103
  private lcReviseInput: Input | null = null;
@@ -359,10 +360,10 @@ export class FleetPanel extends Container {
359
360
  this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
360
361
  this.addChild(this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!);
361
362
  this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
362
- } else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
363
- const prompt = this.lcPhase === "task" ? " task> " : " lifecycle name (blank=default)> ";
363
+ } else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput || this.lcCwdInput)) {
364
+ const prompt = this.lcPhase === "task" ? " task> " : this.lcPhase === "name" ? " lifecycle name (blank=default)> " : " cwd (blank=session cwd)> ";
364
365
  this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
365
- this.addChild(this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!);
366
+ this.addChild(this.lcPhase === "task" ? this.lcTaskInput! : this.lcPhase === "name" ? this.lcNameInput! : this.lcCwdInput!);
366
367
  this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
367
368
  } else if (this.pendingCheckpoint && !this.lcRevising) {
368
369
  const pc = this.pendingCheckpoint;
@@ -586,9 +587,9 @@ export class FleetPanel extends Container {
586
587
  this.invalidate();
587
588
  return;
588
589
  }
589
- if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
590
+ if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput || this.lcCwdInput)) {
590
591
  if (matchesKey(data, "escape")) { this.cancelLifecycleRun(); return; }
591
- (this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!).handleInput(data);
592
+ (this.lcPhase === "task" ? this.lcTaskInput! : this.lcPhase === "name" ? this.lcNameInput! : this.lcCwdInput!).handleInput(data);
592
593
  this.invalidate();
593
594
  return;
594
595
  }
@@ -1131,9 +1132,18 @@ export class FleetPanel extends Container {
1131
1132
  this.lcNameInput = new Input();
1132
1133
  this.lcNameInput.onSubmit = (name: string) => {
1133
1134
  const lcName = name.trim() || "default";
1134
- void this.executeLifecycleRun(task.trim(), lcName);
1135
+ this.lcPhase = "cwd";
1136
+ this.lcCwdInput = new Input();
1137
+ // SPEC-6-5: 3rd input step — the dispatch cwd. Prefilled with the session cwd; Enter
1138
+ // accepts it, Escape accepts the default (mirrors the name step's Escape-accepts-default).
1139
+ this.lcCwdInput.onSubmit = (cwd: string) => {
1140
+ const picked = cwd.trim() || this.deps.parentCwd;
1141
+ void this.executeLifecycleRun(task.trim(), lcName, picked);
1142
+ };
1143
+ this.lcCwdInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), lcName, this.deps.parentCwd); };
1144
+ this.renderShell();
1135
1145
  };
1136
- this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default"); };
1146
+ this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default", this.deps.parentCwd); };
1137
1147
  this.renderShell();
1138
1148
  };
1139
1149
  this.lcTaskInput.onEscape = () => this.cancelLifecycleRun();
@@ -1145,18 +1155,27 @@ export class FleetPanel extends Container {
1145
1155
  this.lcRunMode = false;
1146
1156
  this.lcTaskInput = null;
1147
1157
  this.lcNameInput = null;
1158
+ this.lcCwdInput = null;
1148
1159
  this.renderShell();
1149
1160
  }
1150
1161
 
1151
- private async executeLifecycleRun(task: string, lifecycleName: string): Promise<void> {
1162
+ private async executeLifecycleRun(task: string, lifecycleName: string, cwd: string): Promise<void> {
1152
1163
  this.lcRunMode = false;
1153
1164
  this.lcTaskInput = null;
1154
1165
  this.lcNameInput = null;
1166
+ this.lcCwdInput = null;
1155
1167
  this.renderShell();
1156
1168
  if (!this.deps.lifecycleRegistry.has(lifecycleName)) {
1157
1169
  this.onNotify(`lifecycle '${lifecycleName}' not found; available: ${[...this.deps.lifecycleRegistry.keys()].sort().join(", ")}`, "error");
1158
1170
  return;
1159
1171
  }
1172
+ // SPEC-6-5: validate the chosen cwd (exists + is a dir) before spawning; surface cross-cwd.
1173
+ const { resolveDispatchCwd } = await import("../tools/subagent.ts");
1174
+ const { cwd: resolvedCwd, error: cwdErr } = resolveDispatchCwd(cwd, this.deps.parentCwd);
1175
+ if (cwdErr) { this.onNotify(cwdErr, "error"); return; }
1176
+ if (resolvedCwd && resolvedCwd !== this.deps.parentCwd) {
1177
+ this.onNotify("scoped to " + resolvedCwd + " (≠ session " + this.deps.parentCwd + ")", "info");
1178
+ }
1160
1179
  const onCheckpoint: CheckpointFn = (phase) => new Promise<CheckpointDecision>((resolve) => {
1161
1180
  this.pendingCheckpoint = { phase, resolve };
1162
1181
  this.renderShell();
@@ -1171,10 +1190,11 @@ export class FleetPanel extends Container {
1171
1190
  registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry, lock: this.deps.lock,
1172
1191
  backendRegistry: this.deps.backendRegistry, parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
1173
1192
  runLog: this.deps.runLog,
1193
+ cwd: o.cwd,
1174
1194
  });
1175
1195
  },
1176
1196
  };
1177
- const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: "checkpointed", onCheckpoint });
1197
+ const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: "checkpointed", onCheckpoint, entryCwd: resolvedCwd });
1178
1198
  this.pendingCheckpoint = null;
1179
1199
  // record the run so the Lifecycle view shows it
1180
1200
  this.deps.lifecycleRuns.set(res.runId, res);
@@ -8,6 +8,7 @@
8
8
  // list below editor" intent was never achievable via pi widgets (editor keeps keyboard focus).
9
9
  // `/fleet` is the navigable action surface; this one above-editor widget is the glance surface.
10
10
  import { fmtDuration, fmtTokens } from "./rows.ts";
11
+ import { basename } from "node:path";
11
12
  import type { RunRecord } from "../engine/run-registry.ts";
12
13
  import type { BgRunStatus } from "./rows.ts";
13
14
 
@@ -16,6 +17,11 @@ import type { BgRunStatus } from "./rows.ts";
16
17
  export const LIVENESS_THRESHOLD_MS = 30_000;
17
18
  /** #23: a run whose last event is older than this is flagged stale ("are events still arriving?"). */
18
19
  export const STALE_THRESHOLD_MS = 60_000;
20
+ /** #32: once past turn 1, if contextTokens has grown by less than this fraction of the
21
+ * turn-1 substrate baseline, the tok/ctx% segment is labeled "substrate" (flat overhead)
22
+ * rather than "work" (growing from tool results). 5% — the dogfood evidence showed ~0.2%/turn
23
+ * growth on a substrate-dominated run vs tens-of-K (multi-%) once real tool output lands. */
24
+ export const SUBSTRATE_GROWTH_THRESHOLD = 0.05;
19
25
 
20
26
  export interface WidgetRun {
21
27
  runId: string;
@@ -34,6 +40,10 @@ export interface WidgetRun {
34
40
  task?: string;
35
41
  /** SPEC-6-1: latest context-token snapshot (for ctx% segment). */
36
42
  contextTokens?: number;
43
+ /** #32: context-token baseline at end of turn 1 (armory substrate overhead). When the
44
+ * current contextTokens has grown little beyond this baseline across turns, the tok/ctx%
45
+ * segment is labeled "substrate" (flat overhead) vs "work" (growing from tool results). */
46
+ substrateBaseline?: number;
37
47
  /** SPEC-6-1: max context window for the resolved model (set by controller — Task 7). */
38
48
  maxContext?: number;
39
49
  /** SPEC-6-1: cumulative $ (for the $ segment). */
@@ -46,6 +56,10 @@ export interface WidgetRun {
46
56
  lastEventClass?: string;
47
57
  /** #23: liveness — timestamp (ms) of the last event ("events still arriving?"). */
48
58
  lastEventAt?: number;
59
+ /** SPEC-6-5: the run's (child) cwd — from RunRecord.cwd. */
60
+ cwd?: string;
61
+ /** SPEC-6-5: the session cwd (parentCwd) — from RunRecord.sessionCwd. When cwd !== sessionCwd the widget shows a ↗ glyph. */
62
+ sessionCwd?: string;
49
63
  }
50
64
 
51
65
  export function toWidgetRun(r: RunRecord): WidgetRun {
@@ -53,8 +67,9 @@ export function toWidgetRun(r: RunRecord): WidgetRun {
53
67
  runId: r.runId, agent: r.agent, status: r.status,
54
68
  startedAt: r.startedAt, endedAt: r.endedAt, tokenTotal: r.tokenTotal,
55
69
  kind: "fg",
56
- task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens,
70
+ task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens, substrateBaseline: r.substrateBaseline,
57
71
  turnCount: r.turnCount, turnMax: r.turnMax, lastEventClass: r.lastEventClass, lastEventAt: r.lastEventAt,
72
+ cwd: r.cwd, sessionCwd: r.sessionCwd,
58
73
  };
59
74
  }
60
75
 
@@ -102,6 +117,9 @@ function widgetLine(r: WidgetRun, now: number): string {
102
117
 
103
118
  // fg: task excerpt as primary label (fallback to runId if no task)
104
119
  const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId;
120
+ // SPEC-6-5: cross-cwd glyph — when the run's cwd differs from the session cwd, mark it so the
121
+ // operator sees "this run is scoped to a different project" at a glance. Same-cwd → no glyph.
122
+ const crossCwd = (r.cwd && r.sessionCwd && r.cwd !== r.sessionCwd) ? ` ↗${basename(r.cwd)}` : "";
105
123
  const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : "";
106
124
  // #23: liveness — only after LIVENESS_THRESHOLD_MS, to keep short runs concise (per acceptance).
107
125
  // turn N/max + last-event class (no prompt content, no args/results — only the tool name)
@@ -114,7 +132,17 @@ function widgetLine(r: WidgetRun, now: number): string {
114
132
  const stale = (r.lastEventAt != null && now - r.lastEventAt > STALE_THRESHOLD_MS) ? " ⏰stale" : "";
115
133
  liveness = `${turn}${ev}${stale}`;
116
134
  }
117
- return `${glyph} ${label}${agentSeg}${dur}${liveness}${tok}${ctx}${cost}`;
135
+ // #32: substrate vs work — once past turn 1, classify the tok/ctx% segment. The armory substrate
136
+ // (system prompt + skills + memory) dominates turn-1 context; on substrate-dominated runs the
137
+ // ctx% barely moves across turns and reads as "frozen". Label it "substrate" (flat overhead) so
138
+ // that's distinguishable from "work" (context growing from tool results). Needs ≥2 turns of
139
+ // data (a baseline + a current snapshot); before that there's nothing to compare.
140
+ let substrate = "";
141
+ if ((r.turnCount ?? 0) >= 2 && r.substrateBaseline != null && r.contextTokens != null && r.substrateBaseline > 0) {
142
+ const growth = (r.contextTokens - r.substrateBaseline) / r.substrateBaseline;
143
+ substrate = growth <= SUBSTRATE_GROWTH_THRESHOLD ? " substrate" : " work";
144
+ }
145
+ return `${glyph} ${label}${crossCwd}${agentSeg}${dur}${liveness}${tok}${ctx}${substrate}${cost}`;
118
146
  }
119
147
 
120
148
  /** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet".
@@ -16,6 +16,10 @@ export interface AgentDef {
16
16
  todoSync: boolean;
17
17
  memoryHydrate: boolean;
18
18
  vision: boolean;
19
+ /** #20/SPEC-6-5: opt in to the global cross-project user memory scope (`/__armory-fleet-user__`).
20
+ * Default false — the user scope is a cross-project bleed by construction; hydrate it only when
21
+ * an agent explicitly declares `userMemory: true`. Only meaningful when `memoryHydrate: true`. */
22
+ userMemory: boolean;
19
23
  /** Cross-harness backend routing (SPEC-3). Invalid value → FrontmatterError. */
20
24
  backend: "pi" | "claude";
21
25
  /** Stable id for backend-native resume (SPEC-3). Defaults to name. */
@@ -57,6 +61,7 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS
57
61
  const todoSync = raw.todoSync === undefined ? true : Boolean(raw.todoSync);
58
62
  const memoryHydrate = raw.memoryHydrate === undefined ? true : Boolean(raw.memoryHydrate);
59
63
  const vision = raw.vision === undefined ? true : Boolean(raw.vision);
64
+ const userMemory = raw.userMemory === undefined ? false : Boolean(raw.userMemory);
60
65
 
61
66
  const rawBackend = typeof raw.backend === "string" ? raw.backend.trim() : "pi";
62
67
  if (rawBackend !== "pi" && rawBackend !== "claude") {
@@ -77,6 +82,7 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS
77
82
  todoSync,
78
83
  memoryHydrate,
79
84
  vision,
85
+ userMemory,
80
86
  backend,
81
87
  sessionKey,
82
88
  source,
@@ -15,6 +15,8 @@ export interface RunMetaEvent {
15
15
  pid?: number;
16
16
  /** SPEC-6-2: the cwd this run belongs to. */
17
17
  cwd?: string;
18
+ /** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */
19
+ sessionCwd?: string;
18
20
  }
19
21
  export interface MessageEvent {
20
22
  type: "message"; role: string; text: string;
@@ -49,6 +51,8 @@ export interface RunMeta {
49
51
  pid?: number;
50
52
  /** SPEC-6-2: the cwd this run belongs to. */
51
53
  cwd?: string;
54
+ /** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */
55
+ sessionCwd?: string;
52
56
  }
53
57
 
54
58
  const ARGS_LIMIT = 200;
@@ -104,7 +108,7 @@ export class RunLog {
104
108
  if (!meta) {
105
109
  meta = { runId: e.runId, agent: e.agent, model: e.model, task: e.task, startedAt: e.startedAt,
106
110
  track: e.track, todoId: e.todoId, backendSessionId: e.backendSessionId, sessionKey: e.sessionKey,
107
- status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd };
111
+ status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd, sessionCwd: e.sessionCwd };
108
112
  } else {
109
113
  // latest binding wins
110
114
  if (e.backendSessionId) meta.backendSessionId = e.backendSessionId;
@@ -1,4 +1,6 @@
1
1
  // src/tools/subagent.ts
2
+ import { resolve } from "node:path";
3
+ import { statSync } from "node:fs";
2
4
  import { Type, type Static } from "typebox";
3
5
  import type { AgentDef } from "../registry/frontmatter.ts";
4
6
  import type { TodoSyncPort } from "../todo-sync/port.ts";
@@ -32,6 +34,7 @@ export const subagentParams = Type.Object({
32
34
  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).' })),
33
35
  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).' })),
34
36
  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.' })),
37
+ 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.' })),
35
38
  });
36
39
 
37
40
  export type SubagentInput = Static<typeof subagentParams>;
@@ -44,6 +47,19 @@ export function mergeLifecycleSkills(phaseSkills: string[] | undefined, callerSk
44
47
  return [...new Set([...(phaseSkills ?? []), ...(callerSkills ?? [])])];
45
48
  }
46
49
 
50
+ /** SPEC-6-5: validate + resolve a dispatch cwd. Returns { cwd } on success or { error } on failure. */
51
+ export function resolveDispatchCwd(raw: string | undefined, parentCwd: string): { cwd?: string; error?: string } {
52
+ if (raw === undefined || raw === "") return { cwd: undefined }; // default → parentCwd (handled by spawnSubagent)
53
+ const abs = resolve(parentCwd, raw);
54
+ try {
55
+ const st = statSync(abs);
56
+ if (!st.isDirectory()) return { error: `cwd is not a directory: ${abs}` };
57
+ return { cwd: abs };
58
+ } catch {
59
+ return { error: `cwd does not exist: ${abs}` };
60
+ }
61
+ }
62
+
47
63
  export interface SubagentToolDeps {
48
64
  registry: Map<string, AgentDef>;
49
65
  runRegistry: RunRegistry;
@@ -80,6 +96,8 @@ export interface SubagentToolDeps {
80
96
  * Per-dispatch `modelFallback` (when passed) takes precedence. Applies to the direct foreground
81
97
  * path, the foreground lifecycle spawn, and the background/scheduled spawn. */
82
98
  defaultModelFallback?: string;
99
+ /** SPEC-6-5: notify hook for cross-cwd dispatch surfacing. Wired from ctx.ui.notify in index.ts. */
100
+ onNotify?: (message: string, kind?: "info" | "warning" | "error") => void;
83
101
  }
84
102
 
85
103
  /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
@@ -101,6 +119,12 @@ export function createSubagentTool(deps: SubagentToolDeps) {
101
119
  ],
102
120
  parameters: subagentParams,
103
121
  async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, _ctx: any) {
122
+ // SPEC-6-5: validate + resolve the dispatch cwd before any routing.
123
+ const { cwd: resolvedCwd, error: cwdErr } = resolveDispatchCwd(params.cwd, deps.parentCwd);
124
+ if (cwdErr) return { isError: true, content: [{ type: "text" as const, text: cwdErr }] };
125
+ if (resolvedCwd && resolvedCwd !== deps.parentCwd) {
126
+ deps.onNotify?.(`scoped to ${resolvedCwd} (≠ session ${deps.parentCwd})`, "info");
127
+ }
104
128
  // SPEC-5a: background + schedule routing (Q1/Q2/Q5).
105
129
  if (params.background && params.schedule) {
106
130
  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) {
132
156
  maxTurns: params.maxTurns,
133
157
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
134
158
  readOnly: params.readOnly,
159
+ cwd: o.cwd,
135
160
  }), params.modelFallback ?? deps.defaultModelFallback, signal),
136
161
  };
137
162
  const res = await runLifecycle(params.task, params.lifecycle, {
138
163
  deps: lifecycleFullDeps, mode: "auto",
164
+ entryCwd: resolvedCwd,
139
165
  onCheckpoint: async (phase) => phase.status === "failed" ? { action: "abort" } : { action: "continue" },
140
166
  });
141
167
  const isError = res.status === "failed" || res.status === "aborted";
@@ -166,6 +192,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
166
192
  signal,
167
193
  maxTurns: params.maxTurns,
168
194
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
195
+ cwd: resolvedCwd,
169
196
  });
170
197
  // #39: auto-retry on a retryable provider rate-limit / auth failure (stopReason "error").
171
198
  // The primary run reverted its linked todo to open (finishRun -> markRunTodoReverted), so the
@@ -200,6 +227,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
200
227
  signal,
201
228
  maxTurns: params.maxTurns,
202
229
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
230
+ cwd: resolvedCwd,
203
231
  });
204
232
  retriedWithModel = fallback;
205
233
  }