@smartmemory/compose 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1 -1
- package/bin/compose.js +33 -14
- 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/build-cancel.js +205 -0
- package/lib/build.js +552 -87
- 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 +24 -1
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -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/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +28 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/package.json +7 -5
- 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/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
package/lib/canon-guard.js
CHANGED
|
@@ -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
|
|
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
|
-
+
|
|
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
|
package/lib/canon-registry.js
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
package/lib/codex-preflight.js
CHANGED
|
@@ -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)) {
|
package/lib/colleague/context.js
CHANGED
|
@@ -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
|
*
|
package/lib/consumer-fanout.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/dispatch-ledger.js
CHANGED
|
@@ -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');
|
package/lib/fluid/factory.js
CHANGED
|
@@ -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
|
*
|