# Public Agent Knowledge Delivery — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make the public agent's `KNOWLEDGE.md` reach its spawn prompt, refuse a dud (empty/missing SOUL or KNOWLEDGE) public spawn with a distinct logged reason, and stop seeding/reading the dead public `SOUL`/`config` templates and `plugins` config field.

**Architecture:** The only spawn-prompt path is `composeAppendSystemPrompt` in `claude-session-manager`. It reads `agents/<role>/{IDENTITY,SOUL}.md`; we add a public-only `KNOWLEDGE.md` read rendered as `<knowledge>` after `<soul>`, with strict empty/missing refusal. `pty-spawner` maps the new refusal reasons and emits the per-spawn byte-count line. The installer stops manufacturing a public agent on fresh install but re-syncs the Rubytech IDENTITY into existing agent dirs on upgrade. The UI config reader drops the unused `plugins` field. The admin skill doc is corrected to match.

**Tech Stack:** TypeScript (Node, vitest), bash (`setup-account.sh`), markdown (SKILL.md).

---

### Task 1: `system-prompt.ts` — read KNOWLEDGE for public, distinct refusal reasons

**Files:**
- Modify: `services/claude-session-manager/src/system-prompt.ts`
- Test: `services/claude-session-manager/src/__tests__/system-prompt.test.ts`

**Behaviours:**
- `AppendBlockResult` ok:true gains `knowledgeBytes: number`.
- `AppendBlockResult` ok:false `reason` union gains `'soul-empty' | 'knowledge-missing' | 'knowledge-empty'`.
- `renderAppendBlock` gains a `knowledgeContent: string | null` param; when non-null, render `<knowledge>\n{content}\n</knowledge>` immediately after `</soul>`, before `<about-owner>`; return `knowledgeBytes`. Null ⇒ no block, no bytes (admin + self-test + drift unchanged).
- `composeAppendSystemPrompt`: IDENTITY failure → `identity-unresolved` (all roles, unchanged). SOUL failure → `identity-unresolved` for admin (unchanged); for `role==='public'` → `soul-empty`. When `role==='public'`, read `<roleDir>/KNOWLEDGE.md` via `readIdentityFile`: detail ending `:empty` → `knowledge-empty`; any other failure (ENOENT/unreadable) → `knowledge-missing`. Pass content to `renderAppendBlock`; admin passes `null`.

- [ ] **Step 1: Write failing tests** — append to `system-prompt.test.ts`:

