@dzhechkov/skills-feature-adr 1.3.8 → 1.3.10

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  An 11-step, complexity-routed pipeline that makes an AI coding agent build a feature the way a
6
6
  disciplined engineering team does: **spec first, code last.** Every phase emits a durable,
7
- versioned, human-approved **specification artifact** (`00_…`–`08_…`), and code is *generated from
7
+ versioned, human-approved **specification artifact** (`00_…`–`09_…`), and code is *generated from
8
8
  the frozen spec* — not reverse-documented after the fact. Scales from a 3-file config change to a
9
9
  cross-cutting 30+ file refactor via a Complexity Router (S/M/L/XL). Integrates 15 skills from
10
10
  [agentic-qe](https://github.com/proffesor-for-testing/agentic-qe) for quality engineering. Part of
@@ -14,7 +14,7 @@ the [Keysarium](https://www.npmjs.com/package/@dzhechkov/keysarium) ecosystem.
14
14
  > onboarding doc for free, every decision captured as an ADR, and machine-checked back against the
15
15
  > code by the QE phase. See **[Spec-Driven Development](#spec-driven-development-sdd)** below.
16
16
  >
17
- > 👉 **Onboarding a team? Start here:** [**Feature ADR — Team Onboarding**](https://unpkg.com/@dzhechkov/skills-feature-adr/docs/team-onboarding.md) — a single read-top-to-bottom playbook (install → first feature → SDD mechanics → model routing incl. Fable → self-learning layers → team PR workflow → FAQ). It also ships **inside this package** at `node_modules/@dzhechkov/skills-feature-adr/docs/team-onboarding.md`, so every install has it locally.
17
+ > 👉 **Onboarding a team? Start here:** [**Feature ADR — Team Onboarding**](https://github.com/djd1m/dz-skill-bundles/blob/main/docs/feature-adr-team-onboarding.md) — a single read-top-to-bottom playbook (install → first feature → SDD mechanics → model routing incl. Fable → self-learning layers → team PR workflow → FAQ). It also ships **inside this package** at `node_modules/@dzhechkov/skills-feature-adr/docs/team-onboarding.md` ([raw copy on unpkg](https://unpkg.com/@dzhechkov/skills-feature-adr/docs/team-onboarding.md)), so every install has it locally.
18
18
 
19
19
  ---
20
20
 
@@ -76,13 +76,13 @@ npx @dzhechkov/skills-feature-adr doctor # Health check
76
76
  ## Feature ADR Pipeline
77
77
 
78
78
  ```
79
- Step 0 Step 1 Step 2 Step 3 Step 4
80
- COMPLEXITY → REQUIREMENTS → RESEARCH → ADR → DDD
81
- ROUTER (L/XL only) (M+ only) (L/XL only)
79
+ Step 0 Step 1 Step 2 Step 3 Step 3.5 Step 4
80
+ COMPLEXITY → REQUIREMENTS → RESEARCH → ADR → QCSD SWARM → DDD
81
+ ROUTER (L/XL only) (M+ only) (M+ only) (L/XL only)
82
82
 
83
- Step 5 Step 6 Step 7 Step 8
84
- ARCHITECTURE → IMPLEMENTATION → CODE → QE
85
- (M+ only) PLAN
83
+ Step 5 Step 6 Step 7 Step 8 Step 9
84
+ ARCHITECTURE → IMPLEMENTATION → CODE → QE → FLEET QE
85
+ (M+ only) PLAN (L/XL only)
86
86
  ```
87
87
 
88
88
  ### Usage in Claude Code
@@ -197,7 +197,7 @@ gate — not discovered in production.
197
197
  | Tier | Scope | Active Steps | Time Budget |
198
198
  |------|-------|-------------|-------------|
199
199
  | **S** | 1-3 files, 1 domain | 0→1→6→7→8 | ~15 min |
200
- | **M** | 4-10 files, 1-2 domains | 0→1→3→5→6→7→8 | ~45 min |
200
+ | **M** | 4-10 files, 1-2 domains | 0→1→3→3.5→5→6→7→8 | ~45 min |
201
201
  | **L** | 11-30 files, 2-4 domains | Full pipeline with parallelism | ~2 hours |
202
202
  | **XL** | 30+ files, cross-cutting | Full DAG + multi-agent swarm | ~4+ hours |
203
203
 
@@ -461,9 +461,10 @@ npx @dzhechkov/skills-feature-adr init # Feature development
461
461
 
462
462
  ## Links
463
463
 
464
- - **Team Onboarding playbook (public):** [unpkg.com/@dzhechkov/skills-feature-adr/docs/team-onboarding.md](https://unpkg.com/@dzhechkov/skills-feature-adr/docs/team-onboarding.md) — also shipped in the package at `docs/team-onboarding.md`
465
- - **GitHub:** [https://github.com/dzhechko/product-keysarium-2026](https://github.com/dzhechko/product-keysarium-2026)
466
- - **Issues:** [https://github.com/dzhechko/product-keysarium-2026/issues](https://github.com/dzhechko/product-keysarium-2026/issues)
464
+ - **Team Onboarding playbook (public, rendered):** [dz-skill-bundles/docs/feature-adr-team-onboarding.md](https://github.com/djd1m/dz-skill-bundles/blob/main/docs/feature-adr-team-onboarding.md) — also shipped in this package at `docs/team-onboarding.md` ([raw on unpkg](https://unpkg.com/@dzhechkov/skills-feature-adr/docs/team-onboarding.md))
465
+ - **GitHub (public docs & bundles):** [https://github.com/djd1m/dz-skill-bundles](https://github.com/djd1m/dz-skill-bundles)
466
+ - **Issues:** [https://github.com/djd1m/dz-skill-bundles/issues](https://github.com/djd1m/dz-skill-bundles/issues)
467
+ - *Note: the source monorepo (`djd1m/dz-harness-hub`) is private; the links above are the public entry points.*
467
468
  - **npm:** [https://www.npmjs.com/package/@dzhechkov/skills-feature-adr](https://www.npmjs.com/package/@dzhechkov/skills-feature-adr)
468
469
  - **Keysarium:** [https://www.npmjs.com/package/@dzhechkov/keysarium](https://www.npmjs.com/package/@dzhechkov/keysarium)
469
470
  - **BTO:** [https://www.npmjs.com/package/@dzhechkov/skills-bto](https://www.npmjs.com/package/@dzhechkov/skills-bto)
@@ -6,7 +6,7 @@ This is the practical guide for a dev team adopting `@dzhechkov/skills-feature-a
6
6
  from zero to your first spec-driven feature, then explains the mechanics, the model routing, the
7
7
  self-learning options, and the team conventions.
8
8
 
9
- - 📦 npm: <https://www.npmjs.com/package/@dzhechkov/skills-feature-adr> (current: **1.3.6**)
9
+ - 📦 npm: <https://www.npmjs.com/package/@dzhechkov/skills-feature-adr>
10
10
  - 📄 Full reference README: same npm page (this doc is the *team playbook*, not a reference dump)
11
11
  - 🧭 Deep design: `packages/@dzhechkov/skills-meta/feature-adr/SKILL.md` + its `references/`
12
12
 
@@ -15,7 +15,7 @@ self-learning options, and the team conventions.
15
15
  ## TL;DR — what & why
16
16
 
17
17
  An AI agent, left alone, jumps straight to code. Feature ADR forces the opposite: **spec first,
18
- code last.** Each phase emits a durable, human-approved specification artifact (`00_…`–`08_…`), and
18
+ code last.** Each phase emits a durable, human-approved specification artifact (`00_…`–`09_…`), and
19
19
  code is *generated from the frozen spec* — then verified back against it.
20
20
 
21
21
  **Why your team should care:**
@@ -133,7 +133,7 @@ features/<feature-slug>/
133
133
  | Tier | Scope | Active steps | Budget |
134
134
  |------|-------|--------------|--------|
135
135
  | **S** | 1–3 files, 1 domain | 0→1→6→7→8 (no ADR/DDD/architecture) | ~15 min |
136
- | **M** | 4–10 files | 0→1→3→5→6→7→8 | ~45 min |
136
+ | **M** | 4–10 files | 0→1→3→3.5→5→6→7→8 | ~45 min |
137
137
  | **L** | 11–30 files | Full pipeline with parallelism | ~2 h |
138
138
  | **XL** | 30+ files, cross-cutting | Full DAG + multi-agent swarm | ~4 h+ |
139
139
 
@@ -230,7 +230,7 @@ uses agentdb, and even there it's optional (lexical recall works without it).
230
230
  fixed for the run — restart if scope changes drastically mid-pipeline.
231
231
  - **"`--full-qe` did nothing extra."** `agentic-qe` isn't installed → it silently falls back to
232
232
  Reference mode with a warning. Install it (`npm i -g agentic-qe && aqe init --auto`).
233
- - **"Do I have to use all 9 steps?"** No — the Complexity Router picks the active subset per tier.
233
+ - **"Do I have to use all 11 steps?"** No — the Complexity Router picks the active subset per tier.
234
234
  - **"Where's the learning stored / how do I reset it?"** Layer A: delete `.keysarium/memory/`.
235
235
  B: `.agentic-qe/`. C: `.dz/`. They're independent.
236
236
  - **"Can I change which model a step uses?"** Yes — the `Model` column is a default. See §6.
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "@dzhechkov/skills-feature-adr",
3
- "version": "1.3.8",
3
+ "version": "1.3.10",
4
4
  "description": "Adaptive Feature Development skill pack for Claude Code — 11-step pipeline with Complexity Router (S/M/L/XL), ADR-driven architecture, 15 agentic-qe skills, multi-agent fleet QE. Supports --full-qe, --full-qe-extended, --with-learning, and --knowledge-extractor modes.",
5
- "main": "src/cli.js",
6
5
  "bin": {
7
6
  "skills-feature-adr": "./bin/cli.js"
8
7
  },
@@ -43,9 +42,9 @@
43
42
  "url": "https://github.com/djd1m/dz-harness-hub.git",
44
43
  "directory": "packages/@dzhechkov/skills-feature-adr"
45
44
  },
46
- "homepage": "https://github.com/djd1m/dz-harness-hub/tree/main/packages/@dzhechkov/skills-feature-adr#readme",
45
+ "homepage": "https://github.com/djd1m/dz-skill-bundles/blob/main/docs/feature-adr-team-onboarding.md",
47
46
  "bugs": {
48
- "url": "https://github.com/dzhechko/product-keysarium-2026/issues"
47
+ "url": "https://github.com/djd1m/dz-skill-bundles/issues"
49
48
  },
50
49
  "peerDependencies": {
51
50
  "@dzhechkov/keysarium-core": "^1.0.0"
package/src/cli.js CHANGED
@@ -138,24 +138,19 @@ function main() {
138
138
 
139
139
  switch (resolvedCommand) {
140
140
  case 'init':
141
- require('./commands/init')(flags);
142
- break;
141
+ return require('./commands/init')(flags);
143
142
 
144
143
  case 'update':
145
- require('./commands/update')(flags);
146
- break;
144
+ return require('./commands/update')(flags);
147
145
 
148
146
  case 'remove':
149
- require('./commands/remove')(flags);
150
- break;
147
+ return require('./commands/remove')(flags);
151
148
 
152
149
  case 'list':
153
- require('./commands/list')(flags);
154
- break;
150
+ return require('./commands/list')(flags);
155
151
 
156
152
  case 'doctor':
157
- require('./commands/doctor')(flags);
158
- break;
153
+ return require('./commands/doctor')(flags);
159
154
 
160
155
  default:
161
156
  error(`Unknown command: "${resolvedCommand}"`);
@@ -166,4 +161,11 @@ function main() {
166
161
  }
167
162
  }
168
163
 
169
- main();
164
+ // Command handlers may be async — catch rejections so they never surface as
165
+ // unhandled promise rejections (finding: un-awaited run()).
166
+ Promise.resolve()
167
+ .then(() => main())
168
+ .catch((err) => {
169
+ console.error('✗ ' + (err && err.message ? err.message : err));
170
+ process.exit(1);
171
+ });
@@ -35,15 +35,33 @@ function check(name, fn) {
35
35
  function checkFilesExist(manifest, targetDir) {
36
36
  return check('Files exist', () => {
37
37
  const files = manifest.files || [];
38
+ const resolvedTarget = path.resolve(targetDir);
38
39
  const missing = [];
40
+ const suspicious = [];
39
41
 
40
42
  for (const relPath of files) {
41
- const absPath = path.join(targetDir, relPath);
43
+ const absPath = path.resolve(resolvedTarget, relPath);
44
+
45
+ // Manifest entries must stay inside the project — anything that
46
+ // resolves outside is suspicious (possible tampering), not checked.
47
+ if (absPath !== resolvedTarget && !absPath.startsWith(resolvedTarget + path.sep)) {
48
+ suspicious.push(relPath);
49
+ continue;
50
+ }
51
+
42
52
  if (!fileExists(absPath)) {
43
53
  missing.push(relPath);
44
54
  }
45
55
  }
46
56
 
57
+ if (suspicious.length > 0) {
58
+ return {
59
+ passed: false,
60
+ detail: `${suspicious.length} suspicious manifest entr${suspicious.length === 1 ? 'y' : 'ies'} outside the project (possible tampering): ${suspicious.slice(0, 5).join(', ')}${suspicious.length > 5 ? '...' : ''}`,
61
+ fix: `Inspect ${cyan(MANIFEST_FILE)} and remove entries pointing outside the project, or reinstall with ${cyan('@dzhechkov/skills-feature-adr init --force')}.`,
62
+ };
63
+ }
64
+
47
65
  if (missing.length === 0) {
48
66
  return {
49
67
  passed: true,
@@ -112,7 +130,7 @@ function checkSkillComplete(targetDir) {
112
130
  }
113
131
 
114
132
  function checkModulesComplete(targetDir) {
115
- return check('Pipeline modules (9 steps)', () => {
133
+ return check('Pipeline modules (11)', () => {
116
134
  const modulesDir = path.join(targetDir, '.claude', 'skills', 'feature-adr', 'modules');
117
135
 
118
136
  if (!fileExists(modulesDir)) {
@@ -128,11 +146,13 @@ function checkModulesComplete(targetDir) {
128
146
  '01-requirements.md',
129
147
  '02-research.md',
130
148
  '03-adr.md',
149
+ '03.5-ideation-swarm.md',
131
150
  '04-ddd.md',
132
151
  '05-architecture.md',
133
152
  '06-implementation-plan.md',
134
153
  '07-code.md',
135
154
  '08-qe.md',
155
+ '09-fleet-qe.md',
136
156
  ];
137
157
 
138
158
  const missing = [];
@@ -145,7 +165,7 @@ function checkModulesComplete(targetDir) {
145
165
  if (missing.length === 0) {
146
166
  return {
147
167
  passed: true,
148
- detail: `All 9 pipeline modules present`,
168
+ detail: `All 11 pipeline modules present`,
149
169
  };
150
170
  }
151
171
 
@@ -5,7 +5,7 @@ const path = require('path');
5
5
  const {
6
6
  green, yellow, cyan, bold, dim,
7
7
  info, success, warn, error: logError, step,
8
- copyDirRecursive, copyDirFiltered, fileExists, readJSON,
8
+ fileExists, readJSON,
9
9
  ensureDir, getRelativePaths, getRelativePathsFiltered,
10
10
  createManifest, writeManifest, getTemplatesDir,
11
11
  COMPONENTS, OPTIONAL_COMPONENTS, MANIFEST_FILE, getComponentFilter,
@@ -27,7 +27,9 @@ function showKeysariumIntegration(keysariumManifest) {
27
27
  console.log('');
28
28
  console.log(cyan(' \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510'));
29
29
  console.log(cyan(' \u2502') + bold(' @dzhechkov/keysarium detected!') + ' ' + cyan('\u2502'));
30
- console.log(cyan(' \u2502') + ` Version: ${dim(keysariumManifest.version)}` + ' '.repeat(39 - keysariumManifest.version.length) + cyan('\u2502'));
30
+ const v = keysariumManifest.version;
31
+ const kv = typeof v === 'string' && v.length ? v : 'unknown';
32
+ console.log(cyan(' \u2502') + ` Version: ${dim(kv)}` + ' '.repeat(Math.max(0, 39 - kv.length)) + cyan('\u2502'));
31
33
  console.log(cyan(' \u2502') + ' Feature ADR integrates with existing Keysarium. ' + cyan('\u2502'));
32
34
  console.log(cyan(' \u2502') + ' Shared: .claude/commands, rules, skills ' + cyan('\u2502'));
33
35
  console.log(cyan(' \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518'));
@@ -38,34 +40,67 @@ function showKeysariumIntegration(keysariumManifest) {
38
40
  // Helpers
39
41
  // ---------------------------------------------------------------------------
40
42
 
41
- function installComponent(key, comp, templatesDir, targetDir) {
43
+ // Copies a component file-by-file with per-file overwrite protection:
44
+ // - destination missing -> write, record in `written`
45
+ // - destination exists + --force -> overwrite, record in `written`
46
+ // - destination exists, no --force -> do NOT write, record in `preserved`
47
+ // In dry-run mode nothing is written, but the same written/preserved
48
+ // classification is produced.
49
+ // Returns { missing, fileCount } where `missing` means the template source
50
+ // was absent on disk and `fileCount` is how many files the template provides.
51
+ function installComponent(key, comp, templatesDir, targetDir, opts) {
52
+ const { force, dryRun, written, preserved } = opts;
42
53
  const src = path.join(templatesDir, comp.src);
43
- const dest = path.join(targetDir, comp.src);
54
+ const destRoot = path.join(targetDir, comp.src);
44
55
 
45
56
  if (!fileExists(src)) {
46
57
  warn(`Template source not found: ${comp.src} \u2014 skipping.`);
47
- return [];
58
+ return { missing: true, fileCount: 0 };
48
59
  }
49
60
 
50
- // Single file copy (for isFile components)
61
+ // Build the file list from the TEMPLATE source, never a scan of the destination — otherwise
62
+ // user-created files inside a component dir get adopted into the manifest and a later
63
+ // `remove` deletes them (finding #8).
64
+ let entries;
51
65
  if (comp.isFile) {
52
- ensureDir(path.dirname(dest));
53
- fs.copyFileSync(src, dest);
54
- return [comp.src];
66
+ entries = [{ srcFile: src, destFile: destRoot, rel: comp.src }];
67
+ } else {
68
+ const filterFn = getComponentFilter(comp);
69
+ const rels = filterFn ? getRelativePathsFiltered(src, filterFn) : getRelativePaths(src);
70
+ entries = rels.map((rel) => ({
71
+ srcFile: path.join(src, rel),
72
+ destFile: path.join(destRoot, rel),
73
+ rel: path.join(comp.src, rel),
74
+ }));
55
75
  }
56
76
 
57
- const filterFn = getComponentFilter(comp);
58
-
59
- // Track files from the TEMPLATE source, never a scan of the destination — otherwise
60
- // user-created files inside a component dir get adopted into the manifest and a later
61
- // `remove` deletes them (finding #8). The filtered/plain copy still writes to dest.
62
- if (filterFn) {
63
- copyDirFiltered(src, dest, filterFn);
64
- return getRelativePathsFiltered(src, filterFn).map((rel) => path.join(comp.src, rel));
77
+ for (const entry of entries) {
78
+ if (fileExists(entry.destFile) && !force) {
79
+ preserved.push(entry.rel);
80
+ continue;
81
+ }
82
+ if (!dryRun) {
83
+ ensureDir(path.dirname(entry.destFile));
84
+ fs.copyFileSync(entry.srcFile, entry.destFile);
85
+ }
86
+ written.push(entry.rel);
65
87
  }
66
88
 
67
- copyDirRecursive(src, dest);
68
- return getRelativePaths(src).map((rel) => path.join(comp.src, rel));
89
+ return { missing: false, fileCount: entries.length };
90
+ }
91
+
92
+ // Print the block of pre-existing files that were (or would be) preserved
93
+ function printPreservedBlock(preserved, dryRun) {
94
+ console.log('');
95
+ const verb = dryRun ? 'would be preserved' : 'preserved';
96
+ warn(`${preserved.length} pre-existing file(s) ${verb} (not overwritten, not tracked by the manifest) — use --force to overwrite:`);
97
+ const MAX_SHOWN = 10;
98
+ for (const rel of preserved.slice(0, MAX_SHOWN)) {
99
+ console.log(` ${yellow('⚠')} ${rel}`);
100
+ }
101
+ if (preserved.length > MAX_SHOWN) {
102
+ console.log(dim(` …and ${preserved.length - MAX_SHOWN} more`));
103
+ }
69
104
  }
70
105
 
71
106
  // Resolve which optional component keys to install based on flags
@@ -151,40 +186,81 @@ async function run(options) {
151
186
 
152
187
  console.log('');
153
188
 
154
- if (dryRun) {
155
- warn('Dry run \u2014 no files were written.');
156
- process.exit(0);
157
- }
158
-
159
- // ── e) Install core components ─────────────────────────────────────────
189
+ // ── e) Install components (classify-only when --dry-run) ────────────────
160
190
  const totalComponents = componentKeys.length + optionalKeys.length;
161
- const installedFiles = [];
191
+ const installedFiles = []; // files written (or would-write in dry-run)
192
+ const preservedFiles = []; // pre-existing files NOT overwritten (no --force)
193
+ const completedKeys = []; // component keys processed so far (for partial manifest)
194
+ const installedOptionalKeys = [];
162
195
  let stepNum = 0;
196
+ const installVerb = dryRun ? 'Checking' : 'Installing';
197
+
198
+ try {
199
+ for (const key of componentKeys) {
200
+ stepNum++;
201
+ const comp = COMPONENTS[key];
202
+ step(stepNum, totalComponents, `${installVerb} ${comp.label}...`);
203
+
204
+ installComponent(key, comp, templatesDir, targetDir, {
205
+ force, dryRun, written: installedFiles, preserved: preservedFiles,
206
+ });
207
+ completedKeys.push(key);
208
+ }
163
209
 
164
- for (const key of componentKeys) {
165
- stepNum++;
166
- const comp = COMPONENTS[key];
167
- step(stepNum, totalComponents, `Installing ${comp.label}...`);
168
-
169
- const files = installComponent(key, comp, templatesDir, targetDir);
170
- installedFiles.push(...files);
210
+ // ── e2) Install optional components — track which actually installed ──
211
+ // Components whose template source is missing report missing=true;
212
+ // we must NOT record them in the manifest, otherwise `update` reports
213
+ // "Unknown component" on subsequent runs.
214
+ for (const key of optionalKeys) {
215
+ stepNum++;
216
+ const comp = OPTIONAL_COMPONENTS[key];
217
+ step(stepNum, totalComponents, `${installVerb} ${comp.label}...`);
218
+
219
+ const res = installComponent(key, comp, templatesDir, targetDir, {
220
+ force, dryRun, written: installedFiles, preserved: preservedFiles,
221
+ });
222
+ if (!res.missing && res.fileCount > 0) {
223
+ installedOptionalKeys.push(key);
224
+ completedKeys.push(key);
225
+ }
226
+ }
227
+ } catch (err) {
228
+ // ── Non-atomic install recovery: record what WAS written so far ───────
229
+ console.log('');
230
+ logError(`Install failed: ${err && err.message ? err.message : err}`);
231
+ if (!dryRun) {
232
+ try {
233
+ const failPkg = readJSON(path.resolve(__dirname, '../../package.json'));
234
+ const partialManifest = createManifest(
235
+ failPkg ? failPkg.version : '0.0.0',
236
+ completedKeys,
237
+ installedFiles.sort()
238
+ );
239
+ partialManifest.partial = true;
240
+ writeManifest(targetDir, partialManifest);
241
+ } catch (writeErr) {
242
+ logError(`Could not write partial manifest: ${writeErr && writeErr.message ? writeErr.message : writeErr}`);
243
+ }
244
+ info(`Partial install recorded in ${MANIFEST_FILE} — run \`npx @dzhechkov/skills-feature-adr remove\` to clean up, or fix the cause and re-run init.`);
245
+ }
246
+ process.exit(1);
171
247
  }
172
248
 
173
- // ── e2) Install optional components track which actually installed ──
174
- // Components whose template source is missing return [] from installComponent;
175
- // we must NOT record them in the manifest, otherwise `update` reports
176
- // "Unknown component" on subsequent runs.
177
- const installedOptionalKeys = [];
178
- for (const key of optionalKeys) {
179
- stepNum++;
180
- const comp = OPTIONAL_COMPONENTS[key];
181
- step(stepNum, totalComponents, `Installing ${comp.label}...`);
182
-
183
- const files = installComponent(key, comp, templatesDir, targetDir);
184
- if (files.length > 0) {
185
- installedFiles.push(...files);
186
- installedOptionalKeys.push(key);
249
+ // ── e3) Dry-run report: same would-write / would-preserve classification
250
+ if (dryRun) {
251
+ console.log('');
252
+ info(`Dry run: ${installedFiles.length} file(s) would be written, ${preservedFiles.length} pre-existing file(s) would be preserved.`);
253
+ if (preservedFiles.length > 0) {
254
+ printPreservedBlock(preservedFiles, true);
187
255
  }
256
+ console.log('');
257
+ warn('Dry run — no files were written.');
258
+ process.exit(0);
259
+ }
260
+
261
+ if (preservedFiles.length > 0) {
262
+ printPreservedBlock(preservedFiles, false);
263
+ console.log('');
188
264
  }
189
265
 
190
266
  // ── f) Write manifest ──────────────────────────────────────────────────
@@ -238,9 +314,9 @@ async function run(options) {
238
314
 
239
315
  console.log(bold('Complexity Tiers:'));
240
316
  console.log(` ${green('S')} ${dim('\u2014 1-3 files, ~15 min (Steps: 0\u21921\u21926\u21927\u21928)')}`);
241
- console.log(` ${yellow('M')} ${dim('\u2014 4-10 files, ~45 min (Steps: 0\u21921\u21923\u21925\u21926\u21927\u21928)')}`);
242
- console.log(` ${cyan('L')} ${dim('\u2014 11-30 files, ~2h (Full pipeline with parallelism)')}`);
243
- console.log(` ${bold('XL')} ${dim('\u2014 30+ files, ~4h+ (Full DAG + multi-agent swarm)')}`);
317
+ console.log(` ${yellow('M')} ${dim('\u2014 4-10 files, ~45 min (Steps: 0\u21921\u21923\u21923.5\u21925\u21926\u21927\u21928)')}`);
318
+ console.log(` ${cyan('L')} ${dim('\u2014 11-30 files, ~2h (Steps: 0\u21921\u21922\u21923\u21923.5\u21924\u21925\u21926\u21927\u21928\u21929)')}`);
319
+ console.log(` ${bold('XL')} ${dim('\u2014 30+ files, ~4h+ (Full DAG with parallelism + fleet QE)')}`);
244
320
  console.log('');
245
321
 
246
322
  if (!withLearning && !knowledgeExtractor && !keysariumDetected) {
@@ -43,8 +43,11 @@ function removeFile(filePath) {
43
43
 
44
44
  function removeEmptyDirs(dirPath, stopDir) {
45
45
  try {
46
- let current = dirPath;
47
- while (current !== stopDir && current !== path.dirname(current)) {
46
+ const root = path.resolve(stopDir);
47
+ let current = path.resolve(dirPath);
48
+
49
+ // Containment-based loop: never stat/rmdir/climb outside the project root
50
+ while (current !== root && current.startsWith(root + path.sep)) {
48
51
  if (!fileExists(current)) {
49
52
  current = path.dirname(current);
50
53
  continue;
@@ -66,12 +69,57 @@ function removeEmptyDirs(dirPath, stopDir) {
66
69
  }
67
70
  }
68
71
 
72
+ // Directories shared with @dzhechkov/keysarium — never delete their files
73
+ // when keysarium is installed in the same project.
74
+ const KEYSARIUM_SHARED_DIRS = [
75
+ ['.claude', 'skills', 'explore'],
76
+ ['.claude', 'skills', 'problem-solver-enhanced'],
77
+ ['.claude', 'skills', 'frontend-design'],
78
+ ];
79
+
80
+ function isKeysariumShared(relParts) {
81
+ return KEYSARIUM_SHARED_DIRS.some(
82
+ (dir) => relParts.length > dir.length && dir.every((seg, i) => relParts[i] === seg)
83
+ );
84
+ }
85
+
86
+ // Classify a manifest entry:
87
+ // 'outside' — resolves outside the project root (possible tampering)
88
+ // 'shared' — lives in a keysarium-shared directory (preserve)
89
+ // 'ok' — safe to remove
90
+ function classifyManifestEntry(relPath, resolvedTarget, keysariumDetected) {
91
+ const absPath = path.resolve(resolvedTarget, relPath);
92
+
93
+ if (absPath !== resolvedTarget && !absPath.startsWith(resolvedTarget + path.sep)) {
94
+ return { absPath, kind: 'outside' };
95
+ }
96
+
97
+ if (keysariumDetected) {
98
+ const relParts = path.relative(resolvedTarget, absPath).split(path.sep);
99
+ if (isKeysariumShared(relParts)) {
100
+ return { absPath, kind: 'shared' };
101
+ }
102
+ }
103
+
104
+ return { absPath, kind: 'ok' };
105
+ }
106
+
107
+ function printOutsideWarning(outsideEntries) {
108
+ console.log(
109
+ ` ${yellow('⚠')} ${outsideEntries.length} manifest entr${outsideEntries.length === 1 ? 'y' : 'ies'} outside the project ${outsideEntries.length === 1 ? 'was' : 'were'} skipped (possible tampering):`
110
+ );
111
+ for (const p of outsideEntries) {
112
+ console.log(` ${dim(p)}`);
113
+ }
114
+ }
115
+
69
116
  // ---------------------------------------------------------------------------
70
117
  // Main command
71
118
  // ---------------------------------------------------------------------------
72
119
 
73
120
  async function run(options) {
74
121
  const { force, dryRun, targetDir } = options;
122
+ const resolvedTarget = path.resolve(targetDir);
75
123
  const manifestPath = path.join(targetDir, MANIFEST_FILE);
76
124
 
77
125
  // ── a) Read manifest ──────────────────────────────────────────────────
@@ -105,20 +153,35 @@ async function run(options) {
105
153
  console.log('');
106
154
 
107
155
  const keysariumPath = path.join(targetDir, '.keysarium.json');
108
- if (fileExists(keysariumPath)) {
156
+ const keysariumDetected = fileExists(keysariumPath);
157
+ if (keysariumDetected) {
109
158
  info('@dzhechkov/keysarium detected \u2014 shared directories will be preserved.');
110
159
  }
111
160
 
112
161
  if (dryRun) {
113
162
  console.log(bold('Files to be removed:'));
163
+ const outsideDry = [];
114
164
  for (const relPath of files) {
115
- const absPath = path.join(targetDir, relPath);
165
+ const { absPath, kind } = classifyManifestEntry(relPath, resolvedTarget, keysariumDetected);
166
+ if (kind === 'outside') {
167
+ outsideDry.push(relPath);
168
+ console.log(` ${yellow('- OUT')} ${relPath}`);
169
+ continue;
170
+ }
171
+ if (kind === 'shared') {
172
+ console.log(` ${dim('- KEEP')} ${relPath} ${dim('(shared with Keysarium)')}`);
173
+ continue;
174
+ }
116
175
  const exists = fileExists(absPath);
117
176
  const marker = exists ? red('- DEL') : dim('- N/A');
118
177
  console.log(` ${marker} ${relPath}`);
119
178
  }
120
179
  console.log(` ${red('- DEL')} ${MANIFEST_FILE}`);
121
180
  console.log('');
181
+ if (outsideDry.length > 0) {
182
+ printOutsideWarning(outsideDry);
183
+ console.log('');
184
+ }
122
185
  warn('Dry run \u2014 no files were removed.');
123
186
  process.exit(0);
124
187
  }
@@ -138,11 +201,25 @@ async function run(options) {
138
201
  // ── d) Remove files ───────────────────────────────────────────────────
139
202
  let removedCount = 0;
140
203
  let skippedCount = 0;
204
+ let preservedCount = 0;
205
+ const outsideEntries = [];
141
206
  const dirsToCheck = new Set();
142
207
 
143
208
  for (let i = 0; i < files.length; i++) {
144
209
  const relPath = files[i];
145
- const absPath = path.join(targetDir, relPath);
210
+ const { absPath, kind } = classifyManifestEntry(relPath, resolvedTarget, keysariumDetected);
211
+
212
+ if (kind === 'outside') {
213
+ outsideEntries.push(relPath);
214
+ step(i + 1, files.length, `Skipping ${relPath} (outside project)`);
215
+ continue;
216
+ }
217
+
218
+ if (kind === 'shared') {
219
+ preservedCount++;
220
+ step(i + 1, files.length, `Preserving ${relPath} (shared with Keysarium)`);
221
+ continue;
222
+ }
146
223
 
147
224
  step(i + 1, files.length, `Removing ${relPath}`);
148
225
 
@@ -154,10 +231,12 @@ async function run(options) {
154
231
  }
155
232
  }
156
233
 
157
- // Remove empty directories (bottom-up), only feature-adr-exclusive ones
234
+ // Remove empty directories (bottom-up), only feature-adr-exclusive ones.
235
+ // dirsToCheck contains only parents of files actually deleted, so preserved
236
+ // (keysarium-shared) directories are never pruned.
158
237
  const sortedDirs = Array.from(dirsToCheck).sort((a, b) => b.length - a.length);
159
238
  for (const dir of sortedDirs) {
160
- removeEmptyDirs(dir, targetDir);
239
+ removeEmptyDirs(dir, resolvedTarget);
161
240
  }
162
241
 
163
242
  // ── e) Remove manifest ────────────────────────────────────────────────
@@ -168,9 +247,15 @@ async function run(options) {
168
247
  console.log('');
169
248
  success(bold('Feature ADR skill pack removal complete!'));
170
249
  console.log(` ${green('\u2713')} ${removedCount} file(s) removed`);
250
+ if (preservedCount > 0) {
251
+ console.log(` ${green('\u2713')} preserved (shared with Keysarium): ${preservedCount} file(s)`);
252
+ }
171
253
  if (skippedCount > 0) {
172
254
  console.log(` ${dim('-')} ${skippedCount} file(s) already missing (skipped)`);
173
255
  }
256
+ if (outsideEntries.length > 0) {
257
+ printOutsideWarning(outsideEntries);
258
+ }
174
259
  console.log('');
175
260
  info(`To reinstall, run: ${cyan('@dzhechkov/skills-feature-adr init')}`);
176
261
  console.log('');
@@ -16,7 +16,7 @@ const {
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
18
  async function run(options) {
19
- const { dryRun, targetDir } = options;
19
+ const { dryRun, force, targetDir } = options;
20
20
  const manifestPath = path.join(targetDir, MANIFEST_FILE);
21
21
 
22
22
  // ── a) Read existing manifest ─────────────────────────────────────────
@@ -53,6 +53,10 @@ async function run(options) {
53
53
  let totalModified = 0;
54
54
  let totalUnchanged = 0;
55
55
  const filesToCopy = [];
56
+ // Files the user has locally modified. These are markdown prompt files users
57
+ // are expected to tune, so by default we KEEP them (never clobber). Only
58
+ // --force overwrites them, and then only after writing a .bak sibling.
59
+ const keptModified = [];
56
60
 
57
61
  for (const key of installedKeys) {
58
62
  // Optional components (--with-learning / --knowledge-extractor) live in
@@ -73,6 +77,28 @@ async function run(options) {
73
77
  continue;
74
78
  }
75
79
 
80
+ // Single-file components (--with-learning / --knowledge-extractor extras like
81
+ // lib/memory-protocol.md): diffFiles/getRelativePaths readdir a DIRECTORY and
82
+ // cannot handle them \u2014 without this branch, a deleted learning file is never
83
+ // detected ("Everything is up to date!" while doctor flags it missing).
84
+ if (comp.isFile) {
85
+ if (!fileExists(destBase)) {
86
+ filesToCopy.push({ src: srcBase, dest: destBase, status: 'added', relPath: comp.src });
87
+ totalAdded++;
88
+ } else if (!fs.readFileSync(srcBase).equals(fs.readFileSync(destBase))) {
89
+ const entry = { src: srcBase, dest: destBase, status: 'modified', relPath: comp.src };
90
+ if (force) {
91
+ filesToCopy.push(entry);
92
+ totalModified++;
93
+ } else {
94
+ keptModified.push(entry);
95
+ }
96
+ } else {
97
+ totalUnchanged++;
98
+ }
99
+ continue;
100
+ }
101
+
76
102
  const filterFn = getComponentFilter(comp);
77
103
  const diff = diffFiles(srcBase, destBase, filterFn);
78
104
 
@@ -87,13 +113,18 @@ async function run(options) {
87
113
  }
88
114
 
89
115
  for (const rel of diff.modified) {
90
- filesToCopy.push({
116
+ const entry = {
91
117
  src: path.join(srcBase, rel),
92
118
  dest: path.join(destBase, rel),
93
119
  status: 'modified',
94
120
  relPath: path.join(comp.src, rel),
95
- });
96
- totalModified++;
121
+ };
122
+ if (force) {
123
+ filesToCopy.push(entry);
124
+ totalModified++;
125
+ } else {
126
+ keptModified.push(entry);
127
+ }
97
128
  }
98
129
 
99
130
  totalUnchanged += diff.unchanged.length;
@@ -103,11 +134,26 @@ async function run(options) {
103
134
  info(bold('Update summary:'));
104
135
  console.log(` ${green('+')} ${totalAdded} file(s) to add`);
105
136
  console.log(` ${yellow('~')} ${totalModified} file(s) to update`);
137
+ if (keptModified.length > 0) {
138
+ console.log(` ${yellow('\u26a0')} ${keptModified.length} file(s) locally modified, kept`);
139
+ }
106
140
  console.log(` ${dim('=')} ${totalUnchanged} file(s) unchanged`);
107
141
  console.log('');
108
142
 
143
+ if (keptModified.length > 0) {
144
+ console.log(yellow('\u26a0') + ' ' + bold('locally modified, kept (use --force to overwrite):'));
145
+ for (const f of keptModified) {
146
+ console.log(` ${yellow('~ KEEP')} ${f.relPath}`);
147
+ }
148
+ console.log('');
149
+ }
150
+
109
151
  if (totalAdded === 0 && totalModified === 0) {
110
- success('Everything is up to date!');
152
+ if (keptModified.length > 0) {
153
+ success('No files to update \u2014 locally modified file(s) kept.');
154
+ } else {
155
+ success('Everything is up to date!');
156
+ }
111
157
  process.exit(0);
112
158
  }
113
159
 
@@ -115,7 +161,8 @@ async function run(options) {
115
161
  console.log(bold('Files to be changed:'));
116
162
  for (const f of filesToCopy) {
117
163
  const marker = f.status === 'added' ? green('+ ADD') : yellow('~ MOD');
118
- console.log(` ${marker} ${f.relPath}`);
164
+ const note = f.status === 'modified' ? dim(' (will back up to .bak)') : '';
165
+ console.log(` ${marker} ${f.relPath}${note}`);
119
166
  }
120
167
  console.log('');
121
168
  warn('Dry run \u2014 no files were written.');
@@ -129,11 +176,18 @@ async function run(options) {
129
176
  step(i + 1, filesToCopy.length, `${label} ${f.relPath}`);
130
177
 
131
178
  ensureDir(path.dirname(f.dest));
179
+ // --force on a locally modified file: preserve the user's version as a
180
+ // sibling .bak before overwriting.
181
+ if (f.status === 'modified' && fileExists(f.dest)) {
182
+ fs.copyFileSync(f.dest, `${f.dest}.bak`);
183
+ console.log(` ${dim(`backed up to ${f.relPath}.bak`)}`);
184
+ }
132
185
  fs.copyFileSync(f.src, f.dest);
133
186
  }
134
187
 
135
188
  // ── e) Update manifest ────────────────────────────────────────────────
136
189
  const allFiles = [];
190
+ const prevFiles = Array.isArray(manifest.files) ? manifest.files : [];
137
191
  for (const key of installedKeys) {
138
192
  const comp = COMPONENTS[key] || OPTIONAL_COMPONENTS[key];
139
193
  if (!comp) continue;
@@ -142,12 +196,31 @@ async function run(options) {
142
196
  const destPath = path.join(targetDir, comp.src);
143
197
  const filterFn = getComponentFilter(comp);
144
198
 
145
- // Record from the TEMPLATE source, not a dest scan, so user files aren't adopted.
146
- const scanBase = fileExists(srcPath) ? srcPath : destPath;
199
+ // Record from the TEMPLATE source only NEVER scan destPath, or user-owned
200
+ // files get adopted into manifest.files (and a later remove would delete
201
+ // them). If the template source is missing, keep the previous manifest
202
+ // entries for this component verbatim.
203
+ if (!fileExists(srcPath)) {
204
+ const kept = prevFiles.filter(
205
+ (rel) => rel === comp.src || rel.startsWith(comp.src + path.sep)
206
+ );
207
+ allFiles.push(...kept);
208
+ warn(`component '${key}' not found in current templates — manifest entries kept as-is`);
209
+ continue;
210
+ }
211
+
212
+ // Single-file components: record the file itself. getRelativePaths() would
213
+ // readdir it — without this branch the 3 learning files silently drop out of
214
+ // the manifest on every rebuild, and a later `remove` strands them.
215
+ if (comp.isFile) {
216
+ if (fileExists(destPath)) allFiles.push(comp.src);
217
+ continue;
218
+ }
219
+
147
220
  if (fileExists(destPath)) {
148
221
  const paths = filterFn
149
- ? getRelativePathsFiltered(scanBase, filterFn)
150
- : getRelativePaths(scanBase);
222
+ ? getRelativePathsFiltered(srcPath, filterFn)
223
+ : getRelativePaths(srcPath);
151
224
  allFiles.push(...paths.map((rel) => path.join(comp.src, rel)));
152
225
  }
153
226
  }
@@ -164,6 +237,9 @@ async function run(options) {
164
237
  success(bold('Update complete!'));
165
238
  console.log(` ${green('+')} ${totalAdded} file(s) added`);
166
239
  console.log(` ${yellow('~')} ${totalModified} file(s) updated`);
240
+ if (keptModified.length > 0) {
241
+ console.log(` ${yellow('⚠')} ${keptModified.length} file(s) locally modified, kept`);
242
+ }
167
243
  console.log(` ${dim('=')} ${totalUnchanged} file(s) unchanged`);
168
244
  console.log('');
169
245