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.
- package/LICENSE +21 -0
- package/README.md +184 -2
- package/bin/build-template.js +208 -0
- package/bin/index.js +1139 -0
- package/package.json +41 -4
- package/template/.claude-plugin/plugin.json +10 -0
- package/template/.orchestra/README.md +401 -0
- package/template/.orchestra/blueprints/README.md +87 -0
- package/template/.orchestra/blueprints/api-only.md +46 -0
- package/template/.orchestra/blueprints/component-crud-resource.md +25 -0
- package/template/.orchestra/blueprints/saas-starter.md +69 -0
- package/template/.orchestra/config.yml +277 -0
- package/template/.orchestra/roles/orchestrator.md +105 -0
- package/template/.orchestra/roles/product-manager.md +438 -0
- package/template/.orchestra/scripts/compact-diff.awk +45 -0
- package/template/.orchestra/stacks/README.md +81 -0
- package/template/.orchestra/stacks/_lib/common.sh +3095 -0
- package/template/.orchestra/stacks/_repo/setup.sh +1109 -0
- package/template/.orchestra/stacks/backend-hono/claude/commands/endpoint.md +13 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/api-endpoint/SKILL.md +111 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/testing/SKILL.md +75 -0
- package/template/.orchestra/stacks/backend-hono/setup.sh +5945 -0
- package/template/.orchestra/stacks/backend-hono/stack.md +195 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/commands/component.md +13 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-android-native/setup.sh +607 -0
- package/template/.orchestra/stacks/mobile-android-native/stack.md +138 -0
- package/template/.orchestra/stacks/mobile-expo/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-expo/claude/skills/design-component/SKILL.md +92 -0
- package/template/.orchestra/stacks/mobile-expo/setup.sh +470 -0
- package/template/.orchestra/stacks/mobile-expo/stack.md +141 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-ios-native/setup.sh +468 -0
- package/template/.orchestra/stacks/mobile-ios-native/stack.md +130 -0
- package/template/.orchestra/stacks/web-nextjs/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-nextjs/setup.sh +128 -0
- package/template/.orchestra/stacks/web-nextjs/stack.md +170 -0
- package/template/.orchestra/stacks/web-vite-react/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-vite-react/setup.sh +185 -0
- package/template/.orchestra/stacks/web-vite-react/stack.md +177 -0
- package/template/.orchestra/trackers/github.sh +467 -0
- package/template/CLAUDE.md +54 -0
- package/template/agents/architect.md +83 -0
- package/template/agents/engineer-high.md +17 -0
- package/template/agents/engineer-low.md +17 -0
- package/template/agents/engineer-max.md +17 -0
- package/template/agents/engineer-medium.md +17 -0
- package/template/agents/engineer-xhigh.md +17 -0
- package/template/agents/lead.md +870 -0
- package/template/agents/milestone-reviewer.md +63 -0
- package/template/agents/orchestrator.md +105 -0
- package/template/agents/product-manager.md +438 -0
- package/template/agents/reviewer.md +301 -0
- package/template/agents/scaffolder.md +180 -0
- package/template/agents/tracker.md +202 -0
- package/template/commands/blueprint.md +23 -0
- package/template/commands/create-role.md +64 -0
- package/template/commands/export.md +45 -0
- package/template/commands/help.md +55 -0
- package/template/commands/hotfix.md +12 -0
- package/template/commands/orchestrator.md +12 -0
- package/template/commands/pm.md +18 -0
- package/template/commands/rewind.md +61 -0
- package/template/commands/setup.md +14 -0
- package/template/commands/start.md +29 -0
- package/template/commands/status.md +11 -0
- package/template/commands/sync.md +73 -0
- package/template/commands/verifier.md +53 -0
- package/template/hooks/audit-context.sh +153 -0
- package/template/hooks/enforce-role.sh +175 -0
- package/template/hooks/protect-system-files.sh +123 -0
- package/template/hooks/review-rounds.sh +172 -0
- package/template/rules/acceptance-check.orchestra.md +11 -0
- package/template/rules/analytics-kpi.orchestra.md +56 -0
- package/template/rules/code-standards.orchestra.md +34 -0
- package/template/rules/comments.orchestra.md +77 -0
- package/template/rules/commit-format.orchestra.md +20 -0
- package/template/rules/dependency-upgrade.orchestra.md +112 -0
- package/template/rules/design-parity.orchestra.md +170 -0
- package/template/rules/observability.orchestra.md +118 -0
- package/template/rules/performance.orchestra.md +186 -0
- package/template/rules/phase-limits.orchestra.md +3 -0
- package/template/rules/pull-request.orchestra.md +213 -0
- package/template/rules/role-boundaries.orchestra.md +117 -0
- package/template/rules/security.orchestra.md +106 -0
- package/template/rules/stuck-detection.orchestra.md +25 -0
- package/template/rules/testing-standards.orchestra.md +214 -0
- package/template/rules/tracker-authority.orchestra.md +190 -0
- package/template/rules/verification-gate.orchestra.md +56 -0
- package/template/skills/accessibility/SKILL.md +31 -0
- package/template/skills/android-testing/SKILL.md +112 -0
- package/template/skills/architecture/SKILL.md +175 -0
- package/template/skills/auth-setup/SKILL.md +105 -0
- package/template/skills/best-practices/SKILL.md +29 -0
- package/template/skills/code-optimizer/SKILL.md +27 -0
- package/template/skills/core-web-vitals/SKILL.md +32 -0
- package/template/skills/crud-api/SKILL.md +32 -0
- package/template/skills/debug/SKILL.md +28 -0
- package/template/skills/deployment/SKILL.md +104 -0
- package/template/skills/error-handling/SKILL.md +154 -0
- package/template/skills/frontend-design/SKILL.md +30 -0
- package/template/skills/fullstack-infrastructure/SKILL.md +108 -0
- package/template/skills/ios-testing/SKILL.md +100 -0
- package/template/skills/mobile-testing/SKILL.md +102 -0
- package/template/skills/observability/SKILL.md +91 -0
- package/template/skills/project-structure/SKILL.md +114 -0
- package/template/skills/push-notifications/SKILL.md +146 -0
- package/template/skills/react-best-practices/SKILL.md +31 -0
- package/template/skills/realtime/SKILL.md +146 -0
- package/template/skills/review/SKILL.md +82 -0
- package/template/skills/testing/SKILL.md +44 -0
- package/template/skills/web-quality-audit/SKILL.md +47 -0
- 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
|
+
}
|