@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.
- package/.claude/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +15 -1
- package/bin/compose.js +57 -17
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/agent-string.js +9 -4
- package/lib/build-cancel.js +205 -0
- package/lib/build-stream-writer.js +6 -0
- package/lib/build.js +1189 -165
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +427 -17
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/experiment-pricing.js +5 -1
- package/lib/flow-state.js +38 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/gsd.js +95 -48
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/model-pricing.js +4 -1
- package/lib/output-gate.js +81 -0
- package/lib/pipeline-profiles.js +200 -0
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +41 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/team-flag.js +1 -1
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/lib/wave-checkpoint.js +100 -0
- package/package.json +7 -5
- package/presets/team-fable-astra.profiles.json +18 -0
- package/presets/team-fable-astra.stratum.yaml +236 -0
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/model-tiers.js +14 -6
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- package/lib/canon-override.js +0 -196
|
@@ -68,24 +68,46 @@ function _guardOn(capsOverride) {
|
|
|
68
68
|
try { return loadProjectConfig()?.capabilities?.guard === true; } catch { return false; }
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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)
|
|
1006
|
-
*
|
|
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
|
-
`
|
|
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;
|
package/server/compose-mcp.js
CHANGED
|
@@ -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;
|
package/server/design-routes.js
CHANGED
|
@@ -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 '../
|
|
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';
|
package/server/file-watcher.js
CHANGED
|
@@ -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);
|
package/server/ideabox-routes.js
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/server/maya-routes.js
CHANGED
|
@@ -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
|
},
|
package/server/mcp-tool-defs.js
CHANGED
|
@@ -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
|
|
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.
|
|
32
|
-
* self-approve, self-complete, or mutate roadmap status") and no design ever
|
|
33
|
-
* ruled that an implementer may call
|
|
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
|
|
51
|
-
*
|
|
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
|
|
package/server/model-tiers.js
CHANGED
|
@@ -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-
|
|
12
|
-
standard: 'claude-sonnet-
|
|
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
|
|
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
|
|
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;
|