```ts
describe('composeAppendSystemPrompt — public KNOWLEDGE delivery', () => {
  function makePublic(seed: { identity?: string | null; soul?: string | null; knowledge?: string | null }): string {
    const dir = mkdtempSync(join(tmpdir(), 'task618-account-'))
    const roleDir = join(dir, 'agents', 'public')
    mkdirSync(roleDir, { recursive: true })
    if (typeof seed.identity === 'string') writeFileSync(join(roleDir, 'IDENTITY.md'), seed.identity)
    if (typeof seed.soul === 'string') writeFileSync(join(roleDir, 'SOUL.md'), seed.soul)
    if (typeof seed.knowledge === 'string') writeFileSync(join(roleDir, 'KNOWLEDGE.md'), seed.knowledge)
    return dir
  }

  it('public + non-empty KNOWLEDGE ⇒ <knowledge> verbatim, ordered after <soul>, knowledgeBytes>0', () => {
    const knowledge = '# Knowledge\nWe open 9-5. Sale price £450k.\n'
    const accountDir = makePublic({ identity: 'I', soul: 'S', knowledge })
    try {
      const result = composeAppendSystemPrompt(HOST, { accountDir, role: 'public' })
      expect(result.ok).toBe(true)
      if (!result.ok) return
      expect(result.block).toContain(`<knowledge>\n${knowledge}\n</knowledge>`)
      expect(result.block.indexOf('<knowledge>')).toBeGreaterThan(result.block.indexOf('</soul>'))
      expect(result.knowledgeBytes).toBe(Buffer.byteLength(knowledge, 'utf8'))
    } finally { rmSync(accountDir, { recursive: true, force: true }) }
  })

  it('public + missing KNOWLEDGE ⇒ ok:false reason=knowledge-missing', () => {
    const accountDir = makePublic({ identity: 'I', soul: 'S' })
    try {
      const result = composeAppendSystemPrompt(HOST, { accountDir, role: 'public' })
      expect(result.ok).toBe(false)
      if (result.ok) return
      expect(result.reason).toBe('knowledge-missing')
      expect(result.detail).toContain('KNOWLEDGE.md')
    } finally { rmSync(accountDir, { recursive: true, force: true }) }
  })

  it('public + empty KNOWLEDGE ⇒ ok:false reason=knowledge-empty', () => {
    const accountDir = makePublic({ identity: 'I', soul: 'S', knowledge: '' })
    try {
      const result = composeAppendSystemPrompt(HOST, { accountDir, role: 'public' })
      expect(result.ok).toBe(false)
      if (result.ok) return
      expect(result.reason).toBe('knowledge-empty')
    } finally { rmSync(accountDir, { recursive: true, force: true }) }
  })

  it('public + empty SOUL ⇒ ok:false reason=soul-empty', () => {
    const accountDir = makePublic({ identity: 'I', soul: '', knowledge: 'K' })
    try {
      const result = composeAppendSystemPrompt(HOST, { accountDir, role: 'public' })
      expect(result.ok).toBe(false)
      if (result.ok) return
      expect(result.reason).toBe('soul-empty')
    } finally { rmSync(accountDir, { recursive: true, force: true }) }
  })

  it('public + empty IDENTITY ⇒ identity-unresolved (unchanged)', () => {
    const accountDir = makePublic({ identity: '', soul: 'S', knowledge: 'K' })
    try {
      const result = composeAppendSystemPrompt(HOST, { accountDir, role: 'public' })
      expect(result.ok).toBe(false)
      if (result.ok) return
      expect(result.reason).toBe('identity-unresolved')
    } finally { rmSync(accountDir, { recursive: true, force: true }) }
  })

  it('admin + KNOWLEDGE.md on disk ⇒ no <knowledge> block', () => {
    const accountDir = makeAccountDir({ admin: { identity: 'I', soul: 'S' } })
    writeFileSync(join(accountDir, 'agents', 'admin', 'KNOWLEDGE.md'), 'admin-knowledge')
    try {
      const result = composeAppendSystemPrompt(HOST, { accountDir, role: 'admin' })
      expect(result.ok).toBe(true)
      if (!result.ok) return
      expect(result.block).not.toContain('<knowledge>')
      expect(result.block).not.toContain('admin-knowledge')
    } finally { rmSync(accountDir, { recursive: true, force: true }) }
  })

  it('snapshot: full composed public prompt with a sample KNOWLEDGE.md', () => {
    const accountDir = makePublic({
      identity: '# Identity\nYou speak for the business.',
      soul: '# Soul\nWarm, brief.',
      knowledge: '# Knowledge\nOpen 9-5. Contact 01234 567890.',
    })
    try {
      const result = composeAppendSystemPrompt(HOST, { accountDir, role: 'public' })
      expect(result.ok).toBe(true)
      if (!result.ok) return
      expect(result.block).toMatchSnapshot()
    } finally { rmSync(accountDir, { recursive: true, force: true }) }
  })
})
```

- [ ] **Step 2: Run, verify fail** — `npx vitest run src/__tests__/system-prompt.test.ts` → FAIL (knowledge not read).

- [ ] **Step 3: Implement** in `system-prompt.ts`:
  - Add `knowledgeBytes: number` to ok:true; extend ok:false reason union.
  - Add `knowledgeContent: string | null` param to `renderAppendBlock`; insert `<knowledge>` block after `</soul>` push, before `<about-owner>`; track + return `knowledgeBytes`.
  - Update both internal `renderAppendBlock` callers (`runIdentityDriftAssertion`, `runSystemPromptSelfTest`) to pass `null`.
  - In `composeAppendSystemPrompt`: on SOUL failure, `return { ok:false, reason: sources.role === 'public' ? 'soul-empty' : 'identity-unresolved', detail: soul.detail }`. When `role==='public'`, read `KNOWLEDGE.md`; on failure `return { ok:false, reason: knowledge.detail.endsWith(':empty') ? 'knowledge-empty' : 'knowledge-missing', detail: knowledge.detail }`. Pass `role==='public' ? knowledge.content : null` to `renderAppendBlock`; set `knowledgeBytes` accordingly (0 for admin).

- [ ] **Step 4: Run, verify pass** — `npx vitest run src/__tests__/system-prompt.test.ts` → PASS. Review the new snapshot.

