specpipe 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1319 -0
  2. package/bin/devkit.js +3 -0
  3. package/package.json +61 -0
  4. package/src/cli.js +76 -0
  5. package/src/commands/check.js +33 -0
  6. package/src/commands/diff.js +84 -0
  7. package/src/commands/init-adopt.js +54 -0
  8. package/src/commands/init-agents.js +118 -0
  9. package/src/commands/init-global.js +102 -0
  10. package/src/commands/init.js +311 -0
  11. package/src/commands/list.js +54 -0
  12. package/src/commands/remove.js +133 -0
  13. package/src/commands/upgrade.js +215 -0
  14. package/src/lib/agent-guards.js +100 -0
  15. package/src/lib/agent-install.js +161 -0
  16. package/src/lib/agents.js +280 -0
  17. package/src/lib/claude-global.js +183 -0
  18. package/src/lib/detector.js +93 -0
  19. package/src/lib/hasher.js +21 -0
  20. package/src/lib/installer.js +213 -0
  21. package/src/lib/logger.js +16 -0
  22. package/src/lib/manifest.js +102 -0
  23. package/src/lib/reconcile.js +56 -0
  24. package/templates/.claude/CLAUDE.md +79 -0
  25. package/templates/.claude/hooks/comment-guard.js +126 -0
  26. package/templates/.claude/hooks/file-guard.js +216 -0
  27. package/templates/.claude/hooks/glob-guard.js +104 -0
  28. package/templates/.claude/hooks/path-guard.sh +118 -0
  29. package/templates/.claude/hooks/self-review.sh +27 -0
  30. package/templates/.claude/hooks/sensitive-guard.sh +227 -0
  31. package/templates/.claude/settings.json +68 -0
  32. package/templates/docs/WORKFLOW.md +325 -0
  33. package/templates/docs/specs/.gitkeep +0 -0
  34. package/templates/hooks/specpipe-read-guard.sh +42 -0
  35. package/templates/hooks/specpipe-shell-guard.sh +65 -0
  36. package/templates/rules/specpipe-guards.md +40 -0
  37. package/templates/scripts/test-hooks.sh +66 -0
  38. package/templates/skills/sp-build/SKILL.md +776 -0
  39. package/templates/skills/sp-challenge/SKILL.md +255 -0
  40. package/templates/skills/sp-commit/SKILL.md +174 -0
  41. package/templates/skills/sp-explore/SKILL.md +730 -0
  42. package/templates/skills/sp-fix/SKILL.md +266 -0
  43. package/templates/skills/sp-humanize/SKILL.md +212 -0
  44. package/templates/skills/sp-investigate/SKILL.md +648 -0
  45. package/templates/skills/sp-md-render/SKILL.md +200 -0
  46. package/templates/skills/sp-md-render/components.md +415 -0
  47. package/templates/skills/sp-md-render/template.html +283 -0
  48. package/templates/skills/sp-plan/SKILL.md +947 -0
  49. package/templates/skills/sp-review/SKILL.md +268 -0
  50. package/templates/skills/sp-scaffold/SKILL.md +237 -0
  51. package/templates/skills/sp-scaffold/references/ARCHITECTURE.md.tmpl +228 -0
  52. package/templates/skills/sp-scaffold/references/DESIGN.md.tmpl +113 -0
  53. package/templates/skills/sp-scaffold/references/adr/NNNN-template.md +92 -0
  54. package/templates/skills/sp-scaffold/references/stack-profiles/react.md +36 -0
  55. package/templates/skills/sp-spec-render/SKILL.md +254 -0
  56. package/templates/skills/sp-spec-render/components.md +418 -0
  57. package/templates/skills/sp-spec-render/examples/user-auth.html +749 -0
  58. package/templates/skills/sp-spec-render/examples/user-auth.md +114 -0
  59. package/templates/skills/sp-spec-render/template.html +222 -0
  60. package/templates/skills/sp-voices/SKILL.md +1184 -0
