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.
- package/dist/claude-command.js +6 -1
- package/dist/claude-command.js.map +1 -1
- package/dist/claude-trust.js +46 -0
- package/dist/claude-trust.js.map +1 -0
- package/dist/cli.js +5 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/attach.js +8 -8
- package/dist/commands/attach.js.map +1 -1
- package/dist/commands/launch.js +3 -6
- package/dist/commands/launch.js.map +1 -1
- package/dist/commands/restore.js +6 -10
- package/dist/commands/restore.js.map +1 -1
- package/dist/commands/tui.js +17 -1
- package/dist/commands/tui.js.map +1 -1
- package/dist/mcp/handlers.js +5 -0
- package/dist/mcp/handlers.js.map +1 -1
- package/dist/mcp/tool-defs.js +10 -0
- package/dist/mcp/tool-defs.js.map +1 -1
- package/dist/musicians/dismiss.js +1 -1
- package/dist/musicians/dismiss.js.map +1 -1
- package/dist/musicians/reconcile.js +27 -0
- package/dist/musicians/reconcile.js.map +1 -0
- package/dist/musicians/roles.js +15 -0
- package/dist/musicians/roles.js.map +1 -0
- package/dist/musicians/spawn.js +53 -18
- package/dist/musicians/spawn.js.map +1 -1
- package/dist/permission.js +6 -0
- package/dist/permission.js.map +1 -1
- package/dist/prompts/musician-role.js +2 -1
- package/dist/prompts/musician-role.js.map +1 -1
- package/dist/prompts/orchestrator-role.js +18 -6
- package/dist/prompts/orchestrator-role.js.map +1 -1
- package/dist/prompts/tool-discipline.js +7 -3
- package/dist/prompts/tool-discipline.js.map +1 -1
- package/dist/tmux.js +23 -0
- package/dist/tmux.js.map +1 -1
- package/dist/tui/components/App.js +8 -12
- package/dist/tui/components/App.js.map +1 -1
- package/dist/tui/components/Help.js +1 -1
- package/dist/tui/components/Help.js.map +1 -1
- package/dist/tui/keymap.js +1 -1
- package/dist/tui/keymap.js.map +1 -1
- package/package.json +18 -8
- package/assets/agent-screen.png +0 -0
- package/assets/main-screen.png +0 -0
- package/assets/orche-clawd.png +0 -0
- package/dist/tui/App.js +0 -428
- package/dist/tui/App.js.map +0 -1
- package/dist/tui/AppView.js +0 -13
- package/dist/tui/AppView.js.map +0 -1
- package/dist/tui/Auditorium.js +0 -17
- package/dist/tui/Auditorium.js.map +0 -1
- package/dist/tui/ConcertHall.js +0 -11
- package/dist/tui/ConcertHall.js.map +0 -1
- package/dist/tui/Help.js +0 -49
- package/dist/tui/Help.js.map +0 -1
- package/dist/tui/OrchestratorPane.js +0 -34
- package/dist/tui/OrchestratorPane.js.map +0 -1
- package/dist/tui/SidebarHeader.js +0 -6
- package/dist/tui/SidebarHeader.js.map +0 -1
- package/dist/tui/StatusBar.js +0 -6
- package/dist/tui/StatusBar.js.map +0 -1
- package/docs/plans/2026-05-29-nfo-phase-1-bootstrap.md +0 -2152
- package/docs/plans/2026-05-29-nfo-phase-2-mcp-musicians.md +0 -2467
- package/docs/plans/2026-05-29-nfo-phase-3-ink-tui.md +0 -1611
- package/docs/plans/2026-05-29-nfo-phase-4-permission-prompts.md +0 -460
- package/docs/plans/2026-05-29-nfo-phase-5-help-and-notify.md +0 -933
- package/docs/specs/2026-05-29-nfo-design.md +0 -468
- package/plan-explorer-musician-hardening.md +0 -56
- package/src/claude-command.ts +0 -35
- package/src/claude-detect.ts +0 -42
- package/src/cli.ts +0 -197
- package/src/commands/attach.ts +0 -24
- package/src/commands/dashboard-window.ts +0 -33
- package/src/commands/kill.ts +0 -50
- package/src/commands/launch.ts +0 -134
- package/src/commands/list.ts +0 -43
- package/src/commands/mcp-server.ts +0 -18
- package/src/commands/notes.ts +0 -18
- package/src/commands/restore.ts +0 -153
- package/src/commands/tui.tsx +0 -22
- package/src/config.ts +0 -44
- package/src/dashboard.ts +0 -1
- package/src/mcp/config.ts +0 -39
- package/src/mcp/handlers.ts +0 -141
- package/src/mcp/server.ts +0 -50
- package/src/mcp/tool-defs.ts +0 -151
- package/src/musicians/dismiss.ts +0 -60
- package/src/musicians/ids.ts +0 -21
- package/src/musicians/lookup.ts +0 -13
- package/src/musicians/message-log.ts +0 -152
- package/src/musicians/message.ts +0 -99
- package/src/musicians/query.ts +0 -19
- package/src/musicians/spawn.ts +0 -139
- package/src/notes.ts +0 -39
- package/src/notify.ts +0 -62
- package/src/orchestrator/report-back.ts +0 -33
- package/src/permission.ts +0 -30
- package/src/project-key.ts +0 -12
- package/src/prompts/musician-role.ts +0 -22
- package/src/prompts/orchestrator-role.ts +0 -84
- package/src/prompts/tool-discipline.ts +0 -41
- package/src/repo.ts +0 -14
- package/src/shell-quote.ts +0 -7
- package/src/state-updaters.ts +0 -132
- package/src/state.ts +0 -49
- package/src/state.types.ts +0 -67
- package/src/tmux.ts +0 -226
- package/src/tui/activity-line.ts +0 -16
- package/src/tui/components/App.tsx +0 -534
- package/src/tui/components/AppView.tsx +0 -98
- package/src/tui/components/Auditorium.tsx +0 -56
- package/src/tui/components/ConcertHall.tsx +0 -31
- package/src/tui/components/Help.tsx +0 -63
- package/src/tui/components/OrchestratorPane.tsx +0 -98
- package/src/tui/components/SidebarHeader.tsx +0 -34
- package/src/tui/components/StatusBar.tsx +0 -42
- package/src/tui/detect-permission.ts +0 -93
- package/src/tui/embedded-session-lifecycle.ts +0 -44
- package/src/tui/embedded-terminal.ts +0 -325
- package/src/tui/format-time.ts +0 -25
- package/src/tui/keymap.ts +0 -104
- package/src/tui/poll-activity.ts +0 -25
- package/src/tui/poll-idle.ts +0 -149
- package/src/tui/poll-permission.ts +0 -50
- package/src/tui/status-icon.ts +0 -35
- package/src/tui/terminal-input.ts +0 -136
- package/src/tui/watch-state.ts +0 -43
- package/src/worktree.ts +0 -41
- package/tests/claude-command.test.ts +0 -30
- package/tests/claude-detect.test.ts +0 -14
- package/tests/commands/attach.test.ts +0 -60
- package/tests/commands/kill.test.ts +0 -66
- package/tests/commands/launch.test.ts +0 -75
- package/tests/commands/list.test.ts +0 -47
- package/tests/commands/notes.test.ts +0 -53
- package/tests/commands/restore.test.ts +0 -126
- package/tests/helpers/tmp-config.ts +0 -16
- package/tests/helpers/tmp-repo.ts +0 -29
- package/tests/integration/orchestrator-spawn.test.ts +0 -108
- package/tests/mcp/handlers.test.ts +0 -163
- package/tests/mcp/tool-defs.test.ts +0 -35
- package/tests/musicians/dismiss.test.ts +0 -102
- package/tests/musicians/message.test.ts +0 -159
- package/tests/musicians/query.test.ts +0 -65
- package/tests/musicians/spawn.test.ts +0 -125
- package/tests/notes.test.ts +0 -56
- package/tests/notify.test.ts +0 -80
- package/tests/orchestrator/report-back.test.ts +0 -18
- package/tests/permission.test.ts +0 -39
- package/tests/project-key.test.ts +0 -33
- package/tests/prompts/tool-discipline.test.ts +0 -25
- package/tests/repo.test.ts +0 -38
- package/tests/state-updaters.test.ts +0 -126
- package/tests/state.test.ts +0 -85
- package/tests/tmux.test.ts +0 -126
- package/tests/tui/AppView.test.tsx +0 -92
- package/tests/tui/Auditorium.test.tsx +0 -67
- package/tests/tui/ConcertHall.test.tsx +0 -22
- package/tests/tui/Help.test.tsx +0 -38
- package/tests/tui/OrchestratorPane.test.ts +0 -30
- package/tests/tui/SidebarHeader.test.tsx +0 -20
- package/tests/tui/StatusBar.test.tsx +0 -51
- package/tests/tui/activity-line.test.ts +0 -21
- package/tests/tui/detect-permission.test.ts +0 -92
- package/tests/tui/embedded-session-lifecycle.test.ts +0 -55
- package/tests/tui/embedded-terminal.test.ts +0 -80
- package/tests/tui/format-time.test.ts +0 -25
- package/tests/tui/keymap.test.ts +0 -93
- package/tests/tui/poll-activity.test.ts +0 -81
- package/tests/tui/poll-idle.test.ts +0 -159
- package/tests/tui/poll-permission.test.ts +0 -222
- package/tests/tui/status-icon.test.ts +0 -27
- package/tests/tui/terminal-input.test.ts +0 -113
- package/tests/tui/watch-state.test.ts +0 -54
- package/tests/worktree.test.ts +0 -73
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -12
|
@@ -1,2152 +0,0 @@
|
|
|
1
|
-
# NFO Phase 1 — Bootstrap 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` CLI in a state where a user can run it in a git repo to create or attach to an Orchestra, see the Orchestrator's `claude` session running in a tmux pane, detach, re-attach, and tear down. No Musicians, no Ink TUI yet — those land in Phase 2 and Phase 3.
|
|
6
|
-
|
|
7
|
-
**Architecture:** A Node.js + TypeScript CLI installed globally as `nfo`. It detects git repos, computes a stable project key, manages per-orchestra state JSON under `~/.config/nfo/`, and choreographs tmux sessions via shell commands. The Orchestrator pane runs the user's installed `claude` CLI with our MCP-server-less prompt addendum.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** Node.js 20+, TypeScript 5+, commander (CLI parsing), proper-lockfile (file locking on state.json), execa (child process spawning), vitest (testing). No Ink and no @modelcontextprotocol/sdk in Phase 1 — they arrive in later phases.
|
|
10
|
-
|
|
11
|
-
**Reference spec:** `docs/specs/2026-05-29-nfo-design.md`. Sections relevant to Phase 1: §1, §2, §3.1, §3.2 (the main window/Orchestrator pane half), §3.3 (state.json ownership), §4.1, §4.2, §4.3, §4.4, §4.5, §4.7, §5.1, §5.2 (all four permission levels including `auto` with confirmation gate), §5.4 (prompt composition for the Orchestrator), §7.1, §7.2, §9 (tmux integration), §10 (CLI surface — `nfo`, `nfo <id>`, `nfo list`, `nfo kill <id>`, `nfo restore <id>`, `nfo notes <id>`), §11.
|
|
12
|
-
|
|
13
|
-
**Explicitly NOT in Phase 1 (must not creep in):**
|
|
14
|
-
- NFO MCP server and any of its tools (Phase 2)
|
|
15
|
-
- The Ink TUI side pane (Phase 3) — Phase 1 leaves the right pane empty or with a placeholder message
|
|
16
|
-
- Musician spawn/dismiss/message/query/report_done (Phase 2)
|
|
17
|
-
- Worktrees (Phase 2)
|
|
18
|
-
- Permission prompt detection from §5.2.1 (Phase 4)
|
|
19
|
-
- Notes-as-MCP-tools (Phase 2; in Phase 1, `nfo notes <id>` just opens the dir in $EDITOR — no reading by the Orchestrator yet)
|
|
20
|
-
- Concert Hall multi-orchestra UI (Phase 3)
|
|
21
|
-
|
|
22
|
-
---
|
|
23
|
-
|
|
24
|
-
## File Structure
|
|
25
|
-
|
|
26
|
-
```
|
|
27
|
-
package.json
|
|
28
|
-
tsconfig.json
|
|
29
|
-
vitest.config.ts
|
|
30
|
-
.gitignore
|
|
31
|
-
.npmrc # optional, sets engine strictness
|
|
32
|
-
README.md
|
|
33
|
-
src/
|
|
34
|
-
├── cli.ts # bin entry point — commander wiring
|
|
35
|
-
├── config.ts # paths + constants (~/.config/nfo, etc.)
|
|
36
|
-
├── project-key.ts # project key derivation from repo path
|
|
37
|
-
├── repo.ts # git repo detection + root resolution
|
|
38
|
-
├── claude-detect.ts # claude CLI version check
|
|
39
|
-
├── permission.ts # permission level types + claude flag mapping + auto gate
|
|
40
|
-
├── state.ts # state.json read/write with locking
|
|
41
|
-
├── state.types.ts # TypeScript types for state shape
|
|
42
|
-
├── tmux.ts # tmux command wrappers
|
|
43
|
-
├── prompts/
|
|
44
|
-
│ └── orchestrator-role.ts # the role addendum string (Phase 1 keeps it inline)
|
|
45
|
-
└── commands/
|
|
46
|
-
├── launch.ts # smart launch (no-args)
|
|
47
|
-
├── attach.ts # attach to a known id
|
|
48
|
-
├── restore.ts # restore stopped orchestra
|
|
49
|
-
├── list.ts # nfo list
|
|
50
|
-
├── kill.ts # nfo kill <id>
|
|
51
|
-
└── notes.ts # nfo notes <id>
|
|
52
|
-
tests/
|
|
53
|
-
├── project-key.test.ts
|
|
54
|
-
├── repo.test.ts
|
|
55
|
-
├── permission.test.ts
|
|
56
|
-
├── state.test.ts
|
|
57
|
-
├── tmux.test.ts
|
|
58
|
-
├── commands/
|
|
59
|
-
│ ├── launch.test.ts
|
|
60
|
-
│ └── list.test.ts
|
|
61
|
-
└── helpers/
|
|
62
|
-
├── tmp-repo.ts # creates a throwaway git repo + cleans up
|
|
63
|
-
└── tmp-config.ts # creates a throwaway ~/.config/nfo
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
|
|
68
|
-
## Task 1: Project skeleton
|
|
69
|
-
|
|
70
|
-
**Files:**
|
|
71
|
-
- Create: `package.json`
|
|
72
|
-
- Create: `tsconfig.json`
|
|
73
|
-
- Create: `vitest.config.ts`
|
|
74
|
-
- Create: `.gitignore`
|
|
75
|
-
- Create: `src/cli.ts`
|
|
76
|
-
|
|
77
|
-
- [ ] **Step 1: Create `package.json`**
|
|
78
|
-
|
|
79
|
-
```json
|
|
80
|
-
{
|
|
81
|
-
"name": "nfo-cli",
|
|
82
|
-
"version": "0.0.0",
|
|
83
|
-
"description": "NoFluffOrchestra — TUI multi-agent orchestrator for existing repos",
|
|
84
|
-
"type": "module",
|
|
85
|
-
"bin": {
|
|
86
|
-
"nfo": "./dist/cli.js"
|
|
87
|
-
},
|
|
88
|
-
"scripts": {
|
|
89
|
-
"build": "tsc",
|
|
90
|
-
"dev": "tsx src/cli.ts",
|
|
91
|
-
"test": "vitest run",
|
|
92
|
-
"test:watch": "vitest",
|
|
93
|
-
"typecheck": "tsc --noEmit"
|
|
94
|
-
},
|
|
95
|
-
"engines": {
|
|
96
|
-
"node": ">=20"
|
|
97
|
-
},
|
|
98
|
-
"dependencies": {
|
|
99
|
-
"commander": "^12.0.0",
|
|
100
|
-
"execa": "^9.0.0",
|
|
101
|
-
"proper-lockfile": "^4.1.2"
|
|
102
|
-
},
|
|
103
|
-
"devDependencies": {
|
|
104
|
-
"@types/node": "^20.0.0",
|
|
105
|
-
"@types/proper-lockfile": "^4.1.4",
|
|
106
|
-
"tsx": "^4.0.0",
|
|
107
|
-
"typescript": "^5.3.0",
|
|
108
|
-
"vitest": "^1.0.0"
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
- [ ] **Step 2: Create `tsconfig.json`**
|
|
114
|
-
|
|
115
|
-
```json
|
|
116
|
-
{
|
|
117
|
-
"compilerOptions": {
|
|
118
|
-
"target": "ES2022",
|
|
119
|
-
"module": "ESNext",
|
|
120
|
-
"moduleResolution": "Bundler",
|
|
121
|
-
"outDir": "./dist",
|
|
122
|
-
"rootDir": "./src",
|
|
123
|
-
"strict": true,
|
|
124
|
-
"esModuleInterop": true,
|
|
125
|
-
"skipLibCheck": true,
|
|
126
|
-
"forceConsistentCasingInFileNames": true,
|
|
127
|
-
"resolveJsonModule": true,
|
|
128
|
-
"declaration": false,
|
|
129
|
-
"sourceMap": true
|
|
130
|
-
},
|
|
131
|
-
"include": ["src/**/*"],
|
|
132
|
-
"exclude": ["node_modules", "dist", "tests"]
|
|
133
|
-
}
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
- [ ] **Step 3: Create `vitest.config.ts`**
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
import { defineConfig } from 'vitest/config';
|
|
140
|
-
|
|
141
|
-
export default defineConfig({
|
|
142
|
-
test: {
|
|
143
|
-
include: ['tests/**/*.test.ts'],
|
|
144
|
-
environment: 'node',
|
|
145
|
-
testTimeout: 10000,
|
|
146
|
-
},
|
|
147
|
-
});
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
- [ ] **Step 4: Create `.gitignore`**
|
|
151
|
-
|
|
152
|
-
```
|
|
153
|
-
node_modules/
|
|
154
|
-
dist/
|
|
155
|
-
.DS_Store
|
|
156
|
-
*.log
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
- [ ] **Step 5: Create `src/cli.ts` placeholder**
|
|
160
|
-
|
|
161
|
-
```typescript
|
|
162
|
-
#!/usr/bin/env node
|
|
163
|
-
import { Command } from 'commander';
|
|
164
|
-
|
|
165
|
-
const program = new Command();
|
|
166
|
-
program
|
|
167
|
-
.name('nfo')
|
|
168
|
-
.description('NoFluffOrchestra — TUI multi-agent orchestrator')
|
|
169
|
-
.version('0.0.0');
|
|
170
|
-
|
|
171
|
-
program.parse(process.argv);
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
- [ ] **Step 6: Install dependencies and verify build**
|
|
175
|
-
|
|
176
|
-
Run: `npm install`
|
|
177
|
-
Expected: completes without error, creates `node_modules`.
|
|
178
|
-
|
|
179
|
-
Run: `npm run build`
|
|
180
|
-
Expected: completes without error, produces `dist/cli.js`.
|
|
181
|
-
|
|
182
|
-
Run: `npm run typecheck`
|
|
183
|
-
Expected: no output (passes).
|
|
184
|
-
|
|
185
|
-
- [ ] **Step 7: Commit**
|
|
186
|
-
|
|
187
|
-
```bash
|
|
188
|
-
git add package.json package-lock.json tsconfig.json vitest.config.ts .gitignore src/cli.ts
|
|
189
|
-
git commit -m "chore: project skeleton (TypeScript + commander + vitest)"
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
---
|
|
193
|
-
|
|
194
|
-
## Task 2: Config paths and constants
|
|
195
|
-
|
|
196
|
-
**Files:**
|
|
197
|
-
- Create: `src/config.ts`
|
|
198
|
-
|
|
199
|
-
- [ ] **Step 1: Create `src/config.ts`**
|
|
200
|
-
|
|
201
|
-
```typescript
|
|
202
|
-
import { homedir } from 'node:os';
|
|
203
|
-
import { join } from 'node:path';
|
|
204
|
-
|
|
205
|
-
export const NFO_HOME = process.env.NFO_HOME ?? join(homedir(), '.config', 'nfo');
|
|
206
|
-
export const PROJECTS_DIR = join(NFO_HOME, 'projects');
|
|
207
|
-
export const GLOBAL_CONFIG_FILE = join(NFO_HOME, 'config.json');
|
|
208
|
-
|
|
209
|
-
export const STATE_VERSION = 1;
|
|
210
|
-
export const STATE_FILENAME = 'state.json';
|
|
211
|
-
export const NOTES_DIRNAME = 'notes';
|
|
212
|
-
export const LOGS_DIRNAME = 'logs';
|
|
213
|
-
export const WORKTREES_DIRNAME = 'worktrees';
|
|
214
|
-
export const ARCHIVE_DIRNAME = 'archive';
|
|
215
|
-
|
|
216
|
-
export const orchestraDir = (projectKey: string): string =>
|
|
217
|
-
join(PROJECTS_DIR, projectKey);
|
|
218
|
-
|
|
219
|
-
export const stateFile = (projectKey: string): string =>
|
|
220
|
-
join(orchestraDir(projectKey), STATE_FILENAME);
|
|
221
|
-
|
|
222
|
-
export const notesDir = (projectKey: string): string =>
|
|
223
|
-
join(orchestraDir(projectKey), NOTES_DIRNAME);
|
|
224
|
-
|
|
225
|
-
export const logsDir = (projectKey: string): string =>
|
|
226
|
-
join(orchestraDir(projectKey), LOGS_DIRNAME);
|
|
227
|
-
|
|
228
|
-
export const worktreesDir = (projectKey: string): string =>
|
|
229
|
-
join(orchestraDir(projectKey), WORKTREES_DIRNAME);
|
|
230
|
-
|
|
231
|
-
export const archiveDir = (projectKey: string): string =>
|
|
232
|
-
join(orchestraDir(projectKey), ARCHIVE_DIRNAME);
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
Note: `NFO_HOME` env var override is for testing — production users get `~/.config/nfo`.
|
|
236
|
-
|
|
237
|
-
- [ ] **Step 2: Run typecheck**
|
|
238
|
-
|
|
239
|
-
Run: `npm run typecheck`
|
|
240
|
-
Expected: passes.
|
|
241
|
-
|
|
242
|
-
- [ ] **Step 3: Commit**
|
|
243
|
-
|
|
244
|
-
```bash
|
|
245
|
-
git add src/config.ts
|
|
246
|
-
git commit -m "feat(config): paths and dir helpers for ~/.config/nfo layout"
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
---
|
|
250
|
-
|
|
251
|
-
## Task 3: Project key derivation
|
|
252
|
-
|
|
253
|
-
**Files:**
|
|
254
|
-
- Create: `tests/project-key.test.ts`
|
|
255
|
-
- Create: `src/project-key.ts`
|
|
256
|
-
|
|
257
|
-
- [ ] **Step 1: Write the failing test**
|
|
258
|
-
|
|
259
|
-
`tests/project-key.test.ts`:
|
|
260
|
-
|
|
261
|
-
```typescript
|
|
262
|
-
import { describe, it, expect } from 'vitest';
|
|
263
|
-
import { projectKeyFromPath } from '../src/project-key.js';
|
|
264
|
-
|
|
265
|
-
describe('projectKeyFromPath', () => {
|
|
266
|
-
it('produces a stable key for a given absolute path', () => {
|
|
267
|
-
const key1 = projectKeyFromPath('/home/user/projects/myrepo');
|
|
268
|
-
const key2 = projectKeyFromPath('/home/user/projects/myrepo');
|
|
269
|
-
expect(key1).toBe(key2);
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it('includes the basename as a readable suffix', () => {
|
|
273
|
-
const key = projectKeyFromPath('/home/user/projects/myrepo');
|
|
274
|
-
expect(key.endsWith('-myrepo')).toBe(true);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it('produces a 10-char sha1 prefix', () => {
|
|
278
|
-
const key = projectKeyFromPath('/home/user/projects/myrepo');
|
|
279
|
-
const prefix = key.split('-')[0];
|
|
280
|
-
expect(prefix).toHaveLength(10);
|
|
281
|
-
expect(prefix).toMatch(/^[0-9a-f]{10}$/);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it('produces different keys for different paths', () => {
|
|
285
|
-
const a = projectKeyFromPath('/home/user/projects/foo');
|
|
286
|
-
const b = projectKeyFromPath('/home/user/projects/bar');
|
|
287
|
-
expect(a).not.toBe(b);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it('sanitizes non-alphanumeric basename characters', () => {
|
|
291
|
-
const key = projectKeyFromPath('/tmp/my repo with spaces');
|
|
292
|
-
expect(key).toMatch(/^[0-9a-f]{10}-[a-z0-9-]+$/);
|
|
293
|
-
});
|
|
294
|
-
});
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
298
|
-
|
|
299
|
-
Run: `npm test -- project-key`
|
|
300
|
-
Expected: FAIL (module not found).
|
|
301
|
-
|
|
302
|
-
- [ ] **Step 3: Implement `src/project-key.ts`**
|
|
303
|
-
|
|
304
|
-
```typescript
|
|
305
|
-
import { createHash } from 'node:crypto';
|
|
306
|
-
import { basename } from 'node:path';
|
|
307
|
-
|
|
308
|
-
export function projectKeyFromPath(absolutePath: string): string {
|
|
309
|
-
const hash = createHash('sha1').update(absolutePath).digest('hex').slice(0, 10);
|
|
310
|
-
const name = basename(absolutePath)
|
|
311
|
-
.toLowerCase()
|
|
312
|
-
.replace(/[^a-z0-9-]+/g, '-')
|
|
313
|
-
.replace(/-+/g, '-')
|
|
314
|
-
.replace(/^-|-$/g, '');
|
|
315
|
-
return `${hash}-${name || 'project'}`;
|
|
316
|
-
}
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
320
|
-
|
|
321
|
-
Run: `npm test -- project-key`
|
|
322
|
-
Expected: PASS, all 5 tests green.
|
|
323
|
-
|
|
324
|
-
- [ ] **Step 5: Commit**
|
|
325
|
-
|
|
326
|
-
```bash
|
|
327
|
-
git add src/project-key.ts tests/project-key.test.ts
|
|
328
|
-
git commit -m "feat(project-key): derive stable per-repo orchestra key"
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
---
|
|
332
|
-
|
|
333
|
-
## Task 4: Git repo detection
|
|
334
|
-
|
|
335
|
-
**Files:**
|
|
336
|
-
- Create: `tests/helpers/tmp-repo.ts`
|
|
337
|
-
- Create: `tests/repo.test.ts`
|
|
338
|
-
- Create: `src/repo.ts`
|
|
339
|
-
|
|
340
|
-
- [ ] **Step 1: Create the tmp-repo test helper**
|
|
341
|
-
|
|
342
|
-
`tests/helpers/tmp-repo.ts`:
|
|
343
|
-
|
|
344
|
-
```typescript
|
|
345
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
346
|
-
import { tmpdir } from 'node:os';
|
|
347
|
-
import { join } from 'node:path';
|
|
348
|
-
import { execa } from 'execa';
|
|
349
|
-
|
|
350
|
-
export interface TmpRepo {
|
|
351
|
-
path: string;
|
|
352
|
-
cleanup: () => Promise<void>;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
export async function makeTmpRepo(): Promise<TmpRepo> {
|
|
356
|
-
const path = await mkdtemp(join(tmpdir(), 'nfo-test-repo-'));
|
|
357
|
-
await execa('git', ['init', '-q'], { cwd: path });
|
|
358
|
-
await execa('git', ['config', 'user.email', 'test@test.local'], { cwd: path });
|
|
359
|
-
await execa('git', ['config', 'user.name', 'Test'], { cwd: path });
|
|
360
|
-
await execa('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: path });
|
|
361
|
-
return {
|
|
362
|
-
path,
|
|
363
|
-
cleanup: () => rm(path, { recursive: true, force: true }),
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export async function makeTmpNonRepo(): Promise<TmpRepo> {
|
|
368
|
-
const path = await mkdtemp(join(tmpdir(), 'nfo-test-norepo-'));
|
|
369
|
-
return {
|
|
370
|
-
path,
|
|
371
|
-
cleanup: () => rm(path, { recursive: true, force: true }),
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
- [ ] **Step 2: Write the failing test**
|
|
377
|
-
|
|
378
|
-
`tests/repo.test.ts`:
|
|
379
|
-
|
|
380
|
-
```typescript
|
|
381
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
382
|
-
import { resolveRepoRoot } from '../src/repo.js';
|
|
383
|
-
import { makeTmpRepo, makeTmpNonRepo, type TmpRepo } from './helpers/tmp-repo.js';
|
|
384
|
-
import { mkdir } from 'node:fs/promises';
|
|
385
|
-
import { join } from 'node:path';
|
|
386
|
-
|
|
387
|
-
describe('resolveRepoRoot', () => {
|
|
388
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
389
|
-
afterEach(async () => {
|
|
390
|
-
for (const c of cleanups) await c();
|
|
391
|
-
cleanups.length = 0;
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
async function track(t: TmpRepo) {
|
|
395
|
-
cleanups.push(t.cleanup);
|
|
396
|
-
return t;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
it('returns the repo root when invoked from inside a repo', async () => {
|
|
400
|
-
const repo = await track(await makeTmpRepo());
|
|
401
|
-
const result = await resolveRepoRoot(repo.path);
|
|
402
|
-
expect(result).toBe(repo.path);
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
it('returns the repo root when invoked from a subdirectory', async () => {
|
|
406
|
-
const repo = await track(await makeTmpRepo());
|
|
407
|
-
const subdir = join(repo.path, 'src', 'nested');
|
|
408
|
-
await mkdir(subdir, { recursive: true });
|
|
409
|
-
const result = await resolveRepoRoot(subdir);
|
|
410
|
-
expect(result).toBe(repo.path);
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
it('returns null when invoked outside any repo', async () => {
|
|
414
|
-
const nonRepo = await track(await makeTmpNonRepo());
|
|
415
|
-
const result = await resolveRepoRoot(nonRepo.path);
|
|
416
|
-
expect(result).toBeNull();
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
- [ ] **Step 3: Run test to verify it fails**
|
|
422
|
-
|
|
423
|
-
Run: `npm test -- repo`
|
|
424
|
-
Expected: FAIL.
|
|
425
|
-
|
|
426
|
-
- [ ] **Step 4: Implement `src/repo.ts`**
|
|
427
|
-
|
|
428
|
-
```typescript
|
|
429
|
-
import { execa } from 'execa';
|
|
430
|
-
|
|
431
|
-
export async function resolveRepoRoot(cwd: string): Promise<string | null> {
|
|
432
|
-
try {
|
|
433
|
-
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], {
|
|
434
|
-
cwd,
|
|
435
|
-
reject: false,
|
|
436
|
-
});
|
|
437
|
-
const trimmed = stdout.trim();
|
|
438
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
439
|
-
} catch {
|
|
440
|
-
return null;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
Note: on macOS some git versions resolve `/tmp` differently to `/private/tmp`. If tests fail on macOS due to symlink resolution, normalize both sides with `fs.realpath` before comparing. Add that to the implementation only if a real failure surfaces — YAGNI in Linux CI.
|
|
446
|
-
|
|
447
|
-
- [ ] **Step 5: Run test to verify it passes**
|
|
448
|
-
|
|
449
|
-
Run: `npm test -- repo`
|
|
450
|
-
Expected: PASS.
|
|
451
|
-
|
|
452
|
-
- [ ] **Step 6: Commit**
|
|
453
|
-
|
|
454
|
-
```bash
|
|
455
|
-
git add src/repo.ts tests/repo.test.ts tests/helpers/tmp-repo.ts
|
|
456
|
-
git commit -m "feat(repo): resolve repo root via git rev-parse"
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
---
|
|
460
|
-
|
|
461
|
-
## Task 5: Permission level types and flag mapping
|
|
462
|
-
|
|
463
|
-
**Files:**
|
|
464
|
-
- Create: `tests/permission.test.ts`
|
|
465
|
-
- Create: `src/permission.ts`
|
|
466
|
-
|
|
467
|
-
- [ ] **Step 1: Write the failing test**
|
|
468
|
-
|
|
469
|
-
`tests/permission.test.ts`:
|
|
470
|
-
|
|
471
|
-
```typescript
|
|
472
|
-
import { describe, it, expect } from 'vitest';
|
|
473
|
-
import {
|
|
474
|
-
PERMISSION_LEVELS,
|
|
475
|
-
claudeFlagsForLevel,
|
|
476
|
-
isPermissionLevel,
|
|
477
|
-
type PermissionLevel,
|
|
478
|
-
} from '../src/permission.js';
|
|
479
|
-
|
|
480
|
-
describe('permission levels', () => {
|
|
481
|
-
it('lists all four levels in order from most to least permissive', () => {
|
|
482
|
-
expect(PERMISSION_LEVELS).toEqual(['auto', 'autonomous', 'supervised', 'strict']);
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
it('isPermissionLevel rejects unknown strings', () => {
|
|
486
|
-
expect(isPermissionLevel('auto')).toBe(true);
|
|
487
|
-
expect(isPermissionLevel('autonomous')).toBe(true);
|
|
488
|
-
expect(isPermissionLevel('supervised')).toBe(true);
|
|
489
|
-
expect(isPermissionLevel('strict')).toBe(true);
|
|
490
|
-
expect(isPermissionLevel('YOLO')).toBe(false);
|
|
491
|
-
expect(isPermissionLevel('')).toBe(false);
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
it('claudeFlagsForLevel returns the right flag list per level', () => {
|
|
495
|
-
expect(claudeFlagsForLevel('auto')).toEqual(['--dangerously-skip-permissions']);
|
|
496
|
-
expect(claudeFlagsForLevel('autonomous')).toEqual(['--permission-mode', 'acceptEdits']);
|
|
497
|
-
expect(claudeFlagsForLevel('supervised')).toEqual(['--permission-mode', 'default']);
|
|
498
|
-
expect(claudeFlagsForLevel('strict')).toEqual(['--permission-mode', 'plan']);
|
|
499
|
-
});
|
|
500
|
-
});
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
504
|
-
|
|
505
|
-
Run: `npm test -- permission`
|
|
506
|
-
Expected: FAIL.
|
|
507
|
-
|
|
508
|
-
- [ ] **Step 3: Implement `src/permission.ts`**
|
|
509
|
-
|
|
510
|
-
```typescript
|
|
511
|
-
export const PERMISSION_LEVELS = ['auto', 'autonomous', 'supervised', 'strict'] as const;
|
|
512
|
-
export type PermissionLevel = (typeof PERMISSION_LEVELS)[number];
|
|
513
|
-
|
|
514
|
-
export function isPermissionLevel(s: string): s is PermissionLevel {
|
|
515
|
-
return (PERMISSION_LEVELS as readonly string[]).includes(s);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
export function claudeFlagsForLevel(level: PermissionLevel): string[] {
|
|
519
|
-
switch (level) {
|
|
520
|
-
case 'auto':
|
|
521
|
-
// Spec §5.2 + §12.2 open question: exact bypass flag is `--dangerously-skip-permissions`
|
|
522
|
-
// in current Claude Code releases. If a future release renames it, update here.
|
|
523
|
-
return ['--dangerously-skip-permissions'];
|
|
524
|
-
case 'autonomous':
|
|
525
|
-
return ['--permission-mode', 'acceptEdits'];
|
|
526
|
-
case 'supervised':
|
|
527
|
-
return ['--permission-mode', 'default'];
|
|
528
|
-
case 'strict':
|
|
529
|
-
return ['--permission-mode', 'plan'];
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
export const AUTO_CONFIRM_PHRASE = 'I understand';
|
|
534
|
-
|
|
535
|
-
export const AUTO_WARNING = `⚠ AUTO mode disables all permission checks.
|
|
536
|
-
Musicians can execute arbitrary shell commands, modify files anywhere on
|
|
537
|
-
this system, and access the network without asking. Worktrees limit but
|
|
538
|
-
do not contain risky operations. Use this only in trusted sandboxes or
|
|
539
|
-
when you accept these risks.
|
|
540
|
-
Type "${AUTO_CONFIRM_PHRASE}" to continue.`;
|
|
541
|
-
```
|
|
542
|
-
|
|
543
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
544
|
-
|
|
545
|
-
Run: `npm test -- permission`
|
|
546
|
-
Expected: PASS.
|
|
547
|
-
|
|
548
|
-
- [ ] **Step 5: Commit**
|
|
549
|
-
|
|
550
|
-
```bash
|
|
551
|
-
git add src/permission.ts tests/permission.test.ts
|
|
552
|
-
git commit -m "feat(permission): four-level enum and claude flag mapping"
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
---
|
|
556
|
-
|
|
557
|
-
## Task 6: State types
|
|
558
|
-
|
|
559
|
-
**Files:**
|
|
560
|
-
- Create: `src/state.types.ts`
|
|
561
|
-
|
|
562
|
-
- [ ] **Step 1: Create `src/state.types.ts`**
|
|
563
|
-
|
|
564
|
-
```typescript
|
|
565
|
-
import type { PermissionLevel } from './permission.js';
|
|
566
|
-
|
|
567
|
-
export type MusicianStatus = 'working' | 'idle' | 'awaiting_permission' | 'stopped';
|
|
568
|
-
|
|
569
|
-
export interface Musician {
|
|
570
|
-
id: string;
|
|
571
|
-
name: string;
|
|
572
|
-
task_summary: string;
|
|
573
|
-
status: MusicianStatus;
|
|
574
|
-
pending_permission?: string | null;
|
|
575
|
-
tmux_window_id: string;
|
|
576
|
-
claude_session_id: string | null;
|
|
577
|
-
worktree_path: string | null;
|
|
578
|
-
branch: string | null;
|
|
579
|
-
spawned_at: string;
|
|
580
|
-
last_activity: string;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
export interface ArchivedMusician extends Musician {
|
|
584
|
-
dismissed_at: string;
|
|
585
|
-
summary: string | null;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
export interface OrchestraState {
|
|
589
|
-
version: number;
|
|
590
|
-
orchestra_id: string;
|
|
591
|
-
project_path: string;
|
|
592
|
-
created_at: string;
|
|
593
|
-
permission_level: PermissionLevel;
|
|
594
|
-
orchestrator_session_id: string | null;
|
|
595
|
-
musicians: Musician[];
|
|
596
|
-
archived_musicians: ArchivedMusician[];
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
export function makeInitialState(args: {
|
|
600
|
-
orchestraId: string;
|
|
601
|
-
projectPath: string;
|
|
602
|
-
permissionLevel: PermissionLevel;
|
|
603
|
-
}): OrchestraState {
|
|
604
|
-
const now = new Date().toISOString();
|
|
605
|
-
return {
|
|
606
|
-
version: 1,
|
|
607
|
-
orchestra_id: args.orchestraId,
|
|
608
|
-
project_path: args.projectPath,
|
|
609
|
-
created_at: now,
|
|
610
|
-
permission_level: args.permissionLevel,
|
|
611
|
-
orchestrator_session_id: null,
|
|
612
|
-
musicians: [],
|
|
613
|
-
archived_musicians: [],
|
|
614
|
-
};
|
|
615
|
-
}
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
- [ ] **Step 2: Run typecheck**
|
|
619
|
-
|
|
620
|
-
Run: `npm run typecheck`
|
|
621
|
-
Expected: passes.
|
|
622
|
-
|
|
623
|
-
- [ ] **Step 3: Commit**
|
|
624
|
-
|
|
625
|
-
```bash
|
|
626
|
-
git add src/state.types.ts
|
|
627
|
-
git commit -m "feat(state): TS types for orchestra and musician state"
|
|
628
|
-
```
|
|
629
|
-
|
|
630
|
-
---
|
|
631
|
-
|
|
632
|
-
## Task 7: State read/write with locking
|
|
633
|
-
|
|
634
|
-
**Files:**
|
|
635
|
-
- Create: `tests/helpers/tmp-config.ts`
|
|
636
|
-
- Create: `tests/state.test.ts`
|
|
637
|
-
- Create: `src/state.ts`
|
|
638
|
-
|
|
639
|
-
- [ ] **Step 1: Create the tmp-config helper**
|
|
640
|
-
|
|
641
|
-
`tests/helpers/tmp-config.ts`:
|
|
642
|
-
|
|
643
|
-
```typescript
|
|
644
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
645
|
-
import { tmpdir } from 'node:os';
|
|
646
|
-
import { join } from 'node:path';
|
|
647
|
-
|
|
648
|
-
export interface TmpConfig {
|
|
649
|
-
path: string;
|
|
650
|
-
cleanup: () => Promise<void>;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
export async function makeTmpConfig(): Promise<TmpConfig> {
|
|
654
|
-
const path = await mkdtemp(join(tmpdir(), 'nfo-test-config-'));
|
|
655
|
-
return {
|
|
656
|
-
path,
|
|
657
|
-
cleanup: () => rm(path, { recursive: true, force: true }),
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
```
|
|
661
|
-
|
|
662
|
-
- [ ] **Step 2: Write the failing test**
|
|
663
|
-
|
|
664
|
-
`tests/state.test.ts`:
|
|
665
|
-
|
|
666
|
-
```typescript
|
|
667
|
-
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
668
|
-
import { readState, writeState, ensureOrchestraDir } from '../src/state.js';
|
|
669
|
-
import { makeInitialState } from '../src/state.types.js';
|
|
670
|
-
import { makeTmpConfig } from './helpers/tmp-config.js';
|
|
671
|
-
import { existsSync } from 'node:fs';
|
|
672
|
-
import { join } from 'node:path';
|
|
673
|
-
|
|
674
|
-
describe('state read/write', () => {
|
|
675
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
676
|
-
|
|
677
|
-
beforeEach(() => {
|
|
678
|
-
process.env.NFO_HOME = '';
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
afterEach(async () => {
|
|
682
|
-
for (const c of cleanups) await c();
|
|
683
|
-
cleanups.length = 0;
|
|
684
|
-
delete process.env.NFO_HOME;
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
it('writes and reads back the orchestra state round-trip', async () => {
|
|
688
|
-
const tmp = await makeTmpConfig();
|
|
689
|
-
cleanups.push(tmp.cleanup);
|
|
690
|
-
process.env.NFO_HOME = tmp.path;
|
|
691
|
-
|
|
692
|
-
const state = makeInitialState({
|
|
693
|
-
orchestraId: 'abc123-test',
|
|
694
|
-
projectPath: '/tmp/example',
|
|
695
|
-
permissionLevel: 'supervised',
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
await ensureOrchestraDir('abc123-test');
|
|
699
|
-
await writeState('abc123-test', state);
|
|
700
|
-
|
|
701
|
-
const loaded = await readState('abc123-test');
|
|
702
|
-
expect(loaded).not.toBeNull();
|
|
703
|
-
expect(loaded!.orchestra_id).toBe('abc123-test');
|
|
704
|
-
expect(loaded!.permission_level).toBe('supervised');
|
|
705
|
-
expect(loaded!.musicians).toEqual([]);
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
it('returns null when no state exists for the given key', async () => {
|
|
709
|
-
const tmp = await makeTmpConfig();
|
|
710
|
-
cleanups.push(tmp.cleanup);
|
|
711
|
-
process.env.NFO_HOME = tmp.path;
|
|
712
|
-
|
|
713
|
-
const loaded = await readState('does-not-exist');
|
|
714
|
-
expect(loaded).toBeNull();
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
it('ensureOrchestraDir creates the standard subdirectory layout', async () => {
|
|
718
|
-
const tmp = await makeTmpConfig();
|
|
719
|
-
cleanups.push(tmp.cleanup);
|
|
720
|
-
process.env.NFO_HOME = tmp.path;
|
|
721
|
-
|
|
722
|
-
await ensureOrchestraDir('abc123-test');
|
|
723
|
-
const base = join(tmp.path, 'projects', 'abc123-test');
|
|
724
|
-
expect(existsSync(base)).toBe(true);
|
|
725
|
-
expect(existsSync(join(base, 'notes'))).toBe(true);
|
|
726
|
-
expect(existsSync(join(base, 'logs'))).toBe(true);
|
|
727
|
-
expect(existsSync(join(base, 'worktrees'))).toBe(true);
|
|
728
|
-
expect(existsSync(join(base, 'archive'))).toBe(true);
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
it('serial writes leave a complete file (atomic rename)', async () => {
|
|
732
|
-
const tmp = await makeTmpConfig();
|
|
733
|
-
cleanups.push(tmp.cleanup);
|
|
734
|
-
process.env.NFO_HOME = tmp.path;
|
|
735
|
-
|
|
736
|
-
const state = makeInitialState({
|
|
737
|
-
orchestraId: 'serial-test',
|
|
738
|
-
projectPath: '/tmp/example',
|
|
739
|
-
permissionLevel: 'autonomous',
|
|
740
|
-
});
|
|
741
|
-
await ensureOrchestraDir('serial-test');
|
|
742
|
-
|
|
743
|
-
// Hammer writes serially; each must produce a valid file on disk.
|
|
744
|
-
for (let i = 0; i < 20; i++) {
|
|
745
|
-
state.orchestrator_session_id = `session-${i}`;
|
|
746
|
-
await writeState('serial-test', state);
|
|
747
|
-
const loaded = await readState('serial-test');
|
|
748
|
-
expect(loaded!.orchestrator_session_id).toBe(`session-${i}`);
|
|
749
|
-
}
|
|
750
|
-
});
|
|
751
|
-
});
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
Note: a concurrent-write race test is feasible but flaky to write reliably. `proper-lockfile` is well-trusted; relying on the serial test plus the library is acceptable for v1.
|
|
755
|
-
|
|
756
|
-
- [ ] **Step 3: Run test to verify it fails**
|
|
757
|
-
|
|
758
|
-
Run: `npm test -- state`
|
|
759
|
-
Expected: FAIL (module not found).
|
|
760
|
-
|
|
761
|
-
- [ ] **Step 4: Implement `src/state.ts`**
|
|
762
|
-
|
|
763
|
-
```typescript
|
|
764
|
-
import { mkdir, readFile, rename, writeFile, unlink } from 'node:fs/promises';
|
|
765
|
-
import { existsSync } from 'node:fs';
|
|
766
|
-
import { join, dirname } from 'node:path';
|
|
767
|
-
import lockfile from 'proper-lockfile';
|
|
768
|
-
import {
|
|
769
|
-
notesDir,
|
|
770
|
-
logsDir,
|
|
771
|
-
worktreesDir,
|
|
772
|
-
archiveDir,
|
|
773
|
-
stateFile,
|
|
774
|
-
orchestraDir,
|
|
775
|
-
} from './config.js';
|
|
776
|
-
import type { OrchestraState } from './state.types.js';
|
|
777
|
-
|
|
778
|
-
export async function ensureOrchestraDir(projectKey: string): Promise<void> {
|
|
779
|
-
await mkdir(orchestraDir(projectKey), { recursive: true });
|
|
780
|
-
await mkdir(notesDir(projectKey), { recursive: true });
|
|
781
|
-
await mkdir(logsDir(projectKey), { recursive: true });
|
|
782
|
-
await mkdir(worktreesDir(projectKey), { recursive: true });
|
|
783
|
-
await mkdir(archiveDir(projectKey), { recursive: true });
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
export async function readState(projectKey: string): Promise<OrchestraState | null> {
|
|
787
|
-
const file = stateFile(projectKey);
|
|
788
|
-
if (!existsSync(file)) return null;
|
|
789
|
-
const buf = await readFile(file, 'utf8');
|
|
790
|
-
return JSON.parse(buf) as OrchestraState;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
export async function writeState(projectKey: string, state: OrchestraState): Promise<void> {
|
|
794
|
-
const file = stateFile(projectKey);
|
|
795
|
-
await mkdir(dirname(file), { recursive: true });
|
|
796
|
-
|
|
797
|
-
// proper-lockfile needs the target file to exist before it can lock it.
|
|
798
|
-
if (!existsSync(file)) {
|
|
799
|
-
await writeFile(file, '{}', 'utf8');
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
const release = await lockfile.lock(file, { retries: { retries: 5, minTimeout: 50 } });
|
|
803
|
-
try {
|
|
804
|
-
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
|
|
805
|
-
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
806
|
-
await rename(tmp, file);
|
|
807
|
-
} finally {
|
|
808
|
-
await release();
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
```
|
|
812
|
-
|
|
813
|
-
- [ ] **Step 5: Run test to verify it passes**
|
|
814
|
-
|
|
815
|
-
Run: `npm test -- state`
|
|
816
|
-
Expected: PASS, all 4 tests green.
|
|
817
|
-
|
|
818
|
-
- [ ] **Step 6: Commit**
|
|
819
|
-
|
|
820
|
-
```bash
|
|
821
|
-
git add src/state.ts tests/state.test.ts tests/helpers/tmp-config.ts
|
|
822
|
-
git commit -m "feat(state): atomic read/write with proper-lockfile"
|
|
823
|
-
```
|
|
824
|
-
|
|
825
|
-
---
|
|
826
|
-
|
|
827
|
-
## Task 8: tmux command wrapper
|
|
828
|
-
|
|
829
|
-
**Files:**
|
|
830
|
-
- Create: `tests/tmux.test.ts`
|
|
831
|
-
- Create: `src/tmux.ts`
|
|
832
|
-
|
|
833
|
-
This task assumes `tmux` is installed on the dev machine and CI. We test against real tmux; mocking shell-out semantics is brittle and gives false confidence.
|
|
834
|
-
|
|
835
|
-
- [ ] **Step 1: Write the failing test**
|
|
836
|
-
|
|
837
|
-
`tests/tmux.test.ts`:
|
|
838
|
-
|
|
839
|
-
```typescript
|
|
840
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
841
|
-
import {
|
|
842
|
-
sessionExists,
|
|
843
|
-
createDetachedSession,
|
|
844
|
-
killSession,
|
|
845
|
-
capturePane,
|
|
846
|
-
sendKeys,
|
|
847
|
-
sessionName,
|
|
848
|
-
} from '../src/tmux.js';
|
|
849
|
-
|
|
850
|
-
describe('tmux wrapper', () => {
|
|
851
|
-
const sessionsToKill: string[] = [];
|
|
852
|
-
afterEach(async () => {
|
|
853
|
-
for (const s of sessionsToKill) {
|
|
854
|
-
try { await killSession(s); } catch { /* ignore */ }
|
|
855
|
-
}
|
|
856
|
-
sessionsToKill.length = 0;
|
|
857
|
-
});
|
|
858
|
-
|
|
859
|
-
it('sessionName composes from project key', () => {
|
|
860
|
-
expect(sessionName('abcd1234ef-myrepo')).toBe('nfo-abcd1234ef-myrepo');
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
it('sessionExists returns false when no such session', async () => {
|
|
864
|
-
expect(await sessionExists('nfo-does-not-exist-zzz')).toBe(false);
|
|
865
|
-
});
|
|
866
|
-
|
|
867
|
-
it('creates a detached session and detects it exists', async () => {
|
|
868
|
-
const name = `nfo-test-${Date.now()}`;
|
|
869
|
-
sessionsToKill.push(name);
|
|
870
|
-
await createDetachedSession(name, '/tmp');
|
|
871
|
-
expect(await sessionExists(name)).toBe(true);
|
|
872
|
-
});
|
|
873
|
-
|
|
874
|
-
it('killSession removes a running session', async () => {
|
|
875
|
-
const name = `nfo-test-kill-${Date.now()}`;
|
|
876
|
-
await createDetachedSession(name, '/tmp');
|
|
877
|
-
await killSession(name);
|
|
878
|
-
expect(await sessionExists(name)).toBe(false);
|
|
879
|
-
});
|
|
880
|
-
|
|
881
|
-
it('capturePane returns the visible pane content after sendKeys', async () => {
|
|
882
|
-
const name = `nfo-test-cap-${Date.now()}`;
|
|
883
|
-
sessionsToKill.push(name);
|
|
884
|
-
await createDetachedSession(name, '/tmp');
|
|
885
|
-
await sendKeys(`${name}:0`, 'echo hello-from-test', true);
|
|
886
|
-
// Allow shell to render output.
|
|
887
|
-
await new Promise(r => setTimeout(r, 250));
|
|
888
|
-
const out = await capturePane(`${name}:0`, 20);
|
|
889
|
-
expect(out).toContain('hello-from-test');
|
|
890
|
-
});
|
|
891
|
-
});
|
|
892
|
-
```
|
|
893
|
-
|
|
894
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
895
|
-
|
|
896
|
-
Run: `npm test -- tmux`
|
|
897
|
-
Expected: FAIL (module not found).
|
|
898
|
-
|
|
899
|
-
- [ ] **Step 3: Implement `src/tmux.ts`**
|
|
900
|
-
|
|
901
|
-
```typescript
|
|
902
|
-
import { execa } from 'execa';
|
|
903
|
-
|
|
904
|
-
export function sessionName(projectKey: string): string {
|
|
905
|
-
return `nfo-${projectKey}`;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
export async function sessionExists(name: string): Promise<boolean> {
|
|
909
|
-
const result = await execa('tmux', ['has-session', '-t', name], { reject: false });
|
|
910
|
-
return result.exitCode === 0;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
export async function createDetachedSession(name: string, cwd: string): Promise<void> {
|
|
914
|
-
await execa('tmux', ['new-session', '-d', '-s', name, '-c', cwd]);
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
export async function killSession(name: string): Promise<void> {
|
|
918
|
-
await execa('tmux', ['kill-session', '-t', name], { reject: false });
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
export async function attachSession(name: string): Promise<void> {
|
|
922
|
-
// Inherits stdio so the user's terminal becomes the tmux client.
|
|
923
|
-
await execa('tmux', ['attach-session', '-t', name], { stdio: 'inherit' });
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
export async function splitWindowHorizontal(
|
|
927
|
-
target: string,
|
|
928
|
-
percent: number,
|
|
929
|
-
command?: string,
|
|
930
|
-
): Promise<void> {
|
|
931
|
-
const args = ['split-window', '-h', '-p', String(percent), '-t', target];
|
|
932
|
-
if (command) args.push(command);
|
|
933
|
-
await execa('tmux', args);
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
export async function sendKeys(target: string, text: string, withEnter: boolean): Promise<void> {
|
|
937
|
-
// Use -l (literal) to avoid keystroke interpretation.
|
|
938
|
-
await execa('tmux', ['send-keys', '-l', '-t', target, '--', text]);
|
|
939
|
-
if (withEnter) {
|
|
940
|
-
await execa('tmux', ['send-keys', '-t', target, 'Enter']);
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
export async function capturePane(target: string, lines: number): Promise<string> {
|
|
945
|
-
const { stdout } = await execa('tmux', [
|
|
946
|
-
'capture-pane',
|
|
947
|
-
'-p',
|
|
948
|
-
'-t',
|
|
949
|
-
target,
|
|
950
|
-
'-S',
|
|
951
|
-
`-${lines}`,
|
|
952
|
-
]);
|
|
953
|
-
return stdout;
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
export async function setSessionOption(name: string, option: string, value: string): Promise<void> {
|
|
957
|
-
await execa('tmux', ['set-option', '-t', name, option, value]);
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
export async function bindKeyForSession(name: string, key: string, command: string): Promise<void> {
|
|
961
|
-
// Session-scoped binding requires tmux >= 3.2 with the `-T` flag for tables; for v1 we
|
|
962
|
-
// rely on a globally-installed binding because per-session bindings have varied support.
|
|
963
|
-
// Phase 1 keeps this as a thin shim; the actual binding install lives in launch.ts.
|
|
964
|
-
await execa('tmux', ['bind-key', '-T', name, key, ...command.split(' ')], { reject: false });
|
|
965
|
-
}
|
|
966
|
-
```
|
|
967
|
-
|
|
968
|
-
Note on `bindKeyForSession`: this is a forward-looking helper. Phase 1's launch.ts may end up not using it (we may just rely on default tmux navigation). If unused at the end of Phase 1, delete it before commit.
|
|
969
|
-
|
|
970
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
971
|
-
|
|
972
|
-
Run: `npm test -- tmux`
|
|
973
|
-
Expected: PASS, all 5 tests green.
|
|
974
|
-
|
|
975
|
-
If running on a CI machine without tmux available, these tests will fail outright. Skip with `it.skipIf(!hasTmux)` only if necessary; v1 assumes tmux is a hard dependency, so CI must have it.
|
|
976
|
-
|
|
977
|
-
- [ ] **Step 5: Commit**
|
|
978
|
-
|
|
979
|
-
```bash
|
|
980
|
-
git add src/tmux.ts tests/tmux.test.ts
|
|
981
|
-
git commit -m "feat(tmux): wrappers for session/pane operations"
|
|
982
|
-
```
|
|
983
|
-
|
|
984
|
-
---
|
|
985
|
-
|
|
986
|
-
## Task 9: Claude CLI version check
|
|
987
|
-
|
|
988
|
-
**Files:**
|
|
989
|
-
- Create: `src/claude-detect.ts`
|
|
990
|
-
|
|
991
|
-
- [ ] **Step 1: Implement `src/claude-detect.ts`**
|
|
992
|
-
|
|
993
|
-
```typescript
|
|
994
|
-
import { execa } from 'execa';
|
|
995
|
-
|
|
996
|
-
export interface ClaudeInfo {
|
|
997
|
-
version: string;
|
|
998
|
-
major: number;
|
|
999
|
-
minor: number;
|
|
1000
|
-
patch: number;
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
const MIN_MAJOR = 2;
|
|
1004
|
-
const MIN_MINOR = 1;
|
|
1005
|
-
|
|
1006
|
-
export async function detectClaude(): Promise<ClaudeInfo> {
|
|
1007
|
-
let stdout: string;
|
|
1008
|
-
try {
|
|
1009
|
-
const result = await execa('claude', ['--version']);
|
|
1010
|
-
stdout = result.stdout;
|
|
1011
|
-
} catch (err) {
|
|
1012
|
-
throw new Error(
|
|
1013
|
-
`Failed to run \`claude --version\`. Is Claude Code installed and on PATH?\nDetails: ${(err as Error).message}`,
|
|
1014
|
-
);
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
// Match a semver-shaped substring in the output (claude prints e.g. "2.1.128 (Claude Code)").
|
|
1018
|
-
const match = stdout.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
1019
|
-
if (!match) {
|
|
1020
|
-
throw new Error(`Could not parse Claude Code version from output: ${stdout}`);
|
|
1021
|
-
}
|
|
1022
|
-
const [, majS, minS, patS] = match;
|
|
1023
|
-
const major = Number(majS);
|
|
1024
|
-
const minor = Number(minS);
|
|
1025
|
-
const patch = Number(patS);
|
|
1026
|
-
|
|
1027
|
-
if (major < MIN_MAJOR || (major === MIN_MAJOR && minor < MIN_MINOR)) {
|
|
1028
|
-
throw new Error(
|
|
1029
|
-
`NFO requires Claude Code ${MIN_MAJOR}.${MIN_MINOR}.0 or newer, found ${major}.${minor}.${patch}. ` +
|
|
1030
|
-
`Run \`npm i -g @anthropic-ai/claude-code\` (or your package manager equivalent) to upgrade.`,
|
|
1031
|
-
);
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
return { version: `${major}.${minor}.${patch}`, major, minor, patch };
|
|
1035
|
-
}
|
|
1036
|
-
```
|
|
1037
|
-
|
|
1038
|
-
Note: `MIN_MAJOR`/`MIN_MINOR` are conservative defaults. The exact minimum is in §12.2 of the spec as an open question — bump these in implementation if needed.
|
|
1039
|
-
|
|
1040
|
-
- [ ] **Step 2: Manual smoke check**
|
|
1041
|
-
|
|
1042
|
-
Run from project root (no automated test — depends on installed claude version): `npm run dev -- --help`. Should not crash. Then create a tiny scratch script importing `detectClaude` and run it; should print the version.
|
|
1043
|
-
|
|
1044
|
-
This task ships without unit tests because mocking `execa` for one function feels low-value and the function is small + self-documenting. Acceptable tradeoff.
|
|
1045
|
-
|
|
1046
|
-
- [ ] **Step 3: Commit**
|
|
1047
|
-
|
|
1048
|
-
```bash
|
|
1049
|
-
git add src/claude-detect.ts
|
|
1050
|
-
git commit -m "feat(claude-detect): version probe with minimum-version gate"
|
|
1051
|
-
```
|
|
1052
|
-
|
|
1053
|
-
---
|
|
1054
|
-
|
|
1055
|
-
## Task 10: Orchestrator role prompt
|
|
1056
|
-
|
|
1057
|
-
**Files:**
|
|
1058
|
-
- Create: `src/prompts/orchestrator-role.ts`
|
|
1059
|
-
|
|
1060
|
-
- [ ] **Step 1: Create `src/prompts/orchestrator-role.ts`**
|
|
1061
|
-
|
|
1062
|
-
```typescript
|
|
1063
|
-
/**
|
|
1064
|
-
* The system prompt addendum injected into the Orchestrator's claude session.
|
|
1065
|
-
*
|
|
1066
|
-
* Phase 1 keeps this minimal: there is no MCP server yet, no musicians yet.
|
|
1067
|
-
* The Orchestrator is just an annotated Claude Code session. Phase 2 will
|
|
1068
|
-
* replace this with a version that documents the NFO MCP tool surface.
|
|
1069
|
-
*/
|
|
1070
|
-
export const ORCHESTRATOR_ROLE_PROMPT_V1 = `You are the Orchestrator of an NFO orchestra.
|
|
1071
|
-
|
|
1072
|
-
NFO (NoFluffOrchestra) is a TUI for multi-agent work on the user's repository.
|
|
1073
|
-
In a future phase you will be able to spawn and coordinate Musicians (other
|
|
1074
|
-
LLM agents) via MCP tools. Right now (Phase 1), the orchestra has no Musicians
|
|
1075
|
-
yet and no NFO-specific tools are available — you are a regular Claude Code
|
|
1076
|
-
session augmented only with this role addendum.
|
|
1077
|
-
|
|
1078
|
-
For now, behave as the user's primary assistant for this project. The user
|
|
1079
|
-
will type into your pane. Your normal Claude Code tools (Read, Edit, Write,
|
|
1080
|
-
Bash, etc.) work as expected. CLAUDE.md and project skills load normally.
|
|
1081
|
-
`;
|
|
1082
|
-
```
|
|
1083
|
-
|
|
1084
|
-
- [ ] **Step 2: Run typecheck**
|
|
1085
|
-
|
|
1086
|
-
Run: `npm run typecheck`
|
|
1087
|
-
Expected: passes.
|
|
1088
|
-
|
|
1089
|
-
- [ ] **Step 3: Commit**
|
|
1090
|
-
|
|
1091
|
-
```bash
|
|
1092
|
-
git add src/prompts/orchestrator-role.ts
|
|
1093
|
-
git commit -m "feat(prompts): orchestrator role addendum (phase 1)"
|
|
1094
|
-
```
|
|
1095
|
-
|
|
1096
|
-
---
|
|
1097
|
-
|
|
1098
|
-
## Task 11: Launch command — happy path (in a repo, no prior orchestra)
|
|
1099
|
-
|
|
1100
|
-
**Files:**
|
|
1101
|
-
- Create: `tests/commands/launch.test.ts`
|
|
1102
|
-
- Create: `src/commands/launch.ts`
|
|
1103
|
-
- Modify: `src/cli.ts`
|
|
1104
|
-
|
|
1105
|
-
This task implements the most common entry: `nfo` in a git repo that has no orchestra yet. Other launch branches (existing orchestra in repo, no-repo with orchestras, no-repo no orchestras) are added in Task 12.
|
|
1106
|
-
|
|
1107
|
-
- [ ] **Step 1: Write the failing test**
|
|
1108
|
-
|
|
1109
|
-
`tests/commands/launch.test.ts`:
|
|
1110
|
-
|
|
1111
|
-
```typescript
|
|
1112
|
-
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
|
1113
|
-
import { launch } from '../../src/commands/launch.js';
|
|
1114
|
-
import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
|
|
1115
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
1116
|
-
import { readState } from '../../src/state.js';
|
|
1117
|
-
import { projectKeyFromPath } from '../../src/project-key.js';
|
|
1118
|
-
import { sessionExists, killSession, sessionName } from '../../src/tmux.js';
|
|
1119
|
-
|
|
1120
|
-
describe('launch in a repo with no prior orchestra', () => {
|
|
1121
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
1122
|
-
const sessionsToKill: string[] = [];
|
|
1123
|
-
|
|
1124
|
-
beforeEach(() => {
|
|
1125
|
-
process.env.NFO_HOME = '';
|
|
1126
|
-
});
|
|
1127
|
-
|
|
1128
|
-
afterEach(async () => {
|
|
1129
|
-
for (const s of sessionsToKill) {
|
|
1130
|
-
try { await killSession(s); } catch { /* ignore */ }
|
|
1131
|
-
}
|
|
1132
|
-
sessionsToKill.length = 0;
|
|
1133
|
-
for (const c of cleanups) await c();
|
|
1134
|
-
cleanups.length = 0;
|
|
1135
|
-
delete process.env.NFO_HOME;
|
|
1136
|
-
});
|
|
1137
|
-
|
|
1138
|
-
it('creates an orchestra and a tmux session in dry-run mode', async () => {
|
|
1139
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
1140
|
-
cleanups.push(repo.cleanup);
|
|
1141
|
-
const cfg = await makeTmpConfig();
|
|
1142
|
-
cleanups.push(cfg.cleanup);
|
|
1143
|
-
process.env.NFO_HOME = cfg.path;
|
|
1144
|
-
|
|
1145
|
-
const result = await launch({
|
|
1146
|
-
cwd: repo.path,
|
|
1147
|
-
interactive: false, // skip the permission prompt
|
|
1148
|
-
permissionLevel: 'supervised', // pre-supplied in non-interactive mode
|
|
1149
|
-
dryRun: true, // don't actually attach; just verify state and session
|
|
1150
|
-
});
|
|
1151
|
-
|
|
1152
|
-
expect(result.action).toBe('created');
|
|
1153
|
-
expect(result.orchestraId).toBe(projectKeyFromPath(repo.path));
|
|
1154
|
-
|
|
1155
|
-
sessionsToKill.push(sessionName(result.orchestraId));
|
|
1156
|
-
|
|
1157
|
-
// State file exists with expected fields
|
|
1158
|
-
const state = await readState(result.orchestraId);
|
|
1159
|
-
expect(state).not.toBeNull();
|
|
1160
|
-
expect(state!.project_path).toBe(repo.path);
|
|
1161
|
-
expect(state!.permission_level).toBe('supervised');
|
|
1162
|
-
|
|
1163
|
-
// tmux session exists
|
|
1164
|
-
expect(await sessionExists(sessionName(result.orchestraId))).toBe(true);
|
|
1165
|
-
});
|
|
1166
|
-
});
|
|
1167
|
-
```
|
|
1168
|
-
|
|
1169
|
-
Note: `dryRun: true` and `interactive: false` are explicit test seams. In production CLI use both default to false/true respectively.
|
|
1170
|
-
|
|
1171
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
1172
|
-
|
|
1173
|
-
Run: `npm test -- launch`
|
|
1174
|
-
Expected: FAIL.
|
|
1175
|
-
|
|
1176
|
-
- [ ] **Step 3: Implement `src/commands/launch.ts`**
|
|
1177
|
-
|
|
1178
|
-
```typescript
|
|
1179
|
-
import { writeFile } from 'node:fs/promises';
|
|
1180
|
-
import { join } from 'node:path';
|
|
1181
|
-
import { execa } from 'execa';
|
|
1182
|
-
import { resolveRepoRoot } from '../repo.js';
|
|
1183
|
-
import { projectKeyFromPath } from '../project-key.js';
|
|
1184
|
-
import { ensureOrchestraDir, readState, writeState } from '../state.js';
|
|
1185
|
-
import { makeInitialState } from '../state.types.js';
|
|
1186
|
-
import {
|
|
1187
|
-
isPermissionLevel,
|
|
1188
|
-
claudeFlagsForLevel,
|
|
1189
|
-
type PermissionLevel,
|
|
1190
|
-
} from '../permission.js';
|
|
1191
|
-
import {
|
|
1192
|
-
sessionName,
|
|
1193
|
-
sessionExists,
|
|
1194
|
-
createDetachedSession,
|
|
1195
|
-
splitWindowHorizontal,
|
|
1196
|
-
sendKeys,
|
|
1197
|
-
attachSession,
|
|
1198
|
-
setSessionOption,
|
|
1199
|
-
} from '../tmux.js';
|
|
1200
|
-
import { ORCHESTRATOR_ROLE_PROMPT_V1 } from '../prompts/orchestrator-role.js';
|
|
1201
|
-
import { orchestraDir } from '../config.js';
|
|
1202
|
-
|
|
1203
|
-
export interface LaunchOptions {
|
|
1204
|
-
cwd: string;
|
|
1205
|
-
interactive?: boolean; // when false, must supply permissionLevel
|
|
1206
|
-
permissionLevel?: PermissionLevel;
|
|
1207
|
-
dryRun?: boolean; // when true, do not attach
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
export interface LaunchResult {
|
|
1211
|
-
action: 'created' | 'attached' | 'restored';
|
|
1212
|
-
orchestraId: string;
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
export async function launch(opts: LaunchOptions): Promise<LaunchResult> {
|
|
1216
|
-
const repoRoot = await resolveRepoRoot(opts.cwd);
|
|
1217
|
-
if (!repoRoot) {
|
|
1218
|
-
// Phase 1: out-of-repo handling is covered in Task 12.
|
|
1219
|
-
throw new Error('Phase 1 launch requires being inside a git repository. Open NFO in a repo.');
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
const orchestraId = projectKeyFromPath(repoRoot);
|
|
1223
|
-
const existing = await readState(orchestraId);
|
|
1224
|
-
|
|
1225
|
-
if (existing) {
|
|
1226
|
-
// Spec §4.1 branch 1: attach to existing orchestra. Phase 1 minimal handling.
|
|
1227
|
-
const name = sessionName(orchestraId);
|
|
1228
|
-
if (await sessionExists(name)) {
|
|
1229
|
-
if (!opts.dryRun) await attachSession(name);
|
|
1230
|
-
return { action: 'attached', orchestraId };
|
|
1231
|
-
}
|
|
1232
|
-
// Session is dead; Task 13 handles restore. For now, error explicitly.
|
|
1233
|
-
throw new Error(
|
|
1234
|
-
`Orchestra ${orchestraId} exists but its tmux session is gone. Run \`nfo restore ${orchestraId}\`.`,
|
|
1235
|
-
);
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
// Creating a brand new orchestra.
|
|
1239
|
-
const level = opts.permissionLevel ?? 'supervised';
|
|
1240
|
-
if (!isPermissionLevel(level)) {
|
|
1241
|
-
throw new Error(`Invalid permission level: ${level}`);
|
|
1242
|
-
}
|
|
1243
|
-
// Interactive prompt (with auto confirmation gate) lives in cli.ts;
|
|
1244
|
-
// by the time we reach this function, the level is already chosen.
|
|
1245
|
-
|
|
1246
|
-
await ensureOrchestraDir(orchestraId);
|
|
1247
|
-
const state = makeInitialState({
|
|
1248
|
-
orchestraId,
|
|
1249
|
-
projectPath: repoRoot,
|
|
1250
|
-
permissionLevel: level,
|
|
1251
|
-
});
|
|
1252
|
-
await writeState(orchestraId, state);
|
|
1253
|
-
|
|
1254
|
-
// Write the role prompt to disk so claude can --append-system-prompt-file it.
|
|
1255
|
-
const promptFile = join(orchestraDir(orchestraId), 'orchestrator-prompt.md');
|
|
1256
|
-
await writeFile(promptFile, ORCHESTRATOR_ROLE_PROMPT_V1, 'utf8');
|
|
1257
|
-
|
|
1258
|
-
// Create the tmux session.
|
|
1259
|
-
const name = sessionName(orchestraId);
|
|
1260
|
-
await createDetachedSession(name, repoRoot);
|
|
1261
|
-
await setSessionOption(name, 'mouse', 'on');
|
|
1262
|
-
await setSessionOption(name, 'status-position', 'top');
|
|
1263
|
-
|
|
1264
|
-
// Right pane placeholder for Phase 1 (the Ink TUI ships in Phase 3).
|
|
1265
|
-
const placeholderShell = `bash -c 'echo "NFO Auditorium pane (placeholder — Phase 3 ships the Ink TUI)" && echo "Orchestra: ${orchestraId}" && echo "Permission: ${level}" && exec ${process.env.SHELL ?? '/bin/bash'}'`;
|
|
1266
|
-
await splitWindowHorizontal(`${name}:0`, 35, placeholderShell);
|
|
1267
|
-
|
|
1268
|
-
// Start the Orchestrator's claude session in the left pane.
|
|
1269
|
-
const claudeFlags = claudeFlagsForLevel(level);
|
|
1270
|
-
const claudeCmd = [
|
|
1271
|
-
'claude',
|
|
1272
|
-
...claudeFlags,
|
|
1273
|
-
'--append-system-prompt-file',
|
|
1274
|
-
promptFile,
|
|
1275
|
-
].join(' ');
|
|
1276
|
-
// The new-session opened in the current shell; send the claude command to pane 0.
|
|
1277
|
-
await sendKeys(`${name}:0.0`, claudeCmd, true);
|
|
1278
|
-
|
|
1279
|
-
if (!opts.dryRun) {
|
|
1280
|
-
await attachSession(name);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
return { action: 'created', orchestraId };
|
|
1284
|
-
}
|
|
1285
|
-
```
|
|
1286
|
-
|
|
1287
|
-
- [ ] **Step 4: Wire commander in `src/cli.ts`**
|
|
1288
|
-
|
|
1289
|
-
Replace the contents of `src/cli.ts` with:
|
|
1290
|
-
|
|
1291
|
-
```typescript
|
|
1292
|
-
#!/usr/bin/env node
|
|
1293
|
-
import { Command } from 'commander';
|
|
1294
|
-
import { launch } from './commands/launch.js';
|
|
1295
|
-
import { isPermissionLevel, AUTO_CONFIRM_PHRASE, AUTO_WARNING, type PermissionLevel } from './permission.js';
|
|
1296
|
-
import { detectClaude } from './claude-detect.js';
|
|
1297
|
-
import { createInterface } from 'node:readline/promises';
|
|
1298
|
-
|
|
1299
|
-
const program = new Command();
|
|
1300
|
-
program
|
|
1301
|
-
.name('nfo')
|
|
1302
|
-
.description('NoFluffOrchestra — TUI multi-agent orchestrator')
|
|
1303
|
-
.version('0.0.0');
|
|
1304
|
-
|
|
1305
|
-
program
|
|
1306
|
-
.action(async () => {
|
|
1307
|
-
await detectClaude();
|
|
1308
|
-
const level = await promptPermissionLevel();
|
|
1309
|
-
await launch({ cwd: process.cwd(), interactive: true, permissionLevel: level });
|
|
1310
|
-
});
|
|
1311
|
-
|
|
1312
|
-
program.parseAsync(process.argv);
|
|
1313
|
-
|
|
1314
|
-
async function promptPermissionLevel(): Promise<PermissionLevel> {
|
|
1315
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1316
|
-
try {
|
|
1317
|
-
const ans = (await rl.question(
|
|
1318
|
-
'Permission level for this orchestra:\n' +
|
|
1319
|
-
' 1) auto — RISKY: bypasses all permission checks\n' +
|
|
1320
|
-
' 2) autonomous — auto-accept edits, prompt on risky tools\n' +
|
|
1321
|
-
' 3) supervised — claude\'s default prompt-on-risky behavior\n' +
|
|
1322
|
-
' 4) strict — read-only / plan mode\n' +
|
|
1323
|
-
'Choose [1-4] (default 3): ',
|
|
1324
|
-
)).trim();
|
|
1325
|
-
|
|
1326
|
-
const map: Record<string, PermissionLevel> = {
|
|
1327
|
-
'1': 'auto', '2': 'autonomous', '3': 'supervised', '4': 'strict', '': 'supervised',
|
|
1328
|
-
};
|
|
1329
|
-
const level = map[ans];
|
|
1330
|
-
if (!level || !isPermissionLevel(level)) {
|
|
1331
|
-
throw new Error(`Invalid choice: ${ans}`);
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
if (level === 'auto') {
|
|
1335
|
-
console.log('\n' + AUTO_WARNING + '\n');
|
|
1336
|
-
const confirm = (await rl.question('> ')).trim();
|
|
1337
|
-
if (confirm !== AUTO_CONFIRM_PHRASE) {
|
|
1338
|
-
throw new Error('Auto mode not confirmed. Aborting.');
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
return level;
|
|
1343
|
-
} finally {
|
|
1344
|
-
rl.close();
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
```
|
|
1348
|
-
|
|
1349
|
-
- [ ] **Step 5: Run test to verify it passes**
|
|
1350
|
-
|
|
1351
|
-
Run: `npm test -- launch`
|
|
1352
|
-
Expected: PASS.
|
|
1353
|
-
|
|
1354
|
-
- [ ] **Step 6: Manual smoke test**
|
|
1355
|
-
|
|
1356
|
-
In a throwaway repo:
|
|
1357
|
-
```
|
|
1358
|
-
cd /tmp && rm -rf nfo-smoke && git init nfo-smoke && cd nfo-smoke && git commit --allow-empty -m init
|
|
1359
|
-
NFO_HOME=/tmp/nfo-smoke-home npm --prefix <path-to-nfo> run dev --
|
|
1360
|
-
```
|
|
1361
|
-
|
|
1362
|
-
Choose `3` for supervised. You should land in tmux with a claude session on the left and a placeholder on the right. Detach with `prefix d`.
|
|
1363
|
-
|
|
1364
|
-
- [ ] **Step 7: Commit**
|
|
1365
|
-
|
|
1366
|
-
```bash
|
|
1367
|
-
git add src/commands/launch.ts src/cli.ts tests/commands/launch.test.ts
|
|
1368
|
-
git commit -m "feat(launch): create+attach orchestra in a git repo"
|
|
1369
|
-
```
|
|
1370
|
-
|
|
1371
|
-
---
|
|
1372
|
-
|
|
1373
|
-
## Task 12: Launch — out-of-repo branches
|
|
1374
|
-
|
|
1375
|
-
**Files:**
|
|
1376
|
-
- Modify: `src/commands/launch.ts`
|
|
1377
|
-
- Create: `src/commands/list.ts`
|
|
1378
|
-
- Modify: `src/cli.ts`
|
|
1379
|
-
- Create: `tests/commands/list.test.ts`
|
|
1380
|
-
|
|
1381
|
-
- [ ] **Step 1: Implement `src/commands/list.ts`**
|
|
1382
|
-
|
|
1383
|
-
```typescript
|
|
1384
|
-
import { readdir } from 'node:fs/promises';
|
|
1385
|
-
import { existsSync } from 'node:fs';
|
|
1386
|
-
import { PROJECTS_DIR } from '../config.js';
|
|
1387
|
-
import { readState } from '../state.js';
|
|
1388
|
-
import { sessionExists, sessionName } from '../tmux.js';
|
|
1389
|
-
import type { OrchestraState } from '../state.types.js';
|
|
1390
|
-
|
|
1391
|
-
export interface OrchestraSummary {
|
|
1392
|
-
id: string;
|
|
1393
|
-
project_path: string;
|
|
1394
|
-
permission_level: string;
|
|
1395
|
-
created_at: string;
|
|
1396
|
-
running: boolean;
|
|
1397
|
-
musician_count: number;
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
export async function listOrchestras(): Promise<OrchestraSummary[]> {
|
|
1401
|
-
if (!existsSync(PROJECTS_DIR)) return [];
|
|
1402
|
-
const dirs = await readdir(PROJECTS_DIR, { withFileTypes: true });
|
|
1403
|
-
const summaries: OrchestraSummary[] = [];
|
|
1404
|
-
for (const d of dirs) {
|
|
1405
|
-
if (!d.isDirectory()) continue;
|
|
1406
|
-
const state = await readState(d.name);
|
|
1407
|
-
if (!state) continue;
|
|
1408
|
-
summaries.push({
|
|
1409
|
-
id: state.orchestra_id,
|
|
1410
|
-
project_path: state.project_path,
|
|
1411
|
-
permission_level: state.permission_level,
|
|
1412
|
-
created_at: state.created_at,
|
|
1413
|
-
running: await sessionExists(sessionName(state.orchestra_id)),
|
|
1414
|
-
musician_count: state.musicians.length,
|
|
1415
|
-
});
|
|
1416
|
-
}
|
|
1417
|
-
return summaries;
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
export function formatOrchestraList(summaries: OrchestraSummary[]): string {
|
|
1421
|
-
if (summaries.length === 0) return 'No orchestras found.';
|
|
1422
|
-
const rows = summaries.map(s =>
|
|
1423
|
-
`${s.running ? '●' : '○'} ${s.id}\n ${s.project_path}\n level=${s.permission_level} musicians=${s.musician_count}`,
|
|
1424
|
-
);
|
|
1425
|
-
return rows.join('\n\n');
|
|
1426
|
-
}
|
|
1427
|
-
```
|
|
1428
|
-
|
|
1429
|
-
- [ ] **Step 2: Write the list test**
|
|
1430
|
-
|
|
1431
|
-
`tests/commands/list.test.ts`:
|
|
1432
|
-
|
|
1433
|
-
```typescript
|
|
1434
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1435
|
-
import { listOrchestras } from '../../src/commands/list.js';
|
|
1436
|
-
import { ensureOrchestraDir, writeState } from '../../src/state.js';
|
|
1437
|
-
import { makeInitialState } from '../../src/state.types.js';
|
|
1438
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
1439
|
-
|
|
1440
|
-
describe('listOrchestras', () => {
|
|
1441
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
1442
|
-
|
|
1443
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
1444
|
-
afterEach(async () => {
|
|
1445
|
-
for (const c of cleanups) await c();
|
|
1446
|
-
cleanups.length = 0;
|
|
1447
|
-
delete process.env.NFO_HOME;
|
|
1448
|
-
});
|
|
1449
|
-
|
|
1450
|
-
it('returns empty array when no orchestras exist', async () => {
|
|
1451
|
-
const tmp = await makeTmpConfig();
|
|
1452
|
-
cleanups.push(tmp.cleanup);
|
|
1453
|
-
process.env.NFO_HOME = tmp.path;
|
|
1454
|
-
expect(await listOrchestras()).toEqual([]);
|
|
1455
|
-
});
|
|
1456
|
-
|
|
1457
|
-
it('lists all orchestras with summary info', async () => {
|
|
1458
|
-
const tmp = await makeTmpConfig();
|
|
1459
|
-
cleanups.push(tmp.cleanup);
|
|
1460
|
-
process.env.NFO_HOME = tmp.path;
|
|
1461
|
-
|
|
1462
|
-
await ensureOrchestraDir('aaa-one');
|
|
1463
|
-
await writeState('aaa-one', makeInitialState({
|
|
1464
|
-
orchestraId: 'aaa-one',
|
|
1465
|
-
projectPath: '/tmp/one',
|
|
1466
|
-
permissionLevel: 'supervised',
|
|
1467
|
-
}));
|
|
1468
|
-
await ensureOrchestraDir('bbb-two');
|
|
1469
|
-
await writeState('bbb-two', makeInitialState({
|
|
1470
|
-
orchestraId: 'bbb-two',
|
|
1471
|
-
projectPath: '/tmp/two',
|
|
1472
|
-
permissionLevel: 'autonomous',
|
|
1473
|
-
}));
|
|
1474
|
-
|
|
1475
|
-
const list = await listOrchestras();
|
|
1476
|
-
expect(list).toHaveLength(2);
|
|
1477
|
-
const ids = list.map(o => o.id).sort();
|
|
1478
|
-
expect(ids).toEqual(['aaa-one', 'bbb-two']);
|
|
1479
|
-
});
|
|
1480
|
-
});
|
|
1481
|
-
```
|
|
1482
|
-
|
|
1483
|
-
Run: `npm test -- list`
|
|
1484
|
-
Expected: PASS.
|
|
1485
|
-
|
|
1486
|
-
- [ ] **Step 3: Modify `src/commands/launch.ts` for out-of-repo branches**
|
|
1487
|
-
|
|
1488
|
-
Replace the `if (!repoRoot)` block (currently throws) with a delegation to a new helper. Add this near the top of the file:
|
|
1489
|
-
|
|
1490
|
-
```typescript
|
|
1491
|
-
import { listOrchestras } from './list.js';
|
|
1492
|
-
import { attachOrRestore } from './attach.js'; // created in Task 13
|
|
1493
|
-
```
|
|
1494
|
-
|
|
1495
|
-
And replace the `if (!repoRoot)` block with:
|
|
1496
|
-
|
|
1497
|
-
```typescript
|
|
1498
|
-
if (!repoRoot) {
|
|
1499
|
-
const summaries = await listOrchestras();
|
|
1500
|
-
if (summaries.length === 0) {
|
|
1501
|
-
throw new Error('Open NFO in a git repository to create your first orchestra.');
|
|
1502
|
-
}
|
|
1503
|
-
const running = summaries.filter(s => s.running);
|
|
1504
|
-
if (running.length === 1) {
|
|
1505
|
-
return attachOrRestore(running[0].id, opts.dryRun);
|
|
1506
|
-
}
|
|
1507
|
-
// Multiple orchestras (running or not) — Phase 1 lists them and asks the user to pick by id.
|
|
1508
|
-
// The interactive picker UI lives in cli.ts; the launch() function itself returns a marker.
|
|
1509
|
-
throw new PickerRequiredError(summaries);
|
|
1510
|
-
}
|
|
1511
|
-
```
|
|
1512
|
-
|
|
1513
|
-
And add at the top:
|
|
1514
|
-
|
|
1515
|
-
```typescript
|
|
1516
|
-
export class PickerRequiredError extends Error {
|
|
1517
|
-
constructor(public summaries: import('./list.js').OrchestraSummary[]) {
|
|
1518
|
-
super('PICKER_REQUIRED');
|
|
1519
|
-
this.name = 'PickerRequiredError';
|
|
1520
|
-
}
|
|
1521
|
-
}
|
|
1522
|
-
```
|
|
1523
|
-
|
|
1524
|
-
Note: `attachOrRestore` is implemented in Task 13. To keep this task buildable without it, also export a temporary stub for now and replace it in Task 13:
|
|
1525
|
-
|
|
1526
|
-
For Phase 1 Task 12, stub `src/commands/attach.ts` minimally:
|
|
1527
|
-
|
|
1528
|
-
```typescript
|
|
1529
|
-
import type { LaunchResult } from './launch.js';
|
|
1530
|
-
import { sessionExists, sessionName, attachSession } from '../tmux.js';
|
|
1531
|
-
|
|
1532
|
-
export async function attachOrRestore(orchestraId: string, dryRun?: boolean): Promise<LaunchResult> {
|
|
1533
|
-
const name = sessionName(orchestraId);
|
|
1534
|
-
if (await sessionExists(name)) {
|
|
1535
|
-
if (!dryRun) await attachSession(name);
|
|
1536
|
-
return { action: 'attached', orchestraId };
|
|
1537
|
-
}
|
|
1538
|
-
throw new Error(
|
|
1539
|
-
`Orchestra ${orchestraId} is stopped. Run \`nfo restore ${orchestraId}\` to bring it back. (Restore is implemented in Task 13.)`,
|
|
1540
|
-
);
|
|
1541
|
-
}
|
|
1542
|
-
```
|
|
1543
|
-
|
|
1544
|
-
(Task 13 expands this with real restoration.)
|
|
1545
|
-
|
|
1546
|
-
- [ ] **Step 4: Refactor `launch.ts` into decide+execute, and wire the CLI cleanly**
|
|
1547
|
-
|
|
1548
|
-
The control-flow problem: we should only prompt for a permission level when we're actually about to create a new orchestra, not when we're attaching or about to show a picker. Split the work.
|
|
1549
|
-
|
|
1550
|
-
Add a `decideAction` function to `src/commands/launch.ts` that returns what to do without doing it:
|
|
1551
|
-
|
|
1552
|
-
```typescript
|
|
1553
|
-
export type LaunchDecision =
|
|
1554
|
-
| { kind: 'create'; orchestraId: string; repoRoot: string }
|
|
1555
|
-
| { kind: 'attach_existing'; orchestraId: string }
|
|
1556
|
-
| { kind: 'pick'; summaries: import('./list.js').OrchestraSummary[] }
|
|
1557
|
-
| { kind: 'error'; message: string };
|
|
1558
|
-
|
|
1559
|
-
export async function decideAction(cwd: string): Promise<LaunchDecision> {
|
|
1560
|
-
const repoRoot = await resolveRepoRoot(cwd);
|
|
1561
|
-
|
|
1562
|
-
if (repoRoot) {
|
|
1563
|
-
const orchestraId = projectKeyFromPath(repoRoot);
|
|
1564
|
-
const existing = await readState(orchestraId);
|
|
1565
|
-
if (existing) {
|
|
1566
|
-
return { kind: 'attach_existing', orchestraId };
|
|
1567
|
-
}
|
|
1568
|
-
return { kind: 'create', orchestraId, repoRoot };
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
// Out of repo. Inspect known orchestras.
|
|
1572
|
-
const summaries = await listOrchestras();
|
|
1573
|
-
if (summaries.length === 0) {
|
|
1574
|
-
return { kind: 'error', message: 'Open NFO in a git repository to create your first orchestra.' };
|
|
1575
|
-
}
|
|
1576
|
-
const running = summaries.filter(s => s.running);
|
|
1577
|
-
if (running.length === 1) {
|
|
1578
|
-
return { kind: 'attach_existing', orchestraId: running[0].id };
|
|
1579
|
-
}
|
|
1580
|
-
return { kind: 'pick', summaries };
|
|
1581
|
-
}
|
|
1582
|
-
```
|
|
1583
|
-
|
|
1584
|
-
Keep the existing `launch()` for the create flow — but trim it down to only the create case (refactored signature):
|
|
1585
|
-
|
|
1586
|
-
```typescript
|
|
1587
|
-
export interface CreateOrchestraOptions {
|
|
1588
|
-
repoRoot: string;
|
|
1589
|
-
orchestraId: string;
|
|
1590
|
-
permissionLevel: PermissionLevel;
|
|
1591
|
-
dryRun?: boolean;
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
export async function createOrchestra(opts: CreateOrchestraOptions): Promise<LaunchResult> {
|
|
1595
|
-
await ensureOrchestraDir(opts.orchestraId);
|
|
1596
|
-
const state = makeInitialState({
|
|
1597
|
-
orchestraId: opts.orchestraId,
|
|
1598
|
-
projectPath: opts.repoRoot,
|
|
1599
|
-
permissionLevel: opts.permissionLevel,
|
|
1600
|
-
});
|
|
1601
|
-
await writeState(opts.orchestraId, state);
|
|
1602
|
-
|
|
1603
|
-
const promptFile = join(orchestraDir(opts.orchestraId), 'orchestrator-prompt.md');
|
|
1604
|
-
await writeFile(promptFile, ORCHESTRATOR_ROLE_PROMPT_V1, 'utf8');
|
|
1605
|
-
|
|
1606
|
-
const name = sessionName(opts.orchestraId);
|
|
1607
|
-
await createDetachedSession(name, opts.repoRoot);
|
|
1608
|
-
await setSessionOption(name, 'mouse', 'on');
|
|
1609
|
-
await setSessionOption(name, 'status-position', 'top');
|
|
1610
|
-
|
|
1611
|
-
const placeholderShell = `bash -c 'echo "NFO Auditorium pane (placeholder — Phase 3 ships the Ink TUI)" && echo "Orchestra: ${opts.orchestraId}" && echo "Permission: ${opts.permissionLevel}" && exec ${process.env.SHELL ?? '/bin/bash'}'`;
|
|
1612
|
-
await splitWindowHorizontal(`${name}:0`, 35, placeholderShell);
|
|
1613
|
-
|
|
1614
|
-
const claudeFlags = claudeFlagsForLevel(opts.permissionLevel);
|
|
1615
|
-
const claudeCmd = ['claude', ...claudeFlags, '--append-system-prompt-file', promptFile].join(' ');
|
|
1616
|
-
await sendKeys(`${name}:0.0`, claudeCmd, true);
|
|
1617
|
-
|
|
1618
|
-
if (!opts.dryRun) await attachSession(name);
|
|
1619
|
-
return { action: 'created', orchestraId: opts.orchestraId };
|
|
1620
|
-
}
|
|
1621
|
-
```
|
|
1622
|
-
|
|
1623
|
-
Delete the old monolithic `launch()` body — it's now expressed as `decideAction()` + (`createOrchestra()` | `attachOrRestore()`). Update the Task 11 launch test to drive `createOrchestra` directly (rename test file to `tests/commands/create-orchestra.test.ts` and adjust imports — same assertions, different function name).
|
|
1624
|
-
|
|
1625
|
-
Now in `src/cli.ts`, the default action becomes:
|
|
1626
|
-
|
|
1627
|
-
```typescript
|
|
1628
|
-
program
|
|
1629
|
-
.argument('[id]', 'Orchestra id to attach (optional)')
|
|
1630
|
-
.action(async (id: string | undefined) => {
|
|
1631
|
-
await detectClaude();
|
|
1632
|
-
try {
|
|
1633
|
-
if (id) {
|
|
1634
|
-
await attachOrRestore(id);
|
|
1635
|
-
return;
|
|
1636
|
-
}
|
|
1637
|
-
const decision = await decideAction(process.cwd());
|
|
1638
|
-
switch (decision.kind) {
|
|
1639
|
-
case 'create': {
|
|
1640
|
-
const level = await promptPermissionLevel();
|
|
1641
|
-
await createOrchestra({
|
|
1642
|
-
repoRoot: decision.repoRoot,
|
|
1643
|
-
orchestraId: decision.orchestraId,
|
|
1644
|
-
permissionLevel: level,
|
|
1645
|
-
});
|
|
1646
|
-
return;
|
|
1647
|
-
}
|
|
1648
|
-
case 'attach_existing':
|
|
1649
|
-
await attachOrRestore(decision.orchestraId);
|
|
1650
|
-
return;
|
|
1651
|
-
case 'pick': {
|
|
1652
|
-
const picked = await promptOrchestraPicker(decision.summaries);
|
|
1653
|
-
await attachOrRestore(picked);
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
case 'error':
|
|
1657
|
-
console.error(decision.message);
|
|
1658
|
-
process.exit(1);
|
|
1659
|
-
}
|
|
1660
|
-
} catch (err) {
|
|
1661
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1662
|
-
process.exit(1);
|
|
1663
|
-
}
|
|
1664
|
-
});
|
|
1665
|
-
```
|
|
1666
|
-
|
|
1667
|
-
Imports at the top of `cli.ts`:
|
|
1668
|
-
|
|
1669
|
-
```typescript
|
|
1670
|
-
import { decideAction, createOrchestra } from './commands/launch.js';
|
|
1671
|
-
import { attachOrRestore } from './commands/attach.js';
|
|
1672
|
-
import type { OrchestraSummary } from './commands/list.js';
|
|
1673
|
-
```
|
|
1674
|
-
|
|
1675
|
-
Note: with `program.argument('[id]', ...)` set on the top-level program, subcommands (`list`, `kill`, `restore`, `notes`) still work as long as they're declared before `program.parseAsync()`. Commander treats subcommands with precedence over positional args.
|
|
1676
|
-
|
|
1677
|
-
Also in `attach.ts`, change the type import to `import type` to avoid a value-level circular import with launch.ts:
|
|
1678
|
-
|
|
1679
|
-
```typescript
|
|
1680
|
-
import type { LaunchResult } from './launch.js';
|
|
1681
|
-
```
|
|
1682
|
-
|
|
1683
|
-
Helper for the picker:
|
|
1684
|
-
|
|
1685
|
-
```typescript
|
|
1686
|
-
async function promptOrchestraPicker(summaries: OrchestraSummary[]): Promise<string> {
|
|
1687
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1688
|
-
try {
|
|
1689
|
-
console.log('Multiple orchestras found:');
|
|
1690
|
-
summaries.forEach((s, i) => {
|
|
1691
|
-
console.log(` ${i + 1}) ${s.running ? '●' : '○'} ${s.id} (${s.project_path})`);
|
|
1692
|
-
});
|
|
1693
|
-
const choice = (await rl.question('Pick one [1-N]: ')).trim();
|
|
1694
|
-
const idx = Number(choice) - 1;
|
|
1695
|
-
if (Number.isNaN(idx) || idx < 0 || idx >= summaries.length) {
|
|
1696
|
-
throw new Error('Invalid choice');
|
|
1697
|
-
}
|
|
1698
|
-
return summaries[idx].id;
|
|
1699
|
-
} finally {
|
|
1700
|
-
rl.close();
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
```
|
|
1704
|
-
|
|
1705
|
-
Also import the missing symbols at the top of `cli.ts`:
|
|
1706
|
-
|
|
1707
|
-
```typescript
|
|
1708
|
-
import { PickerRequiredError } from './commands/launch.js';
|
|
1709
|
-
import { attachOrRestore } from './commands/attach.js';
|
|
1710
|
-
import { listOrchestras, type OrchestraSummary } from './commands/list.js';
|
|
1711
|
-
```
|
|
1712
|
-
|
|
1713
|
-
- [ ] **Step 5: Add `nfo list` and `nfo <id>` subcommands**
|
|
1714
|
-
|
|
1715
|
-
In `cli.ts`, add after the default action:
|
|
1716
|
-
|
|
1717
|
-
```typescript
|
|
1718
|
-
program
|
|
1719
|
-
.command('list')
|
|
1720
|
-
.description('List all known orchestras')
|
|
1721
|
-
.action(async () => {
|
|
1722
|
-
const { listOrchestras, formatOrchestraList } = await import('./commands/list.js');
|
|
1723
|
-
const summaries = await listOrchestras();
|
|
1724
|
-
console.log(formatOrchestraList(summaries));
|
|
1725
|
-
});
|
|
1726
|
-
|
|
1727
|
-
program
|
|
1728
|
-
.arguments('[id]')
|
|
1729
|
-
.action(async (id?: string) => {
|
|
1730
|
-
if (!id) return; // default action above handles no-args
|
|
1731
|
-
await attachOrRestore(id);
|
|
1732
|
-
});
|
|
1733
|
-
```
|
|
1734
|
-
|
|
1735
|
-
(Commander's arguments syntax can collide with the default `action`. If during smoke testing you find that `nfo` with no args triggers the `[id]` action incorrectly, restructure as explicit subcommands instead. Note this and adapt.)
|
|
1736
|
-
|
|
1737
|
-
- [ ] **Step 6: Smoke test out-of-repo**
|
|
1738
|
-
|
|
1739
|
-
```
|
|
1740
|
-
NFO_HOME=/tmp/nfo-smoke-home npm --prefix <path> run dev -- list
|
|
1741
|
-
```
|
|
1742
|
-
|
|
1743
|
-
Expected: prints `No orchestras found.` (if you haven't created any).
|
|
1744
|
-
|
|
1745
|
-
After Task 11's smoke test left one orchestra, the same command prints it.
|
|
1746
|
-
|
|
1747
|
-
- [ ] **Step 7: Commit**
|
|
1748
|
-
|
|
1749
|
-
```bash
|
|
1750
|
-
git add src/commands/launch.ts src/commands/list.ts src/commands/attach.ts src/cli.ts tests/commands/list.test.ts
|
|
1751
|
-
git commit -m "feat(launch): out-of-repo branches and picker; add nfo list/attach"
|
|
1752
|
-
```
|
|
1753
|
-
|
|
1754
|
-
---
|
|
1755
|
-
|
|
1756
|
-
## Task 13: Restore command (and split launch())
|
|
1757
|
-
|
|
1758
|
-
**Files:**
|
|
1759
|
-
- Modify: `src/commands/attach.ts`
|
|
1760
|
-
- Create: `src/commands/restore.ts`
|
|
1761
|
-
- Modify: `src/commands/launch.ts`
|
|
1762
|
-
- Modify: `src/cli.ts`
|
|
1763
|
-
|
|
1764
|
-
- [ ] **Step 1: Implement `src/commands/restore.ts`**
|
|
1765
|
-
|
|
1766
|
-
```typescript
|
|
1767
|
-
import { existsSync } from 'node:fs';
|
|
1768
|
-
import { join } from 'node:path';
|
|
1769
|
-
import { readState } from '../state.js';
|
|
1770
|
-
import { orchestraDir } from '../config.js';
|
|
1771
|
-
import {
|
|
1772
|
-
sessionName,
|
|
1773
|
-
sessionExists,
|
|
1774
|
-
createDetachedSession,
|
|
1775
|
-
splitWindowHorizontal,
|
|
1776
|
-
sendKeys,
|
|
1777
|
-
setSessionOption,
|
|
1778
|
-
attachSession,
|
|
1779
|
-
} from '../tmux.js';
|
|
1780
|
-
import { claudeFlagsForLevel } from '../permission.js';
|
|
1781
|
-
import type { LaunchResult } from './launch.js';
|
|
1782
|
-
|
|
1783
|
-
export async function restoreOrchestra(
|
|
1784
|
-
orchestraId: string,
|
|
1785
|
-
dryRun?: boolean,
|
|
1786
|
-
): Promise<LaunchResult> {
|
|
1787
|
-
const state = await readState(orchestraId);
|
|
1788
|
-
if (!state) throw new Error(`Unknown orchestra: ${orchestraId}`);
|
|
1789
|
-
|
|
1790
|
-
const name = sessionName(orchestraId);
|
|
1791
|
-
if (await sessionExists(name)) {
|
|
1792
|
-
if (!dryRun) await attachSession(name);
|
|
1793
|
-
return { action: 'attached', orchestraId };
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
// Spec §4.3: recreate the tmux session, --resume the Orchestrator.
|
|
1797
|
-
await createDetachedSession(name, state.project_path);
|
|
1798
|
-
await setSessionOption(name, 'mouse', 'on');
|
|
1799
|
-
await setSessionOption(name, 'status-position', 'top');
|
|
1800
|
-
|
|
1801
|
-
const placeholderShell = `bash -c 'echo "NFO Auditorium pane (placeholder)" && echo "Restored orchestra ${orchestraId}" && exec ${process.env.SHELL ?? '/bin/bash'}'`;
|
|
1802
|
-
await splitWindowHorizontal(`${name}:0`, 35, placeholderShell);
|
|
1803
|
-
|
|
1804
|
-
const promptFile = join(orchestraDir(orchestraId), 'orchestrator-prompt.md');
|
|
1805
|
-
const flags = claudeFlagsForLevel(state.permission_level);
|
|
1806
|
-
const resumeArgs = state.orchestrator_session_id
|
|
1807
|
-
? ['--resume', state.orchestrator_session_id]
|
|
1808
|
-
: [];
|
|
1809
|
-
const cmd = ['claude', ...flags, ...resumeArgs];
|
|
1810
|
-
if (existsSync(promptFile)) {
|
|
1811
|
-
cmd.push('--append-system-prompt-file', promptFile);
|
|
1812
|
-
}
|
|
1813
|
-
await sendKeys(`${name}:0.0`, cmd.join(' '), true);
|
|
1814
|
-
|
|
1815
|
-
// Phase 1: no musicians to restore (they arrive in Phase 2).
|
|
1816
|
-
|
|
1817
|
-
if (!dryRun) await attachSession(name);
|
|
1818
|
-
return { action: 'restored', orchestraId };
|
|
1819
|
-
}
|
|
1820
|
-
```
|
|
1821
|
-
|
|
1822
|
-
- [ ] **Step 2: Update `src/commands/attach.ts` to delegate to restore**
|
|
1823
|
-
|
|
1824
|
-
Replace the stub with:
|
|
1825
|
-
|
|
1826
|
-
```typescript
|
|
1827
|
-
import type { LaunchResult } from './launch.js';
|
|
1828
|
-
import { sessionExists, sessionName, attachSession } from '../tmux.js';
|
|
1829
|
-
import { restoreOrchestra } from './restore.js';
|
|
1830
|
-
import { readState } from '../state.js';
|
|
1831
|
-
|
|
1832
|
-
export async function attachOrRestore(orchestraId: string, dryRun?: boolean): Promise<LaunchResult> {
|
|
1833
|
-
const state = await readState(orchestraId);
|
|
1834
|
-
if (!state) throw new Error(`Unknown orchestra: ${orchestraId}`);
|
|
1835
|
-
|
|
1836
|
-
const name = sessionName(orchestraId);
|
|
1837
|
-
if (await sessionExists(name)) {
|
|
1838
|
-
if (!dryRun) await attachSession(name);
|
|
1839
|
-
return { action: 'attached', orchestraId };
|
|
1840
|
-
}
|
|
1841
|
-
return restoreOrchestra(orchestraId, dryRun);
|
|
1842
|
-
}
|
|
1843
|
-
```
|
|
1844
|
-
|
|
1845
|
-
- [ ] **Step 3: Update `src/commands/launch.ts` to delegate when session is dead**
|
|
1846
|
-
|
|
1847
|
-
Remove the `throw new Error('Orchestra ... session is gone')` line; replace with:
|
|
1848
|
-
|
|
1849
|
-
```typescript
|
|
1850
|
-
return restoreOrchestra(orchestraId, opts.dryRun);
|
|
1851
|
-
```
|
|
1852
|
-
|
|
1853
|
-
Add the import at the top:
|
|
1854
|
-
|
|
1855
|
-
```typescript
|
|
1856
|
-
import { restoreOrchestra } from './restore.js';
|
|
1857
|
-
```
|
|
1858
|
-
|
|
1859
|
-
- [ ] **Step 4: Add `nfo restore` command in `src/cli.ts`**
|
|
1860
|
-
|
|
1861
|
-
```typescript
|
|
1862
|
-
program
|
|
1863
|
-
.command('restore <id>')
|
|
1864
|
-
.description('Force-restore a stopped orchestra')
|
|
1865
|
-
.action(async (id: string) => {
|
|
1866
|
-
const { restoreOrchestra } = await import('./commands/restore.js');
|
|
1867
|
-
await restoreOrchestra(id);
|
|
1868
|
-
});
|
|
1869
|
-
```
|
|
1870
|
-
|
|
1871
|
-
- [ ] **Step 5: Manual smoke test**
|
|
1872
|
-
|
|
1873
|
-
After Task 11's smoke leaves an orchestra running, in a new terminal: `tmux kill-server` to simulate a reboot. Then run `nfo <id>` (or `nfo` in the repo) — should restore the session and put you back in.
|
|
1874
|
-
|
|
1875
|
-
- [ ] **Step 6: Commit**
|
|
1876
|
-
|
|
1877
|
-
```bash
|
|
1878
|
-
git add src/commands/restore.ts src/commands/attach.ts src/commands/launch.ts src/cli.ts
|
|
1879
|
-
git commit -m "feat(restore): rebuild tmux session and resume Orchestrator"
|
|
1880
|
-
```
|
|
1881
|
-
|
|
1882
|
-
---
|
|
1883
|
-
|
|
1884
|
-
## Task 14: Kill command
|
|
1885
|
-
|
|
1886
|
-
**Files:**
|
|
1887
|
-
- Create: `src/commands/kill.ts`
|
|
1888
|
-
- Modify: `src/cli.ts`
|
|
1889
|
-
|
|
1890
|
-
- [ ] **Step 1: Implement `src/commands/kill.ts`**
|
|
1891
|
-
|
|
1892
|
-
```typescript
|
|
1893
|
-
import { createInterface } from 'node:readline/promises';
|
|
1894
|
-
import { rename, mkdir } from 'node:fs/promises';
|
|
1895
|
-
import { existsSync } from 'node:fs';
|
|
1896
|
-
import { join } from 'node:path';
|
|
1897
|
-
import { readState, writeState } from '../state.js';
|
|
1898
|
-
import {
|
|
1899
|
-
sessionName,
|
|
1900
|
-
sessionExists,
|
|
1901
|
-
killSession,
|
|
1902
|
-
} from '../tmux.js';
|
|
1903
|
-
import { orchestraDir, archiveDir, stateFile } from '../config.js';
|
|
1904
|
-
|
|
1905
|
-
export interface KillOptions {
|
|
1906
|
-
yes?: boolean; // skip confirmation prompt
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
export async function killOrchestra(orchestraId: string, opts: KillOptions = {}): Promise<void> {
|
|
1910
|
-
const state = await readState(orchestraId);
|
|
1911
|
-
if (!state) throw new Error(`Unknown orchestra: ${orchestraId}`);
|
|
1912
|
-
|
|
1913
|
-
if (!opts.yes) {
|
|
1914
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1915
|
-
try {
|
|
1916
|
-
const ans = (await rl.question(
|
|
1917
|
-
`Kill orchestra ${orchestraId} (${state.project_path})? [y/N] `,
|
|
1918
|
-
)).trim().toLowerCase();
|
|
1919
|
-
if (ans !== 'y' && ans !== 'yes') {
|
|
1920
|
-
console.log('Aborted.');
|
|
1921
|
-
return;
|
|
1922
|
-
}
|
|
1923
|
-
} finally {
|
|
1924
|
-
rl.close();
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
// Phase 1: no musicians (and therefore no worktrees to handle).
|
|
1929
|
-
// Phase 2 will add the worktree-archive prompt.
|
|
1930
|
-
|
|
1931
|
-
const name = sessionName(orchestraId);
|
|
1932
|
-
if (await sessionExists(name)) {
|
|
1933
|
-
await killSession(name);
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
// Archive state.json under archive/state-<timestamp>.json so notes/ stays intact.
|
|
1937
|
-
await mkdir(archiveDir(orchestraId), { recursive: true });
|
|
1938
|
-
const archived = join(archiveDir(orchestraId), `state-${Date.now()}.json`);
|
|
1939
|
-
if (existsSync(stateFile(orchestraId))) {
|
|
1940
|
-
await rename(stateFile(orchestraId), archived);
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
```
|
|
1944
|
-
|
|
1945
|
-
- [ ] **Step 2: Add `nfo kill` to `src/cli.ts`**
|
|
1946
|
-
|
|
1947
|
-
```typescript
|
|
1948
|
-
program
|
|
1949
|
-
.command('kill <id>')
|
|
1950
|
-
.description('Tear down an orchestra (state archived, notes preserved)')
|
|
1951
|
-
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1952
|
-
.action(async (id: string, opts: { yes?: boolean }) => {
|
|
1953
|
-
const { killOrchestra } = await import('./commands/kill.js');
|
|
1954
|
-
await killOrchestra(id, opts);
|
|
1955
|
-
});
|
|
1956
|
-
```
|
|
1957
|
-
|
|
1958
|
-
- [ ] **Step 3: Manual smoke test**
|
|
1959
|
-
|
|
1960
|
-
```
|
|
1961
|
-
nfo kill <id-from-list>
|
|
1962
|
-
```
|
|
1963
|
-
|
|
1964
|
-
Confirm yes. Then `nfo list` — the orchestra should be gone (state archived to `archive/state-<ts>.json`; the orchestra dir itself remains so notes survive).
|
|
1965
|
-
|
|
1966
|
-
- [ ] **Step 4: Commit**
|
|
1967
|
-
|
|
1968
|
-
```bash
|
|
1969
|
-
git add src/commands/kill.ts src/cli.ts
|
|
1970
|
-
git commit -m "feat(kill): tear down orchestra, archive state, preserve notes"
|
|
1971
|
-
```
|
|
1972
|
-
|
|
1973
|
-
---
|
|
1974
|
-
|
|
1975
|
-
## Task 15: Notes command
|
|
1976
|
-
|
|
1977
|
-
**Files:**
|
|
1978
|
-
- Create: `src/commands/notes.ts`
|
|
1979
|
-
- Modify: `src/cli.ts`
|
|
1980
|
-
|
|
1981
|
-
- [ ] **Step 1: Implement `src/commands/notes.ts`**
|
|
1982
|
-
|
|
1983
|
-
```typescript
|
|
1984
|
-
import { existsSync } from 'node:fs';
|
|
1985
|
-
import { execa } from 'execa';
|
|
1986
|
-
import { notesDir } from '../config.js';
|
|
1987
|
-
import { readState } from '../state.js';
|
|
1988
|
-
|
|
1989
|
-
export async function openNotes(orchestraId: string): Promise<void> {
|
|
1990
|
-
const state = await readState(orchestraId);
|
|
1991
|
-
if (!state) throw new Error(`Unknown orchestra: ${orchestraId}`);
|
|
1992
|
-
|
|
1993
|
-
const dir = notesDir(orchestraId);
|
|
1994
|
-
if (!existsSync(dir)) {
|
|
1995
|
-
throw new Error(`Notes directory missing for ${orchestraId}: ${dir}`);
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
const editor = process.env.EDITOR ?? 'vi';
|
|
1999
|
-
// Open the dir, not a specific file — the user picks which note to edit.
|
|
2000
|
-
await execa(editor, [dir], { stdio: 'inherit' });
|
|
2001
|
-
}
|
|
2002
|
-
```
|
|
2003
|
-
|
|
2004
|
-
- [ ] **Step 2: Add `nfo notes` to `src/cli.ts`**
|
|
2005
|
-
|
|
2006
|
-
```typescript
|
|
2007
|
-
program
|
|
2008
|
-
.command('notes <id>')
|
|
2009
|
-
.description('Open the orchestra\'s notes/ directory in $EDITOR')
|
|
2010
|
-
.action(async (id: string) => {
|
|
2011
|
-
const { openNotes } = await import('./commands/notes.js');
|
|
2012
|
-
await openNotes(id);
|
|
2013
|
-
});
|
|
2014
|
-
```
|
|
2015
|
-
|
|
2016
|
-
- [ ] **Step 3: Manual smoke test**
|
|
2017
|
-
|
|
2018
|
-
```
|
|
2019
|
-
EDITOR=ls nfo notes <id>
|
|
2020
|
-
```
|
|
2021
|
-
|
|
2022
|
-
Expected: lists the contents of the notes dir (empty in Phase 1 since the Orchestrator can't write notes yet).
|
|
2023
|
-
|
|
2024
|
-
- [ ] **Step 4: Commit**
|
|
2025
|
-
|
|
2026
|
-
```bash
|
|
2027
|
-
git add src/commands/notes.ts src/cli.ts
|
|
2028
|
-
git commit -m "feat(notes): nfo notes opens the orchestra notes/ in \$EDITOR"
|
|
2029
|
-
```
|
|
2030
|
-
|
|
2031
|
-
---
|
|
2032
|
-
|
|
2033
|
-
## Task 16: Wire up minimal README
|
|
2034
|
-
|
|
2035
|
-
**Files:**
|
|
2036
|
-
- Create: `README.md`
|
|
2037
|
-
|
|
2038
|
-
- [ ] **Step 1: Create `README.md`**
|
|
2039
|
-
|
|
2040
|
-
```markdown
|
|
2041
|
-
# NFO — NoFluffOrchestra
|
|
2042
|
-
|
|
2043
|
-
A TUI multi-agent orchestrator that latches onto your existing repos. Built on Claude Code + tmux.
|
|
2044
|
-
|
|
2045
|
-
## Status
|
|
2046
|
-
|
|
2047
|
-
Phase 1 (bootstrap). The `nfo` command can create/attach/restore/list/kill an Orchestra and launch the Orchestrator's `claude` session in a tmux pane. Musicians, the Ink TUI side pane, and permission-prompt detection ship in later phases.
|
|
2048
|
-
|
|
2049
|
-
## Requirements
|
|
2050
|
-
|
|
2051
|
-
- Node.js 20+
|
|
2052
|
-
- `tmux` on PATH
|
|
2053
|
-
- `claude` (Claude Code CLI) on PATH, version ≥ 2.1 (see `src/claude-detect.ts` for the exact gate)
|
|
2054
|
-
- A POSIX shell (`bash` or `zsh`)
|
|
2055
|
-
- Linux or macOS (Windows via WSL only)
|
|
2056
|
-
|
|
2057
|
-
## Install (development)
|
|
2058
|
-
|
|
2059
|
-
```
|
|
2060
|
-
git clone <this repo>
|
|
2061
|
-
cd nfo-cli
|
|
2062
|
-
npm install
|
|
2063
|
-
npm run build
|
|
2064
|
-
npm link # makes the `nfo` command globally available
|
|
2065
|
-
```
|
|
2066
|
-
|
|
2067
|
-
## Use
|
|
2068
|
-
|
|
2069
|
-
In a git repo: `nfo`
|
|
2070
|
-
List orchestras: `nfo list`
|
|
2071
|
-
Attach by id: `nfo <id>`
|
|
2072
|
-
Tear down: `nfo kill <id>`
|
|
2073
|
-
Open notes: `nfo notes <id>`
|
|
2074
|
-
|
|
2075
|
-
## Design
|
|
2076
|
-
|
|
2077
|
-
See `docs/specs/2026-05-29-nfo-design.md`.
|
|
2078
|
-
```
|
|
2079
|
-
|
|
2080
|
-
- [ ] **Step 2: Commit**
|
|
2081
|
-
|
|
2082
|
-
```bash
|
|
2083
|
-
git add README.md
|
|
2084
|
-
git commit -m "docs: minimal README for Phase 1"
|
|
2085
|
-
```
|
|
2086
|
-
|
|
2087
|
-
---
|
|
2088
|
-
|
|
2089
|
-
## Task 17: Final end-to-end manual smoke
|
|
2090
|
-
|
|
2091
|
-
Not a code task — a verification gate before declaring Phase 1 done.
|
|
2092
|
-
|
|
2093
|
-
- [ ] **Step 1: Run the full e2e flow in a throwaway environment**
|
|
2094
|
-
|
|
2095
|
-
```bash
|
|
2096
|
-
# Fresh environment
|
|
2097
|
-
export NFO_HOME=/tmp/nfo-e2e-home
|
|
2098
|
-
rm -rf "$NFO_HOME" /tmp/nfo-e2e-repo
|
|
2099
|
-
mkdir /tmp/nfo-e2e-repo && cd /tmp/nfo-e2e-repo
|
|
2100
|
-
git init -q && git commit --allow-empty -m init
|
|
2101
|
-
|
|
2102
|
-
# 1. Create
|
|
2103
|
-
nfo
|
|
2104
|
-
# Pick option 3 (supervised). You should land in tmux with a claude session left, placeholder right.
|
|
2105
|
-
# Type something at claude. Then detach with `prefix d`.
|
|
2106
|
-
|
|
2107
|
-
# 2. List
|
|
2108
|
-
nfo list
|
|
2109
|
-
# Should show one orchestra, ● marker for "running".
|
|
2110
|
-
|
|
2111
|
-
# 3. Re-enter from inside the repo
|
|
2112
|
-
nfo
|
|
2113
|
-
# Should re-attach to the same session.
|
|
2114
|
-
|
|
2115
|
-
# 4. Re-enter by id from outside the repo
|
|
2116
|
-
cd /tmp
|
|
2117
|
-
nfo list # capture the id
|
|
2118
|
-
nfo <id>
|
|
2119
|
-
# Should re-attach.
|
|
2120
|
-
|
|
2121
|
-
# 5. Simulate reboot
|
|
2122
|
-
tmux kill-server
|
|
2123
|
-
nfo <id>
|
|
2124
|
-
# Should restore the orchestra (--resume the Orchestrator).
|
|
2125
|
-
|
|
2126
|
-
# 6. Tear down
|
|
2127
|
-
nfo kill <id> -y
|
|
2128
|
-
nfo list
|
|
2129
|
-
# Should show "No orchestras found."
|
|
2130
|
-
|
|
2131
|
-
# 7. Out-of-repo with no orchestras
|
|
2132
|
-
cd /tmp
|
|
2133
|
-
NFO_HOME=$(mktemp -d) nfo
|
|
2134
|
-
# Should error: "Open NFO in a git repository to create your first orchestra."
|
|
2135
|
-
```
|
|
2136
|
-
|
|
2137
|
-
- [ ] **Step 2: Run the test suite one more time**
|
|
2138
|
-
|
|
2139
|
-
```bash
|
|
2140
|
-
npm test
|
|
2141
|
-
```
|
|
2142
|
-
|
|
2143
|
-
Expected: all tests pass.
|
|
2144
|
-
|
|
2145
|
-
- [ ] **Step 3: Mark Phase 1 complete**
|
|
2146
|
-
|
|
2147
|
-
```bash
|
|
2148
|
-
git tag phase-1-complete
|
|
2149
|
-
git log --oneline | head -20 # sanity check
|
|
2150
|
-
```
|
|
2151
|
-
|
|
2152
|
-
Phase 2 (NFO MCP server + Musicians + worktrees) gets its own plan.
|