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