@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.
- package/.claude/skills/compose/SKILL.md +12 -3
- package/.compose-deps.json +51 -25
- package/README.md +79 -7
- package/bin/compose.js +495 -360
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
- package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
- package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.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-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
- package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
- package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
- package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
- package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
- package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
- 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 +9 -0
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +874 -170
- package/lib/canon-guard.js +28 -6
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +104 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +26 -13
- 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/deps.js +164 -7
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +3 -3
- 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 +57 -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 -23
- 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/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +9 -1
- package/lib/judgment/store/records.js +1 -1
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +5 -1
- package/lib/judgment-writer.js +56 -2
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +52 -1
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- 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 +396 -199
- 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 +271 -36
- 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 +109 -23
- package/server/compose-mcp.js +88 -882
- 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 +34 -2
- 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-PkZzHeMj.js +0 -894
- package/dist/assets/channel-qVK_qn4E.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
- package/dist/assets/clone-Pu3RyLUh.js +0 -1
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/mobile-BnXEOE3U.js +0 -17
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/render-ideabox.js — `ideabox.md` as a projection of the records.
|
|
3
|
+
*
|
|
4
|
+
* COMP-PLAN-IDEA-UNIFY S2, Decision 3's projection pattern (one canonical
|
|
5
|
+
* store; surfaces are projections; never a second source), the same shape as
|
|
6
|
+
* `ROADMAP.md ← feature.json`.
|
|
7
|
+
*
|
|
8
|
+
* The records are the ONLY input. This module never reads the existing
|
|
9
|
+
* markdown to decide what to write — doing so is what turns a projection back
|
|
10
|
+
* into a second source, and it is how a generator ends up preserving stale
|
|
11
|
+
* content nobody can trace to an owner.
|
|
12
|
+
*
|
|
13
|
+
* Consequence, stated plainly: after the cutover the file is output. Hand edits
|
|
14
|
+
* to it are lost on the next render, which is why the banner says so and why
|
|
15
|
+
* the CLI (S3) is the supported way to change anything.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { withDirLock } from '../dir-lock.js';
|
|
23
|
+
import { toMarkdownDate } from './ideabox-dates.js';
|
|
24
|
+
import { KIND } from './provider.js';
|
|
25
|
+
|
|
26
|
+
// Says only what is true. The cockpit's write path is NOT on the store yet
|
|
27
|
+
// (S3b-2), and its endpoints fail closed until it is — so promising that "both
|
|
28
|
+
// write to the store" would invite exactly the edit that gets discarded.
|
|
29
|
+
const BANNER = [
|
|
30
|
+
'<!-- GENERATED FILE — DO NOT EDIT.',
|
|
31
|
+
' Projection of the fluid-store idea records (COMP-PLAN-IDEA-UNIFY).',
|
|
32
|
+
' Edits here are overwritten on the next render. Change ideas with',
|
|
33
|
+
' `compose ideabox add|pri|kill|discuss|promote`, then `compose ideabox',
|
|
34
|
+
' render` if this file ever looks stale. -->',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const PREAMBLE = [
|
|
38
|
+
'# Ideabox',
|
|
39
|
+
'',
|
|
40
|
+
'**Purpose:** Capture raw ideas before they\'re ready for the roadmap.',
|
|
41
|
+
'',
|
|
42
|
+
'## Conventions',
|
|
43
|
+
'- **ID:** `IDEA-N` (sequential, never reuse)',
|
|
44
|
+
'- **Status:** `NEW` | `DISCUSSING` | `PROMOTED` | `KILLED`',
|
|
45
|
+
'- **Priority:** `P0` (promote now) | `P1` (next up) | `P2` (backlog) | `—` (untriaged)',
|
|
46
|
+
'- **Source:** Where the idea came from',
|
|
47
|
+
'- **Tags:** bare words, space-separated',
|
|
48
|
+
// Carried forward from the hand-authored preamble: a real project convention
|
|
49
|
+
// that the old template never knew about and silently deleted on every write.
|
|
50
|
+
'- **Umbrella:** Ideas are grouped under thematic umbrellas. The umbrella name is a working label; ideas may move between umbrellas as they\'re discussed. IDs are stable.',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const STATUS_TOKEN = Object.freeze({
|
|
54
|
+
new: 'NEW',
|
|
55
|
+
discussing: 'DISCUSSING',
|
|
56
|
+
promoted: 'PROMOTED',
|
|
57
|
+
killed: 'KILLED',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function handleNumber(handle) {
|
|
61
|
+
const m = /-([0-9]+)$/.exec(handle ?? '');
|
|
62
|
+
return m ? Number(m[1]) : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderIdea(record) {
|
|
66
|
+
const out = [];
|
|
67
|
+
out.push(`#### ${record.handle} — ${record.title}`);
|
|
68
|
+
|
|
69
|
+
// `status_label` preserves what the author actually wrote when the canonical
|
|
70
|
+
// enum could not hold it (`RE-AIMED (2026-07-21)`).
|
|
71
|
+
const status = record.status_label || STATUS_TOKEN[record.status];
|
|
72
|
+
const tags = record.tags?.length ? ` | **Tags:** ${record.tags.join(' ')}` : '';
|
|
73
|
+
if (record.status === 'killed') {
|
|
74
|
+
// A killed idea carries no priority segment. Not cosmetic: the legacy
|
|
75
|
+
// serializer omits it (`lib/ideabox.js` — `**Status:** KILLED${tagStr}`),
|
|
76
|
+
// and if the projection emits one, `serialize(parse(projection))` stops
|
|
77
|
+
// being the identity. That fixed point is the assertion the whole cutover
|
|
78
|
+
// rests on, and it would have broken the first time anyone killed an idea.
|
|
79
|
+
// Invisible until now only because the Killed Ideas section was empty.
|
|
80
|
+
out.push(`**Status:** ${status}${tags}`);
|
|
81
|
+
} else {
|
|
82
|
+
out.push(`**Status:** ${status} | **Priority:** ${record.priority || '—'}${tags}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (record.source) out.push(`**Source:** ${record.source}`);
|
|
86
|
+
if (record.body) out.push(`**Idea:** ${record.body}`);
|
|
87
|
+
|
|
88
|
+
// `Promoted to:` is emitted BEFORE `Maps to:`, which reads backwards and is
|
|
89
|
+
// deliberate. The legacy parser knows `Maps to` and does not know `Promoted
|
|
90
|
+
// to`, so the latter lands in `_extraLines` — and the legacy serializer emits
|
|
91
|
+
// extras BEFORE the trailing known fields (`lib/ideabox.js:436-440`, itself a
|
|
92
|
+
// fix for reordering). Emitting them in the readable order therefore flips
|
|
93
|
+
// them on the first `serialize(parse(projection))`, breaking the fixed point
|
|
94
|
+
// for any idea carrying both edges. Invisible so far only because no idea has
|
|
95
|
+
// ever had both.
|
|
96
|
+
for (const link of record.links ?? []) {
|
|
97
|
+
if (link.type === 'promoted_to') out.push(`**Promoted to:** ${link.target}`);
|
|
98
|
+
}
|
|
99
|
+
for (const link of record.links ?? []) {
|
|
100
|
+
if (link.type === 'maps_to') out.push(`**Maps to:** ${link.target}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The 2x2 matrix axes, in the legacy serializer's slot: after `Maps to:` and
|
|
104
|
+
// before the discussion block (`lib/ideabox.js:441-442`). Position is not
|
|
105
|
+
// cosmetic — the projection has to be a fixed point of that serializer.
|
|
106
|
+
if (record.effort) out.push(`**Effort:** ${record.effort}`);
|
|
107
|
+
if (record.impact) out.push(`**Impact:** ${record.impact}`);
|
|
108
|
+
|
|
109
|
+
// `Killed:` precedes the discussion block for the same reason: that is where
|
|
110
|
+
// `serializeKilledIdea` puts it (`lib/ideabox.js:465-471`). Emitting it after
|
|
111
|
+
// the discussion cost the fixed point for every killed idea that had been
|
|
112
|
+
// discussed — which is most of the ones anyone would want to kill.
|
|
113
|
+
if (record.status === 'killed' && record.killed) {
|
|
114
|
+
out.push(`**Killed:** ${toMarkdownDate(record.killed.at)} — ${record.killed.reason}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (record.discussion?.length) {
|
|
118
|
+
out.push('**Discussion:**');
|
|
119
|
+
for (const d of record.discussion) {
|
|
120
|
+
out.push(`- [${toMarkdownDate(d.at)}] ${d.author ?? 'unknown'}: ${d.text}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
out.push('');
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Render the markdown for a set of records.
|
|
130
|
+
* @param {object} data
|
|
131
|
+
* @param {Array<object>} data.ideas
|
|
132
|
+
* @param {Array<object>} data.clusters
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function renderIdeabox({ ideas, clusters }) {
|
|
136
|
+
const lines = [...BANNER, '', ...PREAMBLE, '', '## Ideas', ''];
|
|
137
|
+
// The legacy serializer emits a placeholder comment when the file contains no
|
|
138
|
+
// `###` grouping heading at all, and the projection has to match it byte for
|
|
139
|
+
// byte or `serialize(parse(projection))` stops being the identity. That state
|
|
140
|
+
// is not exotic: it is every brand-new ideabox, and any ideabox whose ideas
|
|
141
|
+
// have all been killed or promoted. Emitted below once both bucket counts are
|
|
142
|
+
// known.
|
|
143
|
+
const groupingPlaceholder = lines.length;
|
|
144
|
+
|
|
145
|
+
// Deterministic ordering everywhere: rendering twice must produce identical
|
|
146
|
+
// bytes, or the file churns in git on every unrelated write.
|
|
147
|
+
const ordered = [...clusters].sort(
|
|
148
|
+
(a, b) => (a.cluster_order ?? Number.MAX_SAFE_INTEGER) - (b.cluster_order ?? Number.MAX_SAFE_INTEGER)
|
|
149
|
+
|| handleNumber(a.handle) - handleNumber(b.handle)
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const live = ideas.filter((i) => i.status !== 'killed');
|
|
153
|
+
const killed = ideas.filter((i) => i.status === 'killed');
|
|
154
|
+
const byHandle = (a, b) => handleNumber(a.handle) - handleNumber(b.handle);
|
|
155
|
+
|
|
156
|
+
for (const cluster of ordered) {
|
|
157
|
+
const members = live.filter((i) => i.cluster === cluster.handle).sort(byHandle);
|
|
158
|
+
lines.push('---', '');
|
|
159
|
+
lines.push(`### ${cluster.title}`, '');
|
|
160
|
+
if (cluster.body) lines.push(`**Theme:** ${cluster.body}`, '');
|
|
161
|
+
for (const idea of members) lines.push(...renderIdea(idea));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const unclustered = live.filter((i) => !i.cluster).sort(byHandle);
|
|
165
|
+
if (unclustered.length) {
|
|
166
|
+
lines.push('---', '');
|
|
167
|
+
lines.push('### Unclustered', '');
|
|
168
|
+
for (const idea of unclustered) lines.push(...renderIdea(idea));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A live idea belongs to exactly one bucket: a cluster whose handle it names,
|
|
172
|
+
// or Unclustered. An idea whose `cluster` holds anything else — most easily a
|
|
173
|
+
// cluster NAME where a handle belongs, which the CLI accepts as free text —
|
|
174
|
+
// matches neither filter and is silently absent from the file while its record
|
|
175
|
+
// sits on disk. The projection is the only surface most readers ever see, so
|
|
176
|
+
// that reads as deletion.
|
|
177
|
+
//
|
|
178
|
+
// Refusing is correct rather than merely safe: the alternative is to invent a
|
|
179
|
+
// home for the idea, and a projection that guesses is a projection nobody can
|
|
180
|
+
// trust. The write is abandoned before it starts, so the previous good file
|
|
181
|
+
// survives to be re-rendered once the reference is fixed.
|
|
182
|
+
if (ordered.length === 0 && unclustered.length === 0) {
|
|
183
|
+
lines.splice(groupingPlaceholder, 0, '<!-- Ideas grouped by potential feature cluster -->', '');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const orphans = live.filter((i) => i.cluster && !clusters.some((c) => c.handle === i.cluster));
|
|
187
|
+
if (orphans.length) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`fluid: cannot render the ideabox — ${orphans.length} idea(s) name a cluster that ` +
|
|
190
|
+
`does not exist, and would silently vanish from the projection: ` +
|
|
191
|
+
orphans.map((i) => `${i.handle} → "${i.cluster}"`).join(', ') +
|
|
192
|
+
`. Point each at a real cluster handle (or clear it) and render again.`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
lines.push('## Killed Ideas', '');
|
|
197
|
+
for (const idea of killed.sort(byHandle)) lines.push(...renderIdea(idea));
|
|
198
|
+
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read the records from a provider and render.
|
|
204
|
+
* @param {import('./provider.js').FluidProvider} provider
|
|
205
|
+
*/
|
|
206
|
+
export async function renderIdeaboxFrom(provider) {
|
|
207
|
+
const [ideas, clusters] = await Promise.all([
|
|
208
|
+
provider.listRecords({ kind: KIND.IDEA }),
|
|
209
|
+
provider.listRecords({ kind: KIND.CLUSTER }),
|
|
210
|
+
]);
|
|
211
|
+
return renderIdeabox({ ideas, clusters });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Render and write atomically (temp + rename), following the roadmap-gen
|
|
216
|
+
* pattern — a half-written projection of canonical data is worse than none.
|
|
217
|
+
*
|
|
218
|
+
* SERIALIZED AGAINST OTHER RENDERS, not just internally atomic.
|
|
219
|
+
*
|
|
220
|
+
* Atomicity alone leaves a real race once there are two writers (the CLI and the
|
|
221
|
+
* REST API, from S3b-2). Each mutation is individually locked inside the
|
|
222
|
+
* provider, but the render is a separate read-then-publish: writer A can read a
|
|
223
|
+
* snapshot, writer B can then mutate AND publish a newer projection, and A's
|
|
224
|
+
* rename lands last with the older content. Canon is untouched — the records are
|
|
225
|
+
* still right — but the generated file stays wrong until the next write, and it
|
|
226
|
+
* is the surface humans read.
|
|
227
|
+
*
|
|
228
|
+
* Taking the provider's mutation lock around read-and-publish closes it without
|
|
229
|
+
* needing a reentrant lock: the mutation has already released by the time this
|
|
230
|
+
* runs, and whichever render acquires last re-reads the current records, so the
|
|
231
|
+
* file that lands last is the correct one.
|
|
232
|
+
*
|
|
233
|
+
* A provider with no lock (SmartMemory — see `factory.js`) renders unserialized,
|
|
234
|
+
* which is the documented state of that provider rather than a new gap.
|
|
235
|
+
*/
|
|
236
|
+
export async function writeIdeaboxProjection(provider, outPath) {
|
|
237
|
+
return provider.lockPath
|
|
238
|
+
? withDirLock(provider.lockPath, () => publishProjection(provider, outPath))
|
|
239
|
+
: publishProjection(provider, outPath);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function publishProjection(provider, outPath) {
|
|
243
|
+
// Rendered BEFORE the destination is touched, so a render that refuses (an
|
|
244
|
+
// idea naming a cluster that does not exist) leaves the previous good file
|
|
245
|
+
// exactly where it was rather than replacing it with a partial view.
|
|
246
|
+
const markdown = await renderIdeaboxFrom(provider);
|
|
247
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
248
|
+
// randomUUID, not pid: two renders from one process must not collide on the
|
|
249
|
+
// temp name, and a recycled pid must not adopt a stranded file. Cleaned up on
|
|
250
|
+
// failure because this directory is tracked — a leftover `.ideabox.md.tmp.*`
|
|
251
|
+
// is named to be committed by accident.
|
|
252
|
+
const tmp = join(dirname(outPath), `.ideabox.md.tmp.${randomUUID()}`);
|
|
253
|
+
try {
|
|
254
|
+
writeFileSync(tmp, markdown, 'utf8');
|
|
255
|
+
renameSync(tmp, outPath);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
rmSync(tmp, { force: true });
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
return markdown;
|
|
261
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/schema.js — fluid-record contract loader.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors lib/judgment/schema.js: the contract is memoized and callers validate
|
|
5
|
+
* against a NAMED DEFINITION rather than the root.
|
|
6
|
+
*
|
|
7
|
+
* This exists because "the seam validates its records" has to be executable to
|
|
8
|
+
* be true. Hand-rolled field checks drift from the published contract the moment
|
|
9
|
+
* either changes, and the drift is invisible — the code keeps accepting what the
|
|
10
|
+
* contract forbids while the contract keeps claiming otherwise.
|
|
11
|
+
*/
|
|
12
|
+
import { dirname, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { SchemaValidator } from '../../server/schema-validator.js';
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
export const FLUID_SCHEMA_PATH = resolve(__dirname, '../../contracts/fluid-record.schema.json');
|
|
19
|
+
|
|
20
|
+
let _validator = null;
|
|
21
|
+
|
|
22
|
+
export function getFluidValidator() {
|
|
23
|
+
if (!_validator) _validator = new SchemaValidator(FLUID_SCHEMA_PATH);
|
|
24
|
+
return _validator;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function describe(errors) {
|
|
28
|
+
return errors
|
|
29
|
+
.map((e) => `${e.instancePath || '/'} ${e.message}`)
|
|
30
|
+
.join('; ');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Validate against a definition, or throw with the schema's own complaint. */
|
|
34
|
+
export function assertValid(defName, obj, what = defName) {
|
|
35
|
+
const { valid, errors } = getFluidValidator().validate(defName, obj);
|
|
36
|
+
if (!valid) {
|
|
37
|
+
throw new Error(`fluid: invalid ${what} — ${describe(errors)}`);
|
|
38
|
+
}
|
|
39
|
+
return obj;
|
|
40
|
+
}
|