@smartmemory/compose 0.4.0 → 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
@@ -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
  *
@@ -286,6 +286,14 @@ export class ConsumerMergeDecisionError extends ConsumerArtifactError {
286
286
  }
287
287
  }
288
288
 
289
+ /** Cancellation must never enter the merge-repair/revise decision channel. */
290
+ export class MergeAfterCancelError extends ConsumerArtifactError {
291
+ constructor(message) {
292
+ super('MERGE_AFTER_CANCEL', message);
293
+ this.name = 'MergeAfterCancelError';
294
+ }
295
+ }
296
+
289
297
  export class ConsumerFanoutArtifacts {
290
298
  constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest }) {
291
299
  this.runId = runId;
@@ -1165,7 +1173,7 @@ export class ConsumerFanoutArtifacts {
1165
1173
  });
1166
1174
  }
1167
1175
 
1168
- restoreMergeBaseline(transaction, audit) {
1176
+ restoreMergeBaseline(transaction, audit, opts = {}) {
1169
1177
  if (!transaction) return undefined;
1170
1178
  return this.#mutate(() => {
1171
1179
  // Re-find against the fresh journal — the passed reference may predate a
@@ -1176,6 +1184,7 @@ export class ConsumerFanoutArtifacts {
1176
1184
  restoreWorkingTree(this.targetCwd, tx.baselineTree);
1177
1185
  tx.state = wasBlocked ? 'blocked' : 'rolled_back';
1178
1186
  tx.rolledBackAt = now();
1187
+ tx.rollbackReason = opts.reason ?? null;
1179
1188
  tx.recovery ??= { baselineRestores: 0 };
1180
1189
  tx.recovery.baselineRestores = (tx.recovery.baselineRestores ?? 0) + 1;
1181
1190
  tx.recovery.baselineVerifiedTree = tx.baselineTree;
@@ -1214,6 +1223,20 @@ export class ConsumerFanoutArtifacts {
1214
1223
  });
1215
1224
  }
1216
1225
 
1226
+ /** C40: a failed restore callback writes nothing. Persist its finding through
1227
+ * a separate guarded mutation, reloading the durable transaction first. */
1228
+ markRollbackFailed(transaction, error) {
1229
+ if (!transaction) return undefined;
1230
+ return this.#mutate(() => {
1231
+ const tx = this.journal.mergeTransactions.find((entry) => entry.gateToken === transaction.gateToken);
1232
+ if (!tx) return undefined;
1233
+ tx.state = 'rollback_failed';
1234
+ tx.failureCode = 'merge_revert_failed';
1235
+ tx.failure = error?.message ?? String(error);
1236
+ return tx;
1237
+ });
1238
+ }
1239
+
1217
1240
  markGateResolved(transaction, outcome) {
1218
1241
  if (!transaction) return undefined;
1219
1242
  return this.#mutate(() => {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Parse markdown text containing ```decision fenced blocks.
3
+ * Returns { parts: Array<{ type: 'text'|'decision', content: string|object }> }.
4
+ *
5
+ * This module is shared by the shipped server and the browser source. Keep it
6
+ * under lib/ so npm installs do not need the otherwise development-only src/.
7
+ */
8
+ export function parseDecisionBlocks(text) {
9
+ const parts = [];
10
+ const regex = /```decision\n([\s\S]*?)```/g;
11
+ let lastIndex = 0;
12
+ let match;
13
+
14
+ while ((match = regex.exec(text)) !== null) {
15
+ if (match.index > lastIndex) {
16
+ parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
17
+ }
18
+
19
+ const raw = match[1].trim();
20
+ try {
21
+ parts.push({ type: 'decision', content: JSON.parse(raw) });
22
+ } catch {
23
+ parts.push({ type: 'text', content: raw });
24
+ }
25
+
26
+ lastIndex = match.index + match[0].length;
27
+ }
28
+
29
+ if (lastIndex < text.length) {
30
+ parts.push({ type: 'text', content: text.slice(lastIndex) });
31
+ }
32
+
33
+ if (parts.length === 0) {
34
+ parts.push({ type: 'text', content: text });
35
+ }
36
+
37
+ return { parts };
38
+ }
@@ -58,6 +58,12 @@ const EVENT_FIELDS = {
58
58
  'build_id', 'feature_code', 'step_id', 'attempt', 'model',
59
59
  'effort_intended', 'effort_executed', 'tokens_in', 'tokens_out',
60
60
  'tokens_total', 'usd', 'duration_ms', 'note',
61
+ // COMP-BUILD-CANCEL S03-6 (C44): compose's DERIVED transport for the run —
62
+ // provider codex plus a cancellationId sent implies exec. Never `transport`:
63
+ // stratum reports none on its ConnectorResult, so this is a rule, not an
64
+ // observation. Registered here AND in the `dispatch` validator below, because
65
+ // this list is an allow-list and an unknown field drops the whole event.
66
+ 'transport_derived',
61
67
  ],
62
68
  },
