@smartmemory/compose 0.2.49-beta → 0.2.51-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 (148) hide show
  1. package/.claude/skills/compose/SKILL.md +24 -1
  2. package/.claude/skills/compose/references/hermes-tools.md +80 -0
  3. package/README.md +1 -1
  4. package/bin/compose.js +340 -70
  5. package/dist/assets/App-CT0vXPgd.js +724 -0
  6. package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
  7. package/dist/assets/{arc-B1TqAC_R.js → arc-BXht3LyE.js} +1 -1
  8. package/dist/assets/architectureDiagram-Q4EWVU46-BV1r86-w.js +36 -0
  9. package/dist/assets/blockDiagram-DXYQGD6D-BgcMJjAR.js +132 -0
  10. package/dist/assets/{browser-DW2JlJCC.js → browser-CnKiSnlr.js} +6 -6
  11. package/dist/assets/{c4Diagram-AAUBKEIU-Dc6swoQo.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
  12. package/dist/assets/channel-Dd4XaiYv.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-CdqXgd68.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
  14. package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
  15. package/dist/assets/{chunk-55IACEB6-D7VauOmU.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
  16. package/dist/assets/{chunk-2J33WTMH-6HKDSnOw.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-C1Z5m8DP.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
  18. package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
  19. package/dist/assets/{chunk-QZHKN3VN-DY9GU2uD.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
  20. package/dist/assets/{chunk-ND2GUHAM-DmqZMEQe.js → chunk-YZCP3GAM-DE6_5aqh.js} +1 -1
  21. package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +1 -0
  22. package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +1 -0
  23. package/dist/assets/clone-CBsbNGAa.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-B34g9xzV.js → cose-bilkent-S5V4N54A-DzzRyJtB.js} +1 -1
  25. package/dist/assets/dagre-KV5264BT-CTcTVGdU.js +4 -0
  26. package/dist/assets/diagram-5BDNPKRD-qR6mc8kJ.js +10 -0
  27. package/dist/assets/diagram-G4DWMVQ6-CUcGXyNb.js +24 -0
  28. package/dist/assets/diagram-MMDJMWI5-B68cPonH.js +43 -0
  29. package/dist/assets/diagram-TYMM5635-q8Id8eFG.js +24 -0
  30. package/dist/assets/{erDiagram-TEJ5UH35-C05_Iqmb.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
  31. package/dist/assets/{flowDiagram-I6XJVG4X-lXpnb-Ch.js → flowDiagram-DWJPFMVM-BzzO4JYU.js} +1 -1
  32. package/dist/assets/ganttDiagram-T4ZO3ILL-Dn0wIyhR.js +292 -0
  33. package/dist/assets/gitGraphDiagram-UUTBAWPF-tnNFXfMM.js +106 -0
  34. package/dist/assets/graph-DZe55uk8.js +331 -0
  35. package/dist/assets/graph-Tq_bs_r0.js +1 -0
  36. package/dist/assets/index-8UhRLbGq.js +123 -0
  37. package/dist/assets/index-CRqB9els.css +1 -0
  38. package/dist/assets/infoDiagram-42DDH7IO-BXLvLeS0.js +2 -0
  39. package/dist/assets/{ishikawaDiagram-YF4QCWOH-CgdsWdgu.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
  40. package/dist/assets/{journeyDiagram-JHISSGLW-CyvHmE3z.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
  41. package/dist/assets/{kanban-definition-UN3LZRKU-Bqjdyv0T.js → kanban-definition-6JOO6SKY-CEq930ew.js} +1 -1
  42. package/dist/assets/katex-DkKDou_j.js +257 -0
  43. package/dist/assets/layout-qtUgN9BC.js +1 -0
  44. package/dist/assets/{linear-DecnRXf0.js → linear-D158F7OT.js} +1 -1
  45. package/dist/assets/min-hESxN-c0.js +1 -0
  46. package/dist/assets/{mindmap-definition-RKZ34NQL-DM1x9vNY.js → mindmap-definition-QFDTVHPH-d9SSh2nG.js} +7 -7
  47. package/dist/assets/mobile-1gVCT0OK.css +1 -0
  48. package/dist/assets/mobile-Chw8RWyH.js +17 -0
  49. package/dist/assets/pieDiagram-DEJITSTG-gglmeMav.js +30 -0
  50. package/dist/assets/quadrantDiagram-34T5L4WZ-CqFC_htU.js +7 -0
  51. package/dist/assets/{requirementDiagram-4Y6WPE33-DD-Gg_y5.js → requirementDiagram-MS252O5E-B1ZFIotK.js} +1 -1
  52. package/dist/assets/sankeyDiagram-XADWPNL6-BvItvZt5.js +10 -0
  53. package/dist/assets/sequenceDiagram-FGHM5R23-CAIn5Z3U.js +157 -0
  54. package/dist/assets/stateDiagram-FHFEXIEX-CyJrGzib.js +1 -0
  55. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DprhF_Ho.js +1 -0
  56. package/dist/assets/{timeline-definition-PNZ67QCA-BPm2AEQj.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
  57. package/dist/assets/{vennDiagram-CIIHVFJN-DQCDWwlj.js → vennDiagram-DHZGUBPP-TZAW60b0.js} +4 -4
  58. package/dist/assets/wardley-RL74JXVD-DzQEoG6X.js +162 -0
  59. package/dist/assets/wardleyDiagram-NUSXRM2D-BAoVVIjZ.js +20 -0
  60. package/dist/assets/{xychartDiagram-2RQKCTM6-Ba05SXXF.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
  61. package/dist/index.html +4 -4
  62. package/lib/build-all.js +2 -1
  63. package/lib/build.js +363 -38
  64. package/lib/checkpoint/checkpoint-writer.js +3 -2
  65. package/lib/codex-preflight.js +151 -0
  66. package/lib/completion-writer.js +59 -15
  67. package/lib/feature-json.js +14 -3
  68. package/lib/feature-validator.js +27 -10
  69. package/lib/feature-write-guard.js +5 -7
  70. package/lib/feature-writer.js +3 -3
  71. package/lib/followup-writer.js +2 -2
  72. package/lib/get-roadmap.js +2 -2
  73. package/lib/gsd.js +2 -1
  74. package/lib/hooks-status.js +135 -0
  75. package/lib/ideabox.js +3 -2
  76. package/lib/install-agent-defs.js +22 -0
  77. package/lib/journal-writer.js +3 -3
  78. package/lib/migrate-anon.js +277 -0
  79. package/lib/migrate-roadmap.js +2 -3
  80. package/lib/paths-core.js +47 -0
  81. package/lib/project-paths.js +46 -37
  82. package/lib/roadmap-gen.js +3 -4
  83. package/lib/roadmap-graph/collect.js +5 -5
  84. package/lib/roadmap-graph/index.js +2 -2
  85. package/lib/roadmap-parser.js +1 -1
  86. package/lib/state-migrations.js +3 -3
  87. package/lib/test-bootstrap.js +165 -2
  88. package/lib/tracker/local-provider.js +2 -2
  89. package/lib/triage.js +7 -5
  90. package/lib/vocabulary-inject.js +103 -0
  91. package/lib/xref-push.js +4 -4
  92. package/lib/xref-sync.js +4 -4
  93. package/package.json +2 -1
  94. package/pipelines/build-quick.stratum.yaml +433 -0
  95. package/pipelines/build.stratum.yaml +60 -3
  96. package/scripts/watch-server.sh +32 -0
  97. package/server/build-routes.js +89 -9
  98. package/server/build-stream-bridge.js +12 -0
  99. package/server/compose-mcp-tools.js +73 -1
  100. package/server/compose-mcp.js +27 -0
  101. package/server/design-routes.js +9 -4
  102. package/server/drift-axes.js +5 -3
  103. package/server/feature-scaffold-routes.js +82 -0
  104. package/server/feature-scan.js +8 -2
  105. package/server/file-watcher.js +13 -7
  106. package/server/health-routes.js +261 -0
  107. package/server/ideabox-routes.js +12 -11
  108. package/server/project-root.js +7 -4
  109. package/server/qa-scope-routes.js +96 -0
  110. package/server/session-routes.js +2 -2
  111. package/server/stratum-sync.js +85 -9
  112. package/server/validate-routes.js +70 -0
  113. package/server/vision-routes.js +46 -2
  114. package/server/vision-server.js +25 -3
  115. package/server/vision-utils.js +23 -4
  116. package/dist/assets/App-DOerhoDp.js +0 -706
  117. package/dist/assets/architectureDiagram-3BPJPVTR-C4hfxNOw.js +0 -36
  118. package/dist/assets/blockDiagram-GPEHLZMM-DRqkGncy.js +0 -132
  119. package/dist/assets/channel-KsY1x-7s.js +0 -1
  120. package/dist/assets/chunk-727SXJPM-CeKpXDif.js +0 -206
  121. package/dist/assets/chunk-AQP2D5EJ-y2XL-YHV.js +0 -231
  122. package/dist/assets/classDiagram-4FO5ZUOK-zDEDoVPk.js +0 -1
  123. package/dist/assets/classDiagram-v2-Q7XG4LA2-zDEDoVPk.js +0 -1
  124. package/dist/assets/dagre-BM42HDAG-klZ_GOcw.js +0 -4
  125. package/dist/assets/diagram-2AECGRRQ-D0FmM6Wf.js +0 -43
  126. package/dist/assets/diagram-5GNKFQAL-DYuhby6q.js +0 -10
  127. package/dist/assets/diagram-KO2AKTUF-CCtQZgal.js +0 -3
  128. package/dist/assets/diagram-LMA3HP47-Cd1OqEln.js +0 -24
  129. package/dist/assets/diagram-OG6HWLK6-CiJrhoxo.js +0 -24
  130. package/dist/assets/ganttDiagram-6RSMTGT7-EPbL_2Hr.js +0 -292
  131. package/dist/assets/gitGraphDiagram-PVQCEYII-COCoF_vW.js +0 -106
  132. package/dist/assets/graph-BBXaecIU.js +0 -331
  133. package/dist/assets/graph-CAnANduQ.js +0 -1
  134. package/dist/assets/index-BwLfbbOu.css +0 -1
  135. package/dist/assets/index-DlgxA8dX.js +0 -123
  136. package/dist/assets/infoDiagram-5YYISTIA-B1sJw6j5.js +0 -2
  137. package/dist/assets/katex-C5jXJg4s.js +0 -257
  138. package/dist/assets/layout-DGIYPm2g.js +0 -1
  139. package/dist/assets/mobile-Cag5dHlF.css +0 -1
  140. package/dist/assets/mobile-DwmxS_O4.js +0 -17
  141. package/dist/assets/pieDiagram-4H26LBE5-CRtmCvwV.js +0 -30
  142. package/dist/assets/quadrantDiagram-W4KKPZXB-CsBLWg27.js +0 -7
  143. package/dist/assets/sankeyDiagram-5OEKKPKP-BPdMN8UU.js +0 -40
  144. package/dist/assets/sequenceDiagram-3UESZ5HK-D4AENdze.js +0 -162
  145. package/dist/assets/stateDiagram-AJRCARHV-DDplpCf_.js +0 -1
  146. package/dist/assets/stateDiagram-v2-BHNVJYJU-J4t9AVzF.js +0 -1
  147. package/dist/assets/wardley-L42UT6IY-WERqOmJt.js +0 -173
  148. package/dist/assets/wardleyDiagram-YWT4CUSO-5teUSOiI.js +0 -78
@@ -5,10 +5,9 @@
5
5
  */
6
6
 
7
7
  import { readFileSync, writeFileSync, existsSync } from 'fs';
8
- import { join } from 'path';
9
8
  import { listFeatures, positionSortKey } from './feature-json.js';
10
9
  import { parseStatusToken } from './roadmap-parser.js';
11
- import { loadFeaturesDir } from './project-paths.js';
10
+ import { loadFeaturesDir, resolveRoadmapPath } from './project-paths.js';
12
11
  import {
13
12
  readPhaseOverrides,
14
13
  readAnonymousRows,
@@ -207,7 +206,7 @@ export function generateRoadmapFromBase(baseText, features, opts = {}) {
207
206
  * @returns {string} - Generated ROADMAP.md content
208
207
  */
209
208
  export function generateRoadmap(cwd, opts = {}) {
210
- const roadmapPath = join(cwd, 'ROADMAP.md');
209
+ const roadmapPath = resolveRoadmapPath(cwd);
211
210
  const existingText = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
212
211
 
213
212
  // Narrative-owned workspace: ROADMAP.md is hand-authored. Return it verbatim
@@ -513,7 +512,7 @@ function buildKeyDocs(features, featuresDir) {
513
512
  * @param {object} [opts]
514
513
  */
515
514
  export function writeRoadmap(cwd, opts = {}) {
516
- const roadmapPath = join(cwd, 'ROADMAP.md');
515
+ const roadmapPath = resolveRoadmapPath(cwd);
517
516
 
518
517
  // Narrative-owned workspace: never overwrite hand-authored ROADMAP.md (#39).
519
518
  // No-op + warn; the existing file on disk is the source of truth.
@@ -12,7 +12,7 @@ import { join, dirname, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { parse as parseYaml } from 'yaml';
14
14
  import { listFeatures } from '../feature-json.js';
15
- import { loadFeaturesDir, loadExternalPrefixes } from '../project-paths.js';
15
+ import { resolveFeaturesPath, resolveRoadmapPath, loadExternalPrefixes } from '../project-paths.js';
16
16
  import { parseRoadmap } from '../roadmap-parser.js';
17
17
  import { SchemaValidator } from '../../server/schema-validator.js';
18
18
  import { depsToEdges } from './model.js';
@@ -28,7 +28,7 @@ const FM_KEYS = ['name', 'priority', 'track', 'desc'];
28
28
  * @param {string} [featuresDir] relative features dir (default from compose.json)
29
29
  * @returns {{ nodes: object[], rawEdges: object[], knownCodes: Set<string>, warnings: string[] }}
30
30
  */
31
- export function collectGraphInputs(cwd, featuresDir = loadFeaturesDir(cwd)) {
31
+ export function collectGraphInputs(cwd, featuresDir = resolveFeaturesPath(cwd)) {
32
32
  const warnings = [];
33
33
  const depsValidator = new SchemaValidator(DEPS_SCHEMA);
34
34
  const fmValidator = new SchemaValidator(FRONTMATTER_SCHEMA);
@@ -48,7 +48,7 @@ export function collectGraphInputs(cwd, featuresDir = loadFeaturesDir(cwd)) {
48
48
  // An external-prefixed folder is a cross-project reference living here —
49
49
  // known (so edges resolve) but never rendered as one of THIS project's nodes.
50
50
  if (isExternal(f.code)) { externalCodes.add(f.code); continue; }
51
- const folder = join(cwd, featuresDir, f.code);
51
+ const folder = join(featuresDir, f.code);
52
52
  const fm = readDisplayMetadata(folder, f, fmValidator, warnings);
53
53
  universe.set(f.code, {
54
54
  id: f.code,
@@ -62,7 +62,7 @@ export function collectGraphInputs(cwd, featuresDir = loadFeaturesDir(cwd)) {
62
62
  }
63
63
 
64
64
  // (b) ROADMAP.md rows — fallback for features not registered as folders.
65
- const roadmapPath = join(cwd, 'ROADMAP.md');
65
+ const roadmapPath = resolveRoadmapPath(cwd);
66
66
  if (existsSync(roadmapPath)) {
67
67
  let entries = [];
68
68
  try { entries = parseRoadmap(readFileSync(roadmapPath, 'utf-8')); } catch { /* ignore */ }
@@ -88,7 +88,7 @@ export function collectGraphInputs(cwd, featuresDir = loadFeaturesDir(cwd)) {
88
88
  for (const f of features) {
89
89
  if (!f || typeof f.code !== 'string') continue;
90
90
  if (isExternal(f.code)) continue;
91
- const depsPath = join(cwd, featuresDir, f.code, 'deps.yaml');
91
+ const depsPath = join(featuresDir, f.code, 'deps.yaml');
92
92
  if (!existsSync(depsPath)) continue;
93
93
  let deps;
94
94
  try {
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'node:fs';
12
12
  import { join, isAbsolute, dirname } from 'node:path';
13
- import { loadFeaturesDir } from '../project-paths.js';
13
+ import { resolveFeaturesPath } from '../project-paths.js';
14
14
  import { collectGraphInputs } from './collect.js';
15
15
  import { buildGraph } from './model.js';
16
16
  import { renderGraphHtml } from './render.js';
@@ -22,7 +22,7 @@ import { loadGraphConfig } from './config.js';
22
22
  * @returns {{ html: string, nodes: object[], edges: object[], dropped: string[], warnings: string[], config: object }}
23
23
  */
24
24
  export function buildRoadmapGraph(cwd) {
25
- const featuresDir = loadFeaturesDir(cwd);
25
+ const featuresDir = resolveFeaturesPath(cwd);
26
26
  const inputs = collectGraphInputs(cwd, featuresDir);
27
27
  const graph = buildGraph(inputs); // throws DanglingEdgeError
28
28
  const config = loadGraphConfig(cwd);
@@ -192,7 +192,7 @@ export function parseRoadmap(text) {
192
192
  * 3-col: ID | Item | Status → code=ID, desc=Item
193
193
  * 3-col: # | Item | Status → anonymous (# is a row number, not a code)
194
194
  */
195
- function detectColumnLayout(headerCells) {
195
+ export function detectColumnLayout(headerCells) {
196
196
  const lower = headerCells.map(c => c.toLowerCase());
197
197
 
198
198
  // 4+ columns: look for a Feature column as the code source
@@ -27,7 +27,7 @@ import {
27
27
  renameSync, unlinkSync, readdirSync,
28
28
  } from 'fs';
29
29
  import { join } from 'path';
30
- import { loadFeaturesDir } from './project-paths.js';
30
+ import { resolveFeaturesPath } from './project-paths.js';
31
31
  import { writeFeature } from './feature-json.js';
32
32
 
33
33
  /**
@@ -283,8 +283,8 @@ export function runStateMigrations(cwd, opts = {}) {
283
283
 
284
284
  // --- feature.json walk -----------------------------------------------------
285
285
  if (featurePending.length > 0) {
286
- const featuresDir = loadFeaturesDir(cwd); // honors paths.features override
287
- const featuresRoot = join(cwd, featuresDir);
286
+ const featuresDir = resolveFeaturesPath(cwd); // absolute; honors paths.features override
287
+ const featuresRoot = featuresDir;
288
288
  // Own directory walk — do NOT use listFeatures(): it silently skips unreadable
289
289
  // files, which would hide exactly the cold-data corruption we must surface.
290
290
  let dirs = [];
@@ -119,7 +119,10 @@ export function detectTestFramework(cwd) {
119
119
  framework: 'go-test',
120
120
  runner: 'go',
121
121
  configFile: 'go.mod',
122
- command: 'go test ./...',
122
+ // -v emits per-test "--- PASS:/FAIL:" verdicts so parseTestSummary can
123
+ // count tests (COMP-TEST-BOOTSTRAP-4); plain `go test` prints only a
124
+ // package-level ok/FAIL with no counts.
125
+ command: 'go test -v ./...',
123
126
  };
124
127
  }
125
128
  }
@@ -298,7 +301,8 @@ export function scaffoldTestFramework(cwd, language) {
298
301
  framework: 'go-test',
299
302
  configFile: 'golden_test.go',
300
303
  testDir: '.',
301
- command: 'go test ./...',
304
+ // -v so parseTestSummary can count per-test verdicts (COMP-TEST-BOOTSTRAP-4).
305
+ command: 'go test -v ./...',
302
306
  };
303
307
  }
304
308
 
@@ -399,3 +403,162 @@ export function scaffoldTestFramework(cwd, language) {
399
403
  }
400
404
  }
401
405
  }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // COMP-TEST-BOOTSTRAP-4-1: isTestFile
409
+ // ---------------------------------------------------------------------------
410
+
411
+ /**
412
+ * Whether a repo-relative path looks like a test file. Used to scope the
413
+ * post-coverage test-review pass to the tests the coverage step produced.
414
+ * Conservative: matches conventional test locations/suffixes across the
415
+ * frameworks detectTestFramework knows, and avoids substring false positives
416
+ * (e.g. "contest", "greatest").
417
+ *
418
+ * @param {string} p - repo-relative file path
419
+ * @returns {boolean}
420
+ */
421
+ export function isTestFile(p) {
422
+ if (typeof p !== 'string' || p.length === 0) return false;
423
+ return (
424
+ /(^|\/)(tests?|__tests__|spec)\//.test(p) || // test/ tests/ __tests__/ spec/ dirs
425
+ /\.(test|spec)\.[mc]?[jt]sx?$/.test(p) || // *.test.* / *.spec.* (js/ts/jsx/tsx/mjs/cjs)
426
+ /_test\.go$/.test(p) || // go
427
+ /(^|\/)test_[^/]*\.py$/.test(p) || // pytest test_*.py
428
+ /_test\.py$/.test(p) // pytest *_test.py
429
+ );
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // COMP-TEST-BOOTSTRAP-4: parseTestSummary
434
+ // ---------------------------------------------------------------------------
435
+
436
+ const _UNPARSED = Object.freeze({ test_count: 0, pass_rate: 0, parsed: false });
437
+
438
+ /** Strip ANSI color/escape codes so regexes match raw text. */
439
+ function _stripAnsi(s) {
440
+ // eslint-disable-next-line no-control-regex
441
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
442
+ }
443
+
444
+ /** First capture group as an int, or null if no match. */
445
+ function _int(m) {
446
+ return m ? parseInt(m[1], 10) : null;
447
+ }
448
+
449
+ /**
450
+ * Build a result from pass/fail counts. The denominator is passed+failed only —
451
+ * skipped/pending/ignored tests are excluded from the rate (a fully-skipped run
452
+ * is not "100% passing"; a run with zero verdicts degrades to parsed:false).
453
+ */
454
+ function _fromCounts(passed, failed) {
455
+ const total = passed + failed;
456
+ if (total <= 0) return _UNPARSED;
457
+ // Round to 2 decimals; an all-pass run yields exactly 100.
458
+ const pass_rate = Math.round((passed / total) * 10000) / 100;
459
+ return { test_count: total, pass_rate, parsed: true };
460
+ }
461
+
462
+ function _parseVitest(out) {
463
+ // " Tests 2 failed | 10 passed (12)" / " Tests 12 passed (12)"
464
+ // Match the "Tests" line specifically, not the "Test Files" line.
465
+ const line = out.match(/^[^\S\n]*Tests[^\S\n]+(.+)$/m);
466
+ if (!line) return _UNPARSED;
467
+ const passed = _int(line[1].match(/(\d+)\s+passed/));
468
+ const failed = _int(line[1].match(/(\d+)\s+failed/));
469
+ if (passed === null && failed === null) return _UNPARSED;
470
+ return _fromCounts(passed ?? 0, failed ?? 0);
471
+ }
472
+
473
+ function _parseJest(out) {
474
+ // "Tests: 2 failed, 10 passed, 12 total"
475
+ const line = out.match(/^[^\S\n]*Tests:[^\S\n]+(.+)$/m);
476
+ if (!line) return _UNPARSED;
477
+ const passed = _int(line[1].match(/(\d+)\s+passed/));
478
+ const failed = _int(line[1].match(/(\d+)\s+failed/));
479
+ if (passed === null && failed === null) return _UNPARSED;
480
+ return _fromCounts(passed ?? 0, failed ?? 0);
481
+ }
482
+
483
+ function _parseMocha(out) {
484
+ // " 10 passing (24ms)" + optional " 2 failing"; pending excluded.
485
+ const passed = _int(out.match(/(\d+)\s+passing/));
486
+ const failed = _int(out.match(/(\d+)\s+failing/));
487
+ if (passed === null && failed === null) return _UNPARSED;
488
+ return _fromCounts(passed ?? 0, failed ?? 0);
489
+ }
490
+
491
+ function _parsePytest(out) {
492
+ // "===== 2 failed, 9 passed, 1 error, 1 skipped in 0.50s =====" / "12 passed in 0.34s"
493
+ const passed = _int(out.match(/(\d+)\s+passed/));
494
+ const failed = _int(out.match(/(\d+)\s+failed/));
495
+ const errors = _int(out.match(/(\d+)\s+error/)); // "error"/"errors"
496
+ if (passed === null && failed === null && errors === null) return _UNPARSED;
497
+ return _fromCounts(passed ?? 0, (failed ?? 0) + (errors ?? 0));
498
+ }
499
+
500
+ function _parseGoTest(out) {
501
+ // Requires verbose output (`go test -v`): per-test "--- PASS:" / "--- FAIL:" lines.
502
+ // Without -v there are no per-test verdicts to count → degrade.
503
+ const passed = (out.match(/^[^\S\n]*--- PASS:/gm) || []).length;
504
+ const failed = (out.match(/^[^\S\n]*--- FAIL:/gm) || []).length;
505
+ if (passed + failed === 0) return _UNPARSED;
506
+ return _fromCounts(passed, failed);
507
+ }
508
+
509
+ function _parseCargo(out) {
510
+ // Sum every "test result: <ok|FAILED>. N passed; M failed; ..." line
511
+ // (a crate emits one per unit/integration/doc target).
512
+ const re = /test result:\s+\w+\.\s+(\d+)\s+passed;\s+(\d+)\s+failed/g;
513
+ let passed = 0, failed = 0, found = false, m;
514
+ while ((m = re.exec(out)) !== null) {
515
+ passed += parseInt(m[1], 10);
516
+ failed += parseInt(m[2], 10);
517
+ found = true;
518
+ }
519
+ if (!found) return _UNPARSED;
520
+ return _fromCounts(passed, failed);
521
+ }
522
+
523
+ /**
524
+ * Extract a structured test-result summary from raw test-runner stdout.
525
+ *
526
+ * Pure and total: any framework it can't read — or output it can't recognize —
527
+ * degrades to `{ test_count: 0, pass_rate: 0, parsed: false }` rather than
528
+ * guessing. Callers MUST treat `parsed:false` as "no signal" and never as a
529
+ * failing gate (see lib/build.js ship path).
530
+ *
531
+ * @param {string|null} framework - value from detectTestFramework().framework
532
+ * (vitest | jest | mocha | pytest | go-test | cargo-test; others degrade)
533
+ * @param {string|null} stdout - combined stdout+stderr of the test run
534
+ * @returns {{ test_count: number, pass_rate: number, parsed: boolean }}
535
+ */
536
+ export function parseTestSummary(framework, stdout) {
537
+ if (!framework || typeof stdout !== 'string' || stdout.length === 0) return _UNPARSED;
538
+ const out = _stripAnsi(stdout);
539
+ switch (framework) {
540
+ case 'vitest': return _parseVitest(out);
541
+ case 'jest': return _parseJest(out);
542
+ case 'mocha': return _parseMocha(out);
543
+ case 'pytest': return _parsePytest(out);
544
+ case 'go-test': return _parseGoTest(out);
545
+ case 'cargo-test': return _parseCargo(out);
546
+ default: return _UNPARSED; // ava, tap, unknown
547
+ }
548
+ }
549
+
550
+ /**
551
+ * The ship gate: derive a `tests_pass` boolean from a parsed summary.
552
+ *
553
+ * - parsed: pass only when at least one test ran AND every test passed.
554
+ * - unparsed (or absent): degrade to `true` — an unreadable summary is "no
555
+ * signal", never a failing gate. This is the safety valve that keeps the
556
+ * gate from misfiring on frameworks/output the parser can't read.
557
+ *
558
+ * @param {{ test_count: number, pass_rate: number, parsed: boolean }|null} summary
559
+ * @returns {boolean}
560
+ */
561
+ export function deriveTestsPass(summary) {
562
+ if (!summary || !summary.parsed) return true;
563
+ return summary.test_count >= 1 && summary.pass_rate === 100;
564
+ }
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameS
2
2
  import { join, dirname } from 'path';
3
3
 
4
4
  import { readFeature, listFeatures as listFeaturesRaw, writeFeature } from '../feature-json.js';
5
- import { loadFeaturesDir } from '../project-paths.js';
5
+ import { resolveFeaturesPath } from '../project-paths.js';
6
6
  import { TrackerProvider, CAP } from './provider.js';
7
7
 
8
8
  import { setFeatureStatus, addRoadmapEntry as addRoadmapEntryRaw } from '../feature-writer.js';
@@ -38,7 +38,7 @@ export class LocalFileProvider extends TrackerProvider {
38
38
 
39
39
  async init(cwd) {
40
40
  this.cwd = cwd;
41
- this.featuresDir = loadFeaturesDir(cwd);
41
+ this.featuresDir = resolveFeaturesPath(cwd);
42
42
  return this;
43
43
  }
44
44
 
package/lib/triage.js CHANGED
@@ -11,6 +11,8 @@
11
11
 
12
12
  import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { resolveFeaturesPath } from './project-paths.js';
15
+ import { resolvePathValue } from './paths-core.js';
14
16
 
15
17
  // ---------------------------------------------------------------------------
16
18
  // Tier definitions
@@ -185,8 +187,8 @@ function deriveProfile(signals) {
185
187
  */
186
188
  export async function runTriage(featureCode, opts = {}) {
187
189
  const cwd = opts.cwd ?? process.cwd();
188
- const featuresDir = opts.featuresDir ?? 'docs/features';
189
- const featureDir = join(cwd, featuresDir, featureCode);
190
+ const featuresDir = opts.featuresDir ?? resolveFeaturesPath(cwd);
191
+ const featureDir = join(resolvePathValue(cwd, featuresDir, 'features'), featureCode);
190
192
 
191
193
  // Collect content from key files
192
194
  const candidateFiles = ['plan.md', 'blueprint.md', 'design.md', 'prd.md', 'architecture.md'];
@@ -234,11 +236,11 @@ export async function runTriage(featureCode, opts = {}) {
234
236
  *
235
237
  * @param {string} cwd - Project root
236
238
  * @param {string} featureCode - Feature code
237
- * @param {string} [featuresDir] - Relative path to features dir (default: docs/features)
239
+ * @param {string} [featuresDir] - Path to features dir (absolute or relative; default: resolved features path)
238
240
  * @returns {boolean}
239
241
  */
240
- export function isTriageStale(cwd, featureCode, featuresDir = 'docs/features') {
241
- const featureDir = join(cwd, featuresDir, featureCode);
242
+ export function isTriageStale(cwd, featureCode, featuresDir = resolveFeaturesPath(cwd)) {
243
+ const featureDir = join(resolvePathValue(cwd, featuresDir, 'features'), featureCode);
242
244
  const featureJsonPath = join(featureDir, 'feature.json');
243
245
 
244
246
  if (!existsSync(featureJsonPath)) return true;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * STRAT-VOCAB-3 — Compose integration for vocabulary enforcement.
3
+ *
4
+ * Wires the already-shipped Stratum `vocabulary_compliance` ensure builtin
5
+ * (stratum-mcp/src/stratum_mcp/spec.py) into the compose lifecycle:
6
+ * - VOCABULARY_TEMPLATE : starter contracts/vocabulary.yaml (compose init)
7
+ * - vocabularyEnabled() : gate — capability not disabled AND a vocab file exists
8
+ * - injectVocabularyEnsure() : append the vocab ensure to the build flow's `review` step
9
+ * - tagVocabularyViolations() : mark vocab violation strings as must-fix for display
10
+ *
11
+ * Design: docs/features/STRAT-VOCAB-3/design.md
12
+ */
13
+ import { existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ /** Project-relative location of the vocabulary file (also the path baked into the ensure). */
17
+ export const VOCABULARY_FILE = 'contracts/vocabulary.yaml';
18
+
19
+ /**
20
+ * The ensure expression injected into the build flow's `review` step.
21
+ * Empty-list literal + git_fallback=True: never references a possibly-missing
22
+ * `result.*` attribute; scans the uncommitted working-tree diff vs HEAD (the
23
+ * implementation changes are merged but not yet committed at review time).
24
+ * Python-evaluated by the Stratum executor — hence `[]` and `True`.
25
+ */
26
+ export const VOCABULARY_ENSURE = `vocabulary_compliance('${VOCABULARY_FILE}', [], True)`;
27
+
28
+ /** Starter vocabulary file: all comments, so `_load_vocabulary` returns {} (inert) until edited. */
29
+ export const VOCABULARY_TEMPLATE = `# contracts/vocabulary.yaml — project naming vocabulary (STRAT-VOCAB)
30
+ #
31
+ # Declare canonical names and the aliases that must NOT appear in code. During
32
+ # \`compose build\`, the review step scans the files changed by the build for any
33
+ # rejected alias (whole-word, case-sensitive) and fails until each is replaced
34
+ # with its canonical name. An empty / comments-only file is a no-op.
35
+ #
36
+ # Format — a flat map of canonical_name -> { reject: [...], reason: "..." }:
37
+ #
38
+ # auth_token:
39
+ # reject: [jwt, accessToken, JwtToken, authToken]
40
+ # reason: "use auth_token everywhere for the session credential"
41
+ #
42
+ # user_id:
43
+ # reject: [uid, userId, UserId]
44
+ #
45
+ # Rules: canonical names and aliases must be identifiers; an alias may not also
46
+ # be a canonical name or be repeated across entries; \`reason\` is optional.
47
+ # Uncomment and edit the examples above (or add your own) to enable enforcement.
48
+ `;
49
+
50
+ /**
51
+ * Is vocabulary enforcement active for this project?
52
+ * Default-ON (honoring the roadmap's "by default") but gated on the file existing,
53
+ * so the generated spec is byte-identical for any project without a vocab file.
54
+ * Opt out with capabilities.vocabularyCompliance === false.
55
+ */
56
+ export function vocabularyEnabled(cwd, composeConfig) {
57
+ if (composeConfig?.capabilities?.vocabularyCompliance === false) return false;
58
+ return existsSync(join(cwd, VOCABULARY_FILE));
59
+ }
60
+
61
+ /**
62
+ * Append the vocabulary ensure to the EXECUTED flow's `review` step (idempotent).
63
+ * Mutates and returns specObj. No-op when that flow has no `review` step.
64
+ *
65
+ * `flowName` must be the flow Stratum will actually run (build.js resolves it via
66
+ * extractFlowName). Targeting it precisely matters because a sub-flow
67
+ * (`review_check`) ALSO has a step id'd `review`; injecting there would be wrong.
68
+ * When `flowName` is omitted/unknown, fall back to the `build` flow (or the first),
69
+ * matching the shipped templates.
70
+ */
71
+ export function injectVocabularyEnsure(specObj, flowName) {
72
+ const flows = specObj?.flows ?? {};
73
+ const keys = Object.keys(flows);
74
+ const flowKey = flowName && keys.includes(flowName)
75
+ ? flowName
76
+ : (keys.includes('build') ? 'build' : keys[0]);
77
+ const steps = flows[flowKey]?.steps ?? [];
78
+ const review = steps.find((s) => s?.id === 'review');
79
+ if (!review) return specObj;
80
+ if (!Array.isArray(review.ensure)) review.ensure = [];
81
+ if (!review.ensure.includes(VOCABULARY_ENSURE)) review.ensure.push(VOCABULARY_ENSURE);
82
+ return specObj;
83
+ }
84
+
85
+ /**
86
+ * Tag vocabulary failure strings as must-fix for the findings display.
87
+ * The cli-progress parser classifies a string by keyword (defaults to `nit`);
88
+ * vocab failures carry no marker, so prefix them with `must-fix:` — the parser
89
+ * then classifies must-fix AND still extracts the file:line, and strips the
90
+ * prefix for a clean description. Non-string / non-vocab items pass through
91
+ * unchanged. Returns a new array (does not mutate input or stored violations).
92
+ *
93
+ * Matches every string the builtin emits — both alias hits
94
+ * ("vocabulary violation: …") and a broken vocab file
95
+ * ("vocabulary.yaml malformed: …" / "vocabulary.yaml schema error: …") — all of
96
+ * which block the step and so deserve must-fix, not nit.
97
+ */
98
+ export function tagVocabularyViolations(violations) {
99
+ if (!Array.isArray(violations)) return violations;
100
+ return violations.map((v) =>
101
+ typeof v === 'string' && /^vocabulary[ .]/i.test(v) ? `must-fix: ${v}` : v
102
+ );
103
+ }
package/lib/xref-push.js CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { readdirSync, existsSync, readFileSync } from 'fs';
19
19
  import { join } from 'path';
20
- import { loadFeaturesDir } from './project-paths.js';
20
+ import { resolveFeaturesPath } from './project-paths.js';
21
21
  import { resolveSiblingRoot } from './xref-local.js';
22
22
 
23
23
  const GITHUB_STATES = new Set(['open', 'closed']);
@@ -145,7 +145,7 @@ function localResolve(link, cwd) {
145
145
  const sib = resolveSiblingRoot(cwd, link.repo);
146
146
  if (sib.skipped) return sib;
147
147
  try {
148
- const fjPath = join(sib.root, loadFeaturesDir(sib.root), link.to_code, 'feature.json');
148
+ const fjPath = join(resolveFeaturesPath(sib.root), link.to_code, 'feature.json');
149
149
  if (!existsSync(fjPath)) return { skipped: true, reason: `local target ${link.repo}/${link.to_code} not found` };
150
150
  return { state: JSON.parse(readFileSync(fjPath, 'utf8')).status || null, root: sib.root };
151
151
  } catch (e) { return { skipped: true, reason: `unreadable local target: ${e.message}` }; }
@@ -171,13 +171,13 @@ function localResolve(link, cwd) {
171
171
  * @returns {Promise<{pushed: Array, skipped: Array, unchanged: number, scanned: number}>}
172
172
  */
173
173
  export async function pushExternalRefs(cwd, opts = {}) {
174
- const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
174
+ const featuresDir = opts.featuresDir ?? resolveFeaturesPath(cwd);
175
175
  const clientOpts = { transport: opts.githubTransport ?? null, auth: opts.githubAuth };
176
176
  const ghResolve = opts.resolve ?? ((link) => defaultResolve(link, clientOpts));
177
177
  const ghWrite = opts.write ?? ((link, patch) => defaultWrite(link, patch, clientOpts));
178
178
  const setStatus = opts.setStatus ?? defaultSetStatus;
179
179
  const apply = opts.apply === true;
180
- const dir = join(cwd, featuresDir);
180
+ const dir = featuresDir;
181
181
 
182
182
  const pushed = [];
183
183
  const skipped = [];
package/lib/xref-sync.js CHANGED
@@ -16,7 +16,7 @@
16
16
  import { readdirSync, existsSync, readFileSync } from 'fs';
17
17
  import { join } from 'path';
18
18
  import { writeFeature } from './feature-json.js';
19
- import { loadFeaturesDir } from './project-paths.js';
19
+ import { resolveFeaturesPath } from './project-paths.js';
20
20
  import { resolveSiblingRoot } from './xref-local.js';
21
21
 
22
22
  const RESOLVABLE = new Set(['github', 'local']);
@@ -80,7 +80,7 @@ async function defaultResolve(link, cwd, featuresDir) {
80
80
  const citedRoot = sib.root;
81
81
  // Resolve the SIBLING's own features dir (it may have its own paths.features).
82
82
  try {
83
- const fjPath = join(citedRoot, loadFeaturesDir(citedRoot), link.to_code, 'feature.json');
83
+ const fjPath = join(resolveFeaturesPath(citedRoot), link.to_code, 'feature.json');
84
84
  if (!existsSync(fjPath)) return { skipped: true, reason: `local target ${link.repo}/${link.to_code} not found` };
85
85
  return { state: JSON.parse(readFileSync(fjPath, 'utf8')).status || null };
86
86
  } catch (e) { return { skipped: true, reason: `unreadable local target: ${e.message}` }; }
@@ -100,9 +100,9 @@ async function defaultResolve(link, cwd, featuresDir) {
100
100
  * @returns {Promise<{synced: Array, skipped: Array, unchanged: number, scanned: number}>}
101
101
  */
102
102
  export async function syncExternalRefs(cwd, opts = {}) {
103
- const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
103
+ const featuresDir = opts.featuresDir ?? resolveFeaturesPath(cwd);
104
104
  const resolve = opts.resolve ?? defaultResolve;
105
- const dir = join(cwd, featuresDir);
105
+ const dir = featuresDir;
106
106
 
107
107
  const synced = [];
108
108
  const skipped = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.49-beta",
3
+ "version": "0.2.51-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",
@@ -16,6 +16,7 @@
16
16
  "dev": "node server/supervisor.js 2>&1 | tee /tmp/compose-server.log",
17
17
  "dev:server": "node server/supervisor.js",
18
18
  "dev:server:bare": "node server/index.js",
19
+ "dev:watch": "bash scripts/watch-server.sh",
19
20
  "dev:client": "vite",
20
21
  "build": "vite build",
21
22
  "preview": "vite preview",