@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.
Files changed (65) hide show
  1. package/bin/compose.js +5 -3
  2. package/dist/assets/{App-CG-2euMe.js → App-rQ0dy9X4.js} +168 -168
  3. package/dist/assets/{arc-7QBWoLra.js → arc-pUkmrihQ.js} +1 -1
  4. package/dist/assets/{architectureDiagram-3BPJPVTR-CUw-7uLm.js → architectureDiagram-3BPJPVTR-HfQQjJmI.js} +1 -1
  5. package/dist/assets/{blockDiagram-GPEHLZMM-COU1vmr7.js → blockDiagram-GPEHLZMM-BsKGohnn.js} +1 -1
  6. package/dist/assets/{c4Diagram-AAUBKEIU-XPO9PSJL.js → c4Diagram-AAUBKEIU-BKxSEqgT.js} +1 -1
  7. package/dist/assets/channel-Dle3XKII.js +1 -0
  8. package/dist/assets/{chunk-2J33WTMH-zMzVB2a6.js → chunk-2J33WTMH-CHHmGuZd.js} +1 -1
  9. package/dist/assets/{chunk-4BX2VUAB-Kke_qcHU.js → chunk-4BX2VUAB-BElBpOhR.js} +1 -1
  10. package/dist/assets/{chunk-55IACEB6-hMeFx5Nh.js → chunk-55IACEB6-DflFI1z8.js} +1 -1
  11. package/dist/assets/{chunk-727SXJPM-DesUnrEw.js → chunk-727SXJPM-D9nK-RWm.js} +1 -1
  12. package/dist/assets/{chunk-AQP2D5EJ-1uGGvkxW.js → chunk-AQP2D5EJ-Dbn0WxRo.js} +1 -1
  13. package/dist/assets/{chunk-FMBD7UC4-DYHv1PcZ.js → chunk-FMBD7UC4-BGcm6Vvv.js} +1 -1
  14. package/dist/assets/{chunk-ND2GUHAM-D0MENOLX.js → chunk-ND2GUHAM-BB1l7iXM.js} +1 -1
  15. package/dist/assets/{chunk-QZHKN3VN-8nn3HP-N.js → chunk-QZHKN3VN-DBednlUY.js} +1 -1
  16. package/dist/assets/classDiagram-4FO5ZUOK-C_u9Angz.js +1 -0
  17. package/dist/assets/classDiagram-v2-Q7XG4LA2-C_u9Angz.js +1 -0
  18. package/dist/assets/{cose-bilkent-S5V4N54A-BoZPVIny.js → cose-bilkent-S5V4N54A-DPu8eCS8.js} +1 -1
  19. package/dist/assets/{dagre-BM42HDAG-BgZzdLG9.js → dagre-BM42HDAG-CO-3fQv_.js} +1 -1
  20. package/dist/assets/{diagram-2AECGRRQ-CknAnpSu.js → diagram-2AECGRRQ-ES8OaCtj.js} +1 -1
  21. package/dist/assets/{diagram-5GNKFQAL-CZUEbKim.js → diagram-5GNKFQAL-DAxAGwHz.js} +1 -1
  22. package/dist/assets/{diagram-KO2AKTUF-DCs-pLdH.js → diagram-KO2AKTUF-Ch8FzysS.js} +1 -1
  23. package/dist/assets/{diagram-LMA3HP47-lRaDjIfM.js → diagram-LMA3HP47-CKzGPl09.js} +1 -1
  24. package/dist/assets/{diagram-OG6HWLK6-CIGqmehP.js → diagram-OG6HWLK6-DO22Vj8Q.js} +1 -1
  25. package/dist/assets/{erDiagram-TEJ5UH35-Lx3c2N6F.js → erDiagram-TEJ5UH35-BqW54tXR.js} +1 -1
  26. package/dist/assets/{flowDiagram-I6XJVG4X-VoluKqSq.js → flowDiagram-I6XJVG4X-B2b-dSfR.js} +1 -1
  27. package/dist/assets/{ganttDiagram-6RSMTGT7-D7hETiNZ.js → ganttDiagram-6RSMTGT7-CmJFWbDF.js} +1 -1
  28. package/dist/assets/{gitGraphDiagram-PVQCEYII-DenEcUvY.js → gitGraphDiagram-PVQCEYII-BT8o_4m6.js} +1 -1
  29. package/dist/assets/{index-B4dv3acY.js → index-D1hzx617.js} +2 -2
  30. package/dist/assets/{infoDiagram-5YYISTIA-v7cq9Er9.js → infoDiagram-5YYISTIA-BqunByDN.js} +1 -1
  31. package/dist/assets/{ishikawaDiagram-YF4QCWOH-CfCCXt2x.js → ishikawaDiagram-YF4QCWOH-B7d9fw7d.js} +1 -1
  32. package/dist/assets/{journeyDiagram-JHISSGLW-Bbokl_xO.js → journeyDiagram-JHISSGLW-gEGcRX8K.js} +1 -1
  33. package/dist/assets/{kanban-definition-UN3LZRKU-DhkOZ2hg.js → kanban-definition-UN3LZRKU-Ce6Ql7ld.js} +1 -1
  34. package/dist/assets/{linear-bHjluRm2.js → linear-DmFVERwp.js} +1 -1
  35. package/dist/assets/{mindmap-definition-RKZ34NQL-C1bHpoXH.js → mindmap-definition-RKZ34NQL-B5XNiIMw.js} +1 -1
  36. package/dist/assets/{pieDiagram-4H26LBE5-CZb1i55T.js → pieDiagram-4H26LBE5-DkZl7DXu.js} +1 -1
  37. package/dist/assets/{quadrantDiagram-W4KKPZXB-o37AwRHB.js → quadrantDiagram-W4KKPZXB-2lwVSBWb.js} +1 -1
  38. package/dist/assets/{requirementDiagram-4Y6WPE33-BVErWDzU.js → requirementDiagram-4Y6WPE33-C0t5AupD.js} +1 -1
  39. package/dist/assets/{sankeyDiagram-5OEKKPKP-BhBK8gHQ.js → sankeyDiagram-5OEKKPKP-CeXMM5uX.js} +1 -1
  40. package/dist/assets/{sequenceDiagram-3UESZ5HK-CsICF23P.js → sequenceDiagram-3UESZ5HK-B3NY1tZo.js} +1 -1
  41. package/dist/assets/{stateDiagram-AJRCARHV-TN1AXwim.js → stateDiagram-AJRCARHV-DWmuapu-.js} +1 -1
  42. package/dist/assets/stateDiagram-v2-BHNVJYJU-Dk4v8A5E.js +1 -0
  43. package/dist/assets/{timeline-definition-PNZ67QCA-DftAajbU.js → timeline-definition-PNZ67QCA-BcThQznB.js} +1 -1
  44. package/dist/assets/{vennDiagram-CIIHVFJN-cFTMstT7.js → vennDiagram-CIIHVFJN-dfCrxHjF.js} +1 -1
  45. package/dist/assets/{wardley-L42UT6IY-DL8CivzO.js → wardley-L42UT6IY-Cp-dRZDP.js} +1 -1
  46. package/dist/assets/{wardleyDiagram-YWT4CUSO-BDZT1hQj.js → wardleyDiagram-YWT4CUSO-BaxHdcre.js} +1 -1
  47. package/dist/assets/{xychartDiagram-2RQKCTM6-DQQSkfC4.js → xychartDiagram-2RQKCTM6-BmPqraoL.js} +1 -1
  48. package/dist/index.html +1 -1
  49. package/lib/feature-json.js +17 -1
  50. package/lib/feature-validator.js +10 -1
  51. package/lib/feature-write-guard.js +201 -0
  52. package/lib/feature-writer.js +42 -1
  53. package/lib/status-projection.js +36 -0
  54. package/lib/tracker/github-provider.js +10 -3
  55. package/lib/tracker/local-provider.js +2 -2
  56. package/package.json +1 -1
  57. package/scripts/backproject-vision-status.mjs +96 -0
  58. package/server/feature-scan.js +13 -0
  59. package/server/graph-export.js +1 -0
  60. package/server/ideabox-routes.js +5 -3
  61. package/server/vision-store.js +1 -1
  62. package/dist/assets/channel-Bcu04MIK.js +0 -1
  63. package/dist/assets/classDiagram-4FO5ZUOK-DU4yxldU.js +0 -1
  64. package/dist/assets/classDiagram-v2-Q7XG4LA2-DU4yxldU.js +0 -1
  65. 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
+ }
@@ -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
- await provider.putFeature(args.from_code, { ...feature, links });
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.17-beta",
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
+ }
@@ -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');
@@ -24,6 +24,7 @@ const STATUS_MAP = {
24
24
  parked: 'parked',
25
25
  complete: 'complete',
26
26
  killed: 'complete',
27
+ superseded: 'complete',
27
28
  };
28
29
 
29
30
  const EDGE_TYPE_MAP = {
@@ -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
- fs.mkdirSync(featuresDir, { recursive: true })
186
- fs.writeFileSync(path.join(featuresDir, 'feature.json'), JSON.stringify({
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
- }, null, 2))
194
+ }, featuresRel)
193
195
  }
194
196
 
195
197
  promoteIdea(parsed, id, resolvedCode)
@@ -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};