@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 +21 -0
- package/package.json +4 -2
- package/src/commands/init.js +15 -4
- package/src/commands/update.js +179 -59
- package/src/utils.js +62 -2
- package/templates/.claude/rules/feature-adr-ultracode.md +17 -0
- package/templates/.claude/skills/feature-adr/SKILL.md +6 -0
- package/templates/.claude/workflows/feature-adr.js +24 -5
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.
|
|
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
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -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 = {
|
package/src/commands/update.js
CHANGED
|
@@ -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
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
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
|
-
// ──
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|