claude-devkit-cli 1.4.1 → 1.4.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": "claude-devkit-cli",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "CLI toolkit for spec-first development with Claude Code — hooks, commands, guards, and test runners",
5
5
  "bin": {
6
6
  "claude-devkit": "./bin/devkit.js",
@@ -37,15 +37,20 @@ export async function initGlobal({ force = false, hooks = false } = {}) {
37
37
  const globalSkillsDir = getGlobalSkillsDir();
38
38
  await mkdir(globalSkillsDir, { recursive: true });
39
39
 
40
+ const existing = await readGlobalManifest() || {};
41
+ const globalFiles = existing.files || {};
42
+ const updatedFiles = { ...globalFiles };
43
+
40
44
  log.blank();
41
45
  console.log('--- Installing global skills ---');
42
46
 
43
47
  let copied = 0; let skipped = 0; let identical = 0;
44
48
  for (const relPath of COMPONENTS.skills) {
45
- const result = await installSkillGlobal(relPath, globalSkillsDir, { force });
49
+ const { result, kitHash } = await installSkillGlobal(relPath, globalSkillsDir, { force, globalFiles });
46
50
  if (result === 'copied') copied++;
47
51
  else if (result === 'identical') identical++;
48
52
  else skipped++;
53
+ if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
49
54
  }
50
55
 
51
56
  const parts = [`${copied} copied`];
@@ -55,32 +60,36 @@ export async function initGlobal({ force = false, hooks = false } = {}) {
55
60
  log.info('Skills available in all projects via ~/.claude/skills/');
56
61
 
57
62
  if (hooks) {
58
- await initGlobalHooks({ force });
63
+ await initGlobalHooks({ force, _globalFiles: updatedFiles, _skipManifestWrite: true });
59
64
  }
60
65
 
61
- // Write global manifest
62
- const existing = await readGlobalManifest() || {};
63
66
  await writeGlobalManifest({
64
67
  ...existing,
65
68
  globalInstalled: true,
66
69
  globalHooksInstalled: hooks || existing.globalHooksInstalled || false,
70
+ files: updatedFiles,
67
71
  updatedAt: new Date().toISOString(),
68
72
  });
69
73
  }
70
74
 
71
- export async function initGlobalHooks({ force = false } = {}) {
75
+ export async function initGlobalHooks({ force = false, _globalFiles, _skipManifestWrite = false } = {}) {
72
76
  const globalHooksDir = getGlobalHooksDir();
73
77
  await mkdir(globalHooksDir, { recursive: true });
74
78
 
79
+ const existing = _skipManifestWrite ? null : (await readGlobalManifest() || {});
80
+ const globalFiles = _globalFiles || existing?.files || {};
81
+ const updatedFiles = { ...globalFiles };
82
+
75
83
  log.blank();
76
84
  console.log('--- Installing global hooks ---');
77
85
 
78
86
  let copied = 0; let skipped = 0; let identical = 0;
79
87
  for (const relPath of COMPONENTS.hooks) {
80
- const result = await installHookGlobal(relPath, globalHooksDir, { force });
88
+ const { result, kitHash } = await installHookGlobal(relPath, globalHooksDir, { force, globalFiles });
81
89
  if (result === 'copied') copied++;
82
90
  else if (result === 'identical') identical++;
83
91
  else skipped++;
92
+ if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
84
93
  }
85
94
 
86
95
  await mergeGlobalSettings(globalHooksDir);
@@ -90,6 +99,15 @@ export async function initGlobalHooks({ force = false } = {}) {
90
99
  if (skipped > 0) parts.push(`${skipped} customized (use --force to overwrite)`);
91
100
  log.pass(`Global hooks: ${parts.join(', ')}`);
92
101
  log.info('Hooks registered in ~/.claude/settings.json — active in all projects');
102
+
103
+ if (!_skipManifestWrite) {
104
+ await writeGlobalManifest({
105
+ ...existing,
106
+ globalHooksInstalled: true,
107
+ files: updatedFiles,
108
+ updatedAt: new Date().toISOString(),
109
+ });
110
+ }
93
111
  }
94
112
 
95
113
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -23,23 +23,26 @@ export async function upgradeGlobal({ force = false } = {}) {
23
23
  const globalSkillsDir = getGlobalSkillsDir();
24
24
  await mkdir(globalSkillsDir, { recursive: true });
25
25
 
26
+ const meta = await readGlobalManifest() || {};
27
+ const globalFiles = meta.files || {};
28
+ const updatedFiles = { ...globalFiles };
29
+
26
30
  log.blank();
27
31
  console.log('--- Upgrading global skills ---');
28
32
  let updated = 0; let skipped = 0; let identical = 0;
29
33
 
30
34
  for (const relPath of COMPONENTS.skills) {
31
- const result = await installSkillGlobal(relPath, globalSkillsDir, { force });
35
+ const { result, kitHash } = await installSkillGlobal(relPath, globalSkillsDir, { force, globalFiles });
32
36
  if (result === 'copied') updated++;
33
37
  else if (result === 'identical') identical++;
34
38
  else skipped++;
39
+ if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
35
40
  }
36
41
 
37
42
  let skillParts = [`${updated} updated`, `${identical} unchanged`];
38
43
  if (skipped > 0) skillParts.push(`${skipped} customized (use --force to overwrite)`);
39
44
  log.pass(`Global skills: ${skillParts.join(', ')}`);
40
45
 
41
- const meta = await readGlobalManifest() || {};
42
-
43
46
  // Upgrade hooks if previously installed globally
44
47
  if (meta.globalHooksInstalled) {
45
48
  const globalHooksDir = getGlobalHooksDir();
@@ -50,10 +53,11 @@ export async function upgradeGlobal({ force = false } = {}) {
50
53
  let hUpdated = 0; let hSkipped = 0; let hIdentical = 0;
51
54
 
52
55
  for (const relPath of COMPONENTS.hooks) {
53
- const result = await installHookGlobal(relPath, globalHooksDir, { force });
56
+ const { result, kitHash } = await installHookGlobal(relPath, globalHooksDir, { force, globalFiles });
54
57
  if (result === 'copied') hUpdated++;
55
58
  else if (result === 'identical') hIdentical++;
56
59
  else hSkipped++;
60
+ if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
57
61
  }
58
62
 
59
63
  await mergeGlobalSettings(globalHooksDir);
@@ -63,7 +67,7 @@ export async function upgradeGlobal({ force = false } = {}) {
63
67
  log.pass(`Global hooks: ${hookParts.join(', ')}`);
64
68
  }
65
69
 
66
- await writeGlobalManifest({ ...meta, globalInstalled: true, updatedAt: new Date().toISOString() });
70
+ await writeGlobalManifest({ ...meta, globalInstalled: true, files: updatedFiles, updatedAt: new Date().toISOString() });
67
71
 
68
72
  // Warn about per-project skills that shadow global
69
73
  const projects = meta.projects || [];
@@ -203,24 +203,31 @@ export function getGlobalHooksDir() {
203
203
  * Copy a hook to the global ~/.claude/hooks/ directory.
204
204
  * Strips the '.claude/hooks/' prefix so path-guard.sh lands at
205
205
  * ~/.claude/hooks/path-guard.sh.
206
- * @returns {string} 'copied' | 'skipped' | 'identical'
206
+ * @param {object} [opts.globalFiles] - files section from global manifest, used to detect true customization
207
+ * @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string }}
207
208
  */
208
- export async function installHookGlobal(hookRelPath, globalHooksDir, { force = false } = {}) {
209
+ export async function installHookGlobal(hookRelPath, globalHooksDir, { force = false, globalFiles = {} } = {}) {
209
210
  const stripped = hookRelPath.replace(/^\.claude\/hooks\//, '');
210
211
  const src = join(getTemplateDir(), hookRelPath);
211
212
  const dst = join(globalHooksDir, stripped);
212
213
 
214
+ const { hashFile } = await import('./hasher.js');
215
+ const srcHash = await hashFile(src);
216
+
213
217
  if (existsSync(dst) && !force) {
214
218
  try {
215
- const { hashFile } = await import('./hasher.js');
216
- const srcHash = await hashFile(src);
217
219
  const dstHash = await hashFile(dst);
218
220
  if (srcHash === dstHash) {
219
221
  log.same(`~/.claude/hooks/${stripped} (identical)`);
220
- return 'identical';
222
+ return { result: 'identical', kitHash: srcHash };
223
+ }
224
+ const savedKitHash = globalFiles[hookRelPath]?.kitHash;
225
+ if (savedKitHash && dstHash === savedKitHash) {
226
+ // fall through to copy
227
+ } else {
228
+ log.skip(`~/.claude/hooks/${stripped} (customized — use --force to overwrite)`);
229
+ return { result: 'skipped', kitHash: srcHash };
221
230
  }
222
- log.skip(`~/.claude/hooks/${stripped} (customized — use --force to overwrite)`);
223
- return 'skipped';
224
231
  } catch { /* hash failed */ }
225
232
  }
226
233
 
@@ -228,7 +235,7 @@ export async function installHookGlobal(hookRelPath, globalHooksDir, { force = f
228
235
  await fsCopyFile(src, dst);
229
236
  await chmod(dst, 0o755);
230
237
  log.copy(`~/.claude/hooks/${stripped}`);
231
- return 'copied';
238
+ return { result: 'copied', kitHash: srcHash };
232
239
  }
233
240
 
234
241
  /**
@@ -331,29 +338,38 @@ export async function removeGlobalHooksFromSettings() {
331
338
  * Copy a skill to the global ~/.claude/skills/ directory.
332
339
  * Strips the '.claude/skills/' prefix so mf-plan/SKILL.md lands at
333
340
  * ~/.claude/skills/mf-plan/SKILL.md.
334
- * @returns {string} 'copied' | 'skipped' | 'identical'
341
+ * @param {object} [opts.globalFiles] - files section from global manifest, used to detect true customization
342
+ * @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string }}
335
343
  */
336
- export async function installSkillGlobal(skillRelPath, globalSkillsDir, { force = false } = {}) {
344
+ export async function installSkillGlobal(skillRelPath, globalSkillsDir, { force = false, globalFiles = {} } = {}) {
337
345
  const stripped = skillRelPath.replace(/^\.claude\/skills\//, '');
338
346
  const src = join(getTemplateDir(), skillRelPath);
339
347
  const dst = join(globalSkillsDir, stripped);
340
348
 
349
+ const { hashFile } = await import('./hasher.js');
350
+ const srcHash = await hashFile(src);
351
+
341
352
  if (existsSync(dst) && !force) {
342
353
  try {
343
- const { hashFile } = await import('./hasher.js');
344
- const srcHash = await hashFile(src);
345
354
  const dstHash = await hashFile(dst);
346
355
  if (srcHash === dstHash) {
347
356
  log.same(`~/.claude/skills/${stripped} (identical)`);
348
- return 'identical';
357
+ return { result: 'identical', kitHash: srcHash };
358
+ }
359
+ // If the installed file still matches the kitHash we saved at last install,
360
+ // the user hasn't touched it — the kit just changed. Safe to update.
361
+ const savedKitHash = globalFiles[skillRelPath]?.kitHash;
362
+ if (savedKitHash && dstHash === savedKitHash) {
363
+ // fall through to copy
364
+ } else {
365
+ log.skip(`~/.claude/skills/${stripped} (customized — use --force to overwrite)`);
366
+ return { result: 'skipped', kitHash: srcHash };
349
367
  }
350
- log.skip(`~/.claude/skills/${stripped} (customized — use --force to overwrite)`);
351
- return 'skipped';
352
368
  } catch { /* hash failed, treat as conflict */ }
353
369
  }
354
370
 
355
371
  await mkdir(dirname(dst), { recursive: true });
356
372
  await fsCopyFile(src, dst);
357
373
  log.copy(`~/.claude/skills/${stripped}`);
358
- return 'copied';
374
+ return { result: 'copied', kitHash: srcHash };
359
375
  }
@@ -15,6 +15,9 @@ TDD delivery loop — write failing tests from spec AS, implement story by story
15
15
  If no changes → "No source changes found. Specify a file or feature."
16
16
 
17
17
  2. **Read the spec** at `docs/specs/<feature>/<feature>.md` — the `## Stories` section with acceptance scenarios is your roadmap. The `## Overview` and `## Constraints` sections tell you the INTENT behind the code.
18
+
19
+ 3. **Locate related code:** If `codebase-memory-mcp` is available, use `search_code` to find all files touching this feature, and `trace_call_path` to understand dependency chain before writing tests — faster and more accurate than manual grep. Fallback: Grep for the main function/type names in the changed files.
20
+
18
21
  4. **Read existing tests** for the changed files — find patterns, fixtures, naming conventions. Don't duplicate.
19
22
 
20
23
  ---
@@ -111,19 +114,32 @@ Files: [test files touched]
111
114
  Stories: [AS-001 ✓, AS-002 ✓, AS-005 new]
112
115
  ```
113
116
 
114
- If behavior changed: "Consider updating the spec in docs/specs/<feature>/<feature>.md."
117
+ ### Spec Update Signal
118
+
119
+ After every build, check against these conditions. If ANY is true → **must** signal.
115
120
 
116
- ### Spec Gap Detection
121
+ **Signal when (MUST):**
117
122
 
118
- If a test fails due to an edge case, error path, or boundary condition that is NOT covered by any existing AS in the spec:
123
+ | # | Condition |
124
+ |---|-----------|
125
+ | S1 | A new test covers behavior, edge case, or error path with no corresponding AS in the spec |
126
+ | S2 | Code behavior no longer matches the Given/When/Then of an existing AS (spec is stale) |
127
+ | S3 | Implementation adds a new constraint or guard not documented in any AS or Constraints section |
119
128
 
120
- 1. State explicitly: **"This failure suggests a missing acceptance scenario."**
121
- 2. Describe the gap: what behavior was tested, which story it belongs to, why no AS covers it.
122
- 3. Prompt: **"Run `/mf-plan <spec-path> 'Add AS for <description>'` to add the missing scenario, then re-run `/mf-build`."**
129
+ **Do not signal when:**
130
+ - Pure refactor behavior unchanged, all existing AS still map correctly
131
+ - Performance fix same output, just faster
132
+ - Fix to match spec — code was wrong, spec was right, no new behavior added
133
+
134
+ **Signal format:**
135
+ ```
136
+ ⚠️ Spec Update Needed — run `/mf-plan docs/specs/<feature>/<feature>.md '<describe change>'`
137
+ Reason: [S1 | S2 | S3] — <one line: what is missing or mismatched>
138
+ ```
123
139
 
124
- Do not silently fix the test and move on. A test that has no corresponding AS means the spec is incomplete the spec must be updated first.
140
+ If S1 applies to a failing test: state **"This failure suggests a missing acceptance scenario."** Describe the gap and prompt to run `/mf-plan` before re-running `/mf-build`. Do not silently add the test without the AS.
125
141
 
126
142
  ## Rules
127
143
  1. **Behavior over implementation.** Test what code DOES, not how.
128
144
  2. **Independent tests.** Each test sets up its own state, cleans up after.
129
- 3. **Spec stays upstream.** If a test reveals a spec gap, update the spec before adding the test.
145
+ 3. **Spec stays upstream.** If a test reveals a spec gap (S1), signal and update the spec before adding the test. If code drifts from spec (S2), signal. If new constraint added (S3), signal.
@@ -13,7 +13,7 @@ Bug: $ARGUMENTS
13
13
  Don't jump to code. Understand the bug first:
14
14
 
15
15
  1. **Parse the report.** Symptom? Expected vs actual? Repro steps?
16
- 2. **Locate the code.** Grep for keywords from the bug (error messages, function names).
16
+ 2. **Locate the code.** If `codebase-memory-mcp` is available, use `search_code("<error message or function name>")` to find related files faster, and `trace_call_path` to map callers and impact radius. Fallback: Grep for keywords from the bug (error messages, function names).
17
17
  3. **Check history.** `git log --oneline -5 -- <file>` and `git blame -L <range> <file>` — who changed this last and why?
18
18
  4. **Form a hypothesis:** "I believe the bug is caused by [X] in [file:function] because [evidence]."
19
19
 
@@ -102,7 +102,27 @@ Prevention: <suggestion>
102
102
  Full suite: All passing ✓
103
103
  ```
104
104
 
105
- If the bug reveals an undocumented edge case: "Consider updating the spec at docs/specs/<feature>/<feature>.md."
105
+ ### Spec Update Signal
106
+
107
+ After fixing, check these conditions. If ANY is true → **must** signal.
108
+
109
+ **Signal when (MUST):**
110
+
111
+ | # | Condition |
112
+ |---|-----------|
113
+ | S1 | Fix covers an edge case or error path with no corresponding AS in the spec |
114
+ | S2 | Bug existed because an AS described wrong behavior — After fix, code and AS now conflict |
115
+ | S3 | Fix adds a new constraint or guard (null check, balance guard, validation) not in spec |
116
+
117
+ **Do not signal when:**
118
+ - Fix is a clear typo/off-by-one — code was always wrong relative to spec, no new behavior
119
+ - Performance-only fix — output unchanged
120
+
121
+ **Signal format:**
122
+ ```
123
+ ⚠️ Spec Update Needed — run `/mf-plan docs/specs/<feature>/<feature>.md '<describe change>'`
124
+ Reason: [S1 | S2 | S3] — <one line: what is missing or mismatched>
125
+ ```
106
126
 
107
127
  ## Multiple Bugs
108
128
 
@@ -13,6 +13,7 @@ Pre-merge code review — security, correctness, spec alignment.
13
13
  ```
14
14
  2. Check for spec in `docs/specs/<feature>/<feature>.md` — review against INTENT.
15
15
  3. Read the diff: `git diff "$BASE"...HEAD`
16
+ 4. **Expand blast radius:** If `codebase-memory-mcp` is available, use `search_code("<changed function or type>")` to find files not in the diff that may be affected, and `get_architecture()` to check if changed files belong to a sensitive layer (auth, payment, core). Fallback: skip, review diff only.
16
17
 
17
18
  If `$ARGUMENTS` provided → scope to those files only.
18
19
  If diff > 500 lines → review file-by-file, prioritize by smart focus below.