chapterhouse 0.3.0 → 0.3.2

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.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * src/squad/init.ts
3
+ *
4
+ * Core scaffolding logic for `chapterhouse squad init`.
5
+ * Creates a complete `.squad/` directory structure with casting state,
6
+ * agent charters, and team coordination files.
7
+ *
8
+ * This is the mechanical implementation of the Squad coordinator's
9
+ * Init Mode Phase 2 flow (see `.squad/squad.agent.md`).
10
+ */
11
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { randomUUID } from 'node:crypto';
14
+ // ---------------------------------------------------------------------------
15
+ // Universe character pools
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Dune character pool — function/pressure/consequence-flavored names.
19
+ * Coordinator = "Squad", Scribe = "Scribe", Ralph = "Ralph" are exempted.
20
+ * Source: Issue #23 + `.squad/skills/dune-release-names/SKILL.md`
21
+ */
22
+ export const DUNE_CHARACTERS = [
23
+ 'Leto',
24
+ 'Jessica',
25
+ 'Stilgar',
26
+ 'Chani',
27
+ 'Gurney',
28
+ 'Duncan',
29
+ 'Thufir',
30
+ 'Piter',
31
+ 'Feyd',
32
+ 'Hawat',
33
+ 'Mapes',
34
+ 'Liet',
35
+ 'Halleck',
36
+ 'Idaho',
37
+ 'Shaddam',
38
+ 'Irulan',
39
+ ];
40
+ /** All available universe pools. Add new universes here. */
41
+ export const UNIVERSE_CHARACTERS = {
42
+ Dune: DUNE_CHARACTERS,
43
+ Firefly: ['Mal', 'Zoe', 'Wash', 'Inara', 'Jayne', 'Kaylee', 'Simon', 'River', 'Book', 'Shepherd'],
44
+ 'Star Wars': ['Luke', 'Leia', 'Han', 'Chewie', 'Obi-Wan', 'Yoda', 'Vader', 'Wedge', 'Lando', 'R2-D2', 'C-3PO', 'Poe'],
45
+ 'The Matrix': ['Neo', 'Trinity', 'Morpheus', 'Oracle', 'Tank', 'Apoc', 'Switch', 'Dozer', 'Cypher', 'Agent-Smith'],
46
+ 'Breaking Bad': ['Walt', 'Jesse', 'Skyler', 'Hank', 'Saul', 'Mike', 'Gus', 'Jane', 'Lydia', 'Todd', 'Andrea', 'Huell'],
47
+ };
48
+ export const DEFAULT_UNIVERSE = 'Dune';
49
+ /** Roles cast when teamSize ≤ 4 (Lead + 3 specialists + Scribe). */
50
+ const ROLE_PRESETS = {
51
+ 3: [
52
+ { slug: 'lead', role: 'Lead', description: 'Scope, decisions, code review', icon: '🏗️' },
53
+ { slug: 'dev', role: 'Developer', description: 'Implementation, features', icon: '⚙️' },
54
+ { slug: 'tester', role: 'Tester', description: 'Tests, quality, edge cases', icon: '🧪' },
55
+ ],
56
+ 4: [
57
+ { slug: 'lead', role: 'Lead', description: 'Scope, decisions, code review', icon: '🏗️' },
58
+ { slug: 'frontend', role: 'Frontend Dev', description: 'UI, components, styling', icon: '⚛️' },
59
+ { slug: 'backend', role: 'Backend Dev', description: 'APIs, database, services', icon: '🔧' },
60
+ { slug: 'tester', role: 'Tester', description: 'Tests, quality, edge cases', icon: '🧪' },
61
+ ],
62
+ 5: [
63
+ { slug: 'lead', role: 'Lead', description: 'Scope, decisions, architecture', icon: '🏗️' },
64
+ { slug: 'frontend', role: 'Frontend Dev', description: 'UI, components, styling', icon: '⚛️' },
65
+ { slug: 'backend', role: 'Backend Dev', description: 'APIs, database, services', icon: '🔧' },
66
+ { slug: 'tester', role: 'Tester', description: 'Tests, quality, edge cases', icon: '🧪' },
67
+ { slug: 'devops', role: 'DevOps', description: 'CI/CD, infra, deployment', icon: '🚀' },
68
+ ],
69
+ };
70
+ /** Always-present fixed-name agents (exempt from casting). */
71
+ const FIXED_AGENTS = [
72
+ { slug: 'scribe', role: 'Scribe', description: 'Memory, decisions, session logs', icon: '📋', castName: 'Scribe' },
73
+ { slug: 'ralph', role: 'Monitor', description: 'Work queue, backlog, keep-alive', icon: '🔄', castName: 'Ralph' },
74
+ ];
75
+ // ---------------------------------------------------------------------------
76
+ // Public API
77
+ // ---------------------------------------------------------------------------
78
+ /** Returns true if `.squad/team.md` with a populated `## Members` section exists. */
79
+ export function isSquadInitialized(projectRoot) {
80
+ const teamMd = join(projectRoot, '.squad', 'team.md');
81
+ if (!existsSync(teamMd))
82
+ return false;
83
+ const content = readFileSync(teamMd, 'utf-8');
84
+ return /^## Members/m.test(content) && content.includes('|');
85
+ }
86
+ /**
87
+ * Allocate cast names from the chosen universe for the given role slots.
88
+ * Returns a map of role slug → cast name.
89
+ */
90
+ export function allocateCast(roles, universe) {
91
+ const pool = UNIVERSE_CHARACTERS[universe] ?? DUNE_CHARACTERS;
92
+ const result = {};
93
+ pool.slice(0, roles.length).forEach((name, i) => {
94
+ result[roles[i].slug] = name;
95
+ });
96
+ return result;
97
+ }
98
+ /**
99
+ * Resolve the AgentRole[] for a given team size.
100
+ * Clamps to valid presets (3–5). Extras default to the 5-slot preset.
101
+ */
102
+ export function resolveRoles(teamSize) {
103
+ const clamped = Math.min(Math.max(teamSize, 3), 5);
104
+ return ROLE_PRESETS[clamped] ?? ROLE_PRESETS[4];
105
+ }
106
+ /**
107
+ * Scaffold a complete `.squad/` directory for `projectRoot`.
108
+ *
109
+ * Safe to call on an already-initialized project — if `.squad/team.md`
110
+ * already has roster entries, the call returns `null` without touching disk.
111
+ * Pass `{ force: true }` to override.
112
+ */
113
+ export function scaffoldSquad(projectRoot, config, opts = {}) {
114
+ if (!opts.force && isSquadInitialized(projectRoot)) {
115
+ return null; // idempotent guard
116
+ }
117
+ const { projectName, stack, goal, teamSize = 4, universe = DEFAULT_UNIVERSE, humanName = 'the team', } = config;
118
+ const squadDir = join(projectRoot, '.squad');
119
+ const roles = resolveRoles(teamSize);
120
+ const castMap = allocateCast(roles, universe);
121
+ const now = new Date().toISOString();
122
+ const assignmentId = randomUUID();
123
+ // Build flat agent list (cast agents + fixed agents)
124
+ const castAgents = roles.map(r => ({
125
+ slug: castMap[r.slug].toLowerCase(),
126
+ castName: castMap[r.slug],
127
+ role: r.role,
128
+ description: r.description,
129
+ icon: r.icon,
130
+ originalSlug: r.slug,
131
+ }));
132
+ // Directories to create
133
+ const dirs = [
134
+ squadDir,
135
+ join(squadDir, 'agents'),
136
+ join(squadDir, 'casting'),
137
+ join(squadDir, 'decisions'),
138
+ join(squadDir, 'decisions', 'inbox'),
139
+ join(squadDir, 'orchestration-log'),
140
+ join(squadDir, 'log'),
141
+ join(squadDir, 'skills'),
142
+ join(squadDir, 'identity'),
143
+ ...castAgents.map(a => join(squadDir, 'agents', a.slug)),
144
+ ...FIXED_AGENTS.map(a => join(squadDir, 'agents', a.slug)),
145
+ ];
146
+ dirs.forEach(d => mkdirSync(d, { recursive: true }));
147
+ // ── team.md ──────────────────────────────────────────────────────────────
148
+ const rosterRows = [
149
+ ...castAgents.map(a => `| ${a.icon} ${a.castName} | @${a.slug} | ${a.role} | ${a.description} |`),
150
+ `| 📋 Scribe | @scribe | Scribe | Memory, decisions, session logs |`,
151
+ `| 🔄 Ralph | @ralph | Monitor | Work queue, backlog, keep-alive |`,
152
+ ].join('\n');
153
+ writeFile(join(squadDir, 'team.md'), `# ${projectName} — Squad Team
154
+
155
+ ## Project Context
156
+
157
+ - **Project:** ${projectName}
158
+ - **Stack:** ${stack}
159
+ - **Goal:** ${goal}
160
+ - **Human:** ${humanName}
161
+ - **Universe:** ${universe}
162
+ - **Initialized:** ${now}
163
+
164
+ ## Members
165
+
166
+ | Icon | Name | Role | Description |
167
+ |------|------|------|-------------|
168
+ ${rosterRows}
169
+ `);
170
+ // ── routing.md ───────────────────────────────────────────────────────────
171
+ const routingRules = castAgents.map(a => `- **${a.role}** → @${a.slug}`).join('\n');
172
+ writeFile(join(squadDir, 'routing.md'), `# Routing Rules
173
+
174
+ Route tasks to the right agent based on their role.
175
+
176
+ ${routingRules}
177
+ - **Memory / Decisions** → @scribe
178
+ - **Backlog / Queue** → @ralph
179
+
180
+ ## Default
181
+
182
+ When unclear, route to the Lead.
183
+ `);
184
+ // ── ceremonies.md ────────────────────────────────────────────────────────
185
+ writeFile(join(squadDir, 'ceremonies.md'), `# Ceremonies
186
+
187
+ ## Sprint Rhythm
188
+
189
+ - **Sprint Planning** — Start of each sprint: define goals, assign issues
190
+ - **Daily Standup** — Each session: what's in progress, blockers
191
+ - **Sprint Review** — End of sprint: demo, retro, decisions
192
+
193
+ ## PR Review Gate
194
+
195
+ All PRs require approval from the Lead before merging.
196
+
197
+ ## Decision Log
198
+
199
+ Architectural decisions land in \`.squad/decisions/inbox/\` via each agent's decision-drop.
200
+ Scribe merges inbox items into \`.squad/decisions.md\`.
201
+ `);
202
+ // ── decisions.md ─────────────────────────────────────────────────────────
203
+ writeFile(join(squadDir, 'decisions.md'), `# Decisions
204
+
205
+ This file is maintained by Scribe. Append-only — do not edit existing entries.
206
+
207
+ <!-- git attribute: .squad/decisions.md merge=union -->
208
+ `);
209
+ // ── agent charters ───────────────────────────────────────────────────────
210
+ for (const agent of castAgents) {
211
+ writeFile(join(squadDir, 'agents', agent.slug, 'charter.md'), buildCharter({
212
+ castName: agent.castName,
213
+ role: agent.role,
214
+ description: agent.description,
215
+ projectName,
216
+ stack,
217
+ goal,
218
+ humanName,
219
+ universe,
220
+ }));
221
+ writeFile(join(squadDir, 'agents', agent.slug, 'history.md'), buildHistory({
222
+ castName: agent.castName,
223
+ role: agent.role,
224
+ projectName,
225
+ stack,
226
+ goal,
227
+ humanName,
228
+ now,
229
+ }));
230
+ }
231
+ // Scribe charter
232
+ writeFile(join(squadDir, 'agents', 'scribe', 'charter.md'), `# Scribe — Memory Keeper
233
+
234
+ > Keeps the record. Remembers everything so everyone else can focus.
235
+
236
+ ## Identity
237
+
238
+ - **Name:** Scribe
239
+ - **Role:** Scribe / Memory Keeper
240
+ - **Project:** ${projectName}
241
+
242
+ ## What I Own
243
+
244
+ - \`.squad/decisions.md\` — merge inbox items, maintain canonical list
245
+ - \`.squad/orchestration-log/\` — session summaries
246
+ - Cross-agent context sharing
247
+
248
+ ## Constraints
249
+
250
+ - Read-only on all non-\`.squad/\` files
251
+ - Never authors code or domain artifacts
252
+ `);
253
+ writeFile(join(squadDir, 'agents', 'scribe', 'history.md'), `# Scribe History\n\n## Init — ${now}\n\nInitialized for **${projectName}** by ${humanName}.\nStack: ${stack}\nGoal: ${goal}\n`);
254
+ // Ralph charter
255
+ writeFile(join(squadDir, 'agents', 'ralph', 'charter.md'), `# Ralph — Monitor
256
+
257
+ > Keeps the queue moving. Never lets work pile up unnoticed.
258
+
259
+ ## Identity
260
+
261
+ - **Name:** Ralph
262
+ - **Role:** Monitor / Work Queue
263
+ - **Project:** ${projectName}
264
+
265
+ ## What I Own
266
+
267
+ - GitHub issue triage and backlog health
268
+ - Keep-alive heartbeat checks
269
+ - Sprint queue management
270
+ `);
271
+ writeFile(join(squadDir, 'agents', 'ralph', 'history.md'), `# Ralph History\n\n## Init — ${now}\n\nInitialized for **${projectName}** by ${humanName}.\n`);
272
+ // ── casting state ─────────────────────────────────────────────────────────
273
+ const policyJson = {
274
+ casting_policy_version: '1.1',
275
+ allowlist_universes: [universe],
276
+ universe_capacity: {
277
+ [universe]: (UNIVERSE_CHARACTERS[universe] ?? DUNE_CHARACTERS).length,
278
+ },
279
+ };
280
+ const registryAgents = {};
281
+ for (const a of castAgents) {
282
+ registryAgents[a.originalSlug] = {
283
+ persistent_name: a.castName,
284
+ universe,
285
+ created_at: now,
286
+ legacy_named: false,
287
+ status: 'active',
288
+ };
289
+ }
290
+ for (const a of FIXED_AGENTS) {
291
+ registryAgents[a.slug] = {
292
+ persistent_name: a.castName,
293
+ universe: 'exempt',
294
+ created_at: now,
295
+ legacy_named: false,
296
+ status: 'active',
297
+ };
298
+ }
299
+ const registryJson = { agents: registryAgents };
300
+ const agentSnapshot = {};
301
+ for (const a of castAgents)
302
+ agentSnapshot[a.originalSlug] = a.castName;
303
+ agentSnapshot['scribe'] = 'Scribe';
304
+ agentSnapshot['ralph'] = 'Ralph';
305
+ const historyJson = {
306
+ universe_usage_history: [{ universe, assignment_id: assignmentId, used_at: now }],
307
+ assignment_cast_snapshots: {
308
+ [assignmentId]: { universe, agents: agentSnapshot, created_at: now },
309
+ },
310
+ };
311
+ writeFile(join(squadDir, 'casting', 'policy.json'), JSON.stringify(policyJson, null, 2));
312
+ writeFile(join(squadDir, 'casting', 'registry.json'), JSON.stringify(registryJson, null, 2));
313
+ writeFile(join(squadDir, 'casting', 'history.json'), JSON.stringify(historyJson, null, 2));
314
+ // ── .gitattributes merge driver ───────────────────────────────────────────
315
+ ensureGitattributes(projectRoot);
316
+ return {
317
+ squadDir,
318
+ agents: castAgents.map(a => ({ slug: a.slug, castName: a.castName, role: a.role })),
319
+ universe,
320
+ assignmentId,
321
+ };
322
+ }
323
+ // ---------------------------------------------------------------------------
324
+ // Private helpers
325
+ // ---------------------------------------------------------------------------
326
+ function writeFile(path, content) {
327
+ writeFileSync(path, content, 'utf-8');
328
+ }
329
+ function buildCharter(opts) {
330
+ return `# ${opts.castName} — ${opts.role}
331
+
332
+ > ${opts.description}
333
+
334
+ ## Identity
335
+
336
+ - **Name:** ${opts.castName}
337
+ - **Role:** ${opts.role}
338
+ - **Universe:** ${opts.universe}
339
+ - **Project:** ${opts.projectName}
340
+
341
+ ## Project Context
342
+
343
+ - **Stack:** ${opts.stack}
344
+ - **Goal:** ${opts.goal}
345
+ - **Human:** ${opts.humanName}
346
+
347
+ ## What I Own
348
+
349
+ _Defined by role: ${opts.role}. Update this section with project-specific scope._
350
+
351
+ ## Constraints
352
+
353
+ - Follow the team's conventions in \`.squad/decisions.md\`
354
+ - All commits must follow Conventional Commits
355
+ - All PRs must reference the issue they close
356
+
357
+ ## Voice
358
+
359
+ Direct. Focused on delivering quality work for ${opts.projectName}.
360
+ `;
361
+ }
362
+ function buildHistory(opts) {
363
+ return `# ${opts.castName} History
364
+
365
+ <!-- git attribute: .squad/agents/${opts.castName.toLowerCase()}/history.md merge=union -->
366
+
367
+ ## Init — ${opts.now}
368
+
369
+ Joined **${opts.projectName}** as ${opts.role}, working with ${opts.humanName}.
370
+
371
+ **Stack:** ${opts.stack}
372
+ **Goal:** ${opts.goal}
373
+ `;
374
+ }
375
+ const GITATTRIBUTES_BLOCK = `
376
+ # Squad — append-only conflict-free merge
377
+ .squad/decisions.md merge=union
378
+ .squad/agents/*/history.md merge=union
379
+ .squad/log/** merge=union
380
+ .squad/orchestration-log/** merge=union
381
+ `;
382
+ function ensureGitattributes(projectRoot) {
383
+ const gaPath = join(projectRoot, '.gitattributes');
384
+ const marker = '.squad/decisions.md merge=union';
385
+ if (existsSync(gaPath)) {
386
+ const existing = readFileSync(gaPath, 'utf-8');
387
+ if (!existing.includes(marker)) {
388
+ appendFileSync(gaPath, GITATTRIBUTES_BLOCK);
389
+ }
390
+ }
391
+ else {
392
+ writeFileSync(gaPath, GITATTRIBUTES_BLOCK.trimStart());
393
+ }
394
+ }
395
+ //# sourceMappingURL=init.js.map