@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
@@ -0,0 +1,135 @@
1
+ /**
2
+ * hooks-status.js — Shared git-hook status logic.
3
+ *
4
+ * Single source of truth for hook drift detection, consumed by:
5
+ * - `compose hooks status` (bin/compose.js) — prints lines via formatHookStatusLines
6
+ * - `GET /api/environment-health` (server/health-routes.js) — maps raw facts to API states
7
+ *
8
+ * Extracted verbatim from the historical inline `statusOne` (bin/compose.js) so
9
+ * the CLI output stays byte-identical. The ONLY addition is the non-state
10
+ * `wsVerified` flag: when the expected workspace id is unknown (null), the CLI
11
+ * stays lenient (reports "current") but the flag lets the API surface
12
+ * `workspace-unverified` instead of a false "current".
13
+ *
14
+ * Pure: no console output, no process exit. Reads only the hook files on disk.
15
+ *
16
+ * Roadmap: COMP-PARITY-3.
17
+ */
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ /** Marker comment baked into each Compose-installed hook (identifies "ours"). */
22
+ export const HOOK_MARKERS = {
23
+ 'post-commit': '# Compose post-commit hook —',
24
+ 'pre-push': '# Compose pre-push hook —',
25
+ };
26
+
27
+ /** The hook types Compose manages, in display order. */
28
+ export const HOOK_TYPE_LIST = Object.keys(HOOK_MARKERS);
29
+
30
+ /** Pull the baked COMPOSE_WORKSPACE_ID="..." value out of hook content, or null. */
31
+ export function extractBakedWorkspaceId(content) {
32
+ const m = content.match(/^COMPOSE_WORKSPACE_ID="([^"]*)"$/m);
33
+ return m ? m[1] : null;
34
+ }
35
+
36
+ /**
37
+ * Compute the raw status facts for every managed hook type.
38
+ *
39
+ * @param {object} opts
40
+ * @param {string} opts.projectRoot — repo root (hooks live at <root>/.git/hooks)
41
+ * @param {string|null} [opts.expectedWsId] — workspace id to compare against; null
42
+ * means "unknown" (lenient match, wsVerified=false)
43
+ * @param {string} opts.composeNode — expected COMPOSE_NODE (process.execPath)
44
+ * @param {string} opts.composeBin — expected COMPOSE_BIN (path to compose.js)
45
+ * @returns {Record<string, HookStatusRaw>} keyed by hook type
46
+ */
47
+ export function computeHooksStatus({ projectRoot, expectedWsId = null, composeNode, composeBin }) {
48
+ const hooksDir = join(projectRoot, '.git', 'hooks');
49
+ const out = {};
50
+ for (const type of HOOK_TYPE_LIST) {
51
+ out[type] = computeOne({ type, hooksDir, expectedWsId, composeNode, composeBin });
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function computeOne({ type, hooksDir, expectedWsId, composeNode, composeBin }) {
57
+ const marker = HOOK_MARKERS[type];
58
+ const dest = join(hooksDir, type);
59
+
60
+ const empty = {
61
+ reason: null,
62
+ bakedWorkspace: null,
63
+ expectedWorkspace: expectedWsId ?? null,
64
+ wsVerified: false,
65
+ nodeMatch: false,
66
+ binMatch: false,
67
+ };
68
+
69
+ if (!existsSync(dest)) return { state: 'absent', ...empty };
70
+
71
+ const content = readFileSync(dest, 'utf-8');
72
+ if (!content.includes(marker)) return { state: 'foreign', ...empty };
73
+
74
+ const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`);
75
+ const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`);
76
+ const hasRawToken = content.includes('__COMPOSE_WORKSPACE_ID__');
77
+ const bakedWorkspace = extractBakedWorkspaceId(content);
78
+ // Mirror of statusOne: raw token never matches; a known expected id must match
79
+ // the baked value; an unknown (null) expected id is treated leniently as a match.
80
+ const wsMatch = hasRawToken ? false : expectedWsId ? content.includes(`COMPOSE_WORKSPACE_ID="${expectedWsId}"`) : true;
81
+
82
+ const base = {
83
+ bakedWorkspace,
84
+ expectedWorkspace: expectedWsId ?? null,
85
+ wsVerified: expectedWsId != null,
86
+ nodeMatch,
87
+ binMatch,
88
+ };
89
+
90
+ if (nodeMatch && binMatch && wsMatch && !hasRawToken) {
91
+ return { state: 'installed-current', reason: null, ...base };
92
+ }
93
+ const reason = hasRawToken
94
+ ? 'MISSING_WORKSPACE_ID'
95
+ : expectedWsId && !wsMatch
96
+ ? 'STALE_WORKSPACE_ID'
97
+ : 'stale paths';
98
+ return { state: 'installed-stale', reason, ...base };
99
+ }
100
+
101
+ /**
102
+ * Reproduce the exact lines `compose hooks status` printed for one hook type.
103
+ * Returns an array of strings (callers print each with console.log).
104
+ *
105
+ * @param {string} type — hook type ('post-commit' | 'pre-push')
106
+ * @param {HookStatusRaw} s — a single entry from computeHooksStatus
107
+ * @param {{composeNode: string, composeBin: string}} opts — current env values for the "expected" lines
108
+ * @returns {string[]}
109
+ */
110
+ export function formatHookStatusLines(type, s, { composeNode, composeBin }) {
111
+ if (s.state === 'absent') return [`${type}: absent — no hook installed`];
112
+ if (s.state === 'foreign') return [`${type}: foreign — hook exists but is not a Compose hook`];
113
+ if (s.state === 'installed-current') {
114
+ const lines = [`${type}: installed (current)`];
115
+ if (s.bakedWorkspace) lines.push(` workspace: ${s.bakedWorkspace}`);
116
+ return lines;
117
+ }
118
+ // installed-stale
119
+ const lines = [`${type}: installed (${s.reason} — re-run install)`];
120
+ if (s.reason === 'STALE_WORKSPACE_ID') lines.push(` expected COMPOSE_WORKSPACE_ID="${s.expectedWorkspace}"`);
121
+ if (!s.nodeMatch) lines.push(` expected COMPOSE_NODE="${composeNode}"`);
122
+ if (!s.binMatch) lines.push(` expected COMPOSE_BIN="${composeBin}"`);
123
+ return lines;
124
+ }
125
+
126
+ /**
127
+ * @typedef {object} HookStatusRaw
128
+ * @property {'absent'|'foreign'|'installed-current'|'installed-stale'} state
129
+ * @property {null|'MISSING_WORKSPACE_ID'|'STALE_WORKSPACE_ID'|'stale paths'} reason
130
+ * @property {string|null} bakedWorkspace
131
+ * @property {string|null} expectedWorkspace
132
+ * @property {boolean} wsVerified
133
+ * @property {boolean} nodeMatch
134
+ * @property {boolean} binMatch
135
+ */
package/lib/ideabox.js CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
18
18
  import { join, dirname } from 'node:path'
19
+ import { resolvePathValue } from './paths-core.js'
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // Default template
@@ -540,7 +541,7 @@ export function loadLens(cwd, lensName) {
540
541
  * Read and parse the ideabox file from the project.
541
542
  */
542
543
  export function readIdeabox(cwd, ideaboxPath) {
543
- const fullPath = join(cwd, ideaboxPath)
544
+ const fullPath = resolvePathValue(cwd, ideaboxPath, 'ideabox')
544
545
  if (!existsSync(fullPath)) {
545
546
  // Return empty state
546
547
  return { ideas: [], killed: [], nextId: 1 }
@@ -553,7 +554,7 @@ export function readIdeabox(cwd, ideaboxPath) {
553
554
  * Write serialized ideabox back to disk.
554
555
  */
555
556
  export function writeIdeabox(cwd, ideaboxPath, parsedData) {
556
- const fullPath = join(cwd, ideaboxPath)
557
+ const fullPath = resolvePathValue(cwd, ideaboxPath, 'ideabox')
557
558
  mkdirSync(dirname(fullPath), { recursive: true })
558
559
  writeFileSync(fullPath, serializeIdeabox(parsedData))
559
560
  }
@@ -0,0 +1,22 @@
1
+ import { existsSync, mkdirSync, readdirSync, copyFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Install Claude Code agent definitions (*.md) from srcDir into destDir.
6
+ * Idempotent: mkdir -p the dest and overwrite existing files. Returns the list
7
+ * of installed agent names (filenames sans `.md`). No-op → [] when srcDir is
8
+ * absent or has no `.md` files. (COMP-AGENT-VENDOR-1 — vendored compose-explorer
9
+ * / compose-architect were referenced by the skill but never installed.)
10
+ */
11
+ export function installAgentDefs(srcDir, destDir) {
12
+ if (!srcDir || !existsSync(srcDir)) return [];
13
+ const defs = readdirSync(srcDir).filter(f => f.endsWith('.md'));
14
+ if (defs.length === 0) return [];
15
+ mkdirSync(destDir, { recursive: true });
16
+ const installed = [];
17
+ for (const f of defs) {
18
+ copyFileSync(join(srcDir, f), join(destDir, f));
19
+ installed.push(f.replace(/\.md$/, ''));
20
+ }
21
+ return installed;
22
+ }
@@ -29,12 +29,12 @@ import { join, dirname } from 'node:path';
29
29
 
30
30
  import { appendEvent, normalizeSince } from './feature-events.js';
31
31
  import { checkOrInsert } from './idempotency.js';
32
+ import { resolveJournalPath } from './project-paths.js';
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // Constants & regexes
35
36
  // ---------------------------------------------------------------------------
36
37
 
37
- const JOURNAL_DIR = 'docs/journal';
38
38
  const INDEX_FILE = 'README.md';
39
39
  const ENTRIES_HEADING = '## Entries';
40
40
 
@@ -649,7 +649,7 @@ export async function writeJournalEntry(cwd, args) {
649
649
  validateArgs(args);
650
650
 
651
651
  return maybeIdempotent({ ...args, cwd }, async () => {
652
- const journalDir = join(cwd, JOURNAL_DIR);
652
+ const journalDir = resolveJournalPath(cwd);
653
653
  const indexPath = join(journalDir, INDEX_FILE);
654
654
 
655
655
  // Pre-flight: read + parse index before any disk mutation.
@@ -857,7 +857,7 @@ export async function writeJournalEntry(cwd, args) {
857
857
  * @returns {{ entries: Array, count: number }}
858
858
  */
859
859
  export function getJournalEntries(cwd, opts = {}) {
860
- const journalDir = join(cwd, JOURNAL_DIR);
860
+ const journalDir = resolveJournalPath(cwd);
861
861
  if (!existsSync(journalDir)) return { entries: [], count: 0 };
862
862
 
863
863
  const sinceMs = opts.since !== undefined ? normalizeSince(opts.since) : null;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * migrate-anon — COMP-MCP-MIGRATION-2-1-1-1
3
+ *
4
+ * `compose migrate-anon`: interactive promotion of historical *anonymous*
5
+ * ROADMAP rows (the `| — | Item | Status |` form preserved verbatim by
6
+ * COMP-MCP-MIGRATION-2-1-1) to typed features.
7
+ *
8
+ * Load-bearing correctness point: anonymous rows are re-emitted on regen by
9
+ * `predecessorCode` anchoring, and a row only stops being anonymous when its raw
10
+ * Feature cell becomes a valid code. So scaffolding a feature.json ALONE leaves
11
+ * the `—` row passing through verbatim → a DUPLICATE. Promotion therefore strips
12
+ * the source `rawLine` from ROADMAP.md *before* scaffold+regen.
13
+ *
14
+ * `readAnonymousRows` is the canonical classifier for which rows are anonymous +
15
+ * their order + anchor; this module only adds header-aware cell display and the
16
+ * strip-then-scaffold primitive on top of it.
17
+ */
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { createInterface } from 'node:readline';
20
+
21
+ import { resolveRoadmapPath, loadFeaturesDir } from './project-paths.js';
22
+ import { readAnonymousRows, PRESERVED_OPEN_RE, PRESERVED_CLOSE_RE } from './roadmap-preservers.js';
23
+ import { splitRoadmapCells, detectColumnLayout } from './roadmap-parser.js';
24
+ import { splitPhaseHeading, PHASE_HEADING_TEXT_RE, parseStatusToken, STATUS_TOKENS } from './roadmap-heading.js';
25
+ import { isFeatureCode } from './feature-code.js';
26
+ import { listFeatures, positionSortKey } from './feature-json.js';
27
+ import { addRoadmapEntry } from './feature-writer.js';
28
+
29
+ const FENCE_RE = /^```/;
30
+ const TABLE_ROW_RE = /^\|.+\|$/;
31
+ const TABLE_DIVIDER_RE = /^\|[\s|:-]+\|$/;
32
+
33
+ // Strip markdown bold/code markers so a `**IN_PROGRESS**` or `` `x` `` cell
34
+ // matches the same way the roadmap parser sees it (it bold-strips before parsing).
35
+ // NB: do NOT strip `_` — it's part of the `IN_PROGRESS` status token itself.
36
+ const stripEmphasis = (s) => String(s ?? '').replace(/[*`]/g, '').trim();
37
+
38
+ /**
39
+ * Parse anonymous rows for display, header-aware. Classification + raw text +
40
+ * anchor come from the canonical `readAnonymousRows`; the column layout only
41
+ * decides which cell is title vs status, and is taken from the **nearest
42
+ * preceding header** of each row's own table — a phase can hold multiple tables
43
+ * with different layouts (3-col then 4-col), so a single per-phase header is
44
+ * wrong. The forward scan mirrors `readAnonymousRows`' table-state reset (a
45
+ * non-table line or a new heading ends the current table) and matches anon rows
46
+ * by in-order `rawLine` equality, so classification never drifts and the layout
47
+ * is display-only (a misread can never affect strip/anchor).
48
+ *
49
+ * @param {string} text - ROADMAP.md source
50
+ * @returns {Array<{phaseId:string, occurrenceIndex:number, num:string, title:string, status:string, rawLine:string, predecessorCode:string|null}>}
51
+ */
52
+ export function collectAnonRowsFromText(text) {
53
+ const anonMap = readAnonymousRows(text);
54
+ const ptr = new Map(); // phaseId → next occurrence index to match
55
+ const out = [];
56
+ let inFence = false;
57
+ let inPreserved = false;
58
+ let cur = null;
59
+ let header = null;
60
+ let inTable = false;
61
+
62
+ for (const line of text.split('\n')) {
63
+ const trimmed = line.trim();
64
+ if (FENCE_RE.test(trimmed)) { inFence = !inFence; continue; }
65
+ if (inFence) continue;
66
+ if (PRESERVED_OPEN_RE.test(trimmed)) { inPreserved = true; inTable = false; header = null; continue; }
67
+ if (PRESERVED_CLOSE_RE.test(trimmed)) { inPreserved = false; continue; }
68
+ if (inPreserved) continue;
69
+
70
+ const h = line.match(PHASE_HEADING_TEXT_RE);
71
+ if (h && line.startsWith('## ')) { cur = splitPhaseHeading(h[1]).title; inTable = false; header = null; continue; }
72
+ if (!cur) continue;
73
+
74
+ if (!TABLE_ROW_RE.test(trimmed)) { inTable = false; header = null; continue; }
75
+ if (!inTable) { header = splitRoadmapCells(line); inTable = true; continue; } // header row
76
+ if (TABLE_DIVIDER_RE.test(trimmed)) continue;
77
+
78
+ // Data row — match against the canonical anon list in document order.
79
+ const rows = anonMap.get(cur) ?? [];
80
+ const p = ptr.get(cur) ?? 0;
81
+ if (p < rows.length && line === rows[p].rawLine) {
82
+ const layout = detectColumnLayout(header ?? ['#', 'Item', 'Status']);
83
+ const cells = splitRoadmapCells(line);
84
+ const num = layout.codeCol >= 0 ? (cells[layout.codeCol] ?? '') : (cells[0] ?? '');
85
+ const title = stripEmphasis(cells[layout.descCol] ?? '');
86
+ const status = parseStatusToken(stripEmphasis(cells[layout.statusCol] ?? '')) ?? 'PLANNED';
87
+ out.push({ phaseId: cur, occurrenceIndex: p, num, title, status, rawLine: line, predecessorCode: rows[p].predecessorCode });
88
+ ptr.set(cur, p + 1);
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Locate the physical line index of the `occurrenceIndex`-th anonymous row in a
96
+ * phase. Forward-scans, consuming `readAnonymousRows`' ordered rawLines so that
97
+ * identical row text resolves to the correct physical occurrence (no global
98
+ * string match). Returns -1 if not found.
99
+ */
100
+ function anonLineIndex(text, phaseId, occurrenceIndex) {
101
+ const rows = readAnonymousRows(text).get(phaseId) ?? [];
102
+ if (occurrenceIndex < 0 || occurrenceIndex >= rows.length) return -1;
103
+ const lines = text.split('\n');
104
+ let inFence = false;
105
+ let inPreserved = false;
106
+ let cur = null;
107
+ let ptr = 0;
108
+ for (let i = 0; i < lines.length; i++) {
109
+ const line = lines[i];
110
+ const trimmed = line.trim();
111
+ if (FENCE_RE.test(trimmed)) { inFence = !inFence; continue; }
112
+ if (inFence) continue;
113
+ if (PRESERVED_OPEN_RE.test(trimmed)) { inPreserved = true; continue; }
114
+ if (PRESERVED_CLOSE_RE.test(trimmed)) { inPreserved = false; continue; }
115
+ if (inPreserved) continue;
116
+ const h = line.match(PHASE_HEADING_TEXT_RE);
117
+ if (h && line.startsWith('## ')) { cur = splitPhaseHeading(h[1]).title; continue; }
118
+ if (cur === phaseId && ptr < rows.length && line === rows[ptr].rawLine) {
119
+ if (ptr === occurrenceIndex) return i;
120
+ ptr++;
121
+ }
122
+ }
123
+ return -1;
124
+ }
125
+
126
+ /**
127
+ * Remove the `occurrenceIndex`-th anonymous row of `phaseId` from ROADMAP text.
128
+ * Phase-scoped and occurrence-specific. Returns the new text (throws if not found).
129
+ */
130
+ export function stripAnonLine(text, phaseId, occurrenceIndex) {
131
+ const idx = anonLineIndex(text, phaseId, occurrenceIndex);
132
+ if (idx === -1) {
133
+ throw new Error(`migrate-anon: anon row #${occurrenceIndex} not found in phase "${phaseId}"`);
134
+ }
135
+ const lines = text.split('\n');
136
+ lines.splice(idx, 1);
137
+ return lines.join('\n');
138
+ }
139
+
140
+ // Best-effort end-of-phase position. Computed with positionSortKey (string-aware),
141
+ // NOT nextPositionInPhase which coerces string/ranged positions to 0.
142
+ async function endOfPhasePosition(cwd, phaseId) {
143
+ // Config-aware features dir (mirrors addRoadmapEntry) — listFeatures defaults to
144
+ // docs/features and would otherwise miss relocated feature sets.
145
+ const peers = (await listFeatures(cwd, loadFeaturesDir(cwd))).filter((f) => f.phase === phaseId);
146
+ if (peers.length === 0) return 1;
147
+ const max = peers.reduce((m, f) => Math.max(m, positionSortKey(f.position)), 0);
148
+ return max + 1;
149
+ }
150
+
151
+ /**
152
+ * Promote one anonymous row to a typed feature: strip its rawLine, then scaffold.
153
+ *
154
+ * Failure handling keys on the error code, because addRoadmapEntry commits
155
+ * feature.json BEFORE regenerating ROADMAP.md:
156
+ * - pre-commit failures (validation / code-exists / ROUNDTRIP_NOT_FIXED_POINT)
157
+ * → restore the stripped snapshot (nothing committed) and rethrow.
158
+ * - ROADMAP_PARTIAL_WRITE (feature.json committed, regen failed) → do NOT
159
+ * restore (that would re-add the anon row alongside the typed feature = the
160
+ * duplicate this flow avoids); rethrow as-is. Re-running reconciles.
161
+ *
162
+ * @param {string} cwd
163
+ * @param {{phaseId:string, occurrenceIndex:number, title:string}} row
164
+ * @param {{code:string, status?:string, scaffold?:Function}} opts
165
+ */
166
+ export async function promoteAnonRow(cwd, row, { code, status = 'PLANNED', scaffold = addRoadmapEntry } = {}) {
167
+ const upper = String(code).toUpperCase();
168
+ if (!isFeatureCode(upper)) {
169
+ throw new Error(`migrate-anon: "${code}" is not a valid feature code`);
170
+ }
171
+ const roadmapPath = resolveRoadmapPath(cwd);
172
+ const snapshot = readFileSync(roadmapPath, 'utf-8');
173
+ const stripped = stripAnonLine(snapshot, row.phaseId, row.occurrenceIndex);
174
+ writeFileSync(roadmapPath, stripped);
175
+ try {
176
+ const position = await endOfPhasePosition(cwd, row.phaseId);
177
+ return await scaffold(cwd, {
178
+ code: upper,
179
+ description: row.title,
180
+ phase: row.phaseId,
181
+ status,
182
+ complexity: 'S',
183
+ position,
184
+ });
185
+ } catch (err) {
186
+ if (err && err.code === 'ROADMAP_PARTIAL_WRITE') throw err;
187
+ // Pre-commit failure: nothing was committed — put the anon row back.
188
+ writeFileSync(roadmapPath, snapshot);
189
+ throw err;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Interactive driver. Streams are injectable for testing. Callers (the CLI) own
195
+ * the non-TTY guard: pass `nonInteractive: true` for piped stdin / --non-interactive
196
+ * / --dry-run, and this lists the rows without prompting.
197
+ *
198
+ * @param {string} cwd
199
+ * @param {{input?:any, output?:any, nonInteractive?:boolean, dryRun?:boolean, scaffold?:Function}} [opts]
200
+ * @returns {Promise<{listed:number, promoted:string[], aborted:boolean}>}
201
+ */
202
+ export async function runMigrateAnon(cwd, opts = {}) {
203
+ const {
204
+ input = process.stdin,
205
+ output = process.stdout,
206
+ nonInteractive = false,
207
+ dryRun = false,
208
+ scaffold = addRoadmapEntry,
209
+ } = opts;
210
+ const write = (s) => output.write(s + '\n');
211
+ const roadmapPath = resolveRoadmapPath(cwd);
212
+ const rows = collectAnonRowsFromText(readFileSync(roadmapPath, 'utf-8'));
213
+
214
+ if (rows.length === 0) {
215
+ write('migrate-anon: no anonymous rows found.');
216
+ return { listed: 0, promoted: [], aborted: false };
217
+ }
218
+
219
+ if (nonInteractive || dryRun) {
220
+ write(`migrate-anon: ${rows.length} anonymous row(s):`);
221
+ for (const r of rows) write(` [${r.phaseId}] ${r.title} (${r.status})`);
222
+ write('Run interactively in a TTY to promote rows.');
223
+ return { listed: rows.length, promoted: [], aborted: false };
224
+ }
225
+
226
+ const rl = createInterface({ input, output });
227
+ const ask = (q) => new Promise((res) => rl.question(q, res));
228
+ const promoted = [];
229
+ const promotedByPhase = {};
230
+ let aborted = false;
231
+
232
+ try {
233
+ for (const row of rows) {
234
+ write(`\n[${row.phaseId}] ${row.title}`);
235
+ write(` inferred status: ${row.status}`);
236
+
237
+ // Code prompt (re-prompt on invalid; Enter=skip; q=abort).
238
+ let code = null;
239
+ for (;;) {
240
+ const ans = (await ask(' Code to assign (Enter=skip, q=abort): ')).trim();
241
+ if (ans === '') break;
242
+ if (ans.toLowerCase() === 'q') { aborted = true; break; }
243
+ const c = ans.toUpperCase();
244
+ if (!isFeatureCode(c)) { write(` ✗ "${ans}" is not a valid feature code — try again`); continue; }
245
+ code = c;
246
+ break;
247
+ }
248
+ if (aborted) break;
249
+ if (!code) continue;
250
+
251
+ // Status confirm/override.
252
+ const sAns = (await ask(` Status [${row.status}] (Enter=keep, or one of ${STATUS_TOKENS.join('/')}): `)).trim();
253
+ let status = row.status;
254
+ if (sAns !== '') {
255
+ const s = sAns.toUpperCase();
256
+ if (STATUS_TOKENS.includes(s)) status = s;
257
+ else write(` (unrecognized status "${sAns}" — keeping ${row.status})`);
258
+ }
259
+
260
+ const liveIndex = row.occurrenceIndex - (promotedByPhase[row.phaseId] ?? 0);
261
+ try {
262
+ await promoteAnonRow(cwd, { ...row, occurrenceIndex: liveIndex }, { code, status, scaffold });
263
+ promoted.push(code);
264
+ promotedByPhase[row.phaseId] = (promotedByPhase[row.phaseId] ?? 0) + 1;
265
+ write(` ✓ promoted to ${code}`);
266
+ } catch (err) {
267
+ write(` ✗ promotion failed: ${err.message}`);
268
+ throw err;
269
+ }
270
+ }
271
+ } finally {
272
+ rl.close();
273
+ }
274
+
275
+ write(`\nmigrate-anon: ${promoted.length} promoted${aborted ? ' (aborted early)' : ''}.`);
276
+ return { listed: rows.length, promoted, aborted };
277
+ }
@@ -6,10 +6,9 @@
6
6
  */
7
7
 
8
8
  import { readFileSync, existsSync } from 'fs';
9
- import { join } from 'path';
10
9
  import { parseRoadmap } from './roadmap-parser.js';
11
10
  import { readFeature, writeFeature } from './feature-json.js';
12
- import { loadFeaturesDir } from './project-paths.js';
11
+ import { loadFeaturesDir, resolveRoadmapPath } from './project-paths.js';
13
12
 
14
13
  /**
15
14
  * Migrate ROADMAP.md entries to feature.json files.
@@ -31,7 +30,7 @@ export function migrateRoadmap(cwd, opts = {}) {
31
30
  const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
32
31
  const externalPrefixes = opts.externalPrefixes ?? [];
33
32
  const isExternal = (code) => externalPrefixes.some((p) => code.startsWith(p));
34
- const roadmapPath = join(cwd, 'ROADMAP.md');
33
+ const roadmapPath = resolveRoadmapPath(cwd);
35
34
 
36
35
  if (!existsSync(roadmapPath)) {
37
36
  throw new Error(`No ROADMAP.md found at ${roadmapPath}`);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * paths-core.js — PURE artifact-path resolution. No fs, no config reading.
3
+ * Single source of truth for default artifact locations (COMP-PATHS-EXTERNAL).
4
+ *
5
+ * `resolvePathValue` uses path.resolve (NOT path.join) so an absolute or
6
+ * ../-escaping `paths.*` override resolves correctly instead of being
7
+ * silently re-rooted under cwd.
8
+ */
9
+ import path from 'node:path';
10
+
11
+ export const DEFAULT_PATHS = Object.freeze({
12
+ docs: 'docs',
13
+ roadmap: 'ROADMAP.md',
14
+ features: 'docs/features',
15
+ journal: 'docs/journal',
16
+ context: 'docs/context',
17
+ ideabox: 'docs/product/ideabox.md',
18
+ });
19
+
20
+ /**
21
+ * Resolve a configured `paths[key]` value to an absolute, normalized path.
22
+ *
23
+ * @param {string} root Absolute workspace root.
24
+ * @param {*} value The configured paths[key] value (any type).
25
+ * @param {string} key Fallback key into DEFAULT_PATHS when value is unusable.
26
+ * @returns {string} Absolute, normalized path.
27
+ */
28
+ export function resolvePathValue(root, value, key) {
29
+ const v = (typeof value === 'string' && value.trim().length > 0)
30
+ ? value
31
+ : DEFAULT_PATHS[key];
32
+ return path.isAbsolute(v) ? path.normalize(v) : path.resolve(root, v);
33
+ }
34
+
35
+ /**
36
+ * Display-safe relativization: a clean root-relative string when `abs` is
37
+ * inside `root`, else the absolute path (never a `../`-prefixed string).
38
+ *
39
+ * @param {string} root Absolute workspace root.
40
+ * @param {string} abs Absolute path to relativize for display.
41
+ * @returns {string}
42
+ */
43
+ export function relForDisplay(root, abs) {
44
+ const rel = path.relative(root, abs);
45
+ if (rel === '') return '.';
46
+ return rel.startsWith('..') || path.isAbsolute(rel) ? abs : rel;
47
+ }
@@ -1,52 +1,61 @@
1
1
  /**
2
- * project-paths.js — read .compose/compose.json `paths.features` override
3
- * for lib-side writers.
2
+ * project-paths.js — read .compose/compose.json and resolve artifact paths
3
+ * for lib-side code. All path math is delegated to lib/paths-core.js so the
4
+ * server and lib resolvers can never diverge.
4
5
  *
5
- * Server-side code uses `server/project-root.js`'s cached `loadProjectConfig`,
6
- * but lib code may run outside the server process (CLI, tests, MCP stdio)
7
- * and shouldn't share that cache. This is a tiny per-call read; the file is
8
- * a few hundred bytes.
9
- *
10
- * Introduced by COMP-MCP-MIGRATION-2.
6
+ * Introduced by COMP-MCP-MIGRATION-2; extended to relocatable artifact paths
7
+ * by COMP-PATHS-EXTERNAL (absolute readers + *FromConfig alternate-root form).
11
8
  */
12
9
  import { existsSync, readFileSync } from 'fs';
13
10
  import { join } from 'path';
11
+ import { DEFAULT_PATHS, resolvePathValue, relForDisplay } from './paths-core.js';
12
+
13
+ function readConfig(cwd) {
14
+ const cfgPath = join(cwd, '.compose', 'compose.json');
15
+ if (!existsSync(cfgPath)) return {};
16
+ try { return JSON.parse(readFileSync(cfgPath, 'utf-8')); }
17
+ catch { return {}; }
18
+ }
19
+
20
+ /** Resolve one artifact key against the config on disk at `cwd`. Absolute. */
21
+ function resolveKey(cwd, key) {
22
+ return resolvePathValue(cwd, readConfig(cwd)?.paths?.[key], key);
23
+ }
24
+ /** Resolve one artifact key against an already-loaded config + arbitrary root. Absolute. */
25
+ function resolveKeyFromConfig(root, config, key) {
26
+ return resolvePathValue(root, config?.paths?.[key], key);
27
+ }
14
28
 
15
- const DEFAULT_FEATURES_DIR = 'docs/features';
29
+ export const resolveDocsPath = (cwd) => resolveKey(cwd, 'docs');
30
+ export const resolveRoadmapPath = (cwd) => resolveKey(cwd, 'roadmap');
31
+ export const resolveFeaturesPath = (cwd) => resolveKey(cwd, 'features');
32
+ export const resolveJournalPath = (cwd) => resolveKey(cwd, 'journal');
33
+ export const resolveContextPath = (cwd) => resolveKey(cwd, 'context');
34
+ export const resolveIdeaboxPath = (cwd) => resolveKey(cwd, 'ideabox');
35
+
36
+ export const resolveDocsPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'docs');
37
+ export const resolveRoadmapPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'roadmap');
38
+ export const resolveFeaturesPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'features');
39
+ export const resolveJournalPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'journal');
40
+ export const resolveContextPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'context');
41
+ export const resolveIdeaboxPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'ideabox');
42
+
43
+ export { relForDisplay };
16
44
 
17
45
  /**
18
- * Resolve the project's features directory, respecting `.compose/compose.json`'s
19
- * `paths.features` override. Returns the relative path (joined onto cwd by callers).
20
- *
21
- * @param {string} cwd
22
- * @returns {string} Relative features dir, e.g. 'docs/features' or 'specs/features'.
46
+ * @deprecated relative form prefer resolveFeaturesPath (absolute). Kept for
47
+ * any caller that genuinely needs the configured RELATIVE string. Callers that
48
+ * pass the result into feature-json.js should migrate to resolveFeaturesPath,
49
+ * which is absolute-safe (COMP-PATHS-EXTERNAL S2).
23
50
  */
24
51
  export function loadFeaturesDir(cwd) {
25
- const cfgPath = join(cwd, '.compose', 'compose.json');
26
- if (!existsSync(cfgPath)) return DEFAULT_FEATURES_DIR;
27
- try {
28
- const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
29
- const rel = cfg?.paths?.features;
30
- return (typeof rel === 'string' && rel.length > 0) ? rel : DEFAULT_FEATURES_DIR;
31
- } catch {
32
- return DEFAULT_FEATURES_DIR;
33
- }
52
+ const rel = readConfig(cwd)?.paths?.features;
53
+ return (typeof rel === 'string' && rel.length > 0) ? rel : DEFAULT_PATHS.features;
34
54
  }
35
55
 
36
- /**
37
- * Read `externalPrefixes` from .compose/compose.json — code prefixes (e.g.
38
- * ["STRAT-"]) for features owned by OTHER projects, present in this roadmap
39
- * only as cross-project references. Returns [] if absent/unreadable.
40
- * @param {string} cwd
41
- * @returns {string[]}
42
- */
43
56
  export function loadExternalPrefixes(cwd) {
44
- try {
45
- const cfgPath = join(cwd, '.compose', 'compose.json');
46
- if (!existsSync(cfgPath)) return [];
47
- const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
48
- return Array.isArray(cfg.externalPrefixes) ? cfg.externalPrefixes : [];
49
- } catch { return []; }
57
+ const arr = readConfig(cwd)?.externalPrefixes;
58
+ return Array.isArray(arr) ? arr : [];
50
59
  }
51
60
 
52
- export const _internals = { DEFAULT_FEATURES_DIR };
61
+ export const _internals = { DEFAULT_FEATURES_DIR: DEFAULT_PATHS.features };