specpipe 1.0.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpipe",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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
@@ -68,8 +68,9 @@ export async function initGlobal({ agents = ['claude'], skills = null, hookSelec
68
68
  // Global hooks are Claude-only (its native enforcement engine).
69
69
  const wantHooks = hooks && agents.includes('claude');
70
70
  if (wantHooks) {
71
- const hookKeys = await initGlobalHooks({ force, hooks: hookSelection, _globalFiles: updatedFiles, _skipManifestWrite: true });
71
+ const { keys: hookKeys, entries: hookEntries } = await initGlobalHooks({ force, hooks: hookSelection, _globalFiles: updatedFiles, _skipManifestWrite: true });
72
72
  for (const k of hookKeys) installedKeys.add(k);
73
+ Object.assign(updatedFiles, hookEntries); // persist hook kitHashes in the global manifest
73
74
  }
74
75
 
75
76
  // Prune orphans: files from a previous global install that are no longer desired
@@ -101,6 +102,18 @@ export async function initGlobal({ agents = ['claude'], skills = null, hookSelec
101
102
  }
102
103
  if (pruned) log.info(`Pruned ${pruned} stale global file(s).`);
103
104
 
105
+ // Legacy migration: older claude-devkit installs left ~/.claude/scripts/build-test.sh
106
+ // (no longer in the kit, untracked by the manifest). Sweep it up on install too —
107
+ // not just on remove — so upgrading from the old tool leaves nothing behind.
108
+ if (wantHooks) {
109
+ const legacyScript = join(homedir(), '.claude', 'scripts', 'build-test.sh');
110
+ try {
111
+ await unlink(legacyScript);
112
+ log.del('~/.claude/scripts/build-test.sh (legacy)');
113
+ try { await rmdir(join(homedir(), '.claude', 'scripts')); } catch { /* keep if other scripts */ }
114
+ } catch { /* not present — fine */ }
115
+ }
116
+
104
117
  await writeGlobalManifest({
105
118
  ...existing,
106
119
  globalInstalled: installedAgents.has('claude') || existing.globalInstalled || false,
@@ -121,6 +134,9 @@ export async function initGlobalHooks({ force = false, hooks = null, _globalFile
121
134
  const globalFiles = _globalFiles || existing?.files || {};
122
135
  const updatedFiles = { ...globalFiles };
123
136
  const keys = []; // home-relative keys installed this run (for the caller's orphan-prune)
137
+ const entries = {}; // kitHash entries written this run — the caller persists these so
138
+ // hooks are TRACKED (else savedKitHash is always undefined → every
139
+ // version bump looks "customized" and stale hooks never auto-update)
124
140
 
125
141
  log.blank();
126
142
  console.log('--- Installing global hooks ---');
@@ -133,7 +149,7 @@ export async function initGlobalHooks({ force = false, hooks = null, _globalFile
133
149
  if (result === 'copied') copied++;
134
150
  else if (result === 'identical') identical++;
135
151
  else skipped++;
136
- if (result !== 'skipped') updatedFiles[key] = { kitHash };
152
+ if (result !== 'skipped') { updatedFiles[key] = { kitHash }; entries[key] = { kitHash }; }
137
153
  keys.push(key);
138
154
  }
139
155
 
@@ -153,5 +169,5 @@ export async function initGlobalHooks({ force = false, hooks = null, _globalFile
153
169
  updatedAt: new Date().toISOString(),
154
170
  });
155
171
  }
156
- return keys;
172
+ return { keys, entries };
157
173
  }
@@ -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 {