specpipe 1.0.0 → 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.
- package/README.md +116 -1220
- package/package.json +3 -2
- package/src/cli.js +16 -6
- package/src/commands/diff.js +1 -1
- package/src/commands/init-agents.js +40 -20
- package/src/commands/init-global.js +88 -33
- package/src/commands/init-interactive.js +71 -0
- package/src/commands/init.js +61 -22
- package/src/commands/remove.js +159 -49
- package/src/commands/upgrade.js +21 -56
- package/src/lib/agent-guards.js +34 -78
- package/src/lib/agent-install.js +38 -25
- package/src/lib/agents.js +53 -11
- package/src/lib/claude-global.js +50 -77
- package/src/lib/hooks.js +203 -0
- package/src/lib/installer.js +73 -61
- package/src/lib/reconcile.js +13 -8
- package/templates/{.claude/hooks → hooks}/file-guard.js +26 -21
- package/templates/hooks/specpipe-read-guard.sh +94 -21
- package/templates/hooks/specpipe-shell-guard.sh +121 -29
- package/templates/rules/specpipe-rules.md +77 -0
- package/templates/skills/sp-build/SKILL.md +101 -1
- package/templates/skills/sp-build-behavior-matrix/SKILL.md +876 -0
- package/templates/skills/sp-challenge/SKILL.md +34 -0
- package/templates/skills/sp-challenge-behavior-matrix/SKILL.md +289 -0
- package/templates/skills/sp-explore/SKILL.md +132 -0
- package/templates/skills/sp-explore-behavior-matrix/SKILL.md +862 -0
- package/templates/skills/sp-fix/SKILL.md +73 -1
- package/templates/skills/sp-fix-behavior-matrix/SKILL.md +338 -0
- package/templates/skills/sp-investigate/SKILL.md +70 -0
- package/templates/skills/sp-investigate-behavior-matrix/SKILL.md +718 -0
- package/templates/skills/sp-plan/SKILL.md +90 -0
- package/templates/skills/sp-plan-behavior-matrix/SKILL.md +1037 -0
- package/templates/skills/sp-review/SKILL.md +29 -3
- package/templates/skills/sp-review-behavior-matrix/SKILL.md +294 -0
- package/templates/.claude/CLAUDE.md +0 -79
- package/templates/.claude/hooks/path-guard.sh +0 -118
- package/templates/.claude/hooks/self-review.sh +0 -27
- package/templates/.claude/hooks/sensitive-guard.sh +0 -227
- package/templates/.claude/settings.json +0 -68
- package/templates/docs/WORKFLOW.md +0 -325
- package/templates/docs/specs/.gitkeep +0 -0
- package/templates/rules/specpipe-guards.md +0 -40
- package/templates/scripts/test-hooks.sh +0 -66
- /package/templates/{.claude/hooks → hooks}/comment-guard.js +0 -0
- /package/templates/{.claude/hooks → hooks}/glob-guard.js +0 -0
package/src/lib/agent-install.js
CHANGED
|
@@ -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,
|
|
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
|
|
60
|
+
const RULES_TEMPLATE_REL = 'rules/specpipe-rules.md';
|
|
55
61
|
|
|
56
|
-
function
|
|
62
|
+
function rulesSectionRegex() {
|
|
57
63
|
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
58
|
-
return new RegExp(esc(
|
|
64
|
+
return new RegExp(esc(RULES_BEGIN) + '[\\s\\S]*?' + esc(RULES_END) + '\\n?', '');
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
/** Merge (or replace) the specpipe
|
|
62
|
-
export async function
|
|
63
|
-
const p = join(targetDir,
|
|
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 =
|
|
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
|
|
74
|
-
export async function
|
|
75
|
-
const p = join(targetDir,
|
|
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(
|
|
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:
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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(),
|
|
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 === '
|
|
96
|
-
await
|
|
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: '
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 .
|
|
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
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
+
RULES_BEGIN, RULES_END, HOOKS_SRC_DIR,
|
|
279
321
|
agentRulesMode, emitRules, agentHasHooks, emitHooks,
|
|
280
322
|
} from './agent-guards.js';
|
package/src/lib/claude-global.js
CHANGED
|
@@ -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
|
-
//
|
|
9
|
-
// Claude-only
|
|
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
|
|
23
|
-
*
|
|
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(
|
|
27
|
-
const
|
|
28
|
-
const src = join(getTemplateDir(),
|
|
29
|
-
const dst = join(globalHooksDir,
|
|
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,12 @@ 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/${
|
|
36
|
+
log.same(`~/.claude/hooks/${base} (identical)`);
|
|
39
37
|
return { result: 'identical', kitHash: srcHash };
|
|
40
38
|
}
|
|
41
|
-
const savedKitHash = globalFiles[
|
|
42
|
-
if (savedKitHash && dstHash === savedKitHash) {
|
|
43
|
-
|
|
44
|
-
} else {
|
|
45
|
-
log.skip(`~/.claude/hooks/${stripped} (customized — use --force to overwrite)`);
|
|
39
|
+
const savedKitHash = globalFiles[key]?.kitHash;
|
|
40
|
+
if (!(savedKitHash && dstHash === savedKitHash)) {
|
|
41
|
+
log.skip(`~/.claude/hooks/${base} (customized — use --force to overwrite)`);
|
|
46
42
|
return { result: 'skipped', kitHash: srcHash };
|
|
47
43
|
}
|
|
48
44
|
} catch { /* hash failed */ }
|
|
@@ -51,43 +47,15 @@ export async function installHookGlobal(hookRelPath, globalHooksDir, { force = f
|
|
|
51
47
|
await mkdir(dirname(dst), { recursive: true });
|
|
52
48
|
await fsCopyFile(src, dst);
|
|
53
49
|
await chmod(dst, 0o755);
|
|
54
|
-
log.copy(`~/.claude/hooks/${
|
|
50
|
+
log.copy(`~/.claude/hooks/${base}`);
|
|
55
51
|
return { result: 'copied', kitHash: srcHash };
|
|
56
52
|
}
|
|
57
53
|
|
|
58
|
-
/**
|
|
59
|
-
function buildGlobalHookEntries(globalHooksDir) {
|
|
60
|
-
//
|
|
61
|
-
// requires forward slashes even when the host OS is Windows.
|
|
54
|
+
/** Claude settings.json hook entries pointing to the absolute global hooks dir. */
|
|
55
|
+
function buildGlobalHookEntries(globalHooksDir, hooksSet) {
|
|
56
|
+
// Forward slashes — bash needs them on every host (WSL, Git Bash, macOS, Linux).
|
|
62
57
|
const dir = globalHooksDir.replace(/\\/g, '/');
|
|
63
|
-
|
|
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
|
-
};
|
|
58
|
+
return buildHookConfig('claude', dir, hooksSet)?.hooks || {};
|
|
91
59
|
}
|
|
92
60
|
|
|
93
61
|
function isDevkitHookCommand(command) {
|
|
@@ -113,7 +81,7 @@ function stripDevkitHooks(existingHooks) {
|
|
|
113
81
|
* Merge devkit hook registrations into ~/.claude/settings.json.
|
|
114
82
|
* Preserves any existing non-devkit hooks the user may have.
|
|
115
83
|
*/
|
|
116
|
-
export async function mergeGlobalSettings(globalHooksDir) {
|
|
84
|
+
export async function mergeGlobalSettings(globalHooksDir, hooksSet = null) {
|
|
117
85
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
118
86
|
let existing = {};
|
|
119
87
|
try {
|
|
@@ -121,7 +89,7 @@ export async function mergeGlobalSettings(globalHooksDir) {
|
|
|
121
89
|
} catch { /* file doesn't exist yet — start fresh */ }
|
|
122
90
|
|
|
123
91
|
const cleanedHooks = stripDevkitHooks(existing.hooks);
|
|
124
|
-
const newEntries = buildGlobalHookEntries(globalHooksDir);
|
|
92
|
+
const newEntries = buildGlobalHookEntries(globalHooksDir, hooksSet);
|
|
125
93
|
const mergedHooks = { ...cleanedHooks };
|
|
126
94
|
for (const [event, entries] of Object.entries(newEntries)) {
|
|
127
95
|
mergedHooks[event] = [...(mergedHooks[event] || []), ...entries];
|
|
@@ -147,37 +115,42 @@ export async function removeGlobalHooksFromSettings() {
|
|
|
147
115
|
}
|
|
148
116
|
|
|
149
117
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
118
|
+
* Install one skill file into an agent's GLOBAL (user-level) dir, with the agent's
|
|
119
|
+
* own content transformation (frontmatter, AskUserQuestion rewrite, subagent caveat).
|
|
120
|
+
* Works for every agent with a globalSkillRoot — Claude emits identity content, others
|
|
121
|
+
* get their own frontmatter. Idempotency is keyed on the EMITTED content (which differs
|
|
122
|
+
* from the kit source for non-Claude agents). The manifest key is the home-relative
|
|
123
|
+
* emitted path, unique per agent.
|
|
124
|
+
* @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string, key: string } | null}
|
|
125
|
+
* null when the agent has no global dir (Cursor) or the path isn't a skill file.
|
|
153
126
|
*/
|
|
154
|
-
export async function
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
|
|
127
|
+
export async function installSkillGlobalForAgent(agentId, skillRelPath, { force = false, globalFiles = {} } = {}) {
|
|
128
|
+
const srcContent = await readFile(join(getTemplateDir(), skillRelPath), 'utf-8');
|
|
129
|
+
const emitted = emitSkillFileGlobal(agentId, skillRelPath, srcContent);
|
|
130
|
+
if (!emitted) return null;
|
|
158
131
|
|
|
159
|
-
const
|
|
160
|
-
const
|
|
132
|
+
const dst = join(homedir(), ...emitted.path.split('/'));
|
|
133
|
+
const display = `~/${emitted.path}`;
|
|
134
|
+
const key = emitted.path;
|
|
135
|
+
const srcHash = hashContent(emitted.content);
|
|
161
136
|
|
|
162
137
|
if (existsSync(dst) && !force) {
|
|
163
138
|
try {
|
|
164
|
-
const dstHash = await
|
|
165
|
-
if (
|
|
166
|
-
log.same(
|
|
167
|
-
return { result: 'identical', kitHash: srcHash };
|
|
139
|
+
const dstHash = hashContent(await readFile(dst, 'utf-8'));
|
|
140
|
+
if (dstHash === srcHash) {
|
|
141
|
+
log.same(`${display} (identical)`);
|
|
142
|
+
return { result: 'identical', kitHash: srcHash, key };
|
|
168
143
|
}
|
|
169
|
-
const savedKitHash = globalFiles[
|
|
170
|
-
if (savedKitHash && dstHash === savedKitHash) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
log.skip(`~/.claude/skills/${stripped} (customized — use --force to overwrite)`);
|
|
174
|
-
return { result: 'skipped', kitHash: srcHash };
|
|
144
|
+
const savedKitHash = globalFiles[key]?.kitHash;
|
|
145
|
+
if (!(savedKitHash && dstHash === savedKitHash)) {
|
|
146
|
+
log.skip(`${display} (customized — use --force to overwrite)`);
|
|
147
|
+
return { result: 'skipped', kitHash: srcHash, key };
|
|
175
148
|
}
|
|
176
|
-
} catch { /* hash failed, treat as conflict */ }
|
|
149
|
+
} catch { /* hash failed, treat as conflict → overwrite below */ }
|
|
177
150
|
}
|
|
178
151
|
|
|
179
152
|
await mkdir(dirname(dst), { recursive: true });
|
|
180
|
-
await
|
|
181
|
-
log.copy(
|
|
182
|
-
return { result: 'copied', kitHash: srcHash };
|
|
153
|
+
await writeFile(dst, emitted.content);
|
|
154
|
+
log.copy(display);
|
|
155
|
+
return { result: 'copied', kitHash: srcHash, key };
|
|
183
156
|
}
|
package/src/lib/hooks.js
ADDED
|
@@ -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
|
+
}
|