nfo-cli 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-command.js +6 -1
- package/dist/claude-command.js.map +1 -1
- package/dist/claude-trust.js +46 -0
- package/dist/claude-trust.js.map +1 -0
- package/dist/cli.js +64 -54
- package/dist/cli.js.map +1 -1
- package/dist/commands/restore.js +0 -1
- package/dist/commands/restore.js.map +1 -1
- package/dist/commands/tui.js +6 -4
- package/dist/commands/tui.js.map +1 -1
- package/dist/mcp/handlers.js +5 -0
- package/dist/mcp/handlers.js.map +1 -1
- package/dist/mcp/tool-defs.js +10 -0
- package/dist/mcp/tool-defs.js.map +1 -1
- package/dist/musicians/dismiss.js +1 -1
- package/dist/musicians/dismiss.js.map +1 -1
- package/dist/musicians/roles.js +15 -0
- package/dist/musicians/roles.js.map +1 -0
- package/dist/musicians/spawn.js +53 -18
- package/dist/musicians/spawn.js.map +1 -1
- package/dist/permission.js +14 -8
- package/dist/permission.js.map +1 -1
- package/dist/prompts/musician-role.js +2 -1
- package/dist/prompts/musician-role.js.map +1 -1
- package/dist/prompts/orchestrator-role.js +42 -8
- package/dist/prompts/orchestrator-role.js.map +1 -1
- package/dist/prompts/tool-discipline.js +10 -0
- package/dist/prompts/tool-discipline.js.map +1 -1
- package/dist/tui/{App.js → components/App.js} +20 -20
- package/dist/tui/components/App.js.map +1 -0
- package/dist/tui/components/AppView.js +13 -0
- package/dist/tui/components/AppView.js.map +1 -0
- package/dist/tui/{Auditorium.js → components/Auditorium.js} +2 -2
- package/dist/tui/components/Auditorium.js.map +1 -0
- package/dist/tui/components/ConcertHall.js.map +1 -0
- package/dist/tui/{Help.js → components/Help.js} +0 -8
- package/dist/tui/components/Help.js.map +1 -0
- package/dist/tui/components/OrchestratorPane.js.map +1 -0
- package/dist/tui/components/SidebarHeader.js +6 -0
- package/dist/tui/components/SidebarHeader.js.map +1 -0
- package/dist/tui/{StatusBar.js → components/StatusBar.js} +1 -1
- package/dist/tui/components/StatusBar.js.map +1 -0
- package/package.json +8 -1
- package/assets/agent-screen.png +0 -0
- package/assets/main-screen.png +0 -0
- package/assets/orche-clawd.png +0 -0
- package/dist/tui/App.js.map +0 -1
- package/dist/tui/AppView.js +0 -13
- package/dist/tui/AppView.js.map +0 -1
- package/dist/tui/Auditorium.js.map +0 -1
- package/dist/tui/ConcertHall.js.map +0 -1
- package/dist/tui/Help.js.map +0 -1
- package/dist/tui/OrchestratorPane.js.map +0 -1
- package/dist/tui/SidebarHeader.js +0 -6
- package/dist/tui/SidebarHeader.js.map +0 -1
- package/dist/tui/StatusBar.js.map +0 -1
- package/docs/plans/2026-05-29-nfo-phase-1-bootstrap.md +0 -2152
- package/docs/plans/2026-05-29-nfo-phase-2-mcp-musicians.md +0 -2467
- package/docs/plans/2026-05-29-nfo-phase-3-ink-tui.md +0 -1611
- package/docs/plans/2026-05-29-nfo-phase-4-permission-prompts.md +0 -460
- package/docs/plans/2026-05-29-nfo-phase-5-help-and-notify.md +0 -933
- package/docs/specs/2026-05-29-nfo-design.md +0 -468
- package/src/claude-command.ts +0 -35
- package/src/claude-detect.ts +0 -42
- package/src/cli.ts +0 -164
- package/src/commands/attach.ts +0 -24
- package/src/commands/dashboard-window.ts +0 -33
- package/src/commands/kill.ts +0 -50
- package/src/commands/launch.ts +0 -134
- package/src/commands/list.ts +0 -43
- package/src/commands/mcp-server.ts +0 -18
- package/src/commands/notes.ts +0 -18
- package/src/commands/restore.ts +0 -153
- package/src/commands/tui.tsx +0 -16
- package/src/config.ts +0 -44
- package/src/dashboard.ts +0 -1
- package/src/mcp/config.ts +0 -39
- package/src/mcp/handlers.ts +0 -141
- package/src/mcp/server.ts +0 -50
- package/src/mcp/tool-defs.ts +0 -151
- package/src/musicians/dismiss.ts +0 -60
- package/src/musicians/ids.ts +0 -21
- package/src/musicians/lookup.ts +0 -13
- package/src/musicians/message-log.ts +0 -152
- package/src/musicians/message.ts +0 -99
- package/src/musicians/query.ts +0 -19
- package/src/musicians/spawn.ts +0 -139
- package/src/notes.ts +0 -39
- package/src/notify.ts +0 -62
- package/src/orchestrator/report-back.ts +0 -33
- package/src/permission.ts +0 -30
- package/src/project-key.ts +0 -12
- package/src/prompts/musician-role.ts +0 -22
- package/src/prompts/orchestrator-role.ts +0 -60
- package/src/prompts/tool-discipline.ts +0 -35
- package/src/repo.ts +0 -14
- package/src/shell-quote.ts +0 -7
- package/src/state-updaters.ts +0 -132
- package/src/state.ts +0 -49
- package/src/state.types.ts +0 -67
- package/src/tmux.ts +0 -226
- package/src/tui/App.tsx +0 -532
- package/src/tui/AppView.tsx +0 -96
- package/src/tui/Auditorium.tsx +0 -56
- package/src/tui/ConcertHall.tsx +0 -31
- package/src/tui/Help.tsx +0 -72
- package/src/tui/OrchestratorPane.tsx +0 -98
- package/src/tui/SidebarHeader.tsx +0 -32
- package/src/tui/StatusBar.tsx +0 -44
- package/src/tui/activity-line.ts +0 -16
- package/src/tui/detect-permission.ts +0 -93
- package/src/tui/embedded-session-lifecycle.ts +0 -44
- package/src/tui/embedded-terminal.ts +0 -325
- package/src/tui/format-time.ts +0 -25
- package/src/tui/keymap.ts +0 -104
- package/src/tui/poll-activity.ts +0 -25
- package/src/tui/poll-idle.ts +0 -149
- package/src/tui/poll-permission.ts +0 -50
- package/src/tui/status-icon.ts +0 -35
- package/src/tui/terminal-input.ts +0 -136
- package/src/tui/watch-state.ts +0 -43
- package/src/worktree.ts +0 -41
- package/tests/claude-command.test.ts +0 -30
- package/tests/claude-detect.test.ts +0 -14
- package/tests/commands/attach.test.ts +0 -60
- package/tests/commands/kill.test.ts +0 -66
- package/tests/commands/launch.test.ts +0 -75
- package/tests/commands/list.test.ts +0 -47
- package/tests/commands/notes.test.ts +0 -53
- package/tests/commands/restore.test.ts +0 -126
- package/tests/helpers/tmp-config.ts +0 -16
- package/tests/helpers/tmp-repo.ts +0 -29
- package/tests/integration/orchestrator-spawn.test.ts +0 -108
- package/tests/mcp/handlers.test.ts +0 -163
- package/tests/mcp/tool-defs.test.ts +0 -35
- package/tests/musicians/dismiss.test.ts +0 -102
- package/tests/musicians/message.test.ts +0 -159
- package/tests/musicians/query.test.ts +0 -65
- package/tests/musicians/spawn.test.ts +0 -125
- package/tests/notes.test.ts +0 -56
- package/tests/notify.test.ts +0 -80
- package/tests/orchestrator/report-back.test.ts +0 -18
- package/tests/permission.test.ts +0 -29
- package/tests/project-key.test.ts +0 -33
- package/tests/prompts/tool-discipline.test.ts +0 -25
- package/tests/repo.test.ts +0 -38
- package/tests/state-updaters.test.ts +0 -126
- package/tests/state.test.ts +0 -85
- package/tests/tmux.test.ts +0 -126
- package/tests/tui/AppView.test.tsx +0 -92
- package/tests/tui/Auditorium.test.tsx +0 -67
- package/tests/tui/ConcertHall.test.tsx +0 -22
- package/tests/tui/Help.test.tsx +0 -38
- package/tests/tui/OrchestratorPane.test.ts +0 -30
- package/tests/tui/SidebarHeader.test.tsx +0 -20
- package/tests/tui/StatusBar.test.tsx +0 -51
- package/tests/tui/activity-line.test.ts +0 -21
- package/tests/tui/detect-permission.test.ts +0 -92
- package/tests/tui/embedded-session-lifecycle.test.ts +0 -55
- package/tests/tui/embedded-terminal.test.ts +0 -80
- package/tests/tui/format-time.test.ts +0 -25
- package/tests/tui/keymap.test.ts +0 -93
- package/tests/tui/poll-activity.test.ts +0 -81
- package/tests/tui/poll-idle.test.ts +0 -159
- package/tests/tui/poll-permission.test.ts +0 -222
- package/tests/tui/status-icon.test.ts +0 -27
- package/tests/tui/terminal-input.test.ts +0 -113
- package/tests/tui/watch-state.test.ts +0 -54
- package/tests/worktree.test.ts +0 -73
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -12
- /package/dist/tui/{ConcertHall.js → components/ConcertHall.js} +0 -0
- /package/dist/tui/{OrchestratorPane.js → components/OrchestratorPane.js} +0 -0
|
@@ -1,2467 +0,0 @@
|
|
|
1
|
-
# NFO Phase 2 — MCP Server + Musicians Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **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.
|
|
4
|
-
|
|
5
|
-
**Goal:** Ship the NFO MCP server and the Musician primitives. After this phase, the Orchestrator can spawn Musicians (real `claude` sessions in tmux windows running inside per-musician git worktrees) via MCP tools, exchange messages with them, query their state, dismiss them, and curate persistent notes. Restoration also rehydrates active musicians.
|
|
6
|
-
|
|
7
|
-
**Architecture:** A single stdio MCP server (`nfo mcp-server --orchestra-id <key>`) is attached to every `claude` session NFO launches (Orchestrator and each Musician) via `--mcp-config`. The server exposes mechanism-shaped tools — `spawn_musician`, `message_musician`, `query_musician`, `list_musicians`, `dismiss_musician`, `report_done`, `note_write`, `note_read`, `note_list` — implemented as thin wrappers over the same tmux + git-worktree + state.json primitives that Phase 1 introduced. Multiple MCP server instances (one per claude session) share `state.json` as the source of truth; concurrent writes are serialised through `proper-lockfile` (already in place from Phase 1).
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** `@modelcontextprotocol/sdk@^1.29.0` (stdio transport, request schemas), `execa` (already), `proper-lockfile` (already). No Ink yet (Phase 3). No permission-prompt detection yet (Phase 4).
|
|
10
|
-
|
|
11
|
-
**Reference spec:** `docs/specs/2026-05-29-nfo-design.md`. Sections most relevant to Phase 2: §3.3 (IPC + state ownership), §5.1 (agent backend), §5.3 (inherited Claude Code features), §5.4 (prompt composition), all of §6 (NFO MCP server tool surface), §7.3 (notes mechanics), §9.4 (input injection caveats).
|
|
12
|
-
|
|
13
|
-
**MANDATORY code style (applies to every task in this plan):**
|
|
14
|
-
- All control flow uses explicit braced, multi-line blocks. Never the brace-less single-line form. So `if (cond) { return x; }`, never `if (cond) return x;`. Same for `else`, `for`, `while`, `switch`.
|
|
15
|
-
- Arrow functions use explicit `{ return ... }` bodies, never implicit-return expression bodies. So `arr.find((m) => { return m.id === id; })`, never `arr.find(m => m.id === id)`. Const arrow helpers too: `const f = (s) => { return join(...); }`.
|
|
16
|
-
- Ternaries (`a ? b : c`) ARE allowed — do not rewrite them into if/else.
|
|
17
|
-
- The code samples below convey the intended logic. Where a sample uses a brace-less statement or an implicit-return arrow, transcribe it in the explicit-block style described here — the style rule overrides the sample's shorthand. Reviewers must flag any shorthand that slips through.
|
|
18
|
-
|
|
19
|
-
**Explicitly NOT in Phase 2 (must not creep in):**
|
|
20
|
-
- The Ink TUI side pane (Phase 3) — Phase 2 leaves the right pane as the placeholder shell from Phase 1
|
|
21
|
-
- Concert Hall multi-orchestra UI (Phase 3)
|
|
22
|
-
- Permission-prompt detection from §5.2.1 (Phase 4) — Phase 2 supports the `awaiting_permission` status field but does not populate it
|
|
23
|
-
- Activity-loop pane scraping for Auditorium display (Phase 3)
|
|
24
|
-
- Token / cost tracking surfacing (Phase 3 status bar)
|
|
25
|
-
- Persistent musician memory beyond `claude --resume`
|
|
26
|
-
- Bell / desktop notifications
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## File Structure
|
|
31
|
-
|
|
32
|
-
```
|
|
33
|
-
package.json # MODIFY: add @modelcontextprotocol/sdk dep
|
|
34
|
-
src/
|
|
35
|
-
├── musicians/
|
|
36
|
-
│ ├── ids.ts # NEW: musician id generation (sequence per orchestra)
|
|
37
|
-
│ ├── spawn.ts # NEW: createMusician(orchestraId, name, task, opts)
|
|
38
|
-
│ ├── message.ts # NEW: messageMusician(orchestraId, musicianId, text)
|
|
39
|
-
│ ├── query.ts # NEW: queryMusician(orchestraId, musicianId, lines)
|
|
40
|
-
│ ├── dismiss.ts # NEW: dismissMusician(orchestraId, musicianId, opts)
|
|
41
|
-
│ └── lookup.ts # NEW: findMusician(state, id) → Musician | undefined
|
|
42
|
-
├── worktree.ts # NEW: git worktree wrapper (add/remove/move/HEAD)
|
|
43
|
-
├── notes.ts # NEW: noteRead / noteWrite / noteList
|
|
44
|
-
├── state-updaters.ts # NEW: addMusician / setMusicianStatus / archiveMusician
|
|
45
|
-
├── prompts/
|
|
46
|
-
│ ├── orchestrator-role.ts # MODIFY: Phase 2 prompt documenting MCP tools
|
|
47
|
-
│ └── musician-role.ts # NEW: musician role addendum
|
|
48
|
-
├── mcp/
|
|
49
|
-
│ ├── server.ts # NEW: MCP Server setup + tool registry
|
|
50
|
-
│ ├── tool-defs.ts # NEW: tool name+description+inputSchema JSON Schemas
|
|
51
|
-
│ └── handlers.ts # NEW: dispatch table from tool name to handler function
|
|
52
|
-
├── commands/
|
|
53
|
-
│ ├── launch.ts # MODIFY: write mcp-config.json; pass --mcp-config to claude
|
|
54
|
-
│ ├── restore.ts # MODIFY: pass --mcp-config; restore musicians
|
|
55
|
-
│ └── mcp-server.ts # NEW: cli subcommand `nfo mcp-server --orchestra-id <id>`
|
|
56
|
-
└── cli.ts # MODIFY: register `mcp-server` subcommand (hidden)
|
|
57
|
-
tests/
|
|
58
|
-
├── worktree.test.ts # NEW
|
|
59
|
-
├── notes.test.ts # NEW
|
|
60
|
-
├── state-updaters.test.ts # NEW
|
|
61
|
-
├── musicians/
|
|
62
|
-
│ ├── spawn.test.ts # NEW
|
|
63
|
-
│ ├── message.test.ts # NEW
|
|
64
|
-
│ ├── query.test.ts # NEW
|
|
65
|
-
│ └── dismiss.test.ts # NEW
|
|
66
|
-
└── mcp/
|
|
67
|
-
├── tool-defs.test.ts # NEW
|
|
68
|
-
└── handlers.test.ts # NEW (covers spawn/message/query/list/dismiss/report/notes via dispatch)
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Total new src files: 14. Modified: 4. Total new test files: 9.
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## Task 1: Add MCP SDK dependency
|
|
76
|
-
|
|
77
|
-
**Files:**
|
|
78
|
-
- Modify: `package.json`
|
|
79
|
-
|
|
80
|
-
- [ ] **Step 1: Add `@modelcontextprotocol/sdk` to dependencies**
|
|
81
|
-
|
|
82
|
-
Edit `package.json` to add `"@modelcontextprotocol/sdk": "^1.29.0"` in `dependencies` (keep alphabetical with existing entries — it sorts before `commander`).
|
|
83
|
-
|
|
84
|
-
- [ ] **Step 2: Install**
|
|
85
|
-
|
|
86
|
-
Run: `npm install`
|
|
87
|
-
Expected: `node_modules/@modelcontextprotocol/sdk` exists, lockfile updates.
|
|
88
|
-
|
|
89
|
-
- [ ] **Step 3: Verify build + typecheck**
|
|
90
|
-
|
|
91
|
-
```
|
|
92
|
-
npm run build
|
|
93
|
-
npm run typecheck
|
|
94
|
-
npm test
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
All must pass (30/30 tests from Phase 1).
|
|
98
|
-
|
|
99
|
-
- [ ] **Step 4: Commit**
|
|
100
|
-
|
|
101
|
-
```
|
|
102
|
-
git add package.json package-lock.json
|
|
103
|
-
git commit -m "$(cat <<'EOF'
|
|
104
|
-
chore: add @modelcontextprotocol/sdk dependency
|
|
105
|
-
|
|
106
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
107
|
-
EOF
|
|
108
|
-
)"
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
---
|
|
112
|
-
|
|
113
|
-
## Task 2: Git worktree wrapper
|
|
114
|
-
|
|
115
|
-
**Files:**
|
|
116
|
-
- Create: `tests/worktree.test.ts`
|
|
117
|
-
- Create: `src/worktree.ts`
|
|
118
|
-
|
|
119
|
-
- [ ] **Step 1: Write the failing test**
|
|
120
|
-
|
|
121
|
-
`tests/worktree.test.ts`:
|
|
122
|
-
|
|
123
|
-
```typescript
|
|
124
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
125
|
-
import { execa } from 'execa';
|
|
126
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
127
|
-
import { existsSync } from 'node:fs';
|
|
128
|
-
import { tmpdir } from 'node:os';
|
|
129
|
-
import { join } from 'node:path';
|
|
130
|
-
import { makeTmpRepo, type TmpRepo } from './helpers/tmp-repo.js';
|
|
131
|
-
import { addWorktree, removeWorktree, worktreeExists } from '../src/worktree.js';
|
|
132
|
-
|
|
133
|
-
describe('worktree wrapper', () => {
|
|
134
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
135
|
-
const dirsToRemove: string[] = [];
|
|
136
|
-
|
|
137
|
-
afterEach(async () => {
|
|
138
|
-
for (const d of dirsToRemove) {
|
|
139
|
-
try { await rm(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
140
|
-
}
|
|
141
|
-
dirsToRemove.length = 0;
|
|
142
|
-
for (const c of cleanups) await c();
|
|
143
|
-
cleanups.length = 0;
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
async function track(t: TmpRepo) {
|
|
147
|
-
cleanups.push(t.cleanup);
|
|
148
|
-
return t;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
it('addWorktree creates a worktree on a new branch from HEAD', async () => {
|
|
152
|
-
const repo = await track(await makeTmpRepo());
|
|
153
|
-
const workArea = await mkdtemp(join(tmpdir(), 'nfo-wt-'));
|
|
154
|
-
dirsToRemove.push(workArea);
|
|
155
|
-
const path = join(workArea, 'mus-001');
|
|
156
|
-
|
|
157
|
-
await addWorktree({ repoRoot: repo.path, path, branch: 'nfo/mus-001' });
|
|
158
|
-
|
|
159
|
-
expect(existsSync(path)).toBe(true);
|
|
160
|
-
expect(await worktreeExists(repo.path, path)).toBe(true);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('addWorktree honours baseRef', async () => {
|
|
164
|
-
const repo = await track(await makeTmpRepo());
|
|
165
|
-
// Make a second commit so HEAD ≠ first commit
|
|
166
|
-
await execa('git', ['commit', '--allow-empty', '-m', 'second'], { cwd: repo.path });
|
|
167
|
-
const { stdout: firstSha } = await execa('git', ['rev-parse', 'HEAD~1'], { cwd: repo.path });
|
|
168
|
-
|
|
169
|
-
const workArea = await mkdtemp(join(tmpdir(), 'nfo-wt-'));
|
|
170
|
-
dirsToRemove.push(workArea);
|
|
171
|
-
const path = join(workArea, 'mus-002');
|
|
172
|
-
|
|
173
|
-
await addWorktree({
|
|
174
|
-
repoRoot: repo.path,
|
|
175
|
-
path,
|
|
176
|
-
branch: 'nfo/mus-002',
|
|
177
|
-
baseRef: firstSha.trim(),
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
const { stdout: branchSha } = await execa('git', ['rev-parse', 'HEAD'], { cwd: path });
|
|
181
|
-
expect(branchSha.trim()).toBe(firstSha.trim());
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('removeWorktree removes the worktree dir and metadata', async () => {
|
|
185
|
-
const repo = await track(await makeTmpRepo());
|
|
186
|
-
const workArea = await mkdtemp(join(tmpdir(), 'nfo-wt-'));
|
|
187
|
-
dirsToRemove.push(workArea);
|
|
188
|
-
const path = join(workArea, 'mus-003');
|
|
189
|
-
|
|
190
|
-
await addWorktree({ repoRoot: repo.path, path, branch: 'nfo/mus-003' });
|
|
191
|
-
expect(await worktreeExists(repo.path, path)).toBe(true);
|
|
192
|
-
|
|
193
|
-
await removeWorktree({ repoRoot: repo.path, path });
|
|
194
|
-
expect(existsSync(path)).toBe(false);
|
|
195
|
-
expect(await worktreeExists(repo.path, path)).toBe(false);
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
- [ ] **Step 2: Run test, confirm FAIL**
|
|
201
|
-
|
|
202
|
-
`npm test -- worktree` — expected FAIL (module not found).
|
|
203
|
-
|
|
204
|
-
- [ ] **Step 3: Implement `src/worktree.ts`**
|
|
205
|
-
|
|
206
|
-
```typescript
|
|
207
|
-
import { execa } from 'execa';
|
|
208
|
-
|
|
209
|
-
export interface AddWorktreeArgs {
|
|
210
|
-
repoRoot: string; // The main repo (where .git lives)
|
|
211
|
-
path: string; // Where to create the worktree
|
|
212
|
-
branch: string; // New branch name to create
|
|
213
|
-
baseRef?: string; // Defaults to HEAD
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export async function addWorktree(args: AddWorktreeArgs): Promise<void> {
|
|
217
|
-
const cmdArgs = ['worktree', 'add', '-b', args.branch, args.path];
|
|
218
|
-
if (args.baseRef) cmdArgs.push(args.baseRef);
|
|
219
|
-
await execa('git', cmdArgs, { cwd: args.repoRoot });
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export interface RemoveWorktreeArgs {
|
|
223
|
-
repoRoot: string;
|
|
224
|
-
path: string;
|
|
225
|
-
force?: boolean;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export async function removeWorktree(args: RemoveWorktreeArgs): Promise<void> {
|
|
229
|
-
const cmdArgs = ['worktree', 'remove'];
|
|
230
|
-
if (args.force) cmdArgs.push('--force');
|
|
231
|
-
cmdArgs.push(args.path);
|
|
232
|
-
await execa('git', cmdArgs, { cwd: args.repoRoot, reject: false });
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export async function worktreeExists(repoRoot: string, path: string): Promise<boolean> {
|
|
236
|
-
const { stdout } = await execa('git', ['worktree', 'list', '--porcelain'], { cwd: repoRoot });
|
|
237
|
-
// porcelain output starts each entry with `worktree <path>` lines.
|
|
238
|
-
const lines = stdout.split('\n');
|
|
239
|
-
return lines.some(l => l === `worktree ${path}`);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export async function deleteBranch(repoRoot: string, branch: string): Promise<void> {
|
|
243
|
-
await execa('git', ['branch', '-D', branch], { cwd: repoRoot, reject: false });
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
- [ ] **Step 4: Run test, confirm PASS**
|
|
248
|
-
|
|
249
|
-
`npm test -- worktree` — expected 3/3 PASS.
|
|
250
|
-
|
|
251
|
-
- [ ] **Step 5: Commit**
|
|
252
|
-
|
|
253
|
-
```
|
|
254
|
-
git add src/worktree.ts tests/worktree.test.ts
|
|
255
|
-
git commit -m "$(cat <<'EOF'
|
|
256
|
-
feat(worktree): git worktree add/remove/list wrapper
|
|
257
|
-
|
|
258
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
259
|
-
EOF
|
|
260
|
-
)"
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
---
|
|
264
|
-
|
|
265
|
-
## Task 3: Musician id generation
|
|
266
|
-
|
|
267
|
-
**Files:**
|
|
268
|
-
- Create: `src/musicians/ids.ts`
|
|
269
|
-
|
|
270
|
-
This is a tiny stateless helper. No tests needed (covered by spawn tests).
|
|
271
|
-
|
|
272
|
-
- [ ] **Step 1: Create `src/musicians/ids.ts`**
|
|
273
|
-
|
|
274
|
-
```typescript
|
|
275
|
-
import type { OrchestraState } from '../state.types.js';
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Generate the next musician id. Format: `mus-NNN` where NNN is zero-padded
|
|
279
|
-
* to 3 digits and counts active + archived musicians. Never reuses an id even
|
|
280
|
-
* after a musician is archived (avoids confusion in logs).
|
|
281
|
-
*/
|
|
282
|
-
export function nextMusicianId(state: OrchestraState): string {
|
|
283
|
-
const used = new Set<string>();
|
|
284
|
-
for (const m of state.musicians) used.add(m.id);
|
|
285
|
-
for (const m of state.archived_musicians) used.add(m.id);
|
|
286
|
-
let n = used.size + 1;
|
|
287
|
-
// Defensive: if collision somehow occurs (race recovery), increment until free.
|
|
288
|
-
while (used.has(`mus-${String(n).padStart(3, '0')}`)) n++;
|
|
289
|
-
return `mus-${String(n).padStart(3, '0')}`;
|
|
290
|
-
}
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
- [ ] **Step 2: Typecheck**
|
|
294
|
-
|
|
295
|
-
`npm run typecheck` — must pass.
|
|
296
|
-
|
|
297
|
-
- [ ] **Step 3: Commit**
|
|
298
|
-
|
|
299
|
-
```
|
|
300
|
-
git add src/musicians/ids.ts
|
|
301
|
-
git commit -m "$(cat <<'EOF'
|
|
302
|
-
feat(musicians): mus-NNN id generator
|
|
303
|
-
|
|
304
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
305
|
-
EOF
|
|
306
|
-
)"
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
---
|
|
310
|
-
|
|
311
|
-
## Task 4: State updaters
|
|
312
|
-
|
|
313
|
-
**Files:**
|
|
314
|
-
- Create: `tests/state-updaters.test.ts`
|
|
315
|
-
- Create: `src/state-updaters.ts`
|
|
316
|
-
|
|
317
|
-
These are the higher-level mutators that several Phase 2 modules call. Keeping them in one place enforces consistent locking and field-update conventions.
|
|
318
|
-
|
|
319
|
-
- [ ] **Step 1: Write the failing test**
|
|
320
|
-
|
|
321
|
-
`tests/state-updaters.test.ts`:
|
|
322
|
-
|
|
323
|
-
```typescript
|
|
324
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
325
|
-
import {
|
|
326
|
-
addMusician,
|
|
327
|
-
setMusicianStatus,
|
|
328
|
-
archiveMusician,
|
|
329
|
-
setOrchestratorSessionId,
|
|
330
|
-
setMusicianClaudeSessionId,
|
|
331
|
-
touchMusicianActivity,
|
|
332
|
-
} from '../src/state-updaters.js';
|
|
333
|
-
import { ensureOrchestraDir, writeState, readState } from '../src/state.js';
|
|
334
|
-
import { makeInitialState } from '../src/state.types.js';
|
|
335
|
-
import { makeTmpConfig } from './helpers/tmp-config.js';
|
|
336
|
-
|
|
337
|
-
describe('state updaters', () => {
|
|
338
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
339
|
-
|
|
340
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
341
|
-
afterEach(async () => {
|
|
342
|
-
for (const c of cleanups) await c();
|
|
343
|
-
cleanups.length = 0;
|
|
344
|
-
delete process.env.NFO_HOME;
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
async function freshState(id: string) {
|
|
348
|
-
const tmp = await makeTmpConfig();
|
|
349
|
-
cleanups.push(tmp.cleanup);
|
|
350
|
-
process.env.NFO_HOME = tmp.path;
|
|
351
|
-
await ensureOrchestraDir(id);
|
|
352
|
-
await writeState(id, makeInitialState({
|
|
353
|
-
orchestraId: id, projectPath: '/tmp/x', permissionLevel: 'supervised',
|
|
354
|
-
}));
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
it('addMusician appends a working musician', async () => {
|
|
358
|
-
await freshState('orch-a');
|
|
359
|
-
await addMusician('orch-a', {
|
|
360
|
-
id: 'mus-001',
|
|
361
|
-
name: 'tester',
|
|
362
|
-
task_summary: 'run tests',
|
|
363
|
-
status: 'working',
|
|
364
|
-
tmux_window_id: '@1',
|
|
365
|
-
claude_session_id: null,
|
|
366
|
-
worktree_path: '/tmp/w',
|
|
367
|
-
branch: 'nfo/mus-001',
|
|
368
|
-
spawned_at: '2026-05-29T10:00:00Z',
|
|
369
|
-
last_activity: '2026-05-29T10:00:00Z',
|
|
370
|
-
});
|
|
371
|
-
const state = await readState('orch-a');
|
|
372
|
-
expect(state!.musicians).toHaveLength(1);
|
|
373
|
-
expect(state!.musicians[0].id).toBe('mus-001');
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
it('setMusicianStatus updates only that musician', async () => {
|
|
377
|
-
await freshState('orch-b');
|
|
378
|
-
await addMusician('orch-b', baseMus('mus-001'));
|
|
379
|
-
await addMusician('orch-b', baseMus('mus-002'));
|
|
380
|
-
|
|
381
|
-
await setMusicianStatus('orch-b', 'mus-001', 'idle');
|
|
382
|
-
|
|
383
|
-
const state = await readState('orch-b');
|
|
384
|
-
expect(state!.musicians.find(m => m.id === 'mus-001')!.status).toBe('idle');
|
|
385
|
-
expect(state!.musicians.find(m => m.id === 'mus-002')!.status).toBe('working');
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
it('archiveMusician moves the musician to archived_musicians with summary + timestamp', async () => {
|
|
389
|
-
await freshState('orch-c');
|
|
390
|
-
await addMusician('orch-c', baseMus('mus-001'));
|
|
391
|
-
|
|
392
|
-
await archiveMusician('orch-c', 'mus-001', { summary: 'done', dismissedAt: '2026-05-29T11:00:00Z' });
|
|
393
|
-
|
|
394
|
-
const state = await readState('orch-c');
|
|
395
|
-
expect(state!.musicians).toHaveLength(0);
|
|
396
|
-
expect(state!.archived_musicians).toHaveLength(1);
|
|
397
|
-
expect(state!.archived_musicians[0].id).toBe('mus-001');
|
|
398
|
-
expect(state!.archived_musicians[0].dismissed_at).toBe('2026-05-29T11:00:00Z');
|
|
399
|
-
expect(state!.archived_musicians[0].summary).toBe('done');
|
|
400
|
-
expect(state!.archived_musicians[0].status).toBe('stopped');
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it('setOrchestratorSessionId records the session id', async () => {
|
|
404
|
-
await freshState('orch-d');
|
|
405
|
-
await setOrchestratorSessionId('orch-d', 'sess-abc');
|
|
406
|
-
const state = await readState('orch-d');
|
|
407
|
-
expect(state!.orchestrator_session_id).toBe('sess-abc');
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
it('setMusicianClaudeSessionId records the session id', async () => {
|
|
411
|
-
await freshState('orch-e');
|
|
412
|
-
await addMusician('orch-e', baseMus('mus-001'));
|
|
413
|
-
await setMusicianClaudeSessionId('orch-e', 'mus-001', 'sess-xyz');
|
|
414
|
-
const state = await readState('orch-e');
|
|
415
|
-
expect(state!.musicians[0].claude_session_id).toBe('sess-xyz');
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
it('touchMusicianActivity updates last_activity', async () => {
|
|
419
|
-
await freshState('orch-f');
|
|
420
|
-
await addMusician('orch-f', baseMus('mus-001'));
|
|
421
|
-
await touchMusicianActivity('orch-f', 'mus-001', '2026-05-29T12:00:00Z');
|
|
422
|
-
const state = await readState('orch-f');
|
|
423
|
-
expect(state!.musicians[0].last_activity).toBe('2026-05-29T12:00:00Z');
|
|
424
|
-
});
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
function baseMus(id: string) {
|
|
428
|
-
return {
|
|
429
|
-
id,
|
|
430
|
-
name: 'm',
|
|
431
|
-
task_summary: 't',
|
|
432
|
-
status: 'working' as const,
|
|
433
|
-
tmux_window_id: '@0',
|
|
434
|
-
claude_session_id: null,
|
|
435
|
-
worktree_path: null,
|
|
436
|
-
branch: null,
|
|
437
|
-
spawned_at: '2026-05-29T10:00:00Z',
|
|
438
|
-
last_activity: '2026-05-29T10:00:00Z',
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
```
|
|
442
|
-
|
|
443
|
-
- [ ] **Step 2: Run test, confirm FAIL**
|
|
444
|
-
|
|
445
|
-
`npm test -- state-updaters` — expected FAIL.
|
|
446
|
-
|
|
447
|
-
- [ ] **Step 3: Implement `src/state-updaters.ts`**
|
|
448
|
-
|
|
449
|
-
```typescript
|
|
450
|
-
import { readState, writeState } from './state.js';
|
|
451
|
-
import type {
|
|
452
|
-
ArchivedMusician,
|
|
453
|
-
Musician,
|
|
454
|
-
MusicianStatus,
|
|
455
|
-
OrchestraState,
|
|
456
|
-
} from './state.types.js';
|
|
457
|
-
|
|
458
|
-
async function update(
|
|
459
|
-
orchestraId: string,
|
|
460
|
-
mutator: (s: OrchestraState) => void,
|
|
461
|
-
): Promise<void> {
|
|
462
|
-
const state = await readState(orchestraId);
|
|
463
|
-
if (!state) throw new Error(`Unknown orchestra: ${orchestraId}`);
|
|
464
|
-
mutator(state);
|
|
465
|
-
await writeState(orchestraId, state);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
export async function addMusician(orchestraId: string, m: Musician): Promise<void> {
|
|
469
|
-
await update(orchestraId, s => { s.musicians.push(m); });
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
export async function setMusicianStatus(
|
|
473
|
-
orchestraId: string,
|
|
474
|
-
musicianId: string,
|
|
475
|
-
status: MusicianStatus,
|
|
476
|
-
pendingPermission?: string | null,
|
|
477
|
-
): Promise<void> {
|
|
478
|
-
await update(orchestraId, s => {
|
|
479
|
-
const m = s.musicians.find(mu => mu.id === musicianId);
|
|
480
|
-
if (!m) throw new Error(`Unknown musician: ${musicianId}`);
|
|
481
|
-
m.status = status;
|
|
482
|
-
if (pendingPermission !== undefined) m.pending_permission = pendingPermission;
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
export async function setMusicianClaudeSessionId(
|
|
487
|
-
orchestraId: string,
|
|
488
|
-
musicianId: string,
|
|
489
|
-
sessionId: string,
|
|
490
|
-
): Promise<void> {
|
|
491
|
-
await update(orchestraId, s => {
|
|
492
|
-
const m = s.musicians.find(mu => mu.id === musicianId);
|
|
493
|
-
if (!m) throw new Error(`Unknown musician: ${musicianId}`);
|
|
494
|
-
m.claude_session_id = sessionId;
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
export async function touchMusicianActivity(
|
|
499
|
-
orchestraId: string,
|
|
500
|
-
musicianId: string,
|
|
501
|
-
timestamp?: string,
|
|
502
|
-
): Promise<void> {
|
|
503
|
-
const ts = timestamp ?? new Date().toISOString();
|
|
504
|
-
await update(orchestraId, s => {
|
|
505
|
-
const m = s.musicians.find(mu => mu.id === musicianId);
|
|
506
|
-
if (!m) throw new Error(`Unknown musician: ${musicianId}`);
|
|
507
|
-
m.last_activity = ts;
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
export async function setOrchestratorSessionId(
|
|
512
|
-
orchestraId: string,
|
|
513
|
-
sessionId: string,
|
|
514
|
-
): Promise<void> {
|
|
515
|
-
await update(orchestraId, s => { s.orchestrator_session_id = sessionId; });
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
export interface ArchiveArgs {
|
|
519
|
-
summary: string | null;
|
|
520
|
-
dismissedAt?: string;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
export async function archiveMusician(
|
|
524
|
-
orchestraId: string,
|
|
525
|
-
musicianId: string,
|
|
526
|
-
args: ArchiveArgs,
|
|
527
|
-
): Promise<void> {
|
|
528
|
-
await update(orchestraId, s => {
|
|
529
|
-
const idx = s.musicians.findIndex(mu => mu.id === musicianId);
|
|
530
|
-
if (idx === -1) throw new Error(`Unknown musician: ${musicianId}`);
|
|
531
|
-
const [m] = s.musicians.splice(idx, 1);
|
|
532
|
-
const archived: ArchivedMusician = {
|
|
533
|
-
...m,
|
|
534
|
-
status: 'stopped',
|
|
535
|
-
dismissed_at: args.dismissedAt ?? new Date().toISOString(),
|
|
536
|
-
summary: args.summary,
|
|
537
|
-
};
|
|
538
|
-
s.archived_musicians.push(archived);
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
```
|
|
542
|
-
|
|
543
|
-
- [ ] **Step 4: Run test, confirm PASS**
|
|
544
|
-
|
|
545
|
-
`npm test -- state-updaters` — expected 6/6 PASS.
|
|
546
|
-
|
|
547
|
-
- [ ] **Step 5: Commit**
|
|
548
|
-
|
|
549
|
-
```
|
|
550
|
-
git add src/state-updaters.ts tests/state-updaters.test.ts
|
|
551
|
-
git commit -m "$(cat <<'EOF'
|
|
552
|
-
feat(state): typed state mutators (musicians, sessions, activity, archive)
|
|
553
|
-
|
|
554
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
555
|
-
EOF
|
|
556
|
-
)"
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
---
|
|
560
|
-
|
|
561
|
-
## Task 5: Musician lookup helper
|
|
562
|
-
|
|
563
|
-
**Files:**
|
|
564
|
-
- Create: `src/musicians/lookup.ts`
|
|
565
|
-
|
|
566
|
-
- [ ] **Step 1: Create `src/musicians/lookup.ts`**
|
|
567
|
-
|
|
568
|
-
```typescript
|
|
569
|
-
import type { Musician, OrchestraState } from '../state.types.js';
|
|
570
|
-
|
|
571
|
-
export function findMusician(state: OrchestraState, id: string): Musician | undefined {
|
|
572
|
-
return state.musicians.find(m => m.id === id);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
export function findMusicianStrict(state: OrchestraState, id: string): Musician {
|
|
576
|
-
const m = findMusician(state, id);
|
|
577
|
-
if (!m) throw new Error(`Unknown musician: ${id}`);
|
|
578
|
-
return m;
|
|
579
|
-
}
|
|
580
|
-
```
|
|
581
|
-
|
|
582
|
-
- [ ] **Step 2: Typecheck and commit**
|
|
583
|
-
|
|
584
|
-
`npm run typecheck` must pass.
|
|
585
|
-
|
|
586
|
-
```
|
|
587
|
-
git add src/musicians/lookup.ts
|
|
588
|
-
git commit -m "$(cat <<'EOF'
|
|
589
|
-
feat(musicians): findMusician / findMusicianStrict helpers
|
|
590
|
-
|
|
591
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
592
|
-
EOF
|
|
593
|
-
)"
|
|
594
|
-
```
|
|
595
|
-
|
|
596
|
-
---
|
|
597
|
-
|
|
598
|
-
## Task 6: Musician role prompt + Orchestrator prompt update
|
|
599
|
-
|
|
600
|
-
**Files:**
|
|
601
|
-
- Create: `src/prompts/musician-role.ts`
|
|
602
|
-
- Modify: `src/prompts/orchestrator-role.ts`
|
|
603
|
-
|
|
604
|
-
- [ ] **Step 1: Create `src/prompts/musician-role.ts`**
|
|
605
|
-
|
|
606
|
-
```typescript
|
|
607
|
-
export const MUSICIAN_ROLE_PROMPT_V1 = `You are a Musician in an NFO orchestra.
|
|
608
|
-
|
|
609
|
-
You were spawned by the Orchestrator with a specific task. The user typing into
|
|
610
|
-
your pane is debugging / observing — usually the user does NOT direct you;
|
|
611
|
-
the Orchestrator does. Treat new user messages as either Orchestrator
|
|
612
|
-
hand-offs or out-of-band human guidance, and use judgment.
|
|
613
|
-
|
|
614
|
-
Your workspace is a dedicated git worktree, so file edits are isolated from
|
|
615
|
-
other Musicians. When you finish the task you were spawned with, call the
|
|
616
|
-
\`report_done\` MCP tool with a concise summary. After that, stay alive — the
|
|
617
|
-
Orchestrator may message you again with follow-up work.
|
|
618
|
-
|
|
619
|
-
You also have the full NFO MCP tool surface (\`spawn_musician\`,
|
|
620
|
-
\`message_musician\`, etc.). Avoid spawning sub-Musicians unless the
|
|
621
|
-
Orchestrator explicitly asks you to. Keep coordination centralised.
|
|
622
|
-
`;
|
|
623
|
-
```
|
|
624
|
-
|
|
625
|
-
- [ ] **Step 2: Replace `src/prompts/orchestrator-role.ts` content**
|
|
626
|
-
|
|
627
|
-
```typescript
|
|
628
|
-
/**
|
|
629
|
-
* Phase 2 Orchestrator role addendum. Documents the NFO MCP tool surface.
|
|
630
|
-
*/
|
|
631
|
-
export const ORCHESTRATOR_ROLE_PROMPT_V1 = `You are the Orchestrator of an NFO orchestra.
|
|
632
|
-
|
|
633
|
-
NFO (NoFluffOrchestra) is a TUI for multi-agent work on the user's repository.
|
|
634
|
-
You coordinate Musicians (other Claude Code agents) via the NFO MCP tools.
|
|
635
|
-
|
|
636
|
-
Available NFO tools (in addition to your normal Claude Code tools):
|
|
637
|
-
|
|
638
|
-
spawn_musician({ name, task, worktree?, branch_from? })
|
|
639
|
-
Create a Musician with the given task. By default the Musician runs in a
|
|
640
|
-
fresh git worktree off HEAD. Pass worktree=false for trivially isolated
|
|
641
|
-
work (e.g., docs-only) that doesn't need an isolated branch. Returns the
|
|
642
|
-
musician_id.
|
|
643
|
-
|
|
644
|
-
message_musician({ musician_id, message })
|
|
645
|
-
Send a message into the Musician's input. Fire-and-forget.
|
|
646
|
-
|
|
647
|
-
query_musician({ musician_id, lines? })
|
|
648
|
-
Read the most recent visible output from the Musician's pane. Use this
|
|
649
|
-
sparingly — capture-pane is heuristic and may include rendering artifacts.
|
|
650
|
-
|
|
651
|
-
list_musicians()
|
|
652
|
-
Return all currently-active Musicians with their status.
|
|
653
|
-
|
|
654
|
-
dismiss_musician({ musician_id, archive_worktree? })
|
|
655
|
-
Tear down a Musician. By default the worktree is archived under
|
|
656
|
-
.../archive/<musician_id>/worktree (the branch is preserved). Pass
|
|
657
|
-
archive_worktree=false to drop the worktree entirely.
|
|
658
|
-
|
|
659
|
-
note_write({ filename, content }) / note_read({ filename }) / note_list()
|
|
660
|
-
Your private project memory under ~/.config/nfo/projects/<key>/notes/.
|
|
661
|
-
On every fresh Orchestrator session, the contents of notes/overview.md
|
|
662
|
-
and notes/decisions.md are loaded into your context automatically.
|
|
663
|
-
Use these to record decisions, open questions, and durable project
|
|
664
|
-
understanding the user would want you to remember next session.
|
|
665
|
-
|
|
666
|
-
Coordination guidance:
|
|
667
|
-
|
|
668
|
-
- For agent coordination, PREFER the NFO MCP tools over Claude Code's built-in
|
|
669
|
-
Task tool. The user tracks Musician work through NFO; Task spawns are invisible
|
|
670
|
-
to NFO.
|
|
671
|
-
- Worktrees solve concurrent file-edit safety, not API coupling. If two
|
|
672
|
-
Musicians' outputs need to be wired together, sequence the work, or spawn an
|
|
673
|
-
integration Musician afterward.
|
|
674
|
-
- The orchestra's permission level applies to every Musician you spawn.
|
|
675
|
-
- Project-level guidance in CLAUDE.md still applies; respect it.
|
|
676
|
-
`;
|
|
677
|
-
```
|
|
678
|
-
|
|
679
|
-
- [ ] **Step 3: Typecheck**
|
|
680
|
-
|
|
681
|
-
`npm run typecheck` — must pass.
|
|
682
|
-
|
|
683
|
-
- [ ] **Step 4: Commit**
|
|
684
|
-
|
|
685
|
-
```
|
|
686
|
-
git add src/prompts/musician-role.ts src/prompts/orchestrator-role.ts
|
|
687
|
-
git commit -m "$(cat <<'EOF'
|
|
688
|
-
feat(prompts): musician role + Phase 2 orchestrator prompt with MCP tools
|
|
689
|
-
|
|
690
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
691
|
-
EOF
|
|
692
|
-
)"
|
|
693
|
-
```
|
|
694
|
-
|
|
695
|
-
---
|
|
696
|
-
|
|
697
|
-
## Task 7: Musician spawn
|
|
698
|
-
|
|
699
|
-
**Files:**
|
|
700
|
-
- Create: `tests/musicians/spawn.test.ts`
|
|
701
|
-
- Create: `src/musicians/spawn.ts`
|
|
702
|
-
|
|
703
|
-
- [ ] **Step 1: Write the failing test**
|
|
704
|
-
|
|
705
|
-
`tests/musicians/spawn.test.ts`:
|
|
706
|
-
|
|
707
|
-
```typescript
|
|
708
|
-
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
709
|
-
import { createMusician } from '../../src/musicians/spawn.js';
|
|
710
|
-
import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
|
|
711
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
712
|
-
import { readState, ensureOrchestraDir, writeState } from '../../src/state.js';
|
|
713
|
-
import { makeInitialState } from '../../src/state.types.js';
|
|
714
|
-
import { projectKeyFromPath } from '../../src/project-key.js';
|
|
715
|
-
import {
|
|
716
|
-
createDetachedSession,
|
|
717
|
-
sessionName,
|
|
718
|
-
killSession,
|
|
719
|
-
sessionExists,
|
|
720
|
-
} from '../../src/tmux.js';
|
|
721
|
-
import { existsSync } from 'node:fs';
|
|
722
|
-
|
|
723
|
-
describe('createMusician', () => {
|
|
724
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
725
|
-
const sessionsToKill: string[] = [];
|
|
726
|
-
|
|
727
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
728
|
-
|
|
729
|
-
afterEach(async () => {
|
|
730
|
-
for (const s of sessionsToKill) {
|
|
731
|
-
try { await killSession(s); } catch { /* ignore */ }
|
|
732
|
-
}
|
|
733
|
-
sessionsToKill.length = 0;
|
|
734
|
-
for (const c of cleanups) await c();
|
|
735
|
-
cleanups.length = 0;
|
|
736
|
-
delete process.env.NFO_HOME;
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
it('creates a worktree, a tmux window, and a state.json entry', async () => {
|
|
740
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
741
|
-
cleanups.push(repo.cleanup);
|
|
742
|
-
const cfg = await makeTmpConfig();
|
|
743
|
-
cleanups.push(cfg.cleanup);
|
|
744
|
-
process.env.NFO_HOME = cfg.path;
|
|
745
|
-
|
|
746
|
-
const orchestraId = projectKeyFromPath(repo.path);
|
|
747
|
-
await ensureOrchestraDir(orchestraId);
|
|
748
|
-
await writeState(orchestraId, makeInitialState({
|
|
749
|
-
orchestraId, projectPath: repo.path, permissionLevel: 'supervised',
|
|
750
|
-
}));
|
|
751
|
-
|
|
752
|
-
const name = sessionName(orchestraId);
|
|
753
|
-
sessionsToKill.push(name);
|
|
754
|
-
await createDetachedSession(name, repo.path, 220, 50);
|
|
755
|
-
|
|
756
|
-
const result = await createMusician({
|
|
757
|
-
orchestraId,
|
|
758
|
-
name: 'tester',
|
|
759
|
-
task: 'run the test suite',
|
|
760
|
-
dryRun: true,
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
expect(result.musician_id).toMatch(/^mus-\d{3}$/);
|
|
764
|
-
expect(result.worktree_path).not.toBeNull();
|
|
765
|
-
if (result.worktree_path) {
|
|
766
|
-
expect(existsSync(result.worktree_path)).toBe(true);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
const state = await readState(orchestraId);
|
|
770
|
-
expect(state!.musicians).toHaveLength(1);
|
|
771
|
-
expect(state!.musicians[0].name).toBe('tester');
|
|
772
|
-
expect(state!.musicians[0].task_summary).toBe('run the test suite');
|
|
773
|
-
expect(state!.musicians[0].status).toBe('working');
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
it('honours worktree=false (no worktree, runs in repo root)', async () => {
|
|
777
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
778
|
-
cleanups.push(repo.cleanup);
|
|
779
|
-
const cfg = await makeTmpConfig();
|
|
780
|
-
cleanups.push(cfg.cleanup);
|
|
781
|
-
process.env.NFO_HOME = cfg.path;
|
|
782
|
-
|
|
783
|
-
const orchestraId = projectKeyFromPath(repo.path);
|
|
784
|
-
await ensureOrchestraDir(orchestraId);
|
|
785
|
-
await writeState(orchestraId, makeInitialState({
|
|
786
|
-
orchestraId, projectPath: repo.path, permissionLevel: 'supervised',
|
|
787
|
-
}));
|
|
788
|
-
|
|
789
|
-
const name = sessionName(orchestraId);
|
|
790
|
-
sessionsToKill.push(name);
|
|
791
|
-
await createDetachedSession(name, repo.path, 220, 50);
|
|
792
|
-
|
|
793
|
-
const result = await createMusician({
|
|
794
|
-
orchestraId,
|
|
795
|
-
name: 'doc-writer',
|
|
796
|
-
task: 'update README',
|
|
797
|
-
worktree: false,
|
|
798
|
-
dryRun: true,
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
expect(result.worktree_path).toBeNull();
|
|
802
|
-
const state = await readState(orchestraId);
|
|
803
|
-
expect(state!.musicians[0].worktree_path).toBeNull();
|
|
804
|
-
expect(state!.musicians[0].branch).toBeNull();
|
|
805
|
-
});
|
|
806
|
-
});
|
|
807
|
-
```
|
|
808
|
-
|
|
809
|
-
- [ ] **Step 2: Run test, confirm FAIL**
|
|
810
|
-
|
|
811
|
-
`npm test -- musicians/spawn` — expected FAIL.
|
|
812
|
-
|
|
813
|
-
- [ ] **Step 3: Implement `src/musicians/spawn.ts`**
|
|
814
|
-
|
|
815
|
-
```typescript
|
|
816
|
-
import { writeFile } from 'node:fs/promises';
|
|
817
|
-
import { join } from 'node:path';
|
|
818
|
-
import { execa } from 'execa';
|
|
819
|
-
import { addMusician } from '../state-updaters.js';
|
|
820
|
-
import { readState } from '../state.js';
|
|
821
|
-
import { orchestraDir, worktreesDir, archiveDir } from '../config.js';
|
|
822
|
-
import { addWorktree } from '../worktree.js';
|
|
823
|
-
import { claudeFlagsForLevel } from '../permission.js';
|
|
824
|
-
import { sessionName, sendKeys } from '../tmux.js';
|
|
825
|
-
import { execa as ex } from 'execa';
|
|
826
|
-
import { MUSICIAN_ROLE_PROMPT_V1 } from '../prompts/musician-role.js';
|
|
827
|
-
import { nextMusicianId } from './ids.js';
|
|
828
|
-
|
|
829
|
-
export interface CreateMusicianOptions {
|
|
830
|
-
orchestraId: string;
|
|
831
|
-
name: string;
|
|
832
|
-
task: string;
|
|
833
|
-
worktree?: boolean; // default true
|
|
834
|
-
branchFrom?: string; // default HEAD
|
|
835
|
-
dryRun?: boolean; // skip launching claude; useful for tests
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
export interface CreateMusicianResult {
|
|
839
|
-
musician_id: string;
|
|
840
|
-
worktree_path: string | null;
|
|
841
|
-
branch: string | null;
|
|
842
|
-
tmux_window_id: string;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
export async function createMusician(opts: CreateMusicianOptions): Promise<CreateMusicianResult> {
|
|
846
|
-
const state = await readState(opts.orchestraId);
|
|
847
|
-
if (!state) throw new Error(`Unknown orchestra: ${opts.orchestraId}`);
|
|
848
|
-
|
|
849
|
-
const musicianId = nextMusicianId(state);
|
|
850
|
-
const useWorktree = opts.worktree !== false;
|
|
851
|
-
|
|
852
|
-
// 1. Worktree (or repo root).
|
|
853
|
-
let workingDir: string;
|
|
854
|
-
let worktreePath: string | null = null;
|
|
855
|
-
let branch: string | null = null;
|
|
856
|
-
if (useWorktree) {
|
|
857
|
-
worktreePath = join(worktreesDir(opts.orchestraId), musicianId);
|
|
858
|
-
branch = `nfo/${musicianId}`;
|
|
859
|
-
await addWorktree({
|
|
860
|
-
repoRoot: state.project_path,
|
|
861
|
-
path: worktreePath,
|
|
862
|
-
branch,
|
|
863
|
-
baseRef: opts.branchFrom,
|
|
864
|
-
});
|
|
865
|
-
workingDir = worktreePath;
|
|
866
|
-
} else {
|
|
867
|
-
workingDir = state.project_path;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// 2. Per-musician role prompt file (so future restore can re-inject it).
|
|
871
|
-
const promptFile = join(orchestraDir(opts.orchestraId), `musician-${musicianId}-prompt.md`);
|
|
872
|
-
const prompt = MUSICIAN_ROLE_PROMPT_V1 + `\n\n## Initial task\n\n${opts.task}\n`;
|
|
873
|
-
await writeFile(promptFile, prompt, 'utf8');
|
|
874
|
-
|
|
875
|
-
// 3. New tmux window for the musician.
|
|
876
|
-
const session = sessionName(opts.orchestraId);
|
|
877
|
-
const winLabel = `mus-${musicianId}-${sanitiseName(opts.name)}`;
|
|
878
|
-
// `tmux new-window -P -F "#{window_id}"` returns the new window id (e.g. "@7").
|
|
879
|
-
const { stdout: tmuxWindowId } = await execa('tmux', [
|
|
880
|
-
'new-window',
|
|
881
|
-
'-t', session,
|
|
882
|
-
'-n', winLabel,
|
|
883
|
-
'-c', workingDir,
|
|
884
|
-
'-d',
|
|
885
|
-
'-P',
|
|
886
|
-
'-F', '#{window_id}',
|
|
887
|
-
]);
|
|
888
|
-
|
|
889
|
-
// 4. Launch claude in the new window (unless dryRun — used by tests).
|
|
890
|
-
if (!opts.dryRun) {
|
|
891
|
-
const mcpConfigPath = join(orchestraDir(opts.orchestraId), 'mcp-config.json');
|
|
892
|
-
const flags = claudeFlagsForLevel(state.permission_level);
|
|
893
|
-
const cmd = [
|
|
894
|
-
'claude',
|
|
895
|
-
...flags,
|
|
896
|
-
'--mcp-config', mcpConfigPath,
|
|
897
|
-
'--append-system-prompt-file', promptFile,
|
|
898
|
-
].join(' ');
|
|
899
|
-
await sendKeys(`${session}:${tmuxWindowId.trim()}`, cmd, true);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// 5. Register in state.json.
|
|
903
|
-
const now = new Date().toISOString();
|
|
904
|
-
await addMusician(opts.orchestraId, {
|
|
905
|
-
id: musicianId,
|
|
906
|
-
name: opts.name,
|
|
907
|
-
task_summary: opts.task.slice(0, 200),
|
|
908
|
-
status: 'working',
|
|
909
|
-
pending_permission: null,
|
|
910
|
-
tmux_window_id: tmuxWindowId.trim(),
|
|
911
|
-
claude_session_id: null,
|
|
912
|
-
worktree_path: worktreePath,
|
|
913
|
-
branch,
|
|
914
|
-
spawned_at: now,
|
|
915
|
-
last_activity: now,
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
return {
|
|
919
|
-
musician_id: musicianId,
|
|
920
|
-
worktree_path: worktreePath,
|
|
921
|
-
branch,
|
|
922
|
-
tmux_window_id: tmuxWindowId.trim(),
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
function sanitiseName(name: string): string {
|
|
927
|
-
return name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'musician';
|
|
928
|
-
}
|
|
929
|
-
```
|
|
930
|
-
|
|
931
|
-
Note: the spec note about `archiveDir` import — drop it; it's unused here (used by dismiss). Drop the duplicate `execa as ex` import that snuck in — keep just `import { execa } from 'execa'`.
|
|
932
|
-
|
|
933
|
-
- [ ] **Step 4: Run test, confirm PASS**
|
|
934
|
-
|
|
935
|
-
`npm test -- musicians/spawn` — expected 2/2 PASS.
|
|
936
|
-
|
|
937
|
-
- [ ] **Step 5: Commit**
|
|
938
|
-
|
|
939
|
-
```
|
|
940
|
-
git add src/musicians/spawn.ts tests/musicians/spawn.test.ts
|
|
941
|
-
git commit -m "$(cat <<'EOF'
|
|
942
|
-
feat(musicians): createMusician — worktree + tmux window + state row
|
|
943
|
-
|
|
944
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
945
|
-
EOF
|
|
946
|
-
)"
|
|
947
|
-
```
|
|
948
|
-
|
|
949
|
-
---
|
|
950
|
-
|
|
951
|
-
## Task 8: Musician message
|
|
952
|
-
|
|
953
|
-
**Files:**
|
|
954
|
-
- Create: `tests/musicians/message.test.ts`
|
|
955
|
-
- Create: `src/musicians/message.ts`
|
|
956
|
-
|
|
957
|
-
- [ ] **Step 1: Test**
|
|
958
|
-
|
|
959
|
-
`tests/musicians/message.test.ts`:
|
|
960
|
-
|
|
961
|
-
```typescript
|
|
962
|
-
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
963
|
-
import { messageMusician } from '../../src/musicians/message.js';
|
|
964
|
-
import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
|
|
965
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
966
|
-
import { ensureOrchestraDir, writeState, readState } from '../../src/state.js';
|
|
967
|
-
import { makeInitialState } from '../../src/state.types.js';
|
|
968
|
-
import { projectKeyFromPath } from '../../src/project-key.js';
|
|
969
|
-
import { addMusician } from '../../src/state-updaters.js';
|
|
970
|
-
import {
|
|
971
|
-
createDetachedSession,
|
|
972
|
-
sessionName,
|
|
973
|
-
killSession,
|
|
974
|
-
capturePane,
|
|
975
|
-
} from '../../src/tmux.js';
|
|
976
|
-
import { execa } from 'execa';
|
|
977
|
-
|
|
978
|
-
describe('messageMusician', () => {
|
|
979
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
980
|
-
const sessionsToKill: string[] = [];
|
|
981
|
-
|
|
982
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
983
|
-
|
|
984
|
-
afterEach(async () => {
|
|
985
|
-
for (const s of sessionsToKill) {
|
|
986
|
-
try { await killSession(s); } catch { /* ignore */ }
|
|
987
|
-
}
|
|
988
|
-
sessionsToKill.length = 0;
|
|
989
|
-
for (const c of cleanups) await c();
|
|
990
|
-
cleanups.length = 0;
|
|
991
|
-
delete process.env.NFO_HOME;
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
it('throws when musician is unknown', async () => {
|
|
995
|
-
const cfg = await makeTmpConfig();
|
|
996
|
-
cleanups.push(cfg.cleanup);
|
|
997
|
-
process.env.NFO_HOME = cfg.path;
|
|
998
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
999
|
-
cleanups.push(repo.cleanup);
|
|
1000
|
-
const id = projectKeyFromPath(repo.path);
|
|
1001
|
-
await ensureOrchestraDir(id);
|
|
1002
|
-
await writeState(id, makeInitialState({
|
|
1003
|
-
orchestraId: id, projectPath: repo.path, permissionLevel: 'supervised',
|
|
1004
|
-
}));
|
|
1005
|
-
|
|
1006
|
-
await expect(
|
|
1007
|
-
messageMusician({ orchestraId: id, musicianId: 'mus-999', message: 'hi' }),
|
|
1008
|
-
).rejects.toThrow(/Unknown musician/);
|
|
1009
|
-
});
|
|
1010
|
-
|
|
1011
|
-
it('sends keys + Enter to the musician\'s tmux window', async () => {
|
|
1012
|
-
const cfg = await makeTmpConfig();
|
|
1013
|
-
cleanups.push(cfg.cleanup);
|
|
1014
|
-
process.env.NFO_HOME = cfg.path;
|
|
1015
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
1016
|
-
cleanups.push(repo.cleanup);
|
|
1017
|
-
|
|
1018
|
-
const orchId = projectKeyFromPath(repo.path);
|
|
1019
|
-
await ensureOrchestraDir(orchId);
|
|
1020
|
-
await writeState(orchId, makeInitialState({
|
|
1021
|
-
orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
|
|
1022
|
-
}));
|
|
1023
|
-
|
|
1024
|
-
const sess = sessionName(orchId);
|
|
1025
|
-
sessionsToKill.push(sess);
|
|
1026
|
-
await createDetachedSession(sess, repo.path, 220, 50);
|
|
1027
|
-
// Create a window the musician can "live in".
|
|
1028
|
-
const { stdout: winId } = await execa('tmux', [
|
|
1029
|
-
'new-window', '-t', sess, '-n', 'mus-001-tester', '-c', repo.path, '-d',
|
|
1030
|
-
'-P', '-F', '#{window_id}',
|
|
1031
|
-
]);
|
|
1032
|
-
|
|
1033
|
-
await addMusician(orchId, {
|
|
1034
|
-
id: 'mus-001',
|
|
1035
|
-
name: 'tester',
|
|
1036
|
-
task_summary: 't',
|
|
1037
|
-
status: 'working',
|
|
1038
|
-
tmux_window_id: winId.trim(),
|
|
1039
|
-
claude_session_id: null,
|
|
1040
|
-
worktree_path: null,
|
|
1041
|
-
branch: null,
|
|
1042
|
-
spawned_at: '2026-05-29T10:00:00Z',
|
|
1043
|
-
last_activity: '2026-05-29T10:00:00Z',
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
await messageMusician({ orchestraId: orchId, musicianId: 'mus-001', message: 'echo nfo-message-test' });
|
|
1047
|
-
// Give the shell a moment to render echo output.
|
|
1048
|
-
await new Promise(r => setTimeout(r, 250));
|
|
1049
|
-
const out = await capturePane(`${sess}:${winId.trim()}`, 20);
|
|
1050
|
-
expect(out).toContain('nfo-message-test');
|
|
1051
|
-
|
|
1052
|
-
// last_activity should have been touched.
|
|
1053
|
-
const state = await readState(orchId);
|
|
1054
|
-
expect(state!.musicians[0].last_activity).not.toBe('2026-05-29T10:00:00Z');
|
|
1055
|
-
});
|
|
1056
|
-
});
|
|
1057
|
-
```
|
|
1058
|
-
|
|
1059
|
-
- [ ] **Step 2: Run test, confirm FAIL**
|
|
1060
|
-
|
|
1061
|
-
`npm test -- musicians/message` — expected FAIL.
|
|
1062
|
-
|
|
1063
|
-
- [ ] **Step 3: Implement `src/musicians/message.ts`**
|
|
1064
|
-
|
|
1065
|
-
```typescript
|
|
1066
|
-
import { sendKeys, sessionName } from '../tmux.js';
|
|
1067
|
-
import { readState } from '../state.js';
|
|
1068
|
-
import { findMusicianStrict } from './lookup.js';
|
|
1069
|
-
import { touchMusicianActivity } from '../state-updaters.js';
|
|
1070
|
-
|
|
1071
|
-
export interface MessageMusicianOptions {
|
|
1072
|
-
orchestraId: string;
|
|
1073
|
-
musicianId: string;
|
|
1074
|
-
message: string;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
export async function messageMusician(opts: MessageMusicianOptions): Promise<void> {
|
|
1078
|
-
const state = await readState(opts.orchestraId);
|
|
1079
|
-
if (!state) throw new Error(`Unknown orchestra: ${opts.orchestraId}`);
|
|
1080
|
-
const musician = findMusicianStrict(state, opts.musicianId);
|
|
1081
|
-
|
|
1082
|
-
const target = `${sessionName(opts.orchestraId)}:${musician.tmux_window_id}`;
|
|
1083
|
-
await sendKeys(target, opts.message, true);
|
|
1084
|
-
await touchMusicianActivity(opts.orchestraId, opts.musicianId);
|
|
1085
|
-
}
|
|
1086
|
-
```
|
|
1087
|
-
|
|
1088
|
-
- [ ] **Step 4: Run test, confirm PASS**
|
|
1089
|
-
|
|
1090
|
-
`npm test -- musicians/message` — expected 2/2 PASS.
|
|
1091
|
-
|
|
1092
|
-
- [ ] **Step 5: Commit**
|
|
1093
|
-
|
|
1094
|
-
```
|
|
1095
|
-
git add src/musicians/message.ts tests/musicians/message.test.ts
|
|
1096
|
-
git commit -m "$(cat <<'EOF'
|
|
1097
|
-
feat(musicians): messageMusician — send-keys + activity touch
|
|
1098
|
-
|
|
1099
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
1100
|
-
EOF
|
|
1101
|
-
)"
|
|
1102
|
-
```
|
|
1103
|
-
|
|
1104
|
-
---
|
|
1105
|
-
|
|
1106
|
-
## Task 9: Musician query
|
|
1107
|
-
|
|
1108
|
-
**Files:**
|
|
1109
|
-
- Create: `tests/musicians/query.test.ts`
|
|
1110
|
-
- Create: `src/musicians/query.ts`
|
|
1111
|
-
|
|
1112
|
-
- [ ] **Step 1: Test**
|
|
1113
|
-
|
|
1114
|
-
`tests/musicians/query.test.ts`:
|
|
1115
|
-
|
|
1116
|
-
```typescript
|
|
1117
|
-
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
1118
|
-
import { queryMusician } from '../../src/musicians/query.js';
|
|
1119
|
-
import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
|
|
1120
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
1121
|
-
import { ensureOrchestraDir, writeState } from '../../src/state.js';
|
|
1122
|
-
import { makeInitialState } from '../../src/state.types.js';
|
|
1123
|
-
import { projectKeyFromPath } from '../../src/project-key.js';
|
|
1124
|
-
import { addMusician } from '../../src/state-updaters.js';
|
|
1125
|
-
import { createDetachedSession, sessionName, killSession, sendKeys } from '../../src/tmux.js';
|
|
1126
|
-
import { execa } from 'execa';
|
|
1127
|
-
|
|
1128
|
-
describe('queryMusician', () => {
|
|
1129
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
1130
|
-
const sessionsToKill: string[] = [];
|
|
1131
|
-
|
|
1132
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
1133
|
-
|
|
1134
|
-
afterEach(async () => {
|
|
1135
|
-
for (const s of sessionsToKill) {
|
|
1136
|
-
try { await killSession(s); } catch { /* ignore */ }
|
|
1137
|
-
}
|
|
1138
|
-
sessionsToKill.length = 0;
|
|
1139
|
-
for (const c of cleanups) await c();
|
|
1140
|
-
cleanups.length = 0;
|
|
1141
|
-
delete process.env.NFO_HOME;
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
it('returns the visible content of the musician pane', async () => {
|
|
1145
|
-
const cfg = await makeTmpConfig();
|
|
1146
|
-
cleanups.push(cfg.cleanup);
|
|
1147
|
-
process.env.NFO_HOME = cfg.path;
|
|
1148
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
1149
|
-
cleanups.push(repo.cleanup);
|
|
1150
|
-
|
|
1151
|
-
const orchId = projectKeyFromPath(repo.path);
|
|
1152
|
-
await ensureOrchestraDir(orchId);
|
|
1153
|
-
await writeState(orchId, makeInitialState({
|
|
1154
|
-
orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
|
|
1155
|
-
}));
|
|
1156
|
-
|
|
1157
|
-
const sess = sessionName(orchId);
|
|
1158
|
-
sessionsToKill.push(sess);
|
|
1159
|
-
await createDetachedSession(sess, repo.path, 220, 50);
|
|
1160
|
-
const { stdout: winId } = await execa('tmux', [
|
|
1161
|
-
'new-window', '-t', sess, '-n', 'mus-001-q', '-c', repo.path, '-d',
|
|
1162
|
-
'-P', '-F', '#{window_id}',
|
|
1163
|
-
]);
|
|
1164
|
-
await addMusician(orchId, baseMus('mus-001', winId.trim()));
|
|
1165
|
-
|
|
1166
|
-
await sendKeys(`${sess}:${winId.trim()}`, 'echo nfo-query-marker', true);
|
|
1167
|
-
await new Promise(r => setTimeout(r, 250));
|
|
1168
|
-
|
|
1169
|
-
const out = await queryMusician({ orchestraId: orchId, musicianId: 'mus-001' });
|
|
1170
|
-
expect(out).toContain('nfo-query-marker');
|
|
1171
|
-
});
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
function baseMus(id: string, winId: string) {
|
|
1175
|
-
return {
|
|
1176
|
-
id, name: 'q', task_summary: 't', status: 'working' as const,
|
|
1177
|
-
tmux_window_id: winId, claude_session_id: null, worktree_path: null,
|
|
1178
|
-
branch: null,
|
|
1179
|
-
spawned_at: '2026-05-29T10:00:00Z', last_activity: '2026-05-29T10:00:00Z',
|
|
1180
|
-
};
|
|
1181
|
-
}
|
|
1182
|
-
```
|
|
1183
|
-
|
|
1184
|
-
- [ ] **Step 2: Run test, confirm FAIL**
|
|
1185
|
-
|
|
1186
|
-
- [ ] **Step 3: Implement `src/musicians/query.ts`**
|
|
1187
|
-
|
|
1188
|
-
```typescript
|
|
1189
|
-
import { capturePane, sessionName } from '../tmux.js';
|
|
1190
|
-
import { readState } from '../state.js';
|
|
1191
|
-
import { findMusicianStrict } from './lookup.js';
|
|
1192
|
-
|
|
1193
|
-
export interface QueryMusicianOptions {
|
|
1194
|
-
orchestraId: string;
|
|
1195
|
-
musicianId: string;
|
|
1196
|
-
lines?: number;
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
export async function queryMusician(opts: QueryMusicianOptions): Promise<string> {
|
|
1200
|
-
const state = await readState(opts.orchestraId);
|
|
1201
|
-
if (!state) throw new Error(`Unknown orchestra: ${opts.orchestraId}`);
|
|
1202
|
-
const musician = findMusicianStrict(state, opts.musicianId);
|
|
1203
|
-
const target = `${sessionName(opts.orchestraId)}:${musician.tmux_window_id}`;
|
|
1204
|
-
return capturePane(target, opts.lines ?? 80);
|
|
1205
|
-
}
|
|
1206
|
-
```
|
|
1207
|
-
|
|
1208
|
-
- [ ] **Step 4: Run test, confirm PASS**
|
|
1209
|
-
|
|
1210
|
-
- [ ] **Step 5: Commit**
|
|
1211
|
-
|
|
1212
|
-
```
|
|
1213
|
-
git add src/musicians/query.ts tests/musicians/query.test.ts
|
|
1214
|
-
git commit -m "$(cat <<'EOF'
|
|
1215
|
-
feat(musicians): queryMusician — capture-pane wrapper
|
|
1216
|
-
|
|
1217
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
1218
|
-
EOF
|
|
1219
|
-
)"
|
|
1220
|
-
```
|
|
1221
|
-
|
|
1222
|
-
---
|
|
1223
|
-
|
|
1224
|
-
## Task 10: Musician dismiss
|
|
1225
|
-
|
|
1226
|
-
**Files:**
|
|
1227
|
-
- Create: `tests/musicians/dismiss.test.ts`
|
|
1228
|
-
- Create: `src/musicians/dismiss.ts`
|
|
1229
|
-
|
|
1230
|
-
- [ ] **Step 1: Test**
|
|
1231
|
-
|
|
1232
|
-
`tests/musicians/dismiss.test.ts`:
|
|
1233
|
-
|
|
1234
|
-
```typescript
|
|
1235
|
-
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
1236
|
-
import { dismissMusician } from '../../src/musicians/dismiss.js';
|
|
1237
|
-
import { createMusician } from '../../src/musicians/spawn.js';
|
|
1238
|
-
import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
|
|
1239
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
1240
|
-
import { ensureOrchestraDir, writeState, readState } from '../../src/state.js';
|
|
1241
|
-
import { makeInitialState } from '../../src/state.types.js';
|
|
1242
|
-
import { projectKeyFromPath } from '../../src/project-key.js';
|
|
1243
|
-
import { createDetachedSession, sessionName, killSession } from '../../src/tmux.js';
|
|
1244
|
-
import { existsSync } from 'node:fs';
|
|
1245
|
-
|
|
1246
|
-
describe('dismissMusician', () => {
|
|
1247
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
1248
|
-
const sessionsToKill: string[] = [];
|
|
1249
|
-
|
|
1250
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
1251
|
-
|
|
1252
|
-
afterEach(async () => {
|
|
1253
|
-
for (const s of sessionsToKill) {
|
|
1254
|
-
try { await killSession(s); } catch { /* ignore */ }
|
|
1255
|
-
}
|
|
1256
|
-
sessionsToKill.length = 0;
|
|
1257
|
-
for (const c of cleanups) await c();
|
|
1258
|
-
cleanups.length = 0;
|
|
1259
|
-
delete process.env.NFO_HOME;
|
|
1260
|
-
});
|
|
1261
|
-
|
|
1262
|
-
it('moves musician to archived_musicians and removes worktree (archive=false drops branch)', async () => {
|
|
1263
|
-
const cfg = await makeTmpConfig();
|
|
1264
|
-
cleanups.push(cfg.cleanup);
|
|
1265
|
-
process.env.NFO_HOME = cfg.path;
|
|
1266
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
1267
|
-
cleanups.push(repo.cleanup);
|
|
1268
|
-
|
|
1269
|
-
const orchId = projectKeyFromPath(repo.path);
|
|
1270
|
-
await ensureOrchestraDir(orchId);
|
|
1271
|
-
await writeState(orchId, makeInitialState({
|
|
1272
|
-
orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
|
|
1273
|
-
}));
|
|
1274
|
-
const sess = sessionName(orchId);
|
|
1275
|
-
sessionsToKill.push(sess);
|
|
1276
|
-
await createDetachedSession(sess, repo.path, 220, 50);
|
|
1277
|
-
|
|
1278
|
-
const spawn = await createMusician({
|
|
1279
|
-
orchestraId: orchId, name: 'tester', task: 'do stuff', dryRun: true,
|
|
1280
|
-
});
|
|
1281
|
-
expect(spawn.worktree_path).not.toBeNull();
|
|
1282
|
-
expect(existsSync(spawn.worktree_path!)).toBe(true);
|
|
1283
|
-
|
|
1284
|
-
await dismissMusician({
|
|
1285
|
-
orchestraId: orchId,
|
|
1286
|
-
musicianId: spawn.musician_id,
|
|
1287
|
-
archiveWorktree: false,
|
|
1288
|
-
summary: 'rejected',
|
|
1289
|
-
});
|
|
1290
|
-
|
|
1291
|
-
const state = await readState(orchId);
|
|
1292
|
-
expect(state!.musicians).toHaveLength(0);
|
|
1293
|
-
expect(state!.archived_musicians).toHaveLength(1);
|
|
1294
|
-
expect(state!.archived_musicians[0].summary).toBe('rejected');
|
|
1295
|
-
expect(existsSync(spawn.worktree_path!)).toBe(false);
|
|
1296
|
-
});
|
|
1297
|
-
});
|
|
1298
|
-
```
|
|
1299
|
-
|
|
1300
|
-
(One test is enough — archive=true behaviour is documented and exercised by the MCP handler tests in Task 14.)
|
|
1301
|
-
|
|
1302
|
-
- [ ] **Step 2: Run test, confirm FAIL**
|
|
1303
|
-
|
|
1304
|
-
- [ ] **Step 3: Implement `src/musicians/dismiss.ts`**
|
|
1305
|
-
|
|
1306
|
-
```typescript
|
|
1307
|
-
import { existsSync } from 'node:fs';
|
|
1308
|
-
import { mkdir, rename } from 'node:fs/promises';
|
|
1309
|
-
import { dirname, join } from 'node:path';
|
|
1310
|
-
import { execa } from 'execa';
|
|
1311
|
-
import { archiveMusician } from '../state-updaters.js';
|
|
1312
|
-
import { readState } from '../state.js';
|
|
1313
|
-
import { findMusicianStrict } from './lookup.js';
|
|
1314
|
-
import { archiveDir } from '../config.js';
|
|
1315
|
-
import { sessionName } from '../tmux.js';
|
|
1316
|
-
import { removeWorktree, deleteBranch } from '../worktree.js';
|
|
1317
|
-
|
|
1318
|
-
export interface DismissMusicianOptions {
|
|
1319
|
-
orchestraId: string;
|
|
1320
|
-
musicianId: string;
|
|
1321
|
-
archiveWorktree?: boolean; // default true
|
|
1322
|
-
summary?: string | null; // recorded on the archived musician
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
export async function dismissMusician(opts: DismissMusicianOptions): Promise<void> {
|
|
1326
|
-
const state = await readState(opts.orchestraId);
|
|
1327
|
-
if (!state) throw new Error(`Unknown orchestra: ${opts.orchestraId}`);
|
|
1328
|
-
const musician = findMusicianStrict(state, opts.musicianId);
|
|
1329
|
-
|
|
1330
|
-
const archive = opts.archiveWorktree !== false;
|
|
1331
|
-
|
|
1332
|
-
// 1. Best-effort graceful shutdown of claude in the musician's window.
|
|
1333
|
-
const target = `${sessionName(opts.orchestraId)}:${musician.tmux_window_id}`;
|
|
1334
|
-
// Send `/quit` first (lets claude persist its session). Then kill the window
|
|
1335
|
-
// regardless. tmux kill-window is idempotent.
|
|
1336
|
-
await execa('tmux', ['send-keys', '-l', '-t', target, '--', '/quit'], { reject: false });
|
|
1337
|
-
await execa('tmux', ['send-keys', '-t', target, 'Enter'], { reject: false });
|
|
1338
|
-
await new Promise(r => setTimeout(r, 200));
|
|
1339
|
-
await execa('tmux', ['kill-window', '-t', target], { reject: false });
|
|
1340
|
-
|
|
1341
|
-
// 2. Worktree handling.
|
|
1342
|
-
if (musician.worktree_path) {
|
|
1343
|
-
if (archive) {
|
|
1344
|
-
// Move worktree to archive/<id>/worktree using `git worktree move`.
|
|
1345
|
-
const dest = join(archiveDir(opts.orchestraId), opts.musicianId, 'worktree');
|
|
1346
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
1347
|
-
// `git worktree move <existing> <new>` updates the worktree metadata.
|
|
1348
|
-
// Falls back to remove if move fails (different fs, locked, etc.).
|
|
1349
|
-
const moved = await execa('git', ['worktree', 'move', musician.worktree_path, dest], {
|
|
1350
|
-
cwd: state.project_path, reject: false,
|
|
1351
|
-
});
|
|
1352
|
-
if (moved.exitCode !== 0) {
|
|
1353
|
-
await removeWorktree({ repoRoot: state.project_path, path: musician.worktree_path, force: true });
|
|
1354
|
-
// Branch is preserved when archive=true, even on fallback.
|
|
1355
|
-
}
|
|
1356
|
-
} else {
|
|
1357
|
-
await removeWorktree({ repoRoot: state.project_path, path: musician.worktree_path, force: true });
|
|
1358
|
-
if (musician.branch) {
|
|
1359
|
-
await deleteBranch(state.project_path, musician.branch);
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
// 3. Move state row.
|
|
1365
|
-
await archiveMusician(opts.orchestraId, opts.musicianId, {
|
|
1366
|
-
summary: opts.summary ?? null,
|
|
1367
|
-
});
|
|
1368
|
-
}
|
|
1369
|
-
```
|
|
1370
|
-
|
|
1371
|
-
- [ ] **Step 4: Run test, confirm PASS**
|
|
1372
|
-
|
|
1373
|
-
- [ ] **Step 5: Commit**
|
|
1374
|
-
|
|
1375
|
-
```
|
|
1376
|
-
git add src/musicians/dismiss.ts tests/musicians/dismiss.test.ts
|
|
1377
|
-
git commit -m "$(cat <<'EOF'
|
|
1378
|
-
feat(musicians): dismissMusician — tmux kill + worktree archive/drop + state move
|
|
1379
|
-
|
|
1380
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
1381
|
-
EOF
|
|
1382
|
-
)"
|
|
1383
|
-
```
|
|
1384
|
-
|
|
1385
|
-
---
|
|
1386
|
-
|
|
1387
|
-
## Task 11: Notes module
|
|
1388
|
-
|
|
1389
|
-
**Files:**
|
|
1390
|
-
- Create: `tests/notes.test.ts`
|
|
1391
|
-
- Create: `src/notes.ts`
|
|
1392
|
-
|
|
1393
|
-
- [ ] **Step 1: Test**
|
|
1394
|
-
|
|
1395
|
-
`tests/notes.test.ts`:
|
|
1396
|
-
|
|
1397
|
-
```typescript
|
|
1398
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1399
|
-
import { noteRead, noteWrite, noteList } from '../src/notes.js';
|
|
1400
|
-
import { ensureOrchestraDir } from '../src/state.js';
|
|
1401
|
-
import { makeTmpConfig } from './helpers/tmp-config.js';
|
|
1402
|
-
|
|
1403
|
-
describe('notes', () => {
|
|
1404
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
1405
|
-
|
|
1406
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
1407
|
-
afterEach(async () => {
|
|
1408
|
-
for (const c of cleanups) await c();
|
|
1409
|
-
cleanups.length = 0;
|
|
1410
|
-
delete process.env.NFO_HOME;
|
|
1411
|
-
});
|
|
1412
|
-
|
|
1413
|
-
it('noteWrite then noteRead returns the written content', async () => {
|
|
1414
|
-
const tmp = await makeTmpConfig();
|
|
1415
|
-
cleanups.push(tmp.cleanup);
|
|
1416
|
-
process.env.NFO_HOME = tmp.path;
|
|
1417
|
-
await ensureOrchestraDir('orch-a');
|
|
1418
|
-
|
|
1419
|
-
await noteWrite('orch-a', 'overview.md', '# Project overview\n');
|
|
1420
|
-
const back = await noteRead('orch-a', 'overview.md');
|
|
1421
|
-
expect(back).toBe('# Project overview\n');
|
|
1422
|
-
});
|
|
1423
|
-
|
|
1424
|
-
it('noteRead returns empty string for missing notes', async () => {
|
|
1425
|
-
const tmp = await makeTmpConfig();
|
|
1426
|
-
cleanups.push(tmp.cleanup);
|
|
1427
|
-
process.env.NFO_HOME = tmp.path;
|
|
1428
|
-
await ensureOrchestraDir('orch-b');
|
|
1429
|
-
|
|
1430
|
-
expect(await noteRead('orch-b', 'nope.md')).toBe('');
|
|
1431
|
-
});
|
|
1432
|
-
|
|
1433
|
-
it('noteList returns all markdown filenames in notes/', async () => {
|
|
1434
|
-
const tmp = await makeTmpConfig();
|
|
1435
|
-
cleanups.push(tmp.cleanup);
|
|
1436
|
-
process.env.NFO_HOME = tmp.path;
|
|
1437
|
-
await ensureOrchestraDir('orch-c');
|
|
1438
|
-
await noteWrite('orch-c', 'overview.md', 'a');
|
|
1439
|
-
await noteWrite('orch-c', 'decisions.md', 'b');
|
|
1440
|
-
|
|
1441
|
-
const list = await noteList('orch-c');
|
|
1442
|
-
expect(list.sort()).toEqual(['decisions.md', 'overview.md']);
|
|
1443
|
-
});
|
|
1444
|
-
|
|
1445
|
-
it('rejects filenames containing path separators', async () => {
|
|
1446
|
-
const tmp = await makeTmpConfig();
|
|
1447
|
-
cleanups.push(tmp.cleanup);
|
|
1448
|
-
process.env.NFO_HOME = tmp.path;
|
|
1449
|
-
await ensureOrchestraDir('orch-d');
|
|
1450
|
-
await expect(noteWrite('orch-d', '../escape.md', 'pwn')).rejects.toThrow(/invalid filename/i);
|
|
1451
|
-
await expect(noteRead('orch-d', '../escape.md')).rejects.toThrow(/invalid filename/i);
|
|
1452
|
-
});
|
|
1453
|
-
});
|
|
1454
|
-
```
|
|
1455
|
-
|
|
1456
|
-
- [ ] **Step 2: Run test, confirm FAIL**
|
|
1457
|
-
|
|
1458
|
-
- [ ] **Step 3: Implement `src/notes.ts`**
|
|
1459
|
-
|
|
1460
|
-
```typescript
|
|
1461
|
-
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
|
|
1462
|
-
import { existsSync } from 'node:fs';
|
|
1463
|
-
import { join } from 'node:path';
|
|
1464
|
-
import { notesDir } from './config.js';
|
|
1465
|
-
|
|
1466
|
-
function ensureSafeFilename(filename: string): void {
|
|
1467
|
-
if (!filename || /[\/\\]/.test(filename) || filename.includes('..')) {
|
|
1468
|
-
throw new Error(`invalid filename: ${filename}`);
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
export async function noteWrite(
|
|
1473
|
-
orchestraId: string,
|
|
1474
|
-
filename: string,
|
|
1475
|
-
content: string,
|
|
1476
|
-
): Promise<void> {
|
|
1477
|
-
ensureSafeFilename(filename);
|
|
1478
|
-
const dir = notesDir(orchestraId);
|
|
1479
|
-
await mkdir(dir, { recursive: true });
|
|
1480
|
-
await writeFile(join(dir, filename), content, 'utf8');
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
export async function noteRead(orchestraId: string, filename: string): Promise<string> {
|
|
1484
|
-
ensureSafeFilename(filename);
|
|
1485
|
-
const file = join(notesDir(orchestraId), filename);
|
|
1486
|
-
if (!existsSync(file)) return '';
|
|
1487
|
-
return readFile(file, 'utf8');
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
export async function noteList(orchestraId: string): Promise<string[]> {
|
|
1491
|
-
const dir = notesDir(orchestraId);
|
|
1492
|
-
if (!existsSync(dir)) return [];
|
|
1493
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
1494
|
-
return entries.filter(e => e.isFile()).map(e => e.name);
|
|
1495
|
-
}
|
|
1496
|
-
```
|
|
1497
|
-
|
|
1498
|
-
- [ ] **Step 4: Run test, confirm PASS**
|
|
1499
|
-
|
|
1500
|
-
- [ ] **Step 5: Commit**
|
|
1501
|
-
|
|
1502
|
-
```
|
|
1503
|
-
git add src/notes.ts tests/notes.test.ts
|
|
1504
|
-
git commit -m "$(cat <<'EOF'
|
|
1505
|
-
feat(notes): noteRead/noteWrite/noteList for orchestrator-curated memory
|
|
1506
|
-
|
|
1507
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
1508
|
-
EOF
|
|
1509
|
-
)"
|
|
1510
|
-
```
|
|
1511
|
-
|
|
1512
|
-
---
|
|
1513
|
-
|
|
1514
|
-
## Task 12: MCP tool definitions
|
|
1515
|
-
|
|
1516
|
-
**Files:**
|
|
1517
|
-
- Create: `tests/mcp/tool-defs.test.ts`
|
|
1518
|
-
- Create: `src/mcp/tool-defs.ts`
|
|
1519
|
-
|
|
1520
|
-
- [ ] **Step 1: Test**
|
|
1521
|
-
|
|
1522
|
-
`tests/mcp/tool-defs.test.ts`:
|
|
1523
|
-
|
|
1524
|
-
```typescript
|
|
1525
|
-
import { describe, it, expect } from 'vitest';
|
|
1526
|
-
import { NFO_TOOLS } from '../../src/mcp/tool-defs.js';
|
|
1527
|
-
|
|
1528
|
-
describe('NFO MCP tool definitions', () => {
|
|
1529
|
-
it('exposes the expected tool names', () => {
|
|
1530
|
-
const names = NFO_TOOLS.map(t => t.name).sort();
|
|
1531
|
-
expect(names).toEqual([
|
|
1532
|
-
'dismiss_musician',
|
|
1533
|
-
'list_musicians',
|
|
1534
|
-
'message_musician',
|
|
1535
|
-
'note_list',
|
|
1536
|
-
'note_read',
|
|
1537
|
-
'note_write',
|
|
1538
|
-
'query_musician',
|
|
1539
|
-
'report_done',
|
|
1540
|
-
'spawn_musician',
|
|
1541
|
-
]);
|
|
1542
|
-
});
|
|
1543
|
-
|
|
1544
|
-
it('every tool has a non-empty description and inputSchema with type "object"', () => {
|
|
1545
|
-
for (const tool of NFO_TOOLS) {
|
|
1546
|
-
expect(tool.description.length).toBeGreaterThan(0);
|
|
1547
|
-
expect(tool.inputSchema.type).toBe('object');
|
|
1548
|
-
}
|
|
1549
|
-
});
|
|
1550
|
-
});
|
|
1551
|
-
```
|
|
1552
|
-
|
|
1553
|
-
- [ ] **Step 2: Run test, confirm FAIL**
|
|
1554
|
-
|
|
1555
|
-
- [ ] **Step 3: Implement `src/mcp/tool-defs.ts`**
|
|
1556
|
-
|
|
1557
|
-
```typescript
|
|
1558
|
-
export interface NfoToolDef {
|
|
1559
|
-
name: string;
|
|
1560
|
-
description: string;
|
|
1561
|
-
inputSchema: {
|
|
1562
|
-
type: 'object';
|
|
1563
|
-
properties: Record<string, unknown>;
|
|
1564
|
-
required?: string[];
|
|
1565
|
-
additionalProperties?: boolean;
|
|
1566
|
-
};
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
export const NFO_TOOLS: NfoToolDef[] = [
|
|
1570
|
-
{
|
|
1571
|
-
name: 'spawn_musician',
|
|
1572
|
-
description: 'Spawn a new Musician (a Claude Code subagent) to work on a task in an isolated git worktree. Returns the musician_id.',
|
|
1573
|
-
inputSchema: {
|
|
1574
|
-
type: 'object',
|
|
1575
|
-
properties: {
|
|
1576
|
-
name: { type: 'string', description: 'Human-friendly identifier (e.g. "test-writer")' },
|
|
1577
|
-
task: { type: 'string', description: 'Initial task prompt sent as the first message' },
|
|
1578
|
-
worktree: { type: 'boolean', description: 'Default true. Pass false for trivially-isolated work that does not need a worktree.' },
|
|
1579
|
-
branch_from: { type: 'string', description: 'Optional base ref (defaults to HEAD).' },
|
|
1580
|
-
},
|
|
1581
|
-
required: ['name', 'task'],
|
|
1582
|
-
additionalProperties: false,
|
|
1583
|
-
},
|
|
1584
|
-
},
|
|
1585
|
-
{
|
|
1586
|
-
name: 'message_musician',
|
|
1587
|
-
description: 'Send a message to a Musician. Fire-and-forget; their response streams into their pane.',
|
|
1588
|
-
inputSchema: {
|
|
1589
|
-
type: 'object',
|
|
1590
|
-
properties: {
|
|
1591
|
-
musician_id: { type: 'string' },
|
|
1592
|
-
message: { type: 'string' },
|
|
1593
|
-
},
|
|
1594
|
-
required: ['musician_id', 'message'],
|
|
1595
|
-
additionalProperties: false,
|
|
1596
|
-
},
|
|
1597
|
-
},
|
|
1598
|
-
{
|
|
1599
|
-
name: 'query_musician',
|
|
1600
|
-
description: 'Read the most recent visible output from a Musician\'s tmux pane. Returns the captured text.',
|
|
1601
|
-
inputSchema: {
|
|
1602
|
-
type: 'object',
|
|
1603
|
-
properties: {
|
|
1604
|
-
musician_id: { type: 'string' },
|
|
1605
|
-
lines: { type: 'integer', description: 'Default 80.' },
|
|
1606
|
-
},
|
|
1607
|
-
required: ['musician_id'],
|
|
1608
|
-
additionalProperties: false,
|
|
1609
|
-
},
|
|
1610
|
-
},
|
|
1611
|
-
{
|
|
1612
|
-
name: 'list_musicians',
|
|
1613
|
-
description: 'List all currently-active Musicians with their status and metadata.',
|
|
1614
|
-
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1615
|
-
},
|
|
1616
|
-
{
|
|
1617
|
-
name: 'dismiss_musician',
|
|
1618
|
-
description: 'Tear down a Musician. Archives the worktree by default (branch preserved); pass archive_worktree=false to drop everything.',
|
|
1619
|
-
inputSchema: {
|
|
1620
|
-
type: 'object',
|
|
1621
|
-
properties: {
|
|
1622
|
-
musician_id: { type: 'string' },
|
|
1623
|
-
archive_worktree: { type: 'boolean' },
|
|
1624
|
-
summary: { type: 'string' },
|
|
1625
|
-
},
|
|
1626
|
-
required: ['musician_id'],
|
|
1627
|
-
additionalProperties: false,
|
|
1628
|
-
},
|
|
1629
|
-
},
|
|
1630
|
-
{
|
|
1631
|
-
name: 'report_done',
|
|
1632
|
-
description: 'Called by a Musician to mark itself as idle/done. Provide a concise summary.',
|
|
1633
|
-
inputSchema: {
|
|
1634
|
-
type: 'object',
|
|
1635
|
-
properties: {
|
|
1636
|
-
summary: { type: 'string' },
|
|
1637
|
-
next_steps: { type: 'string' },
|
|
1638
|
-
},
|
|
1639
|
-
required: ['summary'],
|
|
1640
|
-
additionalProperties: false,
|
|
1641
|
-
},
|
|
1642
|
-
},
|
|
1643
|
-
{
|
|
1644
|
-
name: 'note_write',
|
|
1645
|
-
description: 'Write (or replace) a note file under the orchestra\'s notes/ directory.',
|
|
1646
|
-
inputSchema: {
|
|
1647
|
-
type: 'object',
|
|
1648
|
-
properties: {
|
|
1649
|
-
filename: { type: 'string', description: 'Filename with no path separators, e.g. "overview.md".' },
|
|
1650
|
-
content: { type: 'string' },
|
|
1651
|
-
},
|
|
1652
|
-
required: ['filename', 'content'],
|
|
1653
|
-
additionalProperties: false,
|
|
1654
|
-
},
|
|
1655
|
-
},
|
|
1656
|
-
{
|
|
1657
|
-
name: 'note_read',
|
|
1658
|
-
description: 'Read a note file from the orchestra\'s notes/ directory. Returns the contents, or empty string if missing.',
|
|
1659
|
-
inputSchema: {
|
|
1660
|
-
type: 'object',
|
|
1661
|
-
properties: { filename: { type: 'string' } },
|
|
1662
|
-
required: ['filename'],
|
|
1663
|
-
additionalProperties: false,
|
|
1664
|
-
},
|
|
1665
|
-
},
|
|
1666
|
-
{
|
|
1667
|
-
name: 'note_list',
|
|
1668
|
-
description: 'List all files in the orchestra\'s notes/ directory.',
|
|
1669
|
-
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1670
|
-
},
|
|
1671
|
-
];
|
|
1672
|
-
```
|
|
1673
|
-
|
|
1674
|
-
- [ ] **Step 4: Run test, confirm PASS**
|
|
1675
|
-
|
|
1676
|
-
- [ ] **Step 5: Commit**
|
|
1677
|
-
|
|
1678
|
-
```
|
|
1679
|
-
git add src/mcp/tool-defs.ts tests/mcp/tool-defs.test.ts
|
|
1680
|
-
git commit -m "$(cat <<'EOF'
|
|
1681
|
-
feat(mcp): tool definitions (names, descriptions, JSON Schemas)
|
|
1682
|
-
|
|
1683
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
1684
|
-
EOF
|
|
1685
|
-
)"
|
|
1686
|
-
```
|
|
1687
|
-
|
|
1688
|
-
---
|
|
1689
|
-
|
|
1690
|
-
## Task 13: MCP handler dispatch
|
|
1691
|
-
|
|
1692
|
-
**Files:**
|
|
1693
|
-
- Create: `tests/mcp/handlers.test.ts`
|
|
1694
|
-
- Create: `src/mcp/handlers.ts`
|
|
1695
|
-
|
|
1696
|
-
Handlers wire MCP tool calls to the Musicians / notes / state modules.
|
|
1697
|
-
|
|
1698
|
-
- [ ] **Step 1: Test**
|
|
1699
|
-
|
|
1700
|
-
`tests/mcp/handlers.test.ts`:
|
|
1701
|
-
|
|
1702
|
-
```typescript
|
|
1703
|
-
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
1704
|
-
import { dispatch } from '../../src/mcp/handlers.js';
|
|
1705
|
-
import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
|
|
1706
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
1707
|
-
import { ensureOrchestraDir, writeState, readState } from '../../src/state.js';
|
|
1708
|
-
import { makeInitialState } from '../../src/state.types.js';
|
|
1709
|
-
import { projectKeyFromPath } from '../../src/project-key.js';
|
|
1710
|
-
import { createDetachedSession, sessionName, killSession } from '../../src/tmux.js';
|
|
1711
|
-
|
|
1712
|
-
describe('MCP handlers dispatch', () => {
|
|
1713
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
1714
|
-
const sessionsToKill: string[] = [];
|
|
1715
|
-
|
|
1716
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
1717
|
-
|
|
1718
|
-
afterEach(async () => {
|
|
1719
|
-
for (const s of sessionsToKill) {
|
|
1720
|
-
try { await killSession(s); } catch { /* ignore */ }
|
|
1721
|
-
}
|
|
1722
|
-
sessionsToKill.length = 0;
|
|
1723
|
-
for (const c of cleanups) await c();
|
|
1724
|
-
cleanups.length = 0;
|
|
1725
|
-
delete process.env.NFO_HOME;
|
|
1726
|
-
});
|
|
1727
|
-
|
|
1728
|
-
async function setup(): Promise<{orchId: string; repoPath: string}> {
|
|
1729
|
-
const cfg = await makeTmpConfig();
|
|
1730
|
-
cleanups.push(cfg.cleanup);
|
|
1731
|
-
process.env.NFO_HOME = cfg.path;
|
|
1732
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
1733
|
-
cleanups.push(repo.cleanup);
|
|
1734
|
-
const orchId = projectKeyFromPath(repo.path);
|
|
1735
|
-
await ensureOrchestraDir(orchId);
|
|
1736
|
-
await writeState(orchId, makeInitialState({
|
|
1737
|
-
orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
|
|
1738
|
-
}));
|
|
1739
|
-
sessionsToKill.push(sessionName(orchId));
|
|
1740
|
-
await createDetachedSession(sessionName(orchId), repo.path, 220, 50);
|
|
1741
|
-
return { orchId, repoPath: repo.path };
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
it('spawn_musician returns a musician_id', async () => {
|
|
1745
|
-
const { orchId } = await setup();
|
|
1746
|
-
const result = await dispatch(orchId, 'spawn_musician', {
|
|
1747
|
-
name: 'tester', task: 'do work', worktree: false,
|
|
1748
|
-
}, { dryRun: true });
|
|
1749
|
-
expect(result.musician_id).toMatch(/^mus-\d{3}$/);
|
|
1750
|
-
});
|
|
1751
|
-
|
|
1752
|
-
it('list_musicians returns the live roster', async () => {
|
|
1753
|
-
const { orchId } = await setup();
|
|
1754
|
-
await dispatch(orchId, 'spawn_musician', { name: 'one', task: 't', worktree: false }, { dryRun: true });
|
|
1755
|
-
await dispatch(orchId, 'spawn_musician', { name: 'two', task: 't', worktree: false }, { dryRun: true });
|
|
1756
|
-
const result = await dispatch(orchId, 'list_musicians', {});
|
|
1757
|
-
expect(result.musicians).toHaveLength(2);
|
|
1758
|
-
});
|
|
1759
|
-
|
|
1760
|
-
it('note_write / note_read round-trip', async () => {
|
|
1761
|
-
const { orchId } = await setup();
|
|
1762
|
-
await dispatch(orchId, 'note_write', { filename: 'overview.md', content: '# hi' });
|
|
1763
|
-
const result = await dispatch(orchId, 'note_read', { filename: 'overview.md' });
|
|
1764
|
-
expect(result.content).toBe('# hi');
|
|
1765
|
-
});
|
|
1766
|
-
|
|
1767
|
-
it('report_done sets status to idle and records summary', async () => {
|
|
1768
|
-
const { orchId } = await setup();
|
|
1769
|
-
const { musician_id } = await dispatch(orchId, 'spawn_musician', {
|
|
1770
|
-
name: 'r', task: 't', worktree: false,
|
|
1771
|
-
}, { dryRun: true });
|
|
1772
|
-
await dispatch(orchId, 'report_done', {
|
|
1773
|
-
summary: 'all green', _from_musician_id: musician_id,
|
|
1774
|
-
});
|
|
1775
|
-
const state = await readState(orchId);
|
|
1776
|
-
expect(state!.musicians[0].status).toBe('idle');
|
|
1777
|
-
});
|
|
1778
|
-
|
|
1779
|
-
it('throws on unknown tool', async () => {
|
|
1780
|
-
const { orchId } = await setup();
|
|
1781
|
-
await expect(dispatch(orchId, 'totally_made_up', {})).rejects.toThrow(/Unknown tool/);
|
|
1782
|
-
});
|
|
1783
|
-
});
|
|
1784
|
-
```
|
|
1785
|
-
|
|
1786
|
-
- [ ] **Step 2: Run test, confirm FAIL**
|
|
1787
|
-
|
|
1788
|
-
- [ ] **Step 3: Implement `src/mcp/handlers.ts`**
|
|
1789
|
-
|
|
1790
|
-
```typescript
|
|
1791
|
-
import { createMusician } from '../musicians/spawn.js';
|
|
1792
|
-
import { messageMusician } from '../musicians/message.js';
|
|
1793
|
-
import { queryMusician } from '../musicians/query.js';
|
|
1794
|
-
import { dismissMusician } from '../musicians/dismiss.js';
|
|
1795
|
-
import { noteRead, noteWrite, noteList } from '../notes.js';
|
|
1796
|
-
import { readState } from '../state.js';
|
|
1797
|
-
import { setMusicianStatus } from '../state-updaters.js';
|
|
1798
|
-
|
|
1799
|
-
export interface DispatchOptions {
|
|
1800
|
-
/** Used by tests to skip launching real `claude` processes. */
|
|
1801
|
-
dryRun?: boolean;
|
|
1802
|
-
/**
|
|
1803
|
-
* The id of the Musician making the call, if any. report_done uses this
|
|
1804
|
-
* to know which Musician to mark idle. In production this is derived
|
|
1805
|
-
* from the MCP server's context (Phase 2: passed via an internal arg).
|
|
1806
|
-
*/
|
|
1807
|
-
callerMusicianId?: string;
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
export async function dispatch(
|
|
1811
|
-
orchestraId: string,
|
|
1812
|
-
toolName: string,
|
|
1813
|
-
args: Record<string, unknown>,
|
|
1814
|
-
opts: DispatchOptions = {},
|
|
1815
|
-
): Promise<Record<string, unknown>> {
|
|
1816
|
-
switch (toolName) {
|
|
1817
|
-
case 'spawn_musician': {
|
|
1818
|
-
const r = await createMusician({
|
|
1819
|
-
orchestraId,
|
|
1820
|
-
name: String(args.name),
|
|
1821
|
-
task: String(args.task),
|
|
1822
|
-
worktree: typeof args.worktree === 'boolean' ? args.worktree : undefined,
|
|
1823
|
-
branchFrom: typeof args.branch_from === 'string' ? args.branch_from : undefined,
|
|
1824
|
-
dryRun: opts.dryRun,
|
|
1825
|
-
});
|
|
1826
|
-
return r;
|
|
1827
|
-
}
|
|
1828
|
-
case 'message_musician': {
|
|
1829
|
-
await messageMusician({
|
|
1830
|
-
orchestraId,
|
|
1831
|
-
musicianId: String(args.musician_id),
|
|
1832
|
-
message: String(args.message),
|
|
1833
|
-
});
|
|
1834
|
-
return { ok: true };
|
|
1835
|
-
}
|
|
1836
|
-
case 'query_musician': {
|
|
1837
|
-
const text = await queryMusician({
|
|
1838
|
-
orchestraId,
|
|
1839
|
-
musicianId: String(args.musician_id),
|
|
1840
|
-
lines: typeof args.lines === 'number' ? args.lines : undefined,
|
|
1841
|
-
});
|
|
1842
|
-
return { content: text };
|
|
1843
|
-
}
|
|
1844
|
-
case 'list_musicians': {
|
|
1845
|
-
const state = await readState(orchestraId);
|
|
1846
|
-
if (!state) throw new Error(`Unknown orchestra: ${orchestraId}`);
|
|
1847
|
-
return { musicians: state.musicians };
|
|
1848
|
-
}
|
|
1849
|
-
case 'dismiss_musician': {
|
|
1850
|
-
await dismissMusician({
|
|
1851
|
-
orchestraId,
|
|
1852
|
-
musicianId: String(args.musician_id),
|
|
1853
|
-
archiveWorktree: typeof args.archive_worktree === 'boolean' ? args.archive_worktree : undefined,
|
|
1854
|
-
summary: typeof args.summary === 'string' ? args.summary : null,
|
|
1855
|
-
});
|
|
1856
|
-
return { ok: true };
|
|
1857
|
-
}
|
|
1858
|
-
case 'report_done': {
|
|
1859
|
-
const summary = typeof args.summary === 'string' ? args.summary : '';
|
|
1860
|
-
// Test seam: callers can pass `_from_musician_id` explicitly.
|
|
1861
|
-
const callerId = (typeof args._from_musician_id === 'string')
|
|
1862
|
-
? args._from_musician_id
|
|
1863
|
-
: opts.callerMusicianId;
|
|
1864
|
-
if (!callerId) throw new Error('report_done: no caller musician id');
|
|
1865
|
-
await setMusicianStatus(orchestraId, callerId, 'idle');
|
|
1866
|
-
return { ok: true, recorded: summary };
|
|
1867
|
-
}
|
|
1868
|
-
case 'note_write': {
|
|
1869
|
-
await noteWrite(orchestraId, String(args.filename), String(args.content));
|
|
1870
|
-
return { ok: true };
|
|
1871
|
-
}
|
|
1872
|
-
case 'note_read': {
|
|
1873
|
-
const content = await noteRead(orchestraId, String(args.filename));
|
|
1874
|
-
return { content };
|
|
1875
|
-
}
|
|
1876
|
-
case 'note_list': {
|
|
1877
|
-
const files = await noteList(orchestraId);
|
|
1878
|
-
return { files };
|
|
1879
|
-
}
|
|
1880
|
-
default:
|
|
1881
|
-
throw new Error(`Unknown tool: ${toolName}`);
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
```
|
|
1885
|
-
|
|
1886
|
-
- [ ] **Step 4: Run test, confirm PASS**
|
|
1887
|
-
|
|
1888
|
-
`npm test -- mcp/handlers` — expected 5/5 PASS.
|
|
1889
|
-
|
|
1890
|
-
- [ ] **Step 5: Commit**
|
|
1891
|
-
|
|
1892
|
-
```
|
|
1893
|
-
git add src/mcp/handlers.ts tests/mcp/handlers.test.ts
|
|
1894
|
-
git commit -m "$(cat <<'EOF'
|
|
1895
|
-
feat(mcp): handler dispatch table mapping tool names to Phase 2 ops
|
|
1896
|
-
|
|
1897
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
1898
|
-
EOF
|
|
1899
|
-
)"
|
|
1900
|
-
```
|
|
1901
|
-
|
|
1902
|
-
---
|
|
1903
|
-
|
|
1904
|
-
## Task 14: MCP server skeleton + entry point
|
|
1905
|
-
|
|
1906
|
-
**Files:**
|
|
1907
|
-
- Create: `src/mcp/server.ts`
|
|
1908
|
-
- Create: `src/commands/mcp-server.ts`
|
|
1909
|
-
- Modify: `src/cli.ts`
|
|
1910
|
-
|
|
1911
|
-
- [ ] **Step 1: Implement `src/mcp/server.ts`**
|
|
1912
|
-
|
|
1913
|
-
```typescript
|
|
1914
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
1915
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
1916
|
-
import {
|
|
1917
|
-
CallToolRequestSchema,
|
|
1918
|
-
ListToolsRequestSchema,
|
|
1919
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
1920
|
-
import { NFO_TOOLS } from './tool-defs.js';
|
|
1921
|
-
import { dispatch } from './handlers.js';
|
|
1922
|
-
|
|
1923
|
-
export interface RunServerOptions {
|
|
1924
|
-
orchestraId: string;
|
|
1925
|
-
/** Set when this server is attached to a specific Musician. */
|
|
1926
|
-
callerMusicianId?: string;
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
export async function runServer(opts: RunServerOptions): Promise<void> {
|
|
1930
|
-
const server = new Server(
|
|
1931
|
-
{ name: 'nfo-mcp', version: '0.0.0' },
|
|
1932
|
-
{ capabilities: { tools: {} } },
|
|
1933
|
-
);
|
|
1934
|
-
|
|
1935
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1936
|
-
tools: NFO_TOOLS,
|
|
1937
|
-
}));
|
|
1938
|
-
|
|
1939
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1940
|
-
const { name, arguments: args } = request.params;
|
|
1941
|
-
try {
|
|
1942
|
-
const result = await dispatch(opts.orchestraId, name, (args ?? {}) as Record<string, unknown>, {
|
|
1943
|
-
callerMusicianId: opts.callerMusicianId,
|
|
1944
|
-
});
|
|
1945
|
-
return {
|
|
1946
|
-
content: [
|
|
1947
|
-
{ type: 'text', text: JSON.stringify(result, null, 2) },
|
|
1948
|
-
],
|
|
1949
|
-
};
|
|
1950
|
-
} catch (err) {
|
|
1951
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1952
|
-
return {
|
|
1953
|
-
content: [{ type: 'text', text: `Error: ${msg}` }],
|
|
1954
|
-
isError: true,
|
|
1955
|
-
};
|
|
1956
|
-
}
|
|
1957
|
-
});
|
|
1958
|
-
|
|
1959
|
-
const transport = new StdioServerTransport();
|
|
1960
|
-
await server.connect(transport);
|
|
1961
|
-
}
|
|
1962
|
-
```
|
|
1963
|
-
|
|
1964
|
-
- [ ] **Step 2: Implement `src/commands/mcp-server.ts`**
|
|
1965
|
-
|
|
1966
|
-
```typescript
|
|
1967
|
-
import { runServer } from '../mcp/server.js';
|
|
1968
|
-
import { readState } from '../state.js';
|
|
1969
|
-
|
|
1970
|
-
export interface McpServerCliOptions {
|
|
1971
|
-
orchestraId: string;
|
|
1972
|
-
callerMusicianId?: string;
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
export async function runMcpServerCli(opts: McpServerCliOptions): Promise<void> {
|
|
1976
|
-
const state = await readState(opts.orchestraId);
|
|
1977
|
-
if (!state) {
|
|
1978
|
-
throw new Error(`Unknown orchestra: ${opts.orchestraId}`);
|
|
1979
|
-
}
|
|
1980
|
-
await runServer({
|
|
1981
|
-
orchestraId: opts.orchestraId,
|
|
1982
|
-
callerMusicianId: opts.callerMusicianId,
|
|
1983
|
-
});
|
|
1984
|
-
}
|
|
1985
|
-
```
|
|
1986
|
-
|
|
1987
|
-
- [ ] **Step 3: Wire the `mcp-server` subcommand in `src/cli.ts`**
|
|
1988
|
-
|
|
1989
|
-
Add this subcommand (place after `notes`):
|
|
1990
|
-
|
|
1991
|
-
```typescript
|
|
1992
|
-
program
|
|
1993
|
-
.command('mcp-server', { hidden: true })
|
|
1994
|
-
.description('(internal) Run the NFO MCP server attached to an orchestra')
|
|
1995
|
-
.requiredOption('--orchestra-id <id>', 'Orchestra id')
|
|
1996
|
-
.option('--caller-musician-id <id>', 'When the server is hosting a Musician')
|
|
1997
|
-
.action(async (opts: { orchestraId: string; callerMusicianId?: string }) => {
|
|
1998
|
-
const { runMcpServerCli } = await import('./commands/mcp-server.js');
|
|
1999
|
-
await runMcpServerCli({
|
|
2000
|
-
orchestraId: opts.orchestraId,
|
|
2001
|
-
callerMusicianId: opts.callerMusicianId,
|
|
2002
|
-
});
|
|
2003
|
-
});
|
|
2004
|
-
```
|
|
2005
|
-
|
|
2006
|
-
- [ ] **Step 4: Smoke test — server boots and lists tools**
|
|
2007
|
-
|
|
2008
|
-
Write a temporary script `scratch-mcp-smoke.mjs` (do NOT commit) that spawns `node dist/cli.js mcp-server --orchestra-id <some-known-orchestra-id>` as a child, sends a `tools/list` JSON-RPC message over stdin, and asserts the response includes our 9 tool names. Alternative: leave this to Task 19 e2e. For now just ensure:
|
|
2009
|
-
|
|
2010
|
-
```
|
|
2011
|
-
npm run build
|
|
2012
|
-
npm run typecheck
|
|
2013
|
-
npm test
|
|
2014
|
-
```
|
|
2015
|
-
|
|
2016
|
-
All pass.
|
|
2017
|
-
|
|
2018
|
-
- [ ] **Step 5: Commit**
|
|
2019
|
-
|
|
2020
|
-
```
|
|
2021
|
-
git add src/mcp/server.ts src/commands/mcp-server.ts src/cli.ts
|
|
2022
|
-
git commit -m "$(cat <<'EOF'
|
|
2023
|
-
feat(mcp): stdio server skeleton + hidden `nfo mcp-server` subcommand
|
|
2024
|
-
|
|
2025
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
2026
|
-
EOF
|
|
2027
|
-
)"
|
|
2028
|
-
```
|
|
2029
|
-
|
|
2030
|
-
---
|
|
2031
|
-
|
|
2032
|
-
## Task 15: Wire the Orchestrator to the MCP server
|
|
2033
|
-
|
|
2034
|
-
**Files:**
|
|
2035
|
-
- Modify: `src/commands/launch.ts`
|
|
2036
|
-
- Modify: `tests/commands/launch.test.ts`
|
|
2037
|
-
|
|
2038
|
-
`createOrchestra` now needs to write the mcp-config.json and pass `--mcp-config` to claude.
|
|
2039
|
-
|
|
2040
|
-
- [ ] **Step 1: Update `createOrchestra` in `src/commands/launch.ts`**
|
|
2041
|
-
|
|
2042
|
-
After `writeState(opts.orchestraId, state);` and before the prompt file write, add:
|
|
2043
|
-
|
|
2044
|
-
```typescript
|
|
2045
|
-
// Write the MCP config so claude can spawn `nfo mcp-server --orchestra-id ...` on demand.
|
|
2046
|
-
const mcpConfigPath = join(orchestraDir(opts.orchestraId), 'mcp-config.json');
|
|
2047
|
-
await writeFile(mcpConfigPath, JSON.stringify({
|
|
2048
|
-
mcpServers: {
|
|
2049
|
-
nfo: {
|
|
2050
|
-
command: 'nfo',
|
|
2051
|
-
args: ['mcp-server', '--orchestra-id', opts.orchestraId],
|
|
2052
|
-
},
|
|
2053
|
-
},
|
|
2054
|
-
}, null, 2), 'utf8');
|
|
2055
|
-
```
|
|
2056
|
-
|
|
2057
|
-
Change the prompt-file write to concatenate the role addendum with any existing notes (per spec §7.3 — Orchestrator notes are loaded at every launch):
|
|
2058
|
-
|
|
2059
|
-
```typescript
|
|
2060
|
-
// Role addendum + any prior curated notes (overview.md, decisions.md).
|
|
2061
|
-
const promptFile = join(orchestraDir(opts.orchestraId), 'orchestrator-prompt.md');
|
|
2062
|
-
const notes = await loadOrchestratorNotes(opts.orchestraId);
|
|
2063
|
-
await writeFile(promptFile, ORCHESTRATOR_ROLE_PROMPT_V1 + notes, 'utf8');
|
|
2064
|
-
```
|
|
2065
|
-
|
|
2066
|
-
Add this helper at the bottom of `launch.ts` (it'll also be reused by `restore.ts` — keep it exported):
|
|
2067
|
-
|
|
2068
|
-
```typescript
|
|
2069
|
-
import { noteRead, noteList } from '../notes.js';
|
|
2070
|
-
|
|
2071
|
-
export async function loadOrchestratorNotes(orchestraId: string): Promise<string> {
|
|
2072
|
-
const files = await noteList(orchestraId);
|
|
2073
|
-
const ordered = ['overview.md', 'decisions.md'].filter(f => files.includes(f));
|
|
2074
|
-
if (ordered.length === 0) return '';
|
|
2075
|
-
const parts: string[] = ['\n\n## Curated project notes (loaded from notes/)\n'];
|
|
2076
|
-
for (const f of ordered) {
|
|
2077
|
-
const content = await noteRead(orchestraId, f);
|
|
2078
|
-
if (content.trim().length === 0) continue;
|
|
2079
|
-
parts.push(`\n### ${f}\n\n${content}\n`);
|
|
2080
|
-
}
|
|
2081
|
-
return parts.join('');
|
|
2082
|
-
}
|
|
2083
|
-
```
|
|
2084
|
-
|
|
2085
|
-
Then in the claude command construction, add `'--mcp-config', mcpConfigPath` to the flag list:
|
|
2086
|
-
|
|
2087
|
-
```typescript
|
|
2088
|
-
const claudeFlags = claudeFlagsForLevel(opts.permissionLevel);
|
|
2089
|
-
const claudeCmd = [
|
|
2090
|
-
'claude',
|
|
2091
|
-
...claudeFlags,
|
|
2092
|
-
'--mcp-config', mcpConfigPath,
|
|
2093
|
-
'--append-system-prompt-file', promptFile,
|
|
2094
|
-
].join(' ');
|
|
2095
|
-
```
|
|
2096
|
-
|
|
2097
|
-
- [ ] **Step 2: Update `tests/commands/launch.test.ts`**
|
|
2098
|
-
|
|
2099
|
-
Add an assertion that `mcp-config.json` was written:
|
|
2100
|
-
|
|
2101
|
-
```typescript
|
|
2102
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2103
|
-
import { join } from 'node:path';
|
|
2104
|
-
import { orchestraDir } from '../../src/config.js';
|
|
2105
|
-
|
|
2106
|
-
// ... inside the existing test, after the state assertions:
|
|
2107
|
-
|
|
2108
|
-
const mcpCfg = join(orchestraDir(result.orchestraId), 'mcp-config.json');
|
|
2109
|
-
expect(existsSync(mcpCfg)).toBe(true);
|
|
2110
|
-
const parsed = JSON.parse(readFileSync(mcpCfg, 'utf8'));
|
|
2111
|
-
expect(parsed.mcpServers.nfo.command).toBe('nfo');
|
|
2112
|
-
expect(parsed.mcpServers.nfo.args).toEqual(['mcp-server', '--orchestra-id', result.orchestraId]);
|
|
2113
|
-
```
|
|
2114
|
-
|
|
2115
|
-
- [ ] **Step 3: Run tests**
|
|
2116
|
-
|
|
2117
|
-
`npm test -- launch` — expected PASS with the new assertion.
|
|
2118
|
-
`npm test` — full suite must pass.
|
|
2119
|
-
|
|
2120
|
-
- [ ] **Step 4: Commit**
|
|
2121
|
-
|
|
2122
|
-
```
|
|
2123
|
-
git add src/commands/launch.ts tests/commands/launch.test.ts
|
|
2124
|
-
git commit -m "$(cat <<'EOF'
|
|
2125
|
-
feat(launch): write mcp-config.json and attach to Orchestrator claude session
|
|
2126
|
-
|
|
2127
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
2128
|
-
EOF
|
|
2129
|
-
)"
|
|
2130
|
-
```
|
|
2131
|
-
|
|
2132
|
-
---
|
|
2133
|
-
|
|
2134
|
-
## Task 16: Restore musicians on orchestra restore
|
|
2135
|
-
|
|
2136
|
-
**Files:**
|
|
2137
|
-
- Modify: `src/commands/restore.ts`
|
|
2138
|
-
|
|
2139
|
-
- [ ] **Step 1: Update `restoreOrchestra` in `src/commands/restore.ts`**
|
|
2140
|
-
|
|
2141
|
-
After the Orchestrator pane is launched, restore each non-stopped musician. Add this section before the final `if (!dryRun) await attachSession(name)`:
|
|
2142
|
-
|
|
2143
|
-
```typescript
|
|
2144
|
-
// Rebuild the Orchestrator's prompt file with the current notes content.
|
|
2145
|
-
const { loadOrchestratorNotes } = await import('./launch.js');
|
|
2146
|
-
const promptFileForOrchestrator = join(orchestraDir(orchestraId), 'orchestrator-prompt.md');
|
|
2147
|
-
if (existsSync(promptFileForOrchestrator)) {
|
|
2148
|
-
const { ORCHESTRATOR_ROLE_PROMPT_V1 } = await import('../prompts/orchestrator-role.js');
|
|
2149
|
-
const notes = await loadOrchestratorNotes(orchestraId);
|
|
2150
|
-
const { writeFile } = await import('node:fs/promises');
|
|
2151
|
-
await writeFile(promptFileForOrchestrator, ORCHESTRATOR_ROLE_PROMPT_V1 + notes, 'utf8');
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
// Restore musicians (Phase 2). Stopped musicians are not restored.
|
|
2155
|
-
const mcpConfigPath = join(orchestraDir(orchestraId), 'mcp-config.json');
|
|
2156
|
-
for (const musician of state.musicians) {
|
|
2157
|
-
if (musician.status === 'stopped') continue;
|
|
2158
|
-
// Create the tmux window in the musician's working dir.
|
|
2159
|
-
const workingDir = musician.worktree_path ?? state.project_path;
|
|
2160
|
-
const winLabel = `mus-${musician.id}-${sanitiseNameLocal(musician.name)}`;
|
|
2161
|
-
await execa('tmux', [
|
|
2162
|
-
'new-window',
|
|
2163
|
-
'-t', name,
|
|
2164
|
-
'-n', winLabel,
|
|
2165
|
-
'-c', workingDir,
|
|
2166
|
-
'-d',
|
|
2167
|
-
], { reject: false });
|
|
2168
|
-
// Launch claude --resume in that window.
|
|
2169
|
-
const musicianPromptFile = join(orchestraDir(orchestraId), `musician-${musician.id}-prompt.md`);
|
|
2170
|
-
const resumeArgs = musician.claude_session_id ? ['--resume', musician.claude_session_id] : [];
|
|
2171
|
-
const cmd = [
|
|
2172
|
-
'claude',
|
|
2173
|
-
...flags,
|
|
2174
|
-
...resumeArgs,
|
|
2175
|
-
'--mcp-config', mcpConfigPath,
|
|
2176
|
-
];
|
|
2177
|
-
if (existsSync(musicianPromptFile)) {
|
|
2178
|
-
cmd.push('--append-system-prompt-file', musicianPromptFile);
|
|
2179
|
-
}
|
|
2180
|
-
await sendKeys(`${name}:${winLabel}`, cmd.join(' '), true);
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
function sanitiseNameLocal(s: string): string {
|
|
2184
|
-
return s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'musician';
|
|
2185
|
-
}
|
|
2186
|
-
```
|
|
2187
|
-
|
|
2188
|
-
Also at the top, ensure `execa` is imported (it should already be present from earlier or, if not, add `import { execa } from 'execa';`). And update the existing `--mcp-config` injection for the Orchestrator: the current code in restore launches claude without `--mcp-config`. Add the flag:
|
|
2189
|
-
|
|
2190
|
-
```typescript
|
|
2191
|
-
const cmd = ['claude', ...flags, ...resumeArgs, '--mcp-config', mcpConfigPath];
|
|
2192
|
-
```
|
|
2193
|
-
|
|
2194
|
-
(Use the same `mcpConfigPath` variable defined above.)
|
|
2195
|
-
|
|
2196
|
-
- [ ] **Step 2: Update the restore test minimally**
|
|
2197
|
-
|
|
2198
|
-
The existing `tests/commands/restore.test.ts` test already asserts the orchestra is restored from a known-but-stopped state. Phase 2 doesn't need a new test — the musician-restoration path is exercised at the integration level in Task 17. Run the existing test:
|
|
2199
|
-
|
|
2200
|
-
`npm test -- restore` — must still pass.
|
|
2201
|
-
|
|
2202
|
-
- [ ] **Step 3: Full suite**
|
|
2203
|
-
|
|
2204
|
-
`npm test` — must pass.
|
|
2205
|
-
|
|
2206
|
-
- [ ] **Step 4: Commit**
|
|
2207
|
-
|
|
2208
|
-
```
|
|
2209
|
-
git add src/commands/restore.ts
|
|
2210
|
-
git commit -m "$(cat <<'EOF'
|
|
2211
|
-
feat(restore): attach mcp-config and rehydrate non-stopped musicians
|
|
2212
|
-
|
|
2213
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
2214
|
-
EOF
|
|
2215
|
-
)"
|
|
2216
|
-
```
|
|
2217
|
-
|
|
2218
|
-
---
|
|
2219
|
-
|
|
2220
|
-
## Task 17: Integration smoke — Orchestrator spawns a Musician via MCP
|
|
2221
|
-
|
|
2222
|
-
**Files:**
|
|
2223
|
-
- Create: `tests/integration/orchestrator-spawn.test.ts`
|
|
2224
|
-
|
|
2225
|
-
This test does NOT actually drive a real Claude Code session (that would need an API key and time). Instead it boots the NFO MCP server as a child process and speaks the MCP JSON-RPC protocol to it directly — which is exactly what claude does in production.
|
|
2226
|
-
|
|
2227
|
-
- [ ] **Step 1: Test**
|
|
2228
|
-
|
|
2229
|
-
`tests/integration/orchestrator-spawn.test.ts`:
|
|
2230
|
-
|
|
2231
|
-
```typescript
|
|
2232
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2233
|
-
import { spawn } from 'node:child_process';
|
|
2234
|
-
import { resolve } from 'node:path';
|
|
2235
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2236
|
-
import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
|
|
2237
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
2238
|
-
import { ensureOrchestraDir, writeState, readState } from '../../src/state.js';
|
|
2239
|
-
import { makeInitialState } from '../../src/state.types.js';
|
|
2240
|
-
import { projectKeyFromPath } from '../../src/project-key.js';
|
|
2241
|
-
import { createDetachedSession, sessionName, killSession } from '../../src/tmux.js';
|
|
2242
|
-
|
|
2243
|
-
const CLI = resolve(process.cwd(), 'dist/cli.js');
|
|
2244
|
-
|
|
2245
|
-
describe('NFO MCP server (e2e)', () => {
|
|
2246
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
2247
|
-
const sessionsToKill: string[] = [];
|
|
2248
|
-
|
|
2249
|
-
beforeEach(async () => {
|
|
2250
|
-
process.env.NFO_HOME = '';
|
|
2251
|
-
if (!existsSync(CLI)) {
|
|
2252
|
-
throw new Error(`dist/cli.js missing; run \`npm run build\` first`);
|
|
2253
|
-
}
|
|
2254
|
-
});
|
|
2255
|
-
|
|
2256
|
-
afterEach(async () => {
|
|
2257
|
-
for (const s of sessionsToKill) {
|
|
2258
|
-
try { await killSession(s); } catch { /* ignore */ }
|
|
2259
|
-
}
|
|
2260
|
-
sessionsToKill.length = 0;
|
|
2261
|
-
for (const c of cleanups) await c();
|
|
2262
|
-
cleanups.length = 0;
|
|
2263
|
-
delete process.env.NFO_HOME;
|
|
2264
|
-
});
|
|
2265
|
-
|
|
2266
|
-
it('lists 9 tools and dispatches spawn_musician via JSON-RPC', async () => {
|
|
2267
|
-
const cfg = await makeTmpConfig();
|
|
2268
|
-
cleanups.push(cfg.cleanup);
|
|
2269
|
-
process.env.NFO_HOME = cfg.path;
|
|
2270
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
2271
|
-
cleanups.push(repo.cleanup);
|
|
2272
|
-
const orchId = projectKeyFromPath(repo.path);
|
|
2273
|
-
await ensureOrchestraDir(orchId);
|
|
2274
|
-
await writeState(orchId, makeInitialState({
|
|
2275
|
-
orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
|
|
2276
|
-
}));
|
|
2277
|
-
sessionsToKill.push(sessionName(orchId));
|
|
2278
|
-
await createDetachedSession(sessionName(orchId), repo.path, 220, 50);
|
|
2279
|
-
|
|
2280
|
-
const proc = spawn(
|
|
2281
|
-
process.execPath,
|
|
2282
|
-
[CLI, 'mcp-server', '--orchestra-id', orchId],
|
|
2283
|
-
{ env: { ...process.env, NFO_HOME: cfg.path } },
|
|
2284
|
-
);
|
|
2285
|
-
|
|
2286
|
-
const responses: Array<Record<string, unknown>> = [];
|
|
2287
|
-
let stdoutBuf = '';
|
|
2288
|
-
proc.stdout.on('data', (chunk: Buffer) => {
|
|
2289
|
-
stdoutBuf += chunk.toString();
|
|
2290
|
-
let idx: number;
|
|
2291
|
-
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
|
|
2292
|
-
const line = stdoutBuf.slice(0, idx).trim();
|
|
2293
|
-
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
2294
|
-
if (line.length > 0) responses.push(JSON.parse(line));
|
|
2295
|
-
}
|
|
2296
|
-
});
|
|
2297
|
-
|
|
2298
|
-
function send(msg: Record<string, unknown>) {
|
|
2299
|
-
proc.stdin.write(JSON.stringify(msg) + '\n');
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
// 1. initialize
|
|
2303
|
-
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {
|
|
2304
|
-
protocolVersion: '2024-11-05',
|
|
2305
|
-
capabilities: {},
|
|
2306
|
-
clientInfo: { name: 'test', version: '0' },
|
|
2307
|
-
}});
|
|
2308
|
-
|
|
2309
|
-
// wait for init response
|
|
2310
|
-
await waitFor(() => responses.some(r => r.id === 1));
|
|
2311
|
-
|
|
2312
|
-
// 2. tools/list
|
|
2313
|
-
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
2314
|
-
await waitFor(() => responses.some(r => r.id === 2));
|
|
2315
|
-
const listResp = responses.find(r => r.id === 2) as any;
|
|
2316
|
-
expect(listResp.result.tools.length).toBe(9);
|
|
2317
|
-
|
|
2318
|
-
// 3. tools/call spawn_musician
|
|
2319
|
-
send({
|
|
2320
|
-
jsonrpc: '2.0', id: 3, method: 'tools/call',
|
|
2321
|
-
params: {
|
|
2322
|
-
name: 'spawn_musician',
|
|
2323
|
-
arguments: { name: 'tester', task: 'echo it', worktree: false },
|
|
2324
|
-
},
|
|
2325
|
-
});
|
|
2326
|
-
await waitFor(() => responses.some(r => r.id === 3));
|
|
2327
|
-
|
|
2328
|
-
proc.kill();
|
|
2329
|
-
|
|
2330
|
-
// Confirm a musician was added to state.
|
|
2331
|
-
const state = await readState(orchId);
|
|
2332
|
-
expect(state!.musicians).toHaveLength(1);
|
|
2333
|
-
expect(state!.musicians[0].name).toBe('tester');
|
|
2334
|
-
}, 15000);
|
|
2335
|
-
});
|
|
2336
|
-
|
|
2337
|
-
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
2338
|
-
const start = Date.now();
|
|
2339
|
-
while (!predicate()) {
|
|
2340
|
-
if (Date.now() - start > timeoutMs) throw new Error('Timed out waiting');
|
|
2341
|
-
await new Promise(r => setTimeout(r, 25));
|
|
2342
|
-
}
|
|
2343
|
-
}
|
|
2344
|
-
```
|
|
2345
|
-
|
|
2346
|
-
Note: this test starts the MCP server as a child process; the server in turn runs `createMusician` which (in real production) spawns claude in a new tmux window. In the test the tmux window is created but the `claude` command sent into it will probably fail to find claude or fail to start — that's OK; we only assert on the state.json after the spawn_musician returns. We rely on `dispatch(..., { dryRun: false })` being acceptable because the actual `claude ...` command is just sent to a tmux pane as text, no waiting.
|
|
2347
|
-
|
|
2348
|
-
Actually we DO want a dryRun here. Let me fix that — re-evaluate. The MCP server `dispatch` is called with `{ callerMusicianId: opts.callerMusicianId }` only — no `dryRun`. So the server runs `createMusician` with `dryRun: false`, which calls `tmux new-window` and `sendKeys 'claude ...'`. The sendKeys will inject text into the pane; whether `claude` runs is the shell's problem. The state is written immediately after `tmux new-window` returns. So the test should pass even without dryRun.
|
|
2349
|
-
|
|
2350
|
-
If this becomes flaky, add an env var `NFO_MCP_DRY_RUN=1` that the server respects and passes through to dispatch. For now ship as written.
|
|
2351
|
-
|
|
2352
|
-
- [ ] **Step 2: Run test**
|
|
2353
|
-
|
|
2354
|
-
```
|
|
2355
|
-
npm run build # required so dist/cli.js is fresh
|
|
2356
|
-
npm test -- integration/orchestrator-spawn
|
|
2357
|
-
```
|
|
2358
|
-
|
|
2359
|
-
Expected: 1/1 PASS.
|
|
2360
|
-
|
|
2361
|
-
If it fails because dist is stale, `npm run build` and re-run. If it fails because the MCP child errors before init, dump `proc.stderr` to logs.
|
|
2362
|
-
|
|
2363
|
-
- [ ] **Step 3: Commit**
|
|
2364
|
-
|
|
2365
|
-
```
|
|
2366
|
-
git add tests/integration/orchestrator-spawn.test.ts
|
|
2367
|
-
git commit -m "$(cat <<'EOF'
|
|
2368
|
-
test(mcp): e2e — child-process MCP server speaks JSON-RPC and spawns a Musician
|
|
2369
|
-
|
|
2370
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
2371
|
-
EOF
|
|
2372
|
-
)"
|
|
2373
|
-
```
|
|
2374
|
-
|
|
2375
|
-
---
|
|
2376
|
-
|
|
2377
|
-
## Task 18: Update README for Phase 2
|
|
2378
|
-
|
|
2379
|
-
**Files:**
|
|
2380
|
-
- Modify: `README.md`
|
|
2381
|
-
|
|
2382
|
-
- [ ] **Step 1: Update the Status and Use sections of `README.md`**
|
|
2383
|
-
|
|
2384
|
-
Replace the Status block with:
|
|
2385
|
-
|
|
2386
|
-
```markdown
|
|
2387
|
-
## Status
|
|
2388
|
-
|
|
2389
|
-
Phase 2. The `nfo` command can create/attach/restore/list/kill an Orchestra and launch the Orchestrator's `claude` session in a tmux pane. The Orchestrator has access to the NFO MCP tools — it can spawn Musicians (Claude sub-agents) into hidden tmux windows, message and query them, dismiss them, and curate persistent project notes. Each Musician gets a dedicated git worktree. The Ink TUI side pane and permission-prompt detection ship in later phases — for now, switch between Musician windows with raw tmux (`prefix + w`).
|
|
2390
|
-
```
|
|
2391
|
-
|
|
2392
|
-
Append after the Use section:
|
|
2393
|
-
|
|
2394
|
-
```markdown
|
|
2395
|
-
## Musicians
|
|
2396
|
-
|
|
2397
|
-
Inside an orchestra, the Orchestrator can use these MCP tools:
|
|
2398
|
-
|
|
2399
|
-
- `spawn_musician({ name, task })` — create a Musician in an isolated git worktree
|
|
2400
|
-
- `message_musician({ musician_id, message })`
|
|
2401
|
-
- `query_musician({ musician_id, lines? })` — read recent pane output
|
|
2402
|
-
- `list_musicians()`
|
|
2403
|
-
- `dismiss_musician({ musician_id, archive_worktree? })` — archived = worktree preserved under `archive/`, branch kept; dropped = worktree gone, branch deleted
|
|
2404
|
-
- `report_done({ summary })` — called by Musicians on completion
|
|
2405
|
-
- `note_write` / `note_read` / `note_list` — Orchestrator's persistent notes
|
|
2406
|
-
|
|
2407
|
-
To watch a Musician work, in the tmux session: `prefix + w` to list windows, then select theirs.
|
|
2408
|
-
```
|
|
2409
|
-
|
|
2410
|
-
- [ ] **Step 2: Commit**
|
|
2411
|
-
|
|
2412
|
-
```
|
|
2413
|
-
git add README.md
|
|
2414
|
-
git commit -m "$(cat <<'EOF'
|
|
2415
|
-
docs: README for Phase 2 (MCP tools + musicians)
|
|
2416
|
-
|
|
2417
|
-
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
2418
|
-
EOF
|
|
2419
|
-
)"
|
|
2420
|
-
```
|
|
2421
|
-
|
|
2422
|
-
---
|
|
2423
|
-
|
|
2424
|
-
## Task 19: Final audit + tag
|
|
2425
|
-
|
|
2426
|
-
Not a code task — a verification gate.
|
|
2427
|
-
|
|
2428
|
-
- [ ] **Step 1: Full test suite**
|
|
2429
|
-
|
|
2430
|
-
`npm test` — confirm all tests pass. Should be roughly 30 (Phase 1) + ~20 new = ~50 tests across ~20 files.
|
|
2431
|
-
|
|
2432
|
-
- [ ] **Step 2: Build + typecheck clean**
|
|
2433
|
-
|
|
2434
|
-
```
|
|
2435
|
-
npm run typecheck
|
|
2436
|
-
npm run build
|
|
2437
|
-
```
|
|
2438
|
-
|
|
2439
|
-
Both must pass.
|
|
2440
|
-
|
|
2441
|
-
- [ ] **Step 3: Manual smoke**
|
|
2442
|
-
|
|
2443
|
-
In a throwaway env:
|
|
2444
|
-
|
|
2445
|
-
```bash
|
|
2446
|
-
export NFO_HOME=/tmp/nfo-phase2-home
|
|
2447
|
-
rm -rf "$NFO_HOME" /tmp/nfo-phase2-repo
|
|
2448
|
-
mkdir /tmp/nfo-phase2-repo && cd /tmp/nfo-phase2-repo
|
|
2449
|
-
git init -q && git commit --allow-empty -m init
|
|
2450
|
-
nfo
|
|
2451
|
-
# Pick supervised. Inside the Orchestrator pane, ask claude:
|
|
2452
|
-
# "Use the spawn_musician tool to create a Musician named 'echo-test' with the task 'just print hello'."
|
|
2453
|
-
# Then `prefix + w` to see the musician's window in the tmux session.
|
|
2454
|
-
# Then ask claude: "list_musicians" — should show the spawned musician.
|
|
2455
|
-
# Then: "dismiss_musician with archive_worktree=false on that musician."
|
|
2456
|
-
# Confirm via `tmux list-windows` that the musician's window is gone.
|
|
2457
|
-
```
|
|
2458
|
-
|
|
2459
|
-
Report any issues encountered.
|
|
2460
|
-
|
|
2461
|
-
- [ ] **Step 4: Tag**
|
|
2462
|
-
|
|
2463
|
-
```
|
|
2464
|
-
git tag phase-2-complete
|
|
2465
|
-
```
|
|
2466
|
-
|
|
2467
|
-
Phase 3 (Ink TUI + Auditorium + Concert Hall) gets its own plan.
|