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