diff --git a/agents/general-purpose.md b/agents/general-purpose.md index 6859550..b9cf912 100644 --- a/agents/general-purpose.md +++ b/agents/general-purpose.md @@ -2,6 +2,8 @@ name: general-purpose description: A focused general-purpose subagent delegate. Use for any task needing isolated work. todoSync: true +memoryHydrate: true +vision: true --- You are a focused subagent delegate. Complete the assigned task thoroughly, work autonomously to completion, and return a concise result summary. diff --git a/docs/SPEC-2-smoke-checklist.md b/docs/SPEC-2-smoke-checklist.md new file mode 100644 index 0000000..1a95371 --- /dev/null +++ b/docs/SPEC-2-smoke-checklist.md @@ -0,0 +1,39 @@ +# SPEC-2 smoke checklist (real-pi, term-driven) + +> **STATUS (2026-07-24): full-run smoke PASSED — 15/15.** Run via `node --import tsx scripts/spec-2-smoke.mts` (exercises the real factory components + one real `session.prompt()` against Ollama Cloud). Rows 1-5 all green; the integration spawn path (`new ModelRegistry(realRuntime)` + `createAgentSession({customTools, excludeTools, resourceLoader: buildChildLoader})` + `session.prompt()`) is verified end-to-end. The no-cost term smoke (extension loads, /fleet opens, chip + i:Info render) is also verified. + +Run inside real pi via the `term` tool. The no-cost parts (extension loads, +`/fleet` opens, Agents-view armory chip renders, `i:Info` detail pane renders) +are verifiable without a model call and catch the EditorTheme-gotcha crash class. + +## No-cost smoke (run now, term-driven) + +1. Load the armory-fleet extension in real pi (no crash; no `theme.getFgAnsi` crash). +2. Open `/fleet` → panel renders (Fleet + Agents tabs). +3. Switch to Agents → the `general-purpose` row shows `armory:[t✓ m✓ v✓]`. +4. Press `i` on the selected agent → `i:Info` detail pane renders (all hooks, + model, skills, role prompt); Escape returns to the list. + +## Full-run smoke (needs RECTOR's API keys + budget; rows 1–5) + +Each row: set up the agent/model, spawn a child via the `subagent` tool or +`/fleet` Run, capture the child's composed system prompt + active tool names, +and assert. Inspect via a throwaway project extension in `.pi/extensions/` +that logs the child's `session_start` system prompt + active tools to a file +the smoke reads back. + +| # | Setup | Assert | +|---|---|---| +| 1 | text-only child model, default agent | `describe_image` present; system prompt has 3-scope memory block; `todo` tool absent; **no "Open-TODOs" block leaked**; no host extension hooks fired | +| 2 | multimodal child model, default agent | `describe_image` absent (pass-through); memory block present; `todo` absent | +| 3 | `memoryHydrate: false` agent | no memory block in child prompt | +| 4 | `vision: false` agent, text-only model | no `describe_image` injected | +| 5 | any agent | child prompt contains pi base (tool docs/guidelines/scoped skills) — confirms `systemPromptOverride` composes, not replaces | + +## How to inspect the child's prompt + tools + +Add a throwaway logging extension at `.pi/extensions/log-child.ts` that hooks +`session_start` / `before_agent_start` and writes the composed system prompt + +`ctx.getActiveTools()` to `/tmp/fleet-smoke-.log`, then read it back +after the `subagent` call returns. (The throwaway must be project-scoped so it +doesn't leak into other sessions; delete after the smoke.) \ No newline at end of file diff --git a/package.json b/package.json index 5c7bada..cb6629c 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "@earendil-works/pi-tui": "^0.81.1", "@earendil-works/pi-ai": "^0.81.1", "@getpipher/armory-todo": "file:../armory-todo", + "@getpipher/armory-memory": "file:../armory-memory", + "@getpipher/vision": "file:../vision", "typebox": "^1.1.38", "yaml": "^2.5.0" }, diff --git a/plans/SPEC-2-deep-armory-integration.md b/plans/SPEC-2-deep-armory-integration.md new file mode 100644 index 0000000..c86272b --- /dev/null +++ b/plans/SPEC-2-deep-armory-integration.md @@ -0,0 +1,1257 @@ +# SPEC-2 — Deep armory integration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every fleet-spawned subagent memory-hydrated (three-scope) and vision-capable (capability-aware) by deliberate fleet construction via a `CustomResourceLoader` + ports-and-adapters, completing the child-side moat. + +**Architecture:** A fleet-owned `CustomResourceLoader` (promoting SPEC-1's `DefaultResourceLoader`-with-overrides) takes deliberate control of the child's extension set (`noExtensions: true`), system prompt (composed `rolePrompt + memoryBlock + base`), and tools (`excludeTools: ["todo"]` + conditional `customTools`). Two ports (`MemoryHydratePort`, `VisionPort`) decouple fleet core from the sibling packages; two adapters are the sole importers. Two companion PRs add `exports` + `.d.ts` to armory-memory and vision (same shape as the SPEC-1 armory-todo companion PRs #12/#13). Cursor is deferred to SPEC-5b. + +**Tech Stack:** TypeScript (raw `.ts` via tsx, no build), pi `^0.81.1` SDK (`createAgentSession`, `DefaultResourceLoader`, `ModelRuntime`, `SessionManager.inMemory`), `node:test` via tsx, `@getpipher/armory-memory`, `@getpipher/vision`, `typebox`. + +## Global Constraints + +- **No build step** — raw `.ts` via tsx at runtime; `pnpm typecheck` + `pnpm test:run` (node:test via tsx) before release. +- **Test runner:** `node --import tsx --test test/*.test.mts` (Node 24 won't type-strip under `node_modules`). +- **pi target:** `^0.81.1`. SDK imports from `@earendil-works/pi-coding-agent`; types from `@earendil-works/pi-ai` (`Model`), `typebox` for tool schemas. +- **Ports-and-adapters:** fleet core (engine, loader, tool, panel) depends only on `*Port` interfaces; `Armory*Adapter` is the sole importer of each sibling. Zero sibling types in core. +- **Single-writer discipline:** `noExtensions: true` on the child loader (no host extension hooks fire in the child); `excludeTools: ["todo"]` (hardened guard). +- **No AI attribution** in commits/PRs/files. +- **One commit per task**; conventional branch `feat/spec-2-deep-armory-integration`. +- **getpither conventions:** EditorTheme gotcha — `ctx.ui.custom` receives full `Theme` (import from `@earendil-works/pi-coding-agent`); `ctx.ui.setEditorComponent` receives `EditorTheme`. Thread `() => ctx.ui.theme` for real colors. +- **Companion PRs land first** (Tasks 1–2) so fleet's `pnpm install` against `file:../armory-memory` + `file:../vision` resolves the new `exports`. + +--- + +## File Structure + +**Fleet (this repo):** +- `src/memory-hydrate/port.ts` — `MemoryHydratePort` interface + `MemoryScopes` +- `src/memory-hydrate/adapter.ts` — `ArmoryMemoryAdapter` (only `@getpipher/armory-memory` importer) +- `src/vision/port.ts` — `VisionPort` interface +- `src/vision/adapter.ts` — `ArmoryVisionAdapter` (only `@getpipher/vision` importer) +- `src/vision/describe-image-tool.ts` — fleet-defined `describe_image` tool (thin wrapper over `VisionPort.delegate`) +- `src/engine/child-loader.ts` — the `CustomResourceLoader` builder (`noExtensions` + composed `systemPromptOverride` + `skillsOverride`) +- `src/engine/spawnSubagent.ts` — **modify**: thread ports, `excludeTools`, `customTools` +- `src/registry/frontmatter.ts` — **modify**: add `memoryHydrate` + `vision` fields +- `src/panel/rows.ts` — **modify**: armory chip + `agentInfo` content +- `src/panel/fleet-panel.ts` — **modify**: `i:Info` action + detail pane +- `src/index.ts` — **modify**: wire `ArmoryMemoryAdapter` + `ArmoryVisionAdapter` into deps; pass to factory +- `agents/general-purpose.md` — **modify**: explicit `memoryHydrate: true` + `vision: true` +- `test/memory-hydrate-adapter.test.mts`, `test/vision-adapter.test.mts`, `test/describe-image-tool.test.mts`, `test/child-loader.test.mts`, `test/frontmatter-spec2.test.mts`, `test/spawn-subagent-spec2.test.mts`, `test/panel-spec2.test.mts` +- `docs/SPEC-2-smoke-checklist.md` — the 5-row real-pi smoke matrix + +**Companion PRs (sibling repos):** +- `~/local-dev/getpipher/armory-memory/` — `exports` map + `src/index.ts` + `src/index.d.ts` +- `~/local-dev/getpipher/vision/` — `exports` map + `src/index.ts` + `src/index.d.ts` + `createVisionDelegator` + +--- + +## Task 1: Companion PR — armory-memory public API + +**Repo:** `~/local-dev/getpipher/armory-memory` (branch `feat/exports-surface` → PR to armory-memory main) +**Mirrors:** armory-todo PRs #12 (`exports` + `index.ts`) + #13 (`index.d.ts` + dual-condition exports). + +**Files:** +- Modify: `~/local-dev/getpipher/armory-memory/package.json` (add `exports` + `types`) +- Create: `~/local-dev/getpipher/armory-memory/src/index.ts` +- Create: `~/local-dev/getpipher/armory-memory/src/index.d.ts` +- Create: `~/local-dev/getpipher/armory-memory/test/exports.test.mts` + +**Interfaces:** +- Produces: `@getpipher/armory-memory` public surface — `renderMemoryBlock(cwd: string, opts?: InjectOptions): string`, `listMemory(cwd: string): MemoryFile[]`, `memoryDirFor(cwd: string): string`, `toSlug(cwd: string): string`, `fromSlug(slug: string): string`, plus types `MemoryFile`, `InjectOptions`. + +- [ ] **Step 1: Write the failing test** + +`test/exports.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderMemoryBlock, listMemory, memoryDirFor, toSlug } from "../src/index.ts"; + +test("exports surface re-exports the pure store functions", () => { + assert.equal(typeof renderMemoryBlock, "function"); + assert.equal(typeof listMemory, "function"); + assert.equal(typeof memoryDirFor, "function"); + assert.equal(typeof toSlug, "function"); +}); + +test("toSlug is reachable via the public surface", () => { + assert.equal(toSlug("/Users/x/proj"), "-Users-x-proj"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd ~/local-dev/getpipher/armory-memory && node --import tsx --test test/exports.test.mts` +Expected: FAIL — `Cannot find module '../src/index.ts'` + +- [ ] **Step 3: Create `src/index.ts` (stable re-export)** + +```ts +// src/index.ts — public stable surface for @getpipher/armory-memory. +// src/memory-store.ts remains the implementation; this file is the typed seam +// consumers (e.g. @getpipher/armory-fleet) depend on. +export { + renderMemoryBlock, + listMemory, + memoryDirFor, + toSlug, + fromSlug, + importProject, + importAll, + discoverCCProjects, + PI_MEMORY_ROOT, + CC_PROJECTS_ROOT, + type MemoryFile, + type InjectOptions, + type ImportResult, +} from "./memory-store.ts"; +``` + +- [ ] **Step 4: Create `src/index.d.ts` (dual-condition types)** + +```ts +// src/index.d.ts — typed declaration mirroring src/index.ts. +export { + renderMemoryBlock, + listMemory, + memoryDirFor, + toSlug, + fromSlug, + importProject, + importAll, + discoverCCProjects, + PI_MEMORY_ROOT, + CC_PROJECTS_ROOT, + type MemoryFile, + type InjectOptions, + type ImportResult, +} from "./memory-store.ts"; +``` + +- [ ] **Step 5: Add `exports` + `types` to `package.json`** + +Replace the `package.json` block (preserve name/version/scripts): +```json +{ + "name": "@getpipher/armory-memory", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "test": "node --import tsx --test test/*.test.mts" + } +} +``` +(Keep existing deps/license/keywords; only add `exports` + ensure `type: module`.) + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd ~/local-dev/getpipher/armory-memory && node --import tsx --test test/exports.test.mts` +Expected: PASS (2 tests) + +- [ ] **Step 7: Run the existing suite to confirm no regression** + +Run: `cd ~/local-dev/getpipher/armory-memory && node --import tsx --test test/*.test.mts` +Expected: PASS (existing memory-store tests + new exports test) + +- [ ] **Step 8: Commit + push + open PR** + +```bash +cd ~/local-dev/getpipher/armory-memory +git checkout -b feat/exports-surface +git add package.json src/index.ts src/index.d.ts test/exports.test.mts +git commit -m "feat: add exports surface (index.ts + index.d.ts) for typed consumers" +git push -u origin feat/exports-surface +gh pr create --title "feat: add exports surface for typed consumers" --body "Mirrors armory-todo #12/#13. Adds exports map + src/index.ts re-exporting the pure memory-store functions + src/index.d.ts for TS consumers (dual-condition: types→.d.ts, default→.ts). Additive, no behavior change." --base main +``` + +**Gate:** merge the armory-memory PR before Task 7 (fleet `pnpm install` against `file:../armory-memory` needs the new `exports`). + +--- + +## Task 2: Companion PR — vision public API + `createVisionDelegator` + +**Repo:** `~/local-dev/getpipher/vision` (branch `feat/exports-surface` → PR to vision main) + +**Files:** +- Modify: `~/local-dev/getpipher/vision/package.json` (add `exports`) +- Create: `~/local-dev/getpipher/vision/src/index.ts` +- Create: `~/local-dev/getpipher/vision/src/index.d.ts` +- Modify: `~/local-dev/getpipher/vision/lib/delegate.ts` (add `createVisionDelegator`) +- Create: `~/local-dev/getpipher/vision/test/delegator.test.mts` + +**Interfaces:** +- Produces: `@getpipher/vision` public surface — `isMultimodal(model)`, `loadConfig(agentDir)`, `createVisionDelegator(deps)`, types `VisionConfig`, `DelegateParams`, `DelegateResult`. +- `createVisionDelegator({ modelRuntime, cwd, agentDir }): { delegate(params, signal?): Promise, config: VisionConfig }` — encapsulates the `ModelRuntime → modelRegistry` adaptation inside vision (where `ModelRegistry` knowledge lives), so fleet never builds an `ExtensionContext`. + +- [ ] **Step 1: Verify the `ModelRegistry` → `ModelRuntime` adaptation surface** + +Run: +```bash +PI_SRC=/Users/rector/.nvm/versions/node/v24.13.0/lib/node_modules/@earendil-works/pi-coding-agent/dist +sed -n '1,60p' "$PI_SRC/core/model-registry.d.ts" +grep -n "getApiKeyAndHeaders\|find" "$PI_SRC/core/model-registry.d.ts" +grep -n "getAuth\b" "$PI_SRC/core/model-runtime.d.ts" +``` +Read the output. `delegateToVisionModel` uses `ctx.modelRegistry.find(provider, model)` + `ctx.modelRegistry.getApiKeyAndHeaders(model)`. `ModelRuntime` has `getModel(providerId, modelId)` (≈ `find`) and `getAuth(model, overrides): Promise`. Determine from `model-registry.d.ts` exactly what `getApiKeyAndHeaders` returns (likely `{ ok, apiKey, headers }`) and map `ModelRuntime.getAuth` → that shape in the adapter shim below. Record the exact mapping in a comment. + +- [ ] **Step 2: Write the failing test** + +`test/delegator.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isMultimodal, loadConfig, createVisionDelegator } from "../src/index.ts"; + +test("isMultimodal + loadConfig + createVisionDelegator are exported", () => { + assert.equal(typeof isMultimodal, "function"); + assert.equal(typeof loadConfig, "function"); + assert.equal(typeof createVisionDelegator, "function"); +}); + +test("createVisionDelegator returns a delegator with config + delegate fn", () => { + const fakeRuntime = { + getModel: () => undefined, + getAuth: async () => undefined, + }; + const d = createVisionDelegator({ modelRuntime: fakeRuntime as any, cwd: "/tmp", agentDir: "/tmp" }); + assert.equal(typeof d.delegate, "function"); + assert.equal(typeof d.config, "object"); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd ~/local-dev/getpipher/vision && npx tsx --test test/delegator.test.mts` +Expected: FAIL — `Cannot find module '../src/index.ts'` + +- [ ] **Step 4: Add `createVisionDelegator` to `lib/delegate.ts`** + +Append to `lib/delegate.ts` (after the existing `delegateToVisionModel`): +```ts +// ── ModelRuntime-backed delegator (for non-extension consumers like armory-fleet). +// Encapsulates the ModelRuntime → { modelRegistry, cwd } adaptation inside vision, +// so consumers don't construct an ExtensionContext. +export interface VisionDelegatorDeps { + /** Minimal ModelRuntime slice: find a model + resolve its auth headers. */ + modelRuntime: { + getModel(providerId: string, modelId: string): Model | undefined; + getAuth(model: Model, overrides?: Record): Promise; + }; + cwd: string; + agentDir: string; +} +export interface VisionDelegator { + delegate(params: DelegateParams, signal?: AbortSignal | undefined): Promise; + config: VisionConfig; +} + +/** Adapt a ModelRuntime slice to the { modelRegistry, cwd } shape delegateToVisionModel reads. */ +function adaptRuntimeToCtx(deps: VisionDelegatorDeps): { modelRegistry: any; cwd: string } { + // Map ModelRuntime.getAuth → getApiKeyAndHeaders. Verify the exact return shape + // against dist/core/model-registry.d.ts (getApiKeyAndHeaders) at impl time; + // the AuthResult from getAuth carries the apiKey + headers delegateToVisionModel needs. + // (Step 1 of this task records the precise mapping.) + return { + cwd: deps.cwd, + modelRegistry: { + find: (provider: string, id: string) => deps.modelRuntime.getModel(provider, id), + getApiKeyAndHeaders: async (model: Model) => { + const auth = await deps.modelRuntime.getAuth(model) as any; + if (!auth || !auth.ok) return { ok: false, error: auth?.error ?? "auth unresolved" }; + return { ok: true, apiKey: auth.apiKey, headers: auth.headers ?? {} }; + }, + }, + }; +} + +export function createVisionDelegator(deps: VisionDelegatorDeps): VisionDelegator { + const config = loadConfig(deps.agentDir); + const ctx = adaptRuntimeToCtx(deps); + return { + config, + delegate: (params, signal) => delegateToVisionModel(ctx as any, config, params, signal), + }; +} +``` +(Adjust the `getApiKeyAndHeaders` mapping per Step 1's finding. The interface + approach are fixed; only the auth-shape mapping is impl-verified.) + +- [ ] **Step 5: Create `src/index.ts` + `src/index.d.ts`** + +`src/index.ts`: +```ts +export { isMultimodal, TOOL_NAME } from "../lib/capability.ts"; +export { loadConfig, configFilePath } from "../lib/config.ts"; +export { + delegateToVisionModel, + createVisionDelegator, + type DelegateParams, + type DelegateResult, + type DelegateSuccess, + type DelegateFailure, + type VisionDelegator, + type VisionDelegatorDeps, +} from "../lib/delegate.ts"; +export type { VisionConfig } from "../lib/config.ts"; +``` +`src/index.d.ts`: mirror the same exports (dual-condition: `types`→.d.ts, `default`→.ts). + +- [ ] **Step 6: Add `exports` to `package.json`** + +```json +"exports": { ".": { "types": "./src/index.d.ts", "default": "./src/index.ts" } }, +"type": "module", +``` + +- [ ] **Step 7: Run tests to verify pass** + +Run: `cd ~/local-dev/getpipher/vision && npx tsx --test test/delegator.test.mts && pnpm typecheck && pnpm test:run` +Expected: PASS (delegator tests + existing 203-test suite green; typecheck clean) + +- [ ] **Step 8: Commit + push + open PR** + +```bash +cd ~/local-dev/getpipher/vision +git checkout -b feat/exports-surface +git add package.json src/index.ts src/index.d.ts lib/delegate.ts test/delegator.test.mts +git commit -m "feat: add exports surface + createVisionDelegator for non-extension consumers" +git push -u origin feat/exports-surface +gh pr create --title "feat: exports surface + createVisionDelegator" --body "Mirrors armory-todo #12/#13. Adds exports map + src/index.ts re-exporting capability/delegate/config pure functions + src/index.d.ts. Adds createVisionDelegator({modelRuntime,cwd,agentDir}) so consumers with a ModelRuntime (e.g. armory-fleet) can delegate without constructing an ExtensionContext. Additive." --base main +``` + +**Gate:** merge the vision PR before Task 7. + +--- + +## Task 3: `MemoryHydratePort` + `ArmoryMemoryAdapter` + +**Files:** +- Create: `src/memory-hydrate/port.ts` +- Create: `src/memory-hydrate/adapter.ts` +- Create: `test/memory-hydrate-adapter.test.mts` + +**Interfaces:** +- Consumes: `@getpipher/armory-memory` `renderMemoryBlock`, `listMemory` (Task 1's exports). +- Produces: `MemoryHydratePort` (`renderScopes(scopes) → string`), `ArmoryMemoryAdapter`. + +- [ ] **Step 1: Write the failing test** + +`test/memory-hydrate-adapter.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { ArmoryMemoryAdapter } from "../src/memory-hydrate/adapter.ts"; + +function seed(root: string, cwd: string, files: Record): void { + const dir = join(root, cwd.replace(/\//g, "-")); + mkdirSync(dir, { recursive: true }); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body); +} + +test("renderScopes concatenates non-empty scopes in project → local → user order", () => { + const root = `/tmp/armory-mem-test-${Date.now()}`; + process.env.ARMORY_MEMORY_ROOT = root; + seed(root, "/proj", { "p.md": "# Project\nproj body" }); + seed(root, "/parent", { "l.md": "# Local\nlocal body" }); + seed(root, "/__armory-fleet-user__", { "u.md": "# User\nuser body" }); + try { + const adapter = new ArmoryMemoryAdapter(); + const block = adapter.renderScopes({ project: "/proj", local: "/parent", user: "/__armory-fleet-user__" }); + const pIdx = block.indexOf("Project"); + const lIdx = block.indexOf("Local"); + const uIdx = block.indexOf("User"); + assert.ok(pIdx >= 0 && lIdx > pIdx && uIdx > lIdx, "project before local before user"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("renderScopes returns empty string when all scopes empty", () => { + const root = `/tmp/armory-mem-empty-${Date.now()}`; + process.env.ARMORY_MEMORY_ROOT = root; + try { + const adapter = new ArmoryMemoryAdapter(); + assert.equal(adapter.renderScopes({ project: "/none", local: "/none2", user: "/none3" }), ""); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --import tsx --test test/memory-hydrate-adapter.test.mts` +Expected: FAIL — `Cannot find module '../src/memory-hydrate/adapter.ts'` + +- [ ] **Step 3: Create `src/memory-hydrate/port.ts`** + +```ts +// src/memory-hydrate/port.ts — fleet-owned port; fleet core depends only on this. +export interface MemoryScopes { + /** The cwd the child works in (= parentCwd in SPEC-2; the project cwd at SPEC-5a worktree). */ + 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; +} +export interface MemoryHydratePort { + /** Render the three-scope memory block (project → local → user), concatenated. Empty when all scopes empty. */ + renderScopes(scopes: MemoryScopes): string; +} +``` + +- [ ] **Step 4: Create `src/memory-hydrate/adapter.ts`** + +```ts +// src/memory-hydrate/adapter.ts — ONLY file importing @getpipher/armory-memory. +import { renderMemoryBlock, listMemory } from "@getpipher/armory-memory"; +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 + .map((cwd) => renderMemoryBlock(cwd)) + .join("\n\n"); // → "" when all three empty + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `node --import tsx --test test/memory-hydrate-adapter.test.mts` +Expected: PASS (2 tests) + +- [ ] **Step 6: Commit** + +```bash +git add src/memory-hydrate/port.ts src/memory-hydrate/adapter.ts test/memory-hydrate-adapter.test.mts +git commit -m "feat(spec-2): MemoryHydratePort + ArmoryMemoryAdapter (three-scope hydration)" +``` + +--- + +## Task 4: `VisionPort` + `ArmoryVisionAdapter` + +**Files:** +- Create: `src/vision/port.ts` +- Create: `src/vision/adapter.ts` +- Create: `test/vision-adapter.test.mts` + +**Interfaces:** +- Consumes: `@getpipher/vision` `isMultimodal`, `loadConfig`, `createVisionDelegator` (Task 2's exports); fleet's `ModelRuntime` + `getAgentDir()`. +- Produces: `VisionPort` (`isMultimodal(model)`, `delegate(params, signal?)`, `isConfigured()`). + +- [ ] **Step 1: Write the failing test** + +`test/vision-adapter.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ArmoryVisionAdapter } from "../src/vision/adapter.ts"; + +test("isMultimodal delegates to vision's isMultimodal", () => { + const adapter = new ArmoryVisionAdapter({ + getModel: () => undefined, + getAuth: async () => undefined, + } as any, "/tmp", "/tmp"); + assert.equal(adapter.isMultimodal(undefined), false); + assert.equal(adapter.isMultimodal({ input: ["text", "image"] } as any), true); + assert.equal(adapter.isMultimodal({ input: ["text"] } as any), false); +}); + +test("isConfigured reflects loadConfig", () => { + const adapter = new ArmoryVisionAdapter({ + getModel: () => undefined, + getAuth: async () => undefined, + } as any, "/tmp", "/tmp"); + assert.equal(typeof adapter.isConfigured(), "boolean"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --import tsx --test test/vision-adapter.test.mts` +Expected: FAIL — module not found + +- [ ] **Step 3: Create `src/vision/port.ts`** + +```ts +// src/vision/port.ts — fleet-owned port; fleet core depends only on this. +import type { Model } from "@earendil-works/pi-ai"; +export interface VisionDelegateParams { + /** Absolute path to the image file the child read. */ + imagePath: string; + /** Optional analysis prompt. */ + prompt?: string; +} +export type VisionDelegateResult = { ok: true; text: string } | { ok: false; error: string }; +export interface VisionPort { + isMultimodal(model: Model | undefined): boolean; + delegate(params: VisionDelegateParams, signal?: AbortSignal): Promise; + /** Whether a vision model is configured in the host vision.json. */ + isConfigured(): boolean; +} +``` + +- [ ] **Step 4: Create `src/vision/adapter.ts`** + +```ts +// src/vision/adapter.ts — ONLY file importing @getpipher/vision. +import { isMultimodal, createVisionDelegator, type VisionConfig, type DelegateResult } from "@getpipher/vision"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { Model } from "@earendil-works/pi-ai"; +import type { VisionPort, VisionDelegateParams, VisionDelegateResult } from "./port.ts"; + +/** Minimal ModelRuntime slice the adapter needs. */ +export interface VisionModelRuntime { + getModel(providerId: string, modelId: string): Model | undefined; + getAuth(model: Model, overrides?: Record): Promise; +} + +export class ArmoryVisionAdapter implements VisionPort { + private readonly delegator: ReturnType; + constructor(modelRuntime: VisionModelRuntime, cwd: string, agentDir: string) { + this.delegator = createVisionDelegator({ modelRuntime, cwd, agentDir }); + } + isMultimodal(model: Model | undefined): boolean { + return isMultimodal(model); + } + isConfigured(): boolean { + const c = this.delegator.config as VisionConfig; + return Boolean(c.enabled && c.provider && c.model); + } + async delegate(params: VisionDelegateParams, signal?: AbortSignal): Promise { + if (!this.isConfigured()) { + return { ok: false, error: "no vision model configured; run `/vision model ` in the host or set `vision: false` on this agent." }; + } + const result: DelegateResult = await this.delegator.delegate( + { image_path: params.imagePath, prompt: params.prompt ?? "", compress: true, reasoning: this.delegator.config.defaultReasoningEffort ?? "medium" }, + signal, + ); + return result.ok ? { ok: true, text: result.text } : { ok: false, error: result.error.message }; + } +} +``` +(If `defaultReasoningEffort` isn't the exact config field name, verify against `lib/config.ts` `VisionConfig` and adjust — the field is `reasoning`/`defaultReasoningEffort`; Step 4 of Task 2 exposed `VisionConfig` so tsc will catch a mismatch.) + +- [ ] **Step 5: Run test to verify it passes** + +Run: `node --import tsx --test test/vision-adapter.test.mts` +Expected: PASS (2 tests) + +- [ ] **Step 6: Commit** + +```bash +git add src/vision/port.ts src/vision/adapter.ts test/vision-adapter.test.mts +git commit -m "feat(spec-2): VisionPort + ArmoryVisionAdapter (capability-aware delegation)" +``` + +--- + +## Task 5: The fleet `describe_image` tool + +**Files:** +- Create: `src/vision/describe-image-tool.ts` +- Create: `test/describe-image-tool.test.mts` + +**Interfaces:** +- Consumes: `VisionPort` (Task 4). +- Produces: `createDescribeImageTool(visionPort)` → a pi `ToolDefinition` (the child's `describe_image`). + +- [ ] **Step 1: Write the failing test** + +`test/describe-image-tool.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createDescribeImageTool } from "../src/vision/describe-image-tool.ts"; + +function fakePort(text: string): any { + return { + isMultimodal: () => false, + isConfigured: () => true, + delegate: async () => ({ ok: true, text }), + }; +} + +test("describe_image tool delegates via VisionPort and returns text", async () => { + const tool = createDescribeImageTool(fakePort("a cat sitting on a laptop")); + const result = await tool.execute!("t1", { image: "/tmp/x.png" }, undefined, undefined, undefined as any); + assert.equal(result.content[0].text, "a cat sitting on a laptop"); +}); + +test("describe_image returns actionable error when not configured", async () => { + const tool = createDescribeImageTool({ isMultimodal: () => false, isConfigured: () => false, delegate: async () => ({ ok: false, error: "no" }) } as any); + const result = await tool.execute!("t1", { image: "/tmp/x.png" }, undefined, undefined, undefined as any) as any; + assert.ok(result.isError); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --import tsx --test test/describe-image-tool.test.mts` +Expected: FAIL — module not found + +- [ ] **Step 3: Create `src/vision/describe-image-tool.ts`** + +```ts +// src/vision/describe-image-tool.ts — fleet-defined describe_image for child sessions. +// Mirrors @getpipher/vision's describe_image contract so user muscle memory transfers, +// but execute() delegates via VisionPort (vision's extension never loads into the child). +import { Type, type Static } from "typebox"; +import type { VisionPort } from "./port.ts"; + +const Params = Type.Object({ + image: Type.String({ description: "Absolute path to the image file to analyze." }), + prompt: Type.Optional(Type.String({ description: "Optional question/instruction for the analysis." })), +}); +type P = Static; + +export function createDescribeImageTool(visionPort: VisionPort) { + return { + name: "describe_image", + label: "Vision", + description: + "Analyze an image file and return a text description. Use when you read an image file and need to understand its contents. " + + "Pass an absolute image path and an optional analysis prompt.", + promptSnippet: "Analyze an image file and return a text description", + inputSchema: Params, + execute: async (_toolCallId: string, params: P, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { + const result = await visionPort.delegate({ imagePath: params.image, prompt: params.prompt }); + if (result.ok) { + return { content: [{ type: "text" as const, text: result.text }] }; + } + return { content: [{ type: "text" as const, text: result.error }], isError: true as const }; + }, + }; +} +``` +(Adjust the `execute` signature to match pi's `ToolDefinition` execute shape exactly — verify against `dist/core/extensions/types.d.ts` `ToolDefinition` / `AgentToolResult` at impl time. The `content: [{type:"text",text}]` + `isError` shape matches pi's tool-result convention per SPEC-1 §4.2.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --import tsx --test test/describe-image-tool.test.mts` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/vision/describe-image-tool.ts test/describe-image-tool.test.mts +git commit -m "feat(spec-2): fleet-defined describe_image tool (VisionPort delegation)" +``` + +--- + +## Task 6: The `CustomResourceLoader` builder (`child-loader.ts`) + +**Files:** +- Create: `src/engine/child-loader.ts` +- Create: `test/child-loader.test.mts` + +**Interfaces:** +- Consumes: `MemoryHydratePort` (Task 3), `VisionPort` (Task 4), `AgentDef` (existing `frontmatter.ts`), pi `DefaultResourceLoader`, `getAgentDir`. +- Produces: `buildChildLoader(opts)` → a `DefaultResourceLoader` configured with `noExtensions: true` + composed `systemPromptOverride` + `skillsOverride`; `composeChildPrompt({rolePrompt, memoryBlock, base})` (exported for unit testing); `USER_PSEUDO_CWD` constant. + +- [ ] **Step 1: Write the failing test** + +`test/child-loader.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { composeChildPrompt, USER_PSEUDO_CWD } from "../src/engine/child-loader.ts"; + +test("composeChildPrompt orders rolePrompt → memoryBlock → base, omitting empty memory", () => { + const out = composeChildPrompt({ rolePrompt: "PERSONA", memoryBlock: "## Memory\nstuff", base: "## Tools\n..." }); + assert.equal(out, "PERSONA\n\n## Memory\nstuff\n\n## Tools\n..."); +}); + +test("composeChildPrompt omits the memory block when empty", () => { + const out = composeChildPrompt({ rolePrompt: "PERSONA", memoryBlock: "", base: "## Tools\n..." }); + assert.equal(out, "PERSONA\n\n## Tools\n..."); +}); + +test("USER_PSEUDO_CWD is a stable sentinel", () => { + assert.equal(USER_PSEUDO_CWD, "/__armory-fleet-user__"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --import tsx --test test/child-loader.test.mts` +Expected: FAIL — module not found + +- [ ] **Step 3: Create `src/engine/child-loader.ts`** + +```ts +// src/engine/child-loader.ts — the fleet CustomResourceLoader builder. +// Promotes SPEC-1's DefaultResourceLoader-with-overrides to deliberate control: +// noExtensions (deterministic child, no host-extension leakage), composed +// systemPromptOverride (rolePrompt + memoryBlock + base), scoped skills. +import { DefaultResourceLoader, getAgentDir } from "@earendil-works/pi-coding-agent"; +import { dirname } from "node:path"; +import type { AgentDef } from "../registry/frontmatter.ts"; +import type { MemoryHydratePort } from "../memory-hydrate/port.ts"; + +/** Fixed pseudo-cwd for the global cross-project user memory scope. */ +export const USER_PSEUDO_CWD = "/__armory-fleet-user__"; + +export interface ChildLoaderOpts { + cwd: string; + agent: AgentDef; + memoryPort: MemoryHydratePort; +} + +/** Compose the child system prompt: rolePrompt → memoryBlock → base (empty memoryBlock omitted). */ +export function composeChildPrompt(args: { rolePrompt: string; memoryBlock: string; base: string }): string { + const { rolePrompt, memoryBlock, base } = args; + return [rolePrompt, memoryBlock, 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 fleet CustomResourceLoader for a child session. */ +export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader { + const scopes = memoryScopesFor(opts.cwd); + const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(scopes) : ""; + const loader = new DefaultResourceLoader({ + cwd: opts.cwd, + agentDir: getAgentDir(), + noExtensions: true, + systemPromptOverride: (base: string) => composeChildPrompt({ rolePrompt: opts.agent.rolePrompt, memoryBlock, base }), + skillsOverride: (cur: { skills: { name: string }[]; diagnostics: unknown }) => ({ + skills: opts.agent.skills && opts.agent.skills.length + ? cur.skills.filter((s) => opts.agent.skills!.includes(s.name)) + : cur.skills, + diagnostics: cur.diagnostics, + }), + }); + return loader; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --import tsx --test test/child-loader.test.mts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/child-loader.ts test/child-loader.test.mts +git commit -m "feat(spec-2): CustomResourceLoader builder (noExtensions + composed prompt)" +``` + +--- + +## Task 7: Frontmatter — `memoryHydrate` + `vision` fields + +**Files:** +- Modify: `src/registry/frontmatter.ts` (add fields to `AgentDef` + `parseAgentFile`) +- Create: `test/frontmatter-spec2.test.mts` + +**Interfaces:** +- Produces: `AgentDef.memoryHydrate: boolean` (default `true`), `AgentDef.vision: boolean` (default `true`). + +- [ ] **Step 1: Write the failing test** + +`test/frontmatter-spec2.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseAgentFile } from "../src/registry/frontmatter.ts"; + +const FRONT = `--- +name: reviewer +description: reviews code +memoryHydrate: false +vision: false +--- +body`; +const GLOBAL = `--- +name: x +description: y +--- +body`; + +test("memoryHydrate + vision parse as booleans", () => { + const a = parseAgentFile(FRONT, "reviewer.md", "project"); + assert.equal(a.memoryHydrate, false); + assert.equal(a.vision, false); +}); + +test("memoryHydrate + vision default to true when omitted", () => { + const a = parseAgentFile(GLOBAL, "x.md", "global"); + assert.equal(a.memoryHydrate, true); + assert.equal(a.vision, true); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --import tsx --test test/frontmatter-spec2.test.mts` +Expected: FAIL — `a.memoryHydrate` undefined + +- [ ] **Step 3: Modify `src/registry/frontmatter.ts`** + +Add to `AgentDef` (after `todoSync: boolean;`): +```ts + memoryHydrate: boolean; + vision: boolean; +``` +Add after the `todoSync` line in `parseAgentFile`: +```ts + 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); +``` +And add `memoryHydrate,` + `vision,` to the returned object literal. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --import tsx --test test/frontmatter-spec2.test.mts` +Expected: PASS (2 tests) + +- [ ] **Step 5: Run the full suite + typecheck** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS — the existing frontmatter tests still pass (new fields default-on, no behavior change for agents that omit them). + +- [ ] **Step 6: Commit** + +```bash +git add src/registry/frontmatter.ts test/frontmatter-spec2.test.mts +git commit -m "feat(spec-2): frontmatter memoryHydrate + vision fields (bool, default true)" +``` + +--- + +## Task 8: `spawnSubagent` — thread ports, `excludeTools`, `customTools` + +**Files:** +- Modify: `src/engine/spawnSubagent.ts` +- Modify: `src/engine/spawnSubagent.ts` (`ChildSessionOpts` + `ChildSessionFactory` — add `memoryPort`, `visionPort`) +- Create: `test/spawn-subagent-spec2.test.mts` + +**Interfaces:** +- Consumes: `MemoryHydratePort`, `VisionPort`, `createDescribeImageTool` (Task 5), `buildChildLoader` (Task 6). +- Produces: `SpawnOptions` grows `memoryPort: MemoryHydratePort` + `visionPort: VisionPort`; `ChildSessionOpts` grows the same; the child factory uses `excludeTools: ["todo"]` + conditional `customTools`. + +- [ ] **Step 1: Write the failing test** + +`test/spawn-subagent-spec2.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSubagent } from "../src/engine/spawnSubagent.ts"; +import { RunRegistry } from "../src/engine/run-registry.ts"; +import { createSingleSlotLock } from "../src/engine/concurrency-lock.ts"; + +const noopPort = { linkOrCreateRunTodo: async () => ({ todoId: null }), markRunTodoDone: async () => {}, markRunTodoReverted: async () => {} } as any; +const memPort = { renderScopes: () => "## Memory\nblock" } as any; +const visPort = { isMultimodal: () => false, isConfigured: () => true, delegate: async () => ({ ok: true, text: "desc" }) } as any; + +test("spawnSubagent threads memoryPort + visionPort to the child factory", async () => { + let received: any = {}; + const factory = { + async create(opts: any) { + received = opts; + return { + session: { + prompt: async () => {}, + subscribe: () => () => {}, + abort: async () => {}, + dispose: () => {}, + }, + model: "ollama/qwen3", + }; + }, + }; + const reg = new RunRegistry(); + const result = await spawnSubagent({ + agent: "general-purpose", + task: "do it", + registry: new Map([["general-purpose", { name: "general-purpose", description: "", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, source: "builtin", filePath: "x" } as any]]), + todoSync: noopPort, runRegistry: reg, lock: createSingleSlotLock(), + childFactory: factory, parentModel: { provider: "ollama", id: "qwen3" }, parentCwd: "/proj", + memoryPort: memPort, visionPort: visPort, + } as any); + assert.equal(result.status, "completed"); + assert.equal(received.memoryPort, memPort); + assert.equal(received.visionPort, visPort); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --import tsx --test test/spawn-subagent-spec2.test.mts` +Expected: FAIL — `memoryPort` not on `SpawnOptions` / not threaded + +- [ ] **Step 3: Modify `src/engine/spawnSubagent.ts`** + +In `ChildSessionOpts`, add: +```ts + memoryPort: MemoryHydratePort; + visionPort: VisionPort; +``` +In `SpawnOptions`, add: +```ts + memoryPort: MemoryHydratePort; + visionPort: VisionPort; +``` +(Add imports: `import type { MemoryHydratePort } from "../memory-hydrate/port.ts"; import type { VisionPort } from "../vision/port.ts";`) + +In `spawnSubagent`, the `opts.childFactory.create({ ... })` call already passes `cwd, model, thinkingLevel, tools, rolePrompt, skills, task`. Add `memoryPort: opts.memoryPort` + `visionPort: opts.visionPort` to that object. + +The actual `excludeTools: ["todo"]` + `customTools` wiring happens in `src/index.ts`'s `createChildSessionFactory` (Task 9) — `spawnSubagent` itself stays engine-logic; it just threads the ports through. The tool computation line `const tools = baseTools.filter((t) => !FLEET_OWNED_TOOLS.includes(t));` is **removed** (the `excludeTools` on createAgentSession replaces it). Keep `FLEET_OWNED_TOOLS` as a comment reference or remove it; `PI_DEFAULT_TOOLS` stays. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --import tsx --test test/spawn-subagent-spec2.test.mts` +Expected: PASS + +- [ ] **Step 5: Run the full spawnSubagent test suite** + +Run: `node --import tsx --test test/spawn-subagent.test.mts test/spawn-subagent-spec2.test.mts` +Expected: PASS (existing SPEC-1 tests + new SPEC-2 test; the `todo`-filter removal is covered by updating any SPEC-1 test that asserted the filtered `tools` array — adjust those assertions to expect the unfiltered built-in list since exclusion now happens at createAgentSession). + +- [ ] **Step 6: Commit** + +```bash +git add src/engine/spawnSubagent.ts test/spawn-subagent-spec2.test.mts +git commit -m "feat(spec-2): thread memoryPort + visionPort through spawnSubagent; drop todo-filter (excludeTools replaces)" +``` + +--- + +## Task 9: `index.ts` — wire adapters + build the real child factory with `excludeTools` + `customTools` + +**Files:** +- Modify: `src/index.ts` +- Create: `test/index-spec2.test.mts` (smoke that the extension loads + deps are wired) + +**Interfaces:** +- Consumes: `ArmoryMemoryAdapter` (Task 3), `ArmoryVisionAdapter` (Task 4), `buildChildLoader` (Task 6), `createDescribeImageTool` (Task 5), `ModelRuntime`. +- Produces: the extension wires `memoryPort` + `visionPort` into `deps`; `createChildSessionFactory` uses `buildChildLoader` + `excludeTools: ["todo"]` + conditional `customTools`. + +- [ ] **Step 1: Write the failing test** + +`test/index-spec2.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; + +test("extension entry exports a default function", async () => { + const mod = await import("../src/index.ts"); + assert.equal(typeof mod.default, "function"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails/passes** (it may pass since the default already exists; this is a wiring smoke) + +Run: `node --import tsx --test test/index-spec2.test.mts` + +- [ ] **Step 3: Modify `src/index.ts`** + +Add imports: +```ts +import { ArmoryMemoryAdapter } from "./memory-hydrate/adapter.ts"; +import { ArmoryVisionAdapter } from "./vision/adapter.ts"; +import { buildChildLoader } from "./engine/child-loader.ts"; +import { createDescribeImageTool } from "./vision/describe-image-tool.ts"; +import type { MemoryHydratePort } from "./memory-hydrate/port.ts"; +import type { VisionPort } from "./vision/port.ts"; +``` + +Replace `createChildSessionFactory`'s loader construction + `createAgentSession` call. The factory signature grows to accept `memoryPort` + `visionPort`: +```ts +function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort, visionPort: VisionPort): ChildSessionFactory { + return { + async create(opts) { + let model: Model | undefined; + if (opts.model) { + const slash = opts.model.indexOf("/"); + if (slash < 0) throw new Error(`agent model '${opts.model}' must be 'provider/id'`); + const provider = opts.model.slice(0, slash); + const id = opts.model.slice(slash + 1); + model = modelRuntime.getModel(provider, id); + if (!model) throw new Error(`agent model '${opts.model}' not found in runtime`); + } + const loader = buildChildLoader({ cwd: opts.cwd, agent: opts.agent, memoryPort }); + await loader.reload(); + const injectVision = opts.agent.vision && !visionPort.isMultimodal(model); + const { session } = await createAgentSession({ + cwd: opts.cwd, + model, + thinkingLevel: opts.thinkingLevel, + tools: opts.tools, + excludeTools: ["todo"], + customTools: injectVision ? [createDescribeImageTool(visionPort) as never] : [], + resourceLoader: loader, + sessionManager: SessionManager.inMemory(), + modelRuntime, + }); + return { session: session as unknown as ChildSession, model: opts.model ?? "" }; + }, + }; +} +``` +The `ChildSessionOpts` passed from `spawnSubagent` now carries `agent` (the full `AgentDef`, so the factory can read `memoryHydrate`/`vision`). Add `agent: AgentDef` to `ChildSessionOpts` and pass `opts.agent` from `spawnSubagent`. Update `spawnSubagent`'s `childFactory.create({ ... })` to include `agent: agentDef`. + +In the `deps` object, add: +```ts + memoryPort: new ArmoryMemoryAdapter(), + visionPort: new ArmoryVisionAdapter(modelRuntime, deps.parentCwd, getAgentDir()), +``` +And pass them to `createChildSessionFactory(modelRuntime, deps.memoryPort, deps.visionPort)`. + +- [ ] **Step 4: Run test + typecheck** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS — fix any `ChildSessionOpts`/`ChildSessionFactory` type drift between `spawnSubagent.ts` and `index.ts` (they share the interface; keep them in sync). + +- [ ] **Step 5: Commit** + +```bash +git add src/index.ts test/index-spec2.test.mts +git commit -m "feat(spec-2): wire memory + vision adapters; child factory uses buildChildLoader + excludeTools + conditional describe_image" +``` + +--- + +## Task 10: `general-purpose.md` — explicit `memoryHydrate` + `vision` + +**Files:** +- Modify: `agents/general-purpose.md` + +- [ ] **Step 1: Modify `agents/general-purpose.md`** + +```md +--- +name: general-purpose +description: A focused general-purpose subagent delegate. Use for any task needing isolated work. +todoSync: true +memoryHydrate: true +vision: true +--- +You are a focused subagent delegate. Complete the assigned task thoroughly, work +autonomously to completion, and return a concise result summary. Do not call the +`todo` tool — the fleet engine manages todo tracking for you. +``` + +- [ ] **Step 2: Verify the builtin parses with the new fields** + +Run: `node --import tsx --test test/frontmatter-spec2.test.mts` +Expected: PASS (the builtin's frontmatter now exercises the new fields; add an assertion if desired) + +- [ ] **Step 3: Commit** + +```bash +git add agents/general-purpose.md +git commit -m "feat(spec-2): general-purpose builtin gets explicit memoryHydrate + vision" +``` + +--- + +## Task 11: Panel — armory chip + `i:Info` detail pane + +**Files:** +- Modify: `src/panel/rows.ts` (`agentsRow` armory chip + new `agentInfo` fn) +- Modify: `src/panel/fleet-panel.ts` (`i:Info` action + detail pane rendering) +- Create: `test/panel-spec2.test.mts` + +**Interfaces:** +- Consumes: `AgentDef.memoryHydrate` + `AgentDef.vision` + `AgentDef.todoSync` (Task 7). +- Produces: `agentsRow` shows `armory:[t✓ m✓ v✓]`; `i:Info` opens a read-only detail pane. + +- [ ] **Step 1: Write the failing test** + +`test/panel-spec2.test.mts`: +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { agentsRow, agentInfo } from "../src/panel/rows.ts"; + +const agent = { name: "reviewer", description: "d", model: "anthropic/claude-sonnet-4", tools: ["read","bash"], skills: ["tdd"], rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: false, source: "project" as const, filePath: "x" }; + +test("agentsRow shows the armory chip", () => { + const row = agentsRow(agent); + assert.match(row, /armory:\[t✓ m✓ v✗\]/); +}); + +test("agentInfo renders all armory hooks + model + skills", () => { + const info = agentInfo(agent); + assert.match(info, /todoSync: ✓/); + assert.match(info, /memoryHydrate: ✓/); + assert.match(info, /vision: ✗/); + assert.match(info, /model: anthropic\/claude-sonnet-4/); + assert.match(info, /skills: tdd/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --import tsx --test test/panel-spec2.test.mts` +Expected: FAIL — `agentInfo` not exported; `agentsRow` lacks the chip + +- [ ] **Step 3: Modify `src/panel/rows.ts`** + +Replace the `agentsRow` function: +```ts +export function agentsRow(agent: AgentDef): string { + const model = agent.model ?? "(default)"; + const chip = `armory:[t${agent.todoSync ? "✓" : "✗"} m${agent.memoryHydrate ? "✓" : "✗"} v${agent.vision ? "✓" : "✗"}]`; + const skills = agent.skills?.length ? ` skills: ${agent.skills.join(",")}` : ""; + const tools = agent.tools?.length ? ` tools: ${agent.tools.join(",")}` : ""; + return `${agent.name} [${agent.source}] ${model}${tools}${skills} ${chip}`; +} + +export function agentInfo(agent: AgentDef): string { + const lines = [ + `name: ${agent.name}`, + `source: ${agent.source}`, + `model: ${agent.model ?? "(default)"}`, + `thinkingLevel: ${agent.thinkingLevel ?? "(model default)"}`, + `tools: ${agent.tools?.length ? agent.tools.join(", ") : "(pi default)"}`, + `skills: ${agent.skills?.length ? agent.skills.join(", ") : "(none)"}`, + `todoSync: ${agent.todoSync ? "✓" : "✗"}`, + `memoryHydrate: ${agent.memoryHydrate ? "✓" : "✗"}`, + `vision: ${agent.vision ? "✓" : "✗"}`, + `file: ${agent.filePath}`, + "", + "── role prompt ──", + agent.rolePrompt.trim(), + ]; + return lines.join("\n"); +} +``` + +- [ ] **Step 4: Modify `src/panel/fleet-panel.ts`** — add an `i:Info` action to the Agents submenu that opens a read-only detail pane rendering `agentInfo(activeAgent)`. Follow the existing panel's input/overlay pattern (the EditorTheme gotcha applies if using `ctx.ui.custom`; for a read-only text dump, use `ctx.ui.notify` or a simple overlay component — match the existing fleet-panel tab/overlay style). Exact rendering code depends on the existing `fleet-panel.ts` structure; the key is wiring the `i` key → `agentInfo(activeAgent)` → display, and returning to the list on Escape. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `node --import tsx --test test/panel-spec2.test.mts` +Expected: PASS (2 tests) + +- [ ] **Step 6: Commit** + +```bash +git add src/panel/rows.ts src/panel/fleet-panel.ts test/panel-spec2.test.mts +git commit -m "feat(spec-2): Agents-view armory chip + i:Info detail pane" +``` + +--- + +## Task 12: Real-pi smoke matrix (`term`-driven) + +**Files:** +- Create: `docs/SPEC-2-smoke-checklist.md` +- (No new source — this is the verification gate; mirrors SPEC-1's `docs/SPEC-1-smoke-checklist.md`) + +**Prerequisite:** armory-memory + vision companion PRs merged (Tasks 1–2); `pnpm install` re-run so `file:../armory-memory` + `file:../vision` resolve the new `exports`. + +- [ ] **Step 1: Write the smoke checklist** + +`docs/SPEC-2-smoke-checklist.md`: +```md +# SPEC-2 smoke checklist (real-pi, term-driven) + +Run inside real pi via the `term` tool. Each row: set up the agent/model, spawn a +child via the `subagent` tool or `/fleet` Run, capture the child's system prompt + +tools, and assert. + +| # | Setup | Assert | +|---|---|---| +| 1 | text-only child model, default agent | describe_image present; system prompt has 3-scope memory block; todo tool absent; no "Open-TODOs" block; no host extension hooks fired | +| 2 | multimodal child model, default agent | describe_image absent (pass-through); memory block present; todo absent | +| 3 | memoryHydrate:false agent | no memory block in child prompt | +| 4 | vision:false agent, text-only model | no describe_image injected | +| 5 | any agent | child prompt contains pi base (tool docs/guidelines/scoped skills) — confirms systemPromptOverride composes, not replaces | + +## How to inspect the child's prompt + tools +Spawn the child with a test hook that logs the composed system prompt + active +tool names on session_start, then assert via the `term` capture. (The hook can +be a throwaway project extension in .pi/extensions/ that records to a file the +smoke reads back.) +``` + +- [ ] **Step 2: Run the smoke via `term`** — spawn real pi, load the armory-fleet extension, open `/fleet`, run a child against a text-only model, capture the child's prompt + tools, assert rows 1–5. The no-cost parts (extension loads, `/fleet` opens, Agents-view shows the armory chip, `i:Info` renders) are verifiable without a model call. Rows 1–5's prompt/tool inspection needs the throwaway logging hook above. + +- [ ] **Step 3: Commit** + +```bash +git add docs/SPEC-2-smoke-checklist.md +git commit -m "docs(spec-2): real-pi smoke checklist (5-row moat + no-leak matrix)" +``` + +--- + +## Task 13: CI gate — typecheck + full suite green; release.yml staging + +**Files:** none (verification + CI config check) + +- [ ] **Step 1: Full local gate** + +Run: `cd ~/local-dev/getpipher/armory-fleet && pnpm install && pnpm typecheck && pnpm test:run` +Expected: PASS — all SPEC-1 + SPEC-2 tests green; typecheck clean against the new companion-PR exports. + +- [ ] **Step 2: Confirm `release.yml` is staged for the future v0.2.0 tag** + +Run: `cat .github/workflows/release.yml | head -20` +Expected: the release workflow exists (staged in SPEC-1); it won't fire until `v0.2.0` tag + the dep switch (RECTOR's co-release). + +- [ ] **Step 3: Open the SPEC-2 PR** + +```bash +git push -u origin feat/spec-2-deep-armory-integration +gh pr create --title "feat(spec-2): deep armory integration (memory + vision, fleet-owned)" --body "SPEC-2 completes the child-side moat. See specs/SPEC-2-deep-armory-integration.md. Companion PRs: armory-memory (exports), vision (exports + createVisionDelegator). Cursor deferred to SPEC-5b." --base main +``` +**Gate:** request review (requesting-code-review skill), address findings, merge to main, delete branch. + +--- + +## Self-Review (run after writing; fix inline) + +**Spec coverage:** +- §1 Overview → Tasks 3–9 (the moat) ✓ +- §2 Architecture (CustomResourceLoader) → Task 6 ✓ +- §3 Components → file structure maps to tasks ✓ +- §4 Memory hydration → Tasks 1, 3 ✓ +- §5 Vision → Tasks 2, 4, 5 ✓ +- §6 Frontmatter → Task 7 ✓ +- §7 Spawn lifecycle delta → Tasks 8, 9 ✓ +- §8 Agents-view → Task 11 ✓ +- §9 Guards (hardened todo-exclusion) → Tasks 8, 9 (`excludeTools`) ✓ +- §10 Error handling → Task 4 (not-configured), Task 9 (noExtensions) ✓ +- §11 Testing → unit tests in each task + Task 12 smoke ✓ +- §12 Deferred (cursor) → not implemented (correct) ✓ +- §13 Done bar → Tasks 1–12 ✓ + +**Placeholder scan:** The two `verify against source` steps (Task 2 Step 1 ModelRegistry mapping; Task 5 Step 3 ToolDefinition execute shape) are impl-verification steps with exact files to check — not vague TODOs. Acceptable. + +**Type consistency:** `MemoryHydratePort.renderScopes(scopes)` used identically in Tasks 3, 6, 9 ✓. `VisionPort.{isMultimodal,delegate,isConfigured}` used identically in Tasks 4, 5, 8, 9 ✓. `AgentDef.memoryHydrate`/`vision` added in Task 7, read in Tasks 6, 9, 11 ✓. `USER_PSEUDO_CWD` defined Task 6, used Task 6 ✓. + +--- + +## Execution Handoff + +Plan complete and saved to `plans/SPEC-2-deep-armory-integration.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?** \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 981789e..69318f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,15 @@ importers: '@earendil-works/pi-tui': specifier: ^0.81.1 version: 0.81.1 + '@getpipher/armory-memory': + specifier: file:../armory-memory + version: file:../armory-memory(@earendil-works/pi-ai@0.81.1(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) '@getpipher/armory-todo': specifier: file:../armory-todo version: file:../armory-todo(@earendil-works/pi-ai@0.81.1(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) + '@getpipher/vision': + specifier: file:../vision + version: file:../vision(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) typebox: specifier: ^1.1.38 version: 1.3.7 @@ -323,6 +329,20 @@ packages: cpu: [x64] os: [win32] + '@getpipher/armory-memory@file:../armory-memory': + resolution: {directory: ../armory-memory, type: directory} + peerDependencies: + '@earendil-works/pi-ai': '*' + '@earendil-works/pi-coding-agent': '*' + typebox: '*' + peerDependenciesMeta: + '@earendil-works/pi-ai': + optional: true + '@earendil-works/pi-coding-agent': + optional: true + typebox: + optional: true + '@getpipher/armory-todo@file:../armory-todo': resolution: {directory: ../armory-todo, type: directory} peerDependencies: @@ -337,6 +357,13 @@ packages: typebox: optional: true + '@getpipher/vision@file:../vision': + resolution: {directory: ../vision, type: directory} + engines: {node: '>=20'} + peerDependencies: + '@earendil-works/pi-coding-agent': '*' + typebox: '*' + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -1173,12 +1200,23 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@getpipher/armory-memory@file:../armory-memory(@earendil-works/pi-ai@0.81.1(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': + optionalDependencies: + '@earendil-works/pi-ai': 0.81.1(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-coding-agent': 0.81.1(ws@8.21.1)(zod@4.4.3) + typebox: 1.3.7 + '@getpipher/armory-todo@file:../armory-todo(@earendil-works/pi-ai@0.81.1(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': optionalDependencies: '@earendil-works/pi-ai': 0.81.1(ws@8.21.1)(zod@4.4.3) '@earendil-works/pi-coding-agent': 0.81.1(ws@8.21.1)(zod@4.4.3) typebox: 1.3.7 + '@getpipher/vision@file:../vision(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': + dependencies: + '@earendil-works/pi-coding-agent': 0.81.1(ws@8.21.1)(zod@4.4.3) + typebox: 1.3.7 + '@google/genai@1.52.0': dependencies: google-auth-library: 10.9.1 diff --git a/scripts/spec-2-smoke.mts b/scripts/spec-2-smoke.mts new file mode 100644 index 0000000..1b79992 --- /dev/null +++ b/scripts/spec-2-smoke.mts @@ -0,0 +1,132 @@ +// scripts/spec-2-smoke.mts — SPEC-2 full-run smoke (rows 1-5). +// Exercises the REAL factory components (buildChildLoader + new ModelRegistry(realRuntime) +// + createAgentSession({customTools, excludeTools})) — the runtime-unverified path — +// and inspects the composed system prompt + tools. One trivial real prompt() call confirms +// the end-to-end spawn path doesn't crash. +// +// Run: node --import tsx scripts/spec-2-smoke.mts +import { ModelRuntime, ModelRegistry, createAgentSession, SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { buildChildLoader, USER_PSEUDO_CWD } from "../src/engine/child-loader.ts"; +import { ArmoryMemoryAdapter } from "../src/memory-hydrate/adapter.ts"; +import { ArmoryVisionAdapter } from "../src/vision/adapter.ts"; +import { createDescribeImageTool } from "../src/vision/describe-image-tool.ts"; +import type { AgentDef } from "../src/registry/frontmatter.ts"; + +const PARENT_CWD = "/Users/rector/local-dev/getpipher/armory-fleet"; +const TEXT_ONLY = { provider: "Ollama", id: "glm-5.2:cloud" }; +const MULTIMODAL = { provider: "Ollama", id: "minimax-m3:cloud" }; + +function agent(over: Partial = {}): AgentDef { + return { + name: "smoke", description: "smoke", rolePrompt: "You are a smoke-test delegate. Reply minimally.", + todoSync: true, memoryHydrate: true, vision: true, source: "builtin", filePath: "/x", ...over, + }; +} + +let pass = 0, fail = 0; +function check(name: string, cond: boolean, detail = ""): void { + if (cond) { console.log(` ✔ ${name}`); pass++; } + else { console.log(` ✖ ${name} ${detail}`); fail++; } +} + +// Seed temp memory (project + local + user) under a temp root. +const memRoot = `/tmp/fleet-smoke-mem-${Date.now()}`; +process.env.ARMORY_MEMORY_ROOT = memRoot; +function seed(cwd: string, files: Record): void { + const dir = join(memRoot, cwd.replace(/\//g, "-")); + mkdirSync(dir, { recursive: true }); + for (const [n, b] of Object.entries(files)) writeFileSync(join(dir, n), b); +} +seed(PARENT_CWD, { "project.md": "# Project smoke memory\nfleet-specific note." }); +seed(dirname(PARENT_CWD), { "local.md": "# Local org memory\ngetpipher-wide note." }); +seed(USER_PSEUDO_CWD, { "user.md": "# User global memory\ncross-project note." }); + +async function buildAndInspect(a: AgentDef, modelSpec: { provider: string; id: string }) { + const runtime = await ModelRuntime.create(); + const model = runtime.getModel(modelSpec.provider, modelSpec.id); + if (!model) throw new Error(`model ${modelSpec.provider}/${modelSpec.id} not found in runtime`); + const memoryPort = new ArmoryMemoryAdapter(); + const loader = buildChildLoader({ cwd: PARENT_CWD, agent: a, memoryPort }); + await loader.reload(); + // Capture the composed system prompt by invoking the override with a known base. + // The loader stores systemPromptOverride; reload() wired it. Read it via the loader's + // getExtensions? Simpler: reconstruct via composeChildPrompt by calling renderScopes directly. + const { composeChildPrompt, memoryScopesFor } = await import("../src/engine/child-loader.ts"); + const scopes = memoryScopesFor(PARENT_CWD); + const memoryBlock = a.memoryHydrate ? memoryPort.renderScopes(scopes) : ""; + const composed = composeChildPrompt({ rolePrompt: a.rolePrompt, memoryBlock, base: "## Available tools\n- read\n" }); + // Real factory path: ModelRegistry + createAgentSession with customTools + excludeTools + const visionPort = new ArmoryVisionAdapter({ modelRegistry: new ModelRegistry(runtime), cwd: PARENT_CWD, agentDir: getAgentDir() }); + const injectVision = a.vision && !visionPort.isMultimodal(model); + const customTools: any[] = injectVision ? [createDescribeImageTool(visionPort) as never] : []; + const { session } = await createAgentSession({ + cwd: PARENT_CWD, model, thinkingLevel: a.thinkingLevel, + tools: a.tools ?? ["read", "bash", "edit", "write"], + excludeTools: ["todo"], + customTools, + resourceLoader: loader, + sessionManager: SessionManager.inMemory(), + modelRuntime: runtime, + }); + return { composed, customTools, isMultimodal: visionPort.isMultimodal(model), session, model }; +} + +console.log("\n=== SPEC-2 full-run smoke (rows 1-5) ===\n"); + +try { + // Row 1: text-only child model, default agent + console.log("Row 1: text-only child (glm-5.2:cloud), default agent"); + const r1 = await buildAndInspect(agent(), TEXT_ONLY); + check("describe_image present (text-only child)", r1.customTools.some((t) => t.name === "describe_image")); + check("system prompt has 3-scope memory block (Project + Local + User)", + /Project smoke memory/.test(r1.composed) && /Local org memory/.test(r1.composed) && /User global memory/.test(r1.composed), r1.composed.slice(0, 200)); + check("NO 'Open-TODOs' block leaked", !/Open TODOs|armory-todo: \d+ open/i.test(r1.composed)); + check("excludeTools excludes todo (no todo in customTools)", !r1.customTools.some((t) => t.name === "todo")); + check("isMultimodal(glm-5.2:cloud) === false (text-only)", r1.isMultimodal === false); + // one trivial real prompt() — confirms the full spawn path doesn't crash + console.log(" …spawning real child prompt (text-only)…"); + await r1.session.prompt("Reply with exactly: OK"); + console.log(" ✔ real session.prompt() completed (no crash)"); + pass++; + r1.session.dispose(); + + // Row 2: multimodal child model, default agent + console.log("\nRow 2: multimodal child (minimax-m3:cloud), default agent"); + const r2 = await buildAndInspect(agent(), MULTIMODAL); + check("describe_image ABSENT (multimodal pass-through)", !r2.customTools.some((t) => t.name === "describe_image")); + check("memory block still present", /Project smoke memory/.test(r2.composed)); + check("isMultimodal(minimax-m3:cloud) === true", r2.isMultimodal === true); + r2.session.dispose(); + + // Row 3: memoryHydrate:false + console.log("\nRow 3: memoryHydrate:false agent"); + const r3 = await buildAndInspect(agent({ memoryHydrate: false }), TEXT_ONLY); + check("NO memory block", !/Project smoke memory|Local org memory|User global memory/.test(r3.composed)); + check("describe_image still present (vision unaffected)", r3.customTools.some((t) => t.name === "describe_image")); + r3.session.dispose(); + + // Row 4: vision:false + text-only + console.log("\nRow 4: vision:false agent, text-only model"); + const r4 = await buildAndInspect(agent({ vision: false }), TEXT_ONLY); + check("NO describe_image (vision:false)", !r4.customTools.some((t) => t.name === "describe_image")); + check("memory block still present", /Project smoke memory/.test(r4.composed)); + r4.session.dispose(); + + // Row 5: composed prompt includes the pi base + console.log("\nRow 5: composed prompt includes pi base"); + const r5 = await buildAndInspect(agent(), TEXT_ONLY); + check("base ('## Available tools') present in composed prompt", /## Available tools/.test(r5.composed)); + check("rolePrompt present in composed prompt", /smoke-test delegate/.test(r5.composed)); + r5.session.dispose(); +} catch (e) { + console.log(`\n✖ SMOKE CRASHED: ${(e as Error).message}\n${(e as Error).stack}`); + fail++; +} +finally { + rmSync(memRoot, { recursive: true, force: true }); +} + +console.log(`\n=== smoke result: ${pass} pass, ${fail} fail ===`); +process.exit(fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/specs/SPEC-2-deep-armory-integration.md b/specs/SPEC-2-deep-armory-integration.md new file mode 100644 index 0000000..bf8841f --- /dev/null +++ b/specs/SPEC-2-deep-armory-integration.md @@ -0,0 +1,409 @@ +# SPEC-2 — Deep armory integration + +> **Status:** DRAFT (brainstorming output, pre-plan) · **Owner:** RECTOR · **Created:** 2026-07-24 +> **Package:** `@getpipher/armory-fleet` · **npm org:** getpipher (account `rz1989`) · **Repo:** `getpipher/armory-fleet` +> **Compatibility:** pi `^0.81.1` +> **Pipeline position:** PRD (done) → SPEC-1 (done, merged) → **SPEC-2 (this)** → spec → plan → implementation → SPEC-3 … +> **Anchors:** Master PRD [`../PRD.md`](../PRD.md) §8 SPEC-2 · SPEC-1 spec [`./SPEC-1-core-engine-todo-sync.md`](./SPEC-1-core-engine-todo-sync.md) · Landscape research [`../research/`](../research/) + +--- + +## 1. Overview & goals + +SPEC-2 completes the **child-side moat** (PRD §2: "agents armory-native from birth"). SPEC-1 landed todo-sync; SPEC-2 makes every fleet-spawned subagent **memory-hydrated** and **vision-capable** by default — delivered **deliberately by fleet**, not inherited accidentally from the host. + +**In scope (v0.2):** +- A fleet-owned `CustomResourceLoader` that takes deliberate control of the child's extension set, system prompt, and tools (promoting SPEC-1's `DefaultResourceLoader`-with-overrides per SPEC-1 Q8/§12). +- `MemoryHydratePort` + `ArmoryMemoryAdapter` — three-scope memory hydration (project / local / user) into the child's system prompt. +- `VisionPort` + `ArmoryVisionAdapter` — capability-aware `describe_image` injection: text-only child model → fleet injects a delegation tool; multimodal → pass-through, no tool. +- Two companion PRs to the sibling packages (armory-memory, vision) adding `exports` + `index.ts`/`.d.ts` re-exporting the pure functions fleet consumes (same shape as the SPEC-1 armory-todo companion PRs #12/#13). +- Frontmatter additions: `memoryHydrate` (default `true`) + `vision` (default `true`) — the armory-hook toggles, consistent with SPEC-1's `todoSync`. +- Hardened `todo`-exclusion via `excludeTools: ["todo"]` (replaces SPEC-1's fragile active-set omission). +- `systemPromptOverride` composition fix — the child keeps pi's base system prompt (tool docs, guidelines, scoped skills, context files) + gains the role prompt + memory block, instead of SPEC-1's replace-everything-with-rolePrompt. +- Agents-view maturity: an armory-hook chip per agent row + a read-only `i:Info` detail pane (PRD §8 "Agents-view matures to show armory-hooks per agent"). +- A real-pi smoke matrix proving the moat is delivered AND the negative space holds (no double-injection, no leaked host-extension context, deterministic child). + +**Out of scope (deferred):** +- **cursor** — a TUI editor component; a child SDK session is headless (no editor surface). "Cursor editor in child sessions" (PRD §8) is a category error; cursor lands at **SPEC-5b** (Fleet TUI) where the *fleet panel's* task composer / mid-run steering get the editor. See §12 + decision log Q2. +- Per-agent `memoryHydrate` scope selection and per-agent `vision.model` override — SPEC-5b/SPEC-6 power-knobs. +- Worktree-isolated memory cwd — SPEC-5a (the `MemoryHydratePort` contract is forward-compatible; see §4.1). + +**Done bar (v0.2):** A fleet-spawned subagent is memory-hydrated (three scopes) and vision-capable (delegation when text-only, pass-through when multimodal) by default, with `todo` never callable and no host-extension leakage into the child — all delivered by fleet's own loader + ports, deterministically. The full moat (todo + memory + vision) is real and verified in real pi. Cursor remains a recorded SPEC-5b deferral. + +**Competitive dimension (PRD §8 SPEC-2):** Moat complete — uncopyable. Nobody else has subagents that are TODO-synced + memory-hydrated + vision-capable by deliberate construction. + +--- + +## 2. Architecture — the `CustomResourceLoader` + +### 2.1 The inheritance finding (negative-space guide) + +A child session built via `createAgentSession` + `DefaultResourceLoader` (exactly what SPEC-1 ships) **inherits the host's global packages** — `~/.pi/agent/settings.json` `packages` (armory-memory, vision, armory-todo, every installed extension) are re-resolved by the loader's own `DefaultPackageManager` and loaded into the child. So a SPEC-1 child is *accidentally* armory-native: armory-memory's `before_agent_start` injects memory; vision's `syncToolAvailability` adds/removes `describe_image` against the child's model; armory-todo injects the "Open-TODOs" block. + +SPEC-2 **rejects accidental inheritance** in favor of **deliberate fleet ownership** (SPEC-1 §9.1 principle: *"vision/cursor/memory hooks get injected into the child deliberately by fleet's loader, not inherited accidentally — same single-writer discipline"*). The inheritance finding is retained as the **negative-space guide**: it tells fleet exactly what the `CustomResourceLoader` must suppress to avoid double-injection and leakage. + +### 2.2 Why fleet-owned, not inherited + +| Reason | Detail | +|---|---| +| **SPEC-1 §9.1 already decided it** | deliberate-injection over accidental-inheritance is on the books; inheritance contradicts it. | +| **Deterministic children** | Inheritance makes the child's capability set a function of the host's `settings.json` — non-deterministic across environments (laptop vs CI vs end-user). Fleet-owned makes it a function of fleet + agent frontmatter. | +| **No leaks** | Inheritance loads the host's *entire* extension pile — armory-todo's "Open-TODOs" block leaks into the child's prompt; cursor loads and no-ops; arbitrary extensions may misbehave headless. Fleet-owned = exactly rolePrompt + memory + vision-tool, nothing else. | +| **Moat depth** | "Composition by coincidence" is trivially copied; a typed port contract is a deeper integration. The ports-and-adapters **is** the moat's substance. | +| **Contract stability** | A port pins the contract; the adapter absorbs sibling breaking changes. CI typecheck alarms on sibling churn (same as SPEC-1 todo-sync). | +| **Ecosystem coherence** | armory-todo already has `exports` + `.d.ts`. vision/memory getting the same is the consistent getpipher pattern. | + +### 2.3 The loader (promotes SPEC-1's `DefaultResourceLoader`-with-overrides) + +The child factory constructs a `DefaultResourceLoader` with three deliberate controls SPEC-1 left accidental: + +```ts +const loader = new DefaultResourceLoader({ + cwd: parentCwd, + agentDir: getAgentDir(), + noExtensions: true, // (1) suppress ALL host extensions → deterministic child, no double-injection, no Open-TODOs leak + systemPromptOverride: (base) => composeChildPrompt({ // (2) compose, don't replace + rolePrompt: agent.rolePrompt, + memoryBlock: agent.memoryHydrate ? memoryPort.renderScopes({ project: parentCwd, local: parentDir, user: USER_PSEUDO_CWD }) : "", + base, // pi's base: tool snippets, guidelines, context files (AGENTS.md cascade), scoped skills + }), // → rolePrompt + "\n\n" + memoryBlock + "\n\n" + base (empty memoryBlock omitted) + skillsOverride: (cur) => ({ // SPEC-1 carry-over: scope skills to the agent's declared set + skills: agent.skills.length ? cur.skills.filter((s) => agent.skills.includes(s.name)) : cur.skills, + diagnostics: cur.diagnostics, + }), +}); +await loader.reload(); +``` + +Then the child session (note the hardened `excludeTools` + conditional `customTools`): + +```ts +const { session } = await createAgentSession({ + cwd: parentCwd, + model, + thinkingLevel: agent.thinkingLevel, + tools: childTools, // built-ins (agent.tools ?? ["read","bash","edit","write"]) + excludeTools: ["todo"], // (3) hardened single-writer guard (replaces SPEC-1 active-set omission) + customTools: (agent.vision && !visionPort.isMultimodal(model)) ? [fleetDescribeImageTool] : [], + resourceLoader: loader, + sessionManager: SessionManager.inMemory(), + modelRuntime, +}); +``` + +### 2.4 The `systemPromptOverride` composition (SPEC-1 remediation) + +SPEC-1's override was `() => rolePrompt` — it **discarded** pi's base system prompt, so the child lost tool snippets, guidelines, context files, and — critically — the **scoped skills' prompt contributions** (which SPEC-1 went to the trouble of shaping via `skillsOverride`). SPEC-2 changes the override to `(base) => rolePrompt + "\n\n" + memoryBlock + "\n\n" + base`, composing the persona + memory on top of pi's base. The child now gets the full framework prompt + its persona + its project/workspace/user memory. The `base` argument is the value the loader would have used (resource-loader.js: `systemPrompt = override ? override(baseSystemPrompt) : baseSystemPrompt`); fleet forwards it instead of ignoring it. + +**Prompt order:** `rolePrompt → memoryBlock → base`. Persona first (standard), memory next (project context salient to the task), pi's base last (tool/guideline framework). The order is tunable; this is the v0.2 default. + +--- + +## 3. Components (file layout — additions/changes vs SPEC-1) + +``` +src/ + memory-hydrate/ + port.ts # MemoryHydratePort interface (fleet-owned) + adapter.ts # ArmoryMemoryAdapter — only file importing @getpipher/armory-memory + vision/ + port.ts # VisionPort interface (fleet-owned) + adapter.ts # ArmoryVisionAdapter — only file importing @getpipher/vision + describe-image-tool.ts # the fleet-defined describe_image tool (thin wrapper over VisionPort.delegate) + engine/ + spawnSubagent.ts # MODIFIED: pass memoryPort/visionPort to the child factory; excludeTools hardening + child-loader.ts # NEW: the CustomResourceLoader builder (noExtensions + compose + skills) + registry/ + frontmatter.ts # MODIFIED: add memoryHydrate + vision fields (bool, default true) + panel/ + fleet-panel.ts # MODIFIED: Agents-view armory chip + i:Info detail pane + rows.ts # MODIFIED: armory-chip row fn + info-pane content fn + index.ts # MODIFIED: wire ArmoryMemoryAdapter + ArmoryVisionAdapter into deps; pass to factory +agents/ + general-purpose.md # MODIFIED: explicit memoryHydrate: true + vision: true +``` + +--- + +## 4. Memory hydration — port + adapter + +### 4.1 The port (fleet-owned) + +```ts +// src/memory-hydrate/port.ts +export interface MemoryScopes { + project: string; // cwd — the project the child works in (= parentCwd in SPEC-2) + local: string; // parent-directory / workspace cwd (the org level, e.g. ~/local-dev/getpipher) + user: string; // a fixed global pseudo-cwd for cross-project user memory (e.g. USER_PSEUDO_CWD) +} + +export interface MemoryHydratePort { + /** Render the three-scope memory block (project → local → user), concatenated. Empty scopes contribute nothing. */ + renderScopes(scopes: MemoryScopes): string; +} +``` + +**Forward-compat (recorded, not built):** the port takes explicit scope cwds, not an implicit "the child's cwd." In SPEC-2 `project = parentCwd`. At SPEC-5a (worktree isolation) the child's cwd becomes a worktree but `project` stays the parent project's cwd — so the subagent hydrates the *project's* memory even when isolated. The contract is stable across SPECs; only the caller's choice of `project` changes. + +### 4.2 The adapter (only armory-memory importer) + +```ts +// src/memory-hydrate/adapter.ts +import { renderMemoryBlock, listMemory } from "@getpipher/armory-memory"; +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) + .map((cwd) => renderMemoryBlock(cwd)) // armory-memory's existing cwd-keyed primitive + .join("\n\n"); // → "" when all three scopes are empty + } +} +``` + +armory-memory needs **no new scope logic** — the three-tier scope is fleet-owned composition over armory-memory's cwd-keyed primitive. The companion PR to armory-memory is just the `exports` map + `index.ts`/`.d.ts` re-exporting `renderMemoryBlock`, `listMemory`, `memoryDirFor`, `toSlug` (same shape as armory-todo #12/#13). + +### 4.3 The three scopes (PRD §8 interpretation) + +The PRD says "scoped per project/local/user" without defining the terms. SPEC-2 interprets them by mirroring pi's own context-file cascade (project → parent dirs → global `~/.pi/agent/`): + +| Scope | Means | Memory dir | Delivered by | +|---|---|---|---| +| **project** | the cwd the child works in | `~/.pi/agent/memory//` (armory-memory's existing dir) | armory-memory as-is | +| **local** | the **immediate parent directory** of the project cwd (the workspace/org level — e.g. for cwd `~/local-dev/getpither/armory-fleet`, `local` = `~/local-dev/getpither`) | `~/.pi/agent/memory//` | fleet calls `renderMemoryBlock(dirname(projectCwd))` | +| **user** | global, cross-project user memory | a fixed pseudo-cwd dir (e.g. `~/.pi/agent/memory/_user/`) | fleet calls `renderMemoryBlock(USER_PSEUDO_CWD)` | + +`USER_PSEUDO_CWD` is a fleet-defined constant (e.g. `"/__armory-fleet-user__"`) whose slug resolves to a stable global memory dir. The value is a fleet concern, not an armory-memory concern. + +--- + +## 5. Vision — port + adapter + the fleet `describe_image` tool + +### 5.1 The port (fleet-owned) + +```ts +// src/vision/port.ts +import type { Model } from "@earendil-works/pi-ai"; +export interface VisionPort { + isMultimodal(model: Model | undefined): boolean; + delegate(params: { image: string; prompt?: string }): Promise; // delegates to the configured vision model + loadConfig(): { provider?: string; model?: string } | null; // reads the host vision.json +} +``` + +### 5.2 The adapter (only vision importer) + +```ts +// src/vision/adapter.ts +import { isMultimodal, delegateToVisionModel, loadConfig } from "@getpipher/vision"; +export class ArmoryVisionAdapter implements VisionPort { /* … */ } +``` + +The adapter reads the **host's `vision.json`** (the config the user set via the host `/vision` panel) — "armory-native from birth" = inherit the host's configured vision model. If no vision model is configured, `delegate(...)` returns an actionable error: *"no vision model configured; run `/vision model ` in the host or set `vision: false` on this agent."* Per-agent `vision.model` override is deferred (SPEC-5b power-knob). + +The companion PR to vision: `exports` + `index.ts`/`.d.ts` re-exporting `isMultimodal` (from `lib/capability.ts`), `delegateToVisionModel` (from `lib/delegate.ts`), `loadConfig` (from `lib/config.ts`), and the `VisionConfig` type. + +### 5.3 The fleet `describe_image` tool (injected into the child, text-only only) + +```ts +// src/vision/describe-image-tool.ts +// A fleet-defined tool whose execute() calls visionPort.delegate(...). +// Registered into the child via createAgentSession's customTools (text-only child + agent.vision only). +``` + +The child's `describe_image` is **fleet-defined**, not vision's own extension tool — vision's extension never loads into the child (`noExtensions: true`). The tool's `execute()` calls `visionPort.delegate({ image, prompt })` and returns the text description. The model-callable surface mirrors vision's own `describe_image` contract (path/image input, optional prompt) so user muscle memory transfers. + +### 5.4 Capability-aware rule (applied to the CHILD's model) + +- `agent.vision === false` → never inject `describe_image` (opt-out). +- child model multimodal (`visionPort.isMultimodal(model)`) → **no `describe_image`**; the child's own `read` tool produces image attachments that pass through natively to the child model. Zero delegation, zero extra tokens — the vision thesis, applied to the child. +- child model text-only → inject the fleet `describe_image` via `customTools`; the child calls it when it `read`s an image file. + +**Image path into the child (v0.2):** the child's own `read` tool (built-in) is the only image path. Path-referenced images in the task *string* are NOT auto-attached (that's the host vision extension's paste-hook job; out of scope for headless children). A parent can instruct "read ./img.png" and the child's `read` tool handles it. + +### 5.5 Impl-verification flag (mechanical, not architectural) + +To be confirmed at plan/impl time: **how to inject the fleet `describe_image` custom tool into a child session under `noExtensions: true`.** Two viable paths: (a) `createAgentSession` accepts a `customTools`/`extensionFactories` option that loads even with `noExtensions`, or (b) fleet ships a tiny internal extension via `extensionFactories` that registers the tool. Both achieve the same design goal. Resolve against the SDK source (`dist/core/sdk.js`, `dist/core/resource-loader.js`) when writing the plan. + +--- + +## 6. Frontmatter schema additions + +SPEC-1 §7.2 deferred `memoryHydrate`/`vision`/`cursor`. SPEC-2 lands two; cursor stays deferred. Consistent with the SPEC-1 precedent: **named field, default-on, toggleable — the moat as a visible contract.** + +| Field | v0.2 | Default | Notes | +|---|---|---|---| +| `todoSync` | ✅ (SPEC-1) | `true` | link/track runs in armory-todo | +| `memoryHydrate` | ✅ **NEW** | `true` | hydrate three-scope memory (project/local/user) into the child's system prompt; `false` = opt-out (throwaway delegate) | +| `vision` | ✅ **NEW** | `true` | capability-aware: inject `describe_image` when child model text-only; pass-through when multimodal; `false` = never inject | +| `cursor` | ❌ | — | deferred to SPEC-5b (Fleet TUI) | + +**Shape: bool, not rich object** — consistent with `todoSync`. Per-agent scope selection (`memoryHydrate: { scopes: [...] }`) and per-agent `vision.model` override are deferred to SPEC-5b/SPEC-6 power-knobs. + +### 6.1 The `general-purpose` builtin (grown) + +```md +--- +name: general-purpose +description: A focused general-purpose subagent delegate. Use for any task needing isolated work. +todoSync: true +memoryHydrate: true +vision: true +--- +You are a focused subagent delegate. Complete the assigned task thoroughly, work +autonomously to completion, and return a concise result summary. Do not call the +`todo` tool — the fleet engine manages todo tracking for you. +``` + +--- + +## 7. The spawn lifecycle — what changes from SPEC-1 §5 + +SPEC-1's 8-step lifecycle is unchanged in shape; SPEC-2 modifies step 4 (child session construction) and adds the port wiring. Delta: + +- **Step 2 (Engine — resolve model/tools):** model resolution unchanged. Tool computation now: `tools = (agent.tools ?? PI_DEFAULT_TOOLS)`; the `todo`-exclusion moves from `baseTools.filter(...)` to `excludeTools: ["todo"]` on `createAgentSession`. `customTools = (agent.vision && !visionPort.isMultimodal(model)) ? [fleetDescribeImageTool] : []`. +- **Step 4 (Child session):** the `DefaultResourceLoader` is built per §2.3 (`noExtensions: true`, composed `systemPromptOverride`, `skillsOverride`). `memoryPort` + `visionPort` are threaded into the child factory (new deps on `SpawnOptions`/`ChildSessionOpts`). +- **Steps 1, 3, 5–8:** unchanged (todo-sync lifecycle, run registry, Esc-abort, turn budget, dispose). Memory/vision are child-construction concerns; they don't touch the run↔todo link. + +`memoryHydrate: false` → the `systemPromptOverride` skips the `memoryPort.renderScopes` call (empty memoryBlock, omitted from composition). `vision: false` → `customTools` is empty regardless of model modality. + +--- + +## 8. The `/fleet` panel — Agents-view maturity + +### 8.1 Agents-view row (armory chip) + +SPEC-1 §8.2 row grows an **armory chip** showing the three hooks' state per agent: + +``` +reviewer · [project] · anthropic/claude-sonnet-4 · tools:4 · skills:2 · armory:[t✓ m✓ v✓] +throwaway · [global] · ollama/qwen3 · tools:4 · skills:0 · armory:[t✓ m✗ v✗] +``` + +`t`=todoSync, `m`=memoryHydrate, `v`=vision. Compact to respect panel width. + +### 8.2 `i:Info` action (read-only detail pane) + +New action on the Agents submenu: `i:Info` opens a read-only detail pane showing the full parsed frontmatter (all hooks, model, thinkingLevel, tools, skills, role-prompt preview). Useful for inspecting an agent's armory-hooks without leaving the panel. SPEC-1's `r/e/d/q` actions unchanged. + +### 8.3 Action submenu (Agents, updated) + +| View / row | Actions | +|---|---| +| Agents | `r` Run · `e` Edit · `i` Info (detail pane) · `d` Reload · `q` Quit | + +The Fleet-view submenu is unchanged from SPEC-1 §8.3. + +--- + +## 9. Guards (SPEC-1 §9 generalized + hardened) + +### 9.1 `todo` excluded from child tools — HARDENED +SPEC-1 excluded `todo` by omitting it from the `tools` active-set — fragile (relies on armory-todo never calling `setActiveTools`). SPEC-2 hardens it: `excludeTools: ["todo"]` on `createAgentSession` (sdk.js applies `excludeTools` after any `tools` allowlist). Defense-in-depth: with `noExtensions: true`, armory-todo's extension doesn't load into the child at all, so `todo` is never registered — the `excludeTools` is a belt-and-suspenders guard against any future tool named `todo`. + +### 9.2 Single-writer discipline — generalized + enforced +SPEC-1 §9.1 stated the principle ("vision/cursor/memory hooks injected deliberately by fleet's loader, not inherited accidentally"). SPEC-2 enforces it structurally: `noExtensions: true` means **no host extension hooks fire in the child**; fleet is the sole injector of memory (via `systemPromptOverride`) and vision (via `customTools`). No accidental inheritance is possible. + +### 9.3 Concurrency=1, turn budget, Esc-abort — unchanged from SPEC-1 §9. + +--- + +## 10. Error handling + +Every failure is actionable and specific (CIPHER constraints). New SPEC-2 failure modes: + +- **No vision model configured** (text-only child, `vision: true`, host has no `vision.json` model) → the child's `describe_image` returns: *"no vision model configured; run `/vision model ` in the host or set `vision: false` on this agent."* The run is not failed — the child can proceed without image analysis; the error surfaces per-call. +- **armory-memory not installed** (the `ArmoryMemoryAdapter` import resolves to nothing) → fleet fails fast at extension load with an actionable notify: *"armory-memory not found; install `@getpipher/armory-memory` or set `memoryHydrate: false` on all agents."* (Or, if the port is optional, memory hydration silently no-ops — decide at plan time; lean: fail-fast, the moat is a contract.) +- **vision not installed** (text-only child, `vision: true`) → same fail-fast pattern: *"vision not found; install `@getpipher/vision` or set `vision: false`."* +- **systemPromptOverride composition error** → the override must never crash the child; wrap in try/catch, fall back to `base` only (memory omitted), notify. + +The SPEC-1 error modes (unknown agent, linked todo not found, child provider error, turn-budget, concurrency-busy) are unchanged. + +--- + +## 11. Testing + +`node:test` via tsx (getpipher convention; runner = `node --import tsx --test test/*.test.mts`). Target 80%+ on new code. + +### 11.1 Unit (mocks) + +- `MemoryHydratePort` + `ArmoryMemoryAdapter` — `renderScopes` composes three `renderMemoryBlock` calls in order (project → local → user); empty-scope placeholders dropped; correct concatenation; the adapter is the only `@getpipher/armory-memory` importer. +- `VisionPort` + `ArmoryVisionAdapter` — `isMultimodal` delegates to vision's fn; `delegate` calls `delegateToVisionModel` with host config; `loadConfig` reads `vision.json`; actionable error when no vision model configured. +- `describe-image-tool.ts` — the fleet tool's `execute()` calls `visionPort.delegate(...)` and returns the text; param validation (image required). +- `child-loader.ts` — `noExtensions: true` passed; `systemPromptOverride` composes `rolePrompt + memoryBlock + base` in order, omits empty memoryBlock; `memoryHydrate:false` skips the memory call; `skillsOverride` scopes per agent; `excludeTools: ["todo"]` passed; `customTools` includes `describe_image` only when `vision:true && !isMultimodal(model)`. +- frontmatter parser — `memoryHydrate`/`vision` parsed, default `true`, bool validation, malformed → skip+warn (SPEC-1 §7.1 carry-over). +- panel rows — armory chip renders correct ✓/✗ per hook; `i:Info` detail pane content. + +### 11.2 Real-pi smoke matrix (`term`-driven; the EditorTheme-gotcha lesson) + +| # | Setup | Assert | +|---|---|---| +| 1 | text-only child model, default agent | `describe_image` tool present; system prompt contains three-scope memory block; `todo` tool absent; **no "Open-TODOs" block leaked**; no host extension hooks fired | +| 2 | multimodal child model, default agent | `describe_image` absent (pass-through); memory block present; `todo` absent | +| 3 | `memoryHydrate: false` agent | no memory block in child prompt | +| 4 | `vision: false` agent, text-only model | no `describe_image` injected | +| 5 | any agent | child prompt contains pi base (tool docs/guidelines/scoped skills) — confirms `systemPromptOverride` composes, doesn't replace | + +This is the "verify the moat is real" work AND the negative-space proof (no double-injection, no leaked host-extension context, deterministic child capability set). + +--- + +## 12. Deferred (recorded, with landing SPEC) + +| Deferral | Landing SPEC | Why deferred | +|---|---|---| +| **cursor** frontmatter field + `CursorPort` + cursor-in-fleet-panel | SPEC-5b | cursor is a TUI editor; child SDK sessions are headless. "Cursor editor in child sessions" (PRD §8) is a category error; cursor belongs in the fleet panel's task composer / mid-run steering (Fleet TUI). | +| Per-agent `memoryHydrate` scope selection (`{ scopes: [...] }`) | SPEC-5b/SPEC-6 | power-knob; bool default-on is the v0.2 moat | +| Per-agent `vision.model` override | SPEC-5b | power-knob; reuse host vision.json is the v0.2 default | +| Worktree-isolated memory cwd (`project` ≠ parentCwd) | SPEC-5a | the `MemoryHydratePort` contract is already forward-compatible (explicit scope cwds) | +| Path-referenced image auto-attachment in task string (vision paste-hook for children) | SPEC-5b | the child's `read` tool is the only image path in v0.2 | +| `get_run_result` / async / background / scheduling | SPEC-5a | foreground synchronous in v0.1/v0.2 | +| Conversation viewer / live widget / mid-run steering | SPEC-5b | transcripts ephemeral | +| Cross-harness `backend` (`pi\|claude`) | SPEC-3 | dual-arsenal | + +Nothing is silently dropped; every deferral is recorded with its landing SPEC. The PRD §8 "cursor editor in child sessions" wording is reconciled here (decision log Q2) — same flavor as the SPEC-4 "role-per-phase" flag SPEC-1 §7.3 recorded. + +--- + +## 13. Done bar / success criteria (v0.2) + +- ✅ A fleet-spawned subagent is **memory-hydrated** by default (three-scope: project/local/user) via `MemoryHydratePort` + `ArmoryMemoryAdapter`; the memory block is composed into the child's system prompt alongside pi's base + the role prompt. +- ✅ A fleet-spawned subagent is **vision-capable** by default: text-only child model → fleet `describe_image` injected (delegates to the host's configured vision model); multimodal child model → pass-through, no tool. +- ✅ `todo` is **never callable** in a child (hardened `excludeTools: ["todo"]` + `noExtensions: true` belt-and-suspenders). +- ✅ **No host-extension leakage** into the child — no "Open-TODOs" block, no cursor no-op, no arbitrary extension hooks; the child is a deterministic delegate. +- ✅ `memoryHydrate: false` + `vision: false` opt-outs work per agent; frontmatter fields default `true`. +- ✅ Agents-view shows the armory chip per agent + `i:Info` detail pane. +- ✅ Companion PRs to armory-memory + vision (exports + `.d.ts`) merged; fleet core depends only on the ports. +- ✅ `pnpm typecheck` + `pnpm test:run` green; the 5-row real-pi smoke matrix passes. +- ✅ The full moat (todo + memory + vision) is real and verified. + +**Competitive dimension (PRD §8 SPEC-2):** Moat complete — uncopyable. + +--- + +## 14. Decision log (brainstorm) + +| Q | Decision | +|---|---| +| Q1 (mechanism) | **Ports-and-adapters, fleet-owned** for memory + vision (cursor deferred). Mirrors SPEC-1 todo-sync; fleet owns `*Port`, `Armory*Adapter` is sole importer; companion PR per sibling (`exports` + `.d.ts`). | +| (inheritance finding) | A SPEC-1 child accidentally inherits host global packages via `DefaultResourceLoader`'s `DefaultPackageManager` reading `~/.pi/agent/settings.json`. SPEC-2 **rejects** accidental inheritance (contradicts SPEC-1 §9.1; non-deterministic; leaky; weaker moat) and retains the finding as the negative-space guide for what `noExtensions` must suppress. | +| Q2 (cursor) | **Deferred to SPEC-5b.** Cursor is a TUI editor (`CursorEditor extends CustomEditor`); child SDK sessions are headless — "cursor editor in child sessions" (PRD §8) is a category error. Cursor belongs in the fleet panel's task composer / mid-run steering (Fleet TUI). PRD §8 wording reconciled (§12). | +| Q3 (vision mechanism) | Capability check targets the **child's own model**. Multimodal → no `describe_image` (pass-through via the child's `read` tool). Text-only → fleet injects a `describe_image` (via `VisionPort.delegate`). Image path in v0.2 = the child's `read` tool only (no task-string auto-attach). | +| Q4 (memory scope) | PRD §8 sets three scopes (project/local/user). Interpretation: **project = cwd · local = parent-dir/workspace · user = global cross-project**, mirroring pi's context-file cascade. Fleet composes three `renderMemoryBlock` calls; armory-memory unchanged (cwd-keyed primitive). | +| Q5 (vision delegation config) | **Reuse the host's `vision.json`** (PRD §8 "delegate-to-vision" implies the configured vision model). Per-agent `vision.model` override deferred to SPEC-5b. Fleet-specific config rejected (invents surface the PRD doesn't imply). | +| (loader) | `CustomResourceLoader` = `DefaultResourceLoader` with `noExtensions: true` + composed `systemPromptOverride` + `skillsOverride`; child session uses `excludeTools: ["todo"]` + conditional `customTools`. | +| (systemPromptOverride) | Compose `(base) => rolePrompt + memoryBlock + base` (remediates SPEC-1's replace-everything, which discarded pi's base + scoped-skill contributions). | +| (frontmatter shape) | `memoryHydrate` + `vision` are **bool, default `true`** — consistent with `todoSync`. Rich object/scope-selection deferred to SPEC-5b/SPEC-6. | +| (todo-exclusion hardening) | `excludeTools: ["todo"]` replaces SPEC-1's active-set omission (fragile). Belt-and-suspenders with `noExtensions: true`. | +| (impl flag) | Verify at plan time how `customTools`/`extensionFactories` inject the fleet `describe_image` under `noExtensions: true` (§5.5). | + +--- + +## 15. References + +- Master PRD: [`../PRD.md`](../PRD.md) §2 (moat), §8 SPEC-2 scope +- SPEC-1 spec: [`./SPEC-1-core-engine-todo-sync.md`](./SPEC-1-core-engine-todo-sync.md) §5 (spawn lifecycle), §7.2 (frontmatter + deferred fields), §9.1 (single-writer discipline), §12 (deferrals) +- pi SDK doc: `…/pi-coding-agent/docs/sdk.md` (`createAgentSession`, `tools`/`excludeTools`/`customTools`, `DefaultResourceLoader`, `systemPromptOverride`) +- pi extensions doc: `…/pi-coding-agent/docs/extensions.md` (`before_agent_start`, `setActiveTools`) +- Sibling sources: `~/local-dev/getpipher/armory-memory/src/memory-store.ts` (`renderMemoryBlock`), `~/local-dev/getpipher/vision/lib/{capability,delegate,config}.ts` +- getpither conventions + EditorTheme gotcha: `~/local-dev/getpipher/AGENTS.md` +- SPEC-1 companion PRs (the pattern): armory-todo #12 (`exports` + `index.ts`) + #13 (`index.d.ts` + dual-condition exports) \ No newline at end of file diff --git a/src/engine/child-loader.ts b/src/engine/child-loader.ts new file mode 100644 index 0000000..aded5b9 --- /dev/null +++ b/src/engine/child-loader.ts @@ -0,0 +1,47 @@ +// src/engine/child-loader.ts — the fleet CustomResourceLoader builder. +// Promotes SPEC-1's DefaultResourceLoader-with-overrides to deliberate control: +// noExtensions (deterministic child, no host-extension leakage), composed +// systemPromptOverride (rolePrompt + memoryBlock + base), scoped skills. +import { DefaultResourceLoader, getAgentDir } from "@earendil-works/pi-coding-agent"; +import { dirname } from "node:path"; +import type { AgentDef } from "../registry/frontmatter.ts"; +import type { MemoryHydratePort } from "../memory-hydrate/port.ts"; + +/** Fixed pseudo-cwd for the global cross-project user memory scope. */ +export const USER_PSEUDO_CWD = "/__armory-fleet-user__"; + +/** Compose the child system prompt: rolePrompt → memoryBlock → base (empty memoryBlock omitted). */ +export function composeChildPrompt(args: { rolePrompt: string; memoryBlock: string; base: string }): string { + 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 }; +} + +export interface ChildLoaderOpts { + cwd: string; + agent: AgentDef; + memoryPort: MemoryHydratePort; +} + +/** Build the fleet CustomResourceLoader for a child session. */ +export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader { + const scopes = memoryScopesFor(opts.cwd); + const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(scopes) : ""; + return new DefaultResourceLoader({ + cwd: opts.cwd, + agentDir: getAgentDir(), + noExtensions: true, + systemPromptOverride: (base) => + composeChildPrompt({ rolePrompt: opts.agent.rolePrompt, memoryBlock, base: base ?? "" }), + skillsOverride: (cur) => ({ + skills: + opts.agent.skills && opts.agent.skills.length + ? cur.skills.filter((s) => opts.agent.skills!.includes(s.name)) + : cur.skills, + diagnostics: cur.diagnostics, + }), + }); +} \ No newline at end of file diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index 8f21b37..ed06e85 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -1,13 +1,21 @@ // src/engine/spawnSubagent.ts import type { AgentDef, ThinkingLevel } from "../registry/frontmatter.ts"; import type { FleetRunStatus, TodoSyncPort } from "../todo-sync/port.ts"; +import type { MemoryHydratePort } from "../memory-hydrate/port.ts"; +import type { VisionPort } from "../vision/port.ts"; import { genRunId, RunRegistry } from "./run-registry.ts"; import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts"; import type { SingleSlotLock } from "./concurrency-lock.ts"; const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"]; -/** Tools the child must never call — fleet owns them (single-writer guard, SPEC-1 §9.1). */ -const FLEET_OWNED_TOOLS = ["todo"]; + +/** No-op ports used when a caller omits them (e.g. SPEC-1 unit tests). Production (index.ts) passes real ports. */ +const NOOP_MEMORY_PORT: MemoryHydratePort = { renderScopes: () => "" }; +const NOOP_VISION_PORT: VisionPort = { + isMultimodal: () => false, + isConfigured: () => false, + delegate: async () => ({ ok: false, error: "no vision port configured" }), +}; /** Minimal event shape the engine reads from a child session (decoupled from pi's internal event types). */ export interface ChildSessionEvent { @@ -34,6 +42,9 @@ export interface ChildSessionOpts { rolePrompt: string; skills: string[]; task: string; + agent: AgentDef; + memoryPort: MemoryHydratePort; + visionPort: VisionPort; } export interface ChildSessionFactory { @@ -54,6 +65,8 @@ export interface SpawnOptions { childFactory: ChildSessionFactory; parentModel: { provider: string; id: string }; parentCwd: string; + memoryPort?: MemoryHydratePort; + visionPort?: VisionPort; signal?: AbortSignal; onEvent?: (e: ChildSessionEvent) => void; } @@ -97,9 +110,11 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { // resolve model const model = opts.model ?? agentDef.model ?? `${opts.parentModel.provider}/${opts.parentModel.id}`; - // compute child tools (exclude fleet-owned — SPEC-1 §9.1) - const baseTools = agentDef.tools ?? PI_DEFAULT_TOOLS; - const tools = baseTools.filter((t) => !FLEET_OWNED_TOOLS.includes(t)); + // child tools pass through UNFILTERED — the single-writer `todo`-exclusion is enforced + // downstream by the child factory's `excludeTools: ["todo"]` (SPEC-2 §9.1 hardening). + const tools = agentDef.tools ?? PI_DEFAULT_TOOLS; + const memoryPort = opts.memoryPort ?? NOOP_MEMORY_PORT; + const visionPort = opts.visionPort ?? NOOP_VISION_PORT; // run record opts.runRegistry.add({ @@ -131,6 +146,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { rolePrompt: agentDef.rolePrompt, skills: agentDef.skills ?? [], task: opts.task, + agent: agentDef, + memoryPort, + visionPort, }); const budget = createTurnBudget(maxTurns); diff --git a/src/index.ts b/src/index.ts index db6423a..5827a1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createAgentSession, - DefaultResourceLoader, ModelRuntime, + ModelRegistry, SessionManager, getAgentDir, } from "@earendil-works/pi-coding-agent"; @@ -14,6 +14,12 @@ import { discoverAgents } from "./registry/discovery.ts"; import { RunRegistry } from "./engine/run-registry.ts"; import { createSingleSlotLock } from "./engine/concurrency-lock.ts"; import { ArmoryTodoAdapter } from "./todo-sync/adapter.ts"; +import { ArmoryMemoryAdapter } from "./memory-hydrate/adapter.ts"; +import { ArmoryVisionAdapter } from "./vision/adapter.ts"; +import { buildChildLoader } from "./engine/child-loader.ts"; +import { createDescribeImageTool } from "./vision/describe-image-tool.ts"; +import type { MemoryHydratePort } from "./memory-hydrate/port.ts"; +import type { VisionPort } from "./vision/port.ts"; import type { ChildSessionFactory, ChildSession } from "./engine/spawnSubagent.ts"; import { join } from "node:path"; @@ -22,8 +28,9 @@ function builtinAgentsDir(): string { return join(new URL(".", import.meta.url).pathname, "..", "agents"); } -/** Build the real (SDK-backed) child-session factory. */ -function createChildSessionFactory(modelRuntime: ModelRuntime): ChildSessionFactory { +/** Build the real (SDK-backed) child-session factory. memoryPort is shared (cwd-agnostic); + * the vision adapter is constructed per-spawn (needs the child cwd). */ +function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort): ChildSessionFactory { return { async create(opts) { let model: Model | undefined; @@ -35,22 +42,23 @@ function createChildSessionFactory(modelRuntime: ModelRuntime): ChildSessionFact model = modelRuntime.getModel(provider, id); if (!model) throw new Error(`agent model '${opts.model}' not found in runtime (provider '${provider}', id '${id}')`); } - const loader = new DefaultResourceLoader({ + // Fleet CustomResourceLoader: noExtensions + composed systemPromptOverride (rolePrompt + memory + base) + scoped skills. + const loader = buildChildLoader({ cwd: opts.cwd, agent: opts.agent, memoryPort }); + await loader.reload(); + // Vision adapter built per-spawn (needs the child cwd); ModelRegistry wraps the shared modelRuntime. + const visionPort: VisionPort = new ArmoryVisionAdapter({ + modelRegistry: new ModelRegistry(modelRuntime), cwd: opts.cwd, agentDir: getAgentDir(), - systemPromptOverride: () => opts.rolePrompt, - skillsOverride: (cur) => ({ - // scope the child's skills to the agent's declared set; empty = all discovered - skills: opts.skills.length ? cur.skills.filter((s) => opts.skills.includes(s.name)) : cur.skills, - diagnostics: cur.diagnostics, - }), }); - await loader.reload(); + const injectVision = opts.agent.vision && !visionPort.isMultimodal(model); const { session } = await createAgentSession({ cwd: opts.cwd, model, thinkingLevel: opts.thinkingLevel, tools: opts.tools, + excludeTools: ["todo"], // SPEC-2 §9.1 hardened single-writer guard + customTools: injectVision ? [createDescribeImageTool(visionPort) as never] : [], resourceLoader: loader, sessionManager: SessionManager.inMemory(), modelRuntime, @@ -67,7 +75,7 @@ export default async function (pi: ExtensionAPI): Promise { runRegistry: new RunRegistry(), lock: createSingleSlotLock(), todoSync: new ArmoryTodoAdapter(), - childFactory: createChildSessionFactory(modelRuntime), + childFactory: createChildSessionFactory(modelRuntime, new ArmoryMemoryAdapter()), parentModel: { provider: "", id: "" }, parentCwd: "", }; diff --git a/src/memory-hydrate/adapter.ts b/src/memory-hydrate/adapter.ts new file mode 100644 index 0000000..045507b --- /dev/null +++ b/src/memory-hydrate/adapter.ts @@ -0,0 +1,12 @@ +// src/memory-hydrate/adapter.ts — ONLY file importing @getpipher/armory-memory. +import { renderMemoryBlock, listMemory } from "@getpipher/armory-memory"; +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) + .map((cwd) => renderMemoryBlock(cwd)) // armory-memory's existing cwd-keyed primitive + .join("\n\n"); // → "" when all three empty + } +} \ No newline at end of file diff --git a/src/memory-hydrate/port.ts b/src/memory-hydrate/port.ts new file mode 100644 index 0000000..68fe544 --- /dev/null +++ b/src/memory-hydrate/port.ts @@ -0,0 +1,13 @@ +// src/memory-hydrate/port.ts — fleet-owned port; fleet core depends only on this. +export interface MemoryScopes { + /** The cwd the child works in (= parentCwd in SPEC-2; the project cwd at SPEC-5a worktree). */ + 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; +} +export interface MemoryHydratePort { + /** Render the three-scope memory block (project → local → user), concatenated. Empty string when all scopes empty. */ + renderScopes(scopes: MemoryScopes): string; +} \ No newline at end of file diff --git a/src/panel/fleet-panel.ts b/src/panel/fleet-panel.ts index 270e48c..009ca90 100644 --- a/src/panel/fleet-panel.ts +++ b/src/panel/fleet-panel.ts @@ -11,7 +11,7 @@ import { } from "@earendil-works/pi-tui"; import type { AgentDef } from "../registry/frontmatter.ts"; import type { RunRecord } from "../engine/run-registry.ts"; -import { fleetRow, agentsRow } from "./rows.ts"; +import { fleetRow, agentsRow, agentInfo } from "./rows.ts"; import { spawnSubagent, type ChildSessionFactory, type SpawnResult } from "../engine/spawnSubagent.ts"; import type { RunRegistry } from "../engine/run-registry.ts"; import type { SingleSlotLock } from "../engine/concurrency-lock.ts"; @@ -47,6 +47,7 @@ export class FleetPanel extends Container { private taskInput: Input | null = null; private linkInput: Input | null = null; private linkPhase: "task" | "link" = "task"; + private infoAgent: AgentDef | null = null; constructor(opts: FleetPanelOpts) { super(); @@ -95,15 +96,22 @@ export class FleetPanel extends Container { this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0)); this.addChild(this.linkPhase === "task" ? this.taskInput! : this.linkInput!); this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0)); + } else if (this.infoAgent) { + // i:Info read-only detail pane (agents view) + for (const line of agentInfo(this.infoAgent).split("\n")) { + this.addChild(new Text(this.theme.fg("text", line), 0, 0)); + } } else { this.addChild(this.list); } this.addChild(new Spacer(1)); const hint = - this.view === "fleet" - ? " r:Run-new s:Stop o:Open-todo tab:Agents q:Quit" - : " r:Run e:Edit d:Reload tab:Fleet q:Quit"; + this.infoAgent + ? " esc:Back" + : this.view === "fleet" + ? " r:Run-new s:Stop o:Open-todo tab:Agents q:Quit" + : " r:Run e:Edit i:Info d:Reload tab:Fleet q:Quit"; this.addChild(new Text(this.theme.fg("dim", hint), 0, 0)); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder(accent)); @@ -175,6 +183,10 @@ export class FleetPanel extends Container { } handleInput(data: string): void { + if (this.infoAgent) { + if (matchesKey(data, "escape")) { this.infoAgent = null; this.renderShell(); } + return; + } if (this.runMode && (this.taskInput || this.linkInput)) { if (matchesKey(data, "escape")) { this.cancelRun(); return; } (this.linkPhase === "task" ? this.taskInput! : this.linkInput!).handleInput(data); @@ -189,6 +201,11 @@ export class FleetPanel extends Container { if (sel) this.startRun(sel.value); return; } + if (matchesKey(data, "i") && this.view === "agents") { + const sel = this.list.getSelectedItem(); + if (sel) { this.infoAgent = this.deps.registry.get(sel.value) ?? null; this.renderShell(); } + return; + } this.list.handleInput(data); this.invalidate(); } diff --git a/src/panel/rows.ts b/src/panel/rows.ts index e494c3d..7396346 100644 --- a/src/panel/rows.ts +++ b/src/panel/rows.ts @@ -27,8 +27,27 @@ export function fleetRow(run: RunRecord, ctxPercent?: number): string { export function agentsRow(agent: AgentDef): string { const model = agent.model ?? "(default)"; - const sync = agent.todoSync ? "todoSync:✓" : "todoSync:✗"; + const chip = `armory:[t${agent.todoSync ? "✓" : "✗"} m${agent.memoryHydrate ? "✓" : "✗"} v${agent.vision ? "✓" : "✗"}]`; const skills = agent.skills?.length ? ` skills: ${agent.skills.join(",")}` : ""; const tools = agent.tools?.length ? ` tools: ${agent.tools.join(",")}` : ""; - return `${agent.name} [${agent.source}] ${model}${tools}${skills} ${sync}`; + return `${agent.name} [${agent.source}] ${model}${tools}${skills} ${chip}`; +} + +export function agentInfo(agent: AgentDef): string { + const lines = [ + `name: ${agent.name}`, + `source: ${agent.source}`, + `model: ${agent.model ?? "(default)"}`, + `thinkingLevel: ${agent.thinkingLevel ?? "(model default)"}`, + `tools: ${agent.tools?.length ? agent.tools.join(", ") : "(pi default)"}`, + `skills: ${agent.skills?.length ? agent.skills.join(", ") : "(none)"}`, + `todoSync: ${agent.todoSync ? "✓" : "✗"}`, + `memoryHydrate: ${agent.memoryHydrate ? "✓" : "✗"}`, + `vision: ${agent.vision ? "✓" : "✗"}`, + `file: ${agent.filePath}`, + "", + "── role prompt ──", + agent.rolePrompt.trim(), + ]; + return lines.join("\n"); } \ No newline at end of file diff --git a/src/registry/frontmatter.ts b/src/registry/frontmatter.ts index 3d597a8..66a7636 100644 --- a/src/registry/frontmatter.ts +++ b/src/registry/frontmatter.ts @@ -14,6 +14,8 @@ export interface AgentDef { skills?: string[]; rolePrompt: string; todoSync: boolean; + memoryHydrate: boolean; + vision: boolean; source: AgentSource; filePath: string; } @@ -47,6 +49,8 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS Array.isArray(v) ? v.map((x) => String(x)) : undefined; 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); return { name, @@ -57,6 +61,8 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS skills: strList(raw.skills), rolePrompt: body, todoSync, + memoryHydrate, + vision, source, filePath, }; diff --git a/src/vision/adapter.ts b/src/vision/adapter.ts new file mode 100644 index 0000000..8e03bcb --- /dev/null +++ b/src/vision/adapter.ts @@ -0,0 +1,51 @@ +// src/vision/adapter.ts — ONLY file importing @getpipher/vision. +import { + isMultimodal, + createVisionDelegator, + type VisionConfig, + type DelegateResult, + type ModelRegistryLike, +} from "@getpipher/vision"; +import type { Model } from "@earendil-works/pi-ai"; +import type { VisionPort, VisionDelegateParams, VisionDelegateResult } from "./port.ts"; + +export interface ArmoryVisionAdapterDeps { + /** A ModelRegistry (or the { find, getApiKeyAndHeaders } slice). Fleet constructs new ModelRegistry(modelRuntime). */ + modelRegistry: ModelRegistryLike; + /** The cwd for image path resolution. */ + cwd: string; + /** The pi agent dir (where vision.json lives). */ + agentDir: string; +} + +export class ArmoryVisionAdapter implements VisionPort { + private readonly delegator: ReturnType; + constructor(deps: ArmoryVisionAdapterDeps) { + this.delegator = createVisionDelegator({ modelRegistry: deps.modelRegistry, cwd: deps.cwd, agentDir: deps.agentDir }); + } + isMultimodal(model: Model | undefined): boolean { + return isMultimodal(model); + } + isConfigured(): boolean { + const c = this.delegator.config as VisionConfig; + return Boolean(c.enabled && c.provider && c.model); + } + async delegate(params: VisionDelegateParams, signal?: AbortSignal): Promise { + if (!this.isConfigured()) { + return { + ok: false, + error: "no vision model configured; run `/vision model ` in the host or set `vision: false` on this agent.", + }; + } + const result: DelegateResult = await this.delegator.delegate( + { + image_path: params.imagePath, + prompt: params.prompt ?? "", + compress: true, + reasoning: this.delegator.config.defaultReasoningEffort ?? "off", + }, + signal, + ); + return result.ok ? { ok: true, text: result.text } : { ok: false, error: result.error.message }; + } +} \ No newline at end of file diff --git a/src/vision/describe-image-tool.ts b/src/vision/describe-image-tool.ts new file mode 100644 index 0000000..7afd936 --- /dev/null +++ b/src/vision/describe-image-tool.ts @@ -0,0 +1,34 @@ +// src/vision/describe-image-tool.ts — fleet-defined describe_image for child sessions. +// Mirrors @getpipher/vision's describe_image contract so user muscle memory transfers, +// but execute() delegates via VisionPort (vision's extension never loads into the child). +import { Type, type Static } from "typebox"; +import type { VisionPort } from "./port.ts"; + +const params = Type.Object({ + image: Type.String({ description: "Absolute path to the image file to analyze." }), + prompt: Type.Optional(Type.String({ description: "Optional question/instruction for the analysis." })), +}); +type DescribeImageInput = Static; + +export function createDescribeImageTool(visionPort: VisionPort) { + return { + name: "describe_image", + label: "Vision", + description: + "Analyze an image file and return a text description. Use when you read an image file and need to understand its contents. " + + "Pass an absolute image path and an optional analysis prompt.", + promptSnippet: "Analyze an image file and return a text description", + promptGuidelines: [ + "Use describe_image when you read an image file (read returns an image attachment) and need a textual understanding of its contents.", + "Pass the absolute image path; add an optional prompt to focus the analysis (e.g. 'describe the UI layout').", + ], + parameters: params, + async execute(_toolCallId: string, p: DescribeImageInput, signal: AbortSignal, _onUpdate: unknown, _ctx: unknown) { + const result = await visionPort.delegate({ imagePath: p.image, prompt: p.prompt }, signal); + if (result.ok) { + return { content: [{ type: "text" as const, text: result.text }] }; + } + return { content: [{ type: "text" as const, text: result.error }], isError: true as const }; + }, + }; +} \ No newline at end of file diff --git a/src/vision/port.ts b/src/vision/port.ts new file mode 100644 index 0000000..dddabe0 --- /dev/null +++ b/src/vision/port.ts @@ -0,0 +1,19 @@ +// src/vision/port.ts — fleet-owned port; fleet core depends only on this. +import type { Model } from "@earendil-works/pi-ai"; + +export interface VisionDelegateParams { + /** Absolute path to the image file the child read. */ + imagePath: string; + /** Optional analysis prompt. */ + prompt?: string; +} +export type VisionDelegateResult = { ok: true; text: string } | { ok: false; error: string }; + +export interface VisionPort { + /** Whether the given model can process images natively (pass-through) vs needs delegation. */ + isMultimodal(model: Model | undefined): boolean; + /** Delegate image analysis to the configured vision model; returns text or an actionable error. */ + delegate(params: VisionDelegateParams, signal?: AbortSignal): Promise; + /** Whether a vision model is configured in the host vision.json. */ + isConfigured(): boolean; +} \ No newline at end of file diff --git a/test/child-loader.test.mts b/test/child-loader.test.mts new file mode 100644 index 0000000..231e313 --- /dev/null +++ b/test/child-loader.test.mts @@ -0,0 +1,25 @@ +// child-loader.test.mts — composeChildPrompt ordering + USER_PSEUDO_CWD sentinel. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { composeChildPrompt, USER_PSEUDO_CWD, memoryScopesFor } from "../src/engine/child-loader.ts"; + +test("composeChildPrompt orders rolePrompt → memoryBlock → base", () => { + const out = composeChildPrompt({ rolePrompt: "PERSONA", memoryBlock: "## Memory\nstuff", base: "## Tools\n..." }); + assert.equal(out, "PERSONA\n\n## Memory\nstuff\n\n## Tools\n..."); +}); + +test("composeChildPrompt omits the memory block when empty", () => { + const out = composeChildPrompt({ rolePrompt: "PERSONA", memoryBlock: "", base: "## Tools\n..." }); + assert.equal(out, "PERSONA\n\n## Tools\n..."); +}); + +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", () => { + 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); +}); \ No newline at end of file diff --git a/test/describe-image-tool.test.mts b/test/describe-image-tool.test.mts new file mode 100644 index 0000000..65c7533 --- /dev/null +++ b/test/describe-image-tool.test.mts @@ -0,0 +1,28 @@ +// describe-image-tool.test.mts — the fleet-defined describe_image tool. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createDescribeImageTool } from "../src/vision/describe-image-tool.ts"; + +test("describe_image delegates via VisionPort and returns text", async () => { + const port = { isMultimodal: () => false, isConfigured: () => true, delegate: async () => ({ ok: true, text: "a cat on a laptop" }) } as any; + const tool = createDescribeImageTool(port); + const result = await tool.execute("t1", { image: "/tmp/x.png" }, undefined as any, undefined, undefined as any); + assert.equal(result.content[0]?.text, "a cat on a laptop"); + assert.equal(result.isError, undefined); +}); + +test("describe_image returns actionable error when not configured", async () => { + const port = { isMultimodal: () => false, isConfigured: () => false, delegate: async () => ({ ok: false, error: "no vision model configured" }) } as any; + const tool = createDescribeImageTool(port); + const result = await tool.execute("t1", { image: "/tmp/x.png" }, undefined as any, undefined, undefined as any) as any; + assert.equal(result.isError, true); + assert.match(result.content[0]?.text ?? "", /no vision model configured/); +}); + +test("describe_image surfaces a delegation failure as an actionable error", async () => { + const port = { isMultimodal: () => false, isConfigured: () => true, delegate: async () => ({ ok: false, error: "vision model returned 500" }) } as any; + const tool = createDescribeImageTool(port); + const result = await tool.execute("t1", { image: "/tmp/x.png" }, undefined as any, undefined, undefined as any) as any; + assert.equal(result.isError, true); + assert.match(result.content[0]?.text ?? "", /vision model returned 500/); +}); \ No newline at end of file diff --git a/test/frontmatter-spec2.test.mts b/test/frontmatter-spec2.test.mts new file mode 100644 index 0000000..8e2d804 --- /dev/null +++ b/test/frontmatter-spec2.test.mts @@ -0,0 +1,29 @@ +// frontmatter-spec2.test.mts — memoryHydrate + vision fields (bool, default true). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseAgentFile } from "../src/registry/frontmatter.ts"; + +const OPT_OUT = `--- +name: reviewer +description: reviews code +memoryHydrate: false +vision: false +--- +body`; +const DEFAULT = `--- +name: x +description: y +--- +body`; + +test("memoryHydrate + vision parse as booleans when set", () => { + const a = parseAgentFile(OPT_OUT, "reviewer.md", "project"); + assert.equal(a.memoryHydrate, false); + assert.equal(a.vision, false); +}); + +test("memoryHydrate + vision default to true when omitted", () => { + const a = parseAgentFile(DEFAULT, "x.md", "global"); + assert.equal(a.memoryHydrate, true); + assert.equal(a.vision, true); +}); \ No newline at end of file diff --git a/test/index-spec2.test.mts b/test/index-spec2.test.mts new file mode 100644 index 0000000..90a107d --- /dev/null +++ b/test/index-spec2.test.mts @@ -0,0 +1,8 @@ +// index-spec2.test.mts — smoke that the extension entry wires the armory adapters. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +test("extension entry exports a default function", async () => { + const mod = await import("../src/index.ts"); + assert.equal(typeof mod.default, "function"); +}); diff --git a/test/memory-hydrate-adapter.test.mts b/test/memory-hydrate-adapter.test.mts new file mode 100644 index 0000000..712dd61 --- /dev/null +++ b/test/memory-hydrate-adapter.test.mts @@ -0,0 +1,55 @@ +// memory-hydrate-adapter.test.mts — ArmoryMemoryAdapter three-scope composition. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { ArmoryMemoryAdapter } from "../src/memory-hydrate/adapter.ts"; + +function seed(root: string, cwd: string, files: Record): void { + const dir = join(root, cwd.replace(/\//g, "-")); + mkdirSync(dir, { recursive: true }); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body); +} + +test("renderScopes concatenates non-empty scopes in project → local → user order", () => { + const root = `/tmp/armory-mem-test-${Date.now()}`; + process.env.ARMORY_MEMORY_ROOT = root; + seed(root, "/proj", { "p.md": "# Project\nproj body" }); + seed(root, "/parent", { "l.md": "# Local\nlocal body" }); + seed(root, "/__armory-fleet-user__", { "u.md": "# User\nuser body" }); + try { + const adapter = new ArmoryMemoryAdapter(); + const block = adapter.renderScopes({ project: "/proj", local: "/parent", user: "/__armory-fleet-user__" }); + const pIdx = block.indexOf("Project"); + const lIdx = block.indexOf("Local"); + const uIdx = block.indexOf("User"); + assert.ok(pIdx >= 0 && lIdx > pIdx && uIdx > lIdx, `project before local before user; got:\n${block}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("renderScopes returns empty string when all scopes empty", () => { + const root = `/tmp/armory-mem-empty-${Date.now()}`; + process.env.ARMORY_MEMORY_ROOT = root; + try { + const adapter = new ArmoryMemoryAdapter(); + assert.equal(adapter.renderScopes({ project: "/none", local: "/none2", user: "/none3" }), ""); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("renderScopes omits empty scopes but keeps populated ones", () => { + const root = `/tmp/armory-mem-partial-${Date.now()}`; + process.env.ARMORY_MEMORY_ROOT = root; + seed(root, "/proj", { "p.md": "# Project\nonly project" }); + try { + const adapter = new ArmoryMemoryAdapter(); + const block = adapter.renderScopes({ project: "/proj", local: "/no-local", user: "/no-user" }); + assert.ok(block.includes("Project")); + assert.ok(!block.includes("none —"), "no empty-scope placeholder leaks"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); \ No newline at end of file diff --git a/test/panel-spec2.test.mts b/test/panel-spec2.test.mts new file mode 100644 index 0000000..174d8af --- /dev/null +++ b/test/panel-spec2.test.mts @@ -0,0 +1,35 @@ +// panel-spec2.test.mts — agentsRow armory chip + agentInfo detail pane content. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { agentsRow, agentInfo } from "../src/panel/rows.ts"; +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, source: "project", filePath: "/x/reviewer.md", +}; + +test("agentsRow shows the armory chip [t✓ m✓ v✗]", () => { + const row = agentsRow(agent); + assert.match(row, /armory:\[t✓ m✓ v✗\]/); +}); + +test("agentInfo renders all armory hooks + model + skills + role prompt", () => { + const info = agentInfo(agent); + assert.match(info, /todoSync: ✓/); + assert.match(info, /memoryHydrate: ✓/); + assert.match(info, /vision: ✗/); + assert.match(info, /model: anthropic\/claude-sonnet-4/); + assert.match(info, /skills: tdd/); + assert.match(info, /role prompt/); + assert.match(info, /You are a reviewer\./); +}); + +test("agentInfo shows defaults when model/skills/thinkingLevel omitted", () => { + const a: AgentDef = { ...agent, model: undefined, skills: undefined, thinkingLevel: undefined }; + const info = agentInfo(a); + assert.match(info, /model: \(default\)/); + assert.match(info, /skills: \(none\)/); + assert.match(info, /thinkingLevel: \(model default\)/); +}); \ No newline at end of file diff --git a/test/rows.test.mts b/test/rows.test.mts index 043bce5..dd2cbcf 100644 --- a/test/rows.test.mts +++ b/test/rows.test.mts @@ -35,20 +35,20 @@ test("fleetRow ctxPercent", () => { ok(!fleetRow(run()).includes("ctx")); }); -test("agentsRow includes name, source, model, todoSync", () => { - const a: AgentDef = { name: "scout", description: "d", model: "anthropic/claude-sonnet-4", rolePrompt: "r", todoSync: true, source: "project", filePath: "/x" }; +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, source: "project", filePath: "/x" }; const r = agentsRow(a); ok(r.includes("scout"), r); ok(r.includes("[project]"), r); ok(r.includes("anthropic/claude-sonnet-4"), r); - ok(r.includes("todoSync:✓"), r); + ok(r.includes("armory:[t✓ m✓ v✓]"), r); }); test("agentsRow default model + tools/skills omitted", () => { - const a: AgentDef = { name: "g", description: "d", rolePrompt: "r", todoSync: false, source: "builtin", filePath: "/x" }; + const a: AgentDef = { name: "g", description: "d", rolePrompt: "r", todoSync: false, memoryHydrate: false, vision: false, source: "builtin", filePath: "/x" }; const r = agentsRow(a); ok(r.includes("(default)"), r); - ok(r.includes("todoSync:✗"), r); + ok(r.includes("armory:[t✗ m✗ v✗]"), r); ok(!r.includes("tools:"), r); ok(!r.includes("skills:"), r); }); \ No newline at end of file diff --git a/test/spawn-subagent-spec2.test.mts b/test/spawn-subagent-spec2.test.mts new file mode 100644 index 0000000..c62c9a1 --- /dev/null +++ b/test/spawn-subagent-spec2.test.mts @@ -0,0 +1,64 @@ +// spawn-subagent-spec2.test.mts — threads memoryPort + visionPort to the child factory. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSubagent, type ChildSession } from "../src/engine/spawnSubagent.ts"; +import { RunRegistry } from "../src/engine/run-registry.ts"; +import { createSingleSlotLock } from "../src/engine/concurrency-lock.ts"; +import { ArmoryTodoAdapter } from "../src/todo-sync/adapter.ts"; +import type { AgentDef } from "../src/registry/frontmatter.ts"; + +const agent: AgentDef = { name: "general-purpose", description: "", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, 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; + +function fakeChild(): ChildSession { + return { + prompt: async () => {}, + subscribe: () => () => {}, + abort: async () => {}, + dispose: () => {}, + }; +} + +test("spawnSubagent threads memoryPort + visionPort to the child factory", async () => { + let received: any = {}; + const factory = { + async create(opts: any) { + received = opts; + return { session: fakeChild(), model: "ollama/qwen3" }; + }, + }; + const res = await spawnSubagent({ + agent: "general-purpose", + task: "do it", + track: false, + registry: new Map([["general-purpose", agent]]), + todoSync: new ArmoryTodoAdapter() as any, + runRegistry: new RunRegistry(), + lock: createSingleSlotLock(), + childFactory: factory as any, + parentModel: { provider: "ollama", id: "qwen3" }, + parentCwd: "/proj", + memoryPort: memPort, + visionPort: visPort, + } as any); + assert.equal(res.status, "completed"); + assert.equal(received.memoryPort, memPort, "memoryPort threaded to factory"); + assert.equal(received.visionPort, visPort, "visionPort threaded to factory"); + assert.equal(received.agent.name, "general-purpose", "agent (AgentDef) threaded to factory"); +}); + +test("spawnSubagent passes agent tools through unfiltered (excludeTools is the factory's job)", async () => { + let received: any; + const factory = { async create(opts: any) { received = opts; return { session: fakeChild(), model: "m" }; } }; + const a = { ...agent, name: "g", tools: ["read", "bash", "todo", "edit"] }; + await spawnSubagent({ + agent: "g", task: "x", track: false, + registry: new Map([["g", a]]), + todoSync: new ArmoryTodoAdapter() as any, + runRegistry: new RunRegistry(), lock: createSingleSlotLock(), childFactory: factory as any, + parentModel: { provider: "p", id: "m" }, parentCwd: "/tmp", + } as any); + assert.ok(received.tools.includes("todo"), "todo passes through unfiltered; factory applies excludeTools"); + assert.ok(received.tools.includes("read")); +}); \ No newline at end of file diff --git a/test/spawnSubagent.test.mts b/test/spawnSubagent.test.mts index 7c62b63..65315f1 100644 --- a/test/spawnSubagent.test.mts +++ b/test/spawnSubagent.test.mts @@ -22,7 +22,7 @@ afterEach(() => { }); const agent = (name = "g"): AgentDef => ({ - name, description: "d", rolePrompt: "role", todoSync: true, source: "builtin", filePath: "/x", + name, description: "d", rolePrompt: "role", todoSync: true, memoryHydrate: true, vision: true, source: "builtin", filePath: "/x", }); /** A fake child that emits N turns then finishes with finalText. */ @@ -136,7 +136,7 @@ test("track:false touches no todo", async () => { strictEqual(res.status, "completed"); }); -test("todo excluded from child tools (fleet is single writer)", async () => { +test("todo exclusion moved to the factory (spawnSubagent passes tools through unfiltered)", async () => { let captured: any; const factory: ChildSessionFactory = { create: async (opts) => { captured = opts; return { session: fakeChild(1, "ok"), model: "m" }; }, @@ -148,6 +148,7 @@ test("todo excluded from child tools (fleet is single writer)", async () => { registry: h.registry, todoSync: h.todoSync, runRegistry: h.runRegistry, lock: h.lock, childFactory: h.childFactory, parentModel: PARENT, parentCwd: "/tmp", }); - ok(!captured.tools.includes("todo"), "todo stripped"); + // SPEC-2: spawnSubagent no longer filters — the child factory applies `excludeTools: ["todo"]` downstream. + ok(captured.tools.includes("todo"), "todo passes through unfiltered (factory excludes it via excludeTools)"); ok(captured.tools.includes("read"), "read kept"); }); \ No newline at end of file diff --git a/test/subagent-tool.test.mts b/test/subagent-tool.test.mts index 6c789b3..0043290 100644 --- a/test/subagent-tool.test.mts +++ b/test/subagent-tool.test.mts @@ -20,7 +20,7 @@ afterEach(() => { delete process.env.TODO_DIR; }); -const agent: AgentDef = { name: "g", description: "d", rolePrompt: "r", todoSync: true, source: "builtin", filePath: "/x" }; +const agent: AgentDef = { name: "g", description: "d", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, source: "builtin", filePath: "/x" }; function makeDeps() { return { diff --git a/test/vision-adapter.test.mts b/test/vision-adapter.test.mts new file mode 100644 index 0000000..fec64c3 --- /dev/null +++ b/test/vision-adapter.test.mts @@ -0,0 +1,29 @@ +// vision-adapter.test.mts — ArmoryVisionAdapter capability-aware delegation. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ArmoryVisionAdapter } from "../src/vision/adapter.ts"; + +// Fake ModelRegistry slice (the { find, getApiKeyAndHeaders } delegateToVisionModel reads). +const fakeRegistry = { + find: () => undefined, + getApiKeyAndHeaders: async () => ({ ok: false, error: "no auth" }), +}; + +test("isMultimodal delegates to vision's isMultimodal", () => { + const adapter = new ArmoryVisionAdapter({ modelRegistry: fakeRegistry as any, cwd: "/tmp", agentDir: "/tmp" }); + assert.equal(adapter.isMultimodal(undefined), false); + assert.equal(adapter.isMultimodal({ input: ["text", "image"] } as any), true); + assert.equal(adapter.isMultimodal({ input: ["text"] } as any), false); +}); + +test("isConfigured reflects the loaded config", () => { + const adapter = new ArmoryVisionAdapter({ modelRegistry: fakeRegistry as any, cwd: "/tmp", agentDir: "/tmp" }); + assert.equal(typeof adapter.isConfigured(), "boolean"); +}); + +test("delegate returns actionable error when no vision model is configured", async () => { + const adapter = new ArmoryVisionAdapter({ modelRegistry: fakeRegistry as any, cwd: "/tmp", agentDir: "/tmp/no-vision-config" }); + const result = await adapter.delegate({ imagePath: "/nonexistent.png" }); + assert.equal(result.ok, false); + if (!result.ok) assert.match(result.error, /no vision model configured|not configured|not_configured|model/i); +}); \ No newline at end of file