@smartmemory/compose 0.3.7 → 0.4.0

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 (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
@@ -0,0 +1,254 @@
1
+ import { execFile as defaultExecFile } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { access, lstat as defaultLstat, mkdtemp as defaultMkdtemp, readFile as defaultReadFile, rm as defaultRm, stat as defaultStat, writeFile as defaultWriteFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import {
10
+ CUSTODY_TIMEOUT_MS, custodyBackend as defaultCustodyBackend, custodySign as defaultCustodySign,
11
+ GUARD_DIR, HINT_ENROL, PRIVATE_DIR, PUBKEY_PATH, SIGNER_PATH, SUDO_ENV,
12
+ SUDOERS_PATH, SUDO_LOCAL_PATH,
13
+ } from './guard-custody.js';
14
+ import { guardDescriptors as defaultGuardDescriptors } from '../server/stratum-client.js';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const sshsig = await import(require.resolve('@smartmemory/stratum/dist/guard/sshsig.js'));
18
+ const { parseAllowedSigners, sshFingerprint, verifySshsig } = sshsig;
19
+ const DESCRIPTOR_NAMESPACE = 'stratum-guard-descriptors';
20
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
21
+ const PACKAGED_SIGNER = path.resolve(MODULE_DIR, '..', 'scripts', 'guard-sign', 'compose-guard-sign.sh');
22
+
23
+ export const PRINCIPAL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
24
+ export const SUDOERS_TEXT = (principal) =>
25
+ `Defaults!${SIGNER_PATH} timestamp_timeout=0\n${principal} ALL=(root) ${SIGNER_PATH}\n`;
26
+
27
+ function spawnWithInput(execFile, file, args, options, bytes) {
28
+ return new Promise((resolve) => {
29
+ let settled = false;
30
+ const done = (error, stdout = '', stderr = '') => {
31
+ if (settled) return;
32
+ settled = true;
33
+ resolve({ error, stdout, stderr });
34
+ };
35
+ try {
36
+ const child = execFile(file, args, options, done);
37
+ child?.stdin?.end(bytes);
38
+ child?.on?.('error', (error) => done(error));
39
+ } catch (error) {
40
+ done(error);
41
+ }
42
+ });
43
+ }
44
+
45
+ function firstLine(value, fallback = 'command failed') {
46
+ return String(value || '').split(/\r?\n/, 1)[0] || fallback;
47
+ }
48
+
49
+ /** Check every existing ancestor without following a symlink at any level. */
50
+ export async function validateAncestry(target, deps = {}) {
51
+ const lstat = deps.lstat ?? defaultLstat;
52
+ const stat = deps.stat ?? defaultStat;
53
+ let current = path.resolve(target);
54
+ for (;;) {
55
+ let lst;
56
+ try {
57
+ lst = await lstat(current);
58
+ } catch (error) {
59
+ if (error?.code === 'ENOENT') {
60
+ const parent = path.dirname(current);
61
+ if (parent === current) return { ok: false, offending: { path: current, reason: 'missing' } };
62
+ current = parent;
63
+ continue;
64
+ }
65
+ return { ok: false, offending: { path: current, reason: firstLine(error?.message) } };
66
+ }
67
+ if (lst.isSymbolicLink()) return { ok: false, offending: { path: current, reason: 'symlink' } };
68
+ let info;
69
+ try { info = await stat(current); } catch (error) {
70
+ return { ok: false, offending: { path: current, reason: firstLine(error?.message) } };
71
+ }
72
+ if (info.uid !== 0) return { ok: false, offending: { path: current, reason: 'not root-owned' } };
73
+ if ((info.mode & 0o022) !== 0) return { ok: false, offending: { path: current, reason: 'group or world writable' } };
74
+ const parent = path.dirname(current);
75
+ if (parent === current) return { ok: true, offending: null };
76
+ current = parent;
77
+ }
78
+ }
79
+
80
+ /** Resolve the package's reviewable source trust root, never an environment path. */
81
+ export async function locateTrustRoot(deps = {}) {
82
+ const packageJson = deps.packageJsonPath ?? require.resolve('@smartmemory/stratum/package.json');
83
+ const packageRoot = deps.packageRoot ?? path.dirname(packageJson);
84
+ const exists = deps.exists ?? (async (file) => access(file).then(() => true, () => false));
85
+ const source = path.join(packageRoot, 'contracts', 'guard-signers.allowed');
86
+ if (await exists(source)) return { kind: 'source', path: source, checkoutRoot: packageRoot };
87
+ const dist = path.join(packageRoot, 'dist', 'contracts', 'guard-signers.allowed');
88
+ if (await exists(dist)) return { kind: 'dist', path: dist, checkoutRoot: null };
89
+ throw new Error(`could not locate Stratum guard trust root under ${packageRoot}`);
90
+ }
91
+
92
+ /** Produce a whole-file parsed trust-root candidate, never a partial append. */
93
+ export function candidateTrustRoot(current, principal, publicKeyLine) {
94
+ if (!PRINCIPAL_RE.test(principal)) throw new Error(`invalid principal ${JSON.stringify(principal)}; expected ${PRINCIPAL_RE}`);
95
+ if (typeof publicKeyLine !== 'string' || /[^\x20-\x7e]/.test(publicKeyLine.trim())) {
96
+ throw new Error('public key line must be printable ASCII');
97
+ }
98
+ const key = parseAllowedSigners(`candidate ${publicKeyLine.trim()}\n`)[0];
99
+ if (!key) throw new Error('public key line did not contain a signer');
100
+ const signers = parseAllowedSigners(current);
101
+ const present = signers.some((entry) => entry.fingerprint === key.fingerprint);
102
+ const line = `${principal} ${publicKeyLine.trim()}`;
103
+ const text = present ? current : `${current}${current && !current.endsWith('\n') ? '\n' : ''}${line}\n`;
104
+ const parsed = parseAllowedSigners(text);
105
+ return { present, text, candidate: text, line, fingerprint: key.fingerprint, signers: parsed };
106
+ }
107
+
108
+ export function installPlan(principal) {
109
+ if (!PRINCIPAL_RE.test(principal)) throw new Error(`invalid principal ${JSON.stringify(principal)}; expected ${PRINCIPAL_RE}`);
110
+ return [
111
+ `install root-owned signer: ${SIGNER_PATH}`,
112
+ `create root-owned signing key: ${PRIVATE_DIR}/signing-key`,
113
+ `publish public key: ${PUBKEY_PATH}`,
114
+ `install sudoers rule: ${SUDOERS_PATH}`,
115
+ `enable Touch ID for sudo: ${SUDO_LOCAL_PATH}`,
116
+ `add signer principal to Stratum trust root: ${principal}`,
117
+ `root installation script:\n${installScript(principal)}`,
118
+ ];
119
+ }
120
+
121
+ const SIGNER_HEREDOC_DELIMITER = 'COMPOSE_GUARD_SIGNER_SCRIPT';
122
+
123
+ /**
124
+ * Exact root-side installation payload. It is intentionally shell-only and
125
+ * idempotent.
126
+ *
127
+ * Every `${…}` interpolated into this template is either a constant path
128
+ * exported by guard-custody.js (GUARD_DIR, PRIVATE_DIR, SIGNER_PATH,
129
+ * PUBKEY_PATH, SUDOERS_PATH, SUDO_LOCAL_PATH — never a runtime/user-supplied
130
+ * string) or shell text already embedded via a quoted heredoc (`sudoers`,
131
+ * `signerScript`) rather than interpolated inline. The packaged signer's
132
+ * module-directory path is never interpolated into the script at all: its
133
+ * bytes are read here and written to SIGNER_PATH via a quoted heredoc, so a
134
+ * compose install path containing a space or shell metacharacter cannot break
135
+ * or inject into the root shell.
136
+ */
137
+ export function installScript(principal, deps = {}) {
138
+ if (!PRINCIPAL_RE.test(principal)) throw new Error(`invalid principal ${JSON.stringify(principal)}; expected ${PRINCIPAL_RE}`);
139
+ const sudoers = SUDOERS_TEXT(principal).trimEnd();
140
+ const readSigner = deps.readSigner ?? (() => readFileSync(PACKAGED_SIGNER, 'utf8'));
141
+ const signerScript = readSigner();
142
+ if (signerScript.includes(SIGNER_HEREDOC_DELIMITER)) {
143
+ throw new Error(`packaged signer script contains the heredoc delimiter ${SIGNER_HEREDOC_DELIMITER}; choose a different delimiter`);
144
+ }
145
+ return `set -eu\numask 022\nPATH=/usr/bin:/bin:/usr/sbin\nexport PATH\n/bin/mkdir -p /Library/Compose/guard/private/tmp\n/bin/chmod 0755 /Library/Compose ${GUARD_DIR}\n/bin/chmod 0700 ${PRIVATE_DIR} ${PRIVATE_DIR}/tmp\n/bin/cat > ${SIGNER_PATH} <<'${SIGNER_HEREDOC_DELIMITER}'\n${signerScript.trimEnd()}\n${SIGNER_HEREDOC_DELIMITER}\n/usr/sbin/chown root:wheel ${SIGNER_PATH}\n/bin/chmod 0755 ${SIGNER_PATH}\nif [ ! -f ${PRIVATE_DIR}/signing-key ]; then\n /usr/bin/ssh-keygen -t ed25519 -N '' -q -f ${PRIVATE_DIR}/signing-key -C compose-guard\nfi\n/usr/sbin/chown root:wheel ${PRIVATE_DIR}/signing-key ${PRIVATE_DIR}/signing-key.pub\n/bin/chmod 0600 ${PRIVATE_DIR}/signing-key\n/bin/cp ${PRIVATE_DIR}/signing-key.pub ${PUBKEY_PATH}\n/usr/sbin/chown root:wheel ${PUBKEY_PATH}\n/bin/chmod 0644 ${PUBKEY_PATH}\n_sudoers_tmp=${SUDOERS_PATH}.tmp.$$\n/bin/cat > "$_sudoers_tmp" <<'COMPOSE_GUARD_SUDOERS'\n${sudoers}\nCOMPOSE_GUARD_SUDOERS\n/usr/sbin/visudo -cf "$_sudoers_tmp"\n/usr/bin/install -o root -g wheel -m 0440 "$_sudoers_tmp" ${SUDOERS_PATH}\n/bin/rm -f "$_sudoers_tmp"\nif [ ! -f ${SUDO_LOCAL_PATH} ]; then\n /bin/cat > ${SUDO_LOCAL_PATH} <<'COMPOSE_GUARD_PAM'\nauth sufficient pam_tid.so\nCOMPOSE_GUARD_PAM\nelif ! /usr/bin/grep -Eq '^[[:space:]]*auth[[:space:]]+sufficient[[:space:]]+pam_tid\\.so' ${SUDO_LOCAL_PATH}; then\n /bin/cat >> ${SUDO_LOCAL_PATH} <<'COMPOSE_GUARD_PAM'\nauth sufficient pam_tid.so\nCOMPOSE_GUARD_PAM\nfi\n/usr/sbin/chown root:wheel ${SUDO_LOCAL_PATH}\n/bin/chmod 0644 ${SUDO_LOCAL_PATH}\necho 'ok directories'\necho 'ok signer'\necho 'ok key'\necho 'ok sudoers'\necho 'ok pam_tid'\n`;
146
+ }
147
+
148
+ /** Sign and cryptographically verify a durable fixture against exactly one public key. */
149
+ export async function roundTrip({ custodySign, publicKeyLine, deps = {} }) {
150
+ const mkdtemp = deps.mkdtemp ?? defaultMkdtemp;
151
+ const writeFile = deps.writeFile ?? defaultWriteFile;
152
+ const readFile = deps.readFile ?? defaultReadFile;
153
+ const rm = deps.rm ?? defaultRm;
154
+ const dir = await mkdtemp(path.join(tmpdir(), 'compose-guard-enrol-'));
155
+ const file = path.join(dir, 'descriptors.json');
156
+ const bytes = Buffer.from('{"version":1,"descriptors":[]}\n');
157
+ try {
158
+ await writeFile(file, bytes, { mode: 0o600 });
159
+ const signed = await custodySign({ bytes, namespace: DESCRIPTOR_NAMESPACE });
160
+ if (!signed?.ok) throw new Error(signed?.message || 'fixture signature was not approved');
161
+ await writeFile(`${file}.sig`, signed.armored, { mode: 0o600 });
162
+ const signer = parseAllowedSigners(`fixture ${publicKeyLine.trim()}\n`)[0];
163
+ if (!signer) throw new Error('public key line did not contain a signer');
164
+ verifySshsig(await readFile(file), await readFile(`${file}.sig`, 'utf8'), DESCRIPTOR_NAMESPACE, [signer.publicKey]);
165
+ return { dir, file, sig: `${file}.sig`, namespace: DESCRIPTOR_NAMESPACE, fingerprint: signer.fingerprint, cleanup: () => rm(dir, { recursive: true, force: true }) };
166
+ } catch (error) {
167
+ await rm(dir, { recursive: true, force: true });
168
+ throw error;
169
+ }
170
+ }
171
+
172
+ export async function rebuildStratumDist(checkoutRoot, deps = {}) {
173
+ const { error, stderr } = await spawnWithInput(deps.execFile ?? defaultExecFile, 'npm', ['run', 'build'], { cwd: checkoutRoot, timeout: CUSTODY_TIMEOUT_MS }, Buffer.alloc(0));
174
+ if (error) throw new Error(`Stratum build failed: ${firstLine(stderr || error.message)}`);
175
+ }
176
+
177
+ function result(status, lines, extra = {}) { return { status, lines, ...extra }; }
178
+
179
+ /** Complete, deliberately ordered enrolment ceremony. Every side effect is injectable. */
180
+ export async function runEnrol({ principal = process.env.USER || process.env.USERNAME || '' } = {}, deps = {}) {
181
+ // The plan must always reach a human before the root step runs — a silent
182
+ // no-op emitter would let any in-process caller (including a bare
183
+ // `runGuardEnrol()`) reach `sudo -k /bin/sh -s` with the plan never shown.
184
+ // So the emitter is mandatory, checked before the backend check, before any
185
+ // spawn or filesystem access.
186
+ if (typeof deps.emit !== 'function') {
187
+ return result('refused', ['enrol requires a line emitter so the plan is shown before the root step'], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
188
+ }
189
+ const backend = (deps.custodyBackend ?? defaultCustodyBackend)();
190
+ if (backend !== 'sudo') return result('refused', ['enrol requires the sudo custody backend on macOS'], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
191
+ if (!PRINCIPAL_RE.test(principal)) return result('refused', [`invalid principal ${JSON.stringify(principal)}; expected ${PRINCIPAL_RE}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
192
+
193
+ const ancestry = deps.validateAncestry ?? validateAncestry;
194
+ const installTargets = [GUARD_DIR, SIGNER_PATH, PUBKEY_PATH, PRIVATE_DIR, SUDOERS_PATH, SUDO_LOCAL_PATH];
195
+ for (const target of installTargets) {
196
+ const checked = await ancestry(target, deps);
197
+ if (!checked.ok) return result('refused', [`unsafe install ancestry at ${checked.offending.path}: ${checked.offending.reason}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
198
+ }
199
+ // `installScript` now reads the packaged signer from disk and can throw (a
200
+ // missing/unreadable signer, or a heredoc-delimiter collision); `emit` is
201
+ // caller-supplied and can throw too. Neither has spawned or written
202
+ // anything yet, so a failure here must still return the refused envelope,
203
+ // not reject the promise.
204
+ let script;
205
+ try {
206
+ script = installScript(principal, deps);
207
+ const emit = deps.emit;
208
+ for (const line of installPlan(principal)) emit(line);
209
+ emit('root installation follows:');
210
+ } catch (error) {
211
+ return result('refused', [`enrol preparation failed: ${firstLine(error.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
212
+ }
213
+ const lines = [];
214
+ const rootStep = await spawnWithInput(deps.execFile ?? defaultExecFile, '/usr/bin/sudo', ['-k', '/bin/sh', '-s'], { env: SUDO_ENV(), timeout: CUSTODY_TIMEOUT_MS, maxBuffer: 1 << 20 }, script);
215
+ if (rootStep.error) return result('refused', [...lines, `root installation failed: ${firstLine(rootStep.stderr || rootStep.error.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
216
+ lines.push(...String(rootStep.stdout || '').split(/\r?\n/).filter((line) => line.startsWith('ok ')));
217
+
218
+ for (const target of installTargets) {
219
+ const checked = await ancestry(target, deps);
220
+ if (!checked.ok) return result('refused', [...lines, `unsafe installed ancestry at ${checked.offending.path}: ${checked.offending.reason}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
221
+ }
222
+ const readFile = deps.readFile ?? defaultReadFile;
223
+ let publicKeyLine;
224
+ try { publicKeyLine = (await readFile(PUBKEY_PATH, 'utf8')).trim(); } catch (error) {
225
+ return result('refused', [...lines, `could not read enrolled public key: ${firstLine(error.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
226
+ }
227
+
228
+ let fixture;
229
+ try {
230
+ fixture = await roundTrip({ custodySign: deps.custodySign ?? defaultCustodySign, publicKeyLine, deps });
231
+ } catch (error) {
232
+ return result('refused', [...lines, `round-trip signature failed: ${firstLine(error.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
233
+ }
234
+ try {
235
+ const trust = await (deps.locateTrustRoot ?? locateTrustRoot)(deps);
236
+ const current = await readFile(trust.path, 'utf8');
237
+ const candidate = candidateTrustRoot(current, principal, publicKeyLine);
238
+ if (trust.kind !== 'source') {
239
+ return result('refused', [...lines, `add this line to ${trust.path} in a Stratum source release:`, candidate.line], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
240
+ }
241
+ if (!candidate.present) await (deps.writeFile ?? defaultWriteFile)(trust.path, candidate.text, 'utf8');
242
+ await (deps.rebuildStratumDist ?? rebuildStratumDist)(trust.checkoutRoot, deps);
243
+ const inspected = await (deps.guardDescriptors ?? defaultGuardDescriptors)(fixture.file);
244
+ const expected = `verified: signed by ${principal} (${fixture.fingerprint})`;
245
+ if (inspected?.status !== 'ok' || !String(inspected?.signature || '').startsWith(expected) || inspected?.group_or_world_writable !== false) {
246
+ return result('refused', [...lines, `final Stratum verification failed: ${inspected?.signature || 'no verifier verdict'}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
247
+ }
248
+ return result('done', [...lines, `enrolled ${principal} (${fixture.fingerprint})`, 'done'], { fingerprint: fixture.fingerprint });
249
+ } catch (error) {
250
+ return result('refused', [...lines, `enrolment failed: ${firstLine(error?.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
251
+ } finally {
252
+ await fixture.cleanup();
253
+ }
254
+ }
@@ -6,7 +6,7 @@
6
6
  * - Review findings from parallel_review's ReviewResult (canonical — STRAT-CLAUDE-EFFORT-PARITY)
7
7
  * - Test pass/fail from coverage_check result
8
8
  * - Contract compliance from Stratum ensure results
9
- * - Doc freshness from lib/staleness.js
9
+ * - Doc freshness from lib/lineage.js (findStaleArtifacts)
10
10
  * - Plan completion from plan_completion ensure builtin
11
11
  * - Runtime errors from build_step_done violations
12
12
  */
@@ -0,0 +1,315 @@
1
+ /**
2
+ * lib/ideabox-cli.js — `compose ideabox …`, writing to the record store.
3
+ *
4
+ * COMP-PLAN-IDEA-UNIFY S3b-1 (D18), rebased onto the shared operations in S3b-2
5
+ * (D20).
6
+ *
7
+ * Lifted out of a ~300-line inline `else if` block in `bin/compose.js` with no
8
+ * named symbol, in a 3,400-line file. The move is not cosmetic: it is what lets
9
+ * the cutover be driven in-process by a test instead of only through a
10
+ * subprocess, and the cutover is the part that needed testing most.
11
+ *
12
+ * WHAT THIS MODULE IS NOW
13
+ * -----------------------
14
+ * Argument parsing, presentation, and exit codes. Nothing else. Every mutation
15
+ * lives in `fluid/ideabox-ops.js`, which the REST API calls too — including the
16
+ * two invariants that used to be spelled out here and would have had to be
17
+ * re-remembered by every new surface:
18
+ *
19
+ * 1. the migration gate runs before any write, and
20
+ * 2. the projection is rewritten only after the record is durable.
21
+ *
22
+ * A surface that does not implement an invariant cannot forget it. That is the
23
+ * whole reason the ops module exists; see its header for the failure it prevents.
24
+ *
25
+ * Behaviour is preserved deliberately, including output wording, exit codes and
26
+ * flag parsing, because a cutover that also redesigns the commands cannot be
27
+ * reviewed: any difference becomes ambiguous between "intended" and
28
+ * "regression". The exceptions are documented — `normalizeTags` in the ops
29
+ * module, the killed-idea promote refusal (D22), and the new `resurrect`
30
+ * subcommand (D23).
31
+ */
32
+
33
+ import { existsSync } from 'node:fs';
34
+
35
+ import {
36
+ ideaboxContext,
37
+ addIdea,
38
+ addDiscussion,
39
+ killIdea,
40
+ promoteIdea,
41
+ resurrectIdea,
42
+ setPriority,
43
+ updateIdea,
44
+ IdeaboxConflict,
45
+ IdeaboxInvalid,
46
+ IdeaboxNotFound,
47
+ } from './fluid/ideabox-ops.js';
48
+ import { toMarkdownDate } from './fluid/ideabox-dates.js';
49
+ import { KIND } from './fluid/provider.js';
50
+ import { ensureIdeaboxMigrated } from './fluid/ideabox-migrate.js';
51
+ import { writeIdeaboxProjection } from './fluid/render-ideabox.js';
52
+
53
+ const USAGE = [
54
+ 'Usage: compose ideabox <subcommand>',
55
+ '',
56
+ 'Subcommands:',
57
+ ' add "<title>" Add a new idea',
58
+ ' list List all ideas',
59
+ ' promote <ID> Mark idea as PROMOTED (creates feature folder)',
60
+ ' kill <ID> "<reason>" Move idea to Killed Ideas',
61
+ ' resurrect <ID> Return a killed idea to the live set',
62
+ ' pri <ID> <P0|P1|P2> Set priority',
63
+ ' discuss <ID> "<comment>" Add a discussion comment',
64
+ ' triage [--lens <name>] Walk untriaged ideas and assign priorities',
65
+ ' render Rewrite the ideabox file from the records',
66
+ ];
67
+
68
+ const PRIORITIES = ['P0', 'P1', 'P2'];
69
+ const flagValue = (args, name) => {
70
+ const i = args.indexOf(name);
71
+ return i !== -1 ? args[i + 1] : undefined;
72
+ };
73
+
74
+ /** Status enum → the legacy display label the list output has always used. */
75
+ const displayStatus = (record) => {
76
+ if (record.status === 'promoted') return 'PROMOTED';
77
+ if (record.status === 'discussing') return 'DISCUSSING';
78
+ return 'NEW';
79
+ };
80
+
81
+ const displayPriority = (record) => record.priority ?? '—';
82
+
83
+ /**
84
+ * Turn an op failure into the terminal's contract: a message on stderr and a
85
+ * non-zero exit. Only the ops' own typed failures are handled — anything else
86
+ * is a bug or a broken store, and swallowing those into a tidy exit code is how
87
+ * a corrupt project looks healthy.
88
+ */
89
+ function reportOpFailure(err) {
90
+ if (err instanceof IdeaboxNotFound || err instanceof IdeaboxInvalid || err instanceof IdeaboxConflict) {
91
+ console.error(err.message);
92
+ return 1;
93
+ }
94
+ throw err;
95
+ }
96
+
97
+ /**
98
+ * @param {string} cwd project root
99
+ * @param {string[]} args argv after `ideabox`
100
+ * @param {object} [opts]
101
+ * @param {object} [opts.config] already-loaded `.compose/compose.json`
102
+ * @returns {Promise<number>} process exit code
103
+ */
104
+ export async function runIdeaboxCommand(cwd, args, opts = {}) {
105
+ const sub = args[0];
106
+ if (!sub || sub === '--help' || sub === '-h') {
107
+ for (const line of USAGE) console.log(line);
108
+ return 0;
109
+ }
110
+
111
+ const ctx = await ideaboxContext(cwd, { config: opts.config, origin: 'cli:ideabox' });
112
+ const { provider, ideaboxPath } = ctx;
113
+
114
+ try {
115
+ switch (sub) {
116
+ case 'add': {
117
+ const title = args.slice(1).find((a) => !a.startsWith('-')) || args[1];
118
+ if (!title) {
119
+ console.error('Usage: compose ideabox add "<title>" [--source "..."] [--desc "..."] [--cluster "..."]');
120
+ return 1;
121
+ }
122
+ const { record, createdCluster } = await addIdea(ctx, {
123
+ title,
124
+ body: flagValue(args, '--desc') ?? '',
125
+ source: flagValue(args, '--source') ?? '',
126
+ tags: flagValue(args, '--tags') ?? [],
127
+ cluster: flagValue(args, '--cluster') ?? null,
128
+ });
129
+ if (createdCluster) console.log(`Created cluster ${createdCluster.handle}: ${createdCluster.title}`);
130
+ console.log(`Added ${record.handle}: ${record.title}`);
131
+ return 0;
132
+ }
133
+
134
+ case 'list': {
135
+ // Read-only paths still migrate: `list` on an upgraded project must show
136
+ // the user's real ideas, not an empty store. It is the same import
137
+ // either way, and running it lazily on first read rather than first
138
+ // write means the upgrade happens at the least destructive moment
139
+ // available.
140
+ if (!existsSync(ideaboxPath) && !(await provider.listRecords({ kind: KIND.IDEA })).length) {
141
+ console.log('No ideabox found. Run: compose ideabox add "<title>"');
142
+ return 0;
143
+ }
144
+ await ensureIdeaboxMigrated(provider, ideaboxPath);
145
+ const ideas = await provider.listRecords({ kind: KIND.IDEA });
146
+ if (ideas.length === 0) {
147
+ console.log('No ideas yet.');
148
+ return 0;
149
+ }
150
+
151
+ const live = ideas.filter((i) => i.status !== 'killed');
152
+ const killed = ideas.filter((i) => i.status === 'killed');
153
+ const order = { P0: 0, P1: 1, P2: 2, '—': 3 };
154
+
155
+ for (const status of ['NEW', 'DISCUSSING', 'PROMOTED']) {
156
+ const group = live.filter((i) => displayStatus(i) === status);
157
+ if (!group.length) continue;
158
+ group.sort((a, b) => (order[displayPriority(a)] ?? 3) - (order[displayPriority(b)] ?? 3));
159
+ console.log(`\n[${status}]`);
160
+ for (const idea of group) {
161
+ const pri = displayPriority(idea) !== '—' ? ` [${displayPriority(idea)}]` : '';
162
+ const tags = idea.tags.length ? ` ${idea.tags.join(' ')}` : '';
163
+ console.log(` ${idea.handle}${pri} ${idea.title}${tags}`);
164
+ }
165
+ }
166
+
167
+ if (killed.length) {
168
+ console.log(`\n[KILLED] (${killed.length})`);
169
+ for (const idea of killed) {
170
+ console.log(` ${idea.handle} ${idea.title} — ${idea.killed?.reason ?? ''}`);
171
+ }
172
+ }
173
+ return 0;
174
+ }
175
+
176
+ case 'promote': {
177
+ const id = args[1];
178
+ if (!id) {
179
+ console.error('Usage: compose ideabox promote <ID> [<FEATURE-CODE>]');
180
+ return 1;
181
+ }
182
+ const { record, featureCode, featurePath, createdFeature } =
183
+ await promoteIdea(ctx, id, args[2] || '');
184
+ if (createdFeature) console.log(`Created feature folder: ${featurePath}/`);
185
+ console.log(`Promoted ${record.handle} → ${featureCode}`);
186
+ return 0;
187
+ }
188
+
189
+ case 'kill': {
190
+ const id = args[1];
191
+ if (!id) {
192
+ console.error('Usage: compose ideabox kill <ID> "<reason>"');
193
+ return 1;
194
+ }
195
+ const reason = args[2] || '';
196
+ const { record, alreadyKilled } = await killIdea(ctx, id, reason);
197
+ if (alreadyKilled) {
198
+ console.log(`${record.handle} was already killed on ${toMarkdownDate(record.killed?.at)}: ${record.killed?.reason ?? ''}`);
199
+ } else {
200
+ console.log(`Killed ${record.handle}: ${reason}`);
201
+ }
202
+ return 0;
203
+ }
204
+
205
+ case 'resurrect': {
206
+ const id = args[1];
207
+ if (!id) {
208
+ console.error('Usage: compose ideabox resurrect <ID>');
209
+ return 1;
210
+ }
211
+ const { record } = await resurrectIdea(ctx, id);
212
+ console.log(`Resurrected ${record.handle}: ${record.title}`);
213
+ return 0;
214
+ }
215
+
216
+ case 'pri': {
217
+ const id = args[1];
218
+ const priority = args[2];
219
+ if (!id || !priority) {
220
+ console.error('Usage: compose ideabox pri <ID> <P0|P1|P2>');
221
+ return 1;
222
+ }
223
+ if (!PRIORITIES.includes(priority.toUpperCase()) && priority !== '—') {
224
+ console.error(`Invalid priority: ${priority}. Use P0, P1, P2 or —`);
225
+ return 1;
226
+ }
227
+ const { record } = await setPriority(ctx, id, priority);
228
+ console.log(`Set ${record.handle} priority → ${priority}`);
229
+ return 0;
230
+ }
231
+
232
+ case 'discuss': {
233
+ const id = args[1];
234
+ const comment = args[2];
235
+ if (!id || !comment) {
236
+ console.error('Usage: compose ideabox discuss <ID> "<comment>"');
237
+ return 1;
238
+ }
239
+ await addDiscussion(ctx, id, { text: comment, author: 'human' });
240
+ console.log(`[${new Date().toISOString().slice(0, 10)}] human: ${comment}`);
241
+ return 0;
242
+ }
243
+
244
+ case 'triage': {
245
+ const lensName = flagValue(args, '--lens');
246
+ await ensureIdeaboxMigrated(provider, ideaboxPath);
247
+ const ideas = await provider.listRecords({ kind: KIND.IDEA });
248
+ const untriaged = ideas.filter((i) => !i.priority && i.status === 'new');
249
+ if (!untriaged.length) {
250
+ console.log('No untriaged ideas.');
251
+ return 0;
252
+ }
253
+
254
+ if (lensName) {
255
+ const { loadLens } = await import('./ideabox.js');
256
+ if (!loadLens(cwd, lensName)) {
257
+ console.warn(`Lens not found: docs/product/ideabox-priority-${lensName}.md`);
258
+ } else {
259
+ console.log(`Using lens: ${lensName}`);
260
+ }
261
+ }
262
+
263
+ const { createInterface } = await import('node:readline');
264
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
265
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
266
+
267
+ let changed = false;
268
+ try {
269
+ for (const idea of untriaged) {
270
+ console.log(`\n${idea.handle}: ${idea.title}`);
271
+ if (idea.body) console.log(` ${idea.body.slice(0, 120)}`);
272
+ if (lensName) console.log(` [lens: ${lensName}]`);
273
+ const answer = (await ask(' Priority [P0/P1/P2/skip]: ')).trim().toUpperCase();
274
+ if (PRIORITIES.includes(answer)) {
275
+ // The record write only. Triage is a bulk pass, so it renders once
276
+ // at the end rather than rewriting the whole file per answer.
277
+ await provider.updateRecord(idea.handle, { priority: answer });
278
+ changed = true;
279
+ console.log(` Set ${idea.handle} → ${answer}`);
280
+ } else {
281
+ console.log(' Skipped');
282
+ }
283
+ }
284
+ } finally {
285
+ rl.close();
286
+ }
287
+
288
+ if (changed) {
289
+ await writeIdeaboxProjection(provider, ideaboxPath);
290
+ console.log('\nSaved.');
291
+ }
292
+ return 0;
293
+ }
294
+
295
+ case 'render': {
296
+ // The repair path. Every mutation writes the record before the
297
+ // projection, so a failed render leaves durable canon and a stale file;
298
+ // this is how the file catches up without touching a record. Also the
299
+ // fix for a projection that was interrupted, and the way back from any
300
+ // hand edit.
301
+ await ensureIdeaboxMigrated(provider, ideaboxPath);
302
+ await writeIdeaboxProjection(provider, ideaboxPath);
303
+ console.log(`Rendered ${ideaboxPath}`);
304
+ return 0;
305
+ }
306
+
307
+ default:
308
+ console.error(`Unknown ideabox subcommand: ${sub}`);
309
+ console.error('Run: compose ideabox --help');
310
+ return 1;
311
+ }
312
+ } catch (err) {
313
+ return reportOpFailure(err);
314
+ }
315
+ }