@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.
Files changed (114) hide show
  1. package/.claude/agents/compose-architect.md +40 -0
  2. package/.claude/agents/compose-explorer.md +35 -0
  3. package/.claude/hooks/canon-guard.mjs +52 -0
  4. package/README.md +1 -1
  5. package/bin/compose.js +33 -14
  6. package/bin/git-hooks/pre-push.template +26 -1
  7. package/bin/receipts-gate.js +39 -0
  8. package/contracts/fluid-record.schema.json +5 -0
  9. package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
  10. package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
  11. package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
  15. package/dist/assets/channel-B-7ZRCKC.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
  26. package/dist/assets/clone-CfNV0lUO.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
  37. package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
  38. package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
  39. package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
  43. package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
  44. package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
  45. package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/build-cancel.js +205 -0
  61. package/lib/build.js +552 -87
  62. package/lib/canon-guard.js +3 -24
  63. package/lib/canon-registry.js +2 -71
  64. package/lib/codex-preflight.js +8 -0
  65. package/lib/colleague/context.js +123 -0
  66. package/lib/consumer-fanout.js +24 -1
  67. package/lib/decision-blocks.js +38 -0
  68. package/lib/dispatch-ledger.js +7 -0
  69. package/lib/fluid/factory.js +112 -1
  70. package/lib/fluid/ideabox-manifest.js +203 -0
  71. package/lib/fluid/ideabox-migrate.js +177 -29
  72. package/lib/fluid/ideabox-preamble.js +155 -0
  73. package/lib/fluid/ideabox-readable.js +83 -0
  74. package/lib/fluid/ideabox-recover.js +393 -0
  75. package/lib/fluid/import-ideabox.js +188 -45
  76. package/lib/fluid/local-provider.js +6 -0
  77. package/lib/fluid/portfolio.js +255 -0
  78. package/lib/fluid/record-shape.js +7 -0
  79. package/lib/fluid/render-ideabox.js +153 -7
  80. package/lib/fluid/smartmemory-provider.js +6 -0
  81. package/lib/gate-prompt.js +14 -7
  82. package/lib/ideabox-cli.js +68 -0
  83. package/lib/ideabox.js +209 -9
  84. package/lib/maya-identity.js +16 -2
  85. package/lib/process-termination.js +121 -3
  86. package/lib/receipts-gate.js +268 -0
  87. package/lib/result-normalizer.js +28 -1
  88. package/lib/smartmemory-client.js +68 -1
  89. package/lib/stratum-mcp-client.js +104 -5
  90. package/lib/tool-inventory.js +0 -1
  91. package/lib/version-check.js +9 -3
  92. package/package.json +7 -5
  93. package/server/build-stream-bridge.js +43 -1
  94. package/server/cc-session-watcher.js +54 -5
  95. package/server/compose-mcp-tools.js +48 -50
  96. package/server/compose-mcp.js +0 -2
  97. package/server/design-routes.js +1 -1
  98. package/server/file-watcher.js +14 -0
  99. package/server/ideabox-routes.js +10 -0
  100. package/server/index.js +5 -1
  101. package/server/lifecycle-guard.js +13 -0
  102. package/server/maya-routes.js +111 -7
  103. package/server/mcp-tool-defs.js +0 -25
  104. package/server/mcp-tool-policy.js +6 -13
  105. package/server/stratum-client.js +61 -15
  106. package/server/supervisor.js +18 -4
  107. package/server/vision-routes.js +9 -3
  108. package/dist/assets/channel-SnZzzh7k.js +0 -1
  109. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  110. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  111. package/dist/assets/clone-DgklGjHm.js +0 -1
  112. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  113. package/lib/append-integrity.js +0 -81
  114. 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
- let inIdeasSection = false
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/.test(line)) {
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/.test(line)) {
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/.test(line)) && !(/^##\s+Killed\s+Ideas/.test(line))) {
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
- if (/^###\s/.test(line)) {
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(IDEA_HEADING_RE)
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
- return { ideas, killed, nextId, clusters, preamble: preambleLines.join('\n') }
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
- return parseIdeabox(markdown)
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
  /**
@@ -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 res = await requestJson(`${smBaseUrl}/memory/beta/nda/accept`, {
143
- body: { version: 'v1' }, token, fetchFn,
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') throw error;
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