specpipe 1.0.1 → 1.0.3

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 +48 -20
  6. package/src/commands/init-global.js +104 -33
  7. package/src/commands/init-interactive.js +71 -0
  8. package/src/commands/init.js +68 -20
  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 +55 -77
  15. package/src/lib/hooks.js +203 -0
  16. package/src/lib/installer.js +104 -62
  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
@@ -0,0 +1,203 @@
1
+ // Hook registry — the single source of truth for guard hooks and how each agent
2
+ // declares them. Scripts live in kit/hooks/ and are emitted into each agent's hook
3
+ // dir; each agent's native config file is generated from this map. Formats verified
4
+ // 2026-06-28 against each agent's docs (see docs/multi-agent.md § Sources).
5
+ //
6
+ // Enforced (blocking) agents and their config shapes differ:
7
+ // claude .claude/settings.json {hooks:{Event:[{matcher,hooks:[{type,command}]}]}}
8
+ // codex .codex/hooks.json (same nested shape) matcher "Bash"
9
+ // cursor .cursor/hooks.json {version:1,hooks:{beforeX:[{command,failClosed}]}}
10
+ // antigravity .agents/hooks.json {enabled:true,Event:[{matcher,command,timeout}]} matcher "run_command"
11
+ //
12
+ // Command payloads the guard scripts read (multi-payload in specpipe-shell-guard.sh):
13
+ // .tool_input.command (Claude/Codex) · .command (Cursor) · .tool_args.CommandLine (Antigravity)
14
+
15
+ export const HOOKS_DIR = 'hooks'; // kit-relative source dir for every script
16
+
17
+ // Each guard hook: its script + which agents wire it, and where.
18
+ // shell/read run on bash; the JS guards are Claude-only (no equivalent event elsewhere).
19
+ export const HOOKS = {
20
+ 'shell-guard': {
21
+ script: 'specpipe-shell-guard.sh', run: 'bash',
22
+ desc: 'block wasteful-dir exploration + secret access in shell commands',
23
+ wiring: {
24
+ claude: { event: 'PreToolUse', matcher: 'Bash', env: { SECRET_POLICY: 'warn' } },
25
+ codex: { event: 'PreToolUse', matcher: 'Bash' },
26
+ cursor: { event: 'beforeShellExecution' },
27
+ antigravity: { event: 'PreToolUse', matcher: 'run_command' },
28
+ },
29
+ },
30
+ 'read-guard': {
31
+ script: 'specpipe-read-guard.sh', run: 'bash',
32
+ desc: 'block reads of secret files',
33
+ wiring: {
34
+ claude: { event: 'PreToolUse', matcher: 'Read|Write|Edit|MultiEdit|Grep' },
35
+ cursor: { event: 'beforeReadFile' },
36
+ },
37
+ },
38
+ 'comment-guard': {
39
+ script: 'comment-guard.js', run: 'node',
40
+ desc: 'block placeholder-comment replacements',
41
+ wiring: { claude: { event: 'PreToolUse', matcher: 'Edit|MultiEdit' } },
42
+ },
43
+ 'glob-guard': {
44
+ script: 'glob-guard.js', run: 'node',
45
+ desc: 'block overly broad globs',
46
+ wiring: { claude: { event: 'PreToolUse', matcher: 'Glob' } },
47
+ },
48
+ 'file-guard': {
49
+ script: 'file-guard.js', run: 'node',
50
+ desc: 'warn on large source files',
51
+ wiring: {
52
+ claude: { event: 'PostToolUse', matcher: 'Write|Edit|MultiEdit' },
53
+ // Cursor: generic postToolUse fires for every tool (no matcher) — the guard
54
+ // self-filters to writes by tool_name and injects its warning via
55
+ // `additional_context`. Advisory (never blocks), so no failClosed. Verified
56
+ // live on Cursor 2026.06: postToolUse payload carries tool_name + tool_input.file_path.
57
+ cursor: { event: 'postToolUse', advisory: true },
58
+ },
59
+ },
60
+ };
61
+
62
+ export const HOOK_IDS = Object.keys(HOOKS);
63
+ export const ALL_HOOK_NAMES = HOOK_IDS;
64
+
65
+ /** Agents with a droppable, verified blocking-hook config. */
66
+ export const HOOK_AGENTS = ['claude', 'codex', 'cursor', 'antigravity'];
67
+
68
+ /**
69
+ * Resolve a `--hooks` value into a Set of selected hook ids, or null = all.
70
+ * 'none' → empty Set (option A: install no guard hooks). Accepts ids with or
71
+ * without the `-guard` suffix (e.g. `shell` or `shell-guard`).
72
+ */
73
+ export function resolveHooks(spec) {
74
+ if (spec === undefined || spec === null || spec === 'all') return null;
75
+ if (spec === 'none') return new Set();
76
+ const norm = (n) => (HOOKS[n] ? n : (HOOKS[`${n}-guard`] ? `${n}-guard` : n));
77
+ const names = spec.split(',').map((s) => s.trim()).filter(Boolean).map(norm);
78
+ const unknown = names.filter((n) => !HOOKS[n]);
79
+ if (unknown.length) {
80
+ throw new Error(`Unknown hook(s): ${unknown.join(', ')}. Valid: ${HOOK_IDS.join(', ')}, all, none`);
81
+ }
82
+ return new Set(names);
83
+ }
84
+
85
+ /** Whether a hook id is selected (null = all). */
86
+ export function hookSelected(id, hooksSet) {
87
+ return !hooksSet || hooksSet.has(id);
88
+ }
89
+
90
+ /** The hook ids an agent wires, filtered by selection. */
91
+ function wiredHookIds(agentId, hooksSet) {
92
+ return HOOK_IDS.filter((id) => HOOKS[id].wiring[agentId] && hookSelected(id, hooksSet));
93
+ }
94
+
95
+ /** Scripts an agent installs (kit-relative src + on-disk dst), filtered by selection. */
96
+ export function hookScriptsFor(agentId, hooksDir, hooksSet = null) {
97
+ return wiredHookIds(agentId, hooksSet).map((id) => ({
98
+ src: `${HOOKS_DIR}/${HOOKS[id].script}`,
99
+ dst: `${hooksDir}/${HOOKS[id].script}`,
100
+ run: HOOKS[id].run,
101
+ }));
102
+ }
103
+
104
+ // Build the command string an agent's config runs for a hook.
105
+ // `ref` is how the agent references the script path (e.g. "$CLAUDE_PROJECT_DIR"/.claude/hooks).
106
+ function commandFor(id, agentId, ref) {
107
+ const h = HOOKS[id];
108
+ const env = h.wiring[agentId].env
109
+ ? Object.entries(h.wiring[agentId].env).map(([k, v]) => `${k}=${v} `).join('')
110
+ : '';
111
+ return `${env}${h.run} ${ref}/${h.script}`;
112
+ }
113
+
114
+ // Where each agent's scripts + config live, and how its config references the
115
+ // scripts. `ref` is per-project; the global Claude install passes an absolute ref.
116
+ export const HOOK_TARGETS = {
117
+ claude: { dir: '.claude/hooks', configPath: '.claude/settings.json', ref: '"$CLAUDE_PROJECT_DIR"/.claude/hooks' },
118
+ codex: { dir: '.codex/hooks', configPath: '.codex/hooks.json', ref: '.codex/hooks' },
119
+ cursor: { dir: '.cursor/hooks', configPath: '.cursor/hooks.json', ref: '.cursor/hooks' },
120
+ // Antigravity runs hook commands with cwd = <project>/.agents (verified live on 1.0.13),
121
+ // so the command path is relative to .agents → `hooks/<script>`, NOT `.agents/hooks/<script>`.
122
+ antigravity: { dir: '.agents/hooks', configPath: '.agents/hooks.json', ref: 'hooks' },
123
+ };
124
+
125
+ /**
126
+ * Generate an agent's native hook-config object from the registry, honoring the
127
+ * hook selection. `ref` is the path prefix the agent uses to reach the installed
128
+ * scripts. Returns null when the agent wires no hooks (or selection is empty).
129
+ * Shapes are per-agent and verified; see the table at the top of the file.
130
+ */
131
+ export function buildHookConfig(agentId, ref, hooksSet = null) {
132
+ const ids = wiredHookIds(agentId, hooksSet);
133
+ if (!ids.length) return null;
134
+
135
+ if (agentId === 'claude' || agentId === 'codex') {
136
+ // { hooks: { Event: [ { matcher, hooks: [ { type:'command', command } ] } ] } }
137
+ const events = {};
138
+ for (const id of ids) {
139
+ const w = HOOKS[id].wiring[agentId];
140
+ (events[w.event] ??= []).push({
141
+ matcher: w.matcher,
142
+ hooks: [{ type: 'command', command: commandFor(id, agentId, ref) }],
143
+ });
144
+ }
145
+ return { hooks: events };
146
+ }
147
+
148
+ if (agentId === 'cursor') {
149
+ // { version:1, hooks: { beforeShellExecution:[{command,failClosed}], postToolUse:[{command}] } }
150
+ // Blocking before-hooks use failClosed (deny if the hook errors); advisory post-hooks
151
+ // (file-guard, which only injects a warning) must NOT — failClosed there would block writes.
152
+ const events = {};
153
+ for (const id of ids) {
154
+ const w = HOOKS[id].wiring.cursor;
155
+ const entry = { command: `${ref}/${HOOKS[id].script}` };
156
+ if (!w.advisory) entry.failClosed = true;
157
+ (events[w.event] ??= []).push(entry);
158
+ }
159
+ return { version: 1, hooks: events };
160
+ }
161
+
162
+ if (agentId === 'antigravity') {
163
+ // { "<hook-name>": { Event: [ { matcher, hooks: [ { type:'command', command, timeout } ] } ] } }
164
+ // Top level is a MAP of hook-NAME → spec; events nest inside, each an ARRAY of matcher
165
+ // groups with a nested `hooks` array. NO top-level `enabled` (a bool there makes
166
+ // Antigravity's Go parser reject the whole file → no hooks load). Verified live against
167
+ // Antigravity CLI 1.0.13 (jsonhook schema).
168
+ const events = {};
169
+ for (const id of ids) {
170
+ const w = HOOKS[id].wiring.antigravity;
171
+ (events[w.event] ??= []).push({
172
+ matcher: w.matcher,
173
+ hooks: [{ type: 'command', command: commandFor(id, agentId, ref), timeout: 15 }],
174
+ });
175
+ }
176
+ return { 'specpipe-guards': events };
177
+ }
178
+
179
+ return null;
180
+ }
181
+
182
+ /** Whether an agent has a droppable, verified enforced-hook config. */
183
+ export function agentHasHooks(agentId) {
184
+ return !!HOOK_TARGETS[agentId];
185
+ }
186
+
187
+ /**
188
+ * Emit an agent's enforced-hook artifacts: the scripts to copy (kit-relative src +
189
+ * on-disk dst) and the generated config file. Honors the hook selection (null=all,
190
+ * empty Set=none). Returns null when the agent wires no hooks for this selection.
191
+ */
192
+ export function emitHooks(agentId, hooksSet = null) {
193
+ const t = HOOK_TARGETS[agentId];
194
+ if (!t) return null;
195
+ const cfg = buildHookConfig(agentId, t.ref, hooksSet);
196
+ if (!cfg) return null;
197
+ return {
198
+ hooksDir: t.dir,
199
+ scripts: hookScriptsFor(agentId, t.dir, hooksSet),
200
+ configPath: t.configPath,
201
+ configContent: JSON.stringify(cfg, null, 2) + '\n',
202
+ };
203
+ }
@@ -1,4 +1,4 @@
1
- import { copyFile as fsCopyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { copyFile as fsCopyFile, mkdir, readFile, writeFile, unlink, rmdir } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join, dirname, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
@@ -11,14 +11,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  * Component → file mappings.
12
12
  */
13
13
  export const COMPONENTS = {
14
- hooks: [
15
- '.claude/hooks/file-guard.js',
16
- '.claude/hooks/path-guard.sh',
17
- '.claude/hooks/comment-guard.js',
18
- '.claude/hooks/glob-guard.js',
19
- '.claude/hooks/self-review.sh',
20
- '.claude/hooks/sensitive-guard.sh',
21
- ],
14
+ // Hooks + settings.json are no longer static files — they're emitted per agent
15
+ // from the hook registry (hooks.js) via installAgentHooks. Kept as an (empty)
16
+ // component so `--only hooks` still resolves; init routes it to the emitter.
17
+ hooks: [],
22
18
  skills: [
23
19
  'skills/sp-explore/SKILL.md',
24
20
  'skills/sp-scaffold/SKILL.md',
@@ -44,31 +40,54 @@ export const COMPONENTS = {
44
40
  'skills/sp-md-render/components.md',
45
41
  'skills/sp-humanize/SKILL.md',
46
42
  ],
47
- config: [
48
- '.claude/settings.json',
49
- '.claude/CLAUDE.md',
50
- ],
51
- docs: [
52
- 'docs/WORKFLOW.md',
53
- ],
43
+ // CLAUDE.md is no longer a static file — it's emitted from the single rules source
44
+ // (kit/rules/specpipe-rules.md) as a marked section, like every other agent's rules.
45
+ config: [],
46
+ // docs/WORKFLOW.md was dropped — its content is covered by the skills (detailed) and
47
+ // the rules hub's workflow table. The user's docs/ holds only their own specs.
48
+ docs: [],
54
49
  };
55
50
 
51
+ // ── Skill selection ─────────────────────────────────────────────────────────
52
+ // Skills installed by default but safe to drop — standalone, not part of the
53
+ // spec→build→review pipeline. Tagged "(optional)" in the interactive picker.
54
+ export const OPTIONAL_SKILLS = ['sp-spec-render', 'sp-md-render', 'sp-humanize'];
55
+
56
+ /** Every skill name (sp-*), derived from the skill component list. */
57
+ export const ALL_SKILL_NAMES = [...new Set(COMPONENTS.skills.map((p) => p.split('/')[1]))];
58
+
56
59
  /**
57
- * Placeholder directories to create.
60
+ * Resolve a `--skills` value into a Set of selected skill names, or null = all.
61
+ * Accepts 'all', 'core' (all minus OPTIONAL_SKILLS), or a comma list of names
62
+ * (with or without the `sp-` prefix). Throws on an unknown name.
58
63
  */
59
- export const PLACEHOLDER_DIRS = [
60
- 'docs/specs',
61
- 'docs/test-plans',
62
- ];
64
+ export function resolveSkills(spec) {
65
+ if (!spec || spec === 'all') return null;
66
+ if (spec === 'core') return new Set(ALL_SKILL_NAMES.filter((n) => !OPTIONAL_SKILLS.includes(n)));
67
+ const names = spec.split(',').map((s) => s.trim()).filter(Boolean)
68
+ .map((n) => (n.startsWith('sp-') ? n : `sp-${n}`));
69
+ const unknown = names.filter((n) => !ALL_SKILL_NAMES.includes(n));
70
+ if (unknown.length) {
71
+ throw new Error(`Unknown skill(s): ${unknown.join(', ')}. Valid: ${ALL_SKILL_NAMES.join(', ')}, all, core`);
72
+ }
73
+ return new Set(names);
74
+ }
63
75
 
64
76
  /**
65
- * Files that need +x permission.
77
+ * Whether a template file path is allowed under a skill selection (null = all).
78
+ * Non-skill files (hooks, config, docs) always pass; skill files pass only when
79
+ * their skill name is in the set.
66
80
  */
67
- export const EXECUTABLE_FILES = [
68
- '.claude/hooks/path-guard.sh',
69
- '.claude/hooks/self-review.sh',
70
- '.claude/hooks/sensitive-guard.sh',
71
- ];
81
+ export function skillAllowed(filePath, skillsSet) {
82
+ if (!skillsSet) return true;
83
+ const m = filePath.replace(/\\/g, '/').match(/^skills\/([^/]+)\//);
84
+ return !m || skillsSet.has(m[1]);
85
+ }
86
+
87
+
88
+ // Files needing +x. Empty: guard scripts get +x at emit time (installAgentHooks
89
+ // chmods them); none are installed as plain COMPONENTS files anymore.
90
+ export const EXECUTABLE_FILES = [];
72
91
 
73
92
  /**
74
93
  * Get path to kit (templates) directory.
@@ -131,28 +150,44 @@ export async function installFile(relativePath, targetDir, { force = false } = {
131
150
  return 'copied';
132
151
  }
133
152
 
153
+ /**
154
+ * Migration prune: delete files a PRIOR manifest tracked that the new install no
155
+ * longer wants — e.g. the predecessor `mf-*` (claude-devkit) / `ap-*` (agentpipe)
156
+ * skills, or renamed/removed hooks. Safe because it only touches paths the kit
157
+ * itself recorded as installed; a user's own files (e.g. a personal `mf-commit`
158
+ * skill that was never in our manifest) are never in `priorFiles`, so untouched.
159
+ * Skips preserved paths and the user's docs/. Cleans up emptied dirs.
160
+ * @returns {Promise<number>} count pruned
161
+ */
162
+ export async function pruneOrphans(targetDir, priorFiles, keepSet, { preserve = ['.claude/CLAUDE.md'] } = {}) {
163
+ let pruned = 0;
164
+ const dirs = new Set();
165
+ for (const rel of Object.keys(priorFiles || {})) {
166
+ if (keepSet.has(rel) || preserve.includes(rel) || rel.startsWith('docs/')) continue;
167
+ const p = join(targetDir, rel);
168
+ if (!existsSync(p)) continue;
169
+ try {
170
+ await unlink(p);
171
+ log.del(`${rel} (legacy — superseded, removed)`);
172
+ pruned++;
173
+ let d = dirname(rel);
174
+ while (d && d !== '.' && d !== '/') { dirs.add(d); d = dirname(d); }
175
+ } catch { /* ignore */ }
176
+ }
177
+ for (const d of [...dirs].sort((a, b) => b.split('/').length - a.split('/').length)) {
178
+ try { await rmdir(join(targetDir, d)); } catch { /* not empty / missing */ }
179
+ }
180
+ return pruned;
181
+ }
182
+
134
183
  // Per-agent install (emit skills + guardrails) lives in agent-install.js;
135
184
  // re-exported here so callers keep importing from installer.js.
136
185
  export {
137
186
  installSkillForAgent, installAgentSkills, installAgentRules,
138
- mergeAgentsMdGuards, stripAgentsMdGuards,
187
+ mergeRulesSection, stripRulesSection,
139
188
  installAgentHooks, removeAgentHooks,
140
189
  } from './agent-install.js';
141
190
 
142
- /**
143
- * Create a placeholder directory with .gitkeep.
144
- */
145
- export async function ensurePlaceholderDir(dir, targetDir) {
146
- const fullPath = join(targetDir, dir);
147
- if (existsSync(fullPath)) {
148
- log.skip(`${dir}/ (exists)`);
149
- return;
150
- }
151
- await mkdir(fullPath, { recursive: true });
152
- await writeFile(join(fullPath, '.gitkeep'), '');
153
- log.make(`${dir}/`);
154
- }
155
-
156
191
  /**
157
192
  * Set executable permissions on relevant files.
158
193
  */
@@ -167,28 +202,34 @@ export async function setPermissions(targetDir) {
167
202
  }
168
203
  }
169
204
 
205
+ // Every file the rules section can land in (per agent). fillTemplate fills the
206
+ // detected Project Info into whichever ones exist.
207
+ export const RULE_FILES = [
208
+ '.claude/CLAUDE.md',
209
+ 'AGENTS.md',
210
+ '.cursor/rules/specpipe-rules.mdc',
211
+ '.agents/rules/specpipe-rules.md',
212
+ 'SPECPIPE-RULES.md',
213
+ ];
214
+
170
215
  /**
171
- * Fill [CUSTOMIZE] placeholders in CLAUDE.md with detected project info.
216
+ * Fill the `[CUSTOMIZE]` Project Info placeholders in every installed rules file with
217
+ * the detected project info. Rules are emitted per agent (CLAUDE.md, AGENTS.md, …), so
218
+ * fill all of them, not just CLAUDE.md.
172
219
  */
173
220
  export async function fillTemplate(targetDir, projectInfo) {
174
221
  if (!projectInfo) return;
175
-
176
- const claudeMdPath = join(targetDir, '.claude/CLAUDE.md');
177
- try {
178
- let content = await readFile(claudeMdPath, 'utf-8');
179
- content = content
180
- .replace(/\[CUSTOMIZE\] Language:.*/, `**Language:** ${projectInfo.lang}`)
181
- .replace(/\[CUSTOMIZE\] Test framework:.*/, `**Test framework:** ${projectInfo.framework}`)
182
- .replace(/\[CUSTOMIZE\] Source directory:.*/, `**Source directory:** ${projectInfo.srcDir}`)
183
- .replace(/\[CUSTOMIZE\] Test directory:.*/, `**Test directory:** ${projectInfo.testDir}`)
184
- // Also handle the format without [CUSTOMIZE] prefix
185
- .replace(/\*\*Language:\*\* \[CUSTOMIZE\]/, `**Language:** ${projectInfo.lang}`)
186
- .replace(/\*\*Test framework:\*\* \[CUSTOMIZE\]/, `**Test framework:** ${projectInfo.framework}`)
187
- .replace(/\*\*Source directory:\*\* \[CUSTOMIZE\]/, `**Source directory:** ${projectInfo.srcDir}`)
188
- .replace(/\*\*Test directory:\*\* \[CUSTOMIZE\]/, `**Test directory:** ${projectInfo.testDir}`);
189
- await writeFile(claudeMdPath, content);
190
- } catch {
191
- // CLAUDE.md might not exist
222
+ for (const rel of RULE_FILES) {
223
+ const p = join(targetDir, rel);
224
+ try {
225
+ const before = await readFile(p, 'utf-8');
226
+ const after = before
227
+ .replace(/\*\*Language:\*\* \[CUSTOMIZE\]/, `**Language:** ${projectInfo.lang}`)
228
+ .replace(/\*\*Test framework:\*\* \[CUSTOMIZE\]/, `**Test framework:** ${projectInfo.framework}`)
229
+ .replace(/\*\*Source directory:\*\* \[CUSTOMIZE\]/, `**Source directory:** ${projectInfo.srcDir}`)
230
+ .replace(/\*\*Test directory:\*\* \[CUSTOMIZE\]/, `**Test directory:** ${projectInfo.testDir}`);
231
+ if (after !== before) await writeFile(p, after);
232
+ } catch { /* file not installed for this agent — skip */ }
192
233
  }
193
234
  }
194
235
 
@@ -208,6 +249,7 @@ export async function verifySettingsJson(targetDir) {
208
249
  // Claude's global install (~/.claude/skills + hooks + settings.json) lives in
209
250
  // claude-global.js; re-exported here so callers keep importing from installer.js.
210
251
  export {
211
- getGlobalSkillsDir, getGlobalHooksDir, installHookGlobal,
212
- mergeGlobalSettings, removeGlobalHooksFromSettings, installSkillGlobal,
252
+ getGlobalHooksDir, installHookGlobal,
253
+ mergeGlobalSettings, removeGlobalHooksFromSettings,
254
+ installSkillGlobalForAgent,
213
255
  } from './claude-global.js';
@@ -1,10 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { hashContent } from './hasher.js';
4
- import { getAllFiles, COMPONENTS, getTemplateDir } from './installer.js';
5
- import { emitFile, emitRules } from './agents.js';
4
+ import { getAllFiles, COMPONENTS, getTemplateDir, skillAllowed } from './installer.js';
5
+ import { emitFile, emitRules, AGENTS } from './agents.js';
6
6
 
7
- export const GUARDS_TEMPLATE_REL = 'rules/specpipe-guards.md';
7
+ export const RULES_TEMPLATE_REL = 'rules/specpipe-rules.md';
8
8
 
9
9
  /**
10
10
  * Template files a given agent receives. Claude gets the full kit
@@ -13,7 +13,11 @@ export const GUARDS_TEMPLATE_REL = 'rules/specpipe-guards.md';
13
13
  * hooks are Claude-specific.
14
14
  */
15
15
  export function templateFilesForAgent(agentId) {
16
- return agentId === 'claude' ? getAllFiles() : COMPONENTS.skills;
16
+ if (agentId === 'claude') return getAllFiles();
17
+ // Agents that don't read project-local skills (Hermes scans only ~/.hermes/skills/)
18
+ // get no per-project skill files — they'd be dead. Their rules doc is still emitted.
19
+ if (AGENTS[agentId]?.perProjectSkills === false) return [];
20
+ return COMPONENTS.skills;
17
21
  }
18
22
 
19
23
  /**
@@ -22,13 +26,14 @@ export function templateFilesForAgent(agentId) {
22
26
  * @returns {Promise<Map<string, {agent, templateRel, content, kitHash}>>}
23
27
  * keyed by installed (on-disk) relative path.
24
28
  */
25
- export async function computeDesired(agents) {
29
+ export async function computeDesired(agents, skillsSet = null) {
26
30
  const dir = getTemplateDir();
27
31
  const desired = new Map();
28
- const guardsBody = await readFile(join(dir, GUARDS_TEMPLATE_REL), 'utf-8');
32
+ const guardsBody = await readFile(join(dir, RULES_TEMPLATE_REL), 'utf-8');
29
33
 
30
34
  for (const agent of agents) {
31
35
  for (const templateRel of templateFilesForAgent(agent)) {
36
+ if (!skillAllowed(templateRel, skillsSet)) continue;
32
37
  const content = await readFile(join(dir, templateRel), 'utf-8');
33
38
  const emitted = emitFile(agent, templateRel, content);
34
39
  desired.set(emitted.path, {
@@ -43,10 +48,10 @@ export async function computeDesired(agents) {
43
48
  // are reconciled like any other file. Codex's AGENTS.md is shared, not owned
44
49
  // here — it's merged/stripped separately.
45
50
  const rules = emitRules(agent, guardsBody);
46
- if (rules && rules.mode !== 'agents-md') {
51
+ if (rules && rules.mode !== 'merge') {
47
52
  desired.set(rules.path, {
48
53
  agent,
49
- templateRel: GUARDS_TEMPLATE_REL,
54
+ templateRel: RULES_TEMPLATE_REL,
50
55
  content: rules.content,
51
56
  kitHash: hashContent(rules.content),
52
57
  });
@@ -147,10 +147,18 @@ function main() {
147
147
  const filePath = payload.tool_input?.file_path;
148
148
  if (!filePath) process.exit(0);
149
149
 
150
- // Skip files outside the project directory (e.g. ~/.claude/plans/)
151
- const projectDir = process.cwd() + path.sep;
150
+ // Cursor's generic postToolUse fires for EVERY tool (Read, Grep, Shell, …); only act
151
+ // on writes/edits. Claude's PostToolUse matcher already restricts this, so tool_name is
152
+ // either a write-ish name or absent there — both pass.
153
+ const toolName = payload.tool_name;
154
+ if (toolName && !/^(Write|Edit|MultiEdit|write_to_file|replace_file_content)/i.test(toolName)) process.exit(0);
155
+
156
+ // Skip files outside the project directory (e.g. ~/.claude/plans/). Cursor passes the
157
+ // project root in workspace_roots; otherwise fall back to cwd.
158
+ const projectRoot = (Array.isArray(payload.workspace_roots) && payload.workspace_roots[0]) || process.cwd();
159
+ const projectDir = projectRoot + path.sep;
152
160
  const resolvedFile = path.resolve(filePath);
153
- if (!resolvedFile.startsWith(projectDir) && resolvedFile !== process.cwd()) process.exit(0);
161
+ if (!resolvedFile.startsWith(projectDir) && resolvedFile !== projectRoot) process.exit(0);
154
162
 
155
163
  // Skip non-source-code files (docs, configs, templates are naturally long)
156
164
  const ext = path.extname(filePath).toLowerCase();
@@ -173,14 +181,8 @@ function main() {
173
181
  try {
174
182
  const stat = fs.statSync(filePath);
175
183
  if (stat.size > MAX_BYTES) {
176
- const rel = path.relative(process.cwd(), filePath);
177
- process.stdout.write(JSON.stringify({
178
- continue: true,
179
- hookSpecificOutput: {
180
- hookEventName: "PostToolUse",
181
- additionalContext: `Warning: ${rel} is ${Math.round(stat.size / 1024)}KB. Consider splitting into smaller modules.`,
182
- },
183
- }) + "\n");
184
+ const rel = path.relative(projectRoot, filePath);
185
+ emitWarning(`Warning: ${rel} is ${Math.round(stat.size / 1024)}KB. Consider splitting into smaller modules.`, payload);
184
186
  process.exit(0);
185
187
  }
186
188
  const buf = fs.readFileSync(filePath);
@@ -194,18 +196,21 @@ function main() {
194
196
  if (lineCount <= THRESHOLD) process.exit(0);
195
197
 
196
198
  // Inject non-blocking warning
197
- const rel = path.relative(process.cwd(), filePath);
198
- const warning = `Warning: ${rel} has ${lineCount} lines (threshold: ${THRESHOLD}). Consider splitting into smaller, focused modules.`;
199
+ const rel = path.relative(projectRoot, filePath);
200
+ emitWarning(`Warning: ${rel} has ${lineCount} lines (threshold: ${THRESHOLD}). Consider splitting into smaller, focused modules.`, payload);
201
+ }
199
202
 
200
- process.stdout.write(
201
- JSON.stringify({
203
+ // Inject an advisory warning in the agent's native shape. Cursor's postToolUse reads
204
+ // `additional_context`; Claude (and Codex PostToolUse) read hookSpecificOutput.additionalContext.
205
+ function emitWarning(warning, payload) {
206
+ if (payload.cursor_version || payload.hook_event_name === "postToolUse") {
207
+ process.stdout.write(JSON.stringify({ additional_context: warning }) + "\n");
208
+ } else {
209
+ process.stdout.write(JSON.stringify({
202
210
  continue: true,
203
- hookSpecificOutput: {
204
- hookEventName: "PostToolUse",
205
- additionalContext: warning,
206
- },
207
- }) + "\n"
208
- );
211
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: warning },
212
+ }) + "\n");
213
+ }
209
214
  }
210
215
 
211
216
  try {