@smartmemory/compose 0.2.20-beta → 0.2.21-beta

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/bin/compose.js CHANGED
@@ -1628,6 +1628,9 @@ if (cmd === 'validate') {
1628
1628
  let blockOn = 'error'
1629
1629
  let asJson = false
1630
1630
  let externalXref = false
1631
+ let doFix = false
1632
+ let doApply = false
1633
+ let fixClasses = null
1631
1634
  for (let i = 0; i < args.length; i++) {
1632
1635
  const a = args[i]
1633
1636
  if (a === '--help' || a === '-h') {
@@ -1639,15 +1642,23 @@ Options:
1639
1642
  --block-on=LEVEL Exit non-zero if any finding >= LEVEL (default: error)
1640
1643
  LEVEL: error | warning | info
1641
1644
  --json Emit findings as JSON (default: human-readable)
1645
+ --fix Reconcile mechanical drift (dry-run: prints a fix plan)
1646
+ --apply With --fix, write the fixes (default is dry-run)
1647
+ --fix-class=CSV Override the fix class set, e.g.
1648
+ dangling_link,invalid_link_kind,status_fj_vision,
1649
+ partial_age,roadmap_status_rewrite,invalid_link_kind_repair
1642
1650
 
1643
1651
  Exit codes:
1644
1652
  0 no findings >= block-on threshold
1645
1653
  1 findings >= block-on threshold present
1646
- 2 usage error`)
1654
+ 2 usage error (or reconcile refused on a non-local provider)`)
1647
1655
  process.exit(0)
1648
1656
  }
1649
1657
  if (a === '--json') { asJson = true; continue }
1650
1658
  if (a === '--external') { externalXref = true; continue }
1659
+ if (a === '--fix') { doFix = true; continue }
1660
+ if (a === '--apply') { doApply = true; continue }
1661
+ if (a.startsWith('--fix-class=')) { fixClasses = a.slice('--fix-class='.length).split(',').map((s) => s.trim()).filter(Boolean); continue }
1651
1662
  if (a.startsWith('--scope=')) scope = a.slice('--scope='.length)
1652
1663
  else if (a === '--scope') scope = args[++i]
1653
1664
  else if (a.startsWith('--code=')) code = a.slice('--code='.length)
@@ -1695,13 +1706,64 @@ Exit codes:
1695
1706
  process.exit(2)
1696
1707
  }
1697
1708
 
1709
+ // --fix: reconcile mechanical drift. Dry-run prints a plan; --apply writes and
1710
+ // re-validates so the exit code reflects what's left after the fixes.
1711
+ let reconcile = null
1712
+ if (doFix) {
1713
+ const { reconcileProject } = await import('../lib/feature-reconciler.js')
1714
+ reconcile = await reconcileProject(valCwd, {
1715
+ apply: doApply,
1716
+ classes: fixClasses,
1717
+ scope,
1718
+ code,
1719
+ external: externalXref,
1720
+ })
1721
+ if (reconcile.refused === 'non_local_provider') {
1722
+ console.error('compose validate --fix: reconcile is local-provider only (this workspace uses a remote tracker). No changes made.')
1723
+ process.exit(2)
1724
+ }
1725
+ if (doApply) {
1726
+ // Re-validate so findings/exit reflect the post-fix state.
1727
+ result = scope === 'feature'
1728
+ ? await validateFeature(valCwd, code)
1729
+ : await validateProject(valCwd, { external: externalXref })
1730
+ }
1731
+ if (!asJson) {
1732
+ const verb = doApply ? 'applied' : 'would apply'
1733
+ const total = reconcile.plan.length
1734
+ console.log(`compose validate --fix (${doApply ? 'apply' : 'dry-run'}): ${verb} ${total} fix(es)`)
1735
+ for (const e of reconcile.plan) {
1736
+ const cls = e.classes.join(',')
1737
+ console.log(` [${cls}] ${e.feature_code}: ${e.action}`)
1738
+ console.log(` before: ${Array.isArray(e.before) ? e.before.join(', ') : e.before}`)
1739
+ console.log(` after: ${Array.isArray(e.after) ? e.after.join(', ') : e.after}`)
1740
+ }
1741
+ for (const s of reconcile.skipped_classes || []) {
1742
+ console.log(` (skipped class ${s.class}: ${s.reason})`)
1743
+ }
1744
+ if (doApply) {
1745
+ const failed = (reconcile.applied || []).filter((a) => !a.ok)
1746
+ if (failed.length) {
1747
+ console.log(` ${failed.length} fix(es) failed:`)
1748
+ for (const f of failed) console.log(` ${f.feature_code} ${f.action}: ${f.error}`)
1749
+ }
1750
+ const noops = (reconcile.applied || []).filter((a) => a.ok && a.noop)
1751
+ if (noops.length) {
1752
+ console.log(` ${noops.length} fix(es) made no change (refused as unsafe/ambiguous; left for manual review):`)
1753
+ for (const n of noops) console.log(` ${n.feature_code} ${n.action}`)
1754
+ }
1755
+ }
1756
+ console.log('')
1757
+ }
1758
+ }
1759
+
1698
1760
  // Threshold: findings at or above this severity block the exit code
