@pilotspace/add 1.5.0 → 1.7.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/bin/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  /**
5
5
  * @pilotspace/add installer.
6
6
  *
7
- * npx @pilotspace/add init [targetDir] [--force] [--stage <stage>] [--name <name>]
7
+ * npx @pilotspace/add init [targetDir] [--force] [--stage <stage>] [--name <name>] [--yes|--non-interactive]
8
8
  *
9
9
  * Installs the ADD skill + tooling + book into a target project:
10
10
  * <target>/.claude/skills/add/ (the skill Claude loads)
@@ -15,12 +15,18 @@
15
15
  * to a CLI user. A pre-run plain init would grandfather-lock the gate before `/add` runs
16
16
  * AND consume the brownfield signal in the terminal, where the AI never sees it.
17
17
  *
18
- * Zero npm dependencies, no Python needed at install time. Designed for failure:
19
- * verifies sources exist before copying, never clobbers an existing skill.
18
+ * One lazy, optional dependency (@clack/prompts) powers the interactive flow on a real
19
+ * terminal; it is dynamic-import()ed ONLY on that path, so a non-interactive / CI run
20
+ * (and the `--yes` / `--non-interactive` path) never loads it and degrades to plain text
21
+ * if it is missing. No Python needed at install time. Designed for failure: verifies
22
+ * sources exist before copying, never clobbers an existing skill, never throws on a
23
+ * non-TTY or a failed clack import.
20
24
  */
21
25
 
22
26
  const fs = require("fs");
23
27
  const path = require("path");
28
+ const os = require("os");
29
+ const crypto = require("crypto");
24
30
 
25
31
  const PKG_ROOT = path.resolve(__dirname, "..");
26
32
 
