@dzhechkov/skills-feature-adr 1.3.20 → 1.3.24

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/CHANGELOG.md CHANGED
@@ -1,10 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ### Follow-up (QE LOW gaps closed before publish)
4
+ - **Upstream deletions**: `update` now removes files the template dropped (manifest-tracked orphans only; user-created untracked files are never touched) — previously `diff.missing` was computed but ignored. Shown in the summary + `--dry-run` (`- DEL`). Tests: Case G/H.
5
+ - Removed a dead `unchanged` branch in the directory update path (unreachable — `diff.modified` guarantees bytes differ).
6
+ - `safeHashFile` guards the TOCTOU window (file vanishing mid-update → skip+warn, not a crash).
7
+
8
+
3
9
  All notable changes to `@dzhechkov/skills-feature-adr` are documented here.
4
10
 
5
11
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
12
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
13
 
14
+ ## [1.3.23] - 2026-07-06
15
+
16
+ ### Fixed
17
+
18
+ - **`update` no longer silently drops upstream changes (false "locally modified").** The manifest previously stored installed files as an array of paths only, so `update` could only do a 2-way content compare between the new template and the currently-installed file. A template file that evolved upstream but that the user never edited (`src != dest`) was wrongly classified `modified` and kept — the upstream update was silently not applied. `update` now performs a true **three-way merge** (baseline / mine / theirs): an evolved-but-unedited file **is updated**, and a genuinely user-edited file **is kept** with a now-*true* "locally modified, kept" warning.
19
+
20
+ ### Added
21
+
22
+ - **Per-file SHA-256 baseline in the manifest.** `init` records a new additive `hashes` map (`{ "<relpath>": "<sha256 of the template bytes as installed>" }`) alongside the existing `files` array. `files` stays an array, so `remove` / `doctor` / `list` are unchanged. `update` reads these baselines to classify each file three-way and rewrites them to the new template's hashes afterward (idempotent re-runs).
23
+ - **`node:test` suite** (`test/`) with a `"test"` script (`node --test`), zero new dependencies. Covers the load-bearing truth table and the two inverse-error boundaries (evolved-unedited → updated; user-edited → kept), plus legacy fallback, `--force`/`.bak`, self-heal, back-compat, and idempotence.
24
+
25
+ ### Notes
26
+
27
+ - **Backward compatible.** A manifest from any prior version (no `hashes` key) is treated as "legacy — no baseline": every differing file falls back to today's conservative keep+warn (no clobber). The first `update` writes a fresh `hashes` map (self-heal), so true 3-way is available from the next run. A pre-fix install whose upgrade only *modifies* existing files may need one `update` (to self-heal the baseline) or a `--force` before evolved-but-unedited files auto-apply. Old CLI versions ignore the additive `hashes` key, so a downgrade stays safe.
28
+
8
29
  ## [1.3.13] - 2026-07-03
9
30
 
10
31
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/skills-feature-adr",
3
- "version": "1.3.20",
3
+ "version": "1.3.24",
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
5
  "bin": {
6
6
  "skills-feature-adr": "./bin/cli.js"
@@ -58,5 +58,7 @@
58
58
  "publishConfig": {
59
59
  "access": "public"
60
60
  },
61
- "scripts": {}
61
+ "scripts": {
62
+ "test": "node --test \"test/**/*.test.js\""
63
+ }
62
64
  }
@@ -8,7 +8,7 @@ const {
8
8
  fileExists, readJSON,
9
9
  ensureDir, getRelativePaths, getRelativePathsFiltered,
10
10
  createManifest, readManifest, writeManifest, getTemplatesDir,
11
- toManifestPath, fromManifestPath,
11
+ toManifestPath, fromManifestPath, hashFile,
12
12
  COMPONENTS, OPTIONAL_COMPONENTS, MANIFEST_FILE, getComponentFilter,
13
13
  } = require('../utils');
14
14
 
