@smartmemory/compose 0.2.17-beta → 0.2.19-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 +5 -3
- package/dist/assets/{App-CG-2euMe.js → App-rQ0dy9X4.js} +168 -168
- package/dist/assets/{arc-7QBWoLra.js → arc-pUkmrihQ.js} +1 -1
- package/dist/assets/{architectureDiagram-3BPJPVTR-CUw-7uLm.js → architectureDiagram-3BPJPVTR-HfQQjJmI.js} +1 -1
- package/dist/assets/{blockDiagram-GPEHLZMM-COU1vmr7.js → blockDiagram-GPEHLZMM-BsKGohnn.js} +1 -1
- package/dist/assets/{c4Diagram-AAUBKEIU-XPO9PSJL.js → c4Diagram-AAUBKEIU-BKxSEqgT.js} +1 -1
- package/dist/assets/channel-Dle3XKII.js +1 -0
- package/dist/assets/{chunk-2J33WTMH-zMzVB2a6.js → chunk-2J33WTMH-CHHmGuZd.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-Kke_qcHU.js → chunk-4BX2VUAB-BElBpOhR.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-hMeFx5Nh.js → chunk-55IACEB6-DflFI1z8.js} +1 -1
- package/dist/assets/{chunk-727SXJPM-DesUnrEw.js → chunk-727SXJPM-D9nK-RWm.js} +1 -1
- package/dist/assets/{chunk-AQP2D5EJ-1uGGvkxW.js → chunk-AQP2D5EJ-Dbn0WxRo.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DYHv1PcZ.js → chunk-FMBD7UC4-BGcm6Vvv.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-D0MENOLX.js → chunk-ND2GUHAM-BB1l7iXM.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-8nn3HP-N.js → chunk-QZHKN3VN-DBednlUY.js} +1 -1
- package/dist/assets/classDiagram-4FO5ZUOK-C_u9Angz.js +1 -0
- package/dist/assets/classDiagram-v2-Q7XG4LA2-C_u9Angz.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BoZPVIny.js → cose-bilkent-S5V4N54A-DPu8eCS8.js} +1 -1
- package/dist/assets/{dagre-BM42HDAG-BgZzdLG9.js → dagre-BM42HDAG-CO-3fQv_.js} +1 -1
- package/dist/assets/{diagram-2AECGRRQ-CknAnpSu.js → diagram-2AECGRRQ-ES8OaCtj.js} +1 -1
- package/dist/assets/{diagram-5GNKFQAL-CZUEbKim.js → diagram-5GNKFQAL-DAxAGwHz.js} +1 -1
- package/dist/assets/{diagram-KO2AKTUF-DCs-pLdH.js → diagram-KO2AKTUF-Ch8FzysS.js} +1 -1
- package/dist/assets/{diagram-LMA3HP47-lRaDjIfM.js → diagram-LMA3HP47-CKzGPl09.js} +1 -1
- package/dist/assets/{diagram-OG6HWLK6-CIGqmehP.js → diagram-OG6HWLK6-DO22Vj8Q.js} +1 -1
- package/dist/assets/{erDiagram-TEJ5UH35-Lx3c2N6F.js → erDiagram-TEJ5UH35-BqW54tXR.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-VoluKqSq.js → flowDiagram-I6XJVG4X-B2b-dSfR.js} +1 -1
- package/dist/assets/{ganttDiagram-6RSMTGT7-D7hETiNZ.js → ganttDiagram-6RSMTGT7-CmJFWbDF.js} +1 -1
- package/dist/assets/{gitGraphDiagram-PVQCEYII-DenEcUvY.js → gitGraphDiagram-PVQCEYII-BT8o_4m6.js} +1 -1
- package/dist/assets/{index-B4dv3acY.js → index-D1hzx617.js} +2 -2
- package/dist/assets/{infoDiagram-5YYISTIA-v7cq9Er9.js → infoDiagram-5YYISTIA-BqunByDN.js} +1 -1
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-CfCCXt2x.js → ishikawaDiagram-YF4QCWOH-B7d9fw7d.js} +1 -1
- package/dist/assets/{journeyDiagram-JHISSGLW-Bbokl_xO.js → journeyDiagram-JHISSGLW-gEGcRX8K.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-DhkOZ2hg.js → kanban-definition-UN3LZRKU-Ce6Ql7ld.js} +1 -1
- package/dist/assets/{linear-bHjluRm2.js → linear-DmFVERwp.js} +1 -1
- package/dist/assets/{mindmap-definition-RKZ34NQL-C1bHpoXH.js → mindmap-definition-RKZ34NQL-B5XNiIMw.js} +1 -1
- package/dist/assets/{pieDiagram-4H26LBE5-CZb1i55T.js → pieDiagram-4H26LBE5-DkZl7DXu.js} +1 -1
- package/dist/assets/{quadrantDiagram-W4KKPZXB-o37AwRHB.js → quadrantDiagram-W4KKPZXB-2lwVSBWb.js} +1 -1
- package/dist/assets/{requirementDiagram-4Y6WPE33-BVErWDzU.js → requirementDiagram-4Y6WPE33-C0t5AupD.js} +1 -1
- package/dist/assets/{sankeyDiagram-5OEKKPKP-BhBK8gHQ.js → sankeyDiagram-5OEKKPKP-CeXMM5uX.js} +1 -1
- package/dist/assets/{sequenceDiagram-3UESZ5HK-CsICF23P.js → sequenceDiagram-3UESZ5HK-B3NY1tZo.js} +1 -1
- package/dist/assets/{stateDiagram-AJRCARHV-TN1AXwim.js → stateDiagram-AJRCARHV-DWmuapu-.js} +1 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-Dk4v8A5E.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-DftAajbU.js → timeline-definition-PNZ67QCA-BcThQznB.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-cFTMstT7.js → vennDiagram-CIIHVFJN-dfCrxHjF.js} +1 -1
- package/dist/assets/{wardley-L42UT6IY-DL8CivzO.js → wardley-L42UT6IY-Cp-dRZDP.js} +1 -1
- package/dist/assets/{wardleyDiagram-YWT4CUSO-BDZT1hQj.js → wardleyDiagram-YWT4CUSO-BaxHdcre.js} +1 -1
- package/dist/assets/{xychartDiagram-2RQKCTM6-DQQSkfC4.js → xychartDiagram-2RQKCTM6-BmPqraoL.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/feature-json.js +17 -1
- package/lib/feature-validator.js +10 -1
- package/lib/feature-write-guard.js +201 -0
- package/lib/feature-writer.js +42 -1
- package/lib/status-projection.js +36 -0
- package/lib/tracker/github-provider.js +10 -3
- package/lib/tracker/local-provider.js +2 -2
- package/package.json +1 -1
- package/scripts/backproject-vision-status.mjs +96 -0
- package/server/feature-scan.js +13 -0
- package/server/graph-export.js +1 -0
- package/server/ideabox-routes.js +5 -3
- package/server/vision-store.js +1 -1
- package/dist/assets/channel-Bcu04MIK.js +0 -1
- package/dist/assets/classDiagram-4FO5ZUOK-DU4yxldU.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-DU4yxldU.js +0 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-BLR6AkKX.js +0 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feature-write-guard.js — Write-time feature.json validation (COMP-MCP-VALIDATE-1).
|
|
3
|
+
*
|
|
4
|
+
* The feature.json schema (contracts/feature-json.schema.json) and the
|
|
5
|
+
* cross-reference existence rule were historically enforced ONLY on read, by
|
|
6
|
+
* lib/feature-validator.js. This module enforces the SAME rules at write time so
|
|
7
|
+
* malformed shape / invalid link kind / dangling to_code is rejected before
|
|
8
|
+
* commit — closing the source of FEATURE_JSON_SCHEMA_VIOLATION and
|
|
9
|
+
* DANGLING_LINK_FEATURES_TARGET.
|
|
10
|
+
*
|
|
11
|
+
* Layering: imports only the Ajv SchemaValidator (server/) and the feature-code
|
|
12
|
+
* regex. It does NOT import feature-json.js, feature-validator.js, or
|
|
13
|
+
* feature-writer.js — those import this module, so this stays a leaf to keep the
|
|
14
|
+
* graph acyclic.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
18
|
+
import { join, resolve, dirname } from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
import { SchemaValidator } from '../server/schema-validator.js';
|
|
21
|
+
import { FEATURE_CODE_RE_STRICT } from './feature-code.js';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const SCHEMA_PATH = resolve(__dirname, '../contracts/feature-json.schema.json');
|
|
25
|
+
|
|
26
|
+
// Memoize the compiled validator (the schema is static). This is the only thing
|
|
27
|
+
// safe to cache — code-existence sources change at runtime and are read fresh.
|
|
28
|
+
let _validator = null;
|
|
29
|
+
function validator() {
|
|
30
|
+
if (!_validator) _validator = new SchemaValidator(SCHEMA_PATH);
|
|
31
|
+
return _validator;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Thrown when a feature.json write would persist invalid data.
|
|
36
|
+
* `kind` mirrors the read validator's finding kinds so callers can branch.
|
|
37
|
+
*/
|
|
38
|
+
export class FeatureWriteValidationError extends Error {
|
|
39
|
+
/**
|
|
40
|
+
* @param {'FEATURE_JSON_SCHEMA_VIOLATION'|'DANGLING_LINK_FEATURES_TARGET'} kind
|
|
41
|
+
* @param {string[]} violations
|
|
42
|
+
*/
|
|
43
|
+
constructor(kind, violations) {
|
|
44
|
+
super(`${kind}: ${violations.join('; ')}`);
|
|
45
|
+
this.name = 'FeatureWriteValidationError';
|
|
46
|
+
this.kind = kind;
|
|
47
|
+
this.violations = violations;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate a feature's `links[]` against the canonical JSON schema. Throws
|
|
53
|
+
* FeatureWriteValidationError('FEATURE_JSON_SCHEMA_VIOLATION') for any link-shape
|
|
54
|
+
* violation (bad `kind` enum, missing `to_code` on a non-external link, malformed
|
|
55
|
+
* external-link provider fields, …).
|
|
56
|
+
*
|
|
57
|
+
* SCOPE (COMP-MCP-VALIDATE-1): only `/links/*` violations are enforced at write
|
|
58
|
+
* time. Whole-object schema tightening — `complexity` enum convergence, the
|
|
59
|
+
* `artifacts[].type` enum, `additionalProperties` — is **deliberately deferred**
|
|
60
|
+
* to COMP-MCP-VALIDATE-SCHEMA-TIGHTEN (see contracts/feature-json.schema.json
|
|
61
|
+
* field comments), and the writers legitimately produce values that pass only
|
|
62
|
+
* the permissive read schema today. -1 closes the link-kind / link-shape source
|
|
63
|
+
* named in its charter, nothing wider.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} feature
|
|
66
|
+
*/
|
|
67
|
+
export function assertValidLinkShape(feature) {
|
|
68
|
+
const { valid, errors } = validator().validateRoot(feature);
|
|
69
|
+
if (valid) return;
|
|
70
|
+
const linkErrors = (errors || []).filter((e) => (e.instancePath || '').startsWith('/links'));
|
|
71
|
+
if (linkErrors.length === 0) return;
|
|
72
|
+
throw new FeatureWriteValidationError(
|
|
73
|
+
'FEATURE_JSON_SCHEMA_VIOLATION',
|
|
74
|
+
linkErrors.map((e) => `${e.instancePath}: ${e.message}`),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolvePaths(cwd) {
|
|
79
|
+
let featuresRel = 'docs/features';
|
|
80
|
+
try {
|
|
81
|
+
const cfg = JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf-8'));
|
|
82
|
+
if (cfg?.paths?.features) featuresRel = cfg.paths.features;
|
|
83
|
+
} catch { /* no config — use defaults (mirrors resolveProjectPaths) */ }
|
|
84
|
+
return {
|
|
85
|
+
features: join(cwd, featuresRel),
|
|
86
|
+
roadmap: join(cwd, 'ROADMAP.md'),
|
|
87
|
+
visionState: join(cwd, '.compose', 'data', 'vision-state.json'),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Strict feature codes present in a ROADMAP.md table. Self-contained, lean
|
|
93
|
+
* mirror of the validator's column-aware scan (lib/feature-validator.js:144-202)
|
|
94
|
+
* — NOT extracted from it, because that loop is dual-purpose (it also builds
|
|
95
|
+
* citationRows for XREF parsing). We need only the code set.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} roadmapPath
|
|
98
|
+
* @returns {string[]}
|
|
99
|
+
*/
|
|
100
|
+
export function scanRoadmapRows(roadmapPath) {
|
|
101
|
+
const codes = [];
|
|
102
|
+
let text;
|
|
103
|
+
try { text = readFileSync(roadmapPath, 'utf8'); } catch { return codes; }
|
|
104
|
+
|
|
105
|
+
let codeIdx = -1, statusIdx = -1;
|
|
106
|
+
let inTable = false, sawSeparator = false;
|
|
107
|
+
for (const rawLine of text.split('\n')) {
|
|
108
|
+
if (/^##\s+/.test(rawLine)) { inTable = false; sawSeparator = false; codeIdx = statusIdx = -1; continue; }
|
|
109
|
+
const rowMatch = rawLine.match(/^\|(.+)\|\s*$/);
|
|
110
|
+
if (!rowMatch) { inTable = false; sawSeparator = false; continue; }
|
|
111
|
+
const cols = rowMatch[1].split('|').map((c) => c.trim());
|
|
112
|
+
const lower = cols.map((c) => c.toLowerCase());
|
|
113
|
+
const featureColIdx = lower.findIndex((c) => ['feature', 'code', 'item', 'name'].includes(c));
|
|
114
|
+
const statusColIdx = lower.findIndex((c) => ['status', 'state'].includes(c));
|
|
115
|
+
if (featureColIdx >= 0 && statusColIdx >= 0) {
|
|
116
|
+
codeIdx = featureColIdx; statusIdx = statusColIdx; inTable = true; sawSeparator = false; continue;
|
|
117
|
+
}
|
|
118
|
+
if (cols.every((c) => /^[-:]+$/.test(c))) { if (inTable) sawSeparator = true; continue; }
|
|
119
|
+
if (!inTable || !sawSeparator || codeIdx < 0 || codeIdx >= cols.length) continue;
|
|
120
|
+
const codeRaw = cols[codeIdx].replace(/\*/g, '').replace(/`/g, '').trim();
|
|
121
|
+
if (FEATURE_CODE_RE_STRICT.test(codeRaw)) codes.push(codeRaw);
|
|
122
|
+
}
|
|
123
|
+
return codes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The set of feature codes that "exist" in any authoritative source — feature
|
|
128
|
+
* folders, ROADMAP rows, or vision-state items. Mirrors the union the read
|
|
129
|
+
* validator's dangling-link check consults (foldersByCode ∪ roadmapByCode ∪
|
|
130
|
+
* visionByCode). Read fresh every call (no memo): ROADMAP and vision-state
|
|
131
|
+
* change independently of feature writes in long-lived processes.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} cwd
|
|
134
|
+
* @returns {Set<string>}
|
|
135
|
+
*/
|
|
136
|
+
export function knownFeatureCodes(cwd) {
|
|
137
|
+
const paths = resolvePaths(cwd);
|
|
138
|
+
const codes = new Set();
|
|
139
|
+
|
|
140
|
+
// Feature folders
|
|
141
|
+
if (existsSync(paths.features)) {
|
|
142
|
+
for (const dirent of readdirSync(paths.features, { withFileTypes: true })) {
|
|
143
|
+
if (dirent.isDirectory() && FEATURE_CODE_RE_STRICT.test(dirent.name)) codes.add(dirent.name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ROADMAP rows
|
|
148
|
+
for (const code of scanRoadmapRows(paths.roadmap)) codes.add(code);
|
|
149
|
+
|
|
150
|
+
// Vision-state items
|
|
151
|
+
try {
|
|
152
|
+
const vs = JSON.parse(readFileSync(paths.visionState, 'utf8'));
|
|
153
|
+
for (const item of (Array.isArray(vs.items) ? vs.items : [])) {
|
|
154
|
+
const code = item?.lifecycle?.featureCode || item?.featureCode;
|
|
155
|
+
if (code && FEATURE_CODE_RE_STRICT.test(code)) codes.add(code);
|
|
156
|
+
}
|
|
157
|
+
} catch { /* missing vision-state — folders/roadmap still apply */ }
|
|
158
|
+
|
|
159
|
+
return codes;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Assert every same-project link target (non-external to_code) that this write
|
|
164
|
+
* INTRODUCES exists. No-op (zero I/O) when the feature carries no such links —
|
|
165
|
+
* the common write.
|
|
166
|
+
*
|
|
167
|
+
* Delta-aware: only links not already present in `opts.priorLinks` (the on-disk
|
|
168
|
+
* version) are checked. This is the correct write-time semantic — reject new
|
|
169
|
+
* drift, not pre-existing state — and it makes a legitimately forced
|
|
170
|
+
* forward-reference durable: once persisted, later unrelated writes (status,
|
|
171
|
+
* completions, build lifecycle) re-run this guard but skip the now-existing
|
|
172
|
+
* link, so they don't spuriously throw `DANGLING_LINK_FEATURES_TARGET`.
|
|
173
|
+
*
|
|
174
|
+
* Throws FeatureWriteValidationError('DANGLING_LINK_FEATURES_TARGET') for any
|
|
175
|
+
* newly-introduced missing target unless opts.allowForwardRefs is set (the
|
|
176
|
+
* explicit force path that introduces the forward-reference in the first place).
|
|
177
|
+
*
|
|
178
|
+
* @param {string} cwd
|
|
179
|
+
* @param {object} feature
|
|
180
|
+
* @param {{allowForwardRefs?: boolean, priorLinks?: Array}} [opts]
|
|
181
|
+
*/
|
|
182
|
+
export function assertLinkTargetsExist(cwd, feature, opts = {}) {
|
|
183
|
+
if (opts.allowForwardRefs) return;
|
|
184
|
+
const links = Array.isArray(feature?.links) ? feature.links : [];
|
|
185
|
+
const prior = Array.isArray(opts.priorLinks) ? opts.priorLinks : [];
|
|
186
|
+
const isPrior = (l) => prior.some((p) => p.kind === l.kind && p.to_code === l.to_code);
|
|
187
|
+
const targets = links
|
|
188
|
+
.filter((l) => l && l.kind !== 'external' && typeof l.to_code === 'string')
|
|
189
|
+
.filter((l) => !isPrior(l)) // only newly-introduced links
|
|
190
|
+
.map((l) => l.to_code);
|
|
191
|
+
if (targets.length === 0) return; // cheap path — no scan
|
|
192
|
+
|
|
193
|
+
const known = knownFeatureCodes(cwd);
|
|
194
|
+
const missing = targets.filter((code) => code !== feature.code && !known.has(code));
|
|
195
|
+
if (missing.length > 0) {
|
|
196
|
+
throw new FeatureWriteValidationError(
|
|
197
|
+
'DANGLING_LINK_FEATURES_TARGET',
|
|
198
|
+
missing.map((code) => `${code} does not exist in any source`),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
package/lib/feature-writer.js
CHANGED
|
@@ -24,6 +24,7 @@ import { checkOrInsert } from './idempotency.js';
|
|
|
24
24
|
import { loadFeaturesDir } from './project-paths.js';
|
|
25
25
|
import { checkRoundtrip } from './roadmap-roundtrip.js';
|
|
26
26
|
import { isNarrativeOwned, narrativeOwnedMessage } from './roadmap-config.js';
|
|
27
|
+
import { knownFeatureCodes, FeatureWriteValidationError } from './feature-write-guard.js';
|
|
27
28
|
|
|
28
29
|
// providerFor is imported lazily (inside each function) to break the
|
|
29
30
|
// module-load-time cycle: factory.js → local-provider.js → feature-writer.js.
|
|
@@ -360,6 +361,28 @@ export async function setFeatureStatus(cwd, args) {
|
|
|
360
361
|
if (args.derived && !allowed.includes(to)) event.derived = true;
|
|
361
362
|
await safeAppendEvent(cwd, event);
|
|
362
363
|
|
|
364
|
+
// COMP-MCP-VALIDATE-3: project the new status onto vision-state so it stays
|
|
365
|
+
// in sync with the canonical feature.json instead of drifting as an orphan
|
|
366
|
+
// surface. Best-effort — vision-state is a downstream mirror, so its
|
|
367
|
+
// unavailability must never fail the canonical feature.json/ROADMAP write.
|
|
368
|
+
// Runs only on a real transition (the from===to noop returns above);
|
|
369
|
+
// pre-existing drift on an unchanged status is the migration's job.
|
|
370
|
+
// VisionWriter is dual-dispatch (REST when the server is up → the in-memory
|
|
371
|
+
// store stays the single writer authority; atomic file write when down).
|
|
372
|
+
try {
|
|
373
|
+
const { VisionWriter } = await import('./vision-writer.js');
|
|
374
|
+
const { featureStatusToVisionStatus } = await import('./status-projection.js');
|
|
375
|
+
const visStatus = featureStatusToVisionStatus(to);
|
|
376
|
+
if (visStatus) {
|
|
377
|
+
const writer = new VisionWriter(join(cwd, '.compose', 'data'));
|
|
378
|
+
const item = await writer.findFeatureItem(args.code);
|
|
379
|
+
if (item) await writer.updateItemStatus(item.id, visStatus);
|
|
380
|
+
}
|
|
381
|
+
} catch (err) {
|
|
382
|
+
// eslint-disable-next-line no-console
|
|
383
|
+
console.warn(`[feature-writer] vision-state projection failed for ${args.code}: ${err.message}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
363
386
|
return { code: args.code, from, to, ts: new Date().toISOString(), roundtrip };
|
|
364
387
|
});
|
|
365
388
|
}
|
|
@@ -580,17 +603,34 @@ export async function linkFeatures(cwd, args) {
|
|
|
580
603
|
l => l.kind === args.kind && l.to_code === args.to_code
|
|
581
604
|
);
|
|
582
605
|
|
|
606
|
+
// Re-issuing an existing link without force is a no-op — it introduces no
|
|
607
|
+
// new state, so it short-circuits BEFORE the dangling guard (otherwise an
|
|
608
|
+
// idempotent retry of a previously-forced forward-ref would wrongly throw).
|
|
583
609
|
if (matchIdx !== -1 && !args.force) {
|
|
584
610
|
return { from_code: args.from_code, to_code: args.to_code, kind: args.kind, noop: true };
|
|
585
611
|
}
|
|
586
612
|
|
|
613
|
+
// COMP-MCP-VALIDATE-1: reject a dangling target for genuinely new/updated
|
|
614
|
+
// links. Checked after the source-existence guard (a missing source is the
|
|
615
|
+
// more fundamental error). `force` overrides for intentional forward-refs
|
|
616
|
+
// (link A→B before B is scaffolded).
|
|
617
|
+
const targetMissing = !knownFeatureCodes(cwd).has(args.to_code);
|
|
618
|
+
if (targetMissing && !args.force) {
|
|
619
|
+
throw new FeatureWriteValidationError(
|
|
620
|
+
'DANGLING_LINK_FEATURES_TARGET',
|
|
621
|
+
[`${args.to_code} does not exist in any source (pass force to override)`],
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
587
625
|
const entry = { kind: args.kind, to_code: args.to_code };
|
|
588
626
|
if (args.note) entry.note = args.note;
|
|
589
627
|
|
|
590
628
|
if (matchIdx !== -1) links[matchIdx] = entry;
|
|
591
629
|
else links.push(entry);
|
|
592
630
|
|
|
593
|
-
|
|
631
|
+
// allowForwardRefs only matters when the target is missing (force path);
|
|
632
|
+
// when it exists the chokepoint existence re-check passes anyway.
|
|
633
|
+
await provider.putFeature(args.from_code, { ...feature, links }, { allowForwardRefs: targetMissing });
|
|
594
634
|
|
|
595
635
|
await safeAppendEvent(cwd, {
|
|
596
636
|
tool: 'link_features',
|
|
@@ -599,6 +639,7 @@ export async function linkFeatures(cwd, args) {
|
|
|
599
639
|
kind: args.kind,
|
|
600
640
|
note: args.note,
|
|
601
641
|
forced: matchIdx !== -1 ? true : undefined,
|
|
642
|
+
forced_dangling: (args.force && targetMissing) ? true : undefined,
|
|
602
643
|
idempotency_key: args.idempotency_key,
|
|
603
644
|
});
|
|
604
645
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-projection.js — the single canonical status mapping (COMP-MCP-VALIDATE-3).
|
|
3
|
+
*
|
|
4
|
+
* Projects a feature/ROADMAP status (UPPERCASE roadmap vocabulary) onto the
|
|
5
|
+
* vision-state status (lowercase tracker vocabulary). Used on WRITE (the
|
|
6
|
+
* `setFeatureStatus` projection and the back-projection migration) AND on READ
|
|
7
|
+
* (the validator's `*_VS_VISION_STATE` comparison), so a status written by the
|
|
8
|
+
* projection can never itself trip STATUS_MISMATCH_*_VS_VISION_STATE — one rule
|
|
9
|
+
* set, enforced on write and read.
|
|
10
|
+
*
|
|
11
|
+
* Pure data, no IO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// feature/ROADMAP UPPERCASE status -> vision-state lowercase status.
|
|
15
|
+
const FEATURE_TO_VISION = {
|
|
16
|
+
PLANNED: 'planned',
|
|
17
|
+
IN_PROGRESS: 'in_progress',
|
|
18
|
+
PARTIAL: 'in_progress', // vision cannot represent "partially shipped"
|
|
19
|
+
COMPLETE: 'complete',
|
|
20
|
+
BLOCKED: 'blocked',
|
|
21
|
+
PARKED: 'parked',
|
|
22
|
+
KILLED: 'killed',
|
|
23
|
+
SUPERSEDED: 'superseded', // D1: vision VALID_STATUSES gains 'superseded'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string|null|undefined} status A feature/ROADMAP status (any case).
|
|
28
|
+
* @returns {string|null} The vision-state status, or null for empty/unknown
|
|
29
|
+
* input. Vision-native statuses with no feature-vocab key (e.g. ready/review)
|
|
30
|
+
* also return null; callers treat null as "no opinion" — the validator falls
|
|
31
|
+
* back to identity, the writer skips the projection.
|
|
32
|
+
*/
|
|
33
|
+
export function featureStatusToVisionStatus(status) {
|
|
34
|
+
if (!status) return null;
|
|
35
|
+
return FEATURE_TO_VISION[String(status).toUpperCase()] ?? null;
|
|
36
|
+
}
|
|
@@ -5,6 +5,7 @@ import { GitHubApi } from './github-api.js';
|
|
|
5
5
|
import { OpLog, Cache, ConflictLedger, Reconciler } from './sync-engine.js';
|
|
6
6
|
import { generateRoadmapFromBase } from '../roadmap-gen.js';
|
|
7
7
|
import { spliceChangelog } from '../changelog-writer.js';
|
|
8
|
+
import { assertValidLinkShape } from '../feature-write-guard.js';
|
|
8
9
|
|
|
9
10
|
const META_RE = /<!--compose-feature\n([\s\S]*?)\n-->/;
|
|
10
11
|
function encodeBody(obj) {
|
|
@@ -107,7 +108,8 @@ export class GitHubProvider extends TrackerProvider {
|
|
|
107
108
|
);
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
async createFeature(code, obj) {
|
|
111
|
+
async createFeature(code, obj, opts = {}) {
|
|
112
|
+
if (opts.validate !== false) assertValidLinkShape(obj);
|
|
111
113
|
return this._lock(code, async () => {
|
|
112
114
|
// Idempotent: if already in cache, return it.
|
|
113
115
|
const existing = await this.cache.get(code);
|
|
@@ -120,7 +122,11 @@ export class GitHubProvider extends TrackerProvider {
|
|
|
120
122
|
});
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
async putFeature(code, obj) {
|
|
125
|
+
async putFeature(code, obj, opts = {}) {
|
|
126
|
+
// COMP-MCP-VALIDATE-1: enforce link-shape on the GitHub backend too (same
|
|
127
|
+
// rule as the local chokepoint). Existence is local-folder semantics and is
|
|
128
|
+
// not applied here. `validate:false` opts out (migration/fixture planting).
|
|
129
|
+
if (opts.validate !== false) assertValidLinkShape(obj);
|
|
124
130
|
return this._lock(code, async () => {
|
|
125
131
|
const cur = await this.cache.get(code);
|
|
126
132
|
if (cur && obj.status && obj.status !== cur.status) {
|
|
@@ -140,7 +146,8 @@ export class GitHubProvider extends TrackerProvider {
|
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
// Raw write that allows status change (used by setStatus / policy layers).
|
|
143
|
-
async persistFeatureRaw(code, obj) {
|
|
149
|
+
async persistFeatureRaw(code, obj, opts = {}) {
|
|
150
|
+
if (opts.validate !== false) assertValidLinkShape(obj);
|
|
144
151
|
return this._lock(code, async () => {
|
|
145
152
|
await this.cache.put(code, obj, { pending: true });
|
|
146
153
|
await this.cache.markPending(code);
|
|
@@ -62,12 +62,12 @@ export class LocalFileProvider extends TrackerProvider {
|
|
|
62
62
|
return readFeature(this.cwd, code, this.featuresDir);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
async putFeature(code, obj) {
|
|
65
|
+
async putFeature(code, obj, opts = {}) {
|
|
66
66
|
const cur = readFeature(this.cwd, code, this.featuresDir);
|
|
67
67
|
if (cur && Object.prototype.hasOwnProperty.call(obj, 'status') && obj.status !== cur.status) {
|
|
68
68
|
throw new Error(`putFeature: status delta (${cur.status}->${obj.status}) not allowed; use setStatus`);
|
|
69
69
|
}
|
|
70
|
-
writeFeature(this.cwd, { ...obj, code }, this.featuresDir);
|
|
70
|
+
writeFeature(this.cwd, { ...obj, code }, this.featuresDir, opts);
|
|
71
71
|
return readFeature(this.cwd, code, this.featuresDir);
|
|
72
72
|
}
|
|
73
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.19-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",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* backproject-vision-status.mjs — one-time back-projection of historical
|
|
4
|
+
* vision-state status drift (COMP-MCP-VALIDATE-3).
|
|
5
|
+
*
|
|
6
|
+
* The typed writers now project status onto vision-state on every transition,
|
|
7
|
+
* but historical vision-state was never reconciled. This script projects the
|
|
8
|
+
* canonical feature.json status onto each bound vision item, clearing the
|
|
9
|
+
* pre-existing STATUS_MISMATCH_*_VS_VISION_STATE findings the write-time hook
|
|
10
|
+
* cannot reach (they predate any new mutation).
|
|
11
|
+
*
|
|
12
|
+
* Idempotent: a second run stages zero changes. Dry-run by default; pass
|
|
13
|
+
* --apply to write. Operates on the project root (default: process.cwd()),
|
|
14
|
+
* NOT compose's own data/.
|
|
15
|
+
*
|
|
16
|
+
* node scripts/backproject-vision-status.mjs # dry-run, cwd
|
|
17
|
+
* node scripts/backproject-vision-status.mjs --apply # write
|
|
18
|
+
* node scripts/backproject-vision-status.mjs --root /path/to/project --apply
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import { loadFeaturesDir } from '../lib/project-paths.js';
|
|
26
|
+
import { featureStatusToVisionStatus } from '../lib/status-projection.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute (and optionally apply) the back-projection for one project root.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} opts
|
|
32
|
+
* @param {string} opts.root Project root containing .compose/data + features dir.
|
|
33
|
+
* @param {boolean} [opts.apply=false] Write the reconciled state when true.
|
|
34
|
+
* @returns {{changes: Array<{code:string, id:string, from:string|null, to:string}>,
|
|
35
|
+
* skipped: number, total: number, applied: boolean}}
|
|
36
|
+
*/
|
|
37
|
+
export function backprojectVisionStatus({ root, apply = false }) {
|
|
38
|
+
const visionPath = join(root, '.compose', 'data', 'vision-state.json');
|
|
39
|
+
if (!existsSync(visionPath)) {
|
|
40
|
+
return { changes: [], skipped: 0, total: 0, applied: false };
|
|
41
|
+
}
|
|
42
|
+
const state = JSON.parse(readFileSync(visionPath, 'utf-8'));
|
|
43
|
+
const items = Array.isArray(state.items) ? state.items : [];
|
|
44
|
+
const featuresDir = join(root, loadFeaturesDir(root));
|
|
45
|
+
|
|
46
|
+
const changes = [];
|
|
47
|
+
let skipped = 0;
|
|
48
|
+
for (const item of items) {
|
|
49
|
+
const code = item.lifecycle?.featureCode || item.featureCode;
|
|
50
|
+
if (!code) { skipped++; continue; }
|
|
51
|
+
const fjPath = join(featuresDir, code, 'feature.json');
|
|
52
|
+
if (!existsSync(fjPath)) { skipped++; continue; } // UI-only / external items
|
|
53
|
+
let feature;
|
|
54
|
+
try {
|
|
55
|
+
feature = JSON.parse(readFileSync(fjPath, 'utf-8'));
|
|
56
|
+
} catch {
|
|
57
|
+
skipped++; continue;
|
|
58
|
+
}
|
|
59
|
+
const target = featureStatusToVisionStatus(feature.status);
|
|
60
|
+
if (!target) { skipped++; continue; }
|
|
61
|
+
if (item.status !== target) {
|
|
62
|
+
changes.push({ code, id: item.id, from: item.status ?? null, to: target });
|
|
63
|
+
item.status = target;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (apply && changes.length > 0) {
|
|
68
|
+
const tmp = `${visionPath}.tmp.${crypto.randomUUID()}`;
|
|
69
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
70
|
+
renameSync(tmp, visionPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { changes, skipped, total: items.length, applied: apply && changes.length > 0 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
77
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
78
|
+
if (isMain) {
|
|
79
|
+
const argv = process.argv.slice(2);
|
|
80
|
+
const apply = argv.includes('--apply');
|
|
81
|
+
const rootIdx = argv.indexOf('--root');
|
|
82
|
+
const root = rootIdx !== -1 ? argv[rootIdx + 1] : process.cwd();
|
|
83
|
+
|
|
84
|
+
const { changes, skipped, total, applied } = backprojectVisionStatus({ root, apply });
|
|
85
|
+
|
|
86
|
+
console.log(`Back-projection — ${total} vision items, ${skipped} skipped (no bound feature.json).`);
|
|
87
|
+
if (changes.length === 0) {
|
|
88
|
+
console.log('No drift. vision-state already matches canonical feature.json status.');
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`${changes.length} item(s) ${applied ? 'reconciled' : 'would change'}:`);
|
|
91
|
+
for (const c of changes) {
|
|
92
|
+
console.log(` ${c.code.padEnd(28)} ${String(c.from).padEnd(12)} → ${c.to}`);
|
|
93
|
+
}
|
|
94
|
+
if (!applied) console.log('\nDry-run. Re-run with --apply to write.');
|
|
95
|
+
}
|
|
96
|
+
}
|
package/server/feature-scan.js
CHANGED
|
@@ -16,6 +16,7 @@ import fs from 'node:fs';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
|
|
18
18
|
import { getTargetRoot, resolveProjectPath } from './project-root.js';
|
|
19
|
+
import { assertValidLinkShape } from '../lib/feature-write-guard.js';
|
|
19
20
|
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
21
22
|
// Metadata extraction
|
|
@@ -395,6 +396,18 @@ export function writeFeatureGroupToDisk(item, newGroup, featuresDir) {
|
|
|
395
396
|
spec.updated = new Date().toISOString().slice(0, 10);
|
|
396
397
|
}
|
|
397
398
|
|
|
399
|
+
// COMP-MCP-VALIDATE-1: never persist a malformed link shape via the vision
|
|
400
|
+
// route. Existence (DANGLING) is intentionally NOT checked here: this path
|
|
401
|
+
// only mutates `group`, so it cannot introduce a new dangling link, and
|
|
402
|
+
// re-validating existence would wrongly block a group rename on a feature that
|
|
403
|
+
// already carries a (possibly legitimately forced) forward-ref.
|
|
404
|
+
try {
|
|
405
|
+
assertValidLinkShape(spec);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
console.warn(`[feature-scan] writeFeatureGroupToDisk: invalid feature.json at ${specPath}, not writing: ${err.message}`);
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
|
|
398
411
|
try {
|
|
399
412
|
const tmp = path.join(featureDir, `feature.json.tmp.${Date.now()}.${process.pid}`);
|
|
400
413
|
fs.writeFileSync(tmp, JSON.stringify(spec, null, 2) + '\n', 'utf-8');
|
package/server/graph-export.js
CHANGED
package/server/ideabox-routes.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
updateIdea,
|
|
24
24
|
addDiscussion,
|
|
25
25
|
} from '../lib/ideabox.js'
|
|
26
|
+
import { writeFeature } from '../lib/feature-json.js'
|
|
26
27
|
import { IdeaboxCache } from './ideabox-cache.js'
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -182,14 +183,15 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
|
|
|
182
183
|
}
|
|
183
184
|
const featuresDir = path.join(projectRoot, featuresRel, resolvedCode)
|
|
184
185
|
if (!fs.existsSync(featuresDir)) {
|
|
185
|
-
|
|
186
|
-
fs.writeFileSync
|
|
186
|
+
// COMP-MCP-VALIDATE-1: route through the validated writer instead of a
|
|
187
|
+
// raw fs.writeFileSync so the promoted feature.json is schema-guarded.
|
|
188
|
+
writeFeature(projectRoot, {
|
|
187
189
|
code: resolvedCode,
|
|
188
190
|
description: sourceIdea.title,
|
|
189
191
|
status: 'PLANNED',
|
|
190
192
|
promotedFrom: sourceIdea.id,
|
|
191
193
|
createdAt: new Date().toISOString(),
|
|
192
|
-
},
|
|
194
|
+
}, featuresRel)
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
promoteIdea(parsed, id, resolvedCode)
|
package/server/vision-store.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
10
10
|
export const VALID_TYPES = ['feature', 'bug', 'track', 'idea', 'decision', 'question', 'thread', 'artifact', 'task', 'spec', 'evaluation'];
|
|
11
|
-
export const VALID_STATUSES = ['planned', 'ready', 'in_progress', 'review', 'complete', 'blocked', 'parked', 'killed'];
|
|
11
|
+
export const VALID_STATUSES = ['planned', 'ready', 'in_progress', 'review', 'complete', 'blocked', 'parked', 'killed', 'superseded'];
|
|
12
12
|
export const VALID_CONNECTION_TYPES = ['informs', 'blocks', 'supports', 'contradicts', 'implements'];
|
|
13
13
|
export const VALID_PHASES = ['vision', 'specification', 'planning', 'implementation', 'verification', 'release'];
|
|
14
14
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{ai as o,aj as n}from"./App-CG-2euMe.js";const t=(a,r)=>o.lang.round(n.parse(a)[r]);export{t as c};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as a,c as s,a as e,C as t}from"./chunk-727SXJPM-DesUnrEw.js";import{_ as i}from"./App-CG-2euMe.js";import"./chunk-FMBD7UC4-DYHv1PcZ.js";import"./chunk-ND2GUHAM-D0MENOLX.js";import"./chunk-55IACEB6-hMeFx5Nh.js";import"./chunk-2J33WTMH-zMzVB2a6.js";import"./mobile-CG5tLa2S.js";import"./index-B4dv3acY.js";import"./graph-Cs_vqCR0.js";var b={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{b as diagram};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as a,c as s,a as e,C as t}from"./chunk-727SXJPM-DesUnrEw.js";import{_ as i}from"./App-CG-2euMe.js";import"./chunk-FMBD7UC4-DYHv1PcZ.js";import"./chunk-ND2GUHAM-D0MENOLX.js";import"./chunk-55IACEB6-hMeFx5Nh.js";import"./chunk-2J33WTMH-zMzVB2a6.js";import"./mobile-CG5tLa2S.js";import"./index-B4dv3acY.js";import"./graph-Cs_vqCR0.js";var b={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{b as diagram};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as r,b as e,a,S as s}from"./chunk-AQP2D5EJ-1uGGvkxW.js";import{_ as i}from"./App-CG-2euMe.js";import"./chunk-55IACEB6-hMeFx5Nh.js";import"./chunk-2J33WTMH-zMzVB2a6.js";import"./mobile-CG5tLa2S.js";import"./index-B4dv3acY.js";import"./graph-Cs_vqCR0.js";var n={parser:a,get db(){return new s(2)},renderer:e,styles:r,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{n as diagram};
|