@dzhechkov/skills-feature-adr 1.3.22 → 1.3.26

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.22",
3
+ "version": "1.3.26",
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
  // ===========================================================================
@@ -386,6 +440,12 @@ module.exports = {
386
440
  getRelativePathsFiltered,
387
441
  diffFiles,
388
442
 
443
+ // Content hashing / 3-way classifier
444
+ hashBytes,
445
+ hashFile,
446
+ safeHashFile,
447
+ classifyThreeWay,
448
+
389
449
  // Manifest
390
450
  MANIFEST_FILE,
391
451
  toManifestPath,
@@ -25,6 +25,23 @@ The pipeline ALWAYS runs the feature-adr Pattern memory loop **in-process** —
25
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
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
27
 
28
+ ## Optional Codex planner (opt-in, pre-flight ASK)
29
+
30
+ feature-adr can route the **planning step (Step 6)** to Codex's top model instead of Claude. It is
31
+ **opt-in and graceful**: absent/declined/failed → the default Claude planner runs (the pipeline never
32
+ blocks on Codex).
33
+
34
+ Pre-flight, BEFORE launching a feature-adr run, detect Codex readiness:
35
+ ```
36
+ node "<codex-plugin>/scripts/codex-companion.mjs" setup --json → { ready: true }
37
+ ```
38
+ If `ready`, **ASK the user once**: *"Use the top Codex model for the planning step?"* On yes, pass
39
+ `args.planner: 'codex'` (the workflow routes Step 6 to the `codex:codex-rescue` agentType, with a
40
+ Claude fallback and `plannerUsed` in the result). On no / not-ready, omit `planner` (Claude plans).
41
+ For the interactive SKILL (plain `/feature-adr`), make the same offer at the planning checkpoint.
42
+ Codex needs a headless login on a VPS: `codex login --device-auth` (code + URL) or
43
+ `printenv OPENAI_API_KEY | codex login --with-api-key`.
44
+
28
45
  ## Hybrid checkpoints (router decides)
29
46
 
30
47
  - **S / M** → run autonomously to completion; present a final consolidated review (ADR + plan + QE).
@@ -355,6 +355,12 @@ Step 0 (Complexity Router) checks:
355
355
  description, code, tier, stopAfter, repo, dzBin } })`. Hybrid checkpoints: S/M autonomous; L/XL return
356
356
  after the Plan phase for your steer. See `.claude/rules/feature-adr-ultracode.md`.
357
357
 
358
+ **Optional Codex planner (opt-in).** When Codex is installed + logged in (`codex login --device-auth`
359
+ on a headless VPS, or `printenv OPENAI_API_KEY | codex login --with-api-key`), the pipeline can route the
360
+ **planning step to Codex's top model**. It ALWAYS asks first — a pre-flight *"Use the top Codex model for
361
+ planning?"* — and falls back to Claude if you decline or Codex is unavailable (never blocks). ultracode:
362
+ pass `args.planner: 'codex'`. Plain `/feature-adr`: the same offer appears at the planning checkpoint.
363
+
358
364
  ### Pattern memory loop (self-learning — runs in ALL modes)
359
365
 
360
366
  **Self-learning is MANDATORY on EVERY `/feature-adr` run — including plain `/feature-adr` without any
@@ -1,7 +1,7 @@
1
1
  export const meta = {
2
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.',
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). OPTIONAL Codex planner (args.planner=codex → Step-6 routed to codex:codex-rescue top model, graceful fallback to Claude). 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, planner}}) instead of an ad-hoc orchestration, so every feature ships with an ADR + inline agentic-qe QE + self-learning.',
5
5
  phases: [
6
6
  { title: 'Router', detail: 'Step 0 - classify + self-learning recall' },
7
7
  { title: 'Design', detail: 'Steps 1-5 - requirements, ADR, QCSD, architecture (tier-gated)' },
@@ -58,14 +58,32 @@ if (isMplus) {
58
58
  }
59
59
  const design = await parallel(designThunks)
60
60
 
61
- // Step 6: Plan
61
+ // Step 6: Plan — optionally routed to Codex's top model (opt-in via args.planner='codex').
62
+ // The user opts in at pre-flight ('use the top Codex model for planning?'); we route the Plan step to
63
+ // the codex:codex-rescue runtime and GRACEFULLY FALL BACK to the default (Claude) planner if Codex is
64
+ // unavailable/errors — the pipeline never blocks on Codex.
62
65
  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 })
66
+ const PLANNER = (A.planner === 'codex') ? 'codex' : 'claude'
67
+ const planPrompt = '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.'
68
+ let plan = null
69
+ if (PLANNER === 'codex') {
70
+ const codexPlan = await agent(planPrompt, { label: 'plan:codex', phase: 'Plan', agentType: 'codex:codex-rescue' })
71
+ if (codexPlan) {
72
+ plan = { wrote: [FDIR + '/06_implementation_plan.md'], summary: String(codexPlan).slice(0, 500), planner: 'codex' }
73
+ log('Plan: Codex (top model)')
74
+ } else {
75
+ log('Plan: Codex unavailable — falling back to the default planner')
76
+ }
77
+ }
78
+ if (plan === null) {
79
+ const claudePlan = await agent(planPrompt, { label: 'plan', phase: 'Plan', schema: ARTIFACT })
80
+ plan = claudePlan ? { wrote: claudePlan.wrote, summary: claudePlan.summary, planner: PLANNER === 'codex' ? 'claude-fallback' : 'claude' } : null
81
+ }
64
82
 
65
83
  // Hybrid checkpoint for L/XL
66
84
  const stopHere = STOP_AFTER === 'plan' || (isLplus && STOP_AFTER !== 'none')
67
85
  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.' }
86
+ return { tier: tier, phase: 'checkpoint-after-plan', artifactsDir: FDIR, planner: (plan ? plan.planner : null), plan: (plan ? plan.summary : null), note: 'L/XL checkpoint - review the ADR + plan, then re-invoke with args.stopAfter="none" to implement + QE.' }
69
87
  }
70
88
 
71
89
  // Step 7: Code
@@ -100,6 +118,7 @@ return {
100
118
  codeTestsAdequate: qe ? qe.codeTestsAdequate : null,
101
119
  docTestsPresent: qe ? qe.docTestsPresent : null,
102
120
  fleetQE: fleet,
121
+ plannerUsed: plan ? plan.planner : null,
103
122
  selfLearning: 'recall@Step0 + teach@Step8 (mandatory)',
104
123
  promiseTags: tags,
105
124
  }