agentic-team 0.0.0-stage → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -2
  3. package/bin/build-template.js +208 -0
  4. package/bin/index.js +1139 -0
  5. package/package.json +41 -4
  6. package/template/.claude-plugin/plugin.json +10 -0
  7. package/template/.orchestra/README.md +401 -0
  8. package/template/.orchestra/blueprints/README.md +87 -0
  9. package/template/.orchestra/blueprints/api-only.md +46 -0
  10. package/template/.orchestra/blueprints/component-crud-resource.md +25 -0
  11. package/template/.orchestra/blueprints/saas-starter.md +69 -0
  12. package/template/.orchestra/config.yml +277 -0
  13. package/template/.orchestra/roles/orchestrator.md +105 -0
  14. package/template/.orchestra/roles/product-manager.md +438 -0
  15. package/template/.orchestra/scripts/compact-diff.awk +45 -0
  16. package/template/.orchestra/stacks/README.md +81 -0
  17. package/template/.orchestra/stacks/_lib/common.sh +3095 -0
  18. package/template/.orchestra/stacks/_repo/setup.sh +1109 -0
  19. package/template/.orchestra/stacks/backend-hono/claude/commands/endpoint.md +13 -0
  20. package/template/.orchestra/stacks/backend-hono/claude/skills/api-endpoint/SKILL.md +111 -0
  21. package/template/.orchestra/stacks/backend-hono/claude/skills/testing/SKILL.md +75 -0
  22. package/template/.orchestra/stacks/backend-hono/setup.sh +5945 -0
  23. package/template/.orchestra/stacks/backend-hono/stack.md +195 -0
  24. package/template/.orchestra/stacks/mobile-android-native/claude/commands/component.md +13 -0
  25. package/template/.orchestra/stacks/mobile-android-native/claude/skills/design-component/SKILL.md +58 -0
  26. package/template/.orchestra/stacks/mobile-android-native/setup.sh +607 -0
  27. package/template/.orchestra/stacks/mobile-android-native/stack.md +138 -0
  28. package/template/.orchestra/stacks/mobile-expo/claude/commands/component.md +12 -0
  29. package/template/.orchestra/stacks/mobile-expo/claude/skills/design-component/SKILL.md +92 -0
  30. package/template/.orchestra/stacks/mobile-expo/setup.sh +470 -0
  31. package/template/.orchestra/stacks/mobile-expo/stack.md +141 -0
  32. package/template/.orchestra/stacks/mobile-ios-native/claude/commands/component.md +12 -0
  33. package/template/.orchestra/stacks/mobile-ios-native/claude/skills/design-component/SKILL.md +58 -0
  34. package/template/.orchestra/stacks/mobile-ios-native/setup.sh +468 -0
  35. package/template/.orchestra/stacks/mobile-ios-native/stack.md +130 -0
  36. package/template/.orchestra/stacks/web-nextjs/claude/commands/component.md +12 -0
  37. package/template/.orchestra/stacks/web-nextjs/claude/skills/design-component/SKILL.md +100 -0
  38. package/template/.orchestra/stacks/web-nextjs/claude/skills/design-tokens/SKILL.md +61 -0
  39. package/template/.orchestra/stacks/web-nextjs/setup.sh +128 -0
  40. package/template/.orchestra/stacks/web-nextjs/stack.md +170 -0
  41. package/template/.orchestra/stacks/web-vite-react/claude/commands/component.md +12 -0
  42. package/template/.orchestra/stacks/web-vite-react/claude/skills/design-component/SKILL.md +100 -0
  43. package/template/.orchestra/stacks/web-vite-react/claude/skills/design-tokens/SKILL.md +61 -0
  44. package/template/.orchestra/stacks/web-vite-react/setup.sh +185 -0
  45. package/template/.orchestra/stacks/web-vite-react/stack.md +177 -0
  46. package/template/.orchestra/trackers/github.sh +467 -0
  47. package/template/CLAUDE.md +54 -0
  48. package/template/agents/architect.md +83 -0
  49. package/template/agents/engineer-high.md +17 -0
  50. package/template/agents/engineer-low.md +17 -0
  51. package/template/agents/engineer-max.md +17 -0
  52. package/template/agents/engineer-medium.md +17 -0
  53. package/template/agents/engineer-xhigh.md +17 -0
  54. package/template/agents/lead.md +870 -0
  55. package/template/agents/milestone-reviewer.md +63 -0
  56. package/template/agents/orchestrator.md +105 -0
  57. package/template/agents/product-manager.md +438 -0
  58. package/template/agents/reviewer.md +301 -0
  59. package/template/agents/scaffolder.md +180 -0
  60. package/template/agents/tracker.md +202 -0
  61. package/template/commands/blueprint.md +23 -0
  62. package/template/commands/create-role.md +64 -0
  63. package/template/commands/export.md +45 -0
  64. package/template/commands/help.md +55 -0
  65. package/template/commands/hotfix.md +12 -0
  66. package/template/commands/orchestrator.md +12 -0
  67. package/template/commands/pm.md +18 -0
  68. package/template/commands/rewind.md +61 -0
  69. package/template/commands/setup.md +14 -0
  70. package/template/commands/start.md +29 -0
  71. package/template/commands/status.md +11 -0
  72. package/template/commands/sync.md +73 -0
  73. package/template/commands/verifier.md +53 -0
  74. package/template/hooks/audit-context.sh +153 -0
  75. package/template/hooks/enforce-role.sh +175 -0
  76. package/template/hooks/protect-system-files.sh +123 -0
  77. package/template/hooks/review-rounds.sh +172 -0
  78. package/template/rules/acceptance-check.orchestra.md +11 -0
  79. package/template/rules/analytics-kpi.orchestra.md +56 -0
  80. package/template/rules/code-standards.orchestra.md +34 -0
  81. package/template/rules/comments.orchestra.md +77 -0
  82. package/template/rules/commit-format.orchestra.md +20 -0
  83. package/template/rules/dependency-upgrade.orchestra.md +112 -0
  84. package/template/rules/design-parity.orchestra.md +170 -0
  85. package/template/rules/observability.orchestra.md +118 -0
  86. package/template/rules/performance.orchestra.md +186 -0
  87. package/template/rules/phase-limits.orchestra.md +3 -0
  88. package/template/rules/pull-request.orchestra.md +213 -0
  89. package/template/rules/role-boundaries.orchestra.md +117 -0
  90. package/template/rules/security.orchestra.md +106 -0
  91. package/template/rules/stuck-detection.orchestra.md +25 -0
  92. package/template/rules/testing-standards.orchestra.md +214 -0
  93. package/template/rules/tracker-authority.orchestra.md +190 -0
  94. package/template/rules/verification-gate.orchestra.md +56 -0
  95. package/template/skills/accessibility/SKILL.md +31 -0
  96. package/template/skills/android-testing/SKILL.md +112 -0
  97. package/template/skills/architecture/SKILL.md +175 -0
  98. package/template/skills/auth-setup/SKILL.md +105 -0
  99. package/template/skills/best-practices/SKILL.md +29 -0
  100. package/template/skills/code-optimizer/SKILL.md +27 -0
  101. package/template/skills/core-web-vitals/SKILL.md +32 -0
  102. package/template/skills/crud-api/SKILL.md +32 -0
  103. package/template/skills/debug/SKILL.md +28 -0
  104. package/template/skills/deployment/SKILL.md +104 -0
  105. package/template/skills/error-handling/SKILL.md +154 -0
  106. package/template/skills/frontend-design/SKILL.md +30 -0
  107. package/template/skills/fullstack-infrastructure/SKILL.md +108 -0
  108. package/template/skills/ios-testing/SKILL.md +100 -0
  109. package/template/skills/mobile-testing/SKILL.md +102 -0
  110. package/template/skills/observability/SKILL.md +91 -0
  111. package/template/skills/project-structure/SKILL.md +114 -0
  112. package/template/skills/push-notifications/SKILL.md +146 -0
  113. package/template/skills/react-best-practices/SKILL.md +31 -0
  114. package/template/skills/realtime/SKILL.md +146 -0
  115. package/template/skills/review/SKILL.md +82 -0
  116. package/template/skills/testing/SKILL.md +44 -0
  117. package/template/skills/web-quality-audit/SKILL.md +47 -0
  118. package/template/skills/web-testing/SKILL.md +120 -0
