nfo-cli 0.0.4-improve-prompting → 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.
Files changed (159) hide show
  1. package/dist/claude-command.js +6 -1
  2. package/dist/claude-command.js.map +1 -1
  3. package/dist/claude-trust.js +46 -0
  4. package/dist/claude-trust.js.map +1 -0
  5. package/dist/cli.js +0 -0
  6. package/dist/mcp/handlers.js +5 -0
  7. package/dist/mcp/handlers.js.map +1 -1
  8. package/dist/mcp/tool-defs.js +10 -0
  9. package/dist/mcp/tool-defs.js.map +1 -1
  10. package/dist/musicians/dismiss.js +1 -1
  11. package/dist/musicians/dismiss.js.map +1 -1
  12. package/dist/musicians/roles.js +15 -0
  13. package/dist/musicians/roles.js.map +1 -0
  14. package/dist/musicians/spawn.js +53 -18
  15. package/dist/musicians/spawn.js.map +1 -1
  16. package/dist/permission.js +6 -0
  17. package/dist/permission.js.map +1 -1
  18. package/dist/prompts/musician-role.js +2 -1
  19. package/dist/prompts/musician-role.js.map +1 -1
  20. package/dist/prompts/orchestrator-role.js +18 -6
  21. package/dist/prompts/orchestrator-role.js.map +1 -1
  22. package/dist/prompts/tool-discipline.js +7 -3
  23. package/dist/prompts/tool-discipline.js.map +1 -1
  24. package/package.json +8 -1
  25. package/assets/agent-screen.png +0 -0
  26. package/assets/main-screen.png +0 -0
  27. package/assets/orche-clawd.png +0 -0
  28. package/dist/tui/App.js +0 -428
  29. package/dist/tui/App.js.map +0 -1
  30. package/dist/tui/AppView.js +0 -13
  31. package/dist/tui/AppView.js.map +0 -1
  32. package/dist/tui/Auditorium.js +0 -17
  33. package/dist/tui/Auditorium.js.map +0 -1
  34. package/dist/tui/ConcertHall.js +0 -11
  35. package/dist/tui/ConcertHall.js.map +0 -1
  36. package/dist/tui/Help.js +0 -49
  37. package/dist/tui/Help.js.map +0 -1
  38. package/dist/tui/OrchestratorPane.js +0 -34
  39. package/dist/tui/OrchestratorPane.js.map +0 -1
  40. package/dist/tui/SidebarHeader.js +0 -6
  41. package/dist/tui/SidebarHeader.js.map +0 -1
  42. package/dist/tui/StatusBar.js +0 -6
  43. package/dist/tui/StatusBar.js.map +0 -1
  44. package/docs/plans/2026-05-29-nfo-phase-1-bootstrap.md +0 -2152
  45. package/docs/plans/2026-05-29-nfo-phase-2-mcp-musicians.md +0 -2467
  46. package/docs/plans/2026-05-29-nfo-phase-3-ink-tui.md +0 -1611
  47. package/docs/plans/2026-05-29-nfo-phase-4-permission-prompts.md +0 -460
  48. package/docs/plans/2026-05-29-nfo-phase-5-help-and-notify.md +0 -933
  49. package/docs/specs/2026-05-29-nfo-design.md +0 -468
  50. package/plan-explorer-musician-hardening.md +0 -56
  51. package/src/claude-command.ts +0 -35
  52. package/src/claude-detect.ts +0 -42
  53. package/src/cli.ts +0 -197
  54. package/src/commands/attach.ts +0 -24
  55. package/src/commands/dashboard-window.ts +0 -33
  56. package/src/commands/kill.ts +0 -50
  57. package/src/commands/launch.ts +0 -134
  58. package/src/commands/list.ts +0 -43
  59. package/src/commands/mcp-server.ts +0 -18
  60. package/src/commands/notes.ts +0 -18
  61. package/src/commands/restore.ts +0 -153
  62. package/src/commands/tui.tsx +0 -22
  63. package/src/config.ts +0 -44
  64. package/src/dashboard.ts +0 -1
  65. package/src/mcp/config.ts +0 -39
  66. package/src/mcp/handlers.ts +0 -141
  67. package/src/mcp/server.ts +0 -50
  68. package/src/mcp/tool-defs.ts +0 -151
  69. package/src/musicians/dismiss.ts +0 -60
  70. package/src/musicians/ids.ts +0 -21
  71. package/src/musicians/lookup.ts +0 -13
  72. package/src/musicians/message-log.ts +0 -152
  73. package/src/musicians/message.ts +0 -99
  74. package/src/musicians/query.ts +0 -19
  75. package/src/musicians/spawn.ts +0 -139
  76. package/src/notes.ts +0 -39
  77. package/src/notify.ts +0 -62
  78. package/src/orchestrator/report-back.ts +0 -33
  79. package/src/permission.ts +0 -30
  80. package/src/project-key.ts +0 -12
  81. package/src/prompts/musician-role.ts +0 -22
  82. package/src/prompts/orchestrator-role.ts +0 -84
  83. package/src/prompts/tool-discipline.ts +0 -41
  84. package/src/repo.ts +0 -14
  85. package/src/shell-quote.ts +0 -7
  86. package/src/state-updaters.ts +0 -132
  87. package/src/state.ts +0 -49
  88. package/src/state.types.ts +0 -67
  89. package/src/tmux.ts +0 -226
  90. package/src/tui/activity-line.ts +0 -16
  91. package/src/tui/components/App.tsx +0 -534
  92. package/src/tui/components/AppView.tsx +0 -98
  93. package/src/tui/components/Auditorium.tsx +0 -56
  94. package/src/tui/components/ConcertHall.tsx +0 -31
  95. package/src/tui/components/Help.tsx +0 -63
  96. package/src/tui/components/OrchestratorPane.tsx +0 -98
  97. package/src/tui/components/SidebarHeader.tsx +0 -34
  98. package/src/tui/components/StatusBar.tsx +0 -42
  99. package/src/tui/detect-permission.ts +0 -93
  100. package/src/tui/embedded-session-lifecycle.ts +0 -44
  101. package/src/tui/embedded-terminal.ts +0 -325
  102. package/src/tui/format-time.ts +0 -25
  103. package/src/tui/keymap.ts +0 -104
  104. package/src/tui/poll-activity.ts +0 -25
  105. package/src/tui/poll-idle.ts +0 -149
  106. package/src/tui/poll-permission.ts +0 -50
  107. package/src/tui/status-icon.ts +0 -35
  108. package/src/tui/terminal-input.ts +0 -136
  109. package/src/tui/watch-state.ts +0 -43
  110. package/src/worktree.ts +0 -41
  111. package/tests/claude-command.test.ts +0 -30
  112. package/tests/claude-detect.test.ts +0 -14
  113. package/tests/commands/attach.test.ts +0 -60
  114. package/tests/commands/kill.test.ts +0 -66
  115. package/tests/commands/launch.test.ts +0 -75
  116. package/tests/commands/list.test.ts +0 -47
  117. package/tests/commands/notes.test.ts +0 -53
  118. package/tests/commands/restore.test.ts +0 -126
  119. package/tests/helpers/tmp-config.ts +0 -16
  120. package/tests/helpers/tmp-repo.ts +0 -29
  121. package/tests/integration/orchestrator-spawn.test.ts +0 -108
  122. package/tests/mcp/handlers.test.ts +0 -163
  123. package/tests/mcp/tool-defs.test.ts +0 -35
  124. package/tests/musicians/dismiss.test.ts +0 -102
  125. package/tests/musicians/message.test.ts +0 -159
  126. package/tests/musicians/query.test.ts +0 -65
  127. package/tests/musicians/spawn.test.ts +0 -125
  128. package/tests/notes.test.ts +0 -56
  129. package/tests/notify.test.ts +0 -80
  130. package/tests/orchestrator/report-back.test.ts +0 -18
  131. package/tests/permission.test.ts +0 -39
  132. package/tests/project-key.test.ts +0 -33
  133. package/tests/prompts/tool-discipline.test.ts +0 -25
  134. package/tests/repo.test.ts +0 -38
  135. package/tests/state-updaters.test.ts +0 -126
  136. package/tests/state.test.ts +0 -85
  137. package/tests/tmux.test.ts +0 -126
  138. package/tests/tui/AppView.test.tsx +0 -92
  139. package/tests/tui/Auditorium.test.tsx +0 -67
  140. package/tests/tui/ConcertHall.test.tsx +0 -22
  141. package/tests/tui/Help.test.tsx +0 -38
  142. package/tests/tui/OrchestratorPane.test.ts +0 -30
  143. package/tests/tui/SidebarHeader.test.tsx +0 -20
  144. package/tests/tui/StatusBar.test.tsx +0 -51
  145. package/tests/tui/activity-line.test.ts +0 -21
  146. package/tests/tui/detect-permission.test.ts +0 -92
  147. package/tests/tui/embedded-session-lifecycle.test.ts +0 -55
  148. package/tests/tui/embedded-terminal.test.ts +0 -80
  149. package/tests/tui/format-time.test.ts +0 -25
  150. package/tests/tui/keymap.test.ts +0 -93
  151. package/tests/tui/poll-activity.test.ts +0 -81
  152. package/tests/tui/poll-idle.test.ts +0 -159
  153. package/tests/tui/poll-permission.test.ts +0 -222
  154. package/tests/tui/status-icon.test.ts +0 -27
  155. package/tests/tui/terminal-input.test.ts +0 -113
  156. package/tests/tui/watch-state.test.ts +0 -54
  157. package/tests/worktree.test.ts +0 -73
  158. package/tsconfig.json +0 -19
  159. package/vitest.config.ts +0 -12
@@ -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.