@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
@@ -68,24 +68,46 @@ function _guardOn(capsOverride) {
68
68
  try { return loadProjectConfig()?.capabilities?.guard === true; } catch { return false; }
69
69
  }
70
70
 
71
- /** True iff a valid, non-agent-mintable override token accompanies the call. */
72
- function _overrideOk(args) {
73
- const expected = process.env.STRATUM_GUARD_OVERRIDE_TOKEN;
74
- return !!expected && args?.override_token === expected;
75
- }
71
+ /**
72
+ * COMP-MCP-ENFORCE Slice 3, amended 2026-09-07: the override token is GONE.
73
+ *
74
+ * These gates used to admit a caller who supplied `override_token` matching this
75
+ * server process's `STRATUM_GUARD_OVERRIDE_TOKEN`. The audit in
76
+ * docs/decisions/2026-09-07-override-token-audit.md removed it, on evidence
77
+ * rather than on threat-modelling taste:
78
+ *
79
+ * - the hatch had no user and could not have one — the variable was unset in
80
+ * every environment we ship, and `override_token` appeared in no MCP tool
81
+ * schema, so no agent could discover it and no operator workflow used it;
82
+ * - both capabilities it nominally unlocked already have first-class doors:
83
+ * KILLED through the guarded lifecycle route (`kill_feature`), COMPLETE
84
+ * through the completion gate (which `lib/feature-writer.js` enforces
85
+ * unconditionally anyway);
86
+ * - what remained was `force`, i.e. skipping the roadmap transition table and
87
+ * the prose-loss and duplicate-match refusals — the thing these gates exist
88
+ * to stop, kept reachable by a secret harder to use than editing the file;
89
+ * - and it was never the real protection. Anything that can call these tools
90
+ * can write the files directly. The tamper-EVIDENT ledger and the pre-push
91
+ * canon guard are what hold; this gate only ever stopped a well-meaning
92
+ * agent from casually passing force:true, and a plain refusal does that
93
+ * better than a secret nobody can hold.
94
+ *
95
+ * If a break-glass path is ever genuinely needed, the answer is stratum's signed
96
+ * one-shot authorization (`stratum/ts/src/guard/authorization.ts`), not a shared
97
+ * secret — and the trigger for building it is a real incident where someone hit
98
+ * one of these refusals with nowhere to go.
99
+ */
76
100
 
77
101
  export function assertForceAuthorized(args, toolName, capsOverride) {
78
102
  if (!args?.force) return;
79
103
  if (!_guardOn(capsOverride)) return;
80
- if (!_overrideOk(args)) {
81
- const e = new Error(
82
- `${toolName}: force is disabled under capabilities.guard supply a valid override_token ` +
83
- `(out-of-band STRATUM_GUARD_OVERRIDE_TOKEN; not agent-mintable) to deviate, or drive the ` +
84
- `change through the lifecycle.`,
85
- );
86
- e.code = 'FORCE_REQUIRES_OVERRIDE';
87
- throw e;
88
- }
104
+ const e = new Error(
105
+ `${toolName}: force is disabled under capabilities.guard. There is no override token — ` +
106
+ `drive the change through the lifecycle (/lifecycle routes, kill_feature) or the completion ` +
107
+ `gate (record_completion). If neither can express it, that is a gap to fix, not to bypass.`,
108
+ );
109
+ e.code = 'FORCE_REQUIRES_OVERRIDE';
110
+ throw e;
89
111
  }
90
112
 