63
69
  settlement: {
@@ -146,6 +152,7 @@ function validateEvent(event, { allowExtra = false } = {}) {
146
152
  optionalNullableNumber(event, field);
147
153
  }
148
154
  optionalString(event, 'note');
155
+ optionalNullableString(event, 'transport_derived');
149
156
  break;
150
157
  case 'settlement':
151
158
  requireString(event, 'dispatch_id');
@@ -20,9 +20,17 @@
20
20
  */
21
21
 
22
22
  import { existsSync, readFileSync } from 'node:fs';
23
- import { join } from 'node:path';
23
+ import { join, resolve } from 'node:path';
24
24
 
25
25
  import { getSmartmemoryConfig } from '../smartmemory-config.js';
26
+
27
+ /**
28
+ * Well under the substrate's own membership cap of 100
29
+ * (`auth_repository.py:730`). A portfolio turn costs one round trip per member
30
+ * with no server-side batching, so the practical ceiling is latency, not the
31
+ * substrate.
32
+ */
33
+ const MAX_PORTFOLIO_MEMBERS = 16;
26
34
  import { FluidConfigError, MUTATION_SCOPE, mutationScopeAtLeast } from './provider.js';
27
35
  import { LocalFluidProvider } from './local-provider.js';
28
36
  import { SmartMemoryFluidProvider } from './smartmemory-provider.js';
@@ -74,6 +82,109 @@ function loadFluidConfig(cwd) {
74
82
  return fluid;
75
83
  }
76
84
 
85
+ /**
86
+ * @typedef {object} PortfolioMember
87
+ * @property {string} id the label the user chose; unique within the portfolio
88
+ * @property {string} root absolute path to that member's Compose project
89
+ */
90
+
91
+ /**
92
+ * Parse and validate `fluid.portfolio` (FOH-7).
93
+ *
94
+ * Read HERE, in the authoritative validating reader, and deliberately not in
95
+ * `lib/maya-config.js`: that one swallows a malformed config as `{}`, so a
96
+ * portfolio with a typo in it would come back as "no portfolio declared" and the
97
+ * turn would quietly answer for one product instead of refusing. A config with
98
+ * two readers where only one validates is how a misconfiguration becomes a
99
+ * silent downgrade.
100
+ *
101
+ * @param {string} cwd the declaring root
102
+ * @returns {{members: PortfolioMember[]} | null} null when none is declared
103
+ */
104
+ export function parsePortfolioConfig(cwd) {
105
+ const fluid = loadFluidConfig(cwd);
106
+ const portfolio = fluid.portfolio;
107
+ if (portfolio === undefined || portfolio === null) return null;
108
+
109
+ const where = join(cwd, '.compose/compose.json');
110
+ if (typeof portfolio !== 'object' || Array.isArray(portfolio)) {
111
+ throw new FluidConfigError(
112
+ `compose: fluid.portfolio at ${where} must be an object`, { path: where },
113
+ );
114
+ }
115
+ const declared = portfolio.members;
116
+ if (!Array.isArray(declared) || declared.length === 0) {
117
+ throw new FluidConfigError(
118
+ `compose: fluid.portfolio.members at ${where} must be a non-empty array — ` +
119
+ `a portfolio with no members is a portfolio that cannot answer anything`,
120
+ { path: where },
121
+ );
122
+ }
123
+ if (declared.length > MAX_PORTFOLIO_MEMBERS) {
124
+ throw new FluidConfigError(
125
+ `compose: fluid.portfolio.members at ${where} declares ${declared.length} members, ` +
126
+ `over the limit of ${MAX_PORTFOLIO_MEMBERS}`,
127
+ { path: where },
128
+ );
129
+ }
130
+
131
+ const members = [];
132
+ const seen = new Set();
133
+ for (const entry of declared) {
134
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
135
+ throw new FluidConfigError(
136
+ `compose: every fluid.portfolio.members entry at ${where} must be an object`, { path: where },
137
+ );
138
+ }
139
+ const id = typeof entry.id === 'string' ? entry.id.trim() : '';
140
+ const rawRoot = typeof entry.root === 'string' ? entry.root.trim() : '';
141
+ if (!id || !rawRoot) {
142
+ throw new FluidConfigError(
143
+ `compose: every fluid.portfolio.members entry at ${where} needs a non-empty "id" and "root"`,
144
+ { path: where },
145
+ );
146
+ }
147
+ if (seen.has(id)) {
148
+ throw new FluidConfigError(
149
+ `compose: fluid.portfolio.members at ${where} declares "${id}" twice — ` +
150
+ `ids label the source of every result, so a duplicate makes the answer ambiguous`,
151
+ { path: where, id },
152
+ );
153
+ }
154
+ seen.add(id);
155
+
156
+ const root = resolve(cwd, rawRoot);
157
+ // A member must be a Compose project, and `.compose/compose.json` is what
158
+ // makes it one. Accepting a bare `.compose/` directory would fall through to
159
+ // the local provider and contribute an empty corpus — indistinguishable, in
160
+ // the answer, from a real product that happens to have no ideas.
161
+ if (!existsSync(join(root, '.compose/compose.json'))) {
162
+ throw new FluidConfigError(
163
+ `compose: fluid.portfolio member "${id}" at ${where} points at ${root}, ` +
164
+ `which is not a Compose project (no .compose/compose.json)`,
165
+ { path: where, id, root },
166
+ );
167
+ }
168
+ members.push({ id, root });
169
+ }
170
+
171
+ // Self-membership is never INFERRED — that would be the discovery D-FOH-7-2
172
+ // forbids. But a portfolio that omits the corpus the user is looking at
173
+ // silently answers without it, so its absence is refused by name rather than
174
+ // producing a quietly smaller result.
175
+ const declaringRoot = resolve(cwd);
176
+ if (!members.some((m) => m.root === declaringRoot)) {
177
+ throw new FluidConfigError(
178
+ `compose: fluid.portfolio at ${where} does not list its own declaring root. ` +
179
+ `Membership is never inferred, so this project would be excluded from its own ` +
180
+ `portfolio — add an entry with "root": "." if that is not what you meant`,
181
+ { path: where, declaringRoot },
182
+ );
183
+ }
184
+
185
+ return { members };
186
+ }
187
+
77
188
  /**
78
189
  * Construct the configured provider.
79
190
  *