@@ -32,11 +38,26 @@ function parseArgs(argv) {
32
38
  // stage/name stay null unless EXPLICITLY passed — the engine's own `init`
33
39
  // defaults the stage and infers the name from the folder, so the manual-init
34
40
  // hint only echoes flags the user actually chose (shortest true command).
35
- const args = { _: [], force: false, check: false, stage: null, name: null };
41
+ const args = { _: [], force: false, check: false, noSkill: false, stage: null, name: null,
42
+ yes: false, nonInteractive: false, global: false, globalData: false };
36
43
  for (let i = 0; i < argv.length; i++) {
37
44
  const a = argv[i];
38
45
  if (a === "--force") args.force = true;
39
46
  else if (a === "--check") args.check = true;
47
+ // --global: ALSO install the managed layer to a shared home + register this project
48
+ // (the per-project self-contained drop still runs). update --global refreshes all.
49
+ else if (a === "--global") args.global = true;
50
+ // --global-data: (implies --global) ALSO persist this project's user-data under
51
+ // <home>/data/<key> keyed by path (opt-in, one-way snapshot).
52
+ else if (a === "--global-data") args.globalData = true;
53
+ // --yes / --non-interactive: skip all prompts, take defaults — the explicit
54
+ // non-interactive selector the interactive() gate honors (CI/pipes do this too).
55
+ else if (a === "--yes" || a === "-y") args.yes = true;
56
+ else if (a === "--non-interactive") args.nonInteractive = true;
57
+ // --no-skill: drop the engine + book ONLY, not the skill. The Claude Code plugin
58
+ // already provides the `add` skill, so a plugin bootstrap uses this to materialize
59
+ // .add/tooling/ + .add/docs/ into the project without a duplicate .claude/skills/add.
60
+ else if (a === "--no-skill") args.noSkill = true;
40
61
  else if (a === "--stage" || a === "--name") {
41
62
  const v = argv[++i];
42
63
  // fail loudly on a trailing/abutting flag — never silently drop a value
@@ -50,64 +71,217 @@ function parseArgs(argv) {
50
71
  return args;
51
72
  }
52
73
 
53
- function copyDir(src, dest, { skipIfExists, cleanReplace } = {}) {
54
- if (!fs.existsSync(src)) fail("missing packaged source: " + src);
55
- if (skipIfExists && fs.existsSync(dest)) {
56
- warn(dest + " exists leaving it untouched");
57
- return;
58
- }
59
- // Clean replace: drop a stale dest before copying so a `--force` re-install can
60
- // never leave orphaned files from a previous version behind. fs.cpSync merges
61
- // (it never removes), so without this `--force` is a merge, not a replace. Mirrors
62
- // _installer.py's `shutil.rmtree(skill_dest)` so npm and pip behave identically.
63
- if (cleanReplace && fs.existsSync(dest)) {
64
- fs.rmSync(dest, { recursive: true, force: true });
74
+ // --- agent detection: which coding agent is invoking the installer -----------
75
+ // ORDERED registry; detectAgent walks it top->bottom, first match wins, `generic` is
76
+ // the fallback. Mirror of _installer.py:AGENT_PROFILES. The per-agent env SIGNAL is
77
+ // best-effort (a mis-detect degrades to generic + is overridable in the clack confirm)
78
+ // — refine via a SPEC delta, never a hard fail.
79
+ const GENERIC_NEXT =
80
+ "open your AI Agent CLI (like Claude Code, Codex, etc.), then run `/add`, and " +
81
+ "say what you want to build the agent sets up the foundation, sizes it into a " +
82
+ "milestone, and drives the build with you; you sign off once, at the lock-down.";
83
+
84
+ const AGENT_PROFILES = [
85
+ { id: "claude", label: "Claude Code / Claude app", integration_file: "CLAUDE.md",
86
+ env: ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"], envPrefix: null,
87
+ next_step: "Open Claude Code and run `/add` — the skill drives intake -> milestone -> build." },
88
+ { id: "codex", label: "Codex", integration_file: "AGENTS.md",
89
+ env: ["CODEX_HOME"], envPrefix: "CODEX_",
90
+ next_step: "Open Codex — it reads AGENTS.md; run `/add` or say what you want to build." },
91
+ { id: "opencode", label: "OpenCode", integration_file: "AGENTS.md",
92
+ env: ["OPENCODE"], envPrefix: "OPENCODE",
93
+ next_step: "Open OpenCode — it reads AGENTS.md; say what you want to build." },
94
+ { id: "generic", label: "your AI agent", integration_file: "AGENTS.md",
95
+ env: [], envPrefix: null, next_step: GENERIC_NEXT },
96
+ ];
97
+
98
+ // The SAME markers add.py:sync-guidelines uses, so init's sync-guidelines REPLACES this
99
+ // drop-time pointer in place (one block, never a duplicate).
100
+ const GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->";
101
+ const GUIDE_END = "<!-- ADD:END -->";
102
+
103
+ function profileMatches(profile, env) {
104
+ for (const key of profile.env) { if (env[key]) return true; }
105
+ if (profile.envPrefix) {
106
+ for (const k of Object.keys(env)) {
107
+ if (env[k] && k.startsWith(profile.envPrefix)) return true;
108
+ }
65
109
  }
66
- fs.mkdirSync(path.dirname(dest), { recursive: true });
67
- fs.cpSync(src, dest, { recursive: true });
110
+ return false;
68
111
  }
69
112
 
70
- function cmdInit(args) {
71
- const target = path.resolve(args._[0] || ".");
72
- if (!fs.existsSync(target)) fail("target directory does not exist: " + target);
73
- log("Installing ADD into " + target);
113
+ // Pure, total, deterministic: same env -> same profile; never throws. Generic is last.
114
+ function detectAgent(env) {
115
+ const generic = AGENT_PROFILES[AGENT_PROFILES.length - 1];
116
+ for (const profile of AGENT_PROFILES.slice(0, -1)) {
117
+ if (profileMatches(profile, env)) return profile;
118
+ }
119
+ return generic;
120
+ }
74
121
 
75
- // 1. skill -> .claude/skills/add
76
- copyDir(
77
- path.join(PKG_ROOT, "skill", "add"),
78
- path.join(target, ".claude", "skills", "add"),
79
- { skipIfExists: !args.force, cleanReplace: args.force }
122
+ function agentPointerBlock(profile) {
123
+ return (
124
+ GUIDE_BEGIN + "\n" +
125
+ "## ADD — how to work in this repo\n" +
126
+ "\n" +
127
+ "This project uses **ADD (AI-Driven Development)**. The engine + book are installed.\n" +
128
+ "To begin: run `python3 .add/tooling/add.py status` (the resume point), read\n" +
129
+ "`.add/PROJECT.md`, then `python3 .add/tooling/add.py guide` for the current phase.\n" +
130
+ "\n" +
131
+ profile.next_step + "\n" +
132
+ "\n" +
133
+ "This pointer is replaced by the full guideline block when `add.py sync-guidelines`\n" +
134
+ "runs (at `/add`->init). Edit outside the markers, not inside.\n" +
135
+ GUIDE_END
80
136
  );
81
- log(" ✓ skill -> .claude/skills/add/");
82
-
83
- // 2. tooling -> .add/tooling (exclude tests from the installed copy)
84
- const toolingDest = path.join(target, ".add", "tooling");
85
- copyDir(path.join(PKG_ROOT, "tooling"), toolingDest, { skipIfExists: false });
86
- // installed copy is runtime-only: drop ALL test files and any compiled cache
87
- // (glob test_*.py not just test_add.py — so no test leaks into installs)
88
- fs.rmSync(path.join(toolingDest, "__pycache__"), { recursive: true, force: true });
89
- for (const entry of fs.readdirSync(toolingDest)) {
90
- if (/^test_.*\.py$/.test(entry)) {
91
- fs.rmSync(path.join(toolingDest, entry), { force: true });
137
+ }
138
+
139
+ // Inject the ADD pointer into <target>/<integration_file>, mirroring add.py:_inject_block.
140
+ // created|updated|unchanged|skipped. Only the marked region is (re)written; content outside
141
+ // the markers is preserved; a real change backs up <file>.bak first. Fail-soft (warn+skip).
142
+ function writeAgentPointer(target, profile) {
143
+ const dest = path.join(target, profile.integration_file);
144
+ const block = agentPointerBlock(profile);
145
+ try {
146
+ if (fs.existsSync(dest)) {
147
+ const current = fs.readFileSync(dest, "utf8");
148
+ const begin = current.indexOf(GUIDE_BEGIN);
149
+ let next;
150
+ if (begin !== -1) {
151
+ const endIdx = current.indexOf(GUIDE_END, begin);
152
+ if (endIdx !== -1) {
153
+ next = current.slice(0, begin) + block + current.slice(endIdx + GUIDE_END.length);
154
+ } else { // begin with no end: corrupt — append fresh
155
+ next = current.replace(/\n+$/, "") + "\n\n" + block + "\n";
156
+ }
157
+ } else { // no block yet — append, keep user content
158
+ next = current.replace(/\n+$/, "") + "\n\n" + block + "\n";
159
+ }
160
+ if (next === current) return "unchanged";
161
+ fs.writeFileSync(dest + ".bak", current); // rollback path before mutate
162
+ fs.writeFileSync(dest, next);
163
+ return "updated";
92
164
  }
165
+ fs.writeFileSync(dest, block + "\n");
166
+ return "created";
167
+ } catch (e) {
168
+ warn("could not write " + profile.integration_file + " — " +
169
+ (e && e.message ? e.message : e) + "; skipped");
170
+ return "skipped";
93
171
  }
94
- log(" ✓ tooling -> .add/tooling/add.py (+ templates)");
172
+ }
95
173
 
96
- // 3. docs (the book / trust layer) -> .add/docs
97
- copyDir(path.join(PKG_ROOT, "docs"), path.join(target, ".add", "docs"),
98
- { skipIfExists: false });
99
- log(" ✓ trust docs -> .add/docs/ (the AIDD book)");
174
+ // --- interactive layer (clack on a real TTY; plain text everywhere else) -----
175
+ // Designed-for-failure: any doubt (non-TTY, CI, --yes, a failed import, an
176
+ // un-promptable stream) degrades to the EXACT plain-text path below. The clack
177
+ // import is dynamic + lazy (clack 1.x is ESM-only) so a non-interactive / CI run
178
+ // never loads it. A test seam (ADD_INSTALLER_FORCE_INTERACTIVE) reaches the branch
179
+ // without a PTY: "1" forces interactive, "fail" forces it but throws on import.
180
+
181
+ function interactive(args) {
182
+ if (args.yes || args.nonInteractive) return false; // explicit opt-out wins
183
+ const seam = process.env.ADD_INSTALLER_FORCE_INTERACTIVE;
184
+ if (seam === "1" || seam === "fail") return true; // documented test seam
185
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY) && !process.env.CI;
186
+ }
187
+
188
+ async function loadClack() {
189
+ // honors the "fail" seam so the clack_unavailable fallback is testable without
190
+ // uninstalling the dependency.
191
+ if (process.env.ADD_INSTALLER_FORCE_INTERACTIVE === "fail") {
192
+ throw new Error("forced clack import failure (test seam)");
193
+ }
194
+ return import("@clack/prompts");
195
+ }
196
+
197
+ // Returns { cancelled, target }. A cancel happens BEFORE any file is written, so a
198
+ // cancelled run leaves the target untouched. Without a real TTY to read (the forced
199
+ // test seam), we cannot prompt — abort safely rather than hang.
200
+ async function runClackPreamble(clack, target, detected) {
201
+ clack.intro("ADD — AI-Driven Development");
202
+ if (!process.stdin.isTTY) return { cancelled: true, target: target };
203
+ const chosen = await clack.text({
204
+ message: "Install ADD into which directory?",
205
+ initialValue: target, defaultValue: target,
206
+ });
207
+ if (clack.isCancel(chosen)) return { cancelled: true, target: target };
208
+ const ok = await clack.confirm({ message: "Write the ADD skill + tooling + book here?" });
209
+ if (clack.isCancel(ok) || !ok) return { cancelled: true, target: target };
210
+ // agent-detect STEP (seeded delta: a STEP in THIS flow, via the clack ui layer) — the
211
+ // user confirms or overrides the detected agent before any file is written.
212
+ const picked = await clack.select({
213
+ message: "Set up for which agent? (detected: " + detected.label + ")",
214
+ options: AGENT_PROFILES.map((p) => ({ value: p.id, label: p.label })),
215
+ initialValue: detected.id,
216
+ });
217
+ if (clack.isCancel(picked)) return { cancelled: true, target: target };
218
+ const profile = AGENT_PROFILES.find((p) => p.id === picked) || detected;
219
+ return { cancelled: false, target: String(chosen || target), profile: profile };
220
+ }
221
+
222
+ // The drop — now a RECONCILE: restore missing managed trees + refresh present ones
223
+ // (sweep orphans) + report per-tree status. Byte-compatible handoff with the prior
224
+ // installer. The interactive path resolves a target then calls straight into this.
225
+ function dropFiles(args, target, profile) {
226
+ profile = profile || detectAgent(process.env);
227
+ log("Installing ADD into " + target);
228
+ reconcile(args, target);
229
+
230
+ // Agent detection: write THE detected agent's integration file (a marker-delimited
231
+ // pointer init's sync-guidelines later supersedes) + tailor the closing next-step.
232
+ // Best-effort + fail-soft — never aborts the successful drop above.
233
+ writeAgentPointer(target, profile);
100
234
 
101
235
  // NO step 4: the installer DROPS FILES ONLY. Initialisation is deferred to the AI
102
236
  // (via `/add`) or a CLI user — a pre-run plain `add.py init` would grandfather-lock
103
237
  // the v12 lock-down gate before `/add` runs (see file header). So no Python is run here.
104
- log("\nDone. The `add` skill + tooling are installed (no project state yet that's intentional).");
105
- log("Next: open your AI Agent CLI (like Claude Code, Codex, etc.), then run `/add`, and say what you want to build the agent");
106
- log(" sets up the foundation, sizes it into a milestone, and drives the build with you;");
107
- log(" you sign off once, at the lock-down.");
238
+ log("\nDone. " + (args.noSkill ? "The engine + book are" : "The `add` skill + tooling are") +
239
+ " installed (no project state yetthat's intentional).");
240
+ if (profile.id === "generic") {
241
+ // the generic onramp line — kept literal so the conversational-only handoff is stable
242
+ log("Next: open your AI Agent CLI (like Claude Code, Codex, etc.), then run `/add`, and say what you want to build — the agent");
243
+ log(" sets up the foundation, sizes it into a milestone, and drives the build with you;");
244
+ log(" you sign off once, at the lock-down.");
245
+ } else {
246
+ log("Detected " + profile.label + ".");
247
+ log("Next: " + profile.next_step);
248
+ }
108
249
  log("");
109
250
  }
110
251
 
252
+ async function cmdInit(args) {
253
+ const target = path.resolve(args._[0] || ".");
254
+ if (!fs.existsSync(target)) fail("target directory does not exist: " + target);
255
+
256
+ let chosenTarget = target;
257
+ let profile = detectAgent(process.env); // default: non-interactive / fallback
258
+ if (interactive(args)) {
259
+ let clack = null;
260
+ try { clack = await loadClack(); }
261
+ catch (_e) { warn("clack unavailable — falling back to plain-text install"); }
262
+ if (clack) {
263
+ const outcome = await runClackPreamble(clack, target, profile);
264
+ if (outcome.cancelled) {
265
+ // the exit code IS the contract; a closed-pipe stdout (EPIPE) must not
266
+ // mask the cancel — guard the courtesy message, never let it throw.
267
+ try { clack.cancel("Installation cancelled — nothing was written."); }
268
+ catch (_e) { /* stdout unavailable (e.g. closed pipe) — exit code carries it */ }
269
+ process.exit(130); // user_cancelled: nothing written
270
+ }
271
+ chosenTarget = path.resolve(outcome.target);
272
+ if (!fs.existsSync(chosenTarget)) fail("target directory does not exist: " + chosenTarget);
273
+ if (outcome.profile) profile = outcome.profile; // honor the user's override
274
+ }
275
+ }
276
+ if (args.globalData) args.global = true; // --global-data implies --global (need a home)
277
+ // OPT-IN global home, BEFORE the per-project drop (fail-closed if the home is unwritable
278
+ // or its registry is corrupt — the package + the self-contained default stay usable).
279
+ if (args.global) installGlobal(args, chosenTarget);
280
+ dropFiles(args, chosenTarget, profile);
281
+ // OPT-IN data persist, AFTER the drop (one-way snapshot of existing user-data).
282
+ if (args.globalData) installGlobalData(chosenTarget);
283
+ }
284
+
111
285
  // --- update: re-materialize the managed layer without a re-install -----------
112
286
  // The managed trees (ship-controlled). `update` clean-replaces each, so a file removed
113
287
  // upstream leaves no orphan — and never touches .add/state.json, PROJECT.md, milestones,
@@ -130,11 +304,11 @@ function readStamp(addDir) {
130
304
  try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch (_e) { return null; }
131
305
  }
132
306
 
133
- function writeStamp(addDir, version) {
307
+ function writeStamp(addDir, version, channel) {
134
308
  fs.mkdirSync(addDir, { recursive: true });
135
309
  fs.writeFileSync(
136
310
  path.join(addDir, STAMP_FILE),
137
- JSON.stringify({ version: version, channel: "npm", installed_at: new Date().toISOString() }, null, 2) + "\n"
311
+ JSON.stringify({ version: version, channel: channel || "npm", installed_at: new Date().toISOString() }, null, 2) + "\n"
138
312
  );
139
313
  }
140
314
 
@@ -151,7 +325,217 @@ function cleanReplaceTree(src, dest, stripTests) {
151
325
  }
152
326
  }
153
327
 
328
+ const TREE_LABEL = { "skill/add": "skill", "tooling": "tooling", "docs": "docs" };
329
+
330
+ // Per managed tree: "missing" (dest absent OR empty) or "present".
331
+ function managedStatus(target) {
332
+ const status = {};
333
+ for (const [sub, destParts] of MANAGED) {
334
+ const dest = path.join(target, ...destParts);
335
+ const present = fs.existsSync(dest) && fs.readdirSync(dest).length > 0;
336
+ status[sub] = present ? "present" : "missing";
337
+ }
338
+ return status;
339
+ }
340
+
341
+ // reconcile: restore-missing + refresh-present (sweep orphans) across the managed trees,
342
+ // reporting per-tree status. Honors --no-skill (the plugin provides the skill). Touches
343
+ // ONLY managed trees — never user data. Prechecks ALL sources first (design-for-failure:
344
+ // a corrupt package leaves the target untouched).
345
+ function reconcile(args, target, srcRoot) {
346
+ srcRoot = srcRoot || PKG_ROOT; // default: the package; the global home feeds propagation
347
+ const trees = MANAGED.filter(([sub]) => !(sub === "skill/add" && args.noSkill));
348
+ for (const [sub] of trees) {
349
+ if (!fs.existsSync(path.join(srcRoot, sub))) {
350
+ fail("missing packaged source: " + path.join(srcRoot, sub));
351
+ }
352
+ }
353
+ const status = managedStatus(target);
354
+ for (const [sub, destParts, stripTests] of trees) {
355
+ cleanReplaceTree(path.join(srcRoot, sub), path.join(target, ...destParts), stripTests);
356
+ const dest = destParts.join("/");
357
+ if (status[sub] === "missing") {
358
+ log(" ✓ restored " + TREE_LABEL[sub].padEnd(8) + "-> " + dest + " (was missing)");
359
+ } else {
360
+ log(" ✓ refreshed " + TREE_LABEL[sub].padEnd(8) + "-> " + dest);
361
+ }
362
+ }
363
+ return status;
364
+ }
365
+
366
+ // --- global home: an OPT-IN shared install (engine+book+skill) updated for all projects ----
367
+ // Resolution is PURE + total (never throws); the home MIRRORS the bundled managed layer so
368
+ // `update --global` propagation reuses reconcile() unchanged. Mirror of _installer.py.
369
+ function resolveGlobalHome(env) {
370
+ // ADD_HOME (set, non-empty) -> else XDG_DATA_HOME/add -> else <HOME>/.add. Reads HOME from
371
+ // the env mapping (never $HOME directly) so tests can inject a hermetic home.
372
+ env = env || process.env;
373
+ if (env.ADD_HOME) return path.resolve(env.ADD_HOME);
374
+ if (env.XDG_DATA_HOME) return path.join(path.resolve(env.XDG_DATA_HOME), "add");
375
+ return path.join(env.HOME || os.homedir(), ".add");
376
+ }
377
+
378
+ function claudeSkillsDir(env) {
379
+ env = env || process.env;
380
+ return path.join(env.HOME || os.homedir(), ".claude", "skills", "add");
381
+ }
382
+
383
+ function registryPath(home) { return path.join(home, "registry.json"); }
384
+
385
+ // [] when ABSENT; THROWS on present-but-corrupt so the caller fails LOUD (never a silent
386
+ // empty-list no-op that quietly skips every registered project).
387
+ function readRegistry(home) {
388
+ const p = registryPath(home);
389
+ if (!fs.existsSync(p)) return [];
390
+ let data;
391
+ try { data = JSON.parse(fs.readFileSync(p, "utf8")); }
392
+ catch (_e) { throw new Error("registry_corrupt"); }
393
+ if (!Array.isArray(data)) throw new Error("registry_corrupt");
394
+ return data;
395
+ }
396
+
397
+ // ATOMIC (temp + rename), de-duplicated preserving first-seen order.
398
+ function writeRegistry(home, paths) {
399
+ fs.mkdirSync(home, { recursive: true });
400
+ const seen = [];
401
+ for (const p of paths) { if (!seen.includes(p)) seen.push(p); }
402
+ const target = registryPath(home);
403
+ const tmp = target + ".tmp";
404
+ fs.writeFileSync(tmp, JSON.stringify(seen, null, 2) + "\n");
405
+ fs.renameSync(tmp, target); // atomic on the same filesystem (POSIX + Windows)
406
+ }
407
+
408
+ // The home mirrors the bundled layout (skill/add + tooling + docs at the SAME relative paths
409
+ // the package ships) so reconcile(args, project, home) reuses MANAGED unchanged.
410
+ const GLOBAL_TREES = [
411
+ ["skill/add", ["skill", "add"], false],
412
+ ["tooling", ["tooling"], true],
413
+ ["docs", ["docs"], false],
414
+ ];
415
+
416
+ // Clean-replace the bundled managed layer INTO <home> (canonical mirror), then DEPLOY the
417
+ // skill to ~/.claude/skills/add. Throws if a dir can't be written (caller -> home_unwritable).
418
+ // Prechecks ALL sources first (design-for-failure: a corrupt package leaves the home as-is).
419
+ function reconcileGlobal(home, claudeDir, noSkill) {
420
+ for (const [sub] of GLOBAL_TREES) {
421
+ if (!fs.existsSync(path.join(PKG_ROOT, sub))) {
422
+ fail("missing packaged source: " + path.join(PKG_ROOT, sub));
423
+ }
424
+ }
425
+ for (const [sub, destParts, stripTests] of GLOBAL_TREES) {
426
+ cleanReplaceTree(path.join(PKG_ROOT, sub), path.join(home, ...destParts), stripTests);
427
+ }
428
+ if (!noSkill) cleanReplaceTree(path.join(home, "skill", "add"), claudeDir, false);
429
+ }
430
+
431
+ // --- global DATA: an OPT-IN per-project user-data snapshot under <home>/data/<key> ----------
432
+ // Strictly additive; copies ONLY user-data (managed trees + transient excluded), clean-replaced,
433
+ // one-way (project->home). Mirror of _installer.py (identical key + include/exclude rule).
434
+ const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE]; // managed trees + meta
435
+
436
+ // data_key twin: <sanitized-basename>-<sha1(abspath_utf8)[:12]>. Pure · total · separator-free.
437
+ function dataKey(projectAbspath) {
438
+ const p = String(projectAbspath);
439
+ const digest = crypto.createHash("sha1").update(p, "utf8").digest("hex").slice(0, 12);
440
+ const base = (path.basename(p) || "root").replace(/[^A-Za-z0-9._-]/g, "_");
441
+ return base + "-" + digest;
442
+ }
443
+
444
+ // A top-level .add/ entry is user-data unless it is a managed tree or a transient artifact.
445
+ function isUserData(name) {
446
+ if (DATA_EXCLUDE.includes(name)) return false;
447
+ if (name.startsWith("scope-snapshot")) return false;
448
+ if (name.includes("pre-archive-bak")) return false;
449
+ if (name.endsWith(".bak.json")) return false;
450
+ return true;
451
+ }
452
+
453
+ // Clean-replace a project's USER-DATA into <home>/data/<key>. true=persisted, false=skipped
454
+ // (no .add or no user-data — an honest skip). Throws if the data dir can't be written.
455
+ function persistData(home, projectAbspath) {
456
+ const addDir = path.join(projectAbspath, ".add");
457
+ if (!fs.existsSync(addDir)) return false;
458
+ const entries = fs.readdirSync(addDir).filter(isUserData);
459
+ if (entries.length === 0) return false;
460
+ const dest = path.join(home, "data", dataKey(projectAbspath));
461
+ if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
462
+ fs.mkdirSync(dest, { recursive: true });
463
+ for (const e of entries) {
464
+ fs.cpSync(path.join(addDir, e), path.join(dest, e), { recursive: true });
465
+ }
466
+ return true;
467
+ }
468
+
469
+ // init --global-data: persist this project's user-data after the per-project drop. Resolves the
470
+ // SAME realpath the registry uses (so the key matches). Skip+notice when empty; fail on unwritable.
471
+ function installGlobalData(chosenTarget) {
472
+ const home = resolveGlobalHome(process.env);
473
+ let resolved = chosenTarget;
474
+ try { resolved = fs.realpathSync(chosenTarget); } catch (_e) { /* fall back to the abspath */ }
475
+ let persisted;
476
+ try { persisted = persistData(home, resolved); }
477
+ catch (e) {
478
+ fail("cannot write global data " + path.join(home, "data", dataKey(resolved)) +
479
+ " — " + (e && e.message ? e.message : e));
480
+ }
481
+ if (persisted) log(" ✓ persisted data -> " + path.join(home, "data", dataKey(resolved)));
482
+ else log(" (no project data to persist yet — run /add to create one, then re-run --global-data)");
483
+ }
484
+
485
+ // init --global: install the managed layer ONCE to the shared home + register this project,
486
+ // fail-closed BEFORE the per-project drop. Returns the resolved target for the normal drop.
487
+ function installGlobal(args, chosenTarget) {
488
+ const home = resolveGlobalHome(process.env);
489
+ const claudeDir = claudeSkillsDir(process.env);
490
+ try { reconcileGlobal(home, claudeDir, args.noSkill); } // home_unwritable
491
+ catch (e) { fail("cannot write global home " + home + " — " + (e && e.message ? e.message : e)); }
492
+ writeStamp(home, pkgVersion(), "global");
493
+ let reg;
494
+ try { reg = readRegistry(home); } // registry_corrupt
495
+ catch (_e) { fail("global registry " + registryPath(home) + " is corrupt — fix or delete it; not registering"); }
496
+ let resolved = chosenTarget;
497
+ try { resolved = fs.realpathSync(chosenTarget); } catch (_e) { /* fall back to the abspath */ }
498
+ reg.push(resolved);
499
+ try { writeRegistry(home, reg); } // atomic + dedup
500
+ catch (e) { fail("cannot write global registry " + registryPath(home) + " — " + (e && e.message ? e.message : e)); }
501
+ log(" ✓ global home ready at " + home);
502
+ log(" ✓ registered " + resolved + " (registry: " + readRegistry(home).length + ")");
503
+ }
504
+
505
+ // update --global: refresh the home mirror + skill, then propagate to every registered+existing
506
+ // project via reconcile(.., home); prune vanished projects (warn) + rewrite the registry atomically.
507
+ function cmdUpdateGlobal(args) {
508
+ const home = resolveGlobalHome(process.env);
509
+ const claudeDir = claudeSkillsDir(process.env);
510
+ if (!fs.existsSync(path.join(home, STAMP_FILE))) {
511
+ fail("no global ADD install at " + home + " (.add-version not found) — run `init --global` first");
512
+ }
513
+ // Read the registry BEFORE refreshing the home — a corrupt registry fails closed with ZERO
514
+ // writes (never a silent empty-list no-op), leaving the file for the user to fix or delete.
515
+ let reg;
516
+ try { reg = readRegistry(home); }
517
+ catch (_e) { fail("global registry " + registryPath(home) + " is corrupt — fix or delete it; not propagating"); }
518
+ try { reconcileGlobal(home, claudeDir, args.noSkill); }
519
+ catch (e) { fail("cannot write global home " + home + " — " + (e && e.message ? e.message : e)); }
520
+ const version = pkgVersion();
521
+ writeStamp(home, version, "global");
522
+ const kept = [];
523
+ let pruned = 0;
524
+ for (const p of reg) {
525
+ if (!fs.existsSync(p)) { log(" ⚠ registered project " + p + " not found — pruning"); pruned++; continue; }
526
+ reconcile(args, p, home); // standard MANAGED map, sourced from the home mirror
527
+ // re-persist an opted-in project (one that already has a snapshot); a vanished
528
+ // project's snapshot is KEPT above (the backup outlives the dir).
529
+ if (fs.existsSync(path.join(home, "data", dataKey(p)))) persistData(home, p);
530
+ kept.push(p);
531
+ }
532
+ writeRegistry(home, kept);
533
+ log("ADD " + version + " · global home + " + kept.length + " project(s) reconciled" +
534
+ (pruned ? " (" + pruned + " pruned)" : "") + ".");
535
+ }
536
+
154
537
  function cmdUpdate(args) {
538
+ if (args.global) return cmdUpdateGlobal(args);
155
539
  const target = path.resolve(args._[0] || ".");
156
540
  const addDir = path.join(target, ".add");
157
541
  if (!fs.existsSync(path.join(addDir, "tooling")) && !fs.existsSync(path.join(addDir, "state.json"))) {
@@ -167,7 +551,11 @@ function cmdUpdate(args) {
167
551
  else log("ADD update available: project on " + cur + ", package is " + version + ". Run `update`.");
168
552
  return;
169
553
  }
170
- if (cur === version && !args.force) {
554
+ // same-version no-op ONLY when nothing is missing — a missing managed tree HEALS
555
+ // even at the current version (heal-reconcile).
556
+ const status = managedStatus(target);
557
+ const missing = MANAGED.some(([sub]) => status[sub] === "missing");
558
+ if (cur === version && !args.force && !missing) {
171
559
  log("ADD already at " + version + " — nothing to update (use --force to re-materialize).");
172
560
  return;
173
561
  }
@@ -176,34 +564,37 @@ function cmdUpdate(args) {
176
564
  if (fs.existsSync(stateFile)) {
177
565
  fs.copyFileSync(stateFile, path.join(addDir, "pre-update-state.bak.json"));
178
566
  }
179
- for (const [sub, destParts, stripTests] of MANAGED) {
180
- cleanReplaceTree(path.join(PKG_ROOT, sub), path.join(target, ...destParts), stripTests);
181
- }
567
+ reconcile(args, target);
182
568
  writeStamp(addDir, version);
183
569
  log("ADD updated " + (cur || "(unstamped)") + " -> " + version +
184
- " · skill · tooling · docs refreshed · your project state untouched.");
570
+ " · managed layer reconciled · your project state untouched.");
185
571
  }
186
572
 
187
- function main() {
573
+ async function main() {
188
574
  const argv = process.argv.slice(2);
189
575
  const cmd = argv[0] && !argv[0].startsWith("--") ? argv.shift() : "init";
190
576
  const args = parseArgs(argv);
191
577
  switch (cmd) {
192
578
  case "init":
193
- cmdInit(args);
579
+ await cmdInit(args);
194
580
  break;
195
581
  case "update":
196
582
  cmdUpdate(args);
197
583
  break;
198
584
  case "help":
199
585
  case "--help":
200
- log("usage: npx @pilotspace/add <init|update> [targetDir] [--force] [--check]");
586
+ log("usage: npx @pilotspace/add <init|update> [targetDir] [--force] [--check] [--no-skill] [--global] [--yes|--non-interactive]");
201
587
  log(" init install the ADD skill + tooling + book into a project");
588
+ log(" (--no-skill drops the engine + book only — used by the Claude Code plugin)");
589
+ log(" (--global ALSO installs to a shared home [ADD_HOME|XDG_DATA_HOME/add|~/.add] + registers the project)");
590
+ log(" (--global-data implies --global + persists this project's user-data under <home>/data/<key>)");
591
+ log(" (interactive in a real terminal; --yes / --non-interactive force the plain path)");
202
592
  log(" update re-materialize skill/tooling/docs to this package version (preserves your state)");
593
+ log(" (--global refreshes the shared home + propagates to every registered project)");
203
594
  break;
204
595
  default:
205
596
  fail("unknown command '" + cmd + "'. Try: npx @pilotspace/add init");
206
597
  }
207
598
  }
208
599
 
209
- main();
600
+ main().catch((e) => fail(e && e.message ? e.message : String(e)));
@@ -83,7 +83,7 @@ The flow runs in two directions under two rules that never conflict. **Backward
83
83
  | 6 Verify | own the residue (security · concurrency · architecture); approve when `conservative` | gather evidence; **auto-PASS on complete evidence** under `autonomy: auto` |
84
84
  | 7 Observe | read the signal; consolidate confirmed deltas into PROJECT.md | run behind a flag; emit lessons learned |
85
85
 
86
- **What the human sees when it is their turn — the decision arc.** Whenever the flow stops for the human — the baseline approval that ends setup, the contract-freeze decision point and an escalated verify gate within each task, and the wider decision points of the loop (intake · scope · milestone close · stage graduation) — the AI opens its report with the **decision arc**: three engine-sourced lines — `goal:` the milestone goal the work serves · `done:` the proven progress toward it · `plan:` what comes next. The arc renders first, above the report's summary, so the human confirms with sight of the whole trajectory rather than a local snapshot. It is presentation only — it never adds a gate or changes an outcome. See [Appendix C](./appendix-c-glossary.md).
86
+ **What the human sees when it is their turn — the decision arc.** Whenever the flow stops for the human — the baseline approval that ends setup, the contract-freeze decision point and an escalated verify gate within each task, and the wider decision points of the loop (intake · scope · milestone close · stage graduation) — the AI opens its report with the **decision arc**: three engine-sourced lines — `goal:` the milestone goal the work serves · `done:` the proven progress toward it · `plan:` what comes next. The arc renders first, above the report's summary, so the human confirms with sight of the whole trajectory rather than a local snapshot. Within that report the AI also presents the **DECISION** itself as a **guided choice** — one highlighted **recommended pick** (`▶ … (recommended)`) plus its real, described alternatives — so the human chooses with the recommendation and each option's consequence in view rather than a bare next-step line. Both are presentation only — they never add a gate or change an outcome, and the guided choice fires at human gates only. See [Appendix C](./appendix-c-glossary.md) and the `add` skill's `report-template.md` for the convention itself.
87
87
 
88
88
  ## What survives, and what is disposable
89
89
 
@@ -58,6 +58,10 @@ Scenario: not my account
58
58
 
59
59
  The `And no balance changes` line is doing real work: it specifies that a rejected transfer must leave the world untouched — a property the AI could easily violate by deducting before checking.
60
60
 
61
+ ## Cover the edge cases
62
+
63
+ The transfer above is one domain; the same gaps recur in every domain — an HR leave request, a marketing campaign send, a checkout. Beyond the spec's "Reject" rules, sweep the recurring gaps and add a scenario for each that applies (or rule it out on purpose): boundary, duplicate/idempotent, ownership, stale/out-of-order, partial failure, concurrency, malformed input, limits/volume.
64
+
61
65
  ## The AI's role here
62
66
 
63
67
  Hand the AI the spec and have it draft a scenario for each rule, including the rejection rules. Then read them as the person who owns the requirement: do they describe what you actually meant? Correct any that drift. See `playbook/2_scenarios.md` in [Appendix B](./appendix-b-prompts.md).
@@ -65,6 +69,7 @@ Hand the AI the spec and have it draft a scenario for each rule, including the r
65
69
  ## Common mistakes
66
70
 
67
71
  - **Only happy-path scenarios.** Every "Reject" rule in the spec needs its own scenario, or that rule will never be verified.
72
+ - **Edge cases left to the build.** A boundary, a duplicate, or a partial failure with no scenario becomes whatever the AI happens to code. Sweep the categories above against the task's domain.
68
73
  - **Vague results.** "Then it works" is not checkable. The result must be a specific, observable fact ("A has 70").
69
74
  - **Forgetting the unchanged state.** For any rejection, assert that nothing changed; otherwise a partial, corrupting failure can pass.
70
75
 
@@ -72,6 +77,7 @@ Hand the AI the spec and have it draft a scenario for each rule, including the r
72
77
 
73
78
  - [ ] Every "Must" rule has at least one scenario.
74
79
  - [ ] Every "Reject" rule has at least one scenario.
80
+ - [ ] The edge-case categories that apply to this task's domain have a scenario (or are ruled out on purpose).
75
81
  - [ ] Each scenario's result is a specific, observable fact.
76
82
  - [ ] Rejections assert what must stay unchanged.
77
83