1699
1761
  const SEV_RANK = { error: 3, warning: 2, info: 1 }
1700
1762
  const threshold = SEV_RANK[blockOn]
1701
1763
  const blocking = result.findings.filter((f) => SEV_RANK[f.severity] >= threshold)
1702
1764
 
1703
1765
  if (asJson) {
1704
- console.log(JSON.stringify(result, null, 2))
1766
+ console.log(JSON.stringify(reconcile ? { ...result, reconcile } : result, null, 2))
1705
1767
  } else {
1706
1768
  const byKind = {}
1707
1769
  for (const f of result.findings) {
@@ -6,7 +6,7 @@
6
6
  * ROADMAP.md is generated from these files.
7
7
  */
8
8
 
9
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'fs';
10
10
  import { join, basename } from 'path';
11
11
  import { readdirSync } from 'fs';
12
12
  import { assertValidLinkShape, assertLinkTargetsExist } from './feature-write-guard.js';
@@ -70,7 +70,17 @@ export function writeFeature(cwd, feature, featuresDir = 'docs/features', opts =
70
70
  mkdirSync(dir, { recursive: true });
71
71
  const path = join(dir, 'feature.json');
72
72
  feature.updated = new Date().toISOString().slice(0, 10);
73
- writeFileSync(path, JSON.stringify(feature, null, 2) + '\n');
73
+ // Atomic write: temp + rename so a crash mid-write can't leave a half-written
74
+ // feature.json (a bulk reconcile may touch many features). Mirrors
75
+ // VisionWriter._atomicWrite / LocalFileProvider.putChangelog.
76
+ const tmp = `${path}.tmp.${process.pid}`;
77
+ try {
78
+ writeFileSync(tmp, JSON.stringify(feature, null, 2) + '\n');
79
+ renameSync(tmp, path);
80
+ } catch (err) {
81
+ try { unlinkSync(tmp); } catch { /* tmp may not exist */ }
82
+ throw err;
83
+ }
74
84
  }
75
85
 
76
86
  // Sentinel for absent / non-numeric positions: sorts them after any real
@@ -0,0 +1,358 @@
1
+ /**
2
+ * feature-reconciler.js — COMP-MCP-VALIDATE-2 `compose validate --fix`.
3
+ *
4
+ * Turns the detect-only validator into a closed loop. Reuses the validator's
5
+ * context builders (loadValidationContext / loadFeatureContext) and its emitted
6
+ * findings, derives the canonical fix for the mechanical drift classes, and —
7
+ * on apply — executes each fix through a typed writer (rewriteLinks,
8
+ * resyncRoadmap, setFeatureStatus, VisionWriter).
9
+ *
10
+ * Design decisions (see docs/features/COMP-MCP-VALIDATE-2/blueprint.md):
11
+ * - Validator stays detect-only; this is a sibling pass, not a mutation hook.
12
+ * - Status/ROADMAP classes dispatch on the validator's findings (post-projection,
13
+ * narrative-suppressed); link + partial classes derive from ctx (no clean
14
+ * finding exists). This honors the validator's exact semantics.
15
+ * - v1 is local-provider only: GitHubProvider's putFeature/renderRoadmap skip the
16
+ * local existence + narrative-no-op guarantees the fixes rely on.
17
+ * - Dry-run by default; per-class opt-in; destructive/heuristic classes off by
18
+ * default.
19
+ */
20
+
21
+ import { readFileSync } from 'fs';
22
+
23
+ import {
24
+ loadValidationContext,
25
+ loadFeatureContext,
26
+ validateProject,
27
+ } from './feature-validator.js';
28
+ import {
29
+ getProvider,
30
+ isLocalProvider,
31
+ rewriteLinks,
32
+ setRoadmapRowStatus,
33
+ setFeatureStatus,
34
+ _internals,
35
+ } from './feature-writer.js';
36
+ import { featureStatusToVisionStatus } from './status-projection.js';
37
+
38
+ const VALID_LINK_KINDS = new Set([..._internals.LINK_KINDS, 'external']);
39
+ const CANONICAL_DOCS = new Set([
40
+ 'design.md', 'prd.md', 'architecture.md', 'blueprint.md', 'plan.md', 'report.md',
41
+ ]);
42
+
43
+ // Class registry. `default` = enabled by a bare --fix; opt-in classes require
44
+ // explicit selection. `mutatesFeatureJson` classes are dropped when
45
+ // featureJsonMode is false (feature.json is not canonical in legacy mode).
46
+ export const FIX_CLASSES = {
47
+ dangling_link: { default: true, mutatesFeatureJson: true, group: 'link' },
48
+ invalid_link_kind: { default: true, mutatesFeatureJson: true, group: 'link' },
49
+ // Modifier on invalid_link_kind: repair to nearest allowed instead of dropping.
50
+ invalid_link_kind_repair: { default: false, mutatesFeatureJson: true, group: 'link' },
51
+ status_fj_vision: { default: true, mutatesFeatureJson: false, group: 'status_vision' },
52
+ partial_age: { default: false, mutatesFeatureJson: true, group: 'partial' },
53
+ roadmap_status_rewrite: { default: false, mutatesFeatureJson: false, group: 'roadmap' },
54
+ };
55
+
56
+ export function defaultClasses() {
57
+ return Object.entries(FIX_CLASSES).filter(([, v]) => v.default).map(([k]) => k);
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // nearestLinkKind — edit-distance repair for an invalid link kind
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function levenshtein(a, b) {
65
+ const m = a.length, n = b.length;
66
+ const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...new Array(n).fill(0)]);
67
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
68
+ for (let i = 1; i <= m; i++) {
69
+ for (let j = 1; j <= n; j++) {
70
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
71
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
72
+ }
73
+ }
74
+ return dp[m][n];
75
+ }
76
+
77
+ /**
78
+ * Nearest allowed link kind to `bad`, or null if ambiguous / too far.
79
+ * Conservative: distance must be ≤ 2 and the minimum must be unique.
80
+ * Never targets 'external' (a structural kind, not a typo of the rest).
81
+ */
82
+ export function nearestLinkKind(bad) {
83
+ if (typeof bad !== 'string' || !bad) return null;
84
+ let best = null, bestD = Infinity, tie = false;
85
+ for (const k of _internals.LINK_KINDS) {
86
+ const d = levenshtein(bad.toLowerCase(), k);
87
+ if (d < bestD) { bestD = d; best = k; tie = false; }
88
+ else if (d === bestD) { tie = true; }
89
+ }
90
+ if (bestD <= 2 && !tie) return best;
91
+ return null;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Derive — build the dry-run fix plan from ctx + findings
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function changelogMentions(ctx, code) {
99
+ let text = '';
100
+ try { text = readFileSync(ctx.paths.changelog, 'utf8'); } catch { return false; }
101
+ const re = new RegExp(`^###\\s+${code}(?:\\s|$)`, 'm');
102
+ return re.test(text);
103
+ }
104
+
105
+ // Per-feature link rewrite covering dangling_link + invalid_link_kind. Returns a
106
+ // single plan entry per feature whose links[] needs changes, or null.
107
+ function deriveLinkFix(ctx, code, active) {
108
+ const fctx = loadFeatureContext(ctx.cwd, code, ctx);
109
+ const links = fctx.featureJson?.links;
110
+ if (!Array.isArray(links) || links.length === 0) return null;
111
+
112
+ const dropDangling = active.has('dangling_link');
113
+ const fixKind = active.has('invalid_link_kind');
114
+ const repair = active.has('invalid_link_kind_repair');
115
+ if (!dropDangling && !fixKind) return null;
116
+
117
+ const next = [];
118
+ const changes = [];
119
+ for (const link of links) {
120
+ // Dangling: a to_code that resolves in no source. Mirrors the validator's
121
+ // cross-feature check EXACTLY (feature-validator.js:593-601) — including that
122
+ // it does not special-case kind:"external". A well-formed external link
123
+ // carries no to_code (provider/repo/issue/url instead), so this guard skips
124
+ // it; only a malformed external link with an unresolved to_code is dropped,
125
+ // which is precisely what the validator flags. Matching the validator
126
+ // guarantees --fix converges on every DANGLING_LINK_FEATURES_TARGET it emits.
127
+ const isDangling = dropDangling && link.to_code
128
+ && !ctx.foldersByCode.has(link.to_code)
129
+ && !ctx.roadmapByCode.has(link.to_code)
130
+ && !ctx.visionByCode.has(link.to_code);
131
+ if (isDangling) {
132
+ changes.push({ op: 'drop', reason: 'dangling', kind: link.kind, to_code: link.to_code });
133
+ continue;
134
+ }
135
+ // Invalid kind.
136
+ if (fixKind && !VALID_LINK_KINDS.has(link.kind)) {
137
+ const repaired = repair ? nearestLinkKind(link.kind) : null;
138
+ if (repaired) {
139
+ changes.push({ op: 'repair', from_kind: link.kind, to_kind: repaired, to_code: link.to_code });
140
+ next.push({ ...link, kind: repaired });
141
+ } else {
142
+ changes.push({ op: 'drop', reason: 'invalid_kind', kind: link.kind, to_code: link.to_code });
143
+ }
144
+ continue;
145
+ }
146
+ next.push(link);
147
+ }
148
+ if (changes.length === 0) return null;
149
+ return {
150
+ feature_code: code,
151
+ group: 'link',
152
+ classes: [...new Set(changes.map((c) => c.reason === 'invalid_kind' || c.op === 'repair' ? 'invalid_link_kind' : 'dangling_link'))],
153
+ action: 'rewrite_links',
154
+ before: links.map((l) => `${l.kind}→${l.to_code ?? '(external)'}`),
155
+ after: next.map((l) => `${l.kind}→${l.to_code ?? '(external)'}`),
156
+ changes,
157
+ _links: next,
158
+ };
159
+ }
160
+
161
+ function deriveStatusVisionFix(ctx, finding) {
162
+ const code = finding.feature_code;
163
+ if (!code) return null;
164
+ const fctx = loadFeatureContext(ctx.cwd, code, ctx);
165
+ const fjStatus = fctx.featureJson?.status;
166
+ const visStatus = featureStatusToVisionStatus(fjStatus);
167
+ if (!visStatus) return null;
168
+ const vision = ctx.visionByCode.get(code);
169
+ return {
170
+ feature_code: code,
171
+ group: 'status_vision',
172
+ classes: ['status_fj_vision'],
173
+ action: 'update_vision_status',
174
+ before: vision?.status ?? null,
175
+ after: visStatus,
176
+ _visStatus: visStatus,
177
+ };
178
+ }
179
+
180
+ function deriveRoadmapFix(ctx, finding) {
181
+ const code = finding.feature_code;
182
+ if (!code) return null;
183
+ const fctx = loadFeatureContext(ctx.cwd, code, ctx);
184
+ const canonical = fctx.featureJson?.status;
185
+ if (!canonical) return null;
186
+ // Only plan a fix when the ROADMAP's parsed "status" is itself a recognized
187
+ // status token. When it is not (e.g. a row whose description contains an
188
+ // escaped pipe, so the validator parsed prose into the status column), the
189
+ // surgical writer would refuse the edit anyway — so planning it would produce
190
+ // a misleading dry-run that promises a change apply won't make. Keep the
191
+ // planner as conservative as the writer: structural row problems are left for
192
+ // manual review, not auto-"fixed".
193
+ const before = ctx.roadmapByCode.get(code)?.status ?? null;
194
+ if (!before || !_internals.STATUSES.has(String(before).toUpperCase())) return null;
195
+ return {
196
+ feature_code: code,
197
+ group: 'roadmap',
198
+ classes: ['roadmap_status_rewrite'],
199
+ action: 'set_roadmap_row_status',
200
+ before,
201
+ after: canonical,
202
+ _status: canonical,
203
+ };
204
+ }
205
+
206
+ function derivePartialFix(ctx, code) {
207
+ const fctx = loadFeatureContext(ctx.cwd, code, ctx);
208
+ const status = (fctx.featureJson?.status || '').toUpperCase();
209
+ if (status !== 'PARTIAL') return null;
210
+ const folder = fctx.folder;
211
+ if (!folder) return null;
212
+ const hasCanonicalDoc = [...folder.files].some((f) => CANONICAL_DOCS.has(f));
213
+ const hasArtifacts = Array.isArray(fctx.featureJson?.artifacts) && fctx.featureJson.artifacts.length > 0;
214
+ if (hasCanonicalDoc || hasArtifacts || changelogMentions(ctx, code)) return null;
215
+ return {
216
+ feature_code: code,
217
+ group: 'partial',
218
+ classes: ['partial_age'],
219
+ action: 'set_status',
220
+ before: 'PARTIAL',
221
+ after: 'PLANNED',
222
+ };
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Apply — execute a single plan entry through a typed writer
227
+ // ---------------------------------------------------------------------------
228
+
229
+ // Returns { changed } — changed:false means the writer made no change (e.g. a
230
+ // guarded refusal or already-correct), so callers don't report it as applied.
231
+ async function applyEntry(cwd, entry) {
232
+ switch (entry.action) {
233
+ case 'rewrite_links':
234
+ await rewriteLinks(cwd, { from_code: entry.feature_code, links: entry._links });
235
+ return { changed: true };
236
+ case 'update_vision_status': {
237
+ const { VisionWriter } = await import('./vision-writer.js');
238
+ const { join } = await import('path');
239
+ const writer = new VisionWriter(join(cwd, '.compose', 'data'));
240
+ const item = await writer.findFeatureItem(entry.feature_code);
241
+ if (!item) return { changed: false };
242
+ await writer.updateItemStatus(item.id, entry._visStatus);
243
+ return { changed: true };
244
+ }
245
+ case 'set_status':
246
+ await setFeatureStatus(cwd, { code: entry.feature_code, status: entry.after, derived: true });
247
+ return { changed: true };
248
+ case 'set_roadmap_row_status': {
249
+ const r = await setRoadmapRowStatus(cwd, { code: entry.feature_code, status: entry._status });
250
+ return { changed: r.changed !== false };
251
+ }
252
+ default:
253
+ throw new Error(`feature-reconciler: unknown action "${entry.action}"`);
254
+ }
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // reconcileProject — the public entry point
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /**
262
+ * @param {string} cwd
263
+ * @param {object} [opts]
264
+ * @param {boolean} [opts.apply=false] Write fixes (default: dry-run).
265
+ * @param {string[]} [opts.classes] Enabled class keys (default: defaultClasses()).
266
+ * @param {string} [opts.scope='project'] 'project' | 'feature'.
267
+ * @param {string} [opts.code] Required when scope='feature'.
268
+ * @param {boolean} [opts.featureJsonMode] Forwarded to the validator/context.
269
+ * @param {string[]} [opts.externalPrefixes]
270
+ * @param {boolean} [opts.external]
271
+ * @returns {Promise<object>} { scope, plan, counts, applied?, refused?, skipped_classes? }
272
+ */
273
+ export async function reconcileProject(cwd, opts = {}) {
274
+ const apply = opts.apply === true;
275
+ const scope = opts.scope === 'feature' ? 'feature' : 'project';
276
+
277
+ // Provider guard (C8): v1 reconcile is local-provider only.
278
+ const provider = await getProvider(cwd);
279
+ if (!isLocalProvider(provider)) {
280
+ return { scope, refused: 'non_local_provider', plan: [], counts: {} };
281
+ }
282
+
283
+ const valOpts = {
284
+ featureJsonMode: opts.featureJsonMode,
285
+ externalPrefixes: opts.externalPrefixes,
286
+ external: opts.external === true,
287
+ };
288
+ const { findings } = await validateProject(cwd, valOpts);
289
+ const ctx = loadValidationContext(cwd, valOpts);
290
+
291
+ // Active class set, with mode guard (C7).
292
+ let active = new Set(
293
+ (opts.classes && opts.classes.length ? opts.classes : defaultClasses())
294
+ .filter((k) => FIX_CLASSES[k]),
295
+ );
296
+ const skipped_classes = [];
297
+ if (ctx.featureJsonMode === false) {
298
+ for (const k of [...active]) {
299
+ if (FIX_CLASSES[k].mutatesFeatureJson) {
300
+ active.delete(k);
301
+ skipped_classes.push({ class: k, reason: 'feature_json_mode_off' });
302
+ }
303
+ }
304
+ }
305
+
306
+ const codeFilter = (c) => scope === 'feature' ? c === opts.code : true;
307
+ const codes = [...ctx.foldersByCode.keys()].filter(codeFilter);
308
+ const plan = [];
309
+
310
+ // Link classes (ctx-derived, per-feature single rewrite).
311
+ if (active.has('dangling_link') || active.has('invalid_link_kind')) {
312
+ for (const code of codes) {
313
+ const entry = deriveLinkFix(ctx, code, active);
314
+ if (entry) plan.push(entry);
315
+ }
316
+ }
317
+ // partial_age (ctx-derived).
318
+ if (active.has('partial_age')) {
319
+ for (const code of codes) {
320
+ const entry = derivePartialFix(ctx, code);
321
+ if (entry) plan.push(entry);
322
+ }
323
+ }
324
+ // Finding-sourced classes.
325
+ for (const f of findings) {
326
+ if (!codeFilter(f.feature_code)) continue;
327
+ if (active.has('status_fj_vision') && f.kind === 'STATUS_MISMATCH_FEATUREJSON_VS_VISION_STATE') {
328
+ const e = deriveStatusVisionFix(ctx, f);
329
+ if (e) plan.push(e);
330
+ }
331
+ if (active.has('roadmap_status_rewrite') && f.kind === 'STATUS_MISMATCH_ROADMAP_VS_FEATUREJSON') {
332
+ const e = deriveRoadmapFix(ctx, f);
333
+ if (e) plan.push(e);
334
+ }
335
+ }
336
+
337
+ const counts = {};
338
+ for (const e of plan) for (const c of e.classes) counts[c] = (counts[c] || 0) + 1;
339
+
340
+ if (!apply) {
341
+ return { scope, dry_run: true, plan, counts, skipped_classes };
342
+ }
343
+
344
+ const applied = [];
345
+ for (const entry of plan) {
346
+ try {
347
+ const { changed } = await applyEntry(cwd, entry);
348
+ // ok = "did not error"; noop = "made no change" (a guarded refusal, e.g. a
349
+ // malformed/escaped-pipe ROADMAP row the surgical writer declined). Surfacing
350
+ // noop honestly means --fix never claims a repair it didn't make; the caller
351
+ // re-validates to confirm actual convergence.
352
+ applied.push({ feature_code: entry.feature_code, action: entry.action, ok: true, noop: changed === false });
353
+ } catch (err) {
354
+ applied.push({ feature_code: entry.feature_code, action: entry.action, ok: false, error: err.message });
355
+ }
356
+ }
357
+ return { scope, dry_run: false, plan, counts, applied, skipped_classes };
358
+ }
@@ -124,7 +124,7 @@ function resolveProjectPaths(cwd) {
124
124
  // Context loading
125
125
  // ---------------------------------------------------------------------------
126
126
 
127
- function loadValidationContext(cwd, options = {}) {
127
+ export function loadValidationContext(cwd, options = {}) {
128
128
  const paths = resolveProjectPaths(cwd);
129
129
 
130
130
  // ROADMAP — direct table-row scan. Validator can't depend on parseRoadmap()
@@ -267,12 +267,12 @@ function loadValidationContext(cwd, options = {}) {
267
267
  // Effective status for per-feature checks. feature.json is canonical; the parsed
268
268
  // ROADMAP row is only a fallback — and NOT even that on a narrative-owned
269
269
  // workspace, where the row is hand-authored prose (#39).
270
- function effectiveStatus(fctx, ctx) {
270
+ export function effectiveStatus(fctx, ctx) {
271
271
  return normalizeStatus(fctx.featureJson?.status)
272
272
  || (ctx?.narrativeOwned ? null : normalizeStatus(fctx.roadmap?.status));
273
273
  }
274
274
 
275
- function loadFeatureContext(cwd, code, ctx) {
275
+ export function loadFeatureContext(cwd, code, ctx) {
276
276
  const folder = ctx.foldersByCode.get(code);
277
277
  let featureJson = null;
278
278
  if (folder?.hasFeatureJson && ctx.featureJsonMode) {
@@ -16,7 +16,7 @@
16
16
  * be called from MCP tools, the CLI, or future REST routes.
17
17
  */
18
18
 
19
- import { existsSync, realpathSync, statSync, readFileSync } from 'fs';
19
+ import { existsSync, realpathSync, statSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
20
20
  import { resolve, normalize, sep, basename, dirname, join } from 'path';
21
21
 
22
22
  import { readEvents } from './feature-events.js';
@@ -29,7 +29,7 @@ import { knownFeatureCodes, FeatureWriteValidationError } from './feature-write-
29
29
  // providerFor is imported lazily (inside each function) to break the
30
30
  // module-load-time cycle: factory.js → local-provider.js → feature-writer.js.
31
31
  // Dynamic import resolves at call time, after all modules have loaded.
32
- async function getProvider(cwd) {
32
+ export async function getProvider(cwd) {
33
33
  const { providerFor } = await import('./tracker/factory.js');
34
34
  return providerFor(cwd);
35
35
  }
@@ -209,7 +209,7 @@ function partialWriteError(message, cause) {
209
209
  // wrapping GitHubProvider otherwise — name() resolves correctly through both.
210
210
  // Test mock providers that don't model the local file tracker won't report
211
211
  // 'local', so the guard is correctly skipped for them.
212
- function isLocalProvider(provider) {
212
+ export function isLocalProvider(provider) {
213
213
  return typeof provider?.name === 'function' && provider.name() === 'local';
214
214
  }
215
215
 
@@ -647,6 +647,161 @@ export async function linkFeatures(cwd, args) {
647
647
  });
648
648
  }
649
649
 
650
+ // ---------------------------------------------------------------------------
651
+ // rewriteLinks — replace a feature's entire links[] array in one write
652
+ // ---------------------------------------------------------------------------
653
+
654
+ /**
655
+ * Replace `feature.links` wholesale. Unlike linkFeatures (add/upsert-only), this
656
+ * is the removal/repair primitive the reconciler (COMP-MCP-VALIDATE-2) needs:
657
+ * dropping danglings and repairing invalid kinds in a SINGLE write so the
658
+ * whole-array validation in putFeature can't block one fix behind another.
659
+ * The caller computes the corrected array; this persists it through the same
660
+ * VALIDATE-1 guard (delta-aware existence) and audit chokepoint as the other
661
+ * link writers.
662
+ *
663
+ * @param {string} cwd
664
+ * @param {object} args
665
+ * @param {string} args.from_code
666
+ * @param {Array<{kind:string,to_code?:string,note?:string,provider?:string}>} args.links
667
+ * @param {boolean} [args.allowForwardRefs] — permit a known-good forward ref target
668
+ * @param {string} [args.idempotency_key]
669
+ */
670
+ export async function rewriteLinks(cwd, args) {
671
+ validateCode(args.from_code);
672
+ if (!Array.isArray(args.links)) {
673
+ throw new Error('feature-writer: rewriteLinks requires args.links to be an array');
674
+ }
675
+ return maybeIdempotent({ ...args, cwd }, async () => {
676
+ const provider = await getProvider(cwd);
677
+ const feature = await provider.getFeature(args.from_code);
678
+ if (!feature) {
679
+ throw new Error(`feature-writer: feature "${args.from_code}" not found`);
680
+ }
681
+ const before = Array.isArray(feature.links) ? feature.links.length : 0;
682
+ await provider.putFeature(
683
+ args.from_code,
684
+ { ...feature, links: args.links },
685
+ { allowForwardRefs: args.allowForwardRefs === true },
686
+ );
687
+ await safeAppendEvent(cwd, {
688
+ tool: 'rewrite_links',
689
+ code: args.from_code,
690
+ before_count: before,
691
+ after_count: args.links.length,
692
+ idempotency_key: args.idempotency_key,
693
+ });
694
+ return { from_code: args.from_code, before_count: before, after_count: args.links.length };
695
+ });
696
+ }
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // setRoadmapRowStatus — surgical single-cell ROADMAP status edit
700
+ // ---------------------------------------------------------------------------
701
+
702
+ /**
703
+ * Replace ONLY the Status cell of a feature's ROADMAP.md table row, leaving every
704
+ * other byte of the file untouched. Used by the reconciler to heal a
705
+ * ROADMAP-row↔feature.json status drift when feature.json is already canonical.
706
+ *
707
+ * A full renderRoadmap() is unsafe here: on any ROADMAP that is not already in
708
+ * exact generated form it appends a generated section beside the hand-authored
709
+ * one, producing duplicate conflicting rows (COMP-MCP-VALIDATE-2 finding). This
710
+ * surgical edit mirrors the validator's column-aware row parser
711
+ * (feature-validator.js:139-203): it locates the table by header (Feature/Status
712
+ * columns), finds the data row whose code matches, and rewrites just the status
713
+ * token in that cell — preserving spacing and any emphasis markers.
714
+ *
715
+ * ROADMAP uses the same UPPERCASE status vocabulary as feature.json, so `status`
716
+ * is written verbatim (no projection).
717
+ *
718
+ * @param {string} cwd
719
+ * @param {object} args
720
+ * @param {string} args.code
721
+ * @param {string} args.status — canonical UPPERCASE status (e.g. 'IN_PROGRESS')
722
+ * @param {string} [args.idempotency_key]
723
+ * @returns {Promise<{code:string, changed:boolean, from?:string, to?:string}>}
724
+ */
725
+ export async function setRoadmapRowStatus(cwd, args) {
726
+ validateCode(args.code);
727
+ if (!STATUSES.has(args.status)) {
728
+ throw new Error(`feature-writer: invalid status "${args.status}"`);
729
+ }
730
+ return maybeIdempotent({ ...args, cwd }, async () => {
731
+ const roadmapPath = join(cwd, 'ROADMAP.md');
732
+ let text;
733
+ try { text = readFileSync(roadmapPath, 'utf-8'); }
734
+ catch { return { code: args.code, changed: false }; }
735
+
736
+ const lines = text.split('\n');
737
+ let codeIdx = -1, statusIdx = -1, inTable = false, sawSeparator = false;
738
+ // Record the LAST matching row, not the first: the validator builds
739
+ // roadmapByCode = new Map(rows.map(...)) so a later duplicate row wins
740
+ // (feature-validator.js:206). Patching the first occurrence on a ROADMAP with
741
+ // duplicate rows would leave the validator's (last) row mismatched → no
742
+ // convergence. Mirror its last-wins semantics.
743
+ let target = null; // { line, cellPos, from }
744
+
745
+ for (let i = 0; i < lines.length; i++) {
746
+ const rawLine = lines[i];
747
+ if (/^##\s+/.test(rawLine)) { inTable = false; sawSeparator = false; codeIdx = statusIdx = -1; continue; }
748
+ const rowMatch = rawLine.match(/^\|(.+)\|\s*$/);
749
+ if (!rowMatch) { inTable = false; sawSeparator = false; continue; }
750
+ const cols = rowMatch[1].split('|').map((c) => c.trim());
751
+ const lower = cols.map((c) => c.toLowerCase());
752
+ const fCol = lower.findIndex((c) => ['feature', 'code', 'item', 'name'].includes(c));
753
+ const sCol = lower.findIndex((c) => ['status', 'state'].includes(c));
754
+ if (fCol >= 0 && sCol >= 0) { codeIdx = fCol; statusIdx = sCol; inTable = true; sawSeparator = false; continue; }
755
+ if (cols.every((c) => /^[-:]+$/.test(c))) { if (inTable) sawSeparator = true; continue; }
756
+ if (!inTable || !sawSeparator || codeIdx < 0 || statusIdx < 0) continue;
757
+ if (codeIdx >= cols.length || statusIdx >= cols.length) continue;
758
+
759
+ const codeRaw = cols[codeIdx].replace(/\*/g, '').replace(/`/g, '').trim();
760
+ if (codeRaw !== args.code) continue;
761
+
762
+ // Refuse rows containing an escaped pipe: `\|` splits a logical cell in two,
763
+ // so the header-derived column index can land mid-prose. Skipping such a
764
+ // row avoids the only realistic corruption vector (the validator mis-parses
765
+ // it too — surfaced honestly as an unfixed mismatch rather than mangled).
766
+ if (rawLine.includes('\\|')) continue;
767
+
768
+ // inner-cell index → raw-line index: rawLine.split('|') = ['', ...innerCells, '']
769
+ // so innerCells[statusIdx] === rawCells[statusIdx+1]. The validator reads the
770
+ // same header-identified cell, so overwriting it converges — including when
771
+ // the current value is a non-canonical alpha status (e.g. "Done").
772
+ const rawCells = rawLine.split('|');
773
+ const cellPos = statusIdx + 1;
774
+ if (cellPos >= rawCells.length) continue;
775
+ const tok = rawCells[cellPos].match(/[A-Za-z_]+/);
776
+ // Require an alpha status token. An empty / purely-numeric cell is too
777
+ // ambiguous to safely rewrite, so skip it (reported as changed:false).
778
+ if (!tok) continue;
779
+ target = { line: i, cellPos, from: tok[0] };
780
+ }
781
+
782
+ if (!target) return { code: args.code, changed: false };
783
+ const fromStatus = target.from;
784
+ if (fromStatus === args.status) return { code: args.code, changed: false, from: fromStatus, to: args.status };
785
+ const rawCells = lines[target.line].split('|');
786
+ rawCells[target.cellPos] = rawCells[target.cellPos].replace(/[A-Za-z_]+/, args.status);
787
+ lines[target.line] = rawCells.join('|');
788
+
789
+ const out = lines.join('\n');
790
+ const tmp = `${roadmapPath}.tmp.${process.pid}`;
791
+ try { writeFileSync(tmp, out); renameSync(tmp, roadmapPath); }
792
+ catch (err) { try { unlinkSync(tmp); } catch { /* noop */ } throw err; }
793
+
794
+ await safeAppendEvent(cwd, {
795
+ tool: 'set_roadmap_row_status',
796
+ code: args.code,
797
+ from: fromStatus,
798
+ to: args.status,
799
+ idempotency_key: args.idempotency_key,
800
+ });
801
+ return { code: args.code, changed: true, from: fromStatus, to: args.status };
802
+ });
803
+ }
804
+
650
805
  const XREF_PROVIDERS = new Set(['github', 'local', 'url', 'jira', 'linear', 'notion', 'obsidian']);
651
806
  const XREF_URL_CLASS = new Set(['url', 'jira', 'linear', 'notion', 'obsidian']);
652
807
  const URI_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.20-beta",
3
+ "version": "0.2.21-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -431,12 +431,30 @@ export async function toolValidateFeature(args = {}) {
431
431
 
432
432
  export async function toolValidateProject(args = {}) {
433
433
  const { validateProject } = await import('../lib/feature-validator.js');
434
- const { external_prefixes, feature_json_mode, external } = args;
435
- return validateProject(getTargetRoot(), {
434
+ const { external_prefixes, feature_json_mode, external, fix, apply, fix_classes } = args;
435
+ const opts = {
436
436
  externalPrefixes: external_prefixes,
437
437
  featureJsonMode: feature_json_mode,
438
438
  external: external === true,
439
+ };
440
+ const result = await validateProject(getTargetRoot(), opts);
441
+ if (!fix) return result;
442
+ // COMP-MCP-VALIDATE-2: reconcile mechanical drift. Forwards the same validate
443
+ // options so the fixer operates on the same context that was validated.
444
+ const { reconcileProject } = await import('../lib/feature-reconciler.js');
445
+ const reconcile = await reconcileProject(getTargetRoot(), {
446
+ apply: apply === true,
447
+ classes: fix_classes,
448
+ featureJsonMode: feature_json_mode,
449
+ externalPrefixes: external_prefixes,
450
+ external: external === true,
439
451
  });
452
+ // When fixes were applied, re-validate so the returned findings reflect the
453
+ // post-fix state (closed loop).
454
+ const finalResult = (apply === true && !reconcile.refused)
455
+ ? await validateProject(getTargetRoot(), opts)
456
+ : result;
457
+ return { ...finalResult, reconcile };
440
458
  }
441
459
 
442
460
  export async function toolBindSession({ featureCode, profile } = {}) {
@@ -377,6 +377,9 @@ const TOOLS = [
377
377
  external_prefixes: { type: 'array', items: { type: 'string' } },
378
378
  feature_json_mode: { type: 'boolean' },
379
379
  external: { type: 'boolean', description: 'Resolve github external refs over the network (read-only). Default false: github refs degrade to XREF_RESOLUTION_SKIPPED.' },
380
+ fix: { type: 'boolean', description: 'COMP-MCP-VALIDATE-2: reconcile mechanical drift. Returns a fix plan under `reconcile` (dry-run unless apply:true). Local-provider only.' },
381
+ apply: { type: 'boolean', description: 'With fix:true, write the fixes and re-validate. Default false (dry-run plan only).' },
382
+ fix_classes: { type: 'array', items: { type: 'string' }, description: 'Override the enabled fix classes: dangling_link, invalid_link_kind, status_fj_vision, partial_age, roadmap_status_rewrite, invalid_link_kind_repair. Default: the non-destructive set.' },
380
383
  },
381
384
  },
382
385
  },