package/bin/index.js ADDED
@@ -0,0 +1,1139 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execFileSync } = require("node:child_process");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+
7
+ const targetDir = process.cwd();
8
+ const templateDir = path.join(__dirname, "..", "template");
9
+ const pkgVersion = require("../package.json").version;
10
+
11
+ // Plugin-to-standalone mapping: plugin root dirs → .claude/ subdirs
12
+ const PLUGIN_TO_CLAUDE = {
13
+ agents: ".claude/agents",
14
+ commands: ".claude/commands/orchestra",
15
+ rules: ".claude/rules",
16
+ skills: ".claude/skills",
17
+ hooks: ".claude/hooks",
18
+ };
19
+
20
+ const ORCHESTRA_SECTION_START = "<!-- orchestra -->";
21
+ const ORCHESTRA_SECTION_END = "<!-- /orchestra -->";
22
+
23
+ // User directories to preserve during upgrade
24
+ // roles is here because /orchestra:create-role writes custom roles into it.
25
+ // Without it, upgrading deletes every role the user created.
26
+ const ORCHESTRA_USER_DIRS = ["milestones", "blueprints", "roles"];
27
+ const CLAUDE_USER_DIRS = ["skills", "rules"];
28
+
29
+ // An upgrade is destructive in the middle: .orchestra/ is removed and rebuilt
30
+ // before the user's data goes back. Everything saved for that window lives in
31
+ // one directory carrying a marker, and the marker is removed only once the
32
+ // restore has actually finished. A run that finds a marked backup did not
33
+ // write it — so it refuses rather than clearing the only copy of somebody's
34
+ // milestones.
35
+ const BACKUP_DIR_PREFIX = ".orchestra-backup-run-";
36
+ const BACKUP_MARKER = "IN_PROGRESS";
37
+
38
+ // The pre-4.2 scheme used one directory per backed-up folder and deleted them
39
+ // at the start of the next run. A leftover therefore means the previous run
40
+ // died mid-way, and it is evidence, not litter.
41
+ const LEGACY_BACKUPS = [
42
+ ...["milestones", "blueprints", "roles"].map((d) => `.orchestra-backup-${d}`),
43
+ ...["skills", "rules"].map((d) => `.claude-backup-${d}`),
44
+ ".orchestra-backup-config.yml",
45
+ ];
46
+
47
+ // Keys this project used to have and no longer reads. The merge preserves an
48
+ // unknown key on purpose — it is config somebody wrote — but a preserved key
49
+ // that nothing reads looks live and is not, so the upgrade names it instead of
50
+ // keeping it silently.
51
+ const RETIRED_CONFIG_KEYS = [
52
+ ["delivery.mode", "removed in 6.0.0 — --auto decides who merges; every task gets a PR"],
53
+ [
54
+ "pipeline.models",
55
+ "removed in 7.0.0 — one model (pipeline.model); a phase carries effort instead",
56
+ ],
57
+ ["pipeline.default_complexity", "removed in 7.0.0 — pipeline.default_effort"],
58
+ ];
59
+
60
+ const ALLOW_PERMISSIONS = [
61
+ "Bash(*)",
62
+ "Read(*)",
63
+ "Write(*)",
64
+ "Edit(*)",
65
+ "Glob(*)",
66
+ "Grep(*)",
67
+ "Agent(*)",
68
+ "WebFetch(*)",
69
+ "WebSearch(*)",
70
+ ];
71
+
72
+ function parseArgs() {
73
+ const args = process.argv.slice(2);
74
+ const flags = { skipPermissions: false, help: false };
75
+
76
+ for (const arg of args) {
77
+ switch (arg) {
78
+ case "--dangerously-skip-permissions":
79
+ flags.skipPermissions = true;
80
+ break;
81
+ case "--help":
82
+ case "-h":
83
+ flags.help = true;
84
+ break;
85
+ default:
86
+ // Ignore files passed by lint-staged or other tools
87
+ break;
88
+ }
89
+ }
90
+
91
+ return flags;
92
+ }
93
+
94
+ function showHelp() {
95
+ console.log("");
96
+ console.log(" Usage: npx agentic-team [command] [options]");
97
+ console.log("");
98
+ console.log(" Commands:");
99
+ console.log(" (none) Install or upgrade Orchestra in the current directory");
100
+ console.log(" new <name> Create a GitHub repository, clone it, and install into it");
101
+ console.log("");
102
+ console.log(" Options:");
103
+ console.log(" --public new: create a public repository");
104
+ console.log(" --dangerously-skip-permissions Auto-allow all tool permissions");
105
+ console.log(" --help, -h Show this help");
106
+ console.log("");
107
+ console.log(" Examples:");
108
+ console.log(" npx agentic-team");
109
+ console.log(" npx agentic-team new daily-intake");
110
+ console.log("");
111
+ }
112
+
113
+ function copyDirRecursive(src, dest) {
114
+ if (!fs.existsSync(dest)) {
115
+ fs.mkdirSync(dest, { recursive: true });
116
+ }
117
+ const entries = fs.readdirSync(src, { withFileTypes: true });
118
+ for (const entry of entries) {
119
+ const srcPath = path.join(src, entry.name);
120
+ const destPath = path.join(dest, entry.name);
121
+ if (entry.isDirectory()) {
122
+ copyDirRecursive(srcPath, destPath);
123
+ } else if (entry.isSymbolicLink()) {
124
+ // Dereference rather than recreate: a relative link that resolved in
125
+ // the package will not resolve in the user's tree.
126
+ try {
127
+ fs.unlinkSync(destPath);
128
+ } catch {
129
+ // Optional: on a fresh install there is nothing to remove. The unlink
130
+ // is only here to clear a stale link left by a previous version.
131
+ }
132
+ fs.copyFileSync(fs.realpathSync(srcPath), destPath);
133
+ } else {
134
+ fs.copyFileSync(srcPath, destPath);
135
+ }
136
+ }
137
+ }
138
+
139
+ // A user's own entry, copied as it is. Unlike copyDirRecursive, a symlink
140
+ // stays a link with its target text unchanged: fs.cpSync rewrites a relative
141
+ // link to an absolute path on this machine, and throws on a dangling one.
142
+ function copyAsIs(src, dest) {
143
+ const st = fs.lstatSync(src);
144
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
145
+ if (st.isSymbolicLink()) {
146
+ fs.symlinkSync(fs.readlinkSync(src), dest);
147
+ } else if (st.isDirectory()) {
148
+ fs.mkdirSync(dest, { recursive: true });
149
+ for (const name of fs.readdirSync(src)) copyAsIs(path.join(src, name), path.join(dest, name));
150
+ } else {
151
+ fs.copyFileSync(src, dest);
152
+ }
153
+ }
154
+
155
+ function rmDirRecursive(dir) {
156
+ if (!fs.existsSync(dir)) return;
157
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
158
+ for (const entry of entries) {
159
+ const fullPath = path.join(dir, entry.name);
160
+ if (entry.isDirectory()) {
161
+ rmDirRecursive(fullPath);
162
+ } else {
163
+ fs.unlinkSync(fullPath);
164
+ }
165
+ }
166
+ fs.rmdirSync(dir);
167
+ }
168
+
169
+ /** Backups from a run that never finished restoring. Never delete these. */
170
+ function findUnfinishedBackups() {
171
+ const found = [];
172
+ for (const entry of fs.readdirSync(targetDir)) {
173
+ if (entry.startsWith(BACKUP_DIR_PREFIX)) {
174
+ if (fs.existsSync(path.join(targetDir, entry, BACKUP_MARKER))) found.push(entry);
175
+ continue;
176
+ }
177
+ if (LEGACY_BACKUPS.includes(entry)) found.push(entry);
178
+ }
179
+ return found;
180
+ }
181
+
182
+ /**
183
+ * Merge the user's config.yml onto the template's.
184
+ *
185
+ * The template supplies the structure and every comment; the user's file
186
+ * supplies the values. A key the template does not know about is carried over
187
+ * rather than dropped — it is config somebody wrote on purpose.
188
+ *
189
+ * Indentation decides nesting, so this does not care how deep the file goes.
190
+ * A block sequence is merged as a unit: a list the user edited replaces the
191
+ * template's list entirely. Merging the *members* would be worse than either
192
+ * choice, because it would resurrect entries the user deliberately removed.
193
+ *
194
+ * Every helper lives inside this function on purpose: test/merge-config.test.js
195
+ * extracts it by regex and evaluates it alone.
196
+ */
197
+ function mergeConfigYaml(userContent, templateContent) {
198
+ const KEY = /^([A-Za-z_][\w_-]*):\s*(.*)$/;
199
+ const ITEM = /^-\s*(.*)$/;
200
+
201
+ // A scalar leaf parses to a string, a block sequence to an array, anything
202
+ // else to a map.
203
+ function parseYaml(lines) {
204
+ const root = {};
205
+ const stack = [{ indent: -2, node: root, parent: null, key: null }];
206
+
207
+ for (const raw of lines) {
208
+ const text = raw.trim();
209
+ if (text === "" || text.startsWith("#")) continue;
210
+ const indent = raw.search(/\S/);
211
+
212
+ const item = ITEM.exec(text);
213
+ if (item) {
214
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
215
+ const top = stack[stack.length - 1];
216
+ if (!top.parent) continue;
217
+ if (!Array.isArray(top.parent[top.key])) top.parent[top.key] = [];
218
+ top.parent[top.key].push(item[1]);
219
+ continue;
220
+ }
221
+
222
+ const kv = KEY.exec(text);
223
+ if (!kv) continue;
224
+
225
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
226
+ const parent = stack[stack.length - 1].node;
227
+ const key = kv[1];
228
+ const value = kv[2].trim();
229
+
230
+ if (value === "") {
231
+ // A map or a list — whichever the lines below turn out to be.
232
+ const node = {};
233
+ parent[key] = node;
234
+ stack.push({ indent, node, parent, key });
235
+ } else {
236
+ parent[key] = value;
237
+ }
238
+ }
239
+ return root;
240
+ }
241
+
242
+ function lookup(node, keyPath) {
243
+ let cur = node;
244
+ for (const k of keyPath) {
245
+ if (!cur || typeof cur !== "object" || Array.isArray(cur)) return undefined;
246
+ cur = cur[k];
247
+ }
248
+ return cur;
249
+ }
250
+
251
+ function serialize(node, indent) {
252
+ const out = [];
253
+ const pad = " ".repeat(indent);
254
+ for (const [k, v] of Object.entries(node)) {
255
+ if (Array.isArray(v)) {
256
+ out.push(`${pad}${k}:`);
257
+ for (const it of v) out.push(`${pad} - ${it}`);
258
+ } else if (v && typeof v === "object") {
259
+ out.push(`${pad}${k}:`);
260
+ out.push(...serialize(v, indent + 2));
261
+ } else {
262
+ out.push(`${pad}${k}: ${v}`);
263
+ }
264
+ }
265
+ return out;
266
+ }
267
+
268
+ // What the user has and the template has never heard of, at any depth.
269
+ function extras(userNode, tmplNode) {
270
+ const out = {};
271
+ for (const [k, v] of Object.entries(userNode)) {
272
+ const t = tmplNode ? tmplNode[k] : undefined;
273
+ if (t === undefined) {
274
+ out[k] = v;
275
+ continue;
276
+ }
277
+ const bothMaps =
278
+ v &&
279
+ typeof v === "object" &&
280
+ !Array.isArray(v) &&
281
+ t &&
282
+ typeof t === "object" &&
283
+ !Array.isArray(t);
284
+ if (!bothMaps) continue;
285
+ const sub = extras(v, t);
286
+ if (Object.keys(sub).length) out[k] = sub;
287
+ }
288
+ return out;
289
+ }
290
+
291
+ const userParsed = parseYaml(userContent.split("\n"));
292
+ const templateParsed = parseYaml(templateContent.split("\n"));
293
+
294
+ const merged = [];
295
+ const keyPath = [];
296
+ const indents = [];
297
+ let replacedList = false;
298
+
299
+ for (const raw of templateContent.split("\n")) {
300
+ const text = raw.trim();
301
+
302
+ // The user's list has already been written in place of this one.
303
+ if (replacedList) {
304
+ if (ITEM.test(text)) continue;
305
+ replacedList = false;
306
+ }
307
+
308
+ if (text === "" || text.startsWith("#") || ITEM.test(text)) {
309
+ merged.push(raw);
310
+ continue;
311
+ }
312
+
313
+ const kv = KEY.exec(text);
314
+ if (!kv) {
315
+ merged.push(raw);
316
+ continue;
317
+ }
318
+
319
+ const indent = raw.search(/\S/);
320
+ while (indents.length && indents[indents.length - 1] >= indent) {
321
+ indents.pop();
322
+ keyPath.pop();
323
+ }
324
+ keyPath.push(kv[1]);
325
+ indents.push(indent);
326
+
327
+ const userValue = lookup(userParsed, keyPath);
328
+
329
+ if (kv[2].trim() === "") {
330
+ merged.push(raw);
331
+ if (Array.isArray(userValue) && Array.isArray(lookup(templateParsed, keyPath))) {
332
+ const pad = " ".repeat(indent + 2);
333
+ for (const it of userValue) merged.push(`${pad}- ${it}`);
334
+ replacedList = true;
335
+ }
336
+ continue;
337
+ }
338
+
339
+ if (typeof userValue === "string") {
340
+ merged.push(`${" ".repeat(indent)}${kv[1]}: ${userValue}`);
341
+ continue;
342
+ }
343
+ merged.push(raw);
344
+ }
345
+
346
+ // Carried-over keys, split by depth.
347
+ //
348
+ // A nested one has to go *inside* the block it belongs to. Emitting it at
349
+ // the end produced a second `delivery:` at top level, and YAML rejects a
350
+ // duplicate map key outright — so an upgrade of any config with a custom
351
+ // key under a known section wrote a file that no longer parsed. The key was
352
+ // preserved, technically, in a file nothing could read.
353
+ const carriedAll = extras(userParsed, templateParsed);
354
+ const nested = {};
355
+ const topLevel = {};
356
+ for (const [k, v] of Object.entries(carriedAll)) {
357
+ const inTemplate = templateParsed[k] !== undefined;
358
+ if (inTemplate && v && typeof v === "object" && !Array.isArray(v)) nested[k] = v;
359
+ else topLevel[k] = v;
360
+ }
361
+
362
+ // Insert each nested group at the end of its section — the line before the
363
+ // next top-level key, so it lands under the right parent.
364
+ for (const [section, body] of Object.entries(nested)) {
365
+ const start = merged.findIndex((l) => l.startsWith(`${section}:`));
366
+ if (start === -1) {
367
+ topLevel[section] = body;
368
+ continue;
369
+ }
370
+ let end = start + 1;
371
+ while (end < merged.length && !/^[A-Za-z_]/.test(merged[end])) end++;
372
+ while (end > start + 1 && merged[end - 1].trim() === "") end--;
373
+ merged.splice(
374
+ end,
375
+ 0,
376
+ "",
377
+ " # ── Preserved from your previous config ──",
378
+ ...serialize(body, 2),
379
+ );
380
+ }
381
+
382
+ const carried = serialize(topLevel, 0);
383
+ if (carried.length) {
384
+ merged.push("", "# ── Preserved from your previous config ──", ...carried);
385
+ }
386
+
387
+ return merged.join("\n");
388
+ }
389
+
390
+ function extractOrchestraSection(content) {
391
+ const startIdx = content.indexOf(ORCHESTRA_SECTION_START);
392
+ if (startIdx === -1) return null;
393
+ const endIdx = content.indexOf(ORCHESTRA_SECTION_END, startIdx);
394
+ if (endIdx === -1) return content.slice(startIdx);
395
+ return content.slice(startIdx, endIdx + ORCHESTRA_SECTION_END.length);
396
+ }
397
+
398
+ /**
399
+ * Smart merge for user directories (skills, rules, blueprints):
400
+ * - entries present in template = system files → always updated from template
401
+ * - .orchestra.md files = legacy system files → skip (backward compat)
402
+ * - remaining entries = user files → preserved
403
+ */
404
+ function smartMergeDir(backupPath, restorePath, templateDirPath) {
405
+ const templateFiles = fs.existsSync(templateDirPath) ? fs.readdirSync(templateDirPath) : [];
406
+ const backupFiles = fs.readdirSync(backupPath).filter((f) => f !== ".gitkeep");
407
+ let restored = 0;
408
+
409
+ for (const file of backupFiles) {
410
+ // Skip system entries: in template OR legacy .orchestra.md files
411
+ if (templateFiles.includes(file) || file.endsWith(".orchestra.md")) continue;
412
+
413
+ const srcFile = path.join(backupPath, file);
414
+ try {
415
+ const stat = fs.lstatSync(srcFile);
416
+ const destFile = path.join(restorePath, file);
417
+ if (stat.isDirectory()) {
418
+ copyDirRecursive(srcFile, destFile);
419
+ } else if (stat.isFile()) {
420
+ fs.copyFileSync(srcFile, destFile);
421
+ }
422
+ // Skip broken symlinks and other non-regular entries
423
+ restored++;
424
+ } catch {
425
+ // Entry inaccessible — skip silently
426
+ }
427
+ }
428
+
429
+ return restored;
430
+ }
431
+
432
+ // Which files Orchestra put down, recorded at install time.
433
+ //
434
+ // Rules carry a `.orchestra.md` suffix, so both the upgrade and the hook can
435
+ // tell a system rule from one the user wrote. Skills lost that marker when
436
+ // they became directories, and nothing replaced it: the hook now blocks every
437
+ // skill including the user's own — the extension point docs/skills.md tells
438
+ // them to use — and the upgrade restores a retired system skill forever,
439
+ // because it cannot tell it from a skill somebody wrote.
440
+ //
441
+ // The manifest is that marker, for everything, in one place both sides read.
442
+ const MANIFEST_PATH = ".orchestra/.installed.json";
443
+
444
+ /** Every destination path the template installs, repo-relative and POSIX. */
445
+ function templateFileList() {
446
+ const out = [];
447
+ const walk = (dir, prefix) => {
448
+ if (!fs.existsSync(dir)) return;
449
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
450
+ const rel = `${prefix}/${entry.name}`;
451
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), rel);
452
+ else out.push(rel);
453
+ }
454
+ };
455
+ walk(path.join(templateDir, ".orchestra"), ".orchestra");
456
+ for (const [pluginDir, claudeSubdir] of Object.entries(PLUGIN_TO_CLAUDE)) {
457
+ walk(path.join(templateDir, pluginDir), claudeSubdir);
458
+ }
459
+ return out.sort();
460
+ }
461
+
462
+ function readManifest() {
463
+ const p = path.join(targetDir, MANIFEST_PATH);
464
+ if (!fs.existsSync(p)) return null;
465
+ try {
466
+ const m = JSON.parse(fs.readFileSync(p, "utf-8"));
467
+ return Array.isArray(m.files) ? m : null;
468
+ } catch {
469
+ // A corrupt manifest means retirement cannot run this time. Reported, and
470
+ // not fatal: the install itself is still correct without it.
471
+ console.log(" [!] .orchestra/.installed.json is not valid JSON — nothing retired this run");
472
+ return null;
473
+ }
474
+ }
475
+
476
+ function writeManifest(files) {
477
+ fs.writeFileSync(
478
+ path.join(targetDir, MANIFEST_PATH),
479
+ `${JSON.stringify({ version: pkgVersion, files }, null, 2)}\n`,
480
+ );
481
+ }
482
+
483
+ /**
484
+ * Delete what a previous version installed and this one no longer ships.
485
+ *
486
+ * Without it a retired skill is indistinguishable from one the user wrote, so
487
+ * it survives every future upgrade and keeps teaching whatever it taught.
488
+ */
489
+ function retireRemoved(previous, current) {
490
+ if (!previous) return 0;
491
+ const keep = new Set(current);
492
+ let removed = 0;
493
+ for (const rel of previous.files) {
494
+ if (keep.has(rel)) continue;
495
+ const full = path.join(targetDir, rel);
496
+ if (!fs.existsSync(full)) continue;
497
+ fs.unlinkSync(full);
498
+ // The directory it lived in is ours too when nothing is left in it.
499
+ let dir = path.dirname(full);
500
+ while (dir.startsWith(targetDir + path.sep) && fs.readdirSync(dir).length === 0) {
501
+ fs.rmdirSync(dir);
502
+ dir = path.dirname(dir);
503
+ }
504
+ removed++;
505
+ }
506
+ return removed;
507
+ }
508
+
509
+ // Shared by every registerXHook below: is this hook's own script already one
510
+ // of the commands under this settings.hooks[event] array? Three call sites
511
+ // asked the identical question before this was pulled out.
512
+ function hookAlreadyRegistered(entries, marker) {
513
+ return (entries || []).some((entry) =>
514
+ (entry.hooks || []).some((h) => typeof h.command === "string" && h.command.includes(marker)),
515
+ );
516
+ }
517
+
518
+ // A hook is only enforcement once it is registered. Shipping the script
519
+ // without this leaves an executable nobody runs — the exact failure the rules
520
+ // exist to prevent.
521
+ //
522
+ // settings.json rather than settings.local.json: this encodes a team rule,
523
+ // not a personal preference, and it is meant to be committed.
524
+ function registerHooks() {
525
+ const claudeDir = path.join(targetDir, ".claude");
526
+ const settingsPath = path.join(claudeDir, "settings.json");
527
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a shell parameter expansion, resolved by bash at hook time — not a JS template.
528
+ const command = 'bash "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/protect-system-files.sh"';
529
+
530
+ if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
531
+
532
+ let settings = {};
533
+ if (fs.existsSync(settingsPath)) {
534
+ try {
535
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
536
+ } catch {
537
+ // A malformed settings.json disables every setting in it. Refuse to
538
+ // overwrite someone's file on the strength of a parse error.
539
+ console.log(" [!] .claude/settings.json is not valid JSON — hook not registered");
540
+ return;
541
+ }
542
+ }
543
+
544
+ settings.hooks = settings.hooks || {};
545
+ const pre = settings.hooks.PreToolUse || [];
546
+
547
+ // Merge, never replace: another hook on the same matcher belongs to
548
+ // somebody else.
549
+ const already = hookAlreadyRegistered(pre, "protect-system-files");
550
+ if (already) {
551
+ console.log(" [~] Role-boundary hook already registered");
552
+ return;
553
+ }
554
+
555
+ pre.push({ matcher: "Write|Edit", hooks: [{ type: "command", command }] });
556
+ settings.hooks.PreToolUse = pre;
557
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
558
+ console.log(" [+] Role-boundary hook registered (blocks edits to installed system files)");
559
+ }
560
+
561
+ // The second guard: a session that declared a role stays inside it. Bash as
562
+ // well as Write|Edit, because what the PM did outside its scope was open a
563
+ // pull request, and that is a shell command, not a file write.
564
+ function registerRoleHook() {
565
+ const settingsPath = path.join(targetDir, ".claude", "settings.json");
566
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a shell parameter expansion, resolved by bash at hook time — not a JS template.
567
+ const command = 'bash "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/enforce-role.sh"';
568
+ let settings = {};
569
+ try {
570
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
571
+ } catch {
572
+ // registerHooks() ran first and already said the file is unreadable or
573
+ // not JSON; a second message for the same file would be noise.
574
+ return;
575
+ }
576
+ settings.hooks = settings.hooks || {};
577
+ const pre = settings.hooks.PreToolUse || [];
578
+ if (hookAlreadyRegistered(pre, "enforce-role")) return;
579
+ // Agent and Workflow as well: a PM launched a multi-agent code review
580
+ // because the word "ultracode" in the user's message told the platform to
581
+ // use the Workflow tool, and prose saying "cannot review code" lost. And
582
+ // the ClickUp connector, so a status written by the PM can be refused.
583
+ pre.push({
584
+ matcher: "Bash|Write|Edit|Agent|Workflow|mcp__.*clickup_(create|update)_task",
585
+ hooks: [{ type: "command", command }],
586
+ });
587
+ settings.hooks.PreToolUse = pre;
588
+
589
+ // No role runs a workflow — lead fans out with the Agent tool, the PM
590
+ // launches two named agents, and nothing else orchestrates. Denied at the
591
+ // permission layer so the tool is not there to be talked into.
592
+ settings.permissions = settings.permissions || {};
593
+ const deny = new Set(settings.permissions.deny || []);
594
+ deny.add("Workflow");
595
+ settings.permissions.deny = [...deny];
596
+
597
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
598
+ console.log(" [+] Role-scope hook registered (a declared role stays inside its ownership)");
599
+ }
600
+
601
+ // Review rounds are capped per phase. The cap was prose in lead.md, and a run
602
+ // read the user's "loop until clean" as overriding it and went nine rounds on
603
+ // one phase. See .claude/hooks/review-rounds.sh.
604
+ function registerReviewRoundsHook() {
605
+ const settingsPath = path.join(targetDir, ".claude", "settings.json");
606
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a shell parameter expansion, resolved by bash at hook time — not a JS template.
607
+ const command = 'bash "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/review-rounds.sh"';
608
+ let settings = {};
609
+ try {
610
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
611
+ } catch {
612
+ // registerHooks() ran first and already said the file is unreadable or
613
+ // not JSON; a second message for the same file would be noise.
614
+ return;
615
+ }
616
+ settings.hooks = settings.hooks || {};
617
+ const pre = settings.hooks.PreToolUse || [];
618
+ if (hookAlreadyRegistered(pre, "review-rounds")) return;
619
+ pre.push({ matcher: "Agent", hooks: [{ type: "command", command }] });
620
+ settings.hooks.PreToolUse = pre;
621
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
622
+ console.log(
623
+ " [+] Review-round hook registered (a phase gets at most stuck_retry_limit reviews)",
624
+ );
625
+ }
626
+
627
+ // The fourth guard, and the only one that never blocks: context.md's own
628
+ // bookkeeping (a phase marked done, an in-progress phase) is otherwise
629
+ // checked only when a human happens to reread it. PreCompact cannot block
630
+ // compaction and its own additionalContext is undocumented, so the audit it
631
+ // runs there only writes findings to disk; SessionStart's documented
632
+ // "compact" matcher is what actually surfaces them, right after compaction
633
+ // lands. See .claude/hooks/audit-context.sh.
634
+ function registerContextAuditHook() {
635
+ const settingsPath = path.join(targetDir, ".claude", "settings.json");
636
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a shell parameter expansion, resolved by bash at hook time — not a JS template.
637
+ const command = 'bash "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/audit-context.sh"';
638
+ let settings = {};
639
+ try {
640
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
641
+ } catch {
642
+ // registerHooks() ran first and already said the file is unreadable or
643
+ // not JSON; a second message for the same file would be noise.
644
+ return;
645
+ }
646
+ settings.hooks = settings.hooks || {};
647
+
648
+ const preDone = hookAlreadyRegistered(settings.hooks.PreCompact, "audit-context");
649
+ const startDone = hookAlreadyRegistered(settings.hooks.SessionStart, "audit-context");
650
+ if (preDone && startDone) {
651
+ console.log(" [~] Context-audit hook already registered");
652
+ return;
653
+ }
654
+
655
+ if (!preDone) {
656
+ const pre = settings.hooks.PreCompact || [];
657
+ pre.push({ hooks: [{ type: "command", command }] });
658
+ settings.hooks.PreCompact = pre;
659
+ }
660
+ if (!startDone) {
661
+ const start = settings.hooks.SessionStart || [];
662
+ start.push({ matcher: "compact", hooks: [{ type: "command", command }] });
663
+ settings.hooks.SessionStart = start;
664
+ }
665
+
666
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
667
+ console.log(" [+] Context-audit hook registered (flags context.md defects after compaction)");
668
+ }
669
+
670
+ function setupPermissions() {
671
+ const claudeDir = path.join(targetDir, ".claude");
672
+ const settingsPath = path.join(claudeDir, "settings.local.json");
673
+
674
+ if (!fs.existsSync(claudeDir)) {
675
+ fs.mkdirSync(claudeDir, { recursive: true });
676
+ }
677
+
678
+ let settings = {};
679
+ if (fs.existsSync(settingsPath)) {
680
+ try {
681
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
682
+ } catch {
683
+ // Falling back to {} would write an empty permissions file over one the
684
+ // user still has, and they would find out the next time a tool asked.
685
+ // registerHooks refuses on the same parse error; so does this.
686
+ console.log(
687
+ " [!] .claude/settings.local.json is not valid JSON — permissions not configured",
688
+ );
689
+ return;
690
+ }
691
+ }
692
+
693
+ if (!settings.permissions) settings.permissions = {};
694
+ const existing = settings.permissions.allow || [];
695
+ const merged = [...new Set([...existing, ...ALLOW_PERMISSIONS])];
696
+ settings.permissions.allow = merged;
697
+
698
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
699
+
700
+ const added = merged.length - existing.length;
701
+ if (added > 0) {
702
+ console.log(` [+] Permissions configured (${added} rules added)`);
703
+ } else {
704
+ console.log(" [~] Permissions already configured");
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Create a repository and install into it.
710
+ *
711
+ * Deliberately stops before `/orchestra:setup`. Setup is a conversation — it
712
+ * asks what you are building — and that conversation has to happen inside the
713
+ * new project, where the project's own CLAUDE.md and role boundaries are
714
+ * loaded. Run from the orchestrator repo instead, an agent would be wearing
715
+ * the system maintainer's contract at the moment it starts building an app.
716
+ */
717
+ function newProject(argv) {
718
+ const name = argv.find((a) => !a.startsWith("-"));
719
+ const isPublic = argv.includes("--public");
720
+
721
+ if (!name) {
722
+ console.log(" [x] Usage: npx agentic-team new <name>");
723
+ process.exitCode = 1;
724
+ return;
725
+ }
726
+ // GitHub's own rule, checked here so the failure names the reason rather
727
+ // than arriving as an API error four steps later.
728
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === "." || name === "..") {
729
+ console.log(` [x] "${name}" is not a usable repository name — letters, digits, . _ - only`);
730
+ process.exitCode = 1;
731
+ return;
732
+ }
733
+
734
+ const dir = path.resolve(targetDir, name);
735
+ if (fs.existsSync(dir)) {
736
+ console.log(` [x] ${name}/ already exists here. Refusing to write into it.`);
737
+ process.exitCode = 1;
738
+ return;
739
+ }
740
+
741
+ const gh = (args, opts = {}) =>
742
+ execFileSync("gh", args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], ...opts });
743
+
744
+ try {
745
+ gh(["auth", "status"]);
746
+ } catch {
747
+ // Not something to work around: creating a repository needs the user's
748
+ // own credentials, and this is the one step they have to do themselves.
749
+ console.log(" [x] gh is not available or not authenticated.");
750
+ console.log(" Install it, then run: gh auth login");
751
+ process.exitCode = 1;
752
+ return;
753
+ }
754
+
755
+ console.log("");
756
+ console.log(" Orchestra — new project");
757
+ console.log("");
758
+
759
+ try {
760
+ gh(["repo", "create", name, isPublic ? "--public" : "--private", "--clone"], {
761
+ cwd: targetDir,
762
+ stdio: ["ignore", "pipe", "inherit"],
763
+ });
764
+ } catch {
765
+ console.log(` [x] Could not create ${name}. It may already exist on GitHub.`);
766
+ process.exitCode = 1;
767
+ return;
768
+ }
769
+ console.log(` [+] repository created and cloned into ${name}/`);
770
+
771
+ // The installer runs unchanged, in the new directory. Copying its logic here
772
+ // would give the manifest, the upgrade path and the hook registration a
773
+ // second implementation to drift from.
774
+ execFileSync(process.execPath, [__filename], { cwd: dir, stdio: "inherit" });
775
+ }
776
+
777
+ function run() {
778
+ const flags = parseArgs();
779
+ if (flags.help) {
780
+ showHelp();
781
+ return;
782
+ }
783
+
784
+ console.log("");
785
+ console.log(" Orchestra — AI Team Orchestration");
786
+ console.log(` Installing into: ${targetDir}`);
787
+ console.log("");
788
+
789
+ const orchestraSrc = path.join(templateDir, ".orchestra");
790
+ const orchestraDest = path.join(targetDir, ".orchestra");
791
+ const claudeDest = path.join(targetDir, ".claude");
792
+ const isUpgrade = fs.existsSync(orchestraDest);
793
+ // Read before anything is copied: after the install the manifest describes
794
+ // this version, and what a previous one left behind is no longer knowable.
795
+ const previousManifest = readManifest();
796
+
797
+ if (isUpgrade) {
798
+ // A backup nobody finished restoring holds the only copy of the user's
799
+ // work. Refuse rather than clear it — the old code deleted these at the
800
+ // top of the next run, which turned an interrupted upgrade into a
801
+ // permanent loss.
802
+ const unfinished = findUnfinishedBackups();
803
+ if (unfinished.length) {
804
+ console.log(" [x] A previous install did not finish restoring your data.");
805
+ console.log("");
806
+ console.log(" It is still here, untouched:");
807
+ for (const b of unfinished) console.log(` ${b}`);
808
+ console.log("");
809
+ console.log(" Copy what you need back into .orchestra/ (milestones/ first;");
810
+ console.log(" files you kept at its root are under orchestra-extra/),");
811
+ console.log(" delete the entry above, then run this again.");
812
+ console.log("");
813
+ process.exitCode = 1;
814
+ return;
815
+ }
816
+
817
+ // One backup directory for the whole run, created only if there is
818
+ // something to put in it, and marked until the restore has happened.
819
+ let backupRoot = null;
820
+ const backups = {};
821
+ // Nothing is at risk until .orchestra/ is actually removed. A failure
822
+ // before that point loses nothing, so the backup is litter rather than
823
+ // evidence and must not block the next run.
824
+ let destructiveStarted = false;
825
+ let restored = false;
826
+
827
+ try {
828
+ function ensureBackupRoot() {
829
+ if (backupRoot) return backupRoot;
830
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
831
+ backupRoot = path.join(targetDir, BACKUP_DIR_PREFIX + stamp);
832
+ fs.mkdirSync(backupRoot, { recursive: true });
833
+ fs.writeFileSync(
834
+ path.join(backupRoot, BACKUP_MARKER),
835
+ "This upgrade has not finished restoring. Do not delete this directory.\n",
836
+ );
837
+ return backupRoot;
838
+ }
839
+
840
+ function backupDir(sourceDir, type, dir) {
841
+ if (!fs.existsSync(sourceDir)) return;
842
+ const files = fs.readdirSync(sourceDir).filter((f) => f !== ".gitkeep");
843
+ if (files.length === 0) return;
844
+ const backupPath = path.join(ensureBackupRoot(), type, dir);
845
+ copyDirRecursive(sourceDir, backupPath);
846
+ backups[`${type}:${dir}`] = { backupPath, type, dir };
847
+ const label = type === "orchestra" ? ".orchestra/" : ".claude/";
848
+ console.log(` [~] Backed up ${label}${dir}/ (${files.length} items)`);
849
+ }
850
+
851
+ // ── Backup user data ──
852
+ for (const dir of ORCHESTRA_USER_DIRS) {
853
+ backupDir(path.join(orchestraDest, dir), "orchestra", dir);
854
+ }
855
+ // .claude/ user dirs — the user may have added custom skills or rules.
856
+ for (const dir of CLAUDE_USER_DIRS) {
857
+ backupDir(path.join(claudeDest, dir), "claude", dir);
858
+ }
859
+
860
+ const configPath = path.join(orchestraDest, "config.yml");
861
+ let configBackup = null;
862
+ if (fs.existsSync(configPath)) {
863
+ configBackup = path.join(ensureBackupRoot(), "config.yml");
864
+ fs.copyFileSync(configPath, configBackup);
865
+ console.log(" [~] Backed up config.yml");
866
+ }
867
+
868
+ // Anything else at the root of .orchestra/ is the user's, and the
869
+ // removal below would take it. Back it up with the rest.
870
+ const strays = [];
871
+ if (fs.existsSync(orchestraDest)) {
872
+ const known = new Set([
873
+ ...ORCHESTRA_USER_DIRS,
874
+ "config.yml",
875
+ ".installed.json",
876
+ // A file an earlier version installed and this one retires is not
877
+ // the user's; retireRemoved deletes it after the install.
878
+ ...(previousManifest?.files || [])
879
+ .filter((f) => f.startsWith(".orchestra/"))
880
+ .map((f) => f.split("/")[1]),
881
+ ...(fs.existsSync(orchestraSrc) ? fs.readdirSync(orchestraSrc) : []),
882
+ ]);
883
+ for (const name of fs.readdirSync(orchestraDest)) {
884
+ // Finder's .DS_Store is nobody's work and not worth a line per upgrade.
885
+ if (known.has(name) || name === ".gitkeep" || name === ".DS_Store") continue;
886
+ const backupPath = path.join(ensureBackupRoot(), "orchestra-extra", name);
887
+ copyAsIs(path.join(orchestraDest, name), backupPath);
888
+ strays.push({ name, backupPath });
889
+ // .sessions/ is Orchestra's own runtime state — role markers and
890
+ // review-round counts — kept so an upgrade does not reset a cap.
891
+ if (name !== ".sessions") console.log(` [~] Preserving .orchestra/${name}`);
892
+ }
893
+ }
894
+
895
+ // ── Clean install ──
896
+ // From here until the restore completes, the user's data exists only
897
+ // inside backupRoot.
898
+ destructiveStarted = true;
899
+ rmDirRecursive(orchestraDest);
900
+ console.log(" [~] Removed old .orchestra/");
901
+
902
+ // Remove old .claude/ orchestra files (agents, commands) but keep user files
903
+ const claudeOrchestraDirs = ["agents", "commands"];
904
+ for (const dir of claudeOrchestraDirs) {
905
+ const dirPath = path.join(claudeDest, dir);
906
+ if (fs.existsSync(dirPath)) {
907
+ const files = fs.readdirSync(dirPath);
908
+ for (const file of files) {
909
+ if (
910
+ file === "conductor.md" ||
911
+ file === "lead.md" ||
912
+ file === "reviewer.md" ||
913
+ file === "orchestra"
914
+ ) {
915
+ const fullPath = path.join(dirPath, file);
916
+ if (fs.statSync(fullPath).isDirectory()) {
917
+ rmDirRecursive(fullPath);
918
+ } else {
919
+ fs.unlinkSync(fullPath);
920
+ }
921
+ }
922
+ }
923
+ }
924
+ }
925
+
926
+ copyDirRecursive(orchestraSrc, orchestraDest);
927
+ console.log(" [+] .orchestra/ installed");
928
+
929
+ // Copy plugin dirs → .claude/ (plugin-to-standalone mapping)
930
+ for (const [pluginDir, claudeSubdir] of Object.entries(PLUGIN_TO_CLAUDE)) {
931
+ const src = path.join(templateDir, pluginDir);
932
+ if (fs.existsSync(src)) {
933
+ const dest = path.join(targetDir, claudeSubdir);
934
+ copyDirRecursive(src, dest);
935
+ }
936
+ }
937
+ console.log(" [+] .claude/ orchestra files installed");
938
+
939
+ // ── Restore user data ──
940
+ for (const { backupPath, type, dir } of Object.values(backups)) {
941
+ const baseDest = type === "orchestra" ? orchestraDest : claudeDest;
942
+ const restorePath = path.join(baseDest, dir);
943
+ // Plugin structure: claude dirs sit at the template root, orchestra
944
+ // dirs under .orchestra/.
945
+ const templateDirPath =
946
+ type === "orchestra" ? path.join(orchestraSrc, dir) : path.join(templateDir, dir);
947
+
948
+ if (dir === "milestones") {
949
+ if (fs.existsSync(restorePath)) rmDirRecursive(restorePath);
950
+ copyDirRecursive(backupPath, restorePath);
951
+ console.log(" [+] Restored milestones/");
952
+ } else {
953
+ const count = smartMergeDir(backupPath, restorePath, templateDirPath);
954
+ if (count > 0) {
955
+ console.log(` [+] Restored ${count} user files in ${dir}/`);
956
+ }
957
+ console.log(` [~] Updated system files in ${dir}/`);
958
+ }
959
+ }
960
+
961
+ for (const { name, backupPath } of strays) {
962
+ const dest = path.join(orchestraDest, name);
963
+ fs.rmSync(dest, { recursive: true, force: true });
964
+ copyAsIs(backupPath, dest);
965
+ }
966
+
967
+ // Merge config.yml: new template keys added, user values preserved
968
+ if (configBackup && fs.existsSync(configBackup)) {
969
+ const userConfig = fs.readFileSync(configBackup, "utf-8");
970
+ const templateConfig = fs.readFileSync(path.join(orchestraDest, "config.yml"), "utf-8");
971
+ const mergedConfig = mergeConfigYaml(userConfig, templateConfig);
972
+ fs.writeFileSync(path.join(orchestraDest, "config.yml"), mergedConfig);
973
+ console.log(" [+] Merged config.yml (user values preserved, new keys added)");
974
+ }
975
+
976
+ restored = true;
977
+ } finally {
978
+ if (backupRoot && (restored || !destructiveStarted)) {
979
+ // The marker goes first: if removing the directory then fails, what
980
+ // is left is an unmarked leftover rather than a blocked next run.
981
+ fs.unlinkSync(path.join(backupRoot, BACKUP_MARKER));
982
+ rmDirRecursive(backupRoot);
983
+ } else if (backupRoot) {
984
+ console.log("");
985
+ console.log(" [x] Install failed part-way. Your data is preserved in:");
986
+ console.log(` ${path.basename(backupRoot)}`);
987
+ console.log(" No later run will delete it.");
988
+ console.log("");
989
+ }
990
+ }
991
+ } else {
992
+ // ── Fresh install ──
993
+ copyDirRecursive(orchestraSrc, orchestraDest);
994
+ console.log(" [+] .orchestra/ installed");
995
+
996
+ // Copy plugin dirs → .claude/ (plugin-to-standalone mapping)
997
+ for (const [pluginDir, claudeSubdir] of Object.entries(PLUGIN_TO_CLAUDE)) {
998
+ const src = path.join(templateDir, pluginDir);
999
+ if (fs.existsSync(src)) {
1000
+ const dest = path.join(targetDir, claudeSubdir);
1001
+ copyDirRecursive(src, dest);
1002
+ }
1003
+ }
1004
+ console.log(" [+] .claude/ installed");
1005
+ }
1006
+
1007
+ // ── Record what was installed ──
1008
+ const installed = templateFileList();
1009
+ const retired = retireRemoved(previousManifest, installed);
1010
+ if (retired > 0) {
1011
+ console.log(` [~] Removed ${retired} system file(s) this version no longer ships`);
1012
+ }
1013
+ writeManifest(installed);
1014
+
1015
+ // ── Handle CLAUDE.md ──
1016
+ const templateClaudeMd = fs.readFileSync(path.join(templateDir, "CLAUDE.md"), "utf-8");
1017
+ const targetClaudeMdPath = path.join(targetDir, "CLAUDE.md");
1018
+
1019
+ if (fs.existsSync(targetClaudeMdPath)) {
1020
+ let existingContent = fs.readFileSync(targetClaudeMdPath, "utf-8");
1021
+ const orchestraSection = extractOrchestraSection(templateClaudeMd);
1022
+
1023
+ if (!orchestraSection) {
1024
+ console.log(" [!] Could not extract Orchestra section from template");
1025
+ return;
1026
+ }
1027
+
1028
+ if (existingContent.includes(ORCHESTRA_SECTION_START)) {
1029
+ const existingSection = extractOrchestraSection(existingContent);
1030
+ if (existingSection) {
1031
+ existingContent = existingContent.replace(existingSection, orchestraSection);
1032
+ }
1033
+ console.log(" [~] CLAUDE.md updated (Orchestra section replaced)");
1034
+ } else {
1035
+ existingContent = `${existingContent.trimEnd()}\n\n${orchestraSection}\n`;
1036
+ console.log(" [+] CLAUDE.md updated (Orchestra section appended)");
1037
+ }
1038
+
1039
+ fs.writeFileSync(targetClaudeMdPath, existingContent);
1040
+ } else {
1041
+ fs.copyFileSync(path.join(templateDir, "CLAUDE.md"), targetClaudeMdPath);
1042
+ console.log(" [+] CLAUDE.md created");
1043
+ }
1044
+
1045
+ // PM writes milestones here and CLAUDE.md asks whether the directory is
1046
+ // empty — a question that reads badly against a directory that does not
1047
+ // exist. The installer never created it, so a fresh project had neither.
1048
+ const milestones = path.join(orchestraDest, "milestones");
1049
+ if (!fs.existsSync(milestones)) {
1050
+ fs.mkdirSync(milestones, { recursive: true });
1051
+ fs.writeFileSync(path.join(milestones, ".gitkeep"), "");
1052
+ }
1053
+
1054
+ // In tracker mode lead caches the fetched requirements under each
1055
+ // milestone's .cache/. Committing them would put a second copy of the
1056
+ // requirement in the repo, which is the duplication that mode exists to
1057
+ // remove — so the ignore ships with the directory rather than being
1058
+ // something the user finds out about after the first commit.
1059
+ const milestonesIgnore = path.join(milestones, ".gitignore");
1060
+ if (!fs.existsSync(milestonesIgnore)) {
1061
+ fs.writeFileSync(
1062
+ milestonesIgnore,
1063
+ "# Requirements fetched from the tracker, per run. Never authority,\n" +
1064
+ "# never committed — see .claude/rules/tracker-authority.orchestra.md\n" +
1065
+ ".cache/\n",
1066
+ );
1067
+ }
1068
+
1069
+ // Which role each Claude Code session declared, written by the role hook.
1070
+ // Per machine and per session; a committed marker would tell the next
1071
+ // clone it is a PM.
1072
+ const sessionsIgnore = path.join(targetDir, ".orchestra", ".sessions", ".gitignore");
1073
+ if (!fs.existsSync(sessionsIgnore)) {
1074
+ fs.mkdirSync(path.dirname(sessionsIgnore), { recursive: true });
1075
+ fs.writeFileSync(sessionsIgnore, "*\n!.gitignore\n");
1076
+ }
1077
+ // .claude/hooks/audit-context.sh keys its scratch file by session_id under
1078
+ // this same .orchestra/.sessions/ directory, so the *!.gitignore ignore
1079
+ // above already covers it — never authority, never committed, no separate
1080
+ // gitignore entry to maintain.
1081
+
1082
+ // ── Handle permissions ──
1083
+ if (flags.skipPermissions) {
1084
+ setupPermissions();
1085
+ }
1086
+
1087
+ registerHooks();
1088
+ registerRoleHook();
1089
+ registerReviewRoundsHook();
1090
+ registerContextAuditHook();
1091
+
1092
+ // The previous message sent a fresh repo straight to the PM, while the
1093
+ // CLAUDE.md it had just written says to point at setup before anything else.
1094
+ // The first screen a new user reads contradicted the system it installed.
1095
+ const scaffolded =
1096
+ fs.existsSync(path.join(targetDir, "apps")) ||
1097
+ fs.existsSync(path.join(targetDir, "package.json"));
1098
+
1099
+ // Preserved, and said out loud. Silently keeping a key nothing reads is how
1100
+ // somebody spends an afternoon wondering why their setting has no effect.
1101
+ const cfgFile = path.join(orchestraDest, "config.yml");
1102
+ const cfgAfter = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, "utf-8") : "";
1103
+ const retiredKeys = RETIRED_CONFIG_KEYS.filter(([key]) => {
1104
+ const [section, leaf] = key.split(".");
1105
+ const at = cfgAfter.indexOf(`\n${section}:`);
1106
+ if (at === -1) return false;
1107
+ const rest = cfgAfter.slice(at + 1);
1108
+ const end = rest.search(/\n[a-z_]+:/);
1109
+ return new RegExp(`^\\s+${leaf}:`, "m").test(end === -1 ? rest : rest.slice(0, end));
1110
+ });
1111
+ if (retiredKeys.length) {
1112
+ console.log("");
1113
+ for (const [key, why] of retiredKeys) {
1114
+ console.log(` [~] ${key} is kept but no longer read — ${why}`);
1115
+ }
1116
+ }
1117
+
1118
+ console.log("");
1119
+ console.log(isUpgrade ? " Done! Orchestra is up to date." : " Done! Orchestra is ready.");
1120
+ console.log("");
1121
+ console.log(" Next steps — in Claude Code, from this project:");
1122
+ console.log("");
1123
+ if (!scaffolded) {
1124
+ console.log(" /orchestra:setup scaffold the app. once, before anything else.");
1125
+ }
1126
+ console.log(" /orchestra:pm plan a milestone");
1127
+ console.log(" /orchestra:start execute it — in a second terminal");
1128
+ console.log("");
1129
+ console.log(" /orchestra:help everything else");
1130
+ console.log("");
1131
+ }
1132
+
1133
+ // Only when executed, never when required. Without this guard, importing
1134
+ // this file to reach one of its helpers installs Orchestra over the caller's
1135
+ // working directory — which is exactly as bad as it sounds.
1136
+ if (require.main === module) {
1137
+ if (process.argv[2] === "new") newProject(process.argv.slice(3));
1138
+ else run();
1139
+ }