@smartmemory/compose 0.2.50-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 +310 -52
- package/dist/assets/App-CT0vXPgd.js +724 -0
- package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
- package/dist/assets/{arc-DeHak63Z.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-BsFJmPuS.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
- package/dist/assets/channel-Dd4XaiYv.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-XO4S2_89.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
- package/dist/assets/{chunk-55IACEB6-CtFSLInj.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
- package/dist/assets/{chunk-2J33WTMH-AEu6HIoY.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BMGd_Y9a.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
- package/dist/assets/{chunk-QZHKN3VN-DHhUJ8RK.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-BlrOECEr.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-C3M0mZqk.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-AMDitV0b.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-C0t2ZGQv.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-CCBwgjFs.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
- package/dist/assets/{journeyDiagram-JHISSGLW-zUJN38EU.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-BNxWi-8J.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-CRH9b4g7.js → linear-D158F7OT.js} +1 -1
- package/dist/assets/min-hESxN-c0.js +1 -0
- package/dist/assets/{mindmap-definition-RKZ34NQL-DolYwq7Y.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-DsXZ05jo.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-Bvnhw58e.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-5WnyjZqf.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-BCShes6B.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
- package/dist/index.html +4 -4
- package/lib/build.js +272 -33
- package/lib/codex-preflight.js +151 -0
- package/lib/completion-writer.js +21 -1
- package/lib/feature-validator.js +5 -1
- package/lib/hooks-status.js +135 -0
- package/lib/install-agent-defs.js +22 -0
- package/lib/migrate-anon.js +277 -0
- package/lib/roadmap-parser.js +1 -1
- package/lib/test-bootstrap.js +165 -2
- package/lib/vocabulary-inject.js +103 -0
- 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/feature-scaffold-routes.js +82 -0
- package/server/health-routes.js +261 -0
- package/server/qa-scope-routes.js +96 -0
- package/server/validate-routes.js +70 -0
- package/server/vision-routes.js +29 -0
- package/server/vision-server.js +21 -0
- package/dist/assets/App-Bxyif1yv.js +0 -706
- package/dist/assets/architectureDiagram-3BPJPVTR-C5Nkyl9y.js +0 -36
- package/dist/assets/blockDiagram-GPEHLZMM-BilnUBG2.js +0 -132
- package/dist/assets/channel-ZgbQ1k0u.js +0 -1
- package/dist/assets/chunk-727SXJPM-CZeBRpM9.js +0 -206
- package/dist/assets/chunk-AQP2D5EJ-_vmxcBc4.js +0 -231
- package/dist/assets/classDiagram-4FO5ZUOK-KXxOorUx.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-KXxOorUx.js +0 -1
- package/dist/assets/dagre-BM42HDAG-DJlm4fEk.js +0 -4
- package/dist/assets/diagram-2AECGRRQ-BmcONQG5.js +0 -43
- package/dist/assets/diagram-5GNKFQAL-5JbWGIwd.js +0 -10
- package/dist/assets/diagram-KO2AKTUF-DAnIvyI0.js +0 -3
- package/dist/assets/diagram-LMA3HP47-Bmh7tvmI.js +0 -24
- package/dist/assets/diagram-OG6HWLK6-hnHVMNnk.js +0 -24
- package/dist/assets/ganttDiagram-6RSMTGT7-BPlM-BEN.js +0 -292
- package/dist/assets/gitGraphDiagram-PVQCEYII-Lv_YTxnb.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-COq21Zym.js +0 -119
- package/dist/assets/infoDiagram-5YYISTIA-Dul1vdUm.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-oxNfr2TX.js +0 -30
- package/dist/assets/quadrantDiagram-W4KKPZXB-BmZCvD-z.js +0 -7
- package/dist/assets/sankeyDiagram-5OEKKPKP-DsnYaawP.js +0 -40
- package/dist/assets/sequenceDiagram-3UESZ5HK-Dt5mu3g8.js +0 -162
- package/dist/assets/stateDiagram-AJRCARHV-BFR5ZINQ.js +0 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-BDrQD8fR.js +0 -1
- package/dist/assets/wardley-L42UT6IY-CTxW4xow.js +0 -173
- package/dist/assets/wardleyDiagram-YWT4CUSO-K8Y1EOvb.js +0 -78
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COMP-CODEX-IMPL — preflight probe for the Codex-as-implementer path.
|
|
3
|
+
*
|
|
4
|
+
* Codex self-applies a Seatbelt sandbox ([[project_codex_seatbelt_nonnesting]]).
|
|
5
|
+
* Compose's `execute` step runs each task agent inside a *detached git worktree*
|
|
6
|
+
* (`git worktree add --detach`), whose `.git` is a file pointer, not a directory.
|
|
7
|
+
* Whether Codex's Seatbelt tolerates writing inside such a worktree is UNVERIFIED —
|
|
8
|
+
* no Codex+worktree path exists in the repo today (Codex only ever ran read-only
|
|
9
|
+
* reviews with cwd at the repo root). If it cannot, a `--codex` build would
|
|
10
|
+
* hard-fail at `execute`. A runtime warning would only explain that after the fact.
|
|
11
|
+
*
|
|
12
|
+
* So before any real `--codex` work, we probe the exact primitive: create a throwaway
|
|
13
|
+
* detached worktree, have Codex write a sentinel file in it, and verify the write
|
|
14
|
+
* landed. On failure the build aborts fast with an actionable message rather than
|
|
15
|
+
* failing mid-execute. The result is cached per-repo (the primitive doesn't change
|
|
16
|
+
* between builds) and can be skipped via COMPOSE_SKIP_CODEX_PROBE for CI/tests.
|
|
17
|
+
*
|
|
18
|
+
* The proper fix for a probe-fails environment (a Codex `isolation: none` execution
|
|
19
|
+
* path) is deferred to COMP-CODEX-IMPL-SPIKE.
|
|
20
|
+
*/
|
|
21
|
+
import { execSync } from 'node:child_process';
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
|
|
26
|
+
export const PROBE_SENTINEL = 'COMPOSE_CODEX_PROBE_OK';
|
|
27
|
+
const WORKTREE_BASE = join(homedir(), '.stratum', 'worktrees');
|
|
28
|
+
const PROBE_CACHE = 'codex-worktree-probe.json';
|
|
29
|
+
// Bound the probe's agent run so a wedged Codex CLI can't hang the whole build
|
|
30
|
+
// before the flow even starts. On timeout we reject → the finally cleanup runs.
|
|
31
|
+
const PROBE_AGENT_TIMEOUT_MS = 180_000;
|
|
32
|
+
|
|
33
|
+
function cachePath(dataDir) {
|
|
34
|
+
return join(dataDir, PROBE_CACHE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readProbeCache(dataDir) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(cachePath(dataDir), 'utf-8'));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function writeProbeCache(dataDir, result) {
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(dataDir, { recursive: true });
|
|
48
|
+
writeFileSync(cachePath(dataDir), JSON.stringify(result, null, 2));
|
|
49
|
+
} catch { /* best-effort cache; a miss just re-probes */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isGitRepo(cwd) {
|
|
53
|
+
try {
|
|
54
|
+
execSync('git rev-parse --is-inside-work-tree', { cwd, encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Verify Codex can write inside a detached git worktree under its sandbox.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} args
|
|
65
|
+
* @param {string} args.cwd repo working directory
|
|
66
|
+
* @param {object} args.stratum stratum client (uses runAgentText('codex', ...))
|
|
67
|
+
* @param {string} args.dataDir .compose/data dir for the per-repo cache
|
|
68
|
+
* @param {string} args.ts caller-supplied timestamp string (unique worktree name)
|
|
69
|
+
* @param {boolean} [args.force] ignore the cache and re-probe
|
|
70
|
+
* @returns {Promise<{ok: boolean, reason: string, cached?: boolean, skipped?: boolean}>}
|
|
71
|
+
*/
|
|
72
|
+
export async function preflightCodexWorktreeProbe({ cwd, stratum, dataDir, ts, force = false }) {
|
|
73
|
+
if (process.env.COMPOSE_SKIP_CODEX_PROBE) {
|
|
74
|
+
return { ok: true, skipped: true, reason: 'COMPOSE_SKIP_CODEX_PROBE set — probe skipped' };
|
|
75
|
+
}
|
|
76
|
+
if (!force) {
|
|
77
|
+
const cached = readProbeCache(dataDir);
|
|
78
|
+
if (cached && cached.ok === true) return { ...cached, cached: true };
|
|
79
|
+
}
|
|
80
|
+
// Not a git repo → the execute step won't create worktrees (useWorktrees gates on
|
|
81
|
+
// isGitRepo), so there is no worktree primitive to probe. Treat as a pass.
|
|
82
|
+
if (!isGitRepo(cwd)) {
|
|
83
|
+
return { ok: true, reason: 'not a git repo — worktree isolation is not used, nothing to probe' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try { mkdirSync(WORKTREE_BASE, { recursive: true }); } catch { /* best-effort */ }
|
|
87
|
+
const wtPath = join(WORKTREE_BASE, `codex-probe-${ts}`);
|
|
88
|
+
|
|
89
|
+
// Create the detached worktree (the exact primitive the execute step uses).
|
|
90
|
+
try {
|
|
91
|
+
execSync(`git worktree add "${wtPath}" --detach HEAD`, {
|
|
92
|
+
cwd, encoding: 'utf-8', timeout: 30_000, stdio: 'pipe',
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
try { execSync(`rm -rf "${wtPath}"`, { encoding: 'utf-8', timeout: 10_000 }); } catch { /* best-effort */ }
|
|
96
|
+
const result = { ok: false, reason: `could not create a probe worktree: ${err?.message ?? err}` };
|
|
97
|
+
writeProbeCache(dataDir, result);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Unique per-run filename so a file committed in the repo (carried into the
|
|
102
|
+
// detached worktree from HEAD) can never make the probe false-pass.
|
|
103
|
+
const probeName = `codex-probe-${ts}.txt`;
|
|
104
|
+
try {
|
|
105
|
+
const prompt =
|
|
106
|
+
`Write a file named ${probeName} in the current working directory whose exact ` +
|
|
107
|
+
`contents are the single line: ${PROBE_SENTINEL}\n` +
|
|
108
|
+
`Do nothing else. This is an environment write-probe.`;
|
|
109
|
+
try {
|
|
110
|
+
// Bound the agent run — a hang here would otherwise stall the build and skip
|
|
111
|
+
// the finally cleanup. On timeout the race rejects and we fall to the catch.
|
|
112
|
+
await Promise.race([
|
|
113
|
+
stratum.runAgentText('codex', prompt, { cwd: wtPath }),
|
|
114
|
+
new Promise((_, reject) =>
|
|
115
|
+
setTimeout(() => reject(new Error(`probe timed out after ${PROBE_AGENT_TIMEOUT_MS}ms`)), PROBE_AGENT_TIMEOUT_MS).unref?.()
|
|
116
|
+
),
|
|
117
|
+
]);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const result = { ok: false, reason: `Codex agent run failed/timed out inside the worktree: ${err?.message ?? err}` };
|
|
120
|
+
writeProbeCache(dataDir, result);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
const probeFile = join(wtPath, probeName);
|
|
124
|
+
let landed = false;
|
|
125
|
+
try {
|
|
126
|
+
landed = existsSync(probeFile) && readFileSync(probeFile, 'utf-8').includes(PROBE_SENTINEL);
|
|
127
|
+
} catch { landed = false; }
|
|
128
|
+
const result = landed
|
|
129
|
+
? { ok: true, reason: 'Codex wrote the sentinel inside a detached worktree' }
|
|
130
|
+
: { ok: false, reason: 'Codex did not write the sentinel file inside the detached worktree (likely a Seatbelt/worktree write restriction)' };
|
|
131
|
+
writeProbeCache(dataDir, result);
|
|
132
|
+
return result;
|
|
133
|
+
} finally {
|
|
134
|
+
try {
|
|
135
|
+
execSync(`git worktree remove "${wtPath}" --force`, { cwd, encoding: 'utf-8', timeout: 30_000, stdio: 'pipe' });
|
|
136
|
+
} catch {
|
|
137
|
+
try { execSync(`rm -rf "${wtPath}"`, { encoding: 'utf-8', timeout: 10_000 }); } catch { /* give up */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The actionable abort message when the probe fails. */
|
|
143
|
+
export function codexProbeAbortMessage(reason) {
|
|
144
|
+
return (
|
|
145
|
+
`--codex aborted: Codex cannot write inside a detached git worktree in this environment.\n` +
|
|
146
|
+
` Reason: ${reason}\n` +
|
|
147
|
+
` This is the known COMP-CODEX-IMPL-SPIKE limitation (Codex Seatbelt vs worktree isolation).\n` +
|
|
148
|
+
` Re-run without --codex (Claude implements), or set COMPOSE_SKIP_CODEX_PROBE=1 to bypass the\n` +
|
|
149
|
+
` probe at your own risk. A Codex isolation:none execution path is tracked as the follow-up.`
|
|
150
|
+
);
|
|
151
|
+
}
|
package/lib/completion-writer.js
CHANGED
|
@@ -209,6 +209,18 @@ function validate(args) {
|
|
|
209
209
|
throw inputError('completion-writer: set_status must be a boolean');
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
// built_via — OPTIONAL marker recording the pipeline template a feature was
|
|
213
|
+
// built with (e.g. 'build-quick'). Persisted on feature.json so downstream
|
|
214
|
+
// consumers (e.g. the validator's report-completion check) can tell which
|
|
215
|
+
// lifecycle produced the feature. Must be a lowercase template slug.
|
|
216
|
+
if (args.built_via !== undefined && args.built_via !== null) {
|
|
217
|
+
if (typeof args.built_via !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(args.built_via)) {
|
|
218
|
+
throw inputError(
|
|
219
|
+
`completion-writer: built_via must be a lowercase template slug (e.g. "build-quick"), got "${args.built_via}"`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
212
224
|
// force — boolean if present
|
|
213
225
|
if (args.force !== undefined && typeof args.force !== 'boolean') {
|
|
214
226
|
throw inputError('completion-writer: force must be a boolean');
|
|
@@ -264,6 +276,8 @@ async function safeAppendEvent(cwd, event) {
|
|
|
264
276
|
* @param {string} [args.notes]
|
|
265
277
|
* @param {boolean} [args.set_status] Default true — flip status to COMPLETE
|
|
266
278
|
* @param {boolean} [args.force] Replace existing same-(code,sha) record
|
|
279
|
+
* @param {string} [args.built_via] Optional template slug (e.g. 'build-quick')
|
|
280
|
+
* stamped onto feature.json (COMP-BUILD-QUICK-1)
|
|
267
281
|
* @param {string} [args.idempotency_key]
|
|
268
282
|
*
|
|
269
283
|
* @returns {{ feature_code, completion_id, commit_sha, commit_sha_short,
|
|
@@ -344,7 +358,13 @@ export async function recordCompletion(cwd, args) {
|
|
|
344
358
|
// 5h. Persist completion record BEFORE status flip (so flip failure doesn't lose the record)
|
|
345
359
|
// Use persistFeatureRaw (policy-free, no status-delta guard) — completion-writer
|
|
346
360
|
// owns its own ordering/locking and only mutates the completions array, not status.
|
|
347
|
-
await provider.persistFeatureRaw(feature_code, {
|
|
361
|
+
await provider.persistFeatureRaw(feature_code, {
|
|
362
|
+
...feature,
|
|
363
|
+
completions,
|
|
364
|
+
// COMP-BUILD-QUICK-1: stamp the build template (e.g. 'build-quick') so the
|
|
365
|
+
// validator can exempt quick-built features from MISSING_COMPLETION_REPORT.
|
|
366
|
+
...(args.built_via ? { built_via: args.built_via } : {}),
|
|
367
|
+
});
|
|
348
368
|
|
|
349
369
|
// 5i. Status flip (default on)
|
|
350
370
|
const set_status = args.set_status !== false;
|
package/lib/feature-validator.js
CHANGED
|
@@ -573,7 +573,11 @@ function runArtifactLinkChecks(fctx, ctx, findings) {
|
|
|
573
573
|
// PLANNED / SUPERSEDED / KILLED / PARKED — no finding (expected baseline)
|
|
574
574
|
}
|
|
575
575
|
// MISSING_COMPLETION_REPORT (warning, COMPLETE)
|
|
576
|
-
|
|
576
|
+
// COMP-BUILD-QUICK-1: the quick lifecycle (compose build --quick) omits the
|
|
577
|
+
// report phase by design and stamps built_via:'build-quick' on feature.json at
|
|
578
|
+
// ship. Exempt those features — a missing report.md is expected, not debt.
|
|
579
|
+
if (status === 'COMPLETE' && !folder.files.has('report.md') &&
|
|
580
|
+
featureJson?.built_via !== 'build-quick') {
|
|
577
581
|
findings.push(finding('warning', 'MISSING_COMPLETION_REPORT', code,
|
|
578
582
|
`Feature is COMPLETE but report.md is missing (current writers do not enforce this; warning only)`));
|
|
579
583
|
}
|
|
@@ -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
|
+
*/
|
|
@@ -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
|
+
}
|
|
@@ -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/roadmap-parser.js
CHANGED
|
@@ -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
|