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.
- package/README.md +111 -311
- package/package.json +2 -1
- package/src/cli.js +16 -6
- package/src/commands/diff.js +1 -1
- package/src/commands/init-agents.js +48 -20
- package/src/commands/init-global.js +104 -33
- package/src/commands/init-interactive.js +71 -0
- package/src/commands/init.js +68 -20
- 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 +55 -77
- package/src/lib/hooks.js +203 -0
- package/src/lib/installer.js +104 -62
- 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/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
|
+
}
|
package/src/lib/installer.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
docs
|
|
52
|
-
|
|
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
|
-
*
|
|
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
|
|
60
|
-
'
|
|
61
|
-
'
|
|
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
|
-
*
|
|
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
|
|
68
|
-
|
|
69
|
-
'
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
212
|
-
mergeGlobalSettings, removeGlobalHooksFromSettings,
|
|
252
|
+
getGlobalHooksDir, installHookGlobal,
|
|
253
|
+
mergeGlobalSettings, removeGlobalHooksFromSettings,
|
|
254
|
+
installSkillGlobalForAgent,
|
|
213
255
|
} from './claude-global.js';
|
package/src/lib/reconcile.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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 !== '
|
|
51
|
+
if (rules && rules.mode !== 'merge') {
|
|
47
52
|
desired.set(rules.path, {
|
|
48
53
|
agent,
|
|
49
|
-
templateRel:
|
|
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
|
-
//
|
|
151
|
-
|
|
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 !==
|
|
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(
|
|
177
|
-
|
|
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(
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
},
|
|
207
|
-
}) + "\n"
|
|
208
|
-
);
|
|
211
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: warning },
|
|
212
|
+
}) + "\n");
|
|
213
|
+
}
|
|
209
214
|
}
|
|
210
215
|
|
|
211
216
|
try {
|