@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/CHANGELOG.md +91 -0
- package/README.md +34 -1
- package/bin/cli.js +449 -58
- package/docs/02-the-flow.md +1 -1
- package/docs/04-step-2-scenarios.md +6 -0
- package/docs/09-the-loop.md +2 -0
- package/docs/16-releasing.md +182 -0
- package/docs/README.md +3 -0
- package/docs/appendix-c-glossary.md +19 -1
- package/package.json +4 -1
- package/skill/add/SKILL.md +33 -0
- package/skill/add/fold.md +53 -35
- package/skill/add/graduate.md +2 -1
- package/skill/add/intake.md +1 -0
- package/skill/add/loop.md +18 -4
- package/skill/add/phases/0-setup.md +1 -1
- package/skill/add/phases/2-scenarios.md +2 -0
- package/skill/add/phases/3-contract.md +1 -1
- package/skill/add/phases/6-verify.md +1 -1
- package/skill/add/release.md +115 -0
- package/skill/add/report-template.md +42 -6
- package/skill/add/scope.md +1 -0
- package/tooling/add.py +862 -91
- package/tooling/templates/GLOSSARY.md.tmpl +2 -0
- package/tooling/templates/MILESTONE.md.tmpl +23 -0
- package/tooling/templates/TASK.md.tmpl +5 -1
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
fs.cpSync(src, dest, { recursive: true });
|
|
110
|
+
return false;
|
|
68
111
|
}
|
|
69
112
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
fs.
|
|
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
|
-
|
|
172
|
+
}
|
|
95
173
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
238
|
+
log("\nDone. " + (args.noSkill ? "The engine + book are" : "The `add` skill + tooling are") +
|
|
239
|
+
" installed (no project state yet — that'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
|
-
|
|
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
|
-
|
|
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
|
-
" ·
|
|
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)));
|
package/docs/02-the-flow.md
CHANGED
|
@@ -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.
|
|
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
|
|