@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.
Files changed (118) 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 +310 -52
  5. package/dist/assets/App-CT0vXPgd.js +724 -0
  6. package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
  7. package/dist/assets/{arc-DeHak63Z.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-BsFJmPuS.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
  12. package/dist/assets/channel-Dd4XaiYv.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-XO4S2_89.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
  14. package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
  15. package/dist/assets/{chunk-55IACEB6-CtFSLInj.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
  16. package/dist/assets/{chunk-2J33WTMH-AEu6HIoY.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-BMGd_Y9a.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
  18. package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
  19. package/dist/assets/{chunk-QZHKN3VN-DHhUJ8RK.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
  20. package/dist/assets/{chunk-ND2GUHAM-BlrOECEr.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-C3M0mZqk.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-AMDitV0b.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
  31. package/dist/assets/{flowDiagram-I6XJVG4X-C0t2ZGQv.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-CCBwgjFs.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
  40. package/dist/assets/{journeyDiagram-JHISSGLW-zUJN38EU.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
  41. package/dist/assets/{kanban-definition-UN3LZRKU-BNxWi-8J.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-CRH9b4g7.js → linear-D158F7OT.js} +1 -1
  45. package/dist/assets/min-hESxN-c0.js +1 -0
  46. package/dist/assets/{mindmap-definition-RKZ34NQL-DolYwq7Y.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-DsXZ05jo.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-Bvnhw58e.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
  57. package/dist/assets/{vennDiagram-CIIHVFJN-5WnyjZqf.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-BCShes6B.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
  61. package/dist/index.html +4 -4
  62. package/lib/build.js +272 -33
  63. package/lib/codex-preflight.js +151 -0
  64. package/lib/completion-writer.js +21 -1
  65. package/lib/feature-validator.js +5 -1
  66. package/lib/hooks-status.js +135 -0
  67. package/lib/install-agent-defs.js +22 -0
  68. package/lib/migrate-anon.js +277 -0
  69. package/lib/roadmap-parser.js +1 -1
  70. package/lib/test-bootstrap.js +165 -2
  71. package/lib/vocabulary-inject.js +103 -0
  72. package/package.json +2 -1
  73. package/pipelines/build-quick.stratum.yaml +433 -0
  74. package/pipelines/build.stratum.yaml +60 -3
  75. package/scripts/watch-server.sh +32 -0
  76. package/server/build-routes.js +89 -9
  77. package/server/build-stream-bridge.js +12 -0
  78. package/server/compose-mcp-tools.js +73 -1
  79. package/server/compose-mcp.js +27 -0
  80. package/server/feature-scaffold-routes.js +82 -0
  81. package/server/health-routes.js +261 -0
  82. package/server/qa-scope-routes.js +96 -0
  83. package/server/validate-routes.js +70 -0
  84. package/server/vision-routes.js +29 -0
  85. package/server/vision-server.js +21 -0
  86. package/dist/assets/App-Bxyif1yv.js +0 -706
  87. package/dist/assets/architectureDiagram-3BPJPVTR-C5Nkyl9y.js +0 -36
  88. package/dist/assets/blockDiagram-GPEHLZMM-BilnUBG2.js +0 -132
  89. package/dist/assets/channel-ZgbQ1k0u.js +0 -1
  90. package/dist/assets/chunk-727SXJPM-CZeBRpM9.js +0 -206
  91. package/dist/assets/chunk-AQP2D5EJ-_vmxcBc4.js +0 -231
  92. package/dist/assets/classDiagram-4FO5ZUOK-KXxOorUx.js +0 -1
  93. package/dist/assets/classDiagram-v2-Q7XG4LA2-KXxOorUx.js +0 -1
  94. package/dist/assets/dagre-BM42HDAG-DJlm4fEk.js +0 -4
  95. package/dist/assets/diagram-2AECGRRQ-BmcONQG5.js +0 -43
  96. package/dist/assets/diagram-5GNKFQAL-5JbWGIwd.js +0 -10
  97. package/dist/assets/diagram-KO2AKTUF-DAnIvyI0.js +0 -3
  98. package/dist/assets/diagram-LMA3HP47-Bmh7tvmI.js +0 -24
  99. package/dist/assets/diagram-OG6HWLK6-hnHVMNnk.js +0 -24
  100. package/dist/assets/ganttDiagram-6RSMTGT7-BPlM-BEN.js +0 -292
  101. package/dist/assets/gitGraphDiagram-PVQCEYII-Lv_YTxnb.js +0 -106
  102. package/dist/assets/graph-BBXaecIU.js +0 -331
  103. package/dist/assets/graph-CAnANduQ.js +0 -1
  104. package/dist/assets/index-BwLfbbOu.css +0 -1
  105. package/dist/assets/index-COq21Zym.js +0 -119
  106. package/dist/assets/infoDiagram-5YYISTIA-Dul1vdUm.js +0 -2
  107. package/dist/assets/katex-C5jXJg4s.js +0 -257
  108. package/dist/assets/layout-DGIYPm2g.js +0 -1
  109. package/dist/assets/mobile-Cag5dHlF.css +0 -1
  110. package/dist/assets/mobile-DwmxS_O4.js +0 -17
  111. package/dist/assets/pieDiagram-4H26LBE5-oxNfr2TX.js +0 -30
  112. package/dist/assets/quadrantDiagram-W4KKPZXB-BmZCvD-z.js +0 -7
  113. package/dist/assets/sankeyDiagram-5OEKKPKP-DsnYaawP.js +0 -40
  114. package/dist/assets/sequenceDiagram-3UESZ5HK-Dt5mu3g8.js +0 -162
  115. package/dist/assets/stateDiagram-AJRCARHV-BFR5ZINQ.js +0 -1
  116. package/dist/assets/stateDiagram-v2-BHNVJYJU-BDrQD8fR.js +0 -1
  117. package/dist/assets/wardley-L42UT6IY-CTxW4xow.js +0 -173
  118. 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
+ }
@@ -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, { ...feature, completions });
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;
@@ -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
- if (status === 'COMPLETE' && !folder.files.has('report.md')) {
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
+ }
@@ -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