@smartmemory/compose 0.4.1 → 0.5.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/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +1 -1
- package/bin/compose.js +33 -14
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/build-cancel.js +205 -0
- package/lib/build.js +552 -87
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +24 -1
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +28 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/package.json +7 -5
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- package/lib/canon-override.js +0 -196
package/lib/ideabox.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
18
18
|
import { join, dirname } from 'node:path'
|
|
19
|
+
import { assertIdeaboxReadable } from './fluid/ideabox-readable.js'
|
|
19
20
|
import { resolvePathValue } from './paths-core.js'
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
@@ -47,6 +48,36 @@ export const IDEABOX_TEMPLATE = `# Ideabox
|
|
|
47
48
|
// Matches: #### IDEA-42 — Some Title (or "- " variant)
|
|
48
49
|
const IDEA_HEADING_RE = /^####\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
|
|
49
50
|
|
|
51
|
+
// The LEGACY (pre-umbrella) dialect: ideas are H3 under arbitrary `## Topic`
|
|
52
|
+
// headings, with no `## Ideas` wrapper and no cluster level. Published installs
|
|
53
|
+
// upgrading from before COMP-PLAN-IDEA-UNIFY have this shape, so it is the
|
|
54
|
+
// dialect the migration path actually meets — see COMP-IDEABOX-MIGRATE-DIALECT,
|
|
55
|
+
// where failing to read it destroyed 18 ideas in one command.
|
|
56
|
+
const LEGACY_IDEA_HEADING_RE = /^###\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
|
|
57
|
+
|
|
58
|
+
// An idea heading at EITHER level. A third vintage in the wild is a hybrid: it
|
|
59
|
+
// has the modern `## Ideas` wrapper (so it is not the flat dialect) but writes
|
|
60
|
+
// both its ideas AND its umbrellas at H3. Those two are still unambiguous —
|
|
61
|
+
// an H3 that starts with an `IDEA-N` id is an idea, and any other H3 is an
|
|
62
|
+
// umbrella — so the level alone was never what distinguished them.
|
|
63
|
+
// Measured 2026-09-06: three projects on this machine are in this dialect
|
|
64
|
+
// (books 9 ideas, ScaleMate 2, trustflow 1). Before the guard landed they were
|
|
65
|
+
// silently destroyed; with the guard but without this they are refused, which
|
|
66
|
+
// is safe but leaves them unable to use the ideabox at all.
|
|
67
|
+
const IDEA_HEADING_ANY_RE = /^#{3,4}\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A line SHAPED like an idea declaration, at any heading level.
|
|
71
|
+
*
|
|
72
|
+
* Used only to notice a heading the dialect's own idea pattern could not
|
|
73
|
+
* consume — the "I can see something here and cannot read it" case.
|
|
74
|
+
*/
|
|
75
|
+
const IDEA_DECL_SHAPE_RE = /^\s*#{1,6}\s+(IDEA-\d+)\b/
|
|
76
|
+
|
|
77
|
+
/** The bullet form, for a document that uses no idea headings at all. */
|
|
78
|
+
const IDEA_BULLET_SHAPE_RE = /^\s*[-*]\s+(IDEA-\d+)\s*[—–:-]/
|
|
79
|
+
|
|
80
|
+
|
|
50
81
|
// Matches a field line: **FieldName:** value (colon inside bold markers)
|
|
51
82
|
const FIELD_RE = /^\*\*([^*:]+):\*\*\s*(.*)$/
|
|
52
83
|
|
|
@@ -80,6 +111,41 @@ const DISCUSSION_ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s+([^:\n]+?):\s+(.+)$/
|
|
|
80
111
|
export function parseIdeabox(markdown) {
|
|
81
112
|
const lines = markdown.split('\n')
|
|
82
113
|
|
|
114
|
+
// Legacy dialect detection, fenced as narrowly as possible: it activates ONLY
|
|
115
|
+
// when the document has no `## Ideas` section AND carries H3 idea headings.
|
|
116
|
+
// A new-dialect file always opens its ideas with `## Ideas`, so legacy mode
|
|
117
|
+
// can never engage on one. A file with BOTH shapes is deliberately left to
|
|
118
|
+
// the modern path — a half-converted document is not something to guess at,
|
|
119
|
+
// and the migration gate refuses it by declaration count instead.
|
|
120
|
+
// A COMPLETE heading, not a prefix: `/^##\s+Ideas/` also matched a legitimate
|
|
121
|
+
// legacy topic called `## Ideas for Later`, which switched legacy mode off and
|
|
122
|
+
// made that document's ideas unreadable. Fenced blocks are skipped for the
|
|
123
|
+
// same reason — an example containing `## Ideas` must not decide the dialect
|
|
124
|
+
// of the file quoting it.
|
|
125
|
+
const structural = []
|
|
126
|
+
let fenced = false
|
|
127
|
+
for (const l of lines) {
|
|
128
|
+
if (/^\s*```/.test(l)) { fenced = !fenced; continue }
|
|
129
|
+
if (!fenced) structural.push(l)
|
|
130
|
+
}
|
|
131
|
+
const hasIdeasSection = structural.some((l) => /^##\s+Ideas\s*$/.test(l))
|
|
132
|
+
const legacy = !hasIdeasSection && structural.some((l) => LEGACY_IDEA_HEADING_RE.test(l))
|
|
133
|
+
|
|
134
|
+
// The hybrid dialect only exists in a document the tools have NEVER written:
|
|
135
|
+
// every write emits ideas at H4, so a document containing any `#### IDEA-` is
|
|
136
|
+
// modern, and an H3 there is an umbrella no matter what it is called.
|
|
137
|
+
//
|
|
138
|
+
// That distinction is load-bearing rather than cosmetic. `compose ideabox`
|
|
139
|
+
// will happily create a cluster named `IDEA-9 — Cache research`, and reading
|
|
140
|
+
// that back as an idea made the next command fail: the guard saw a declared
|
|
141
|
+
// id with no record behind it and refused. Recognising H3 ideas everywhere
|
|
142
|
+
// broke round-tripping the tool's own output.
|
|
143
|
+
const hasModernIdeaHeadings = structural.some((l) => IDEA_HEADING_RE.test(l))
|
|
144
|
+
const hybrid = hasIdeasSection && !hasModernIdeaHeadings
|
|
145
|
+
const ideaHeadingRe = legacy
|
|
146
|
+
? LEGACY_IDEA_HEADING_RE
|
|
147
|
+
: (hybrid ? IDEA_HEADING_ANY_RE : IDEA_HEADING_RE)
|
|
148
|
+
|
|
83
149
|
const ideas = []
|
|
84
150
|
const killed = []
|
|
85
151
|
// Cluster-scoped data. An umbrella heading carries a hand-authored multi-
|
|
@@ -89,11 +155,25 @@ export function parseIdeabox(markdown) {
|
|
|
89
155
|
// parse→serialize cycle (i.e. every `compose ideabox` mutation) deleted it.
|
|
90
156
|
const clusters = []
|
|
91
157
|
const clusterIndex = new Map()
|
|
158
|
+
// Idea-shaped headings that NO branch consumed. The migration gate compares
|
|
159
|
+
// these against the ideas actually produced, so that "the parser could not
|
|
160
|
+
// read this" can never be mistaken for "there was nothing here". Collected
|
|
161
|
+
// HERE, by the parser itself, rather than by a second scanner: the whole class
|
|
162
|
+
// of bug this module keeps producing is two readers disagreeing about what the
|
|
163
|
+
// document says, and a separate scanner is a second reader by construction.
|
|
164
|
+
const shapes = []
|
|
165
|
+
// Idea-shaped headings taken as UMBRELLA names. Legitimate on its own — the
|
|
166
|
+
// tools will create a cluster called `IDEA-9 — Cache research` — but an
|
|
167
|
+
// umbrella named after an id that is ALSO a real idea in the same document is
|
|
168
|
+
// the half-converted file, not a naming choice.
|
|
169
|
+
const clusterNamedIds = []
|
|
92
170
|
// Everything before `## Ideas`. Regenerating this from IDEABOX_TEMPLATE
|
|
93
171
|
// instead of preserving it drops hand-authored convention bullets.
|
|
94
172
|
const preambleLines = []
|
|
95
173
|
|
|
96
|
-
|
|
174
|
+
// In the legacy dialect there is no `## Ideas` wrapper — the ideas simply
|
|
175
|
+
// live under topic headings — so the whole document is the ideas section.
|
|
176
|
+
let inIdeasSection = legacy
|
|
97
177
|
let inKilledSection = false
|
|
98
178
|
let currentCluster = null
|
|
99
179
|
let currentIdea = null
|
|
@@ -111,11 +191,43 @@ export function parseIdeabox(markdown) {
|
|
|
111
191
|
currentIdea = null
|
|
112
192
|
}
|
|
113
193
|
|
|
194
|
+
// Fenced blocks are documentation, not content. Detection and the declaration
|
|
195
|
+
// scan already skipped them; the parse loop did not, so an example in a
|
|
196
|
+
// ```markdown``` block was imported as a real idea — and, worse, satisfied the
|
|
197
|
+
// readability guard on behalf of a genuine entry with the same id that the
|
|
198
|
+
// parser could NOT read, letting the destructive write through. All three
|
|
199
|
+
// readers have to agree on what is content.
|
|
200
|
+
let inFence = false
|
|
201
|
+
|
|
114
202
|
for (let i = 0; i < lines.length; i++) {
|
|
115
203
|
const line = lines[i]
|
|
116
204
|
|
|
205
|
+
if (/^\s*```/.test(line)) {
|
|
206
|
+
inFence = !inFence
|
|
207
|
+
if (!inIdeasSection && !inKilledSection && !seenAnySection) preambleLines.push(line)
|
|
208
|
+
// The DELIMITERS are content too. Keeping the enclosed lines while
|
|
209
|
+
// dropping the fence turned a fenced `#### IDEA-2 — example` inside an
|
|
210
|
+
// idea's body into a real heading on the next projection, and the render
|
|
211
|
+
// after that refused the file it had just written.
|
|
212
|
+
else if (currentIdea) currentIdea._extraLines.push(line)
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (inFence) {
|
|
216
|
+
if (!inIdeasSection && !inKilledSection && !seenAnySection) preambleLines.push(line)
|
|
217
|
+
else if (currentIdea) currentIdea._extraLines.push(line)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Every idea-shaped heading in the document, recorded BEFORE any branch can
|
|
222
|
+
// swallow it. What is consumed as a real idea or as an umbrella is
|
|
223
|
+
// subtracted at the end; whatever is left is content the parser could see
|
|
224
|
+
// and could not read, which the migration gate must refuse rather than
|
|
225
|
+
// treat as absent.
|
|
226
|
+
const shapeHere = line.match(IDEA_DECL_SHAPE_RE)
|
|
227
|
+
if (shapeHere) shapes.push(shapeHere[1])
|
|
228
|
+
|
|
117
229
|
// Detect section boundaries
|
|
118
|
-
if (/^##\s+Ideas
|
|
230
|
+
if (/^##\s+Ideas\s*$/.test(line)) {
|
|
119
231
|
flushCurrentIdea()
|
|
120
232
|
inIdeasSection = true
|
|
121
233
|
inKilledSection = false
|
|
@@ -123,7 +235,7 @@ export function parseIdeabox(markdown) {
|
|
|
123
235
|
currentCluster = null
|
|
124
236
|
continue
|
|
125
237
|
}
|
|
126
|
-
if (/^##\s+Killed\s+Ideas
|
|
238
|
+
if (/^##\s+Killed\s+Ideas\s*$/.test(line)) {
|
|
127
239
|
flushCurrentIdea()
|
|
128
240
|
inIdeasSection = false
|
|
129
241
|
inKilledSection = true
|
|
@@ -132,8 +244,34 @@ export function parseIdeabox(markdown) {
|
|
|
132
244
|
continue
|
|
133
245
|
}
|
|
134
246
|
// Other H2 sections end both
|
|
135
|
-
if (/^##\s/.test(line) && !(/^##\s+Ideas
|
|
247
|
+
if (/^##\s/.test(line) && !(/^##\s+Ideas\s*$/.test(line)) && !(/^##\s+Killed\s+Ideas\s*$/.test(line))) {
|
|
136
248
|
flushCurrentIdea()
|
|
249
|
+
if (legacy) {
|
|
250
|
+
// A `## Topic` heading in the legacy dialect is not the end of the
|
|
251
|
+
// ideas — it is how that dialect GROUPED them, which is precisely what
|
|
252
|
+
// an umbrella is. Mapping it to a cluster instead of discarding it
|
|
253
|
+
// means the upgrade preserves the author's grouping rather than
|
|
254
|
+
// flattening ten topics into one undifferentiated list. Treating it as
|
|
255
|
+
// preamble (the previous behaviour) collected every topic heading at
|
|
256
|
+
// the top of the file, detached from its ideas.
|
|
257
|
+
const name = line.replace(/^##\s+/, '').trim()
|
|
258
|
+
seenAnySection = true
|
|
259
|
+
// Leaving the killed section matters: without this, every idea under a
|
|
260
|
+
// topic heading that happened to follow `## Killed Ideas` was imported
|
|
261
|
+
// as KILLED. A topic heading opens a group, it does not inherit the
|
|
262
|
+
// previous section's disposition — and it re-enters the ideas section,
|
|
263
|
+
// which `## Killed Ideas` had closed, or the ideas below it are read as
|
|
264
|
+
// neither live nor killed and vanish entirely.
|
|
265
|
+
inKilledSection = false
|
|
266
|
+
inIdeasSection = true
|
|
267
|
+
currentCluster = name
|
|
268
|
+
if (!clusterIndex.has(name)) {
|
|
269
|
+
const entry = { name, theme: '', order: clusters.length }
|
|
270
|
+
clusters.push(entry)
|
|
271
|
+
clusterIndex.set(name, entry)
|
|
272
|
+
}
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
137
275
|
inIdeasSection = false
|
|
138
276
|
inKilledSection = false
|
|
139
277
|
// Still part of the preamble when it precedes the first real section —
|
|
@@ -142,6 +280,15 @@ export function parseIdeabox(markdown) {
|
|
|
142
280
|
continue
|
|
143
281
|
}
|
|
144
282
|
|
|
283
|
+
// Legacy preamble: everything before the first topic heading or idea. The
|
|
284
|
+
// modern collector below is unreachable here because legacy mode is inside
|
|
285
|
+
// the ideas section from line one, so without this the document's title and
|
|
286
|
+
// introduction were dropped on the first render after migration.
|
|
287
|
+
if (legacy && !seenAnySection && !currentIdea) {
|
|
288
|
+
preambleLines.push(line)
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
|
|
145
292
|
if (!inIdeasSection && !inKilledSection) {
|
|
146
293
|
// Preamble = everything before the first section heading.
|
|
147
294
|
if (!seenAnySection) preambleLines.push(line)
|
|
@@ -156,9 +303,14 @@ export function parseIdeabox(markdown) {
|
|
|
156
303
|
continue
|
|
157
304
|
}
|
|
158
305
|
|
|
159
|
-
// H3 = cluster heading
|
|
160
|
-
|
|
306
|
+
// H3 = cluster heading (modern dialect only — in the legacy dialect H3 IS
|
|
307
|
+
// the idea heading, handled below, and there is no cluster level at all).
|
|
308
|
+
// An H3 carrying an idea id is an IDEA at this level too, not an umbrella
|
|
309
|
+
// named after one; the hybrid dialect writes both at H3.
|
|
310
|
+
if (!legacy && /^###\s/.test(line) && !(hybrid && IDEA_HEADING_ANY_RE.test(line))) {
|
|
161
311
|
flushCurrentIdea()
|
|
312
|
+
const eaten = line.match(IDEA_DECL_SHAPE_RE)
|
|
313
|
+
if (eaten) clusterNamedIds.push(eaten[1])
|
|
162
314
|
currentCluster = line.replace(/^###\s+/, '').trim()
|
|
163
315
|
if (!clusterIndex.has(currentCluster)) {
|
|
164
316
|
const entry = { name: currentCluster, theme: '', order: clusters.length }
|
|
@@ -179,7 +331,7 @@ export function parseIdeabox(markdown) {
|
|
|
179
331
|
}
|
|
180
332
|
|
|
181
333
|
// H4 = idea heading
|
|
182
|
-
const headingMatch = line.match(
|
|
334
|
+
const headingMatch = line.match(ideaHeadingRe)
|
|
183
335
|
if (headingMatch) {
|
|
184
336
|
flushCurrentIdea()
|
|
185
337
|
currentIdea = {
|
|
@@ -273,7 +425,47 @@ export function parseIdeabox(markdown) {
|
|
|
273
425
|
// serializer re-adds the separator itself.
|
|
274
426
|
while (preambleLines.length && preambleLines.at(-1).trim() === '') preambleLines.pop()
|
|
275
427
|
|
|
276
|
-
|
|
428
|
+
// The bullet form is a fallback, not a rule: it counts ONLY for a document
|
|
429
|
+
// with no idea headings whatsoever. Counting bullets unconditionally made
|
|
430
|
+
// `- IDEA-1 needs research`, written inside IDEA-1's own body, a second
|
|
431
|
+
// declaration of IDEA-1 — and the gate then refused a perfectly readable file.
|
|
432
|
+
// A reference is not a declaration, and the only document where a bullet
|
|
433
|
+
// plausibly IS one is a document that declares nothing any other way.
|
|
434
|
+
const parsedIds = [...ideas, ...killed].map((i) => i.id)
|
|
435
|
+
|
|
436
|
+
// By COUNT, not by membership. A half-converted document declares the same id
|
|
437
|
+
// twice — once in each dialect — and the parser consumes only one of them; a
|
|
438
|
+
// membership test sees the id in `parsedIds` and calls it accounted for, while
|
|
439
|
+
// the other copy (routinely the older, richer one) is invisible and would be
|
|
440
|
+
// deleted by the next render. Counting says two were written and one was read.
|
|
441
|
+
const count = (arr, id) => arr.filter((x) => x === id).length
|
|
442
|
+
const unconsumed = [...new Set(shapes)].filter(
|
|
443
|
+
(id) => count(shapes, id) > count(parsedIds, id) + count(clusterNamedIds, id),
|
|
444
|
+
)
|
|
445
|
+
// An umbrella named after an id that is also a real idea here.
|
|
446
|
+
const collisions = clusterNamedIds.filter((id) => parsedIds.includes(id))
|
|
447
|
+
|
|
448
|
+
if (!ideas.length && !killed.length && !unconsumed.length) {
|
|
449
|
+
let bulletFence = false
|
|
450
|
+
for (const line of lines) {
|
|
451
|
+
if (/^\s*```/.test(line)) { bulletFence = !bulletFence; continue }
|
|
452
|
+
if (bulletFence) continue
|
|
453
|
+
const m = line.match(IDEA_BULLET_SHAPE_RE)
|
|
454
|
+
if (m && !unconsumed.includes(m[1])) unconsumed.push(m[1])
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
ideas,
|
|
460
|
+
killed,
|
|
461
|
+
nextId,
|
|
462
|
+
clusters,
|
|
463
|
+
preamble: preambleLines.join('\n'),
|
|
464
|
+
// Idea-shaped content this parse could NOT turn into an idea.
|
|
465
|
+
unconsumed,
|
|
466
|
+
// Umbrellas named after ids that are also real ideas in this document.
|
|
467
|
+
collisions,
|
|
468
|
+
}
|
|
277
469
|
}
|
|
278
470
|
|
|
279
471
|
function extractStatus(raw) {
|
|
@@ -647,7 +839,15 @@ export function readIdeabox(cwd, ideaboxPath) {
|
|
|
647
839
|
return { ideas: [], killed: [], nextId: 1 }
|
|
648
840
|
}
|
|
649
841
|
const markdown = readFileSync(fullPath, 'utf-8')
|
|
650
|
-
|
|
842
|
+
// FU-2. Returning a partial parse from here made "I could not read this file"
|
|
843
|
+
// indistinguishable from "this file has nothing in it" for every caller —
|
|
844
|
+
// most visibly `compose new --from-idea`, which reported "idea not found" for
|
|
845
|
+
// an idea plainly present in the file and then built a feature without the
|
|
846
|
+
// content it had been asked for. The assertion lives in its own leaf module
|
|
847
|
+
// (`fluid/ideabox-readable.js`) precisely so the parser's module can call it:
|
|
848
|
+
// the migration gate imports `parseIdeabox` from here, so keeping it there
|
|
849
|
+
// would close an import cycle.
|
|
850
|
+
return assertIdeaboxReadable(parseIdeabox(markdown), fullPath)
|
|
651
851
|
}
|
|
652
852
|
|
|
653
853
|
/**
|
package/lib/maya-identity.js
CHANGED
|
@@ -136,12 +136,26 @@ export async function provisionIdentity({ smBaseUrl, fetchFn = fetch }) {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** The oldest NDA version this client knows; the server names a newer one. */
|
|
140
|
+
const NDA_VERSION_FLOOR = 'v1';
|
|
141
|
+
|
|
139
142
|
/** Accept the beta NDA for a provisioned identity (403 `nda_required` gates
|
|
140
143
|
* every memory route until this runs — FOH-4 ledger prereq 3). */
|
|
141
144
|
export async function acceptNda({ smBaseUrl, token, fetchFn = fetch }) {
|
|
142
|
-
const
|
|
143
|
-
body: { version
|
|
145
|
+
const attempt = (version) => requestJson(`${smBaseUrl}/memory/beta/nda/accept`, {
|
|
146
|
+
body: { version }, token, fetchFn,
|
|
144
147
|
});
|
|
148
|
+
let res = await attempt(NDA_VERSION_FLOOR);
|
|
149
|
+
// The NDA version is upstream's to bump, not ours to track: a 409
|
|
150
|
+
// `version_mismatch` names the version currently in force, and accepting the
|
|
151
|
+
// one the server names is the only answer that survives the next bump.
|
|
152
|
+
// FOH-7 live-fire (2026-09-06) found v1 hardcoded after upstream moved to v2 —
|
|
153
|
+
// every provisioned colleague identity failed its first turn.
|
|
154
|
+
const named = res.status === 409 && res.body?.detail?.code === 'version_mismatch'
|
|
155
|
+
? res.body.detail.current_version : null;
|
|
156
|
+
if (typeof named === 'string' && named && named !== NDA_VERSION_FLOOR) {
|
|
157
|
+
res = await attempt(named);
|
|
158
|
+
}
|
|
145
159
|
if (res.status < 200 || res.status >= 300) {
|
|
146
160
|
throw new MayaIdentityError(`maya: NDA accept failed (HTTP ${res.status})`);
|
|
147
161
|
}
|
|
@@ -11,8 +11,35 @@
|
|
|
11
11
|
* alive → await leader close and group disappearance (bounded at 2 seconds).
|
|
12
12
|
*
|
|
13
13
|
* Grace period: `COMPOSE_CANCEL_GRACE_MS` (default 5000ms).
|
|
14
|
+
*
|
|
15
|
+
* D-TERM-1 (2026-09-07): `-pid` IS still signalled after the leader is reaped.
|
|
16
|
+
* ------------------------------------------------------------------------
|
|
17
|
+
* The open question was whether signalling the group after `close` aims at a
|
|
18
|
+
* pgid the kernel may have recycled to a stranger. It does not, for the whole
|
|
19
|
+
* window that matters:
|
|
20
|
+
*
|
|
21
|
+
* POSIX 4.13 — "if there exists a process group whose process group ID is
|
|
22
|
+
* equal to that process ID, the process ID shall not be reused until the
|
|
23
|
+
* process group lifetime ends" — and a group's lifetime ends only when its
|
|
24
|
+
* LAST member leaves.
|
|
25
|
+
*
|
|
26
|
+
* So while our group has any living member, `-pid` provably names OUR group,
|
|
27
|
+
* and a living member is exactly the condition teardown is waiting on.
|
|
28
|
+
* Measured on Darwin 25.6.0 rather than taken on faith: with the leader reaped
|
|
29
|
+
* and one grandchild left, 400,000 fork/exit cycles never got the pgid handed
|
|
30
|
+
* back (the pid space is ~100k, so that is four wraps). Emptying the group
|
|
31
|
+
* first, the same pid came back at iteration 98,102 — one full wrap.
|
|
32
|
+
*
|
|
33
|
+
* That measurement RETIRES the fear rather than confirming it: the recycled
|
|
34
|
+
* pgid needs roughly 98,000 process creations between our group emptying and
|
|
35
|
+
* our probe, inside a 2s reap deadline. It is not a millisecond race, and it
|
|
36
|
+
* is the LEAST likely reading of a group-signal failure, not the diagnosis.
|
|
37
|
+
* Keep signalling the group after close — "the group outlives the leader" is
|
|
38
|
+
* correct and load-bearing.
|
|
14
39
|
*/
|
|
15
40
|
|
|
41
|
+
import { execFileSync } from 'node:child_process';
|
|
42
|
+
|
|
16
43
|
const DEFAULT_GRACE_MS = 5000;
|
|
17
44
|
|
|
18
45
|
/** Read the configured cancellation grace period. */
|
|
@@ -35,6 +62,89 @@ function graceMsFromEnv(env = process.env) {
|
|
|
35
62
|
* @param {number} [graceMs]
|
|
36
63
|
* @returns {{ close: Promise<void>, terminate: () => Promise<void>, finish: () => Promise<void> }}
|
|
37
64
|
*/
|
|
65
|
+
/**
|
|
66
|
+
* Every process currently in `pgid`, so a group-signal failure can be
|
|
67
|
+
* attributed instead of argued about.
|
|
68
|
+
*
|
|
69
|
+
* `ps -g` is NOT portable — BSD reads it as a pgid list, procps as a
|
|
70
|
+
* session/group list — so the whole table is read and filtered here.
|
|
71
|
+
*
|
|
72
|
+
* @returns {Array<object> | {error: string}} never throws: this runs on an
|
|
73
|
+
* error path, where a second failure would replace the first one.
|
|
74
|
+
*/
|
|
75
|
+
function groupMembers(pgid) {
|
|
76
|
+
try {
|
|
77
|
+
const table = execFileSync('ps', ['-eo', 'pid=,pgid=,ppid=,uid=,comm='], {
|
|
78
|
+
encoding: 'utf8', timeout: 2000, maxBuffer: 4 * 1024 * 1024,
|
|
79
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
80
|
+
});
|
|
81
|
+
return table.split('\n')
|
|
82
|
+
.map((line) => line.trim().split(/\s+/))
|
|
83
|
+
.filter((f) => f.length >= 5 && Number(f[1]) === pgid)
|
|
84
|
+
// `comm` is last and may contain spaces (it is a path), so it takes the tail.
|
|
85
|
+
.map((f) => ({ pid: Number(f[0]), ppid: Number(f[2]), uid: Number(f[3]), comm: f.slice(4).join(' ') }));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { error: e?.code ?? e?.message ?? 'unknown' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Why a group signal failed, stamped on the error itself.
|
|
93
|
+
*
|
|
94
|
+
* A non-ESRCH failure here is rethrown, relabelled `CANCELLATION_UNCONFIRMED`
|
|
95
|
+
* by `terminate`, and reaches the caller carrying only Node's bare message —
|
|
96
|
+
* `kill EPERM` and nothing else, which is unattributable after the fact: two
|
|
97
|
+
* call sites send group signals.
|
|
98
|
+
*
|
|
99
|
+
* WHAT EPERM MEANS HERE. Measured on Darwin 25.6.0: `kill(-pgid, 0)` against a
|
|
100
|
+
* group of processes we may not signal answers EPERM, not ESRCH. So EPERM says
|
|
101
|
+
* exactly one thing — *a group with this pgid exists and every member refused
|
|
102
|
+
* our signal*. Three sub-causes, deliberately unranked except for the last:
|
|
103
|
+
*
|
|
104
|
+
* 1. a member runs as another uid (a tool child that escalated), or
|
|
105
|
+
* 2. a policy — MAC / sandbox — refused the signal for a member that shares
|
|
106
|
+
* our uid, so "same uid" does NOT rule this out, or
|
|
107
|
+
* 3. our group is gone and a stranger holds a recycled pgid.
|
|
108
|
+
*
|
|
109
|
+
* (3) was the original hypothesis and is the LEAST likely of the three: per
|
|
110
|
+
* D-TERM-1 above it needs ~98,000 process creations inside a 2s deadline.
|
|
111
|
+
*
|
|
112
|
+
* `killGroupMembers` is what actually separates them, and it is why this
|
|
113
|
+
* function exists at all: uids other than ours point at (1) or (2), and
|
|
114
|
+
* processes plainly unrelated to the run point at (3).
|
|
115
|
+
*
|
|
116
|
+
* `killLeader` is kept but is NOT the discriminator it shipped as. After
|
|
117
|
+
* `close` the leader has been reaped, so it reads `gone` in every realistic
|
|
118
|
+
* recurrence; `ours` / `not-ours` need the same implausible wrap as (3). Do not
|
|
119
|
+
* read `not-ours` as "recycled pgid confirmed" — that inference was wrong.
|
|
120
|
+
*
|
|
121
|
+
* Diagnostic only: it changes no control flow and rethrows the same error.
|
|
122
|
+
*/
|
|
123
|
+
function describeGroupSignalFailure(error, { site, pid, signal }) {
|
|
124
|
+
error.killSite = site;
|
|
125
|
+
error.killTarget = -pid;
|
|
126
|
+
error.killSignal = signal;
|
|
127
|
+
error.killerPgid = typeof process.getpgrp === 'function' ? process.getpgrp() : null;
|
|
128
|
+
try {
|
|
129
|
+
// The LEADER as a plain pid, not the group. Probing must never throw out of
|
|
130
|
+
// an error path, so every outcome is recorded rather than raised.
|
|
131
|
+
process.kill(pid, 0);
|
|
132
|
+
error.killLeader = 'ours';
|
|
133
|
+
} catch (probe) {
|
|
134
|
+
error.killLeader = probe.code === 'EPERM' ? 'not-ours' : (probe.code === 'ESRCH' ? 'gone' : probe.code);
|
|
135
|
+
}
|
|
136
|
+
error.killGroupMembers = groupMembers(pid);
|
|
137
|
+
const members = Array.isArray(error.killGroupMembers)
|
|
138
|
+
? (error.killGroupMembers.length
|
|
139
|
+
? error.killGroupMembers.map((m) => `${m.pid}/uid ${m.uid} ${m.comm}`).join(', ')
|
|
140
|
+
: 'none')
|
|
141
|
+
: `unreadable (${error.killGroupMembers.error})`;
|
|
142
|
+
error.message = `${error.message} (${site} ${String(signal)} → pgid ${pid}; `
|
|
143
|
+
+ `leader ${error.killLeader}; our pgid ${error.killerPgid}; our uid ${process.getuid?.() ?? '?'}; `
|
|
144
|
+
+ `group members: ${members})`;
|
|
145
|
+
return error;
|
|
146
|
+
}
|
|
147
|
+
|
|
38
148
|
export function processTermination(child, group, graceMs = graceMsFromEnv(), reapTimeoutMs = 2000) {
|
|
39
149
|
let closed = false;
|
|
40
150
|
const close = new Promise((resolve) => child.once('close', () => { closed = true; resolve(); }));
|
|
@@ -45,7 +155,9 @@ export function processTermination(child, group, graceMs = graceMsFromEnv(), rea
|
|
|
45
155
|
try {
|
|
46
156
|
process.kill(-child.pid, signal);
|
|
47
157
|
} catch (error) {
|
|
48
|
-
if (error.code !== 'ESRCH')
|
|
158
|
+
if (error.code !== 'ESRCH') {
|
|
159
|
+
throw describeGroupSignalFailure(error, { site: 'send', pid: child.pid, signal });
|
|
160
|
+
}
|
|
49
161
|
}
|
|
50
162
|
} else if (!closed) {
|
|
51
163
|
child.kill(signal);
|
|
@@ -61,15 +173,21 @@ export function processTermination(child, group, graceMs = graceMsFromEnv(), rea
|
|
|
61
173
|
return true;
|
|
62
174
|
} catch (error) {
|
|
63
175
|
if (error.code === 'ESRCH') return false;
|
|
64
|
-
throw error;
|
|
176
|
+
throw describeGroupSignalFailure(error, { site: 'alive', pid: child.pid, signal: 0 });
|
|
65
177
|
}
|
|
66
178
|
};
|
|
67
179
|
|
|
68
180
|
const terminate = () => {
|
|
69
181
|
teardown ??= (async () => {
|
|
70
|
-
send('SIGTERM');
|
|
71
182
|
let timer;
|
|
72
183
|
try {
|
|
184
|
+
// INSIDE the try. Outside it, a refused opening SIGTERM escaped with
|
|
185
|
+
// Node's bare `EPERM` as its code, so the one failure the caller is
|
|
186
|
+
// told to expect from a group signal — CANCELLATION_UNCONFIRMED, per
|
|
187
|
+
// `describeGroupSignalFailure` — was the one code it never got. Only
|
|
188
|
+
// the later `alive()` and SIGKILL sites were ever labelled. Found by
|
|
189
|
+
// the D-TERM-1 test below, which is the first test this path ever had.
|
|
190
|
+
send('SIGTERM');
|
|
73
191
|
await Promise.race([
|
|
74
192
|
new Promise((resolve) => { timer = setTimeout(resolve, graceMs); }),
|
|
75
193
|
// A leader that closed while its group lives must still wait out the
|