91
113
  /**
@@ -103,15 +125,13 @@ export function assertTerminalStatusAuthorized(args, toolName, capsOverride) {
103
125
  const status = args?.status;
104
126
  if (!status || !LIFECYCLE_OWNED_STATUS.has(status)) return;
105
127
  if (!_guardOn(capsOverride)) return;
106
- if (!_overrideOk(args)) {
107
- const e = new Error(
108
- `${toolName}: status ${status} is lifecycle-owned under capabilities.guard drive it through ` +
109
- `/lifecycle (evidence-gated for complete, guarded for kill) instead of setting it directly, ` +
110
- `or supply a valid override_token.`,
111
- );
112
- e.code = 'STATUS_OWNED_BY_LIFECYCLE';
113
- throw e;
114
- }
128
+ const e = new Error(
129
+ `${toolName}: status ${status} is lifecycle-owned under capabilities.guard — drive it through ` +
130
+ `/lifecycle (evidence-gated for complete, guarded for kill) instead of setting it directly. ` +
131
+ `There is no override token.`,
132
+ );
133
+ e.code = 'STATUS_OWNED_BY_LIFECYCLE';
134
+ throw e;
115
135
  }
116
136
  import { resolveWorkspace } from '../lib/resolve-workspace.js';
117
137
  import { discoverWorkspaces } from '../lib/discover-workspaces.js';
@@ -474,29 +494,6 @@ export async function toolAddChangelogEntry(args) {
474
494
  return addChangelogEntry(getTargetRoot(), args);
475
495
  }
476
496
 
477
- /**
478
- * COMP-CANON-OVERRIDE — mint a single-use, path-scoped canon override.
479
- *
480
- * `actor` is deliberately not forwarded from args: it is stamped by the writer
481
- * per Decision 3 and must never be caller-supplied.
482
- */
483
- export async function toolCanonOverrideGrant(args) {
484
- const { mintGrant } = await import('../lib/canon-override.js');
485
- const { loadFeaturesDir } = await import('../lib/project-paths.js');
486
- const root = getTargetRoot();
487
- const grant = mintGrant(root, {
488
- path: args?.path,
489
- reason: args?.reason,
490
- operation: args?.operation,
491
- featuresDir: loadFeaturesDir(root),
492
- });
493
- return {
494
- ...grant,
495
- recorded_in: '.compose/canon-overrides.jsonl',
496
- note: 'Single-use and path-scoped. Audit tooling for the Claude Write/Edit path, not enforcement.',
497
- };
498
- }
499
-
500
497
  export async function toolGetChangelogEntries(args) {
501
498
  const { getChangelogEntries } = await import('../lib/changelog-writer.js');
502
499
  return getChangelogEntries(getTargetRoot(), args);
@@ -1002,8 +999,10 @@ function _targetMatchesBoundFeature(tool, args) {
1002
999
 
1003
1000
  /**
1004
1001
  * Throw PHASE_TOOL_DENIED if the tool is not allowed for the current
1005
- * profile×phase. No-op when the capability is off (default) or on a valid
1006
- * override token. On unresolved CONTEXT the behavior is graduated, NOT blanket
1002
+ * profile×phase. No-op when the capability is off (default). The override-token
1003
+ * escape was REMOVED 2026-09-07 with the other two (see the note above
1004
+ * `assertForceAuthorized`): same secret, same absence of any caller who could
1005
+ * hold it. On unresolved CONTEXT the behavior is graduated, NOT blanket
1007
1006
  * fail-open: an unresolved PROFILE (no/unknown env) normalizes to orchestrator →
1008
1007
  * unrestricted; an unresolved PHASE only fails open the phase *refinement* — the
1009
1008
  * profile BASE policy (implementer deny / reviewer allowlist) still applies
@@ -1013,7 +1012,6 @@ function _targetMatchesBoundFeature(tool, args) {
1013
1012
  export function assertToolPhaseAllowed(tool, args = {}, _testCtx) {
1014
1013
  const guardOn = _testCtx?.phaseScopedTools ?? (loadProjectConfig()?.capabilities?.phaseScopedTools === true);
1015
1014
  if (!guardOn) return;
1016
- if (_overrideOk(args)) return;
1017
1015
 
1018
1016
  const profile = _testCtx?.profile ?? sessionContext().profile;
1019
1017
  const phase = _testCtx?.phase ?? resolveBoundPhase();
@@ -1024,7 +1022,7 @@ export function assertToolPhaseAllowed(tool, args = {}, _testCtx) {
1024
1022
  const e = new Error(
1025
1023
  `${tool} is not available to profile '${profile}'` +
1026
1024
  (phase ? ` in phase '${phase}'` : '') + `: ${verdict.reason}. ` +
1027
- `Supply a valid override_token to deviate.`,
1025
+ `There is no override token; use a session bound to a profile that owns this tool.`,
1028
1026
  );
1029
1027
  e.code = 'PHASE_TOOL_DENIED';
1030
1028
  e.profile = profile;
@@ -55,7 +55,6 @@ import {
55
55
  toolGetFeatureLinks,
56
56
  toolProposeFollowup,
57
57
  toolAddChangelogEntry,
58
- toolCanonOverrideGrant,
59
58
  toolGetChangelogEntries,
60
59
  toolWriteJournalEntry,
61
60
  toolGetJournalEntries,
@@ -175,7 +174,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
175
174
  case 'get_feature_artifacts': result = await toolGetFeatureArtifacts(args); break;
176
175
  case 'get_feature_links': result = await toolGetFeatureLinks(args); break;
177
176
  case 'add_changelog_entry': result = await toolAddChangelogEntry(args); break;
178
- case 'canon_override_grant': result = await toolCanonOverrideGrant(args); break;
179
177
  case 'get_changelog_entries': result = await toolGetChangelogEntries(args); break;
180
178
  case 'write_journal_entry': result = await toolWriteJournalEntry(args); break;
181
179
  case 'get_journal_entries': result = await toolGetJournalEntries(args); break;
@@ -13,7 +13,7 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { randomUUID } from 'node:crypto';
16
- import { parseDecisionBlocks } from '../src/components/vision/designSessionState.js';
16
+ import { parseDecisionBlocks } from '../lib/decision-blocks.js';
17
17
  import { StratumMcpClient } from '../lib/stratum-mcp-client.js';
18
18
  import { KNOWN_VERSIONS } from '../lib/build-stream-schema.js';
19
19
  import { getTargetRoot, resolveProjectPath, trackProjectWork } from './project-root.js';
@@ -288,6 +288,20 @@ export class FileWatcherServer {
288
288
 
289
289
  onChanged(relativePath, fullPath);
290
290
  });
291
+ // A watcher can die AFTER construction, and fs.watch reports that as an
292
+ // 'error' event, not a throw — with no handler Node has historically
293
+ // treated it as unhandled. Either way the failure was completely silent:
294
+ // the pane simply stops updating.
295
+ //
296
+ // Deliberately NOT given the backstop poll that cc-session-watcher and
297
+ // the build-stream bridge now carry. Those lose data that never returns
298
+ // (branch DecisionEvents, a build's live output); this one loses a
299
+ // hot-reload, and the REST path already serves current content on load,
300
+ // so a refresh recovers it. A recursive re-stat of docs/** on a timer is
301
+ // real cost for a recoverable symptom. Logged, so it stops being silent.
302
+ watcher.on?.('error', (err) => {
303
+ console.error(`[file-watcher] watch on ${prefix}/ died — changes there stop live-updating until restart: ${err?.message}`);
304
+ });
291
305
  this.watchers.push(watcher);
292
306
  } catch (err) {
293
307
  console.error(`[file-watcher] Failed to watch ${prefix}/:`, err.message);
@@ -51,6 +51,7 @@ import {
51
51
  IdeaboxRenderFailed,
52
52
  } from '../lib/fluid/ideabox-ops.js'
53
53
  import { writeIdeaboxProjection } from '../lib/fluid/render-ideabox.js'
54
+ import { IdeaboxMigrationConflict, IdeaboxUnreadable } from '../lib/fluid/ideabox-migrate.js'
54
55
  import { ideaboxView, toClientIdeaWith } from '../lib/fluid/ideabox-view.js'
55
56
  import { relForDisplay } from '../lib/project-paths.js'
56
57
 
@@ -109,6 +110,12 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, broadcastMessage }) {
109
110
  if (err instanceof IdeaboxNotFound) return res.status(404).json({ error: err.message, code: err.code })
110
111
  if (err instanceof IdeaboxInvalid) return res.status(400).json({ error: err.message, code: err.code, field: err.field })
111
112
  if (err instanceof IdeaboxConflict) return res.status(409).json({ error: err.message, code: err.code })
113
+ // The migration gate's two refusals. Both mean "the file on disk is in a
114
+ // state this operation must not write over", which is a conflict, not a
115
+ // server fault — and the client needs the code to say so.
116
+ if (err instanceof IdeaboxMigrationConflict || err instanceof IdeaboxUnreadable) {
117
+ return res.status(409).json({ error: err.message, code: err.code })
118
+ }
112
119
  return res.status(500).json({ error: err.message })
113
120
  }
114
121
  }
@@ -214,6 +221,9 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, broadcastMessage }) {
214
221
  app.post('/api/ideabox/render', async (_req, res) => {
215
222
  await send(res, async () => {
216
223
  const ctx = await context()
224
+ // The migration gate is no longer applied here: it lives inside
225
+ // `writeIdeaboxProjection`, which every projection write goes through.
226
+ // This route reached that writer without a guard once already.
217
227
  await writeIdeaboxProjection(ctx.provider, ctx.ideaboxPath)
218
228
  return { body: { ok: true } }
219
229
  })
package/server/index.js CHANGED
@@ -222,7 +222,11 @@ const _distExists = () => {
222
222
  catch { return false; }
223
223
  };
224
224
 
225
- app.use(express.static(_distDir, { index: false }));
225
+ // Source checkouts keep Vite/HMR on :5195. Published installs have no Vite or
226
+ // src/ by design, so compose start serves the prebuilt desktop shell on :4001.
227
+ app.use(express.static(_distDir, {
228
+ index: process.env.COMPOSE_PACKAGED_UI === '1' ? 'index.html' : false,
229
+ }));
226
230
 
227
231
  // /m/* SPA fallback — paths matching /m or /m/...
228
232
  app.get(/^\/m(\/|$)/, (_req, res) => {
@@ -70,6 +70,19 @@ export const isGuardError = (result) => !result || Boolean(result.error) || resu
70
70
  export const guardErrorType = (result) => (result && (result.error?.code ?? result.error_type)) ?? null;
71
71
  export const guardErrorMessage = (result) => (result && (result.error?.message ?? result.message)) ?? 'no guard response';
72
72
 
73
+ /**
74
+ * Infrastructure failures: the guard was never reached, or never answered, so
75
+ * the evidence was NOT evaluated. Distinct from a refusal (the guard ran and
76
+ * said no) and from a policy error (the guard ran and could not apply). Routes
77
+ * must not render these as "refused by guard" — for two months a timed-out
78
+ * `guard transition` was reported to the user as a rejection of their evidence
79
+ * (stratum-client.js, f7865d4), and nothing at the surface could tell the two
80
+ * apart.
81
+ */
82
+ const GUARD_INFRA_CODES = new Set(['TIMEOUT', 'SPAWN', 'GUARD_UNREACHABLE', 'PARSE_ERROR', 'UNKNOWN']);
83
+ export const isGuardInfraError = (result) =>
84
+ !!result && result.applied !== true && result.refused !== true && GUARD_INFRA_CODES.has(guardErrorType(result));
85
+
73
86
  /**
74
87
  * Assemble the FULL guarded graph the design requires: the forward
75
88
  * `BASE_TRANSITIONS` PLUS the `ship → complete` edge and a `<any non-terminal>
@@ -44,7 +44,9 @@ import {
44
44
  MayaWorkspaceCollisionError,
45
45
  } from '../lib/maya-identity.js';
46
46
  import { ideaboxContext } from '../lib/fluid/ideabox-ops.js';
47
- import { composeColleagueContext } from '../lib/colleague/context.js';
47
+ import { composeColleagueContext, composePortfolioContext, toMayaContext } from '../lib/colleague/context.js';
48
+ import { openPortfolio, recallAcrossPortfolio, assertMemberWorkspacesDistinct } from '../lib/fluid/portfolio.js';
49
+ import { parsePortfolioConfig } from '../lib/fluid/factory.js';
48
50
  import { writebackReply } from '../lib/colleague/writeback.js';
49
51
 
50
52
  /**
@@ -67,11 +69,51 @@ const CAPABILITIES = Object.freeze({
67
69
  // `author:'maya'` plus the embedded msg marker. `ui:ideabox` is the cockpit
68
70
  // door these contexts genuinely come through; a colleague-specific origin
69
71
  // joins the contract enum only when the panel gains a record-creating op.
70
- async function defaultComposeContext(root, { focusId }) {
72
+ async function defaultComposeContext(root, { focusId, scope, text }) {
73
+ if (scope === 'portfolio') {
74
+ // The declaring root's ideabox context is NOT built here. A portfolio turn
75
+ // reads through the members' own providers, so constructing this first made
76
+ // a failure in the declaring root's provider abort the turn before a single
77
+ // member was asked — the one product being broken silencing the other N,
78
+ // which is the whole failure mode this feature exists to avoid.
79
+ // The portfolio path is a separate composer, not a widened one: the
80
+ // project-scoped turn must stay byte-identical so a portfolio bug can never
81
+ // degrade the ordinary one.
82
+ const portfolio = await openPortfolio(root);
83
+ return composePortfolioContext({ text }, { portfolio, recallAcross: recallAcrossPortfolio });
84
+ }
71
85
  const ctx = await ideaboxContext(root, { origin: 'ui:ideabox' });
72
86
  return composeColleagueContext(ctx, { focusId });
73
87
  }
74
88
 
89
+ /**
90
+ * Is a portfolio declared here at all, and is it valid?
91
+ *
92
+ * Read through the AUTHORITATIVE validating parser, never the lenient
93
+ * `maya-config` reader: a portfolio with a typo in it must refuse, not come back
94
+ * as "none declared" and quietly answer for one product.
95
+ */
96
+ function portfolioDeclared(root) {
97
+ try {
98
+ return parsePortfolioConfig(root) !== null;
99
+ } catch {
100
+ // Invalid is not absent. Let the caller name the real reason.
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /** The exact reason, so the funnel points at the setting that is actually wrong. */
106
+ function portfolioMisconfigReason(root) {
107
+ try {
108
+ if (parsePortfolioConfig(root) === null) {
109
+ return 'this project declares no fluid.portfolio, so a portfolio turn has no members to ask';
110
+ }
111
+ } catch (e) {
112
+ return `fluid.portfolio is declared but invalid — ${shortReason(e)}`;
113
+ }
114
+ return 'fluid.portfolio is declared but unusable';
115
+ }
116
+
75
117
  /** The real write-back (S4): reconcile-then-append through the shared ops
76
118
  * module. Returns an OUTCOME, never throws (lib/colleague/writeback.js). */
77
119
  async function defaultPerformWriteback(root, args) {
@@ -168,6 +210,54 @@ export function attachMayaRoutes(app, {
168
210
  }
169
211
  const focusId = req.body?.focusId ? String(req.body.focusId) : null;
170
212
 
213
+ // A CLOSED enum. Without this a typo ('portoflio') falls through to a
214
+ // project-scoped answer that looks exactly like a correct one — the silent
215
+ // downgrade this feature exists to refuse, arriving through the door left
216
+ // open by not checking.
217
+ // Validated as the RAW value. Coercing first made `null` look like "absent"
218
+ // (silently selecting project scope) and turned `["portfolio"]` into the
219
+ // string "portfolio" — both of which are the silent widening/downgrade this
220
+ // enum exists to refuse, arriving through the coercion rather than the check.
221
+ const rawScope = req.body?.scope;
222
+ const turnScope = rawScope === undefined ? undefined : rawScope;
223
+ if (turnScope !== undefined && turnScope !== 'project' && turnScope !== 'portfolio') {
224
+ return {
225
+ errorBody: {
226
+ ok: false,
227
+ error: {
228
+ kind: 'invalid',
229
+ message: `unknown scope ${JSON.stringify(turnScope)} — expected "project" or "portfolio"`,
230
+ },
231
+ },
232
+ };
233
+ }
234
+ if (turnScope === 'portfolio' && focusId) {
235
+ return {
236
+ errorBody: {
237
+ ok: false,
238
+ error: {
239
+ kind: 'invalid',
240
+ message: 'a portfolio turn spans products and cannot also be focused on one idea',
241
+ },
242
+ },
243
+ };
244
+ }
245
+
246
+ if (turnScope === 'portfolio' && !portfolioDeclared(root)) {
247
+ // Named, never a silent downgrade. Without its own branch this surfaces as
248
+ // the generic `context` funnel, which tells the user nothing about
249
+ // membership — and a portfolio question answered for one product looks
250
+ // exactly like a correct answer.
251
+ return {
252
+ errorBody: {
253
+ ok: false,
254
+ error: {
255
+ kind: 'misconfigured',
256
+ message: portfolioMisconfigReason(root),
257
+ },
258
+ },
259
+ };
260
+ }
171
261
  try {
172
262
  if (!hasSmartmemoryFluidProvider(root)) {
173
263
  return { errorBody: { ok: false, error: { kind: 'connect-smartmemory' } } };
@@ -204,11 +294,25 @@ export function attachMayaRoutes(app, {
204
294
  await ensureIdentity(root, { smBaseUrl: getSmartmemoryConfig(root)?.baseUrl, mode });
205
295
  }
206
296
 
297
+ // BEFORE composition, not after. Composition is what fans out to the
298
+ // members, so checking afterwards let a member configured at Maya's own
299
+ // workspace be READ and only then refused — the guard reported a refusal
300
+ // for an access that had already happened, which is not a guard.
301
+ if (turnScope === 'portfolio') {
302
+ try {
303
+ assertMemberWorkspacesDistinct(root, workspaceClaimOf(identity));
304
+ } catch (e) {
305
+ return {
306
+ errorBody: { ok: false, error: { kind: 'workspace-collision', message: e.message } },
307
+ };
308
+ }
309
+ }
310
+
207
311
  // Context composition is load-bearing: a failure here is a funnel, never
208
312
  // a silent fall-through to plain chat (COLLEAGUE-ALL-IN).
209
313
  let context;
210
314
  try {
211
- context = await composeContext(root, { focusId });
315
+ context = await composeContext(root, { focusId, scope: turnScope, text });
212
316
  } catch (e) {
213
317
  return {
214
318
  errorBody: {
@@ -315,7 +419,7 @@ export function attachMayaRoutes(app, {
315
419
 
316
420
  if (req.query?.stream !== '1') {
317
421
  try {
318
- const reply = await client.chat({ message: text, channelContext: context.blocks });
422
+ const reply = await client.chat({ message: text, channelContext: toMayaContext(context.blocks) });
319
423
 
320
424
  // Write-back (S4): the chat result is AUTHORITATIVE — her reply renders
321
425
  // whatever happens here, and a write-back failure is an outcome field,
@@ -332,7 +436,7 @@ export function attachMayaRoutes(app, {
332
436
  memory_available: reply.memory_available ?? null,
333
437
  writeback,
334
438
  context: {
335
- sent: context.blocks.map((b) => b.author),
439
+ sent: [...new Set(context.blocks.map((b) => b.author))],
336
440
  omissions: context.omissions,
337
441
  // The composed blocks themselves — the panel's findings accordion
338
442
  // renders these (design §4); authors carry the provenance labels.
@@ -382,7 +486,7 @@ export function attachMayaRoutes(app, {
382
486
  try {
383
487
  const reply = await client.chatStream({
384
488
  message: text,
385
- channelContext: context.blocks,
489
+ channelContext: toMayaContext(context.blocks),
386
490
  onToken: (token) => {
387
491
  openStream();
388
492
  writeEvent('token', { text: token });
@@ -395,7 +499,7 @@ export function attachMayaRoutes(app, {
395
499
  message_id: reply.message_id,
396
500
  memory_available: reply.memory_available ?? null,
397
501
  context: {
398
- sent: context.blocks.map((b) => b.author),
502
+ sent: [...new Set(context.blocks.map((b) => b.author))],
399
503
  omissions: context.omissions,
400
504
  blocks: context.blocks,
401
505
  },
@@ -527,31 +527,6 @@ export const TOOLS = [
527
527
  },
528
528
  },
529
529
  },
530
- // -------------------------------------------------------------------------
531
- // Canon override — COMP-CANON-OVERRIDE (COMP-CANON-GUARD Decision 4)
532
- // -------------------------------------------------------------------------
533
- {
534
- name: 'canon_override_grant',
535
- effect: 'mutating',
536
- writes: ["override-ledger", "override-attest", "override-grants"],
537
- description:
538
- 'Mint a single-use, path-scoped grant permitting ONE direct write to a guarded canon path. '
539
- + 'The bypass row is appended to .compose/canon-overrides.jsonl BEFORE the grant exists, so a grant '
540
- + 'cannot be unrecorded. The token expires in 5 minutes and is burned by the first write. '
541
- + 'Governance state (the bypass ledger, its baseline, the grant directory) is deliberately NOT grantable. '
542
- + 'SCOPE: this is audit and careless-drift tooling for the Claude Write/Edit path — it is not enforcement. '
543
- + 'Bash and Codex writes never reach the guard, and `operation` is a declared label recorded for later '
544
- + 'analysis, never verified against the write that follows.',
545
- inputSchema: {
546
- type: 'object',
547
- required: ['path', 'reason'],
548
- properties: {
549
- path: { type: 'string', description: 'Repo-relative path to grant one write for. Must be guarded at the write-time hook and override-eligible.' },
550
- reason: { type: 'string', description: 'Why the bypass is justified. Empty or whitespace-only is rejected — the recorded reason is the point.' },
551
- operation: { type: 'string', description: 'Caller-declared intent label (e.g. "repair-malformed-record"). Recorded for analysis; unverifiable by construction.' },
552
- },
553
- },
554
- },
555
530
  {
556
531
  name: 'get_changelog_entries',
557
532
  effect: 'read',
@@ -26,18 +26,12 @@ export const SETUP_TOOLS = new Set([
26
26
  /**
27
27
  * Management/approval/completion tools an implementer context must not wield.
28
28
  *
29
- * COMP-COVERAGE-GATE (2026-08-24) added the last two, found by
29
+ * COMP-COVERAGE-GATE (2026-08-24) added `roadmap_xref_push`, found by
30
30
  * `checkAuthorizationCoverage`'s C4 check — mutating tools this list had never
31
- * been asked about. Both postdate COMP-MCP-ENFORCE-1's charter ("cannot
32
- * self-approve, self-complete, or mutate roadmap status") and no design ever
33
- * ruled that an implementer may call them:
31
+ * been asked about. It postdates COMP-MCP-ENFORCE-1's charter ("cannot
32
+ * self-approve, self-complete, or mutate roadmap status"), and no design ever
33
+ * ruled that an implementer may call it:
34
34
  *
35
- * - `canon_override_grant` — the escape hatch FROM canon enforcement was
36
- * callable by the profile SUBJECT to it. COMP-CANON-OVERRIDE already reasoned
37
- * that the override must not be grantable for its own governance state
38
- * (`overrideEligible: false`); this is the same argument one level up, at the
39
- * caller instead of the target. An implementer that hits a canon block must
40
- * escalate, not self-authorise.
41
35
  * - `roadmap_xref_push` — writes EXTERNAL trackers (github issues, sibling
42
36
  * repos). An implementer session should not be reaching outside the repo.
43
37
  *
@@ -47,8 +41,8 @@ export const SETUP_TOOLS = new Set([
47
41
  * recorded decision, not an omission. That ruling is now recorded machine-side
48
42
  * in `C4_EXCEPTIONS` (lib/coverage-gate.js) so the gate stops re-raising it.
49
43
  *
50
- * Verified before adding the two: no pipeline spec, prompt template or server
51
- * flow invokes either from an implementer-profile session.
44
+ * Verified before adding it: no pipeline spec, prompt template or server flow
45
+ * invokes it from an implementer-profile session.
52
46
  */
53
47
  const IMPLEMENTER_DENY = [
54
48
  'approve_gate', 'complete_feature', 'kill_feature',
@@ -56,7 +50,6 @@ const IMPLEMENTER_DENY = [
56
50
  // COMP-LIFECYCLE-BACKFILL: a completion is a management act, backfilled or not.
57
51
  'backfill_completion',
58
52
  // ── added by COMP-COVERAGE-GATE C4 ──
59
- 'canon_override_grant',
60
53
  'roadmap_xref_push',
61
54
  ];
62
55
 
@@ -2,21 +2,26 @@
2
2
  * model-tiers.js — Model tier routing for STRAT-TIER.
3
3
  *
4
4
  * Maps symbolic tier names to provider-specific model IDs.
5
- * Tiers let pipeline specs declare intent (critical / standard / fast)
6
- * without hard-coding model strings — the map here is the single source of truth.
5
+ * Tiers let pipeline specs declare intent (critical / standard / fast / coordinator)
6
+ * without hard-coding model strings — the map here is the single source of truth,
7
+ * including the agent-string tier allow-list. Coordinator lets one preset role
8
+ * explicitly name Fable while critical stays Opus 5; other presets do not move
9
+ * silently to Fable.
7
10
  */
8
11
 
9
12
  /** @type {Record<string, string>} */
10
13
  export const MODEL_TIERS = {
11
- critical: 'claude-opus-4-7',
12
- standard: 'claude-sonnet-4-6',
14
+ critical: 'claude-opus-5',
15
+ standard: 'claude-sonnet-5',
13
16
  fast: 'claude-haiku-4-5-20251001',
17
+ coordinator: 'claude-fable-5-1',
14
18
  };
15
19
 
16
20
  export const CODEX_MODEL_TIERS = {
17
21
  critical: 'gpt-6-astra',
18
22
  standard: 'gpt-5.6-terra',
19
23
  fast: 'gpt-5.3-codex-spark',
24
+ coordinator: null,
20
25
  };
21
26
 
22
27
  // C12: codex efforts follow the routing convention — `low` is for trivial
@@ -26,11 +31,13 @@ const CODEX_TIER_THINKING = {
26
31
  critical: { mode: null, effort: 'high' },
27
32
  standard: { mode: null, effort: 'high' },
28
33
  fast: { mode: null, effort: 'medium' },
34
+ coordinator: null,
29
35
  };
30
36
 
31
37
  /**
32
38
  * Default thinking config per tier.
33
- * - Opus 4.7 / Sonnet 4.6 support adaptive thinking and the effort parameter.
39
+ * - Opus 5 / Sonnet 5 support adaptive thinking and the effort parameter.
40
+ * - Fable 5.1 thinking is always on; adaptive thinking uses effort to control depth.
34
41
  * - Haiku 4.5 doesn't accept the effort parameter (400 error), so fast tier stays off.
35
42
  *
36
43
  * @type {Record<string, { mode: 'adaptive'|'off', effort: 'low'|'medium'|'high'|'xhigh'|'max'|null }>}
@@ -39,13 +46,14 @@ export const TIER_THINKING = {
39
46
  critical: { mode: 'adaptive', effort: 'xhigh' },
40
47
  standard: { mode: 'adaptive', effort: 'high' },
41
48
  fast: { mode: 'off', effort: null },
49
+ coordinator: { mode: 'adaptive', effort: 'high' },
42
50
  };
43
51
 
44
52
  /**
45
53
  * Resolve a tier name to a concrete model ID.
46
54
  *
47
55
  * @param {string|null|undefined} tier
48
- * @returns {string|null} Model ID, or null if tier is unknown / not provided.
56
+ * @returns {string|null} Model ID, or null if tier is unknown, unavailable for the provider, or not provided.
49
57
  */
50
58
  export function resolveTierModel(tier, provider = 'claude') {
51
59
  if (!tier) return null;