specpipe 1.0.2 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpipe",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Spec-first development toolkit for agentic AI coding agents — installs skills + guardrails for Claude Code, Codex, Cursor, Antigravity, and more.",
5
5
  "bin": {
6
6
  "specpipe": "./bin/devkit.js",
@@ -9,7 +9,7 @@ import { hashContent } from '../lib/hasher.js';
9
9
  import {
10
10
  COMPONENTS,
11
11
  fillTemplate, installAgentSkills, installAgentRules, installAgentHooks,
12
- resolveSkills,
12
+ resolveSkills, pruneOrphans,
13
13
  } from '../lib/installer.js';
14
14
  import { resolveAgents, AGENTS, agentHasHooks } from '../lib/agents.js';
15
15
  import { resolveHooks } from '../lib/hooks.js';
@@ -102,6 +102,14 @@ export async function initMultiAgent(targetDir, opts, warnings = 0) {
102
102
  setFileEntry(manifest, relPath, d.kitHash, installedHash, { agent: d.agent, templateRel: d.templateRel });
103
103
  }
104
104
 
105
+ // Migration: prune predecessor files (mf-* / ap-*, renamed hooks) a prior manifest
106
+ // tracked but this install no longer wants — only manifest-tracked paths, so a
107
+ // user's own files are never touched.
108
+ if (existing?.files) {
109
+ const n = await pruneOrphans(targetDir, existing.files, new Set(Object.keys(manifest.files)));
110
+ if (n) log.info(`Migrated: removed ${n} superseded file(s) from a previous version.`);
111
+ }
112
+
105
113
  await writeManifest(targetDir, manifest);
106
114
 
107
115
  // Summary
@@ -15,6 +15,14 @@ import { hookScriptsFor } from '../lib/hooks.js';
15
15
 
16
16
  const GLOBAL_MANIFEST = join(homedir(), '.claude', '.devkit-manifest.json');
17
17
 
18
+ /** Which agent a home-relative global key belongs to, by its skill-root prefix. */
19
+ function ownerFromGlobalKey(key) {
20
+ for (const [id, a] of Object.entries(AGENTS)) {
21
+ if (a.globalSkillRoot && key.startsWith(a.globalSkillRoot + '/')) return id;
22
+ }
23
+ return null;
24
+ }
25
+
18
26
  export async function readGlobalManifest() {
19
27
  try {
20
28
  return JSON.parse(await readFile(GLOBAL_MANIFEST, 'utf-8'));
@@ -68,8 +76,9 @@ export async function initGlobal({ agents = ['claude'], skills = null, hookSelec
68
76
  // Global hooks are Claude-only (its native enforcement engine).
69
77
  const wantHooks = hooks && agents.includes('claude');
70
78
  if (wantHooks) {
71
- const hookKeys = await initGlobalHooks({ force, hooks: hookSelection, _globalFiles: updatedFiles, _skipManifestWrite: true });
79
+ const { keys: hookKeys, entries: hookEntries } = await initGlobalHooks({ force, hooks: hookSelection, _globalFiles: updatedFiles, _skipManifestWrite: true });
72
80
  for (const k of hookKeys) installedKeys.add(k);
81
+ Object.assign(updatedFiles, hookEntries); // persist hook kitHashes in the global manifest
73
82
  }
74
83
 
75
84
  // Prune orphans: files from a previous global install that are no longer desired
@@ -80,7 +89,10 @@ export async function initGlobal({ agents = ['claude'], skills = null, hookSelec
80
89
  for (const [key, entry] of Object.entries(globalFiles)) {
81
90
  if (installedKeys.has(key)) continue;
82
91
  const isHook = key.startsWith('.claude/hooks/');
83
- const owner = entry.agent || (isHook ? 'claude' : null);
92
+ // Legacy claude-devkit / agentpipe manifests recorded skills with NO `agent` field;
93
+ // derive the owner from the key's global-skill-root prefix (e.g. .claude/skills/ →
94
+ // claude) so those predecessor mf-*/ap-* skills get pruned, not silently skipped.
95
+ const owner = entry.agent || (isHook ? 'claude' : ownerFromGlobalKey(key));
84
96
  const inScope = isHook ? wantHooks : (owner && agents.includes(owner));
85
97
  if (!inScope) continue;
86
98
  const abs = join(homedir(), ...key.split('/'));
@@ -101,6 +113,18 @@ export async function initGlobal({ agents = ['claude'], skills = null, hookSelec
101
113
  }
102
114
  if (pruned) log.info(`Pruned ${pruned} stale global file(s).`);
103
115
 
116
+ // Legacy migration: older claude-devkit installs left ~/.claude/scripts/build-test.sh
117
+ // (no longer in the kit, untracked by the manifest). Sweep it up on install too —
118
+ // not just on remove — so upgrading from the old tool leaves nothing behind.
119
+ if (wantHooks) {
120
+ const legacyScript = join(homedir(), '.claude', 'scripts', 'build-test.sh');
121
+ try {
122
+ await unlink(legacyScript);
123
+ log.del('~/.claude/scripts/build-test.sh (legacy)');
124
+ try { await rmdir(join(homedir(), '.claude', 'scripts')); } catch { /* keep if other scripts */ }
125
+ } catch { /* not present — fine */ }
126
+ }
127
+
104
128
  await writeGlobalManifest({
105
129
  ...existing,
106
130
  globalInstalled: installedAgents.has('claude') || existing.globalInstalled || false,
@@ -121,6 +145,9 @@ export async function initGlobalHooks({ force = false, hooks = null, _globalFile
121
145
  const globalFiles = _globalFiles || existing?.files || {};
122
146
  const updatedFiles = { ...globalFiles };
123
147
  const keys = []; // home-relative keys installed this run (for the caller's orphan-prune)
148
+ const entries = {}; // kitHash entries written this run — the caller persists these so
149
+ // hooks are TRACKED (else savedKitHash is always undefined → every
150
+ // version bump looks "customized" and stale hooks never auto-update)
124
151
 
125
152
  log.blank();
126
153
  console.log('--- Installing global hooks ---');
@@ -133,7 +160,7 @@ export async function initGlobalHooks({ force = false, hooks = null, _globalFile
133
160
  if (result === 'copied') copied++;
134
161
  else if (result === 'identical') identical++;
135
162
  else skipped++;
136
- if (result !== 'skipped') updatedFiles[key] = { kitHash };
163
+ if (result !== 'skipped') { updatedFiles[key] = { kitHash }; entries[key] = { kitHash }; }
137
164
  keys.push(key);
138
165
  }
139
166
 
@@ -153,5 +180,5 @@ export async function initGlobalHooks({ force = false, hooks = null, _globalFile
153
180
  updatedAt: new Date().toISOString(),
154
181
  });
155
182
  }
156
- return keys;
183
+ return { keys, entries };
157
184
  }
@@ -14,6 +14,7 @@ import {
14
14
  setPermissions, fillTemplate,
15
15
  verifySettingsJson, COMPONENTS,
16
16
  getTemplateDir, installSkillForAgent, installAgentHooks, installAgentRules, resolveSkills, skillAllowed,
17
+ pruneOrphans,
17
18
  } from '../lib/installer.js';
18
19
  import { resolveHooks } from '../lib/hooks.js';
19
20
  import { AGENTS, parseSkillPath, resolveAgents } from '../lib/agents.js';
@@ -190,6 +191,14 @@ export async function initCommand(path, opts) {
190
191
  } catch { /* prior agent's file not on disk — don't record a phantom */ }
191
192
  }
192
193
 
194
+ // Migration: drop predecessor files a prior manifest tracked but we no longer
195
+ // install (mf-* / ap-* skills, renamed hooks) so an install over an old version
196
+ // doesn't leave orphaned /mf-* commands beside the new /sp-* ones.
197
+ if (prior?.files) {
198
+ const n = await pruneOrphans(targetDir, prior.files, new Set(Object.keys(manifest.files)));
199
+ if (n) log.info(`Migrated: removed ${n} superseded file(s) from a previous version.`);
200
+ }
201
+
193
202
  // --- Permissions ---
194
203
  await setPermissions(targetDir);
195
204
 
@@ -36,6 +36,9 @@ export async function installHookGlobal(srcRel, globalHooksDir, { force = false,
36
36
  log.same(`~/.claude/hooks/${base} (identical)`);
37
37
  return { result: 'identical', kitHash: srcHash };
38
38
  }
39
+ // Overwrite only when the on-disk file is one WE wrote (matches the kit hash
40
+ // recorded in the manifest) — i.e. a stale specpipe version, safe to update.
41
+ // Otherwise the user changed it (or we never tracked it) → preserve.
39
42
  const savedKitHash = globalFiles[key]?.kitHash;
40
43
  if (!(savedKitHash && dstHash === savedKitHash)) {
41
44
  log.skip(`~/.claude/hooks/${base} (customized — use --force to overwrite)`);
@@ -141,6 +144,8 @@ export async function installSkillGlobalForAgent(agentId, skillRelPath, { force
141
144
  log.same(`${display} (identical)`);
142
145
  return { result: 'identical', kitHash: srcHash, key };
143
146
  }
147
+ // Overwrite only a stale version we wrote (disk matches the recorded kit hash);
148
+ // otherwise the user customized it (or it's untracked) → preserve.
144
149
  const savedKitHash = globalFiles[key]?.kitHash;
145
150
  if (!(savedKitHash && dstHash === savedKitHash)) {
146
151
  log.skip(`${display} (customized — use --force to overwrite)`);
@@ -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';
@@ -150,6 +150,36 @@ export async function installFile(relativePath, targetDir, { force = false } = {
150
150
  return 'copied';
151
151
  }
152
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
+
153
183
  // Per-agent install (emit skills + guardrails) lives in agent-install.js;
154
184
  // re-exported here so callers keep importing from installer.js.
155
185
  export {