@smartmemory/compose 0.2.19-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 +64 -2
- package/lib/feature-json.js +12 -2
- package/lib/feature-reconciler.js +358 -0
- package/lib/feature-validator.js +3 -3
- package/lib/feature-writer.js +158 -3
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +20 -2
- package/server/compose-mcp.js +3 -0
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) {
|
package/lib/feature-json.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/feature-validator.js
CHANGED
|
@@ -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) {
|
package/lib/feature-writer.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 } = {}) {
|
package/server/compose-mcp.js
CHANGED
|
@@ -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
|
},
|