nfo-cli 0.0.4-improve-prompting → 0.0.6-a89844d-dev

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 (178) 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 +5 -4
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/attach.js +8 -8
  8. package/dist/commands/attach.js.map +1 -1
  9. package/dist/commands/launch.js +3 -6
  10. package/dist/commands/launch.js.map +1 -1
  11. package/dist/commands/restore.js +6 -10
  12. package/dist/commands/restore.js.map +1 -1
  13. package/dist/commands/tui.js +17 -1
  14. package/dist/commands/tui.js.map +1 -1
  15. package/dist/mcp/handlers.js +5 -0
  16. package/dist/mcp/handlers.js.map +1 -1
  17. package/dist/mcp/tool-defs.js +10 -0
  18. package/dist/mcp/tool-defs.js.map +1 -1
  19. package/dist/musicians/dismiss.js +1 -1
  20. package/dist/musicians/dismiss.js.map +1 -1
  21. package/dist/musicians/reconcile.js +27 -0
  22. package/dist/musicians/reconcile.js.map +1 -0
  23. package/dist/musicians/roles.js +15 -0
  24. package/dist/musicians/roles.js.map +1 -0
  25. package/dist/musicians/spawn.js +53 -18
  26. package/dist/musicians/spawn.js.map +1 -1
  27. package/dist/permission.js +6 -0
  28. package/dist/permission.js.map +1 -1
  29. package/dist/prompts/musician-role.js +2 -1
  30. package/dist/prompts/musician-role.js.map +1 -1
  31. package/dist/prompts/orchestrator-role.js +18 -6
  32. package/dist/prompts/orchestrator-role.js.map +1 -1
  33. package/dist/prompts/tool-discipline.js +7 -3
  34. package/dist/prompts/tool-discipline.js.map +1 -1
  35. package/dist/tmux.js +23 -0
  36. package/dist/tmux.js.map +1 -1
  37. package/dist/tui/components/App.js +8 -12
  38. package/dist/tui/components/App.js.map +1 -1
  39. package/dist/tui/components/Help.js +1 -1
  40. package/dist/tui/components/Help.js.map +1 -1
  41. package/dist/tui/keymap.js +1 -1
  42. package/dist/tui/keymap.js.map +1 -1
  43. package/package.json +18 -8
  44. package/assets/agent-screen.png +0 -0
  45. package/assets/main-screen.png +0 -0
  46. package/assets/orche-clawd.png +0 -0
  47. package/dist/tui/App.js +0 -428
  48. package/dist/tui/App.js.map +0 -1
  49. package/dist/tui/AppView.js +0 -13
  50. package/dist/tui/AppView.js.map +0 -1
  51. package/dist/tui/Auditorium.js +0 -17
  52. package/dist/tui/Auditorium.js.map +0 -1
  53. package/dist/tui/ConcertHall.js +0 -11
  54. package/dist/tui/ConcertHall.js.map +0 -1
  55. package/dist/tui/Help.js +0 -49
  56. package/dist/tui/Help.js.map +0 -1
  57. package/dist/tui/OrchestratorPane.js +0 -34
  58. package/dist/tui/OrchestratorPane.js.map +0 -1
  59. package/dist/tui/SidebarHeader.js +0 -6
  60. package/dist/tui/SidebarHeader.js.map +0 -1
  61. package/dist/tui/StatusBar.js +0 -6
  62. package/dist/tui/StatusBar.js.map +0 -1
  63. package/docs/plans/2026-05-29-nfo-phase-1-bootstrap.md +0 -2152
  64. package/docs/plans/2026-05-29-nfo-phase-2-mcp-musicians.md +0 -2467
  65. package/docs/plans/2026-05-29-nfo-phase-3-ink-tui.md +0 -1611
  66. package/docs/plans/2026-05-29-nfo-phase-4-permission-prompts.md +0 -460
  67. package/docs/plans/2026-05-29-nfo-phase-5-help-and-notify.md +0 -933
  68. package/docs/specs/2026-05-29-nfo-design.md +0 -468
  69. package/plan-explorer-musician-hardening.md +0 -56
  70. package/src/claude-command.ts +0 -35
  71. package/src/claude-detect.ts +0 -42
  72. package/src/cli.ts +0 -197
  73. package/src/commands/attach.ts +0 -24
  74. package/src/commands/dashboard-window.ts +0 -33
  75. package/src/commands/kill.ts +0 -50
  76. package/src/commands/launch.ts +0 -134
  77. package/src/commands/list.ts +0 -43
  78. package/src/commands/mcp-server.ts +0 -18
  79. package/src/commands/notes.ts +0 -18
  80. package/src/commands/restore.ts +0 -153
  81. package/src/commands/tui.tsx +0 -22
  82. package/src/config.ts +0 -44
  83. package/src/dashboard.ts +0 -1
  84. package/src/mcp/config.ts +0 -39
  85. package/src/mcp/handlers.ts +0 -141
  86. package/src/mcp/server.ts +0 -50
  87. package/src/mcp/tool-defs.ts +0 -151
  88. package/src/musicians/dismiss.ts +0 -60
  89. package/src/musicians/ids.ts +0 -21
  90. package/src/musicians/lookup.ts +0 -13
  91. package/src/musicians/message-log.ts +0 -152
  92. package/src/musicians/message.ts +0 -99
  93. package/src/musicians/query.ts +0 -19
  94. package/src/musicians/spawn.ts +0 -139
  95. package/src/notes.ts +0 -39
  96. package/src/notify.ts +0 -62
  97. package/src/orchestrator/report-back.ts +0 -33
  98. package/src/permission.ts +0 -30
  99. package/src/project-key.ts +0 -12
  100. package/src/prompts/musician-role.ts +0 -22
  101. package/src/prompts/orchestrator-role.ts +0 -84
  102. package/src/prompts/tool-discipline.ts +0 -41
  103. package/src/repo.ts +0 -14
  104. package/src/shell-quote.ts +0 -7
  105. package/src/state-updaters.ts +0 -132
  106. package/src/state.ts +0 -49
  107. package/src/state.types.ts +0 -67
  108. package/src/tmux.ts +0 -226
  109. package/src/tui/activity-line.ts +0 -16
  110. package/src/tui/components/App.tsx +0 -534
  111. package/src/tui/components/AppView.tsx +0 -98
  112. package/src/tui/components/Auditorium.tsx +0 -56
  113. package/src/tui/components/ConcertHall.tsx +0 -31
  114. package/src/tui/components/Help.tsx +0 -63
  115. package/src/tui/components/OrchestratorPane.tsx +0 -98
  116. package/src/tui/components/SidebarHeader.tsx +0 -34
  117. package/src/tui/components/StatusBar.tsx +0 -42
  118. package/src/tui/detect-permission.ts +0 -93
  119. package/src/tui/embedded-session-lifecycle.ts +0 -44
  120. package/src/tui/embedded-terminal.ts +0 -325
  121. package/src/tui/format-time.ts +0 -25
  122. package/src/tui/keymap.ts +0 -104
  123. package/src/tui/poll-activity.ts +0 -25
  124. package/src/tui/poll-idle.ts +0 -149
  125. package/src/tui/poll-permission.ts +0 -50
  126. package/src/tui/status-icon.ts +0 -35
  127. package/src/tui/terminal-input.ts +0 -136
  128. package/src/tui/watch-state.ts +0 -43
  129. package/src/worktree.ts +0 -41
  130. package/tests/claude-command.test.ts +0 -30
  131. package/tests/claude-detect.test.ts +0 -14
  132. package/tests/commands/attach.test.ts +0 -60
  133. package/tests/commands/kill.test.ts +0 -66
  134. package/tests/commands/launch.test.ts +0 -75
  135. package/tests/commands/list.test.ts +0 -47
  136. package/tests/commands/notes.test.ts +0 -53
  137. package/tests/commands/restore.test.ts +0 -126
  138. package/tests/helpers/tmp-config.ts +0 -16
  139. package/tests/helpers/tmp-repo.ts +0 -29
  140. package/tests/integration/orchestrator-spawn.test.ts +0 -108
  141. package/tests/mcp/handlers.test.ts +0 -163
  142. package/tests/mcp/tool-defs.test.ts +0 -35
  143. package/tests/musicians/dismiss.test.ts +0 -102
  144. package/tests/musicians/message.test.ts +0 -159
  145. package/tests/musicians/query.test.ts +0 -65
  146. package/tests/musicians/spawn.test.ts +0 -125
  147. package/tests/notes.test.ts +0 -56
  148. package/tests/notify.test.ts +0 -80
  149. package/tests/orchestrator/report-back.test.ts +0 -18
  150. package/tests/permission.test.ts +0 -39
  151. package/tests/project-key.test.ts +0 -33
  152. package/tests/prompts/tool-discipline.test.ts +0 -25
  153. package/tests/repo.test.ts +0 -38
  154. package/tests/state-updaters.test.ts +0 -126
  155. package/tests/state.test.ts +0 -85
  156. package/tests/tmux.test.ts +0 -126
  157. package/tests/tui/AppView.test.tsx +0 -92
  158. package/tests/tui/Auditorium.test.tsx +0 -67
  159. package/tests/tui/ConcertHall.test.tsx +0 -22
  160. package/tests/tui/Help.test.tsx +0 -38
  161. package/tests/tui/OrchestratorPane.test.ts +0 -30
  162. package/tests/tui/SidebarHeader.test.tsx +0 -20
  163. package/tests/tui/StatusBar.test.tsx +0 -51
  164. package/tests/tui/activity-line.test.ts +0 -21
  165. package/tests/tui/detect-permission.test.ts +0 -92
  166. package/tests/tui/embedded-session-lifecycle.test.ts +0 -55
  167. package/tests/tui/embedded-terminal.test.ts +0 -80
  168. package/tests/tui/format-time.test.ts +0 -25
  169. package/tests/tui/keymap.test.ts +0 -93
  170. package/tests/tui/poll-activity.test.ts +0 -81
  171. package/tests/tui/poll-idle.test.ts +0 -159
  172. package/tests/tui/poll-permission.test.ts +0 -222
  173. package/tests/tui/status-icon.test.ts +0 -27
  174. package/tests/tui/terminal-input.test.ts +0 -113
  175. package/tests/tui/watch-state.test.ts +0 -54
  176. package/tests/worktree.test.ts +0 -73
  177. package/tsconfig.json +0 -19
  178. package/vitest.config.ts +0 -12
