specpipe 1.0.1 → 1.0.2

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 (46) hide show
  1. package/README.md +111 -311
  2. package/package.json +2 -1
  3. package/src/cli.js +16 -6
  4. package/src/commands/diff.js +1 -1
  5. package/src/commands/init-agents.js +40 -20
  6. package/src/commands/init-global.js +88 -33
  7. package/src/commands/init-interactive.js +71 -0
  8. package/src/commands/init.js +61 -22
  9. package/src/commands/remove.js +159 -49
  10. package/src/commands/upgrade.js +21 -56
  11. package/src/lib/agent-guards.js +34 -78
  12. package/src/lib/agent-install.js +38 -25
  13. package/src/lib/agents.js +53 -11
  14. package/src/lib/claude-global.js +50 -77
  15. package/src/lib/hooks.js +203 -0
  16. package/src/lib/installer.js +73 -61
  17. package/src/lib/reconcile.js +13 -8
  18. package/templates/{.claude/hooks → hooks}/file-guard.js +26 -21
  19. package/templates/hooks/specpipe-read-guard.sh +94 -21
  20. package/templates/hooks/specpipe-shell-guard.sh +121 -29
  21. package/templates/rules/specpipe-rules.md +77 -0
  22. package/templates/skills/sp-build/SKILL.md +101 -1
  23. package/templates/skills/sp-build-behavior-matrix/SKILL.md +876 -0
  24. package/templates/skills/sp-challenge/SKILL.md +34 -0
  25. package/templates/skills/sp-challenge-behavior-matrix/SKILL.md +289 -0
  26. package/templates/skills/sp-explore/SKILL.md +132 -0
  27. package/templates/skills/sp-explore-behavior-matrix/SKILL.md +862 -0
  28. package/templates/skills/sp-fix/SKILL.md +73 -1
  29. package/templates/skills/sp-fix-behavior-matrix/SKILL.md +338 -0
  30. package/templates/skills/sp-investigate/SKILL.md +70 -0
  31. package/templates/skills/sp-investigate-behavior-matrix/SKILL.md +718 -0
  32. package/templates/skills/sp-plan/SKILL.md +90 -0
  33. package/templates/skills/sp-plan-behavior-matrix/SKILL.md +1037 -0
  34. package/templates/skills/sp-review/SKILL.md +29 -3
  35. package/templates/skills/sp-review-behavior-matrix/SKILL.md +294 -0
  36. package/templates/.claude/CLAUDE.md +0 -79
  37. package/templates/.claude/hooks/path-guard.sh +0 -118
  38. package/templates/.claude/hooks/self-review.sh +0 -27
  39. package/templates/.claude/hooks/sensitive-guard.sh +0 -227
  40. package/templates/.claude/settings.json +0 -68
  41. package/templates/docs/WORKFLOW.md +0 -325
  42. package/templates/docs/specs/.gitkeep +0 -0
  43. package/templates/rules/specpipe-guards.md +0 -40
  44. package/templates/scripts/test-hooks.sh +0 -66
  45. /package/templates/{.claude/hooks → hooks}/comment-guard.js +0 -0
  46. /package/templates/{.claude/hooks → hooks}/glob-guard.js +0 -0
@@ -3,9 +3,14 @@ import { unlink, rmdir, rm } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
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';
6
+ import { readManifest, writeManifest, getAgents, MANIFEST_FILE, LEGACY_MANIFEST_FILE } from '../lib/manifest.js';
7
+ import { removeGlobalHooksFromSettings, stripRulesSection, removeAgentHooks, COMPONENTS } from '../lib/installer.js';
8
+ import { agentHasHooks, AGENTS, resolveAgents, emitRules } from '../lib/agents.js';
9
+ import { computeDesired } from '../lib/reconcile.js';
10
+ import { readGlobalManifest } from './init-global.js';
11
+
12
+ // specpipe's skill dir names (sp-*), derived from the kit's skill component list.
13
+ const GLOBAL_SKILL_NAMES = [...new Set(COMPONENTS.skills.map((p) => p.split('/')[1]))];
9
14
 
