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
@@ -2,8 +2,8 @@ import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, writeFile, unlink, chmod, rmdir } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { log } from './logger.js';
5
- import { getTemplateDir, COMPONENTS } from './installer.js';
6
- import { emitSkillFile, emitRules, emitHooks, AGENTS, GUARDS_BEGIN, GUARDS_END } from './agents.js';
5
+ import { getTemplateDir, COMPONENTS, skillAllowed } from './installer.js';
6
+ import { emitSkillFile, emitRules, emitHooks, AGENTS, RULES_BEGIN, RULES_END } from './agents.js';
7
7
 
8
8
  /**
9
9
  * Emit one canonical skill file for a target agent and write it to targetDir.
@@ -38,10 +38,16 @@ export async function installSkillForAgent(agentId, canonicalRel, targetDir, { f
38
38
  * Install the full skill set for one agent into targetDir.
39
39
  * @returns {{ agent: string, copied: number, skipped: number, identical: number, paths: string[] }}
40
40
  */
41
- export async function installAgentSkills(agentId, targetDir, { force = false } = {}) {
41
+ export async function installAgentSkills(agentId, targetDir, { force = false, skills = null } = {}) {
42
42
  let copied = 0, skipped = 0, identical = 0;
43
43
  const paths = [];
44
+ // Hermes (and any perProjectSkills:false agent) reads skills only from its global dir,
45
+ // never the project — emitting per-project skill files here would be dead. Skip them.
46
+ if (AGENTS[agentId]?.perProjectSkills === false) {
47
+ return { agent: agentId, label: AGENTS[agentId].label, copied, skipped, identical, paths };
48
+ }
44
49
  for (const relPath of COMPONENTS.skills) {
50
+ if (!skillAllowed(relPath, skills)) continue;
45
51
  const { result, path } = await installSkillForAgent(agentId, relPath, targetDir, { force });
46
52
  if (result === 'copied') copied++;
47
53
  else if (result === 'identical') identical++;
@@ -51,31 +57,32 @@ export async function installAgentSkills(agentId, targetDir, { force = false } =
51
57
  return { agent: agentId, label: AGENTS[agentId].label, copied, skipped, identical, paths };
52
58
  }
53
59
 
54
- const GUARDS_TEMPLATE_REL = 'rules/specpipe-guards.md';
60
+ const RULES_TEMPLATE_REL = 'rules/specpipe-rules.md';
55
61
 
56
- function guardsSectionRegex() {
62
+ function rulesSectionRegex() {
57
63
  const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
- return new RegExp(esc(GUARDS_BEGIN) + '[\\s\\S]*?' + esc(GUARDS_END) + '\\n?', '');
64
+ return new RegExp(esc(RULES_BEGIN) + '[\\s\\S]*?' + esc(RULES_END) + '\\n?', '');
59
65
  }
60
66
 
61
- /** Merge (or replace) the specpipe guards section in a shared AGENTS.md. */
62
- export async function mergeAgentsMdGuards(targetDir, section) {
63
- const p = join(targetDir, 'AGENTS.md');
67
+ /** Merge (or replace) the specpipe rules section in a shared file (CLAUDE.md / AGENTS.md). */
68
+ export async function mergeRulesSection(targetDir, fileRel, section) {
69
+ const p = join(targetDir, fileRel);
64
70
  let existing = '';
65
71
  try { existing = await readFile(p, 'utf-8'); } catch { /* new file */ }
66
- const re = guardsSectionRegex();
72
+ const re = rulesSectionRegex();
67
73
  existing = re.test(existing)
68
74
  ? existing.replace(re, section)
69
75
  : (existing.trim() ? existing.trimEnd() + '\n\n' : '') + section;
76
+ await mkdir(dirname(p), { recursive: true });
70
77
  await writeFile(p, existing);
71
78
  }
72
79
 
73
- /** Remove the specpipe guards section from AGENTS.md (deletes file if now empty). */
74
- export async function stripAgentsMdGuards(targetDir) {
75
- const p = join(targetDir, 'AGENTS.md');
80
+ /** Remove the specpipe rules section from a shared file (deletes it if now empty). */
81
+ export async function stripRulesSection(targetDir, fileRel) {
82
+ const p = join(targetDir, fileRel);
76
83
  let existing;
77
84
  try { existing = await readFile(p, 'utf-8'); } catch { return false; }
78
- const stripped = existing.replace(guardsSectionRegex(), '').trim();
85
+ const stripped = existing.replace(rulesSectionRegex(), '').trim();
79
86
  if (stripped === existing.trim()) return false;
80
87
  if (stripped) await writeFile(p, stripped + '\n');
81
88
  else await unlink(p);
@@ -83,19 +90,19 @@ export async function stripAgentsMdGuards(targetDir) {
83
90
  }
84
91
 
85
92
  /**
86
- * Install an agent's guardrails: an owned rules file (Cursor/Antigravity/
87
- * OpenClaw/Hermes) or a merged section in a shared AGENTS.md (Codex).
88
- * Claude returns null it uses native hooks instead.
93
+ * Install an agent's guardrails: a merged section in a shared file (Claude →
94
+ * .claude/CLAUDE.md, Codex AGENTS.md) or an owned rules file (Cursor/
95
+ * Antigravity/OpenClaw/Hermes). Returns null only if the agent has no rules entry.
89
96
  */
90
97
  export async function installAgentRules(agentId, targetDir, { force = false } = {}) {
91
- const body = await readFile(join(getTemplateDir(), GUARDS_TEMPLATE_REL), 'utf-8');
98
+ const body = await readFile(join(getTemplateDir(), RULES_TEMPLATE_REL), 'utf-8');
92
99
  const r = emitRules(agentId, body);
93
100
  if (!r) return null;
94
101
 
95
- if (r.mode === 'agents-md') {
96
- await mergeAgentsMdGuards(targetDir, r.content);
102
+ if (r.mode === 'merge') {
103
+ await mergeRulesSection(targetDir, r.path, r.content);
97
104
  log.copy(`${r.path} (specpipe operating-rules section)`);
98
- return { mode: 'agents-md', path: r.path };
105
+ return { mode: 'merge', path: r.path };
99
106
  }
100
107
 
101
108
  const dst = join(targetDir, r.path);
@@ -117,12 +124,13 @@ export async function installAgentRules(agentId, targetDir, { force = false } =
117
124
 
118
125
  /**
119
126
  * Install an agent's ENFORCED (blocking) hooks: the guard scripts + the agent's
120
- * hook config file. Codex/Cursor only (verified payloads). Returns null otherwise.
127
+ * hook config file. Agents with a verified hook config (Claude/Codex/Cursor/
128
+ * Antigravity); returns null for agents without one.
121
129
  * The hook config is specpipe-owned; if a different one already exists, we skip
122
130
  * unless --force (don't clobber a user's hooks).
123
131
  */
124
- export async function installAgentHooks(agentId, targetDir, { force = false } = {}) {
125
- const h = emitHooks(agentId);
132
+ export async function installAgentHooks(agentId, targetDir, { force = false, hooks = null } = {}) {
133
+ const h = emitHooks(agentId, hooks);
126
134
  if (!h) return null;
127
135
 
128
136
  for (const { src, dst } of h.scripts) {
@@ -151,11 +159,16 @@ export async function installAgentHooks(agentId, targetDir, { force = false } =
151
159
  return { configPath: h.configPath };
152
160
  }
153
161
 
154
- /** Remove an agent's enforced-hook scripts + config (+ empty hooks dir). */
162
+ /** Remove an agent's enforced-hook scripts + config (+ empty hooks/agent dirs). */
155
163
  export async function removeAgentHooks(agentId, targetDir) {
156
164
  const h = emitHooks(agentId);
157
165
  if (!h) return;
158
166
  for (const { dst } of h.scripts) { try { await unlink(join(targetDir, dst)); } catch { /* */ } }
159
167
  try { await unlink(join(targetDir, h.configPath)); } catch { /* */ }
160
168
  try { await rmdir(join(targetDir, h.hooksDir)); } catch { /* not empty / missing */ }
169
+ // Tidy the agent's container dir too (e.g. .codex/, .agents/) — its only specpipe
170
+ // content was the hook config + hooks/. rmdir is a no-op when the dir still holds
171
+ // other content (.claude/CLAUDE.md, .cursor/rules/, .agents/skills/ for a kept agent).
172
+ const agentDir = dirname(h.configPath);
173
+ if (agentDir && agentDir !== '.') { try { await rmdir(join(targetDir, agentDir)); } catch { /* not empty / missing */ } }
161
174
  }
package/src/lib/agents.js CHANGED
@@ -91,7 +91,8 @@ export const AGENTS = {
91
91
  label: 'Claude Code',
92
92
  // Verified: code.claude.com/docs/en/skills
93
93
  skillTarget: (name, inner) => `.claude/skills/${name}/${inner}`,
94
- globalRoot: '.claude/skills',
94
+ // Global (user-level) skills dir, relative to home. Verified: ~/.claude/skills/.
95
+ globalSkillRoot: '.claude/skills',
95
96
  skillFile: 'SKILL.md',
96
97
  hooks: 'native',
97
98
  capabilities: 'full',
@@ -104,9 +105,12 @@ export const AGENTS = {
104
105
  label: 'Antigravity',
105
106
  // Verified: official Google Codelab — .agents/skills/<name>/SKILL.md
106
107
  skillTarget: (name, inner) => `.agents/skills/${name}/${inner}`,
107
- globalRoot: '.agents/skills',
108
+ // Global differs from the project path: Antigravity CLI reads ~/.gemini/antigravity-cli/skills/
109
+ // (the IDE uses ~/.gemini/config/skills/ and also reads ~/.agents/skills/). Verified: Google
110
+ // Antigravity codelab. We target the CLI's global dir.
111
+ globalSkillRoot: '.gemini/antigravity-cli/skills',
108
112
  skillFile: 'SKILL.md',
109
- hooks: 'rules', // guards emitted to .agent/rules/ (plain markdown)
113
+ hooks: 'rules', // guards emitted to .agents/rules/ (plain markdown)
110
114
  capabilities: 'router-no-hooks',
111
115
  emitFrontmatter: fmNameDesc,
112
116
  },
@@ -114,7 +118,9 @@ export const AGENTS = {
114
118
  label: 'OpenClaw',
115
119
  // Verified: github.com/openclaw/openclaw skills/<name>/SKILL.md
116
120
  skillTarget: (name, inner) => `skills/${name}/${inner}`,
117
- globalRoot: 'skills',
121
+ // Verified (docs.openclaw.ai): global skills live in ~/.openclaw/skills/ (the `--global`
122
+ // target; OpenClaw also reads ~/.agents/skills/).
123
+ globalSkillRoot: '.openclaw/skills',
118
124
  skillFile: 'SKILL.md',
119
125
  hooks: 'none',
120
126
  capabilities: 'router-no-hooks',
@@ -122,9 +128,14 @@ export const AGENTS = {
122
128
  },
123
129
  hermes: {
124
130
  label: 'Hermes-Agent',
125
- // Verified: github.com/NousResearch/hermes-agent optional-skills/<cat>/<name>/SKILL.md
126
- skillTarget: (name, inner) => `optional-skills/specpipe/${name}/${inner}`,
127
- globalRoot: 'optional-skills/specpipe',
131
+ // Hermes discovers skills ONLY from ~/.hermes/skills/ ("the primary directory and source
132
+ // of truth") + explicitly-configured external_dirs — it does NOT scan the project/workspace.
133
+ // So per-project skill files are dead (never read); Hermes is global-only for skills. The
134
+ // skillTarget is still used to shape the GLOBAL emission (globalSkillRoot + name + inner).
135
+ // Source: hermes-agent.nousresearch.com/docs/user-guide/features/skills
136
+ skillTarget: (name, inner) => `${name}/${inner}`,
137
+ perProjectSkills: false,
138
+ globalSkillRoot: '.hermes/skills',
128
139
  skillFile: 'SKILL.md',
129
140
  hooks: 'none',
130
141
  capabilities: 'router-no-hooks',
@@ -136,7 +147,10 @@ export const AGENTS = {
136
147
  // Skills live in the vendor-neutral `.agents/skills/` (NOT `.codex/skills/`, which
137
148
  // is a known non-working path — openai/codex#15136). Custom-prompts are deprecated.
138
149
  skillTarget: (name, inner) => `.agents/skills/${name}/${inner}`,
139
- globalRoot: '.agents/skills',
150
+ // Global differs from the project path: Codex reads user skills from ~/.codex/skills/
151
+ // (ships system skills in ~/.codex/skills/.system), NOT ~/.agents/skills/. Verified:
152
+ // developers.openai.com/codex/skills.
153
+ globalSkillRoot: '.codex/skills',
140
154
  skillFile: 'SKILL.md',
141
155
  hooks: 'agents-md', // guards fold into AGENTS.md (plain markdown, no frontmatter)
142
156
  capabilities: 'router-no-hooks',
@@ -149,7 +163,10 @@ export const AGENTS = {
149
163
  // Skills are on-demand (/skill, @skill) — the correct home, not always-on .mdc rules.
150
164
  // Guards stay an always-on .cursor/rules/*.mdc (see RULES.cursor).
151
165
  skillTarget: (name, inner) => `.cursor/skills/${name}/${inner}`,
152
- globalRoot: '.cursor/skills',
166
+ // Cursor reads a user-level skills dir at ~/.cursor/skills/ (verified: cursor.com/docs/skills
167
+ // — it loads .cursor/skills, .agents/skills, ~/.cursor/skills, ~/.agents/skills, plus
168
+ // .claude/.codex for compat). So `--global` installs Cursor's own global skills here.
169
+ globalSkillRoot: '.cursor/skills',
153
170
  skillFile: 'SKILL.md',
154
171
  hooks: 'rules', // advisory now; Cursor DOES support blocking hooks (.cursor/hooks.json) — roadmapped
155
172
  capabilities: 'router-no-hooks',
@@ -205,6 +222,26 @@ export function emitSkillFile(agentId, canonicalRel, content) {
205
222
  return { path, content: compose(agent.emitFrontmatter(parsed, skill), body) };
206
223
  }
207
224
 
225
+ /**
226
+ * Emit one skill file for an agent's GLOBAL (user-level) install. Same per-agent
227
+ * content transformation as emitSkillFile, but the returned path is rooted at the
228
+ * agent's home-relative globalSkillRoot — which differs from the project skillTarget
229
+ * for Codex (~/.codex/skills), Antigravity (~/.gemini/antigravity-cli/skills), etc.
230
+ * Returns null when the agent has no native global skills dir (Cursor) or the input
231
+ * isn't a skill file.
232
+ * @returns {{ path: string, content: string } | null} path is relative to the user's home dir
233
+ */
234
+ export function emitSkillFileGlobal(agentId, canonicalRel, content) {
235
+ const agent = AGENTS[agentId];
236
+ if (!agent) throw new Error(`Unknown agent: ${agentId}`);
237
+ if (!agent.globalSkillRoot) return null;
238
+ const parts = parseSkillPath(canonicalRel);
239
+ if (!parts) return null;
240
+ const emitted = emitSkillFile(agentId, canonicalRel, content);
241
+ if (!emitted) return null;
242
+ return { path: `${agent.globalSkillRoot}/${parts.skill}/${parts.inner}`, content: emitted.content };
243
+ }
244
+
208
245
  /** Tools a canonical skill declares (from its `allowed-tools` frontmatter). */
209
246
  function toolsOf(parsed) {
210
247
  const block = getKeyBlock(parsed, 'allowed-tools');
@@ -251,7 +288,12 @@ function adaptBody(agentId, body, tools) {
251
288
  let out = rewriteAsk(body);
252
289
 
253
290
  if (has('Agent') || has('Task')) {
254
- out = `${out.replace(/\s*$/, '')}\n\n---\n\n## Running on ${AGENTS[agentId].label}\n\n` +
291
+ // Header is agent-NEUTRAL on purpose: codex and antigravity share the same
292
+ // emit path (.agents/skills/<name>/), so the caveat must be byte-identical
293
+ // between them or computeDesired's Map sees divergent content and a clean
294
+ // install false-flags the file as "customized". The caveat text is already
295
+ // runtime-agnostic, so a fixed heading loses nothing.
296
+ out = `${out.replace(/\s*$/, '')}\n\n---\n\n## Running outside Claude Code\n\n` +
255
297
  '- **Subagents:** parts of this skill describe Claude subagent orchestration ' +
256
298
  '(parallel waves, worktrees, auto-mode dispatch). If your runtime has no ' +
257
299
  'subagents, do that work yourself — one item at a time, sequentially, in this ' +
@@ -275,6 +317,6 @@ export function emitFile(agentId, templateRel, content) {
275
317
  // Guardrails (advisory rules) + enforced (blocking) hooks live in agent-guards.js;
276
318
  // re-exported here so callers keep importing them from agents.js.
277
319
  export {
278
- GUARDS_BEGIN, GUARDS_END, HOOKS_SRC_DIR,
320
+ RULES_BEGIN, RULES_END, HOOKS_SRC_DIR,
279
321
  agentRulesMode, emitRules, agentHasHooks, emitHooks,
280
322
  } from './agent-guards.js';
@@ -4,14 +4,12 @@ import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { log } from './logger.js';
6
6
  import { getTemplateDir } from './installer.js';
7
+ import { emitSkillFileGlobal } from './agents.js';
8
+ import { buildHookConfig } from './hooks.js';
9
+ import { hashContent } from './hasher.js';
7
10
 
8
- // Claude's global install (~/.claude/skills, ~/.claude/hooks, ~/.claude/settings.json).
9
- // Claude-only: it's Claude Code's own enforcement engine; other agents have no equivalent.
10
-
11
- /** Global skills directory: ~/.claude/skills/ */
12
- export function getGlobalSkillsDir() {
13
- return join(homedir(), '.claude', 'skills');
14
- }
11
+ // Global install. Skills install per-agent via installSkillGlobalForAgent (below);
12
+ // hooks + settings stay Claude-only (Claude Code's native enforcement engine).
15
13
 
16
14
  /** Global hooks directory: ~/.claude/hooks/ */
17
15
  export function getGlobalHooksDir() {
@@ -19,14 +17,14 @@ export function getGlobalHooksDir() {
19
17
  }
20
18
 
21
19
  /**
22
- * Copy a hook to the global ~/.claude/hooks/ directory.
23
- * Strips the '.claude/hooks/' prefix so path-guard.sh lands at ~/.claude/hooks/path-guard.sh.
20
+ * Copy one guard script (kit-relative src, e.g. 'hooks/specpipe-shell-guard.sh') into
21
+ * the global ~/.claude/hooks/ dir. `key` is the home-relative manifest key.
24
22
  * @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string }}
25
23
  */
26
- export async function installHookGlobal(hookRelPath, globalHooksDir, { force = false, globalFiles = {} } = {}) {
27
- const stripped = hookRelPath.replace(/^\.claude\/hooks\//, '');
28
- const src = join(getTemplateDir(), hookRelPath);
29
- const dst = join(globalHooksDir, stripped);
24
+ export async function installHookGlobal(srcRel, globalHooksDir, { force = false, globalFiles = {}, key } = {}) {
25
+ const base = srcRel.split('/').pop();
26
+ const src = join(getTemplateDir(), srcRel);
27
+ const dst = join(globalHooksDir, base);
30
28
 
31
29
  const { hashFile } = await import('./hasher.js');
32
30
  const srcHash = await hashFile(src);
@@ -35,14 +33,15 @@ export async function installHookGlobal(hookRelPath, globalHooksDir, { force = f
35
33
  try {
36
34
  const dstHash = await hashFile(dst);
37
35
  if (srcHash === dstHash) {
38
- log.same(`~/.claude/hooks/${stripped} (identical)`);
36
+ log.same(`~/.claude/hooks/${base} (identical)`);
39
37
  return { result: 'identical', kitHash: srcHash };
40
38
  }
41
- const savedKitHash = globalFiles[hookRelPath]?.kitHash;
42
- if (savedKitHash && dstHash === savedKitHash) {
43
- // fall through to copy
44
- } else {
45
- log.skip(`~/.claude/hooks/${stripped} (customized use --force to overwrite)`);
39
+ // Overwrite only when the on-disk file is one WE wrote (matches the kit hash
40
+ // recorded in the manifest) — i.e. a stale specpipe version, safe to update.
41
+ // Otherwise the user changed it (or we never tracked it) → preserve.
42
+ const savedKitHash = globalFiles[key]?.kitHash;
43
+ if (!(savedKitHash && dstHash === savedKitHash)) {
44
+ log.skip(`~/.claude/hooks/${base} (customized — use --force to overwrite)`);
46
45
  return { result: 'skipped', kitHash: srcHash };
47
46
  }
48
47
  } catch { /* hash failed */ }
@@ -51,43 +50,15 @@ export async function installHookGlobal(hookRelPath, globalHooksDir, { force = f
51
50
  await mkdir(dirname(dst), { recursive: true });
52
51
  await fsCopyFile(src, dst);
53
52
  await chmod(dst, 0o755);
54
- log.copy(`~/.claude/hooks/${stripped}`);
53
+ log.copy(`~/.claude/hooks/${base}`);
55
54
  return { result: 'copied', kitHash: srcHash };
56
55
  }
57
56
 
58
- /** Build hook entries for ~/.claude/settings.json pointing to globalHooksDir. */
59
- function buildGlobalHookEntries(globalHooksDir) {
60
- // Normalize to forward slashes — bash on all platforms (WSL, Git Bash, macOS, Linux)
61
- // requires forward slashes even when the host OS is Windows.
57
+ /** Claude settings.json hook entries pointing to the absolute global hooks dir. */
58
+ function buildGlobalHookEntries(globalHooksDir, hooksSet) {
59
+ // Forward slashes — bash needs them on every host (WSL, Git Bash, macOS, Linux).
62
60
  const dir = globalHooksDir.replace(/\\/g, '/');
63
- const h = (file) => `"${dir}/${file}"`;
64
- return {
65
- PreToolUse: [
66
- { matcher: 'Bash', hooks: [
67
- { type: 'command', command: `bash ${h('path-guard.sh')}` },
68
- { type: 'command', command: `bash ${h('sensitive-guard.sh')}` },
69
- ]},
70
- { matcher: 'Read|Write|Edit|MultiEdit|Grep', hooks: [
71
- { type: 'command', command: `bash ${h('sensitive-guard.sh')}` },
72
- ]},
73
- { matcher: 'Edit|MultiEdit', hooks: [
74
- { type: 'command', command: `node ${h('comment-guard.js')}` },
75
- ]},
76
- { matcher: 'Glob', hooks: [
77
- { type: 'command', command: `node ${h('glob-guard.js')}` },
78
- ]},
79
- ],
80
- PostToolUse: [
81
- { matcher: 'Write|Edit|MultiEdit', hooks: [
82
- { type: 'command', command: `node ${h('file-guard.js')}` },
83
- ]},
84
- ],
85
- Stop: [
86
- { matcher: '', hooks: [
87
- { type: 'command', command: `bash ${h('self-review.sh')}` },
88
- ]},
89
- ],
90
- };
61
+ return buildHookConfig('claude', dir, hooksSet)?.hooks || {};
91
62
  }
92
63
 
93
64
  function isDevkitHookCommand(command) {
@@ -113,7 +84,7 @@ function stripDevkitHooks(existingHooks) {
113
84
  * Merge devkit hook registrations into ~/.claude/settings.json.
114
85
  * Preserves any existing non-devkit hooks the user may have.
115
86
  */
116
- export async function mergeGlobalSettings(globalHooksDir) {
87
+ export async function mergeGlobalSettings(globalHooksDir, hooksSet = null) {
117
88
  const settingsPath = join(homedir(), '.claude', 'settings.json');
118
89
  let existing = {};
119
90
  try {
@@ -121,7 +92,7 @@ export async function mergeGlobalSettings(globalHooksDir) {
121
92
  } catch { /* file doesn't exist yet — start fresh */ }
122
93
 
123
94
  const cleanedHooks = stripDevkitHooks(existing.hooks);
124
- const newEntries = buildGlobalHookEntries(globalHooksDir);
95
+ const newEntries = buildGlobalHookEntries(globalHooksDir, hooksSet);
125
96
  const mergedHooks = { ...cleanedHooks };
126
97
  for (const [event, entries] of Object.entries(newEntries)) {
127
98
  mergedHooks[event] = [...(mergedHooks[event] || []), ...entries];
@@ -147,37 +118,44 @@ export async function removeGlobalHooksFromSettings() {
147
118
  }
148
119
 
149
120
  /**
150
- * Copy a skill to the global ~/.claude/skills/ directory.
151
- * Strips the 'skills/' prefix so sp-plan/SKILL.md lands at ~/.claude/skills/sp-plan/SKILL.md.
152
- * @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string }}
121
+ * Install one skill file into an agent's GLOBAL (user-level) dir, with the agent's
122
+ * own content transformation (frontmatter, AskUserQuestion rewrite, subagent caveat).
123
+ * Works for every agent with a globalSkillRoot — Claude emits identity content, others
124
+ * get their own frontmatter. Idempotency is keyed on the EMITTED content (which differs
125
+ * from the kit source for non-Claude agents). The manifest key is the home-relative
126
+ * emitted path, unique per agent.
127
+ * @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string, key: string } | null}
128
+ * null when the agent has no global dir (Cursor) or the path isn't a skill file.
153
129
  */
154
- export async function installSkillGlobal(skillRelPath, globalSkillsDir, { force = false, globalFiles = {} } = {}) {
155
- const stripped = skillRelPath.replace(/^skills\//, '');
156
- const src = join(getTemplateDir(), skillRelPath);
157
- const dst = join(globalSkillsDir, stripped);
130
+ export async function installSkillGlobalForAgent(agentId, skillRelPath, { force = false, globalFiles = {} } = {}) {
131
+ const srcContent = await readFile(join(getTemplateDir(), skillRelPath), 'utf-8');
132
+ const emitted = emitSkillFileGlobal(agentId, skillRelPath, srcContent);
133
+ if (!emitted) return null;
158
134
 
159
- const { hashFile } = await import('./hasher.js');
160
- const srcHash = await hashFile(src);
135
+ const dst = join(homedir(), ...emitted.path.split('/'));
136
+ const display = `~/${emitted.path}`;
137
+ const key = emitted.path;
138
+ const srcHash = hashContent(emitted.content);
161
139
 
162
140
  if (existsSync(dst) && !force) {
163
141
  try {
164
- const dstHash = await hashFile(dst);
165
- if (srcHash === dstHash) {
166
- log.same(`~/.claude/skills/${stripped} (identical)`);
167
- return { result: 'identical', kitHash: srcHash };
142
+ const dstHash = hashContent(await readFile(dst, 'utf-8'));
143
+ if (dstHash === srcHash) {
144
+ log.same(`${display} (identical)`);
145
+ return { result: 'identical', kitHash: srcHash, key };
168
146
  }
169
- const savedKitHash = globalFiles[skillRelPath]?.kitHash;
170
- if (savedKitHash && dstHash === savedKitHash) {
171
- // fall through to copy
172
- } else {
173
- log.skip(`~/.claude/skills/${stripped} (customized — use --force to overwrite)`);
174
- return { result: 'skipped', kitHash: srcHash };
147
+ // Overwrite only a stale version we wrote (disk matches the recorded kit hash);
148
+ // otherwise the user customized it (or it's untracked) → preserve.
149
+ const savedKitHash = globalFiles[key]?.kitHash;
150
+ if (!(savedKitHash && dstHash === savedKitHash)) {
151
+ log.skip(`${display} (customized — use --force to overwrite)`);
152
+ return { result: 'skipped', kitHash: srcHash, key };
175
153
  }
176
- } catch { /* hash failed, treat as conflict */ }
154
+ } catch { /* hash failed, treat as conflict → overwrite below */ }
177
155
  }
178
156
 
179
157
  await mkdir(dirname(dst), { recursive: true });
180
- await fsCopyFile(src, dst);
181
- log.copy(`~/.claude/skills/${stripped}`);
182
- return { result: 'copied', kitHash: srcHash };
158
+ await writeFile(dst, emitted.content);
159
+ log.copy(display);
160
+ return { result: 'copied', kitHash: srcHash, key };
183
161
  }