- [ ] **Step 5: Commit** — `feat(public-agent): read KNOWLEDGE.md into public spawn prompt with strict refusal`

---

### Task 2: `pty-spawner.ts` — surface refusal reasons + per-spawn compose line

**Files:**
- Modify: `services/claude-session-manager/src/pty-spawner.ts` (`SpawnFailureReason` ~351; compose handling ~1292-1315)

**Behaviours:**
- `SpawnFailureReason` gains `'soul-empty' | 'knowledge-missing' | 'knowledge-empty'`.
- In the `!composed.ok` branch, for the three new reasons emit `[pty-spawn] spawn-failed reason=<r> role=public slug=<agentSlug>` and return `{ ok:false, reason:<r>, stderrTail: composed.detail }`. The existing `identity-unresolved` path is unchanged.
- Track `knowledge` in `composedBytes`. On public success emit `[pty-spawn] op=public-prompt-compose role=public slug=<agentSlug> identityBytes=N soulBytes=N knowledgeBytes=N` (in addition to the existing `compose-system-prompt-ok` line, which gains `knowledgeBytes`).

- [ ] **Step 1: Implement** — extend the union; in the failure branch add a `case` for the three reasons before the identity fallthrough; add `knowledge: composed.knowledgeBytes` to `composedBytes`; append `knowledgeBytes=${composed.knowledgeBytes}` to the `compose-system-prompt-ok` log; after that log, `if (args.role === 'public') deps.logger(\`op=public-prompt-compose role=public slug=${agentSlug} identityBytes=${composed.identityBytes} soulBytes=${composed.soulBytes} knowledgeBytes=${composed.knowledgeBytes}\`)`.

- [ ] **Step 2: Verify** — `npx vitest run` for csm (no test directly asserts these strings; the build + existing suite must stay green). Confirm tsc has no error on the union/result fields.

- [ ] **Step 3: Commit** — `feat(public-agent): map knowledge/soul refusal reasons + emit public-prompt-compose line`

---

### Task 3: Templates — delete dead public SOUL + config

**Files:**
- Delete: `templates/agents/public/SOUL.md`, `templates/agents/public/config.json`
- Keep: `templates/agents/public/IDENTITY.md`

- [ ] **Step 1:** `git rm templates/agents/public/SOUL.md templates/agents/public/config.json`
- [ ] **Step 2: Verify no live reader** — `grep -rIn 'agents/public/SOUL\|agents/public/config' --include=*.ts --include=*.sh .` returns only `setup-account.sh` lines removed in Task 4. (`smoke-boot-services.sh` and `verify-doc-impl.sh` copy/grep IDENTITY only.)
- [ ] **Step 3: Commit** with Task 4 (template deletion is meaningless without the seed removal).

---

### Task 4: `setup-account.sh` — no fresh-install public agent; re-sync IDENTITY for existing

**Files:**
- Modify: `scripts/setup-account.sh` (mkdir ~38; IDENTITY block ~181-184; SOUL/config seed ~221-224)

**Behaviours:**
- Drop `"$ACCOUNT_DIR/agents/public"` from the line-38 `mkdir -p`.
- Remove the public IDENTITY overwrite (lines 183-184) and the public SOUL/config seed (lines 221-224).
- Add, in the agent-identities section, a loop: for each `"$ACCOUNT_DIR/agents"/*/` whose basename ≠ `admin` and which already contains `IDENTITY.md`, overwrite `IDENTITY.md` from `"$TEMPLATES_DIR/agents/public/IDENTITY.md"`; echo a re-sync line. Never create dirs; never write SOUL/KNOWLEDGE/config. Admin block untouched.

- [ ] **Step 1: Implement** the three edits.
- [ ] **Step 2: Verify fresh install** — run `setup-account.sh` against a scratch `ACCOUNT_DIR` with no `agents/public`; assert no `agents/public/` is created and `agents/admin/IDENTITY.md` exists.
- [ ] **Step 3: Verify upgrade** — scratch `ACCOUNT_DIR` pre-seeded with `agents/sales/IDENTITY.md` (stale text) + `agents/sales/SOUL.md`; run; assert `agents/sales/IDENTITY.md` now equals the template and `agents/sales/SOUL.md` is untouched.
- [ ] **Step 4: Commit** (with Task 3) — `refactor(installer): public agents author client-side; re-sync IDENTITY for existing dirs only`