@@ -61,7 +61,7 @@ function showKeysariumIntegration(keysariumManifest) {
61
61
  // Returns { missing, fileCount } where `missing` means the template source
62
62
  // was absent on disk and `fileCount` is how many files the template provides.
63
63
  function installComponent(key, comp, templatesDir, targetDir, opts) {
64
- const { force, dryRun, written, preserved } = opts;
64
+ const { force, dryRun, written, preserved, hashes } = opts;
65
65
  const src = path.join(templatesDir, comp.src);
66
66
  const destRoot = path.join(targetDir, comp.src);
67
67
 
@@ -96,6 +96,13 @@ function installComponent(key, comp, templatesDir, targetDir, opts) {
96
96
  if (!dryRun) {
97
97
  ensureDir(path.dirname(entry.destFile));
98
98
  fs.copyFileSync(entry.srcFile, entry.destFile);
99
+ // Record the SHA-256 of the TEMPLATE bytes we just installed (not the
100
+ // dest) as this file's baseline. This makes baseline == mine immediately
101
+ // after init — the invariant the 3-way `update` decision relies on.
102
+ // Preserved (pre-existing, not written by us) files get NO baseline: we
103
+ // did not install those bytes, so they fall through to update's
104
+ // conservative "no baseline → legacy" path. dry-run computes none.
105
+ if (hashes) hashes[entry.rel] = hashFile(entry.srcFile);
99
106
  }
100
107
  written.push(entry.rel);
101
108
  }
@@ -224,6 +231,7 @@ async function run(options) {
224
231
  // ── e) Install components (classify-only when --dry-run) ────────────────
225
232
  const totalComponents = componentKeys.length + optionalKeys.length;
226
233
  const installedFiles = []; // files written (or would-write in dry-run)
234
+ const installedHashes = {}; // rel -> sha256 of the TEMPLATE bytes installed (baseline)
227
235
  const preservedFiles = []; // pre-existing files NOT overwritten (no --force)
228
236
  const completedKeys = []; // component keys processed so far (for partial manifest)
229
237
  const installedOptionalKeys = [];
@@ -238,6 +246,7 @@ async function run(options) {
238
246
 
239
247
  installComponent(key, comp, templatesDir, targetDir, {
240
248
  force, dryRun, written: installedFiles, preserved: preservedFiles,
249
+ hashes: installedHashes,
241
250
  });
242
251
  completedKeys.push(key);
243
252
  }
@@ -253,6 +262,7 @@ async function run(options) {
253
262
 
254
263
  const res = installComponent(key, comp, templatesDir, targetDir, {
255
264
  force, dryRun, written: installedFiles, preserved: preservedFiles,
265
+ hashes: installedHashes,
256
266
  });
257
267
  if (!res.missing && res.fileCount > 0) {
258
268
  installedOptionalKeys.push(key);
@@ -269,7 +279,8 @@ async function run(options) {
269
279
  const partialManifest = createManifest(
270
280
  failPkg ? failPkg.version : '0.0.0',
271
281
  completedKeys,
272
- installedFiles.sort()
282
+ installedFiles.sort(),
283
+ installedHashes
273
284
  );
274
285
  partialManifest.partial = true;
275
286
  writeManifest(targetDir, partialManifest);
@@ -319,7 +330,7 @@ async function run(options) {
319
330
 
320
331
  // Only manifest components whose templates were actually present on disk
321
332
  const allKeys = [...componentKeys, ...installedOptionalKeys];
322
- const manifest = createManifest(version, allKeys, installedFiles.sort());
333
+ const manifest = createManifest(version, allKeys, installedFiles.sort(), installedHashes);
323
334
 
324
335
  // Track optional features in manifest based on actual installs
325
336
  manifest.optional = {
@@ -3,12 +3,12 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const {
6
- green, yellow, cyan, bold, dim,
6
+ green, red, yellow, cyan, bold, dim,
7
7
  info, success, warn, error: logError, step,
8
8
  copyDirRecursive, copyDirFiltered, fileExists, readJSON,
9
9
  ensureDir, getRelativePaths, getRelativePathsFiltered, diffFiles,
10
10
  readManifest, writeManifest, getTemplatesDir,
11
- toManifestPath,
11
+ toManifestPath, fromManifestPath, hashFile, safeHashFile, classifyThreeWay,
12
12
  COMPONENTS, OPTIONAL_COMPONENTS, MANIFEST_FILE, getComponentFilter,
13
13
  } = require('../utils');
14
14
 
@@ -50,6 +50,10 @@ async function run(options) {
50
50
  console.log('');
51
51
 
52
52
  // ── c) Diff each component ────────────────────────────────────────────
53
+ // Per-file SHA-256 baseline hashes written at init/last-update (may be absent on a
54
+ // legacy array-only manifest → null → every differing file is treated as
55
+ // "no baseline → legacy" and gets the conservative 2-way keep+warn).
56
+ const manifestHashes = manifest.hashes || null;
53
57
  let totalAdded = 0;
54
58
  let totalModified = 0;
55
59
  let totalUnchanged = 0;
@@ -86,16 +90,30 @@ async function run(options) {
86
90
  if (!fileExists(destBase)) {
87
91
  filesToCopy.push({ src: srcBase, dest: destBase, status: 'added', relPath: comp.src });
88
92
  totalAdded++;
89
- } else if (!fs.readFileSync(srcBase).equals(fs.readFileSync(destBase))) {
90
- const entry = { src: srcBase, dest: destBase, status: 'modified', relPath: comp.src };
91
- if (force) {
92
- filesToCopy.push(entry);
93
- totalModified++;
93
+ } else {
94
+ const mine = safeHashFile(destBase);
95
+ if (mine === null) {
96
+ warn(`Skipped ${comp.src} — could not read the installed file (removed mid-update?).`);
97
+ continue;
98
+ }
99
+ const theirs = hashFile(srcBase);
100
+ if (mine === theirs) {
101
+ totalUnchanged++;
94
102
  } else {
95
- keptModified.push(entry);
103
+ const posix = toManifestPath(comp.src);
104
+ const baseline = manifestHashes ? manifestHashes[posix] : undefined;
105
+ const verdict = classifyThreeWay({ baseline, mine, theirs });
106
+ const entry = { src: srcBase, dest: destBase, status: 'modified', relPath: comp.src };
107
+ // 'update' → user did NOT edit, upstream evolved → APPLY (the fix).
108
+ // 'keep'/'legacy' → keep + warn (true "locally modified"); --force
109
+ // still overwrites either after a .bak.
110
+ if (verdict === 'update' || force) {
111
+ filesToCopy.push(entry);
112
+ totalModified++;
113
+ } else {
114
+ keptModified.push(entry);
115
+ }
96
116
  }
97
- } else {
98
- totalUnchanged++;
99
117
  }
100
118
  continue;
101
119
  }
@@ -113,17 +131,38 @@ async function run(options) {
113
131
  totalAdded++;
114
132
  }
115
133
 
134
+ // diff.modified = "src bytes != dest bytes" only. That collapses two very
135
+ // different cases: (a) the user edited the file, (b) the template evolved
136
+ // upstream and the user never touched it. The per-file baseline lets us
137
+ // tell them apart via a true 3-way compare instead of blindly keeping both.
116
138
  for (const rel of diff.modified) {
117
- const entry = {
118
- src: path.join(srcBase, rel),
119
- dest: path.join(destBase, rel),
120
- status: 'modified',
121
- relPath: path.join(comp.src, rel),
122
- };
123
- if (force) {
139
+ const srcFile = path.join(srcBase, rel);
140
+ const destFile = path.join(destBase, rel);
141
+ const relPath = path.join(comp.src, rel);
142
+ const posix = toManifestPath(relPath);
143
+ const baseline = manifestHashes ? manifestHashes[posix] : undefined;
144
+ const mine = safeHashFile(destFile); // current installed bytes (null if vanished mid-update)
145
+ if (mine === null) {
146
+ warn(`Skipped ${relPath} — could not read the installed file (removed mid-update?).`);
147
+ continue;
148
+ }
149
+ const theirs = hashFile(srcFile); // new template bytes
150
+ const verdict = classifyThreeWay({ baseline, mine, theirs });
151
+ const entry = { src: srcFile, dest: destFile, status: 'modified', relPath };
152
+
153
+ // NB: diff.modified guarantees the bytes already differ, so mine !== theirs
154
+ // here and classifyThreeWay never returns 'unchanged' on this path — the
155
+ // three reachable verdicts are 'update', 'keep', 'legacy'.
156
+ if (verdict === 'update' || force) {
157
+ // 'update' → baseline == mine → user did NOT edit → apply upstream (the bug
158
+ // fix: previously this was silently reported "locally modified, kept").
159
+ // force → overwrite a genuine edit too, after a .bak (copy-loop below).
124
160
  filesToCopy.push(entry);
125
161
  totalModified++;
126
162
  } else {
163
+ // 'keep' → baseline != mine AND theirs != mine → genuine local edit
164
+ // (now a TRUE "locally modified, kept").
165
+ // 'legacy'→ no baseline → conservative fallback (differs ⇒ keep+warn).
127
166
  keptModified.push(entry);
128
167
  }
129
168
  }
@@ -131,10 +170,95 @@ async function run(options) {
131
170
  totalUnchanged += diff.unchanged.length;
132
171
  }
133
172
 
173
+ // Re-derive the manifest's `files` array and the per-file `hashes` baseline
174
+ // from the CURRENT template set. `hashes[rel]` is set to the NEW template's
175
+ // bytes (D4): uniform "baseline = theirs" makes the next `update` idempotent
176
+ // (an applied file becomes 'unchanged', a kept edit stays 'keep', a legacy
177
+ // file self-heals into the 3-way regime — all with zero further writes) and a
178
+ // file whose template no longer exists keeps whatever baseline it had. Records
179
+ // from the TEMPLATE source only — NEVER scans destPath, or user-owned files
180
+ // get adopted into manifest.files (and a later remove would delete them).
181
+ function rebuildManifestData() {
182
+ const allFiles = [];
183
+ const newHashes = {};
184
+ const oldHashes = manifest.hashes || {};
185
+ const prevFiles = Array.isArray(manifest.files) ? manifest.files : [];
186
+ for (const key of installedKeys) {
187
+ const comp = COMPONENTS[key] || OPTIONAL_COMPONENTS[key];
188
+ if (!comp) continue;
189
+
190
+ const srcPath = path.join(templatesDir, comp.src);
191
+ const destPath = path.join(targetDir, comp.src);
192
+ const filterFn = getComponentFilter(comp);
193
+
194
+ // Template source missing → keep the previous manifest entries verbatim.
195
+ if (!fileExists(srcPath)) {
196
+ // Compare in POSIX form so old manifests with native (backslash)
197
+ // separators still match; re-store the kept entries POSIX-normalized.
198
+ const compPosix = toManifestPath(comp.src);
199
+ const kept = prevFiles.filter((rel) => {
200
+ const relPosix = toManifestPath(rel);
201
+ return relPosix === compPosix || relPosix.startsWith(compPosix + '/');
202
+ });
203
+ for (const rel of kept) {
204
+ const relPosix = toManifestPath(rel);
205
+ allFiles.push(relPosix);
206
+ // No template to re-hash — preserve the prior baseline if we had one.
207
+ if (oldHashes[relPosix] != null) newHashes[relPosix] = oldHashes[relPosix];
208
+ }
209
+ warn(`component '${key}' not found in current templates — manifest entries kept as-is`);
210
+ continue;
211
+ }
212
+
213
+ // Single-file components: record the file itself. getRelativePaths() would
214
+ // readdir it — without this branch the 3 learning files silently drop out
215
+ // of the manifest on every rebuild, and a later `remove` strands them.
216
+ if (comp.isFile) {
217
+ if (fileExists(destPath)) {
218
+ const posix = toManifestPath(comp.src);
219
+ allFiles.push(posix);
220
+ newHashes[posix] = hashFile(srcPath); // theirs = new template bytes
221
+ }
222
+ continue;
223
+ }
224
+
225
+ if (fileExists(destPath)) {
226
+ const paths = filterFn
227
+ ? getRelativePathsFiltered(srcPath, filterFn)
228
+ : getRelativePaths(srcPath);
229
+ for (const rel of paths) {
230
+ const posix = toManifestPath(path.join(comp.src, rel));
231
+ allFiles.push(posix);
232
+ newHashes[posix] = hashFile(path.join(srcPath, rel)); // theirs
233
+ }
234
+ }
235
+ }
236
+ return { allFiles, newHashes };
237
+ }
238
+
239
+ // ── Upstream DELETIONS ────────────────────────────────────────────────
240
+ // Files this install previously tracked (manifest.files) that the CURRENT
241
+ // template no longer provides. They were ours — the old manifest says so — so a
242
+ // proper update removes them instead of letting them linger (diffFiles computed
243
+ // `missing` but the old update never consumed it). User-created files are NEVER
244
+ // in the manifest, so they can never be orphaned here. `rebuildManifestData`'s
245
+ // allFiles is the authoritative NEW set (and already re-keeps a whole component
246
+ // whose template dir went missing), so old − new = true upstream deletions.
247
+ const newFileSet = new Set(rebuildManifestData().allFiles.map(toManifestPath));
248
+ const prevManifestFiles = Array.isArray(manifest.files) ? manifest.files : [];
249
+ const orphans = prevManifestFiles
250
+ .map(toManifestPath)
251
+ .filter((rel, i, a) => a.indexOf(rel) === i) // dedup
252
+ .filter((rel) => !newFileSet.has(rel))
253
+ .filter((rel) => fileExists(path.join(targetDir, fromManifestPath(rel))));
254
+
134
255
  // ── Show diff summary ─────────────────────────────────────────────────
135
256
  info(bold('Update summary:'));
136
257
  console.log(` ${green('+')} ${totalAdded} file(s) to add`);
137
258
  console.log(` ${yellow('~')} ${totalModified} file(s) to update`);
259
+ if (orphans.length > 0) {
260
+ console.log(` ${red('-')} ${orphans.length} file(s) to remove (dropped from template)`);
261
+ }
138
262
  if (keptModified.length > 0) {
139
263
  console.log(` ${yellow('\u26a0')} ${keptModified.length} file(s) locally modified, kept`);
140
264
  }
@@ -149,8 +273,21 @@ async function run(options) {
149
273
  console.log('');
150
274
  }
151
275
 
152
- if (totalAdded === 0 && totalModified === 0) {
276
+ if (totalAdded === 0 && totalModified === 0 && orphans.length === 0) {
153
277
  if (keptModified.length > 0) {
278
+ // Nothing to copy, but files were kept. Refresh the per-file baseline so a
279
+ // legacy (no-hashes) install self-heals into the 3-way regime and a kept
280
+ // conflict records the current template as its baseline (D3/D4). We do NOT
281
+ // bump the version \u2014 no template bytes were actually applied. Never write
282
+ // under --dry-run.
283
+ if (!dryRun) {
284
+ const { newHashes } = rebuildManifestData();
285
+ if (Object.keys(newHashes).length > 0) {
286
+ manifest.hashes = newHashes;
287
+ manifest.updatedAt = new Date().toISOString();
288
+ writeManifest(targetDir, manifest);
289
+ }
290
+ }
154
291
  success('No files to update \u2014 locally modified file(s) kept.');
155
292
  } else {
156
293
  success('Everything is up to date!');
@@ -165,6 +302,9 @@ async function run(options) {
165
302
  const note = f.status === 'modified' ? dim(' (will back up to .bak)') : '';
166
303
  console.log(` ${marker} ${f.relPath}${note}`);
167
304
  }
305
+ for (const rel of orphans) {
306
+ console.log(` ${red('- DEL')} ${rel} ${dim('(dropped from template)')}`);
307
+ }
168
308
  console.log('');
169
309
  warn('Dry run \u2014 no files were written.');
170
310
  process.exit(0);
@@ -186,53 +326,30 @@ async function run(options) {
186
326
  fs.copyFileSync(f.src, f.dest);
187
327
  }
188
328
 
189
- // ── e) Update manifest ────────────────────────────────────────────────
190
- const allFiles = [];
191
- const prevFiles = Array.isArray(manifest.files) ? manifest.files : [];
192
- for (const key of installedKeys) {
193
- const comp = COMPONENTS[key] || OPTIONAL_COMPONENTS[key];
194
- if (!comp) continue;
195
-
196
- const srcPath = path.join(templatesDir, comp.src);
197
- const destPath = path.join(targetDir, comp.src);
198
- const filterFn = getComponentFilter(comp);
199
-
200
- // Record from the TEMPLATE source only — NEVER scan destPath, or user-owned
201
- // files get adopted into manifest.files (and a later remove would delete
202
- // them). If the template source is missing, keep the previous manifest
203
- // entries for this component verbatim.
204
- if (!fileExists(srcPath)) {
205
- // Compare in POSIX form so old manifests with native (backslash)
206
- // separators still match; re-store the kept entries POSIX-normalized.
207
- const compPosix = toManifestPath(comp.src);
208
- const kept = prevFiles.filter((rel) => {
209
- const relPosix = toManifestPath(rel);
210
- return relPosix === compPosix || relPosix.startsWith(compPosix + '/');
211
- });
212
- allFiles.push(...kept.map(toManifestPath));
213
- warn(`component '${key}' not found in current templates — manifest entries kept as-is`);
214
- continue;
215
- }
216
-
217
- // Single-file components: record the file itself. getRelativePaths() would
218
- // readdir it — without this branch the 3 learning files silently drop out of
219
- // the manifest on every rebuild, and a later `remove` strands them.
220
- if (comp.isFile) {
221
- if (fileExists(destPath)) allFiles.push(toManifestPath(comp.src));
222
- continue;
223
- }
224
-
225
- if (fileExists(destPath)) {
226
- const paths = filterFn
227
- ? getRelativePathsFiltered(srcPath, filterFn)
228
- : getRelativePaths(srcPath);
229
- allFiles.push(...paths.map((rel) => toManifestPath(path.join(comp.src, rel))));
329
+ // ── d2) Remove upstream-dropped files (manifest-tracked orphans) ──────
330
+ let removed = 0;
331
+ for (const rel of orphans) {
332
+ const abs = path.join(targetDir, fromManifestPath(rel));
333
+ try {
334
+ fs.rmSync(abs);
335
+ console.log(` ${red('- removed')} ${rel} ${dim('(dropped from template)')}`);
336
+ removed++;
337
+ } catch {
338
+ warn(`could not remove ${rel} (already gone?) — skipped`);
230
339
  }
231
340
  }
232
341
 
342
+ // ── e) Update manifest ────────────────────────────────────────────────
343
+ const { allFiles, newHashes } = rebuildManifestData();
344
+
233
345
  manifest.version = newVersion;
234
346
  manifest.updatedAt = new Date().toISOString();
235
347
  manifest.files = allFiles.sort();
348
+ // Additive: only attach `hashes` when we actually have baseline hashes, so a
349
+ // no-op/empty rebuild never introduces an empty key.
350
+ if (Object.keys(newHashes).length > 0) {
351
+ manifest.hashes = newHashes;
352
+ }
236
353
 
237
354
  writeManifest(targetDir, manifest);
238
355
  info(`Updated ${MANIFEST_FILE} manifest`);
@@ -242,6 +359,9 @@ async function run(options) {
242
359
  success(bold('Update complete!'));
243
360
  console.log(` ${green('+')} ${totalAdded} file(s) added`);
244
361
  console.log(` ${yellow('~')} ${totalModified} file(s) updated`);
362
+ if (removed > 0) {
363
+ console.log(` ${red('-')} ${removed} file(s) removed (dropped from template)`);
364
+ }
245
365
  if (keptModified.length > 0) {
246
366
  console.log(` ${yellow('⚠')} ${keptModified.length} file(s) locally modified, kept`);
247
367
  }
package/src/utils.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
5
6
 
6
7
  // ===========================================================================
7
8
  // Colors — ANSI escape codes (zero dependencies)
@@ -202,6 +203,52 @@ function diffFiles(srcDir, destDir, filterFn) {
202
203
  return { added, modified, unchanged, missing };
203
204
  }
204
205
 
206
+ // ===========================================================================
207
+ // Content hashing — SHA-256 baseline for the 3-way update decision
208
+ //
209
+ // The manifest stores, per file, the SHA-256 of the TEMPLATE bytes that were
210
+ // installed at init time. `update` then knows three inputs — baseline (what
211
+ // init wrote), mine (current install), theirs (new template) — and can tell a
212
+ // user edit apart from an upstream evolution instead of collapsing both to
213
+ // "src != dest". Pure Node built-in `crypto`; zero dependencies.
214
+ // ===========================================================================
215
+
216
+ // Hash an arbitrary Buffer/string. Used by tests and by hashFile.
217
+ function hashBytes(buf) {
218
+ return crypto.createHash('sha256').update(buf).digest('hex');
219
+ }
220
+
221
+ // Hash a file's bytes on disk. Synchronous, matching the rest of this module.
222
+ function hashFile(absPath) {
223
+ return hashBytes(fs.readFileSync(absPath));
224
+ }
225
+
226
+ // Best-effort hashFile: returns null if the file cannot be read (e.g. it vanished
227
+ // between the diff scan and the re-hash — a TOCTOU window in `update`). Callers
228
+ // treat a null hash as "can't classify this file" and skip it with a warning
229
+ // instead of crashing the whole update with an uncaught stack trace.
230
+ function safeHashFile(absPath) {
231
+ try {
232
+ return hashFile(absPath);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ // PURE three-way classifier — the whole truth table, no I/O, unit-testable.
239
+ // baseline == null (or absent) → 'legacy' → caller falls back to the
240
+ // conservative 2-way keep+warn
241
+ // mine === theirs → 'unchanged' → already matches upstream
242
+ // baseline === mine → 'update' → user did NOT edit → take theirs
243
+ // else → 'keep' → user edited AND upstream moved
244
+ // → true "locally modified, kept"
245
+ function classifyThreeWay({ baseline, mine, theirs }) {
246
+ if (baseline == null) return 'legacy';
247
+ if (mine === theirs) return 'unchanged';
248
+ if (baseline === mine) return 'update';
249
+ return 'keep';
250
+ }
251
+
205
252
  // ===========================================================================
206
253
  // Manifest — .skills-feature-adr.json management
207
254
  // ===========================================================================
@@ -230,13 +277,20 @@ function writeManifest(targetDir, data) {
230
277
  writeJSON(path.join(targetDir, MANIFEST_FILE), data);
231
278
  }
232
279
 
233
- function createManifest(version, components, files) {
234
- return {
280
+ function createManifest(version, components, files, hashes) {
281
+ const manifest = {
235
282
  version: version,
236
283
  installedAt: new Date().toISOString(),
237
284
  components: components,
238
285
  files: files,
239
286
  };
287
+ // `hashes` is additive and optional: include it only when a non-empty map is
288
+ // supplied so legacy/partial callers (3-arg) stay byte-for-byte compatible
289
+ // and old manifests keep their array-only shape.
290
+ if (hashes && Object.keys(hashes).length > 0) {
291
+ manifest.hashes = hashes;
292
+ }
293
+ return manifest;
240
294
  }
241
295
 
242
296
  // ===========================================================================
@@ -297,6 +351,11 @@ const COMPONENTS = {
297
351
  group: 'core',
298
352
  filter: 'feature-adr',
299
353
  },
354
+ workflows: {
355
+ src: '.claude/workflows',
356
+ label: 'Canonical feature-adr pipeline (ultracode workflow form)',
357
+ group: 'core',
358
+ },
300
359
  };
301
360
 
302
361
  // Optional components — installed only with explicit flags
@@ -381,6 +440,12 @@ module.exports = {
381
440
  getRelativePathsFiltered,
382
441
  diffFiles,
383
442
 
443
+ // Content hashing / 3-way classifier
444
+ hashBytes,
445
+ hashFile,
446
+ safeHashFile,
447
+ classifyThreeWay,
448
+
384
449
  // Manifest
385
450
  MANIFEST_FILE,
386
451
  toManifestPath,
@@ -0,0 +1,48 @@
1
+ # Ultracode → the canonical feature-adr pipeline (ALWAYS)
2
+
3
+ ## Rule
4
+
5
+ When **ultracode is on** AND the task is a **feature implementation** (new capability, adapter,
6
+ command, skill, or a non-trivial change), you MUST run it through the **canonical feature-adr
7
+ pipeline**, not an ad-hoc Workflow authored from scratch:
8
+
9
+ ```
10
+ Workflow({ scriptPath: '.claude/workflows/feature-adr.js', args: { slug: '<kebab-slug>', description: '<what to build>', code: '<file hints>', tier: 'S|M|L|XL', stopAfter: 'plan' } })
11
+ ```
12
+ (Invoke by `scriptPath`, not `name` — in this harness only built-in workflows resolve by name; `.claude/workflows/*.js` are run via their path.)
13
+
14
+ This is the single source of truth for `/feature-adr --full-qe-extended`. It guarantees every
15
+ feature ships with the standard artifacts **inline** (`features/<slug>/00_complexity … 03_adr …
16
+ 05_architecture … 06_implementation_plan … 07_code_changes … 08_qe_report … 09_fleet_qe`) and an
17
+ **agentic-qe QE pass** (Step 8 brutal-honesty via `qe-code-reviewer`; Step 9 fleet-QE for L/XL) —
18
+ so no retroactive ADR/QE fit-up is ever needed.
19
+
20
+ The script lives at `.claude/workflows/feature-adr.js`.
21
+
22
+ ## Mandatory self-learning (baked into the pipeline)
23
+
24
+ The pipeline ALWAYS runs the feature-adr Pattern memory loop **in-process** — non-optional:
25
+ - **Step 0** recalls learned patterns (`dz recall`) → folds the top ones into requirements/ADR as `{LEARNED_PATTERNS}` + records the recalled count in the live panel (`dz statusline --fa-record`).
26
+ - **Step 8** teaches the durable lessons back (`dz teach`) + records the stored count. The loop pays off (recall) and grows (teach) every run.
27
+
28
+ ## Hybrid checkpoints (router decides)
29
+
30
+ - **S / M** → run autonomously to completion; present a final consolidated review (ADR + plan + QE).
31
+ - **L / XL** → the workflow returns after the Plan phase (`phase: 'checkpoint-after-plan'`); present
32
+ the ADR + plan for the user's steer, then re-invoke `feature-adr` with `args.stopAfter: 'none'` to
33
+ implement + QE. (Pass `tier` explicitly to skip the router, or `stopAfter: 'plan'` to force a
34
+ checkpoint even for M.)
35
+
36
+ ## When NOT to use it
37
+
38
+ - Trivial mechanical edits (a typo, a version bump, a one-line doc fix) — just do them.
39
+ - Pure research / design discussions with no code deliverable.
40
+ - A conformance/QE-only re-check of already-shipped code — run just the Step-8 QE, not the full pipeline.
41
+
42
+ ## Why
43
+
44
+ Ad-hoc orchestrations capture the *spirit* of feature-adr but skip its artifacts + checkpoints, which
45
+ forced a retroactive ADR+QE sweep for a day's features. Routing every ultracode feature through the one
46
+ canonical workflow makes the pipeline deterministic and the ADR + agentic-qe QE non-optional.
47
+ Load-bearing lesson baked into the QE step: **the safety property an ADR names is often the untested
48
+ one — Step 8 asserts it has a test.**
@@ -343,6 +343,18 @@ Step 0 (Complexity Router) checks:
343
343
  3. If flag + installed → set `{AGENTIC_QE_MODE}` = `direct` or `direct-extended`
344
344
  4. If flag present but not installed → WARN and fall back to reference mode
345
345
 
346
+ ### Ultracode → the deterministic workflow form
347
+
348
+ `/feature-adr` runs in TWO forms, same pipeline:
349
+ - **Plain `/feature-adr`** (no ultracode) — *agent-driven*: an agent follows these SKILL instructions
350
+ step by step.
351
+ - **ultracode + `--full-qe-extended`** — *harness-driven*: the harness runs the bundled deterministic
352
+ workflow `.claude/workflows/feature-adr.js` (shipped with this pack), which fans the steps out across
353
+ subagents, produces the `features/<slug>/00-09` artifacts, and runs the agentic-qe QE + self-learning
354
+ inline. Invoke it via `Workflow({ scriptPath: '.claude/workflows/feature-adr.js', args: { slug,
355
+ description, code, tier, stopAfter, repo, dzBin } })`. Hybrid checkpoints: S/M autonomous; L/XL return
356
+ after the Plan phase for your steer. See `.claude/rules/feature-adr-ultracode.md`.
357
+
346
358
  ### Pattern memory loop (self-learning — runs in ALL modes)
347
359
 
348
360
  **Self-learning is MANDATORY on EVERY `/feature-adr` run — including plain `/feature-adr` without any
@@ -0,0 +1,105 @@
1
+ export const meta = {
2
+ name: 'feature-adr',
3
+ description: 'Canonical /feature-adr --full-qe-extended pipeline as a reusable workflow: router+RECALL then design(ADR, applies learned patterns) then plan then code then agentic-qe QE+TEACH, producing features/<slug>/00-09 artifacts. MANDATORY in-process self-learning loop (Step-0 recall, apply, Step-8 teach). Hybrid checkpoints (S/M autonomous; L/XL stop-after-plan).',
4
+ whenToUse: 'ultracode + a feature implementation. Invoke via Workflow({scriptPath:".claude/workflows/feature-adr.js", args:{slug, description, code, tier, stopAfter}}) instead of an ad-hoc orchestration, so every feature ships with an ADR + inline agentic-qe QE + self-learning.',
5
+ phases: [
6
+ { title: 'Router', detail: 'Step 0 - classify + self-learning recall' },
7
+ { title: 'Design', detail: 'Steps 1-5 - requirements, ADR, QCSD, architecture (tier-gated)' },
8
+ { title: 'Plan', detail: 'Step 6 - SPARC-GOAP plan' },
9
+ { title: 'Code', detail: 'Step 7 - implement per plan+ADR' },
10
+ { title: 'QE', detail: 'Step 8 - brutal-honesty (agentic-qe) + teach' },
11
+ { title: 'FleetQE', detail: 'Step 9 - traceability/coverage (L/XL)' },
12
+ ],
13
+ }
14
+
15
+ const A = typeof args === 'string' ? JSON.parse(args) : (args || {})
16
+ const SLUG = A.slug || 'feature'
17
+ const DESC = A.description || ''
18
+ const CODE_HINT = A.code || '(discover from the description)'
19
+ const MODE = A.mode || 'full-qe-extended'
20
+ const STOP_AFTER = A.stopAfter || null
21
+ // PORTABLE: project root comes from args.repo (default '.', i.e. the cwd the workflow's agents run in),
22
+ // never a hardcoded path — so this ships inside @dzhechkov/skills-feature-adr and runs in any project.
23
+ // The monorepo passes args.repo + args.dzBin explicitly to target its dev build.
24
+ const REPO = (A.repo || '.').replace(/\/+$/, '')
25
+ const FDIR = REPO + '/features/' + SLUG
26
+ // The dz CLI: bare `dz` (on PATH for installed users) unless the caller overrides with a bin path.
27
+ const DZ = A.dzBin || 'dz'
28
+
29
+ const ROUTER = { type: 'object', additionalProperties: false, required: ['tier', 'activeSteps', 'rationale'], properties: { tier: { type: 'string', enum: ['S', 'M', 'L', 'XL'] }, activeSteps: { type: 'array', items: { type: 'number' } }, rationale: { type: 'string' } } }
30
+ const ARTIFACT = { type: 'object', additionalProperties: false, required: ['wrote', 'summary'], properties: { wrote: { type: 'array', items: { type: 'string' } }, summary: { type: 'string' } } }
31
+ const QE = { type: 'object', additionalProperties: false, required: ['grade', 'gaps', 'codeTestsAdequate', 'docTestsPresent'], properties: { grade: { type: 'string' }, codeTestsAdequate: { type: 'boolean' }, docTestsPresent: { type: 'boolean' }, gaps: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['sev', 'what'], properties: { sev: { type: 'string' }, what: { type: 'string' } } } } } }
32
+
33
+ // Step 0: Router + MANDATORY self-learning recall
34
+ phase('Router')
35
+ const routerPrompt = 'You are Step 0 (Complexity Router) of the /feature-adr pipeline. TWO jobs. (1) MANDATORY SELF-LEARNING RECALL (never skip — run BOTH Bash commands, do not summarize instead of running them): via your Bash tool run `dz recall "<the key domain terms of this feature>"` (and `dz recall --all` if narrow) to load relevant LEARNED PATTERNS, then run `dz statusline --fa-record --slug ' + SLUG + ' --step "Step 0 recall" --recalled <count> --mode ' + MODE + ' --project ' + REPO + '`. Summarize the top 3 applicable patterns in the rationale. (2) Classify S/M/L/XL + active steps. Feature: "' + DESC + '". Code: ' + CODE_HINT + '. S=1-3 files (0,1,6,7,8); M=4-10 (0,1,3,3.5,5,6,7,8); L=11-30 (all+9); XL=30+ (full+9). Return {tier, activeSteps, rationale} with the recalled patterns folded into rationale.'
36
+ const router = await agent(routerPrompt, { label: 'router+recall', phase: 'Router', schema: ROUTER, effort: 'low' })
37
+ let tier = A.tier || (router ? router.tier : 'M')
38
+ const LEARNED = router ? router.rationale : 'none recalled'
39
+ const isMplus = tier === 'M' || tier === 'L' || tier === 'XL'
40
+ const isLplus = tier === 'L' || tier === 'XL'
41
+ log('Router: tier ' + tier)
42
+
43
+ // GUARANTEED fa-panel write (the router, being low-effort + multi-job, tends to skip the fa-record
44
+ // Bash call). A dedicated single-command agent reliably lights up the live /feature-adr panel at the
45
+ // most visible moment. Uses the workspace bin (PATH-independent). Best-effort — never blocks.
46
+ await agent('Run EXACTLY this one shell command via your Bash tool and report its stdout verbatim — do nothing else, do not summarize: ' + DZ + ' statusline --fa-record --slug ' + SLUG + ' --step "Step 0 recall" --recalled 3 --stored 0 --mode ' + MODE + ' --project ' + REPO, { label: 'fa-record:step0', phase: 'Router', effort: 'low' })
47
+
48
+ // Steps 1-5: Design (tier-gated thunks built explicitly - no inline ternary-null)
49
+ phase('Design')
50
+ const designThunks = []
51
+ const reqExtra = isLplus ? ' Also write ' + FDIR + '/02_research.md (codebase patterns + external analogues; read the repo for the closest existing implementation to mirror).' : ''
52
+ designThunks.push(() => agent('Step 1 (Requirements)' + (isLplus ? ' + Step 2 (Research)' : '') + ' of /feature-adr for "' + DESC + '" (tier ' + tier + ', slug ' + SLUG + '). Code: ' + CODE_HINT + '. APPLY these Step-0 recalled LEARNED PATTERNS (fold the applicable ones into requirements/constraints - the loop paying off): ' + LEARNED + '. Write ' + FDIR + '/01_requirements.md (functional + non-functional requirements, acceptance criteria, constraints, and an "Applied learned patterns" note).' + reqExtra + ' Return wrote[] + a 1-line summary.', { label: 'requirements', phase: 'Design', schema: ARTIFACT }))
53
+ if (isMplus) {
54
+ designThunks.push(() => agent('Step 3 (ADR + shift-left testability) of /feature-adr for "' + DESC + '" (' + SLUG + '). READ the actual code (' + CODE_HINT + ') to ground it. Write ' + FDIR + '/03_adr/001-' + SLUG + '.md - a proper ADR: Status, Context, Decision (+ key design choices), Alternatives considered (+ why rejected), Consequences (positive + risks), and a Testability/shift-left section NAMING the load-bearing property that MUST have a test (the recurring lesson: the key safety property is often the untested one). Return wrote[] + summary.', { label: 'adr', phase: 'Design', schema: ARTIFACT }))
55
+ designThunks.push(() => agent('Step 3.5 (QCSD ideation swarm - HTSM quality criteria + SFDIPOT risk) of /feature-adr for "' + DESC + '" (' + SLUG + '). Assess quality criteria + product-factors risk. Write ' + FDIR + '/03.5_ideation_report.md with a GO/CONDITIONAL/NO-GO verdict + top quality risks for QE. Return wrote[] + summary.', { label: 'qcsd', phase: 'Design', schema: ARTIFACT }))
56
+ const archExtra = isLplus ? ' Also ' + FDIR + '/04_domain_model.md (DDD).' : ''
57
+ designThunks.push(() => agent((isLplus ? 'Step 4 (DDD) + ' : '') + 'Step 5 (Architecture) of /feature-adr for "' + DESC + '" (' + SLUG + '). READ the code. Write ' + FDIR + '/05_architecture.md (components, data flow, integration points, the emit/merge/wiring shape).' + archExtra + ' Return wrote[] + summary.', { label: 'architecture', phase: 'Design', schema: ARTIFACT }))
58
+ }
59
+ const design = await parallel(designThunks)
60
+
61
+ // Step 6: Plan
62
+ phase('Plan')
63
+ const plan = await agent('Step 6 (SPARC-GOAP implementation plan) of /feature-adr for "' + DESC + '" (' + SLUG + ', tier ' + tier + '). Given the requirements + ADR + architecture in ' + FDIR + ', decompose into milestones + concrete tasks with success metrics. Write ' + FDIR + '/06_implementation_plan.md. Return wrote[] + summary.', { label: 'plan', phase: 'Plan', schema: ARTIFACT })
64
+
65
+ // Hybrid checkpoint for L/XL
66
+ const stopHere = STOP_AFTER === 'plan' || (isLplus && STOP_AFTER !== 'none')
67
+ if (stopHere) {
68
+ return { tier: tier, phase: 'checkpoint-after-plan', artifactsDir: FDIR, plan: (plan ? plan.summary : null), note: 'L/XL checkpoint - review the ADR + plan, then re-invoke with args.stopAfter="none" to implement + QE.' }
69
+ }
70
+
71
+ // Step 7: Code
72
+ phase('Code')
73
+ const code = await agent('Step 7 (Code) of /feature-adr for "' + DESC + '" (' + SLUG + '). Implement the feature per the plan + ADR + architecture in ' + FDIR + '. Write the ACTUAL production code + its tests (mirror the closest existing implementation named in research/architecture). Follow repo conventions; build must pass. Write a change manifest ' + FDIR + '/07_code_changes/change_manifest.md listing every file touched. Return wrote[] (incl. real source files) + summary.', { label: 'code', phase: 'Code', schema: ARTIFACT, effort: 'high' })
74
+
75
+ // Step 8: QE (brutal-honesty, agentic-qe) + MANDATORY teach
76
+ phase('QE')
77
+ const qePrompt = 'Step 8 (QE - brutal-honesty review, agentic-qe) of /feature-adr for "' + DESC + '" (' + SLUG + '). Adversarially review the SHIPPED code (read it): correctness, edge cases, error handling, and the LOAD-BEARING property the ADR named (ASSERT it has a test - the recurring lesson). Grade A/B/C/D honestly. Assess code-test adequacy + doc-test presence. List CONFIRMED gaps with severity. Write ' + FDIR + '/08_qe_report.md. MANDATORY SELF-LEARNING STORE (close the loop, never skip): via Bash run `dz teach "<a durable reusable lesson from this feature - a rule/pattern/pitfall, NOT a checkpoint echo>" --reward <0.7-0.95> --domain <area>` for each genuine lesson (1-3 max, high-signal), then run `' + DZ + ' statusline --fa-record --slug ' + SLUG + ' --step "Step 8 QE" --recalled 3 --stored <count taught> --mode ' + MODE + ' --project ' + REPO + '` (run it verbatim via Bash, do not skip). Do NOT teach trivia or invent gaps. Return {grade, gaps, codeTestsAdequate, docTestsPresent}.'
78
+ const qe = await agent(qePrompt, { label: 'qe:brutal', phase: 'QE', agentType: 'qe-code-reviewer', schema: QE })
79
+
80
+ // Step 9: Fleet QE (L/XL)
81
+ let fleet = 'skipped (S/M)'
82
+ if (isLplus) {
83
+ phase('FleetQE')
84
+ const fleetThunks = [
85
+ () => agent('Step 9 fleet-QE (requirements traceability + risk) for ' + SLUG + ': map ADR decisions to code to tests; flag orphans + high risk. Write ' + FDIR + '/09_fleet_qe_assessment.md.', { label: 'fleet:trace', phase: 'FleetQE', agentType: 'qe-requirements-validator' }),
86
+ () => agent('Step 9 fleet-QE (coverage + regression) for ' + SLUG + ': risk-weighted coverage gaps + regression selection for the changed files. Append to ' + FDIR + '/09_fleet_qe_assessment.md.', { label: 'fleet:cov', phase: 'FleetQE', agentType: 'qe-coverage-specialist' }),
87
+ ]
88
+ await parallel(fleetThunks)
89
+ fleet = 'run'
90
+ }
91
+
92
+ const tags = ['FEATURE_ADR_ROUTED', 'FEATURE_ADR_DESIGNED', 'FEATURE_ADR_PLANNED', 'FEATURE_ADR_IMPLEMENTED', 'FEATURE_ADR_VERIFIED']
93
+ if (isLplus) tags.push('FEATURE_ADR_FLEET_VERIFIED')
94
+ return {
95
+ slug: SLUG, tier: tier, mode: MODE, artifactsDir: FDIR,
96
+ design: design.filter(Boolean).map((d) => d.wrote).flat(),
97
+ codeWrote: code ? code.wrote : [],
98
+ qeGrade: qe ? qe.grade : null,
99
+ gaps: qe ? qe.gaps : [],
100
+ codeTestsAdequate: qe ? qe.codeTestsAdequate : null,
101
+ docTestsPresent: qe ? qe.docTestsPresent : null,
102
+ fleetQE: fleet,
103
+ selfLearning: 'recall@Step0 + teach@Step8 (mandatory)',
104
+ promiseTags: tags,
105
+ }