@@ -1,126 +0,0 @@
1
- import { describe, it, expect, afterEach, beforeEach } from 'vitest';
2
- import { restoreOrchestra } from '../../src/commands/restore.js';
3
- import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
4
- import { makeTmpConfig } from '../helpers/tmp-config.js';
5
- import { ensureOrchestraDir, writeState, readState } from '../../src/state.js';
6
- import { makeInitialState } from '../../src/state.types.js';
7
- import { projectKeyFromPath } from '../../src/project-key.js';
8
- import { sessionExists, killSession, sessionName } from '../../src/tmux.js';
9
- import { musicianMcpConfigPath } from '../../src/mcp/config.js';
10
- import { readFileSync } from 'node:fs';
11
- import { join } from 'node:path';
12
- import { orchestraDir } from '../../src/config.js';
13
- import { MUSICIAN_ROLE_PROMPT_V1 } from '../../src/prompts/musician-role.js';
14
-
15
- describe('restoreOrchestra', () => {
16
- const cleanups: Array<() => Promise<void>> = [];
17
- const sessionsToKill: string[] = [];
18
-
19
- beforeEach(() => { process.env.NFO_HOME = ''; });
20
-
21
- afterEach(async () => {
22
- for (const s of sessionsToKill) {
23
- try { await killSession(s); } catch { /* ignore */ }
24
- }
25
- sessionsToKill.length = 0;
26
- for (const c of cleanups) await c();
27
- cleanups.length = 0;
28
- delete process.env.NFO_HOME;
29
- });
30
-
31
- it('throws when orchestra is unknown', async () => {
32
- const cfg = await makeTmpConfig();
33
- cleanups.push(cfg.cleanup);
34
- process.env.NFO_HOME = cfg.path;
35
- await expect(restoreOrchestra('nope-doesnt-exist')).rejects.toThrow(/Unknown orchestra/);
36
- });
37
-
38
- it('creates a fresh tmux session for an orchestra whose state exists but session is gone', async () => {
39
- const repo: TmpRepo = await makeTmpRepo();
40
- cleanups.push(repo.cleanup);
41
- const cfg = await makeTmpConfig();
42
- cleanups.push(cfg.cleanup);
43
- process.env.NFO_HOME = cfg.path;
44
-
45
- const orchestraId = projectKeyFromPath(repo.path);
46
- await ensureOrchestraDir(orchestraId);
47
- await writeState(orchestraId, makeInitialState({
48
- orchestraId,
49
- projectPath: repo.path,
50
- permissionLevel: 'supervised',
51
- }));
52
-
53
- sessionsToKill.push(sessionName(orchestraId));
54
-
55
- const result = await restoreOrchestra(orchestraId, true);
56
- expect(result.action).toBe('restored');
57
- expect(result.orchestraId).toBe(orchestraId);
58
- expect(await sessionExists(sessionName(orchestraId))).toBe(true);
59
- const { execa } = await import('execa');
60
- const { stdout: status } = await execa('tmux', [
61
- 'show-options', '-t', sessionName(orchestraId), 'status',
62
- ]);
63
- expect(status.trim()).toBe('status off');
64
- const { stdout: paneCount } = await execa('tmux', [
65
- 'list-panes', '-t', `${sessionName(orchestraId)}:0`, '-F', '#{pane_index}',
66
- ]);
67
- expect(paneCount.trim().split('\n').length).toBe(1);
68
- const { stdout: windows } = await execa('tmux', [
69
- 'list-windows', '-t', sessionName(orchestraId), '-F', '#{window_name}',
70
- ]);
71
- expect(windows).toContain('nfo-dashboard');
72
- });
73
-
74
- it('recreates windows for non-stopped musicians and updates their window id', async () => {
75
- const repo: TmpRepo = await makeTmpRepo();
76
- cleanups.push(repo.cleanup);
77
- const cfg = await makeTmpConfig();
78
- cleanups.push(cfg.cleanup);
79
- process.env.NFO_HOME = cfg.path;
80
-
81
- const orchestraId = projectKeyFromPath(repo.path);
82
- await ensureOrchestraDir(orchestraId);
83
- const initial = makeInitialState({
84
- orchestraId, projectPath: repo.path, permissionLevel: 'supervised',
85
- });
86
- initial.musicians.push({
87
- id: 'mus-001',
88
- name: 'tester',
89
- task_summary: 't',
90
- status: 'working',
91
- tmux_window_id: '@stale',
92
- claude_session_id: null,
93
- worktree_path: null,
94
- branch: null,
95
- spawned_at: '2026-05-29T10:00:00Z',
96
- last_activity: '2026-05-29T10:00:00Z',
97
- model: 'haiku',
98
- });
99
- await writeState(orchestraId, initial);
100
-
101
- sessionsToKill.push(sessionName(orchestraId));
102
-
103
- const result = await restoreOrchestra(orchestraId, true);
104
- expect(result.action).toBe('restored');
105
-
106
- const state = await readState(orchestraId);
107
- expect(state!.musicians[0].tmux_window_id).not.toBe('@stale');
108
- expect(state!.musicians[0].tmux_window_id).toMatch(/^@/);
109
- expect(state!.musicians[0].model).toBe('haiku');
110
-
111
- const musicianCfg = JSON.parse(readFileSync(musicianMcpConfigPath(orchestraId, 'mus-001'), 'utf8'));
112
- expect(musicianCfg.mcpServers.nfo.args).toEqual([
113
- 'mcp-server',
114
- '--orchestra-id',
115
- orchestraId,
116
- '--caller-musician-id',
117
- 'mus-001',
118
- ]);
119
-
120
- const musicianPrompt = readFileSync(
121
- join(orchestraDir(orchestraId), 'musician-mus-001-prompt.md'),
122
- 'utf8',
123
- );
124
- expect(musicianPrompt).toBe(MUSICIAN_ROLE_PROMPT_V1);
125
- });
126
- });
@@ -1,16 +0,0 @@
1
- import { mkdtemp, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- export interface TmpConfig {
6
- path: string;
7
- cleanup: () => Promise<void>;
8
- }
9
-
10
- export async function makeTmpConfig(): Promise<TmpConfig> {
11
- const path = await mkdtemp(join(tmpdir(), 'nfo-test-config-'));
12
- return {
13
- path,
14
- cleanup: () => rm(path, { recursive: true, force: true }),
15
- };
16
- }
@@ -1,29 +0,0 @@
1
- import { mkdtemp, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { execa } from 'execa';
5
-
6
- export interface TmpRepo {
7
- path: string;
8
- cleanup: () => Promise<void>;
9
- }
10
-
11
- export async function makeTmpRepo(): Promise<TmpRepo> {
12
- const path = await mkdtemp(join(tmpdir(), 'nfo-test-repo-'));
13
- await execa('git', ['init', '-q'], { cwd: path });
14
- await execa('git', ['config', 'user.email', 'test@test.local'], { cwd: path });
15
- await execa('git', ['config', 'user.name', 'Test'], { cwd: path });
16
- await execa('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: path });
17
- return {
18
- path,
19
- cleanup: () => rm(path, { recursive: true, force: true }),
20
- };
21
- }
22
-
23
- export async function makeTmpNonRepo(): Promise<TmpRepo> {
24
- const path = await mkdtemp(join(tmpdir(), 'nfo-test-norepo-'));
25
- return {
26
- path,
27
- cleanup: () => rm(path, { recursive: true, force: true }),
28
- };
29
- }
@@ -1,108 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { spawn } from 'node:child_process';
3
- import { resolve } from 'node:path';
4
- import { existsSync } from 'node:fs';
5
- import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
6
- import { makeTmpConfig } from '../helpers/tmp-config.js';
7
- import { ensureOrchestraDir, writeState, readState } from '../../src/state.js';
8
- import { makeInitialState } from '../../src/state.types.js';
9
- import { projectKeyFromPath } from '../../src/project-key.js';
10
- import { createDetachedSession, sessionName, killSession } from '../../src/tmux.js';
11
-
12
- const CLI = resolve(process.cwd(), 'dist/cli.js');
13
-
14
- describe('NFO MCP server (e2e)', () => {
15
- const cleanups: Array<() => Promise<void>> = [];
16
- const sessionsToKill: string[] = [];
17
-
18
- beforeEach(async () => {
19
- process.env.NFO_HOME = '';
20
- if (!existsSync(CLI)) {
21
- throw new Error(`dist/cli.js missing; run \`npm run build\` first`);
22
- }
23
- });
24
-
25
- afterEach(async () => {
26
- for (const s of sessionsToKill) {
27
- try { await killSession(s); } catch { /* ignore */ }
28
- }
29
- sessionsToKill.length = 0;
30
- for (const c of cleanups) await c();
31
- cleanups.length = 0;
32
- delete process.env.NFO_HOME;
33
- });
34
-
35
- it('lists 9 tools and dispatches spawn_musician via JSON-RPC', async () => {
36
- const cfg = await makeTmpConfig();
37
- cleanups.push(cfg.cleanup);
38
- process.env.NFO_HOME = cfg.path;
39
- const repo: TmpRepo = await makeTmpRepo();
40
- cleanups.push(repo.cleanup);
41
- const orchId = projectKeyFromPath(repo.path);
42
- await ensureOrchestraDir(orchId);
43
- await writeState(orchId, makeInitialState({
44
- orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
45
- }));
46
- sessionsToKill.push(sessionName(orchId));
47
- await createDetachedSession(sessionName(orchId), repo.path, 220, 50);
48
-
49
- const proc = spawn(
50
- process.execPath,
51
- [CLI, 'mcp-server', '--orchestra-id', orchId],
52
- { env: { ...process.env, NFO_HOME: cfg.path } },
53
- );
54
-
55
- const responses: Array<Record<string, unknown>> = [];
56
- let stdoutBuf = '';
57
- proc.stdout.on('data', (chunk: Buffer) => {
58
- stdoutBuf += chunk.toString();
59
- let idx: number;
60
- while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
61
- const line = stdoutBuf.slice(0, idx).trim();
62
- stdoutBuf = stdoutBuf.slice(idx + 1);
63
- if (line.length > 0) responses.push(JSON.parse(line));
64
- }
65
- });
66
-
67
- function send(msg: Record<string, unknown>) {
68
- proc.stdin.write(JSON.stringify(msg) + '\n');
69
- }
70
-
71
- send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {
72
- protocolVersion: '2024-11-05',
73
- capabilities: {},
74
- clientInfo: { name: 'test', version: '0' },
75
- }});
76
-
77
- await waitFor(() => responses.some(r => r.id === 1));
78
-
79
- send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
80
- await waitFor(() => responses.some(r => r.id === 2));
81
- const listResp = responses.find(r => r.id === 2) as any;
82
- expect(listResp.result.tools.length).toBe(9);
83
-
84
- send({
85
- jsonrpc: '2.0', id: 3, method: 'tools/call',
86
- params: {
87
- name: 'spawn_musician',
88
- arguments: { name: 'tester', task: 'echo it', worktree: false, model: 'haiku' },
89
- },
90
- });
91
- await waitFor(() => responses.some(r => r.id === 3));
92
-
93
- proc.kill();
94
-
95
- const state = await readState(orchId);
96
- expect(state!.musicians).toHaveLength(1);
97
- expect(state!.musicians[0].name).toBe('tester');
98
- expect(state!.musicians[0].model).toBe('haiku');
99
- }, 15000);
100
- });
101
-
102
- async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
103
- const start = Date.now();
104
- while (!predicate()) {
105
- if (Date.now() - start > timeoutMs) throw new Error('Timed out waiting');
106
- await new Promise(r => setTimeout(r, 25));
107
- }
108
- }
@@ -1,163 +0,0 @@
1
- import { describe, it, expect, afterEach, beforeEach } from 'vitest';
2
- import { dispatch } from '../../src/mcp/handlers.js';
3
- import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
4
- import { makeTmpConfig } from '../helpers/tmp-config.js';
5
- import { ensureOrchestraDir, writeState, readState } from '../../src/state.js';
6
- import { makeInitialState } from '../../src/state.types.js';
7
- import { projectKeyFromPath } from '../../src/project-key.js';
8
- import { createDetachedSession, sessionName, killSession } from '../../src/tmux.js';
9
-
10
- describe('MCP handlers dispatch', () => {
11
- const cleanups: Array<() => Promise<void>> = [];
12
- const sessionsToKill: string[] = [];
13
-
14
- beforeEach(() => { process.env.NFO_HOME = ''; });
15
-
16
- afterEach(async () => {
17
- for (const s of sessionsToKill) {
18
- try { await killSession(s); } catch { /* ignore */ }
19
- }
20
- sessionsToKill.length = 0;
21
- for (const c of cleanups) await c();
22
- cleanups.length = 0;
23
- delete process.env.NFO_HOME;
24
- });
25
-
26
- async function setup(): Promise<{orchId: string; repoPath: string}> {
27
- const cfg = await makeTmpConfig();
28
- cleanups.push(cfg.cleanup);
29
- process.env.NFO_HOME = cfg.path;
30
- const repo: TmpRepo = await makeTmpRepo();
31
- cleanups.push(repo.cleanup);
32
- const orchId = projectKeyFromPath(repo.path);
33
- await ensureOrchestraDir(orchId);
34
- await writeState(orchId, makeInitialState({
35
- orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
36
- }));
37
- sessionsToKill.push(sessionName(orchId));
38
- await createDetachedSession(sessionName(orchId), repo.path, 220, 50);
39
- return { orchId, repoPath: repo.path };
40
- }
41
-
42
- it('spawn_musician returns a musician_id', async () => {
43
- const { orchId } = await setup();
44
- const result = await dispatch(orchId, 'spawn_musician', {
45
- name: 'tester', task: 'do work', worktree: false, model: 'haiku',
46
- }, { dryRun: true });
47
- expect(result.musician_id).toMatch(/^mus-\d{3}$/);
48
- const state = await readState(orchId);
49
- expect(state!.musicians[0].model).toBe('haiku');
50
- });
51
-
52
- it('spawn_musician defaults to sonnet when model is omitted', async () => {
53
- const { orchId } = await setup();
54
- await dispatch(orchId, 'spawn_musician', {
55
- name: 'tester', task: 'do work', worktree: false,
56
- }, { dryRun: true });
57
- const state = await readState(orchId);
58
- expect(state!.musicians[0].model).toBe('sonnet');
59
- });
60
-
61
- it('list_musicians returns the live roster', async () => {
62
- const { orchId } = await setup();
63
- await dispatch(orchId, 'spawn_musician', { name: 'one', task: 't', worktree: false }, { dryRun: true });
64
- await dispatch(orchId, 'spawn_musician', { name: 'two', task: 't', worktree: false }, { dryRun: true });
65
- const result = await dispatch(orchId, 'list_musicians', {});
66
- expect(result.musicians).toHaveLength(2);
67
- });
68
-
69
- it('note_write / note_read round-trip', async () => {
70
- const { orchId } = await setup();
71
- await dispatch(orchId, 'note_write', { filename: 'overview.md', content: '# hi' });
72
- const result = await dispatch(orchId, 'note_read', { filename: 'overview.md' });
73
- expect(result.content).toBe('# hi');
74
- });
75
-
76
- it('report_done sets status to idle and records summary', async () => {
77
- const { orchId } = await setup();
78
- const { musician_id } = await dispatch(orchId, 'spawn_musician', {
79
- name: 'r', task: 't', worktree: false,
80
- }, { dryRun: true });
81
- const result = await dispatch(orchId, 'report_done', {
82
- summary: 'all green', _from_musician_id: musician_id,
83
- });
84
- expect(result.notified_orchestrator).toBe(true);
85
- const state = await readState(orchId);
86
- expect(state!.musicians[0].status).toBe('idle');
87
- expect(state!.musicians[0].latest_report).toMatchObject({
88
- summary: 'all green',
89
- next_steps: null,
90
- });
91
- });
92
-
93
- it('report_done drains queued follow-up work onto the musician', async () => {
94
- const { orchId, repoPath } = await setup();
95
- const name = sessionName(orchId);
96
- const { execa } = await import('execa');
97
- const { stdout: winId } = await execa('tmux', [
98
- 'new-window', '-t', name, '-n', 'mus-001-r', '-c', repoPath, '-d',
99
- '-P', '-F', '#{window_id}',
100
- ]);
101
-
102
- const { addMusician } = await import('../../src/state-updaters.js');
103
- const { capturePane } = await import('../../src/tmux.js');
104
- await addMusician(orchId, {
105
- id: 'mus-001',
106
- name: 'r',
107
- task_summary: 't',
108
- status: 'working',
109
- tmux_window_id: winId.trim(),
110
- claude_session_id: null,
111
- worktree_path: null,
112
- branch: null,
113
- spawned_at: '2026-05-29T10:00:00Z',
114
- last_activity: '2026-05-29T10:00:00Z',
115
- });
116
-
117
- const queued = await dispatch(orchId, 'message_musician', {
118
- musician_id: 'mus-001',
119
- message: 'echo queued-follow-up',
120
- });
121
- expect(queued.delivery).toBe('queued');
122
-
123
- const result = await dispatch(orchId, 'report_done', {
124
- summary: 'finished first task',
125
- _from_musician_id: 'mus-001',
126
- });
127
- expect(result.delivered_messages).toBe(1);
128
- expect(result.notified_orchestrator).toBe(false);
129
-
130
- await new Promise(r => setTimeout(r, 250));
131
- const out = await capturePane(`${name}:${winId.trim()}`, 20);
132
- expect(out).toContain('queued-follow-up');
133
-
134
- const state = await readState(orchId);
135
- expect(state!.musicians[0].status).toBe('working');
136
- });
137
-
138
- it('report_done pushes the completion summary back to the orchestrator pane', async () => {
139
- const { orchId } = await setup();
140
- const { capturePane } = await import('../../src/tmux.js');
141
- const { musician_id } = await dispatch(orchId, 'spawn_musician', {
142
- name: 'reviewer', task: 't', worktree: false,
143
- }, { dryRun: true });
144
-
145
- await dispatch(orchId, 'report_done', {
146
- summary: 'Implemented the requested change',
147
- next_steps: 'If needed, ask me to tighten the tests.',
148
- _from_musician_id: musician_id,
149
- });
150
-
151
- await new Promise(r => setTimeout(r, 250));
152
- const out = await capturePane(`${sessionName(orchId)}:0`, 80);
153
- expect(out).toContain(`Musician ${musician_id} (reviewer) reported done and is now idle.`);
154
- expect(out).toContain('Implemented the requested change');
155
- expect(out).toContain('dismiss_musician');
156
- expect(out).toContain('message_musician');
157
- });
158
-
159
- it('throws on unknown tool', async () => {
160
- const { orchId } = await setup();
161
- await expect(dispatch(orchId, 'totally_made_up', {})).rejects.toThrow(/Unknown tool/);
162
- });
163
- });
@@ -1,35 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { NFO_TOOLS } from '../../src/mcp/tool-defs.js';
3
-
4
- describe('NFO MCP tool definitions', () => {
5
- it('exposes the expected tool names', () => {
6
- const names = NFO_TOOLS.map(t => t.name).sort();
7
- expect(names).toEqual([
8
- 'dismiss_musician',
9
- 'list_musicians',
10
- 'message_musician',
11
- 'note_list',
12
- 'note_read',
13
- 'note_write',
14
- 'query_musician',
15
- 'report_done',
16
- 'spawn_musician',
17
- ]);
18
- });
19
-
20
- it('every tool has a non-empty description and inputSchema with type "object"', () => {
21
- for (const tool of NFO_TOOLS) {
22
- expect(tool.description.length).toBeGreaterThan(0);
23
- expect(tool.inputSchema.type).toBe('object');
24
- }
25
- });
26
-
27
- it('spawn_musician exposes the optional model choice', () => {
28
- const spawnTool = NFO_TOOLS.find((tool) => tool.name === 'spawn_musician');
29
- expect(spawnTool?.inputSchema.properties.model).toEqual({
30
- type: 'string',
31
- enum: ['sonnet', 'haiku'],
32
- description: 'Optional subagent model (defaults to sonnet).',
33
- });
34
- });
35
- });
@@ -1,102 +0,0 @@
1
- import { describe, it, expect, afterEach, beforeEach } from 'vitest';
2
- import { dismissMusician } from '../../src/musicians/dismiss.js';
3
- import { createMusician } from '../../src/musicians/spawn.js';
4
- import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
5
- import { makeTmpConfig } from '../helpers/tmp-config.js';
6
- import { ensureOrchestraDir, writeState, readState } from '../../src/state.js';
7
- import { makeInitialState } from '../../src/state.types.js';
8
- import { projectKeyFromPath } from '../../src/project-key.js';
9
- import { createDetachedSession, sessionName, killSession } from '../../src/tmux.js';
10
- import { existsSync } from 'node:fs';
11
-
12
- describe('dismissMusician', () => {
13
- const cleanups: Array<() => Promise<void>> = [];
14
- const sessionsToKill: string[] = [];
15
-
16
- beforeEach(() => { process.env.NFO_HOME = ''; });
17
-
18
- afterEach(async () => {
19
- for (const s of sessionsToKill) {
20
- try { await killSession(s); } catch { /* ignore */ }
21
- }
22
- sessionsToKill.length = 0;
23
- for (const c of cleanups) await c();
24
- cleanups.length = 0;
25
- delete process.env.NFO_HOME;
26
- });
27
-
28
- it('moves musician to archived_musicians and removes worktree (archive=false drops branch)', async () => {
29
- const cfg = await makeTmpConfig();
30
- cleanups.push(cfg.cleanup);
31
- process.env.NFO_HOME = cfg.path;
32
- const repo: TmpRepo = await makeTmpRepo();
33
- cleanups.push(repo.cleanup);
34
-
35
- const orchId = projectKeyFromPath(repo.path);
36
- await ensureOrchestraDir(orchId);
37
- await writeState(orchId, makeInitialState({
38
- orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
39
- }));
40
- const sess = sessionName(orchId);
41
- sessionsToKill.push(sess);
42
- await createDetachedSession(sess, repo.path, 220, 50);
43
-
44
- const spawn = await createMusician({
45
- orchestraId: orchId, name: 'tester', task: 'do stuff', dryRun: true,
46
- });
47
- expect(spawn.worktree_path).not.toBeNull();
48
- expect(existsSync(spawn.worktree_path!)).toBe(true);
49
-
50
- await dismissMusician({
51
- orchestraId: orchId,
52
- musicianId: spawn.musician_id,
53
- archiveWorktree: false,
54
- summary: 'rejected',
55
- });
56
-
57
- const state = await readState(orchId);
58
- expect(state!.musicians).toHaveLength(0);
59
- expect(state!.archived_musicians).toHaveLength(1);
60
- expect(state!.archived_musicians[0].summary).toBe('rejected');
61
- expect(existsSync(spawn.worktree_path!)).toBe(false);
62
- });
63
-
64
- it('defaults the archived summary from the latest idle report', async () => {
65
- const cfg = await makeTmpConfig();
66
- cleanups.push(cfg.cleanup);
67
- process.env.NFO_HOME = cfg.path;
68
- const repo: TmpRepo = await makeTmpRepo();
69
- cleanups.push(repo.cleanup);
70
-
71
- const orchId = projectKeyFromPath(repo.path);
72
- await ensureOrchestraDir(orchId);
73
- await writeState(orchId, makeInitialState({
74
- orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
75
- }));
76
- const sess = sessionName(orchId);
77
- sessionsToKill.push(sess);
78
- await createDetachedSession(sess, repo.path, 220, 50);
79
-
80
- const spawn = await createMusician({
81
- orchestraId: orchId, name: 'tester', task: 'do stuff', dryRun: true,
82
- });
83
-
84
- const stateBefore = await readState(orchId);
85
- stateBefore!.musicians[0].status = 'idle';
86
- stateBefore!.musicians[0].latest_report = {
87
- summary: 'ready to merge',
88
- next_steps: null,
89
- reported_at: '2026-05-29T11:00:00Z',
90
- };
91
- await writeState(orchId, stateBefore!);
92
-
93
- await dismissMusician({
94
- orchestraId: orchId,
95
- musicianId: spawn.musician_id,
96
- archiveWorktree: false,
97
- });
98
-
99
- const state = await readState(orchId);
100
- expect(state!.archived_musicians[0].summary).toBe('ready to merge');
101
- });
102
- });