---

### Task 5: `account.ts` — drop the dead `plugins` config field

**Files:**
- Modify: `ui/app/lib/claude-agent/account.ts` (`ResolvedAgentConfig` ~277; reader ~304,324; return ~395)
- Test: `ui/app/lib/claude-agent/__tests__/spawn-context.test.ts` (trim `plugins` assertions only)

**Behaviours:**
- Remove `plugins: string[] | null` from `ResolvedAgentConfig`; remove `let plugins`, the `parsed.plugins` read, and `plugins` from the return object. Leave `knowledgeKeywords` and the budget-breakdown `plugins: 0` token field intact.

- [ ] **Step 1: Find & update the test** — `grep -n 'plugins' ui/app/lib/claude-agent/__tests__/spawn-context.test.ts`; remove only the assertions reading `config.plugins`; add/confirm a case that a public `config.json` lacking `plugins` resolves and `knowledgeKeywords` is still read.
- [ ] **Step 2: Run, verify fail/observe** — `npx vitest run ui/app/lib/claude-agent/__tests__/spawn-context.test.ts`.
- [ ] **Step 3: Implement** the field removal in `account.ts`.
- [ ] **Step 4: Run, verify pass** — same vitest command → PASS.
- [ ] **Step 5: Commit** — `refactor(public-agent): drop unused plugins field from resolved agent config`

---

### Task 6: `public-agent-manager/SKILL.md` — correct the doc

**Files:**
- Modify: `plugins/admin/skills/public-agent-manager/SKILL.md`

**Behaviours (scope item 6):**
- (a) Table: `KNOWLEDGE.md` Required → Yes; `SOUL.md` purpose → personality + role (basic public info, capture the visitor's contact details for human follow-up — the chat equivalent of the landing-page contact form). Add a line: SOUL.md and KNOWLEDGE.md are authored entirely client-side and must be non-empty before the agent goes active; an empty one is refused at spawn. KNOWLEDGE = the business's public-facing facts at landing-page detail.
- (b) Create step: IDENTITY.md is copied from the Rubytech template verbatim, not drafted/edited.
- (c) Remove the `plugins` key from the `config.json` example; remove "selected plugins" from the table; delete the "Plugin Selection" section; remove the plugin-selection Create step and renumber the Create steps + every cross-reference (`Step 4/5/6/7`, `All other steps (4, 8-12)`). Update the template-create plugin pre-select bullet (plugins gone) and note IDENTITY is the fixed Rubytech template.
- (d) Ensure no reference to a SOUL/config seed template remains.
- Keep: `knowledgeKeywords` field, the create-form field, keyword-subscription and direct-tagging sections, projection steps.

- [ ] **Step 1: Implement** the edits above.
- [ ] **Step 2: Verify** — `grep -n 'plugins' SKILL.md` shows no `plugins` config key or Plugin Selection section (knowledgeKeywords/tagging prose may remain); Create steps are sequentially numbered with consistent cross-refs.
- [ ] **Step 3: Commit** — `docs(public-agent): KNOWLEDGE required, IDENTITY templated, drop plugins from skill`

---

### Task 7: Verify — build, bundle, full suites

- [ ] **Step 1:** `npm run build:claude-session-manager` → no tsc errors.
- [ ] **Step 2:** Full csm suite `cd services/claude-session-manager && npx vitest run` → green (esp. `public-identity-template`, `pty-spawner-channels`).
- [ ] **Step 3:** `npx vitest run ui/app/lib/claude-agent/__tests__/` → green.
- [ ] **Step 4:** Bundle the installer payload (`maxy-code/packages/create-maxy-code` `npm run bundle`) → no runtime errors; confirm `templates/agents/public/` in the payload contains only `IDENTITY.md`.

---

## Self-Review

- **Spec coverage:** scope 1→T1, 2→T2, 3→T3, 4→T4, 5→T5, 6→T6; verification + observability→T1/T2/T7. All covered.
- **Out of scope (untouched):** `knowledgeKeywords` machinery, projection routes, the migrated Real Agent agent's KNOWLEDGE authoring, Pi hand-patched files.
- **Type consistency:** `knowledgeBytes` and the three reasons named identically in T1 (composer) and T2 (spawner). `renderAppendBlock` `knowledgeContent` param threaded through all three callers.
