@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.
- package/.claude/skills/compose/SKILL.md +24 -1
- package/.claude/skills/compose/references/hermes-tools.md +80 -0
- package/README.md +1 -1
- package/bin/compose.js +340 -70
- package/dist/assets/App-CT0vXPgd.js +724 -0
- package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
- package/dist/assets/{arc-B1TqAC_R.js → arc-BXht3LyE.js} +1 -1
- package/dist/assets/architectureDiagram-Q4EWVU46-BV1r86-w.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-BgcMJjAR.js +132 -0
- package/dist/assets/{browser-DW2JlJCC.js → browser-CnKiSnlr.js} +6 -6
- package/dist/assets/{c4Diagram-AAUBKEIU-Dc6swoQo.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
- package/dist/assets/channel-Dd4XaiYv.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-CdqXgd68.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
- package/dist/assets/{chunk-55IACEB6-D7VauOmU.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
- package/dist/assets/{chunk-2J33WTMH-6HKDSnOw.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C1Z5m8DP.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
- package/dist/assets/{chunk-QZHKN3VN-DY9GU2uD.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-DmqZMEQe.js → chunk-YZCP3GAM-DE6_5aqh.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +1 -0
- package/dist/assets/clone-CBsbNGAa.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-B34g9xzV.js → cose-bilkent-S5V4N54A-DzzRyJtB.js} +1 -1
- package/dist/assets/dagre-KV5264BT-CTcTVGdU.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-qR6mc8kJ.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-CUcGXyNb.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-B68cPonH.js +43 -0
- package/dist/assets/diagram-TYMM5635-q8Id8eFG.js +24 -0
- package/dist/assets/{erDiagram-TEJ5UH35-C05_Iqmb.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-lXpnb-Ch.js → flowDiagram-DWJPFMVM-BzzO4JYU.js} +1 -1
- package/dist/assets/ganttDiagram-T4ZO3ILL-Dn0wIyhR.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-tnNFXfMM.js +106 -0
- package/dist/assets/graph-DZe55uk8.js +331 -0
- package/dist/assets/graph-Tq_bs_r0.js +1 -0
- package/dist/assets/index-8UhRLbGq.js +123 -0
- package/dist/assets/index-CRqB9els.css +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-BXLvLeS0.js +2 -0
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-CgdsWdgu.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
- package/dist/assets/{journeyDiagram-JHISSGLW-CyvHmE3z.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-Bqjdyv0T.js → kanban-definition-6JOO6SKY-CEq930ew.js} +1 -1
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-qtUgN9BC.js +1 -0
- package/dist/assets/{linear-DecnRXf0.js → linear-D158F7OT.js} +1 -1
- package/dist/assets/min-hESxN-c0.js +1 -0
- package/dist/assets/{mindmap-definition-RKZ34NQL-DM1x9vNY.js → mindmap-definition-QFDTVHPH-d9SSh2nG.js} +7 -7
- package/dist/assets/mobile-1gVCT0OK.css +1 -0
- package/dist/assets/mobile-Chw8RWyH.js +17 -0
- package/dist/assets/pieDiagram-DEJITSTG-gglmeMav.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-CqFC_htU.js +7 -0
- package/dist/assets/{requirementDiagram-4Y6WPE33-DD-Gg_y5.js → requirementDiagram-MS252O5E-B1ZFIotK.js} +1 -1
- package/dist/assets/sankeyDiagram-XADWPNL6-BvItvZt5.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-CAIn5Z3U.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-CyJrGzib.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DprhF_Ho.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-BPm2AEQj.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-DQCDWwlj.js → vennDiagram-DHZGUBPP-TZAW60b0.js} +4 -4
- package/dist/assets/wardley-RL74JXVD-DzQEoG6X.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-BAoVVIjZ.js +20 -0
- package/dist/assets/{xychartDiagram-2RQKCTM6-Ba05SXXF.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
- package/dist/index.html +4 -4
- package/lib/build-all.js +2 -1
- package/lib/build.js +363 -38
- package/lib/checkpoint/checkpoint-writer.js +3 -2
- package/lib/codex-preflight.js +151 -0
- package/lib/completion-writer.js +59 -15
- package/lib/feature-json.js +14 -3
- package/lib/feature-validator.js +27 -10
- package/lib/feature-write-guard.js +5 -7
- package/lib/feature-writer.js +3 -3
- package/lib/followup-writer.js +2 -2
- package/lib/get-roadmap.js +2 -2
- package/lib/gsd.js +2 -1
- package/lib/hooks-status.js +135 -0
- package/lib/ideabox.js +3 -2
- package/lib/install-agent-defs.js +22 -0
- package/lib/journal-writer.js +3 -3
- package/lib/migrate-anon.js +277 -0
- package/lib/migrate-roadmap.js +2 -3
- package/lib/paths-core.js +47 -0
- package/lib/project-paths.js +46 -37
- package/lib/roadmap-gen.js +3 -4
- package/lib/roadmap-graph/collect.js +5 -5
- package/lib/roadmap-graph/index.js +2 -2
- package/lib/roadmap-parser.js +1 -1
- package/lib/state-migrations.js +3 -3
- package/lib/test-bootstrap.js +165 -2
- package/lib/tracker/local-provider.js +2 -2
- package/lib/triage.js +7 -5
- package/lib/vocabulary-inject.js +103 -0
- package/lib/xref-push.js +4 -4
- package/lib/xref-sync.js +4 -4
- package/package.json +2 -1
- package/pipelines/build-quick.stratum.yaml +433 -0
- package/pipelines/build.stratum.yaml +60 -3
- package/scripts/watch-server.sh +32 -0
- package/server/build-routes.js +89 -9
- package/server/build-stream-bridge.js +12 -0
- package/server/compose-mcp-tools.js +73 -1
- package/server/compose-mcp.js +27 -0
- package/server/design-routes.js +9 -4
- package/server/drift-axes.js +5 -3
- package/server/feature-scaffold-routes.js +82 -0
- package/server/feature-scan.js +8 -2
- package/server/file-watcher.js +13 -7
- package/server/health-routes.js +261 -0
- package/server/ideabox-routes.js +12 -11
- package/server/project-root.js +7 -4
- package/server/qa-scope-routes.js +96 -0
- package/server/session-routes.js +2 -2
- package/server/stratum-sync.js +85 -9
- package/server/validate-routes.js +70 -0
- package/server/vision-routes.js +46 -2
- package/server/vision-server.js +25 -3
- package/server/vision-utils.js +23 -4
- package/dist/assets/App-DOerhoDp.js +0 -706
- package/dist/assets/architectureDiagram-3BPJPVTR-C4hfxNOw.js +0 -36
- package/dist/assets/blockDiagram-GPEHLZMM-DRqkGncy.js +0 -132
- package/dist/assets/channel-KsY1x-7s.js +0 -1
- package/dist/assets/chunk-727SXJPM-CeKpXDif.js +0 -206
- package/dist/assets/chunk-AQP2D5EJ-y2XL-YHV.js +0 -231
- package/dist/assets/classDiagram-4FO5ZUOK-zDEDoVPk.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-zDEDoVPk.js +0 -1
- package/dist/assets/dagre-BM42HDAG-klZ_GOcw.js +0 -4
- package/dist/assets/diagram-2AECGRRQ-D0FmM6Wf.js +0 -43
- package/dist/assets/diagram-5GNKFQAL-DYuhby6q.js +0 -10
- package/dist/assets/diagram-KO2AKTUF-CCtQZgal.js +0 -3
- package/dist/assets/diagram-LMA3HP47-Cd1OqEln.js +0 -24
- package/dist/assets/diagram-OG6HWLK6-CiJrhoxo.js +0 -24
- package/dist/assets/ganttDiagram-6RSMTGT7-EPbL_2Hr.js +0 -292
- package/dist/assets/gitGraphDiagram-PVQCEYII-COCoF_vW.js +0 -106
- package/dist/assets/graph-BBXaecIU.js +0 -331
- package/dist/assets/graph-CAnANduQ.js +0 -1
- package/dist/assets/index-BwLfbbOu.css +0 -1
- package/dist/assets/index-DlgxA8dX.js +0 -123
- package/dist/assets/infoDiagram-5YYISTIA-B1sJw6j5.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DGIYPm2g.js +0 -1
- package/dist/assets/mobile-Cag5dHlF.css +0 -1
- package/dist/assets/mobile-DwmxS_O4.js +0 -17
- package/dist/assets/pieDiagram-4H26LBE5-CRtmCvwV.js +0 -30
- package/dist/assets/quadrantDiagram-W4KKPZXB-CsBLWg27.js +0 -7
- package/dist/assets/sankeyDiagram-5OEKKPKP-BPdMN8UU.js +0 -40
- package/dist/assets/sequenceDiagram-3UESZ5HK-D4AENdze.js +0 -162
- package/dist/assets/stateDiagram-AJRCARHV-DDplpCf_.js +0 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-J4t9AVzF.js +0 -1
- package/dist/assets/wardley-L42UT6IY-WERqOmJt.js +0 -173
- 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 =
|
|
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 =
|
|
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
|
+
}
|
package/lib/journal-writer.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
+
}
|
package/lib/migrate-roadmap.js
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/lib/project-paths.js
CHANGED
|
@@ -1,52 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* project-paths.js — read .compose/compose.json
|
|
3
|
-
* for lib-side
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
26
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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 };
|