@smartmemory/compose 0.4.1 → 0.5.1

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 (127) 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 +15 -1
  5. package/bin/compose.js +57 -17
  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/agent-string.js +9 -4
  61. package/lib/build-cancel.js +205 -0
  62. package/lib/build-stream-writer.js +6 -0
  63. package/lib/build.js +1189 -165
  64. package/lib/canon-guard.js +3 -24
  65. package/lib/canon-registry.js +2 -71
  66. package/lib/codex-preflight.js +8 -0
  67. package/lib/colleague/context.js +123 -0
  68. package/lib/consumer-fanout.js +427 -17
  69. package/lib/decision-blocks.js +38 -0
  70. package/lib/dispatch-ledger.js +7 -0
  71. package/lib/experiment-pricing.js +5 -1
  72. package/lib/flow-state.js +38 -0
  73. package/lib/fluid/factory.js +112 -1
  74. package/lib/fluid/ideabox-manifest.js +203 -0
  75. package/lib/fluid/ideabox-migrate.js +177 -29
  76. package/lib/fluid/ideabox-preamble.js +155 -0
  77. package/lib/fluid/ideabox-readable.js +83 -0
  78. package/lib/fluid/ideabox-recover.js +393 -0
  79. package/lib/fluid/import-ideabox.js +188 -45
  80. package/lib/fluid/local-provider.js +6 -0
  81. package/lib/fluid/portfolio.js +255 -0
  82. package/lib/fluid/record-shape.js +7 -0
  83. package/lib/fluid/render-ideabox.js +153 -7
  84. package/lib/fluid/smartmemory-provider.js +6 -0
  85. package/lib/gate-prompt.js +14 -7
  86. package/lib/gsd.js +95 -48
  87. package/lib/ideabox-cli.js +68 -0
  88. package/lib/ideabox.js +209 -9
  89. package/lib/maya-identity.js +16 -2
  90. package/lib/model-pricing.js +4 -1
  91. package/lib/output-gate.js +81 -0
  92. package/lib/pipeline-profiles.js +200 -0
  93. package/lib/process-termination.js +121 -3
  94. package/lib/receipts-gate.js +268 -0
  95. package/lib/result-normalizer.js +41 -1
  96. package/lib/smartmemory-client.js +68 -1
  97. package/lib/stratum-mcp-client.js +104 -5
  98. package/lib/team-flag.js +1 -1
  99. package/lib/tool-inventory.js +0 -1
  100. package/lib/version-check.js +9 -3
  101. package/lib/wave-checkpoint.js +100 -0
  102. package/package.json +7 -5
  103. package/presets/team-fable-astra.profiles.json +18 -0
  104. package/presets/team-fable-astra.stratum.yaml +236 -0
  105. package/server/build-stream-bridge.js +43 -1
  106. package/server/cc-session-watcher.js +54 -5
  107. package/server/compose-mcp-tools.js +48 -50
  108. package/server/compose-mcp.js +0 -2
  109. package/server/design-routes.js +1 -1
  110. package/server/file-watcher.js +14 -0
  111. package/server/ideabox-routes.js +10 -0
  112. package/server/index.js +5 -1
  113. package/server/lifecycle-guard.js +13 -0
  114. package/server/maya-routes.js +111 -7
  115. package/server/mcp-tool-defs.js +0 -25
  116. package/server/mcp-tool-policy.js +6 -13
  117. package/server/model-tiers.js +14 -6
  118. package/server/stratum-client.js +61 -15
  119. package/server/supervisor.js +18 -4
  120. package/server/vision-routes.js +9 -3
  121. package/dist/assets/channel-SnZzzh7k.js +0 -1
  122. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  123. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  124. package/dist/assets/clone-DgklGjHm.js +0 -1
  125. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  126. package/lib/append-integrity.js +0 -81
  127. package/lib/canon-override.js +0 -196