@@ -0,0 +1,311 @@
1
+ import { resolve, dirname } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { execSync } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { log } from '../lib/logger.js';
7
+ import { detectProject } from '../lib/detector.js';
8
+ import { writeManifest, createManifest, setFileEntry, readManifest, mergeAgents } from '../lib/manifest.js';
9
+ import { hashFile, hashContent } from '../lib/hasher.js';
10
+ import { readFile } from 'node:fs/promises';
11
+ import { computeDesired } from '../lib/reconcile.js';
12
+ import {
13
+ getAllFiles, getFilesForComponents, installFile,
14
+ ensurePlaceholderDir, setPermissions, fillTemplate,
15
+ verifySettingsJson, PLACEHOLDER_DIRS, COMPONENTS,
16
+ getTemplateDir, installSkillForAgent,
17
+ } from '../lib/installer.js';
18
+ import { AGENTS, parseSkillPath } from '../lib/agents.js';
19
+ import { initMultiAgent } from './init-agents.js';
20
+ import { adoptExisting } from './init-adopt.js';
21
+ import { readGlobalManifest, writeGlobalManifest, initGlobal } from './init-global.js';
22
+
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8'));
26
+
27
+ export async function initCommand(path, opts) {
28
+ const targetDir = resolve(path);
29
+
30
+ if (!existsSync(targetDir)) {
31
+ log.fail(`Directory not found: ${targetDir}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ log.info(`specpipe v${pkg.version}`);
36
+ log.info(`Target: ${targetDir}`);
37
+ log.blank();
38
+
39
+ // --- Global mode ---
40
+ if (opts.global) {
41
+ await initGlobal({ force: opts.force, hooks: true });
42
+ return;
43
+ }
44
+
45
+ // --- Adopt mode ---
46
+ if (opts.adopt) {
47
+ await adoptExisting(targetDir);
48
+ return;
49
+ }
50
+
51
+ // --- Prerequisites ---
52
+ let warnings = 0;
53
+
54
+ if (!commandExists('git')) {
55
+ log.fail('Git not found — required');
56
+ process.exit(1);
57
+ }
58
+
59
+ if (!commandExists('node')) {
60
+ log.warn('Node.js not found — file-guard.js hook requires it');
61
+ warnings++;
62
+ }
63
+
64
+ if (!existsSync(resolve(targetDir, '.git'))) {
65
+ log.warn('Not a git repository. Some features need git.');
66
+ warnings++;
67
+ }
68
+
69
+ // --- Multi-agent install (opt-in via --agents) ---
70
+ if (opts.agents) {
71
+ await initMultiAgent(targetDir, opts, warnings);
72
+ return;
73
+ }
74
+
75
+ // --- Determine files to install ---
76
+ let components = Object.keys(COMPONENTS);
77
+ if (opts.only) {
78
+ components = opts.only.split(',').map((c) => c.trim());
79
+ const valid = Object.keys(COMPONENTS);
80
+ for (const c of components) {
81
+ if (!valid.includes(c)) {
82
+ log.fail(`Unknown component: ${c}. Valid: ${valid.join(', ')}`);
83
+ process.exit(1);
84
+ }
85
+ }
86
+ }
87
+
88
+ const files = opts.only ? getFilesForComponents(components) : getAllFiles();
89
+
90
+ // --- Dry run ---
91
+ if (opts.dryRun) {
92
+ log.info('Dry run — no changes will be made');
93
+ log.blank();
94
+ for (const file of files) {
95
+ const sk = parseSkillPath(file);
96
+ const rel = sk ? AGENTS.claude.skillTarget(sk.skill, sk.inner) : file;
97
+ const dst = resolve(targetDir, rel);
98
+ if (existsSync(dst) && !opts.force) {
99
+ log.skip(`${rel} (exists)`);
100
+ } else {
101
+ log.copy(`${rel} (would copy)`);
102
+ }
103
+ }
104
+ return;
105
+ }
106
+
107
+ // --- Install files ---
108
+ console.log('--- Installing ---');
109
+
110
+ const manifest = createManifest(pkg.version, null, components);
111
+ let copied = 0;
112
+ let skipped = 0;
113
+ let identical = 0;
114
+
115
+ for (const file of files) {
116
+ // Skills are emitted through the Claude target (canonical skills/ → .claude/skills/);
117
+ // hooks/config/docs are copied verbatim at their own path.
118
+ const isSkill = !!parseSkillPath(file);
119
+ let outPath = file;
120
+ if (isSkill) {
121
+ const { result, path } = await installSkillForAgent('claude', file, targetDir, { force: opts.force });
122
+ outPath = path || file;
123
+ if (result === 'copied') copied++;
124
+ else if (result === 'identical') identical++;
125
+ else skipped++;
126
+ } else {
127
+ const result = await installFile(file, targetDir, { force: opts.force });
128
+ if (result === 'copied') copied++;
129
+ else if (result === 'identical') identical++;
130
+ else skipped++;
131
+ }
132
+
133
+ // Record in manifest (keyed by on-disk path; Claude emit is byte-identical to source).
134
+ const kitHash = await hashFile(resolve(getTemplateDir(), file));
135
+ let installedHash = kitHash;
136
+ try {
137
+ installedHash = await hashFile(resolve(targetDir, outPath));
138
+ } catch { /* file might not exist if skipped */ }
139
+ setFileEntry(manifest, outPath, kitHash, installedHash, { agent: 'claude', templateRel: file });
140
+ }
141
+
142
+ // Accumulate: keep agents installed by an earlier run so a plain `init` doesn't
143
+ // orphan files from a prior `init --agents …`. Record their on-disk files too.
144
+ const prior = await readManifest(targetDir);
145
+ manifest.agents = mergeAgents(prior?.agents, ['claude']);
146
+ for (const [relPath, d] of await computeDesired(manifest.agents.filter((a) => a !== 'claude'))) {
147
+ if (manifest.files[relPath]) continue;
148
+ try {
149
+ const installedHash = hashContent(await readFile(resolve(targetDir, relPath), 'utf-8'));
150
+ setFileEntry(manifest, relPath, d.kitHash, installedHash, { agent: d.agent, templateRel: d.templateRel });
151
+ } catch { /* prior agent's file not on disk — don't record a phantom */ }
152
+ }
153
+
154
+ // Placeholder directories
155
+ for (const dir of PLACEHOLDER_DIRS) {
156
+ await ensurePlaceholderDir(dir, targetDir);
157
+ }
158
+
159
+ // --- Permissions ---
160
+ await setPermissions(targetDir);
161
+
162
+ // --- Project detection ---
163
+ log.blank();
164
+ console.log('--- Detecting project ---');
165
+
166
+ const projectInfo = detectProject(targetDir);
167
+ if (projectInfo) {
168
+ log.info(`Detected: ${projectInfo.lang} (${projectInfo.framework})`);
169
+ log.info(`Source: ${projectInfo.srcDir} | Tests: ${projectInfo.testDir}`);
170
+ manifest.projectType = { lang: projectInfo.lang, framework: projectInfo.framework };
171
+
172
+ await fillTemplate(targetDir, projectInfo);
173
+ log.info('Updated CLAUDE.md with project info');
174
+
175
+ // Re-hash CLAUDE.md after template fill
176
+ try {
177
+ const claudeHash = await hashFile(resolve(targetDir, '.claude/CLAUDE.md'));
178
+ if (manifest.files['.claude/CLAUDE.md']) {
179
+ manifest.files['.claude/CLAUDE.md'].installedHash = claudeHash;
180
+ manifest.files['.claude/CLAUDE.md'].customized = false; // Template fill is not "customization"
181
+ }
182
+ } catch { /* */ }
183
+ } else {
184
+ log.warn('Could not detect project type. Fill in CLAUDE.md manually.');
185
+ warnings++;
186
+ }
187
+
188
+ // --- Write manifest ---
189
+ await writeManifest(targetDir, manifest);
190
+
191
+ // --- Verification ---
192
+ log.blank();
193
+ console.log('--- Verification ---');
194
+
195
+ if (await verifySettingsJson(targetDir)) {
196
+ log.pass('settings.json is valid JSON');
197
+ } else {
198
+ log.fail('settings.json is invalid JSON');
199
+ }
200
+
201
+ // --- Summary ---
202
+ log.blank();
203
+ console.log('=== Setup Complete ===');
204
+ log.blank();
205
+ console.log('Installed:');
206
+ console.log(' .claude/CLAUDE.md — Project rules (review and customize)');
207
+ console.log(' .claude/settings.json — Hook configuration');
208
+ console.log(' .claude/hooks/ — 6 guards (file, path, glob, comment, sensitive, self-review)');
209
+ console.log(' .claude/skills/ — /sp-plan, /sp-challenge, /sp-build, /sp-fix, /sp-review, /sp-commit, /sp-voices');
210
+ console.log(' docs/WORKFLOW.md — Workflow reference');
211
+ log.blank();
212
+ const parts = [`${copied} copied`];
213
+ if (identical > 0) parts.push(`${identical} identical`);
214
+ if (skipped > 0) parts.push(`${skipped} conflicted (use --force to overwrite)`);
215
+ console.log(` ${parts.join(', ')}`);
216
+ log.blank();
217
+ console.log('Next steps:');
218
+ console.log(' 1. Review .claude/CLAUDE.md — ensure project info is correct');
219
+ console.log(' 2. Write your first spec: docs/specs/<feature>.md');
220
+ console.log(' 3. Generate test plan: /sp-plan docs/specs/<feature>.md');
221
+ console.log(' 4. Start coding + testing: /sp-build');
222
+ log.blank();
223
+
224
+ if (warnings > 0) {
225
+ console.log(`⚠ ${warnings} warning(s) above — review before proceeding.`);
226
+ }
227
+
228
+ // --- Global install prompt (first-time only) ---
229
+ if (!opts.global) {
230
+ const globalMeta = await readGlobalManifest();
231
+ if (globalMeta?.globalInstalled === undefined) {
232
+ await promptGlobalInstall(opts);
233
+ } else if (globalMeta?.globalInstalled === true) {
234
+ // Auto-upgrade global on init if previously installed
235
+ await initGlobal({ force: opts.force });
236
+ }
237
+ }
238
+ }
239
+
240
+ async function promptGlobalInstall(opts) {
241
+ log.blank();
242
+ console.log('─── Global Install ───');
243
+ console.log('');
244
+ console.log('Skills and hooks are installed per-project by default.');
245
+ console.log('You can install them globally so every project is covered without running init again.');
246
+ console.log('');
247
+ console.log(' ~/.claude/skills/ ← global skills (fallback when no per-project skills)');
248
+ console.log(' ~/.claude/hooks/ ← global hooks (active in all projects)');
249
+ console.log(' .claude/skills/ ← per-project skills (takes precedence over global)');
250
+ console.log(' .claude/hooks/ ← per-project hooks (takes precedence over global)');
251
+ console.log('');
252
+ console.log('To revert global hooks back to per-project later:');
253
+ console.log(' specpipe remove --global');
254
+ console.log(' then: specpipe init (in each project)');
255
+ console.log('');
256
+ console.log('RECOMMENDATION: Choose A if you work across many projects.');
257
+ console.log('');
258
+
259
+ const answer = await askGlobalInstall();
260
+
261
+ if (answer === 'skills+hooks') {
262
+ await initGlobal({ force: opts.force, hooks: true });
263
+ await trackProjectPath(process.cwd());
264
+ } else if (answer === 'skills') {
265
+ await initGlobal({ force: opts.force, hooks: false });
266
+ await trackProjectPath(process.cwd());
267
+ } else if (answer === 'no') {
268
+ await writeGlobalManifest({ globalInstalled: false, updatedAt: new Date().toISOString() });
269
+ log.info('Skipping global install. Run `specpipe init --global` anytime.');
270
+ }
271
+ // 'later' = don't write anything, prompt again next time
272
+ }
273
+
274
+ async function askGlobalInstall() {
275
+ const { createInterface } = await import('node:readline');
276
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
277
+ return new Promise((resolve) => {
278
+ console.log('A) Skills + Hooks globally (recommended)');
279
+ console.log('B) Skills only (hooks stay per-project)');
280
+ console.log('C) No — keep everything per-project');
281
+ console.log('D) Ask me next time');
282
+ console.log('');
283
+ rl.question('Choice [A/B/C/D]: ', (answer) => {
284
+ rl.close();
285
+ const a = answer.trim().toUpperCase();
286
+ if (a === 'A') resolve('skills+hooks');
287
+ else if (a === 'B') resolve('skills');
288
+ else if (a === 'C') resolve('no');
289
+ else resolve('later');
290
+ });
291
+ });
292
+ }
293
+
294
+ async function trackProjectPath(projectPath) {
295
+ const meta = await readGlobalManifest() || {};
296
+ const projects = new Set(meta.projects || []);
297
+ projects.add(projectPath);
298
+ await writeGlobalManifest({ ...meta, projects: [...projects] });
299
+ }
300
+
301
+ function commandExists(cmd) {
302
+ // `command -v` is a POSIX shell builtin and is unavailable under Windows
303
+ // cmd.exe, where execSync runs. Use `where` on Windows, `command -v` elsewhere.
304
+ const probe = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
305
+ try {
306
+ execSync(probe, { stdio: 'ignore' });
307
+ return true;
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
@@ -0,0 +1,54 @@
1
+ import { resolve } from 'node:path';
2
+ import chalk from 'chalk';
3
+ import { log } from '../lib/logger.js';
4
+ import { readManifest, refreshCustomizationStatus, getAgents } from '../lib/manifest.js';
5
+ import { AGENTS } from '../lib/agents.js';
6
+
7
+ export async function listCommand(path) {
8
+ const targetDir = resolve(path);
9
+ const manifest = await readManifest(targetDir);
10
+
11
+ if (!manifest) {
12
+ log.fail('No manifest found. Run `specpipe init` first.');
13
+ process.exit(1);
14
+ }
15
+
16
+ // Refresh hashes to get accurate customization status
17
+ await refreshCustomizationStatus(targetDir, manifest);
18
+
19
+ const agents = getAgents(manifest);
20
+ log.info(`specpipe v${manifest.version} — installed ${manifest.installedAt.split('T')[0]}`);
21
+ if (manifest.projectType) {
22
+ log.info(`Project: ${manifest.projectType.lang} (${manifest.projectType.framework})`);
23
+ }
24
+ log.info(`Agents: ${agents.map((a) => AGENTS[a]?.label || a).join(', ')}`);
25
+ log.blank();
26
+
27
+ // Group files by the agent that produced them.
28
+ const byAgent = {};
29
+ for (const [file, entry] of Object.entries(manifest.files)) {
30
+ const a = entry.agent || 'claude';
31
+ (byAgent[a] ||= []).push([file, entry]);
32
+ }
33
+
34
+ const fileCol = 44;
35
+ let totalFiles = 0;
36
+ let customized = 0;
37
+
38
+ for (const agent of Object.keys(byAgent)) {
39
+ const meta = AGENTS[agent];
40
+ const hookNote = meta && meta.hooks !== 'native' ? chalk.gray(' (guards as advisory rules)') : '';
41
+ console.log(chalk.bold(`${meta?.label || agent}`) + hookNote);
42
+ for (const [file, entry] of byAgent[agent].sort((a, b) => a[0].localeCompare(b[0]))) {
43
+ totalFiles++;
44
+ let status;
45
+ if (entry.installedHash === null) status = chalk.red('deleted');
46
+ else if (entry.customized) { status = chalk.yellow('customized'); customized++; }
47
+ else status = chalk.green('up-to-date');
48
+ console.log(' ' + file.padEnd(fileCol) + status);
49
+ }
50
+ log.blank();
51
+ }
52
+
53
+ console.log(`${totalFiles} files | ${customized} customized | ${agents.length} agent(s)`);
54
+ }
@@ -0,0 +1,133 @@
1
+ import { resolve, join, dirname } from 'node:path';
2
+ import { unlink, rmdir, rm } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { log } from '../lib/logger.js';
6
+ import { readManifest, getAgents, MANIFEST_FILE, LEGACY_MANIFEST_FILE } from '../lib/manifest.js';
7
+ import { removeGlobalHooksFromSettings, stripAgentsMdGuards, removeAgentHooks } from '../lib/installer.js';
8
+ import { agentHasHooks } from '../lib/agents.js';
9
+
10
+ const PRESERVE = [
11
+ '.claude/CLAUDE.md',
12
+ ];
13
+
14
+ const PRESERVE_DIRS = [
15
+ 'docs/',
16
+ ];
17
+
18
+ export async function removeGlobal() {
19
+ log.info('Removing global specpipe install...');
20
+ log.blank();
21
+
22
+ // Remove ~/.claude/skills/
23
+ const globalSkillsDir = join(homedir(), '.claude', 'skills');
24
+ if (existsSync(globalSkillsDir)) {
25
+ await rm(globalSkillsDir, { recursive: true, force: true });
26
+ log.del('~/.claude/skills/');
27
+ } else {
28
+ log.skip('~/.claude/skills/ (not found)');
29
+ }
30
+
31
+ // Remove ~/.claude/hooks/
32
+ const globalHooksDir = join(homedir(), '.claude', 'hooks');
33
+ if (existsSync(globalHooksDir)) {
34
+ await rm(globalHooksDir, { recursive: true, force: true });
35
+ log.del('~/.claude/hooks/');
36
+ } else {
37
+ log.skip('~/.claude/hooks/ (not found)');
38
+ }
39
+
40
+ // Legacy cleanup: older installs shipped ~/.claude/scripts/build-test.sh.
41
+ // The script is no longer part of the kit — sweep up the orphan if present.
42
+ const legacyScript = join(homedir(), '.claude', 'scripts', 'build-test.sh');
43
+ if (existsSync(legacyScript)) {
44
+ await unlink(legacyScript);
45
+ log.del('~/.claude/scripts/build-test.sh (legacy)');
46
+ try {
47
+ await rmdir(join(homedir(), '.claude', 'scripts'));
48
+ } catch { /* keep dir if user has other scripts in it */ }
49
+ }
50
+
51
+ // Remove devkit hook entries from ~/.claude/settings.json
52
+ await removeGlobalHooksFromSettings();
53
+ log.del('hook entries from ~/.claude/settings.json');
54
+
55
+ // Remove global manifest
56
+ const globalManifest = join(homedir(), '.claude', '.devkit-manifest.json');
57
+ if (existsSync(globalManifest)) {
58
+ await unlink(globalManifest);
59
+ log.del('~/.claude/.devkit-manifest.json');
60
+ }
61
+
62
+ log.blank();
63
+ log.pass('Global install removed. Per-project installs are unaffected.');
64
+ log.info('Run `specpipe init` in each project to restore per-project hooks.');
65
+ }
66
+
67
+ export async function removeCommand(path, opts = {}) {
68
+ if (opts.global) {
69
+ await removeGlobal();
70
+ return;
71
+ }
72
+
73
+ const targetDir = resolve(path);
74
+ const manifest = await readManifest(targetDir);
75
+
76
+ if (!manifest) {
77
+ log.fail('No manifest found. Nothing to remove.');
78
+ process.exit(1);
79
+ }
80
+
81
+ log.info('Removing specpipe files...');
82
+ log.blank();
83
+
84
+ const removedDirs = new Set();
85
+
86
+ // Remove tracked files (except preserved), across every agent's layout.
87
+ for (const file of Object.keys(manifest.files)) {
88
+ if (PRESERVE.includes(file) || PRESERVE_DIRS.some((dir) => file.startsWith(dir))) {
89
+ log.keep(file);
90
+ continue;
91
+ }
92
+ const fullPath = join(targetDir, file);
93
+ if (existsSync(fullPath)) {
94
+ await unlink(fullPath);
95
+ log.del(file);
96
+ // Track ancestor dirs (within the project) for empty-dir cleanup.
97
+ let d = dirname(file);
98
+ while (d && d !== '.' && d !== '/') { removedDirs.add(d); d = dirname(d); }
99
+ }
100
+ }
101
+
102
+ // Remove the manifest (new + legacy locations).
103
+ for (const rel of [MANIFEST_FILE, LEGACY_MANIFEST_FILE]) {
104
+ const p = join(targetDir, rel);
105
+ if (existsSync(p)) {
106
+ await unlink(p);
107
+ log.del(rel);
108
+ let d = dirname(rel);
109
+ while (d && d !== '.' && d !== '/') { removedDirs.add(d); d = dirname(d); }
110
+ }
111
+ }
112
+
113
+ // Codex guards live as a section in a shared AGENTS.md — strip just that section.
114
+ if (manifest.agentsMdGuards) {
115
+ if (await stripAgentsMdGuards(targetDir)) log.del('AGENTS.md (specpipe guards section)');
116
+ }
117
+
118
+ // Enforced hooks (Codex/Cursor) live outside the tracked file set — clean per agent.
119
+ for (const agent of getAgents(manifest)) {
120
+ if (agentHasHooks(agent)) await removeAgentHooks(agent, targetDir);
121
+ }
122
+
123
+ // Legacy: older installs placed build-test.sh under scripts/.
124
+ removedDirs.add('scripts');
125
+
126
+ // Remove now-empty directories, deepest first (preserves dirs with user content).
127
+ for (const dir of [...removedDirs].sort((a, b) => b.split('/').length - a.split('/').length)) {
128
+ try { await rmdir(join(targetDir, dir)); } catch { /* not empty or missing */ }
129
+ }
130
+
131
+ log.blank();
132
+ log.pass('Removed. CLAUDE.md and docs/ preserved.');
133
+ }