10
15
  const PRESERVE = [
11
16
  '.claude/CLAUDE.md',
@@ -15,24 +20,36 @@ const PRESERVE_DIRS = [
15
20
  'docs/',
16
21
  ];
17
22
 
18
- export async function removeGlobal() {
19
- log.info('Removing global specpipe install...');
23
+ export async function removeGlobal({ dryRun = false } = {}) {
24
+ log.info(dryRun ? 'Global remove — dry run (no changes):' : 'Removing global specpipe install...');
20
25
  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)');
26
+ const would = (label) => log.del(dryRun ? `${label} (would remove)` : label);
27
+
28
+ // Remove only specpipe's sp-* skill dirs from each globally-installed agent's
29
+ // skills root — never the whole root (it may hold the agent's own / vendor skills,
30
+ // e.g. Codex ships system skills under ~/.codex/skills/.system).
31
+ const gm = await readGlobalManifest() || {};
32
+ const globalAgents = gm.globalAgents || (gm.globalInstalled ? ['claude'] : ['claude']);
33
+ for (const agent of globalAgents) {
34
+ const root = AGENTS[agent]?.globalSkillRoot;
35
+ if (!root) continue;
36
+ let removed = 0;
37
+ for (const name of GLOBAL_SKILL_NAMES) {
38
+ const dir = join(homedir(), ...root.split('/'), name);
39
+ if (existsSync(dir)) { if (!dryRun) await rm(dir, { recursive: true, force: true }); removed++; }
40
+ }
41
+ if (removed) would(`~/${root}/sp-* (${removed} skill${removed === 1 ? '' : 's'})`);
42
+ else log.skip(`~/${root}/sp-* (none found)`);
43
+ // Tidy up the skills root if specpipe was its only occupant; rmdir is a no-op
44
+ // (throws) when other skills remain — e.g. Codex's ~/.codex/skills/.system.
45
+ if (!dryRun) try { await rmdir(join(homedir(), ...root.split('/'))); } catch { /* not empty — keep */ }
29
46
  }
30
47
 
31
48
  // Remove ~/.claude/hooks/
32
49
  const globalHooksDir = join(homedir(), '.claude', 'hooks');
33
50
  if (existsSync(globalHooksDir)) {
34
- await rm(globalHooksDir, { recursive: true, force: true });
35
- log.del('~/.claude/hooks/');
51
+ if (!dryRun) await rm(globalHooksDir, { recursive: true, force: true });
52
+ would('~/.claude/hooks/');
36
53
  } else {
37
54
  log.skip('~/.claude/hooks/ (not found)');
38
55
  }
@@ -41,44 +58,91 @@ export async function removeGlobal() {
41
58
  // The script is no longer part of the kit — sweep up the orphan if present.
42
59
  const legacyScript = join(homedir(), '.claude', 'scripts', 'build-test.sh');
43
60
  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 */ }
61
+ if (!dryRun) await unlink(legacyScript);
62
+ would('~/.claude/scripts/build-test.sh (legacy)');
63
+ if (!dryRun) try { await rmdir(join(homedir(), '.claude', 'scripts')); } catch { /* keep if other scripts */ }
49
64
  }
50
65
 
51
66
  // Remove devkit hook entries from ~/.claude/settings.json
52
- await removeGlobalHooksFromSettings();
53
- log.del('hook entries from ~/.claude/settings.json');
67
+ if (!dryRun) await removeGlobalHooksFromSettings();
68
+ would('hook entries from ~/.claude/settings.json');
54
69
 
55
70
  // Remove global manifest
56
71
  const globalManifest = join(homedir(), '.claude', '.devkit-manifest.json');
57
72
  if (existsSync(globalManifest)) {
58
- await unlink(globalManifest);
59
- log.del('~/.claude/.devkit-manifest.json');
73
+ if (!dryRun) await unlink(globalManifest);
74
+ would('~/.claude/.devkit-manifest.json');
60
75
  }
61
76
 
62
77
  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.');
78
+ log.pass(dryRun ? 'Dry run — nothing changed.' : 'Global install removed. Per-project installs are unaffected.');
79
+ if (!dryRun) log.info('Run `specpipe init` in each project to restore per-project hooks.');
65
80
  }