@@ -98,14 +98,9 @@ export function realpathCanonicalize(p) {
98
98
  * @param {string} [args.featuresDir='docs/features']
99
99
  * @param {(p:string)=>string} [args.canonicalize] - map a path to its real, alias-free form.
100
100
  * The runtime wrapper passes realpathCanonicalize; pure tests inject a stub or omit it.
101
- * @param {string} [args.profile] - the caller's MCP profile (COMPOSE_SESSION_PROFILE).
102
- * Only changes the ESCAPE SENTENCE of the deny message, never the verdict:
103
- * `canon_override_grant` is denied to restricted profiles by IMPLEMENTER_DENY /
104
- * REVIEWER_ALLOW, so telling a restricted caller to mint a grant sends it to a
105
- * tool it cannot call. Absent/unknown → the unrestricted wording (fail-open).
106
101
  * @returns {{deny: boolean, reason?: string, path?: string}}
107
102
  */
108
- export function decideCanonGuard({ toolName, toolInput, cwd, projectRoot, featuresDir = 'docs/features', canonicalize, profile } = {}) {
103
+ export function decideCanonGuard({ toolName, toolInput, cwd, projectRoot, featuresDir = 'docs/features', canonicalize } = {}) {
109
104
  try {
110
105
  if (!GUARDED_TOOLS.has(toolName)) return { deny: false };
111
106
  const raw = toolInput && (toolInput.file_path ?? toolInput.notebook_path);
@@ -126,33 +121,17 @@ export function decideCanonGuard({ toolName, toolInput, cwd, projectRoot, featur
126
121
  const entry = matchEntry(relPosix, { featuresDir, point: 'hook' });
127
122
  if (!entry) return { deny: false };
128
123
 
129
- // Classification only — this function stays pure. The atomic claim happens
130
- // in the hook wrapper, because a destructive callback here would make the
131
- // verdict depend on how many times it was evaluated.
132
- const overrideEligible = entry.overrideEligible !== false;
133
124
  const tools = entry.tools.join(', ');
134
- // COMP-COVERAGE-GATE C4: canon_override_grant is now denied to restricted
135
- // profiles, so pointing one at it would loop it against its own tool gate.
136
- const restricted = profile === 'implementer' || profile === 'reviewer';
137
- const escape = !overrideEligible
138
- ? `This path is the override's own governance state, so it cannot be overridden: a bypass `
139
- + `must not be able to authorise rewriting its own record.`
140
- : restricted
141
- ? `A canon override exists, but canon_override_grant is not available to the '${profile}' `
142
- + `profile — the session subject to canon enforcement cannot exempt itself from it. `
143
- + `Escalate: report what you need to write and why, and let the orchestrator decide.`
144
- : `To do it deliberately anyway, mint a single-use grant with canon_override_grant `
145
- + `({ path, reason, operation }) — the bypass is recorded before the grant exists.`;
146
125
 
147
126
  return {
148
127
  deny: true,
149
128
  path: relPosix,
150
- overrideEligible,
151
129
  reason:
152
130
  `${relPosix} is tool-owned canon (COMP-CANON-GUARD). A direct ${toolName} is blocked — `
153
131
  + `write it through one of: ${tools}. These tools stamp provenance and regenerate the `
154
132
  + `projections from records; a hand-edit is unattributed and overwritten on the next regen. `
155
- + `${escape}`,
133
+ + `For an exceptional repair, use an authorized write outside this hook's reach; a restricted `
134
+ + `agent must escalate.`,
156
135
  };
157
136
  } catch {
158
137
  return { deny: false }; // fail open — never wedge the session
@@ -19,10 +19,10 @@
19
19
  * (open a preserved section; edit a feature description — Decision 2), so
20
20
  * the always-deny hook would lock them out. They stay ['ship'] (their
21
21
  * existing build-event correlation) until update_feature_fields /
22
- * open_preserved_section + the override land.
22
+ * open_preserved_section land.
23
23
  *
24
24
  * Adding a path here turns on real enforcement — register a path for 'hook'
25
- * ONLY once every legal mutation of it has a tool or an override.
25
+ * ONLY once every legal mutation of it has a typed tool.
26
26
  */
27
27
 
28
28
  // ── Tool sets ────────────────────────────────────────────────────────────────
@@ -30,8 +30,6 @@
30
30
  // contract test pins these against the legacy values.
31
31
  const TOOLS_FOR_ROADMAP = ['add_roadmap_entry', 'set_feature_status', 'propose_followup'];
32
32
  const TOOLS_FOR_CHANGELOG = ['add_changelog_entry'];
33
- /** The override's governance state is written only by the grant tool itself. */
34
- const TOOLS_FOR_OVERRIDE = ['canon_override_grant'];
35
33
  const TOOLS_FOR_FEATURE_JSON = [
36
34
  'add_roadmap_entry',
37
35
  'set_feature_status',
@@ -94,14 +92,6 @@ function matchJudgment(path) {
94
92
  return typeof path === 'string' && path.startsWith('docs/judgment/');
95
93
  }
96
94
 
97
- /**
98
- * Prefix match on a directory, requiring the separator so that a sibling with
99
- * a longer name (`canon-grants-backup/`) cannot masquerade as a child.
100
- */
101
- function matchUnder(dir) {
102
- return (path) => typeof path === 'string' && path.startsWith(`${dir}/`);
103
- }
104
-
105
95
  // ── The registry ─────────────────────────────────────────────────────────────
106
96
 
107
97
  /**
@@ -111,9 +101,6 @@ function matchUnder(dir) {
111
101
  * @property {string} writer — the module that legitimately produces this path
112
102
  * @property {string[]} tools — typed tools authorised to write it
113
103
  * @property {Array<'ship'|'hook'|'pre-commit'>} enforcedBy — points that guard it
114
- * @property {boolean} [overrideEligible] — may a canon override be granted FOR
115
- * this path? Absent means yes. Set false for the override's own governance
116
- * state, which must be guarded without being grantable.
117
104
  * @property {(path:string, featuresDir:string)=>boolean} matches
118
105
  */
119
106
 
@@ -151,44 +138,6 @@ const REGISTRY = [
151
138
  enforcedBy: ['hook'],
152
139
  matches: (path) => matchJudgment(path),
153
140
  },
154
-
155
- // ── Governance class (COMP-CANON-OVERRIDE S1) ──────────────────────────────
156
- // The override's own state. Guarded like any canon, and additionally
157
- // `overrideEligible: false` so the override cannot be turned on itself:
158
- // without this, "hook-registered" and "grantable" are the same set, and an
159
- // agent could grant a bypass FOR the bypass ledger and then rewrite it
160
- // (gate round 2, finding 2). The grant directory is governance state for the
161
- // same reason — unregistered, a raw-written token would be consumable with
162
- // no ledger row at all (finding 1).
163
- //
164
- // Runtime-scoped, like every hook guarantee: `Bash` never reaches this.
165
- {
166
- id: 'override-ledger',
167
- display: '.compose/canon-overrides.jsonl',
168
- writer: 'lib/canon-override.js',
169
- tools: TOOLS_FOR_OVERRIDE,
170
- enforcedBy: ['hook'],
171
- overrideEligible: false,
172
- matches: matchExact('.compose/canon-overrides.jsonl'),
173
- },
174
- {
175
- id: 'override-attest',
176
- display: '.compose/canon-overrides-attest.json',
177
- writer: 'lib/canon-override.js',
178
- tools: TOOLS_FOR_OVERRIDE,
179
- enforcedBy: ['hook'],
180
- overrideEligible: false,
181
- matches: matchExact('.compose/canon-overrides-attest.json'),
182
- },
183
- {
184
- id: 'override-grants',
185
- display: '.compose/data/canon-grants/**',
186
- writer: 'lib/canon-override.js',
187
- tools: TOOLS_FOR_OVERRIDE,
188
- enforcedBy: ['hook'],
189
- overrideEligible: false,
190
- matches: matchUnder('.compose/data/canon-grants'),
191
- },
192
141
  ];
193
142
 
194
143
  // ── Public API ───────────────────────────────────────────────────────────────
@@ -215,19 +164,6 @@ export function isGuarded(path, opts) {
215
164
  return matchEntry(path, opts) !== null;
216
165
  }
217
166
 
218
- /**
219
- * True if a canon override may be granted for `path` at `point`.
220
- *
221
- * Deliberately NOT the same predicate as `isGuarded`. Two paths are guarded
222
- * but ungrantable:
223
- * - governance state (`overrideEligible: false`) — else the override could
224
- * authorise rewriting its own audit trail;
225
- * - anything not guarded at THIS point — a grant for a ship-only path is
226
- * meaningless, because the hook already allows it, and would write a
227
- * misleading bypass row.
228
- * An unguarded path is likewise ineligible: nothing is blocking it, so there
229
- * is nothing to override (the lockout invariant).
230
- */
231
167
  /**
232
168
  * Human-readable path patterns guarded at `point`, for status and help output.
233
169
  * Derived rather than hand-written: a hardcoded string in `compose guard
@@ -237,11 +173,6 @@ export function guardedDisplaysFor(point) {
237
173
  return REGISTRY.filter((e) => e.enforcedBy.includes(point)).map((e) => e.display);
238
174
  }
239
175
 
240
- export function isOverrideEligible(path, opts) {
241
- const entry = matchEntry(path, opts);
242
- return entry !== null && entry.overrideEligible !== false;
243
- }
244
-
245
176
  /** The typed tools authorised to write `path` at `point`, or [] if unguarded. */
246
177
  export function toolsForPath(path, opts) {
247
178
  const entry = matchEntry(path, opts);
@@ -83,6 +83,10 @@ export async function preflightCodexWorktreeProbe({
83
83
  dataDir,
84
84
  ts,
85
85
  force = false,
86
+ // COMP-BUILD-CANCEL C36: the build-level cancel signal. A second abort source alongside
87
+ // this probe's own 180s timer, so an abort during the probe stops it instead of leaving
88
+ // the build unreachable for up to three minutes.
89
+ signal = null,
86
90
  }) {
87
91
  if (process.env.COMPOSE_SKIP_CODEX_PROBE) {
88
92
  return { ok: true, skipped: true, reason: 'COMPOSE_SKIP_CODEX_PROBE set — probe skipped' };
@@ -127,6 +131,9 @@ export async function preflightCodexWorktreeProbe({
127
131
  const controller = new AbortController();
128
132
  const timer = setTimeout(() => controller.abort(), PROBE_AGENT_TIMEOUT_MS);
129
133
  timer.unref?.();
134
+ const onBuildCancel = () => controller.abort();
135
+ if (signal?.aborted) controller.abort();
136
+ else signal?.addEventListener('abort', onBuildCancel, { once: true });
130
137
  try {
131
138
  await stratum.runAgentText('codex', prompt, {
132
139
  cwd: wtPath,
@@ -141,6 +148,7 @@ export async function preflightCodexWorktreeProbe({
141
148
  });
142
149
  } finally {
143
150
  clearTimeout(timer);
151
+ signal?.removeEventListener('abort', onBuildCancel);
144
152
  }
145
153
  } catch (err) {
146
154
  if (['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT'].includes(err?.code)) {
@@ -93,6 +93,129 @@ function discussionText(idea) {
93
93
  return `Recent discussion on ${idea.handle}:\n${lines.join('\n')}`;
94
94
  }
95
95
 
96
+ /**
97
+ * Project composed blocks into the shape Maya actually accepts.
98
+ *
99
+ * Maya's schema is flat `List[Dict[str, str]]` and `lib/maya-client.js` sends
100
+ * `channel_context` verbatim, so ANY extra key on a block is sent too. This
101
+ * exists because the route previously handed `context.blocks` straight to the
102
+ * client: once portfolio blocks gained a nested `source`, that field went to
103
+ * Maya unannounced — and the test that was supposed to catch it built the
104
+ * projection itself instead of reading what production sends, so it passed.
105
+ *
106
+ * The identity is not lost by the strip: `composePortfolioContext` writes it
107
+ * into `text` as well, precisely so it survives here.
108
+ *
109
+ * @param {Array<{author: string, text: string}>} blocks
110
+ * @returns {Array<{author: string, text: string}>}
111
+ */
112
+ export function toMayaContext(blocks) {
113
+ return (blocks ?? []).map(({ author, text }) => ({ author, text }));
114
+ }
115
+
116
+ /**
117
+ * Compose the per-turn context for a PORTFOLIO turn (COMP-FOH FOH-7).
118
+ *
119
+ * Separate from `composeColleagueContext` on purpose. The project-scoped path is
120
+ * unchanged and must stay byte-identical — this is an additional shape, not a
121
+ * widened one, so that a portfolio bug can never degrade the ordinary turn.
122
+ *
123
+ * THE TWO PROJECTIONS
124
+ * -------------------
125
+ * Each block carries a structured `source` for the panel, AND repeats that
126
+ * identity inside `text`. Both are required, for different consumers:
127
+ *
128
+ * - The panel groups by `block.source`, which it can only do if the field is
129
+ * structured.
130
+ * - Maya's schema is flat `List[Dict[str, str]]` and `lib/maya-client.js` sends
131
+ * `channel_context` verbatim, so a nested `source` would not survive. If the
132
+ * identity lived ONLY in that field, the flat projection would hand Maya two
133
+ * identical handles from two products with nothing to tell them apart — and
134
+ * a test asserting "no nested source reaches Maya" would pass while doing it.
135
+ * So the identity is written into the prose, where the flat projection keeps
136
+ * it.
137
+ *
138
+ * @param {{text: string}} turn
139
+ * @param {{recallAcross: Function, portfolio?: object, limit?: number}} deps
140
+ * @returns {Promise<{blocks: Array<{author, text, source}>, omissions: string[]}>}
141
+ */
142
+ export async function composePortfolioContext(turn, {
143
+ recallAcross, portfolio = null, limit, byteBudget = DEFAULT_BYTE_BUDGET,
144
+ } = {}) {
145
+ const query = String(turn?.text ?? '').trim();
146
+ const result = await recallAcross(portfolio, query, limit ? { limit } : {});
147
+
148
+ const omissions = [...result.omissions];
149
+
150
+ // A FAIR share per source, and every trim NAMED.
151
+ //
152
+ // Without a budget the portfolio silently answers for fewer products than it
153
+ // shows: Maya caps the context section and keeps a prefix, so with enough
154
+ // members the later products are discarded upstream while the panel still
155
+ // reports every source as sent. That is this feature's core failure mode
156
+ // reached from the inside — a partial answer that looks complete.
157
+ //
158
+ // Split evenly rather than first-come, because a budget consumed in order
159
+ // privileges whichever product happens to sort first and starves the rest
160
+ // for a reason no reader could infer.
161
+ const share = result.sources.length
162
+ ? Math.floor(byteBudget / result.sources.length)
163
+ : byteBudget;
164
+
165
+ const blocks = result.sources.map((source) => {
166
+ const how = source.listedNotSearched
167
+ ? 'listed, not searched — this product could not run a query'
168
+ : 'matching this question';
169
+ const header = `From ${source.id} (${source.root}) — ${how}:`;
170
+
171
+ const lines = [];
172
+ // The HEADER counts. With a small share (or a long root) the header alone
173
+ // could exceed it and still be emitted, so the "budget" was advisory — and
174
+ // the test that claimed it held allowed 20% overage, which made the claim
175
+ // unfalsifiable rather than true.
176
+ let used = Buffer.byteLength(header, 'utf8');
177
+ let dropped = 0;
178
+ const headerOverflowed = used > share;
179
+ for (const h of source.hits) {
180
+ const line = headline(h.record ?? h);
181
+ const cost = Buffer.byteLength(line, 'utf8') + 1;
182
+ if (used + cost > share) { dropped += 1; continue; }
183
+ lines.push(line);
184
+ used += cost;
185
+ }
186
+ if (dropped) {
187
+ omissions.push(
188
+ `${source.id} truncated to ${lines.length} of ${source.hits.length} results, over budget`,
189
+ );
190
+ } else if (headerOverflowed) {
191
+ // Nothing was dropped only because there was nothing to drop; the source
192
+ // still did not fit, and a silent over-budget source is the failure this
193
+ // budget exists to prevent.
194
+ omissions.push(`${source.id} over budget before any result could be included`);
195
+ }
196
+
197
+ // The body must be honest on its own. Omissions travel to the PANEL, not
198
+ // into Maya's context — so a source whose results were all dropped would
199
+ // otherwise tell the model "(nothing)", i.e. that this product has no
200
+ // matching ideas, which is the opposite of what happened.
201
+ const body = lines.length
202
+ ? lines.join('\n')
203
+ : (source.hits.length
204
+ ? `(${source.hits.length} result(s) omitted here — too large for this turn's share of the context)`
205
+ : '(nothing)');
206
+
207
+ return {
208
+ author: 'compose:portfolio',
209
+ // The source identity lives in the prose because the flat projection to
210
+ // Maya keeps only `author` and `text`.
211
+ text: `${header}\n${body}`,
212
+ source: { id: source.id, root: source.root },
213
+ };
214
+ });
215
+
216
+ return { blocks, omissions };
217
+ }
218
+
96
219
  /**
97
220
  * Compose the per-turn context for a colleague turn.
98
221
  *