@smartmemory/compose 0.3.6-beta → 0.3.8
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 +42 -88
- package/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +754 -347
- package/bin/git-hooks/pre-push.template +29 -0
- package/bin/judgment-import.js +7 -0
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/judgment-record.schema.json +425 -4
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
- package/dist/assets/arc-DY26UIVo.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
- package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
- package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
- package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
- package/dist/assets/graph-BNzKGvoy.js +1 -0
- package/dist/assets/graph-CI_1htl0.js +331 -0
- package/dist/assets/index-BEfrNBp8.js +123 -0
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-BI8cXFPI.js +1 -0
- package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/min-vPHfnXcC.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
- package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
- package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
- package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +39 -4
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +1624 -195
- package/lib/canon-guard.js +245 -0
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +291 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +50 -15
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +301 -0
- package/lib/dispatch-metrics.js +236 -0
- package/lib/experiment-judge.js +6 -1
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +66 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -14
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/hooks-status.js +32 -3
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +166 -0
- package/lib/judgment/store/records.js +184 -25
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +375 -22
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-writer.js +2842 -262
- package/lib/lane-gate.js +2 -0
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +250 -54
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/mcp-enforcement.js +21 -35
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +428 -153
- package/lib/review-normalize.js +4 -0
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +391 -31
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +124 -24
- package/server/compose-mcp.js +91 -790
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +35 -3
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-BG3ngu8H.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
- package/dist/assets/arc-_v4hR_uD.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
- package/dist/assets/channel-B3U5wFAT.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
- package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
- package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-3ZH5eMcZ.js +0 -119
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DEXfKzaS.js +0 -1
- package/dist/assets/map-Czzmt4hB.js +0 -1
- package/dist/assets/mobile-CaoXUwAr.js +0 -17
- package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
- package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
|
@@ -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
|
+
}
|
package/lib/ideabox.js
CHANGED
|
@@ -51,7 +51,22 @@ const IDEA_HEADING_RE = /^####\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
|
|
|
51
51
|
const FIELD_RE = /^\*\*([^*:]+):\*\*\s*(.*)$/
|
|
52
52
|
|
|
53
53
|
// Matches a discussion entry: - [2026-04-10] author: text
|
|
54
|
-
|
|
54
|
+
//
|
|
55
|
+
// The author is "everything up to the first colon", not `\w+`.
|
|
56
|
+
//
|
|
57
|
+
// COMP-FLUID-SEAM-GUARANTEES F7-1. `\w+` matched no real person's name: an entry
|
|
58
|
+
// written by `Jane Doe` rendered correctly into the file and then parsed to
|
|
59
|
+
// ZERO discussion entries, silently, taking the comment with it and breaking the
|
|
60
|
+
// `serialize(parse(projection))` fixed point the cutover rests on. It was
|
|
61
|
+
// unreachable while the CLI was the only writer (it always writes `human`) and
|
|
62
|
+
// became reachable the moment the REST API accepted an author from a request
|
|
63
|
+
// body (COMP-PLAN-IDEA-UNIFY S3b-2).
|
|
64
|
+
//
|
|
65
|
+
// Lazy, so the FIRST colon delimits: an author cannot contain one (the contract
|
|
66
|
+
// forbids it, since the file could not represent it), while the comment text
|
|
67
|
+
// routinely does — `- [2026-08-05] human: see this: it matters` keeps the whole
|
|
68
|
+
// sentence.
|
|
69
|
+
const DISCUSSION_ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s+([^:\n]+?):\s+(.+)$/
|
|
55
70
|
|
|
56
71
|
// ---------------------------------------------------------------------------
|
|
57
72
|
// parseIdeabox(markdown) → { ideas, killed, nextId }
|
|
@@ -67,11 +82,22 @@ export function parseIdeabox(markdown) {
|
|
|
67
82
|
|
|
68
83
|
const ideas = []
|
|
69
84
|
const killed = []
|
|
85
|
+
// Cluster-scoped data. An umbrella heading carries a hand-authored multi-
|
|
86
|
+
// sentence `**Theme:**` paragraph, which is information, not decoration —
|
|
87
|
+
// before this was captured it sat between the H3 and the first H4 where the
|
|
88
|
+
// loop had no `currentIdea`, so it was silently dropped and every
|
|
89
|
+
// parse→serialize cycle (i.e. every `compose ideabox` mutation) deleted it.
|
|
90
|
+
const clusters = []
|
|
91
|
+
const clusterIndex = new Map()
|
|
92
|
+
// Everything before `## Ideas`. Regenerating this from IDEABOX_TEMPLATE
|
|
93
|
+
// instead of preserving it drops hand-authored convention bullets.
|
|
94
|
+
const preambleLines = []
|
|
70
95
|
|
|
71
96
|
let inIdeasSection = false
|
|
72
97
|
let inKilledSection = false
|
|
73
98
|
let currentCluster = null
|
|
74
99
|
let currentIdea = null
|
|
100
|
+
let seenAnySection = false
|
|
75
101
|
|
|
76
102
|
function flushCurrentIdea() {
|
|
77
103
|
if (!currentIdea) return
|
|
@@ -93,6 +119,7 @@ export function parseIdeabox(markdown) {
|
|
|
93
119
|
flushCurrentIdea()
|
|
94
120
|
inIdeasSection = true
|
|
95
121
|
inKilledSection = false
|
|
122
|
+
seenAnySection = true
|
|
96
123
|
currentCluster = null
|
|
97
124
|
continue
|
|
98
125
|
}
|
|
@@ -100,6 +127,7 @@ export function parseIdeabox(markdown) {
|
|
|
100
127
|
flushCurrentIdea()
|
|
101
128
|
inIdeasSection = false
|
|
102
129
|
inKilledSection = true
|
|
130
|
+
seenAnySection = true
|
|
103
131
|
currentCluster = null
|
|
104
132
|
continue
|
|
105
133
|
}
|
|
@@ -108,18 +136,48 @@ export function parseIdeabox(markdown) {
|
|
|
108
136
|
flushCurrentIdea()
|
|
109
137
|
inIdeasSection = false
|
|
110
138
|
inKilledSection = false
|
|
139
|
+
// Still part of the preamble when it precedes the first real section —
|
|
140
|
+
// `## Conventions` lives here and was being dropped on the floor.
|
|
141
|
+
if (!seenAnySection) preambleLines.push(line)
|
|
111
142
|
continue
|
|
112
143
|
}
|
|
113
144
|
|
|
114
|
-
if (!inIdeasSection && !inKilledSection)
|
|
145
|
+
if (!inIdeasSection && !inKilledSection) {
|
|
146
|
+
// Preamble = everything before the first section heading.
|
|
147
|
+
if (!seenAnySection) preambleLines.push(line)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Horizontal rules inside the ideas section are structural separators
|
|
152
|
+
// between umbrellas. They are regenerated by the serializer from the
|
|
153
|
+
// cluster list, so capturing them here would attach each one to the
|
|
154
|
+
// PRECEDING idea's extra lines and duplicate every rule on write.
|
|
155
|
+
if (/^---\s*$/.test(line)) {
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
115
158
|
|
|
116
159
|
// H3 = cluster heading
|
|
117
160
|
if (/^###\s/.test(line)) {
|
|
118
161
|
flushCurrentIdea()
|
|
119
162
|
currentCluster = line.replace(/^###\s+/, '').trim()
|
|
163
|
+
if (!clusterIndex.has(currentCluster)) {
|
|
164
|
+
const entry = { name: currentCluster, theme: '', order: clusters.length }
|
|
165
|
+
clusters.push(entry)
|
|
166
|
+
clusterIndex.set(currentCluster, entry)
|
|
167
|
+
}
|
|
120
168
|
continue
|
|
121
169
|
}
|
|
122
170
|
|
|
171
|
+
// Cluster-scoped `**Theme:**` paragraph — appears after the H3 and before
|
|
172
|
+
// the first H4, i.e. exactly where there is no current idea to attach it to.
|
|
173
|
+
if (currentCluster && !currentIdea) {
|
|
174
|
+
const themeMatch = line.match(/^\*\*Theme:\*\*\s*(.*)$/)
|
|
175
|
+
if (themeMatch) {
|
|
176
|
+
clusterIndex.get(currentCluster).theme = themeMatch[1].trim()
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
123
181
|
// H4 = idea heading
|
|
124
182
|
const headingMatch = line.match(IDEA_HEADING_RE)
|
|
125
183
|
if (headingMatch) {
|
|
@@ -211,7 +269,11 @@ export function parseIdeabox(markdown) {
|
|
|
211
269
|
const maxNum = allNums.length ? Math.max(...allNums) : 0
|
|
212
270
|
const nextId = maxNum + 1
|
|
213
271
|
|
|
214
|
-
|
|
272
|
+
// Trailing blank lines before `## Ideas` are structural, not content — the
|
|
273
|
+
// serializer re-adds the separator itself.
|
|
274
|
+
while (preambleLines.length && preambleLines.at(-1).trim() === '') preambleLines.pop()
|
|
275
|
+
|
|
276
|
+
return { ideas, killed, nextId, clusters, preamble: preambleLines.join('\n') }
|
|
215
277
|
}
|
|
216
278
|
|
|
217
279
|
function extractStatus(raw) {
|
|
@@ -232,7 +294,17 @@ function applyField(idea, key, val) {
|
|
|
232
294
|
idea.priority = val.replace(/`/g, '').trim() || '—'
|
|
233
295
|
break
|
|
234
296
|
case 'Tags':
|
|
235
|
-
|
|
297
|
+
// Accepts BOTH spellings. The documented convention is `` `#ux` ``, but
|
|
298
|
+
// the real ideabox writes bare words (`stratum integrity research-
|
|
299
|
+
// influence`), and the old `/#\w+/g` matched none of them — so all 20
|
|
300
|
+
// ideas parsed with zero tags and every CLI mutation stripped the lot.
|
|
301
|
+
// Tokens are kept verbatim (a leading `#` is preserved, never added) so
|
|
302
|
+
// whichever spelling a file uses survives a round-trip unchanged.
|
|
303
|
+
idea.tags = val
|
|
304
|
+
.replace(/`/g, ' ')
|
|
305
|
+
.trim()
|
|
306
|
+
.split(/\s+/)
|
|
307
|
+
.filter(Boolean)
|
|
236
308
|
break
|
|
237
309
|
case 'Source':
|
|
238
310
|
idea.source = val
|
|
@@ -280,28 +352,42 @@ function applyField(idea, key, val) {
|
|
|
280
352
|
* @param {{ ideas: IdeaEntry[], killed: IdeaEntry[], nextId: number }} parsedData
|
|
281
353
|
* @returns {string}
|
|
282
354
|
*/
|
|
283
|
-
export function serializeIdeabox({ ideas, killed }) {
|
|
355
|
+
export function serializeIdeabox({ ideas, killed, clusters: clusterMeta = [], preamble = '' }) {
|
|
284
356
|
const lines = []
|
|
285
357
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
358
|
+
if (preamble) {
|
|
359
|
+
// Round-trip the author's own preamble. Regenerating it from the template
|
|
360
|
+
// silently deletes any convention the project added (the real ideabox
|
|
361
|
+
// documents an `**Umbrella:**` rule the template has never known about).
|
|
362
|
+
lines.push(...preamble.split('\n'))
|
|
363
|
+
lines.push('')
|
|
364
|
+
} else {
|
|
365
|
+
lines.push('# Ideabox')
|
|
366
|
+
lines.push('')
|
|
367
|
+
lines.push('**Purpose:** Capture raw ideas before they\'re ready for the roadmap.')
|
|
368
|
+
lines.push('')
|
|
369
|
+
lines.push('## Conventions')
|
|
370
|
+
lines.push('- **ID:** `IDEA-N` (sequential, never reuse)')
|
|
371
|
+
lines.push('- **Status:** `NEW` | `DISCUSSING` | `PROMOTED` | `KILLED`')
|
|
372
|
+
lines.push('- **Priority:** `P0` (promote now) | `P1` (next up) | `P2` (backlog) | `—` (untriaged)')
|
|
373
|
+
lines.push('- **Source:** Where the idea came from')
|
|
374
|
+
lines.push('- **Tags:** `#ux` `#core` `#distribution` `#integration` `#research` `#infra`')
|
|
375
|
+
lines.push('')
|
|
376
|
+
}
|
|
297
377
|
lines.push('## Ideas')
|
|
298
378
|
lines.push('')
|
|
299
|
-
|
|
300
|
-
|
|
379
|
+
if (!clusterMeta.length) {
|
|
380
|
+
lines.push('<!-- Ideas grouped by potential feature cluster -->')
|
|
381
|
+
lines.push('')
|
|
382
|
+
}
|
|
301
383
|
|
|
302
384
|
// Group active ideas by cluster
|
|
303
385
|
const clusters = new Map()
|
|
304
386
|
const unclustered = []
|
|
387
|
+
// Seed in declared cluster order so an empty cluster keeps its place and its
|
|
388
|
+
// theme rather than disappearing.
|
|
389
|
+
for (const c of clusterMeta) clusters.set(c.name, [])
|
|
390
|
+
const themeOf = new Map(clusterMeta.map((c) => [c.name, c.theme]))
|
|
305
391
|
for (const idea of ideas) {
|
|
306
392
|
if (idea.cluster) {
|
|
307
393
|
if (!clusters.has(idea.cluster)) clusters.set(idea.cluster, [])
|
|
@@ -312,8 +398,17 @@ export function serializeIdeabox({ ideas, killed }) {
|
|
|
312
398
|
}
|
|
313
399
|
|
|
314
400
|
for (const [cluster, clusterIdeas] of clusters) {
|
|
401
|
+
if (clusterMeta.length) {
|
|
402
|
+
lines.push('---')
|
|
403
|
+
lines.push('')
|
|
404
|
+
}
|
|
315
405
|
lines.push(`### ${cluster}`)
|
|
316
406
|
lines.push('')
|
|
407
|
+
const theme = themeOf.get(cluster)
|
|
408
|
+
if (theme) {
|
|
409
|
+
lines.push(`**Theme:** ${theme}`)
|
|
410
|
+
lines.push('')
|
|
411
|
+
}
|
|
317
412
|
for (const idea of clusterIdeas) {
|
|
318
413
|
lines.push(...serializeIdea(idea))
|
|
319
414
|
}
|
|
@@ -348,14 +443,19 @@ function serializeIdea(idea) {
|
|
|
348
443
|
|
|
349
444
|
if (idea.source) out.push(`**Source:** ${idea.source}`)
|
|
350
445
|
if (idea.description) out.push(`**Idea:** ${idea.description}`)
|
|
351
|
-
if (idea.mapsTo) out.push(`**Maps to:** ${idea.mapsTo}`)
|
|
352
|
-
if (idea.effort) out.push(`**Effort:** ${idea.effort}`)
|
|
353
|
-
if (idea.impact) out.push(`**Impact:** ${idea.impact}`)
|
|
354
446
|
|
|
447
|
+
// Unrecognized field lines are emitted here, BEFORE the trailing known
|
|
448
|
+
// fields, because that is where they occur in practice: an idea that carries
|
|
449
|
+
// custom `**Idea (original):**` / `**Re-aim:**` blocks ends with `**Maps
|
|
450
|
+
// to:**`, and emitting extras last reordered the file on every write.
|
|
355
451
|
for (const extra of (idea._extraLines || [])) {
|
|
356
452
|
out.push(extra)
|
|
357
453
|
}
|
|
358
454
|
|
|
455
|
+
if (idea.mapsTo) out.push(`**Maps to:** ${idea.mapsTo}`)
|
|
456
|
+
if (idea.effort) out.push(`**Effort:** ${idea.effort}`)
|
|
457
|
+
if (idea.impact) out.push(`**Impact:** ${idea.impact}`)
|
|
458
|
+
|
|
359
459
|
// Discussion thread
|
|
360
460
|
if (idea.discussion && idea.discussion.length > 0) {
|
|
361
461
|
out.push('**Discussion:**')
|