66
81
 
67
- export async function removeCommand(path, opts = {}) {
68
- if (opts.global) {
69
- await removeGlobal();
70
- return;
82
+ /**
83
+ * Remove only the named agents, keeping the rest. A file is deleted only when no
84
+ * remaining agent still wants it (reconcile against computeDesired), so shared
85
+ * artifacts survive: .agents/skills/* stays while Codex OR Antigravity remains,
86
+ * SPECPIPE-RULES.md stays while OpenClaw OR Hermes remains. Each removed agent's
87
+ * merge-mode rules section (Claude → CLAUDE.md, Codex → AGENTS.md) and enforced
88
+ * hook config are stripped/removed separately — those are unique per agent.
89
+ */
90
+ async function removeAgentsPartial(targetDir, manifest, removeSet, remaining, dryRun) {
91
+ const removedLabels = removeSet.map((a) => AGENTS[a]?.label || a).join(', ');
92
+ const keptLabels = remaining.map((a) => AGENTS[a]?.label || a).join(', ');
93
+ log.info(dryRun ? `Dry run — would remove ${removedLabels}, keep ${keptLabels}:` : `Removing ${removedLabels}; keeping ${keptLabels}.`);
94
+ log.blank();
95
+
96
+ const skillsSet = manifest.skills ? new Set(manifest.skills) : null;
97
+ const desired = await computeDesired(remaining, skillsSet); // paths a remaining agent still needs
98
+ const removedDirs = new Set();
99
+
100
+ for (const file of Object.keys(manifest.files)) {
101
+ if (PRESERVE.includes(file) || PRESERVE_DIRS.some((d) => file.startsWith(d))) { log.keep(file); continue; }
102
+ if (desired.has(file)) {
103
+ // Still owned by a remaining agent — keep, and reassign the owner so the
104
+ // manifest stays accurate (the removed agent may have been the recorded owner).
105
+ if (!dryRun) manifest.files[file].agent = desired.get(file).agent;
106
+ continue;
107
+ }
108
+ const full = join(targetDir, file);
109
+ if (existsSync(full)) {
110
+ if (dryRun) { log.del(`${file} (would remove)`); }
111
+ else { await unlink(full); log.del(file); delete manifest.files[file]; }
112
+ let d = dirname(file);
113
+ while (d && d !== '.' && d !== '/') { removedDirs.add(d); d = dirname(d); }
114
+ } else if (!dryRun) {
115
+ delete manifest.files[file];
116
+ }
71
117
  }
72
118
 
73
- const targetDir = resolve(path);
74
- const manifest = await readManifest(targetDir);
119
+ for (const agent of removeSet) {
120
+ const r = emitRules(agent, '');
121
+ if (r && r.mode === 'merge') {
122
+ if (dryRun) log.del(`${r.path} (specpipe rules section — would strip)`);
123
+ else if (await stripRulesSection(targetDir, r.path)) log.del(`${r.path} (specpipe rules section)`);
124
+ }
125
+ if (agentHasHooks(agent)) {
126
+ if (dryRun) log.del(`${AGENTS[agent].label} enforced hooks (would remove)`);
127
+ else await removeAgentHooks(agent, targetDir);
128
+ }
129
+ }
75
130
 
76
- if (!manifest) {
77
- log.fail('No manifest found. Nothing to remove.');
78
- process.exit(1);
131
+ if (!dryRun) {
132
+ manifest.agents = remaining;
133
+ await writeManifest(targetDir, manifest);
134
+ for (const dir of [...removedDirs].sort((a, b) => b.split('/').length - a.split('/').length)) {
135
+ try { await rmdir(join(targetDir, dir)); } catch { /* not empty or missing */ }
136
+ }
79
137
  }
80
138
 
81
- log.info('Removing specpipe files...');
139
+ log.blank();
140
+ log.pass(dryRun ? `Dry run — would remove ${removedLabels}, keep ${keptLabels}.` : `Removed ${removedLabels}. Kept ${keptLabels}.`);
141
+ }
142
+
143
+ /** Remove everything specpipe installed in this project (all agents). */
144
+ async function removeAll(targetDir, manifest, dryRun) {
145
+ log.info(dryRun ? 'Remove — dry run (no changes):' : 'Removing specpipe files...');
82
146
  log.blank();
83
147
 
84
148
  const removedDirs = new Set();
@@ -91,9 +155,8 @@ export async function removeCommand(path, opts = {}) {
91
155
  }
92
156
  const fullPath = join(targetDir, file);
93
157
  if (existsSync(fullPath)) {
94
- await unlink(fullPath);
95
- log.del(file);
96
- // Track ancestor dirs (within the project) for empty-dir cleanup.
158
+ if (dryRun) { log.del(`${file} (would remove)`); }
159
+ else { await unlink(fullPath); log.del(file); }
97
160
  let d = dirname(file);
98
161
  while (d && d !== '.' && d !== '/') { removedDirs.add(d); d = dirname(d); }
99
162
  }
@@ -103,31 +166,78 @@ export async function removeCommand(path, opts = {}) {
103
166
  for (const rel of [MANIFEST_FILE, LEGACY_MANIFEST_FILE]) {
104
167
  const p = join(targetDir, rel);
105
168
  if (existsSync(p)) {
106
- await unlink(p);
107
- log.del(rel);
169
+ if (dryRun) { log.del(`${rel} (would remove)`); }
170
+ else { await unlink(p); log.del(rel); }
108
171
  let d = dirname(rel);
109
172
  while (d && d !== '.' && d !== '/') { removedDirs.add(d); d = dirname(d); }
110
173
  }
111
174
  }
112
175
 
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)');
176
+ // Rules live as a marked section in shared CLAUDE.md / AGENTS.md — strip just our
177
+ // section, preserving the rest of the user's file (don't delete the whole file).
178
+ for (const f of ['.claude/CLAUDE.md', 'AGENTS.md']) {
179
+ if (dryRun) { if (existsSync(join(targetDir, f))) log.del(`${f} (specpipe rules section — would strip)`); }
180
+ else if (await stripRulesSection(targetDir, f)) log.del(`${f} (specpipe rules section)`);
116
181
  }
117
182
 
118
- // Enforced hooks (Codex/Cursor) live outside the tracked file set — clean per agent.
183
+ // Enforced hooks (Codex/Cursor/Antigravity) live outside the tracked file set — clean per agent.
119
184
  for (const agent of getAgents(manifest)) {
120
- if (agentHasHooks(agent)) await removeAgentHooks(agent, targetDir);
185
+ if (agentHasHooks(agent) && !dryRun) await removeAgentHooks(agent, targetDir);
121
186
  }
122
187
 
123
188
  // Legacy: older installs placed build-test.sh under scripts/.
124
189
  removedDirs.add('scripts');
125
190
 
126
191
  // 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 */ }
192
+ if (!dryRun) {
193
+ for (const dir of [...removedDirs].sort((a, b) => b.split('/').length - a.split('/').length)) {
194
+ try { await rmdir(join(targetDir, dir)); } catch { /* not empty or missing */ }
195
+ }
129
196
  }
130
197
 
131
198
  log.blank();
132
- log.pass('Removed. CLAUDE.md and docs/ preserved.');
199
+ log.pass(dryRun ? 'Dry run — nothing changed. CLAUDE.md and docs/ would be preserved.' : 'Removed. CLAUDE.md and docs/ preserved.');
200
+ }
201
+
202
+ export async function removeCommand(path, opts = {}) {
203
+ const dryRun = !!opts.dryRun;
204
+
205
+ if (opts.global) {
206
+ await removeGlobal({ dryRun });
207
+ return;
208
+ }
209
+
210
+ const targetDir = resolve(path);
211
+ const manifest = await readManifest(targetDir);
212
+
213
+ if (!manifest) {
214
+ log.fail('No manifest found. Nothing to remove.');
215
+ process.exit(1);
216
+ }
217
+
218
+ const installedAgents = getAgents(manifest);
219
+
220
+ // Selective removal: drop only the named agents, keep the rest.
221
+ if (opts.agents) {
222
+ let requested;
223
+ try { requested = resolveAgents(opts.agents); }
224
+ catch (e) { log.fail(e.message); process.exit(1); }
225
+
226
+ const removeSet = requested.filter((a) => installedAgents.includes(a));
227
+ for (const a of requested.filter((a) => !installedAgents.includes(a))) {
228
+ log.warn(`${AGENTS[a]?.label || a} is not installed here — skipping.`);
229
+ }
230
+ if (removeSet.length === 0) {
231
+ log.fail('None of the requested agents are installed here. Nothing to remove.');
232
+ process.exit(1);
233
+ }
234
+ const remaining = installedAgents.filter((a) => !removeSet.includes(a));
235
+ if (remaining.length > 0) {
236
+ await removeAgentsPartial(targetDir, manifest, removeSet, remaining, dryRun);
237
+ return;
238
+ }
239
+ // Removing every installed agent — fall through to a full teardown.
240
+ }
241
+
242
+ await removeAll(targetDir, manifest, dryRun);
133
243
  }
@@ -6,9 +6,10 @@ import { fileURLToPath } from 'node:url';
6
6
  import { homedir } from 'node:os';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { readManifest, writeManifest, setFileEntry, refreshCustomizationStatus, getAgents } from '../lib/manifest.js';
9
- import { setPermissions, COMPONENTS, installSkillGlobal, getGlobalSkillsDir, installHookGlobal, getGlobalHooksDir, mergeGlobalSettings, installAgentRules, installAgentHooks } from '../lib/installer.js';
9
+ import { setPermissions, installAgentRules, installAgentHooks } from '../lib/installer.js';
10
10
  import { agentRulesMode, agentHasHooks } from '../lib/agents.js';
11
11
  import { computeDesired } from '../lib/reconcile.js';
12
+ import { initGlobal } from './init-global.js';
12
13
  import { unlink } from 'node:fs/promises';
13
14
 
14
15
  const GLOBAL_MANIFEST = join(homedir(), '.claude', '.devkit-manifest.json');
@@ -16,60 +17,21 @@ const GLOBAL_MANIFEST = join(homedir(), '.claude', '.devkit-manifest.json');
16
17
  async function readGlobalManifest() {
17
18
  try { return JSON.parse(await readFile(GLOBAL_MANIFEST, 'utf-8')); } catch { return null; }
18
19
  }
19
- async function writeGlobalManifest(data) {
20
- await mkdir(join(homedir(), '.claude'), { recursive: true });
21
- await writeFile(GLOBAL_MANIFEST, JSON.stringify(data, null, 2) + '\n');
22
- }
23
20
 
24
21
  export async function upgradeGlobal({ force = false } = {}) {
25
- const globalSkillsDir = getGlobalSkillsDir();
26
- await mkdir(globalSkillsDir, { recursive: true });
27
-
28
22
  const meta = await readGlobalManifest() || {};
29
- const globalFiles = meta.files || {};
30
- const updatedFiles = { ...globalFiles };
31
-
32
- log.blank();
33
- console.log('--- Upgrading global skills ---');
34
- let updated = 0; let skipped = 0; let identical = 0;
35
-
36
- for (const relPath of COMPONENTS.skills) {
37
- const { result, kitHash } = await installSkillGlobal(relPath, globalSkillsDir, { force, globalFiles });
38
- if (result === 'copied') updated++;
39
- else if (result === 'identical') identical++;
40
- else skipped++;
41
- if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
42
- }
43
-
44
- let skillParts = [`${updated} updated`, `${identical} unchanged`];
45
- if (skipped > 0) skillParts.push(`${skipped} customized (use --force to overwrite)`);
46
- log.pass(`Global skills: ${skillParts.join(', ')}`);
47
-
48
- // Upgrade hooks if previously installed globally
49
- if (meta.globalHooksInstalled) {
50
- const globalHooksDir = getGlobalHooksDir();
51
- await mkdir(globalHooksDir, { recursive: true });
52
-
53
- log.blank();
54
- console.log('--- Upgrading global hooks ---');
55
- let hUpdated = 0; let hSkipped = 0; let hIdentical = 0;
56
-
57
- for (const relPath of COMPONENTS.hooks) {
58
- const { result, kitHash } = await installHookGlobal(relPath, globalHooksDir, { force, globalFiles });
59
- if (result === 'copied') hUpdated++;
60
- else if (result === 'identical') hIdentical++;
61
- else hSkipped++;
62
- if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
63
- }
64
-
65
- await mergeGlobalSettings(globalHooksDir);
66
-
67
- let hookParts = [`${hUpdated} updated`, `${hIdentical} unchanged`];
68
- if (hSkipped > 0) hookParts.push(`${hSkipped} customized (use --force to overwrite)`);
69
- log.pass(`Global hooks: ${hookParts.join(', ')}`);
70
- }
71
-
72
- await writeGlobalManifest({ ...meta, globalInstalled: true, files: updatedFiles, updatedAt: new Date().toISOString() });
23
+ const agents = meta.globalAgents || (meta.globalInstalled ? ['claude'] : ['claude']);
24
+
25
+ // initGlobal is idempotent + customization-aware, so it doubles as the upgrade
26
+ // path. It refreshes every agent installed globally (plus Claude hooks if any) and
27
+ // rewrites the manifest.
28
+ await initGlobal({
29
+ agents,
30
+ skills: meta.skills ? new Set(meta.skills) : null,
31
+ hookSelection: meta.hooks ? new Set(meta.hooks) : null,
32
+ force,
33
+ hooks: meta.globalHooksInstalled || false,
34
+ });
73
35
 
74
36
  // Warn about per-project skills that shadow global
75
37
  const projects = meta.projects || [];
@@ -112,9 +74,11 @@ export async function upgradeCommand(path, opts) {
112
74
  log.blank();
113
75
  }
114
76
 
115
- // Desired installed state for every agent this project targets.
77
+ // Desired installed state for every agent this project targets, honoring the
78
+ // skill selection recorded at install time (so upgrade doesn't resurrect skills
79
+ // the user deselected).
116
80
  const agents = getAgents(manifest);
117
- const desired = await computeDesired(agents);
81
+ const desired = await computeDesired(agents, manifest.skills ? new Set(manifest.skills) : null);
118
82
 
119
83
  let updated = 0;
120
84
  let skippedCustomized = 0;
@@ -183,9 +147,10 @@ export async function upgradeCommand(path, opts) {
183
147
  // merged (not reconciled via computeDesired), so re-merge it here to pick up kit
184
148
  // changes. Owned rule files were already handled by the reconcile loop above.
185
149
  if (!opts.dryRun) {
150
+ const hooksSet = manifest.hooks ? new Set(manifest.hooks) : null;
186
151
  for (const agent of agents) {
187
- if (agentRulesMode(agent) === 'agents-md') await installAgentRules(agent, targetDir, { force: opts.force });
188
- if (agentHasHooks(agent)) await installAgentHooks(agent, targetDir, { force: opts.force });
152
+ if (agentRulesMode(agent) === 'merge') await installAgentRules(agent, targetDir, { force: opts.force });
153
+ if (agentHasHooks(agent)) await installAgentHooks(agent, targetDir, { force: opts.force, hooks: hooksSet });
189
154
  }
190
155
  }
191
156
 
@@ -1,100 +1,56 @@
1
- // Guardrails (advisory rules) + enforced (blocking) hooks per agent.
2
- // Claude enforces guards via its native .claude/hooks; every other agent gets the
3
- // same intent as an always-on RULE. Codex/Cursor additionally support blocking
4
- // hooks (verified payloads), so they also get enforced guard scripts.
1
+ // Operating rules (the single rich source kit/rules/specpipe-rules.md) emitted per
2
+ // agent into its project-config file. Claude → CLAUDE.md and Codex AGENTS.md get a
3
+ // marked section merged into the (possibly pre-existing) shared file; the others get
4
+ // an owned rules file. Enforced (blocking) hooks are separate (hooks.js).
5
5
 
6
6
  const RULES = {
7
+ // Claude reads .claude/CLAUDE.md — merge our section in, don't clobber the user's file.
8
+ claude: { mode: 'merge', path: '.claude/CLAUDE.md' },
7
9
  cursor: {
8
10
  mode: 'file',
9
- path: '.cursor/rules/specpipe-guards.mdc',
11
+ path: '.cursor/rules/specpipe-rules.mdc',
10
12
  frontmatter: 'description: specpipe operating rules — spec-first cycle, guardrails, testing, conventions\nglobs:\nalwaysApply: true',
11
13
  },
12
- // Antigravity rules are plain markdown (no documented trigger/glob frontmatter);
13
- // official Google DevRel uses the singular `.agent/rules/` path.
14
- antigravity: { mode: 'doc', path: '.agent/rules/specpipe-guards.md' },
15
- codex: { mode: 'agents-md', path: 'AGENTS.md' },
16
- openclaw: { mode: 'doc', path: 'SPECPIPE-GUARDS.md' },
17
- hermes: { mode: 'doc', path: 'SPECPIPE-GUARDS.md' },
14
+ // Antigravity rules are plain markdown (no documented trigger/glob frontmatter).
15
+ // Antigravity moved its default workspace-rules dir to `.agents/rules/` (plural) as of
16
+ // v1.19.5 the team rep confirmed `.agents` is the path "going forward"; `.agent/rules`
17
+ // (singular) is only a backward-compat fallback. Use the plural default, which also lines
18
+ // up with Antigravity's `.agents/skills/` + `.agents/hooks.json`.
19
+ // Source: discuss.ai.google.dev/t/new-folder-for-rules/126165
20
+ antigravity: { mode: 'doc', path: '.agents/rules/specpipe-rules.md' },
21
+ codex: { mode: 'merge', path: 'AGENTS.md' },
22
+ openclaw: { mode: 'doc', path: 'SPECPIPE-RULES.md' },
23
+ hermes: { mode: 'doc', path: 'SPECPIPE-RULES.md' },
18
24
  };
19
25
 
20
- export const GUARDS_BEGIN = '<!-- specpipe:guards:begin -->';
21
- export const GUARDS_END = '<!-- specpipe:guards:end -->';
26
+ export const RULES_BEGIN = '<!-- specpipe:rules:begin -->';
27
+ export const RULES_END = '<!-- specpipe:rules:end -->';
22
28
 
23
- /** How an agent carries guardrails: 'file' | 'doc' | 'agents-md' | null (native hooks). */
29
+ /** How an agent carries its rules: 'merge' (marked section in a shared file) | 'file' | 'doc' | null. */
24
30
  export function agentRulesMode(agentId) {
25
31
  return RULES[agentId]?.mode || null;
26
32
  }
27
33
 
28
34
  /**
29
- * Emit the guardrails artifact for an agent from the canonical guards body.
30
- * @returns {{ mode, path, content } | null} null for Claude (native hooks).
35
+ * Emit an agent's rules artifact from the canonical rules body. 'merge' agents
36
+ * (Claude CLAUDE.md, Codex AGENTS.md) get a marked section to merge into a shared
37
+ * file; the rest get an owned file (Cursor .mdc, Antigravity/OpenClaw/Hermes doc).
38
+ * @returns {{ mode, path, content } | null}
31
39
  */
32
40
  export function emitRules(agentId, body) {
33
41
  const r = RULES[agentId];
34
42
  if (!r) return null;
35
43
  if (r.mode === 'file') return { mode: 'file', path: r.path, content: `---\n${r.frontmatter}\n---\n${body}` };
36
44
  if (r.mode === 'doc') return { mode: 'doc', path: r.path, content: `# specpipe — operating rules\n\n${body}` };
37
- // agents-md: a marked section merged into a shared AGENTS.md
38
- return { mode: 'agents-md', path: r.path, content: `${GUARDS_BEGIN}\n## specpipeoperating rules\n\n${body}${GUARDS_END}\n` };
45
+ // merge: a marked section merged into a shared CLAUDE.md / AGENTS.md. Force a
46
+ // newline before the END marker so it always sits on its own line otherwise a
47
+ // rules source that doesn't end in \n would glue the body to the marker and break
48
+ // stripRulesSection's line-based match.
49
+ return { mode: 'merge', path: r.path, content: `${RULES_BEGIN}\n## specpipe — operating rules\n\n${body.replace(/\n*$/, '\n')}${RULES_END}\n` };
39
50
  }
40
51
 
41
- // ── Enforced hooks (block tool calls, not just advise) ──────────────────────
42
- // Agents whose hook payloads + block primitive (exit 2) are verified compatible
43
- // with the shared guard scripts (kit/hooks/specpipe-*.sh). Claude has its own
44
- // .claude/hooks; Antigravity/Hermes lack a usable blocking-hook surface omitted.
45
- const SHELL_GUARD = 'specpipe-shell-guard.sh';
46
- const READ_GUARD = 'specpipe-read-guard.sh';
47
-
48
- const EHOOKS = {
49
- // Codex PreToolUse payload == Claude's (.tool_input.command); exit 2 blocks.
50
- // Verified matcher: "Bash". Read/Edit tool names unverified → shell guard only.
51
- codex: {
52
- dir: '.codex/hooks',
53
- scripts: [SHELL_GUARD],
54
- configPath: '.codex/hooks.json',
55
- config: {
56
- hooks: {
57
- PreToolUse: [
58
- { matcher: 'Bash', hooks: [{ type: 'command', command: `bash .codex/hooks/${SHELL_GUARD}` }] },
59
- ],
60
- },
61
- },
62
- },
63
- // Cursor: beforeShellExecution (.command) + beforeReadFile (.file_path) verified;
64
- // fail-open by default, so failClosed: true to actually enforce.
65
- cursor: {
66
- dir: '.cursor/hooks',
67
- scripts: [SHELL_GUARD, READ_GUARD],
68
- configPath: '.cursor/hooks.json',
69
- config: {
70
- version: 1,
71
- hooks: {
72
- beforeShellExecution: [{ command: `./.cursor/hooks/${SHELL_GUARD}`, failClosed: true }],
73
- beforeReadFile: [{ command: `./.cursor/hooks/${READ_GUARD}`, failClosed: true }],
74
- },
75
- },
76
- },
77
- };
78
-
79
- /** Kit-relative source path for a guard script. */
80
- export const HOOKS_SRC_DIR = 'hooks';
81
-
82
- /** Whether an agent gets enforced (blocking) hooks beyond advisory rules. */
83
- export function agentHasHooks(agentId) {
84
- return !!EHOOKS[agentId];
85
- }
86
-
87
- /**
88
- * Emit an agent's enforced-hook artifacts (Codex/Cursor). Returns the scripts to
89
- * copy (kit-relative src + on-disk dst) and the hook config file to write, or null.
90
- */
91
- export function emitHooks(agentId) {
92
- const h = EHOOKS[agentId];
93
- if (!h) return null;
94
- return {
95
- hooksDir: h.dir,
96
- scripts: h.scripts.map((name) => ({ src: `${HOOKS_SRC_DIR}/${name}`, dst: `${h.dir}/${name}` })),
97
- configPath: h.configPath,
98
- configContent: JSON.stringify(h.config, null, 2) + '\n',
99
- };
100
- }
52
+ // ── Enforced hooks ──────────────────────────────────────────────────────────
53
+ // The hook registry (which agents block which tool calls + each agent's verified
54
+ // config shape, including Claude and Antigravity) lives in hooks.js. Re-exported
55
+ // here so callers keep importing from agents.js. HOOKS_SRC_DIR stays as an alias.
56
+ export { emitHooks, agentHasHooks, HOOKS_DIR as HOOKS_SRC_DIR } from './hooks.js';