@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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/portfolio.js — the cross-product aggregator (COMP-FOH FOH-7).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT
|
|
5
|
+
* ---------------------------------------------
|
|
6
|
+
* A consumer-side aggregator that sits ABOVE the provider seam and fans a single
|
|
7
|
+
* question out across N independently-configured products.
|
|
8
|
+
*
|
|
9
|
+
* It is **not** a `FluidProvider` and must not be registered as one. A provider
|
|
10
|
+
* implies a store, and this has none; and putting fan-out below the seam would
|
|
11
|
+
* place it under per-workspace capability semantics, where "does this support
|
|
12
|
+
* recall" stops having a single answer. Members may use different providers —
|
|
13
|
+
* that is the point of aggregating above the seam rather than below it.
|
|
14
|
+
*
|
|
15
|
+
* PARTIALITY IS THE NORMAL CASE
|
|
16
|
+
* -----------------------------
|
|
17
|
+
* With N independently-configured products reached over N connections, some
|
|
18
|
+
* subset being unavailable is the expected steady state, not an error path. The
|
|
19
|
+
* contract is therefore that **every absent source is NAMED**: a portfolio answer
|
|
20
|
+
* that quietly covers three products when the user declared five is worse than
|
|
21
|
+
* no answer, because nothing in it looks wrong.
|
|
22
|
+
*
|
|
23
|
+
* The one thing that is an error is EVERY member failing. An empty result set
|
|
24
|
+
* and a total outage must never be presented identically.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { parsePortfolioConfig } from './factory.js';
|
|
28
|
+
import { getFluidWorkspaceId } from '../maya-config.js';
|
|
29
|
+
import { fluidProviderFor } from './factory.js';
|
|
30
|
+
import { KIND, SEMANTIC_CAP } from './provider.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-member deadline.
|
|
34
|
+
*
|
|
35
|
+
* Explicit, and not inherited: the colleague composer's 35s `withDeadline` wraps
|
|
36
|
+
* only its three findings calls, so there is no ambient whole-turn bound to sit
|
|
37
|
+
* inside (blueprint C2). A portfolio turn costs one round trip per member with no
|
|
38
|
+
* server-side batching, so an unbounded member holds the whole turn open.
|
|
39
|
+
*/
|
|
40
|
+
export const MEMBER_DEADLINE_MS = 20_000;
|
|
41
|
+
|
|
42
|
+
/** How many records a storage-only member contributes when it cannot search. */
|
|
43
|
+
const LISTED_FALLBACK_LIMIT = 25;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {object} PortfolioSource
|
|
47
|
+
* @property {string} id the declared label
|
|
48
|
+
* @property {string} root the member's absolute root — carried because two
|
|
49
|
+
* products can hold the same handle, and the id alone is a name the user chose
|
|
50
|
+
* while the root is what actually disambiguates
|
|
51
|
+
* @property {import('./provider.js').RecallHit[]} hits
|
|
52
|
+
* @property {boolean} [listedNotSearched] true when the member could not search
|
|
53
|
+
* and these are its records listed, never a synthesized query result
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Construct one provider per declared member, concurrently.
|
|
58
|
+
*
|
|
59
|
+
* A member that cannot be opened is an omission, never a thrown turn: the whole
|
|
60
|
+
* point is that one broken product does not silence the others.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} cwd the declaring root
|
|
63
|
+
* @returns {Promise<{sources: Array<{id, root, provider}>, omissions: string[]}>}
|
|
64
|
+
*/
|
|
65
|
+
export async function openPortfolio(cwd) {
|
|
66
|
+
const config = parsePortfolioConfig(cwd);
|
|
67
|
+
if (!config) return { sources: [], omissions: [], declared: 0 };
|
|
68
|
+
|
|
69
|
+
const settled = await Promise.allSettled(
|
|
70
|
+
config.members.map(async (m) => ({ ...m, provider: await fluidProviderFor(m.root) })),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const sources = [];
|
|
74
|
+
const omissions = [];
|
|
75
|
+
settled.forEach((outcome, i) => {
|
|
76
|
+
const member = config.members[i];
|
|
77
|
+
if (outcome.status === 'fulfilled') sources.push(outcome.value);
|
|
78
|
+
else omissions.push(`${member.id} misconfigured: ${reasonOf(outcome.reason)}`);
|
|
79
|
+
});
|
|
80
|
+
return { sources, omissions, declared: config.members.length };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ask every member the same question.
|
|
85
|
+
*
|
|
86
|
+
* @param {{sources: Array<{id, root, provider}>, omissions: string[]}} portfolio
|
|
87
|
+
* @param {string} query
|
|
88
|
+
* @param {{deadlineMs?: number, limit?: number}} [opts]
|
|
89
|
+
* @returns {Promise<{sources: PortfolioSource[], omissions: string[]}>}
|
|
90
|
+
*/
|
|
91
|
+
export async function recallAcrossPortfolio(portfolio, query, opts = {}) {
|
|
92
|
+
const deadlineMs = opts.deadlineMs ?? MEMBER_DEADLINE_MS;
|
|
93
|
+
const omissions = [...portfolio.omissions];
|
|
94
|
+
|
|
95
|
+
const settled = await Promise.allSettled(
|
|
96
|
+
portfolio.sources.map((source) => withDeadline(askOne(source, query, opts), deadlineMs, source.id)),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const sources = [];
|
|
100
|
+
settled.forEach((outcome, i) => {
|
|
101
|
+
const source = portfolio.sources[i];
|
|
102
|
+
if (outcome.status === 'rejected') {
|
|
103
|
+
omissions.push(`${source.id} ${classify(outcome.reason)}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const { hits, listedNotSearched, omission } = outcome.value;
|
|
107
|
+
if (omission) omissions.push(omission);
|
|
108
|
+
sources.push({
|
|
109
|
+
id: source.id,
|
|
110
|
+
root: source.root,
|
|
111
|
+
hits,
|
|
112
|
+
...(listedNotSearched ? { listedNotSearched: true } : {}),
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// An empty answer and a total outage must not look the same. This is the one
|
|
117
|
+
// partiality that is NOT the normal case.
|
|
118
|
+
const declared = portfolio.declared ?? portfolio.sources.length;
|
|
119
|
+
if (!sources.length && declared > 0) {
|
|
120
|
+
const err = new Error(
|
|
121
|
+
`compose: every portfolio member failed — ${omissions.join('; ')}`,
|
|
122
|
+
);
|
|
123
|
+
err.code = 'PORTFOLIO_ALL_FAILED';
|
|
124
|
+
err.omissions = omissions;
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { sources, omissions };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Internals
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One member's contribution.
|
|
137
|
+
*
|
|
138
|
+
* A member that cannot search is not skipped and is not faked: it contributes
|
|
139
|
+
* what it genuinely has — its records, listed — flagged as such, plus a named
|
|
140
|
+
* omission. Returning its list as if it were a query result would be the silent
|
|
141
|
+
* substitution this feature exists to avoid.
|
|
142
|
+
*/
|
|
143
|
+
async function askOne(source, query, opts) {
|
|
144
|
+
const { provider, id } = source;
|
|
145
|
+
// The DECLARED constant, never a bare string: `has()` is a plain Set lookup
|
|
146
|
+
// on `capabilities()`, and the FOH-7 live-fire (2026-09-06) found `'recall'`
|
|
147
|
+
// here against a provider declaring `'RECALL'` — every member, SmartMemory
|
|
148
|
+
// included, silently took the listed-not-searched path and the suite stayed
|
|
149
|
+
// green because its stubs replaced `has()` outright.
|
|
150
|
+
const canRecall = typeof provider.has === 'function' ? provider.has(SEMANTIC_CAP.RECALL) : true;
|
|
151
|
+
|
|
152
|
+
if (!canRecall) {
|
|
153
|
+
const records = await provider.listRecords({ kind: KIND.IDEA });
|
|
154
|
+
// BY RECENCY, before the limit. `listRecords` returns the local provider's
|
|
155
|
+
// storage order (cluster, then handle number), so slicing it directly hands
|
|
156
|
+
// back a product's OLDEST ideas — an established product would omit its
|
|
157
|
+
// newest decisions every time, which is the worst possible subset to show
|
|
158
|
+
// for a question about what was recently decided.
|
|
159
|
+
const recent = [...records].sort((a, b) =>
|
|
160
|
+
String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')));
|
|
161
|
+
return {
|
|
162
|
+
hits: recent.slice(0, opts.limit ?? LISTED_FALLBACK_LIMIT).map((record) => ({
|
|
163
|
+
handle: record.handle,
|
|
164
|
+
// Null, never 0: this member did not rank anything, and 0 would read as
|
|
165
|
+
// "ranked, and terrible" (see RecallHit in provider.js).
|
|
166
|
+
score: null,
|
|
167
|
+
record,
|
|
168
|
+
})),
|
|
169
|
+
listedNotSearched: true,
|
|
170
|
+
omission: `${id} cannot search (no recall capability) — listed its records instead`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const hits = await provider.recall(query, opts.limit ? { limit: opts.limit } : {});
|
|
175
|
+
return { hits: hits ?? [], listedNotSearched: false, omission: null };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Reject after `ms`, naming the member so the omission can name it too. */
|
|
179
|
+
function withDeadline(promise, ms, id) {
|
|
180
|
+
let timer;
|
|
181
|
+
const bell = new Promise((_, reject) => {
|
|
182
|
+
timer = setTimeout(() => {
|
|
183
|
+
const err = new Error(`did not answer within ${ms}ms`);
|
|
184
|
+
err.code = 'PORTFOLIO_MEMBER_TIMEOUT';
|
|
185
|
+
err.memberId = id;
|
|
186
|
+
reject(err);
|
|
187
|
+
}, ms);
|
|
188
|
+
});
|
|
189
|
+
return Promise.race([promise, bell]).finally(() => clearTimeout(timer));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const reasonOf = (err) => String(err?.message ?? err ?? 'unknown').slice(0, 160);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Turn a failure into the omission's reason class.
|
|
196
|
+
*
|
|
197
|
+
* The vocabulary reuses wording the cockpit already uses ("SmartMemory
|
|
198
|
+
* unreachable", `RecallTab.jsx:29`) rather than coining a parallel one, and the
|
|
199
|
+
* 403 branch depends on `scopeError` being retained on the error — see the
|
|
200
|
+
* blueprint's D1.
|
|
201
|
+
*/
|
|
202
|
+
function classify(err) {
|
|
203
|
+
if (err?.code === 'PORTFOLIO_MEMBER_TIMEOUT') return `unreachable: ${reasonOf(err)}`;
|
|
204
|
+
const status = typeof err?.status === 'number' ? err.status : null;
|
|
205
|
+
if (status === 403 || status === 401) {
|
|
206
|
+
const scope = err?.scopeError;
|
|
207
|
+
if (scope === 'not-a-member') return 'unauthorized: not a member of that workspace';
|
|
208
|
+
if (scope === 'missing-scope') return 'unauthorized: the key lacks the required scope';
|
|
209
|
+
return 'unauthorized: reason undetermined';
|
|
210
|
+
}
|
|
211
|
+
if (status === 0 || /ECONNREFUSED|ENOTFOUND|fetch failed|network/i.test(reasonOf(err))) {
|
|
212
|
+
return `unreachable: ${reasonOf(err)}`;
|
|
213
|
+
}
|
|
214
|
+
return `unavailable: ${reasonOf(err)}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Refuse a portfolio where any member's fluid workspace is the colleague's own.
|
|
219
|
+
*
|
|
220
|
+
* The existing guard (`lib/maya-identity.js`) compares Maya's identity claim
|
|
221
|
+
* against ONE configured workspace — the declaring root's. A portfolio adds N
|
|
222
|
+
* more workspaces the turn will read from, and none of them was checked: the
|
|
223
|
+
* shallow-binding isolation this protects is not a property of the declaring
|
|
224
|
+
* root, it is a property of every workspace the turn touches. Without it a
|
|
225
|
+
* member configured at Maya's own workspace reaches a successful chat, which is
|
|
226
|
+
* the isolation failure the declaring-root check exists to prevent, arriving
|
|
227
|
+
* through a door that check does not cover.
|
|
228
|
+
*
|
|
229
|
+
* Deliberately NOT member-vs-member: two declared products may legitimately
|
|
230
|
+
* share a workspace, and refusing that would be our policy rather than a
|
|
231
|
+
* required invariant.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} cwd the declaring root
|
|
234
|
+
* @param {string|null} identityWorkspaceId Maya's own verified workspace
|
|
235
|
+
* @throws {Error} code PORTFOLIO_WORKSPACE_COLLISION
|
|
236
|
+
*/
|
|
237
|
+
export function assertMemberWorkspacesDistinct(cwd, identityWorkspaceId) {
|
|
238
|
+
if (!identityWorkspaceId) return;
|
|
239
|
+
const config = parsePortfolioConfig(cwd);
|
|
240
|
+
if (!config) return;
|
|
241
|
+
|
|
242
|
+
const collisions = config.members
|
|
243
|
+
.filter((m) => getFluidWorkspaceId(m.root) === identityWorkspaceId)
|
|
244
|
+
.map((m) => m.id);
|
|
245
|
+
|
|
246
|
+
if (collisions.length) {
|
|
247
|
+
const err = new Error(
|
|
248
|
+
`compose: portfolio member(s) ${collisions.join(', ')} are configured at the colleague's own ` +
|
|
249
|
+
`workspace (${identityWorkspaceId}) — a turn cannot read the memory it is writing from`,
|
|
250
|
+
);
|
|
251
|
+
err.code = 'PORTFOLIO_WORKSPACE_COLLISION';
|
|
252
|
+
err.collisions = collisions;
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -204,6 +204,13 @@ export function normalizeRecord(record) {
|
|
|
204
204
|
cluster_order: record.cluster_order ?? null,
|
|
205
205
|
tags: Array.isArray(record.tags) ? [...record.tags] : [],
|
|
206
206
|
source: record.source ?? null,
|
|
207
|
+
// Hand-authored field lines the markdown parser did not recognise. Carried
|
|
208
|
+
// verbatim so the upgrade path is LOSSLESS: this shape is an allowlist, so
|
|
209
|
+
// a field absent here is deleted from the user's ideabox by the first
|
|
210
|
+
// render after migration, silently and with no way back
|
|
211
|
+
// (COMP-IDEABOX-MIGRATE-DIALECT). `effort` and `impact` were rescued from
|
|
212
|
+
// exactly this fate one slice earlier; this is the general case they left.
|
|
213
|
+
extra_fields: Array.isArray(record.extra_fields) ? [...record.extra_fields] : [],
|
|
207
214
|
links: Array.isArray(record.links) ? record.links.map((l) => ({ ...l })) : [],
|
|
208
215
|
killed: record.killed ? { ...record.killed } : null,
|
|
209
216
|
discussion: Array.isArray(record.discussion) ? record.discussion.map((d) => ({ ...d })) : [],
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { randomUUID } from 'node:crypto';
|
|
19
|
-
import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
21
|
|
|
22
22
|
import { withDirLock } from '../dir-lock.js';
|
|
23
23
|
import { toMarkdownDate } from './ideabox-dates.js';
|
|
24
|
+
import { readPreamble } from './ideabox-preamble.js';
|
|
24
25
|
import { KIND } from './provider.js';
|
|
25
26
|
|
|
26
27
|
// Says only what is true. The cockpit's write path is NOT on the store yet
|
|
@@ -31,7 +32,20 @@ const BANNER = [
|
|
|
31
32
|
' Projection of the fluid-store idea records (COMP-PLAN-IDEA-UNIFY).',
|
|
32
33
|
' Edits here are overwritten on the next render. Change ideas with',
|
|
33
34
|
' `compose ideabox add|pri|kill|discuss|promote`, then `compose ideabox',
|
|
34
|
-
' render` if this file ever looks stale.
|
|
35
|
+
' render` if this file ever looks stale.',
|
|
36
|
+
// The heading and introduction come from a DIFFERENT file, and nothing in the
|
|
37
|
+
// document says so. Someone correcting the title here loses it on the next
|
|
38
|
+
// render with no clue where it went — the one edit this file invites that has
|
|
39
|
+
// no visible home (COMP-IDEABOX-MIGRATE-DIALECT FU-4 follow-up).
|
|
40
|
+
' The title and introduction above are not generated: they live in',
|
|
41
|
+
' `<this file>.preamble.md`. Edit them THERE, not here.',
|
|
42
|
+
// The inverse rule, and nothing said it. Everything else in this file is
|
|
43
|
+
// generated output whose hand edits are discarded on the next render; the
|
|
44
|
+
// sidecar is the one input, so ITS hand edits are canon and are kept
|
|
45
|
+
// untouched forever. Two files side by side following opposite rules, with
|
|
46
|
+
// only one of them stated, is a trap for the next person to edit either.
|
|
47
|
+
' That file is the opposite of this one: it is yours, nothing',
|
|
48
|
+
' regenerates it, and what you write there is kept. -->',
|
|
35
49
|
];
|
|
36
50
|
|
|
37
51
|
const PREAMBLE = [
|
|
@@ -85,6 +99,19 @@ function renderIdea(record) {
|
|
|
85
99
|
if (record.source) out.push(`**Source:** ${record.source}`);
|
|
86
100
|
if (record.body) out.push(`**Idea:** ${record.body}`);
|
|
87
101
|
|
|
102
|
+
// Unrecognised hand-authored fields. The two serializers put these in
|
|
103
|
+
// DIFFERENT slots and the projection has to match whichever one will read it
|
|
104
|
+
// back: `serializeIdea` emits them after `**Idea:**` and before the trailing
|
|
105
|
+
// known fields (`lib/ideabox.js`), while `serializeKilledIdea` emits them
|
|
106
|
+
// after `**Killed:**`. Using the live slot for both meant a killed idea
|
|
107
|
+
// carrying a custom field reordered the file on every write, so the
|
|
108
|
+
// projection stopped being a fixed point of the serializer — the property the
|
|
109
|
+
// whole cutover rests on. Killed ideas are emitted further down, next to
|
|
110
|
+
// `**Killed:**`.
|
|
111
|
+
if (!record.killed) {
|
|
112
|
+
for (const extra of record.extra_fields ?? []) out.push(extra);
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
// `Promoted to:` is emitted BEFORE `Maps to:`, which reads backwards and is
|
|
89
116
|
// deliberate. The legacy parser knows `Maps to` and does not know `Promoted
|
|
90
117
|
// to`, so the latter lands in `_extraLines` — and the legacy serializer emits
|
|
@@ -112,6 +139,9 @@ function renderIdea(record) {
|
|
|
112
139
|
// discussed — which is most of the ones anyone would want to kill.
|
|
113
140
|
if (record.status === 'killed' && record.killed) {
|
|
114
141
|
out.push(`**Killed:** ${toMarkdownDate(record.killed.at)} — ${record.killed.reason}`);
|
|
142
|
+
// The killed serializer's slot for unrecognised fields: immediately after
|
|
143
|
+
// `**Killed:**`, before the discussion block.
|
|
144
|
+
for (const extra of record.extra_fields ?? []) out.push(extra);
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
if (record.discussion?.length) {
|
|
@@ -132,8 +162,14 @@ function renderIdea(record) {
|
|
|
132
162
|
* @param {Array<object>} data.clusters
|
|
133
163
|
* @returns {string}
|
|
134
164
|
*/
|
|
135
|
-
export function renderIdeabox({ ideas, clusters }) {
|
|
136
|
-
|
|
165
|
+
export function renderIdeabox({ ideas, clusters, preamble = null }) {
|
|
166
|
+
// A project's own title and introduction, when it has one. The default
|
|
167
|
+
// PREAMBLE is a template, and using it unconditionally deleted the heading and
|
|
168
|
+
// prose an upgrading project had written for itself — the migration kept every
|
|
169
|
+
// idea and silently rewrote the document around them
|
|
170
|
+
// (COMP-IDEABOX-MIGRATE-DIALECT, Codex review finding 4).
|
|
171
|
+
const preambleLines = preamble && preamble.length ? preamble : PREAMBLE;
|
|
172
|
+
const lines = [...BANNER, '', ...preambleLines, '', '## Ideas', ''];
|
|
137
173
|
// The legacy serializer emits a placeholder comment when the file contains no
|
|
138
174
|
// `###` grouping heading at all, and the projection has to match it byte for
|
|
139
175
|
// byte or `serialize(parse(projection))` stops being the identity. That state
|
|
@@ -203,14 +239,44 @@ export function renderIdeabox({ ideas, clusters }) {
|
|
|
203
239
|
* Read the records from a provider and render.
|
|
204
240
|
* @param {import('./provider.js').FluidProvider} provider
|
|
205
241
|
*/
|
|
206
|
-
export async function renderIdeaboxFrom(provider) {
|
|
242
|
+
export async function renderIdeaboxFrom(provider, { preamble = null, outPath = null } = {}) {
|
|
207
243
|
const [ideas, clusters] = await Promise.all([
|
|
208
244
|
provider.listRecords({ kind: KIND.IDEA }),
|
|
209
245
|
provider.listRecords({ kind: KIND.CLUSTER }),
|
|
210
246
|
]);
|
|
211
|
-
|
|
247
|
+
// The project's own heading and introduction, captured at migration into a
|
|
248
|
+
// tracked sibling of the ideabox (FU-4). NOT read from the destination —
|
|
249
|
+
// `<ideabox>.preamble.md` is a different file; see the note below. `outPath`
|
|
250
|
+
// names which ideabox's sidecar to read, not a file to read from. An explicit
|
|
251
|
+
// `preamble` still wins, and a project with no sidecar gets the standard
|
|
252
|
+
// template exactly as before.
|
|
253
|
+
const captured = preamble ?? (outPath ? readPreamble(outPath) : null);
|
|
254
|
+
return renderIdeabox({
|
|
255
|
+
ideas,
|
|
256
|
+
clusters,
|
|
257
|
+
preamble: captured ? captured.split('\n') : null,
|
|
258
|
+
});
|
|
212
259
|
}
|
|
213
260
|
|
|
261
|
+
/**
|
|
262
|
+
* STILL NOT READ FROM THE DESTINATION (FU-4, resolved 2026-09-07).
|
|
263
|
+
*
|
|
264
|
+
* Carrying the existing file's preamble forward would have preserved a
|
|
265
|
+
* project's own title and introduction through migration, but it makes the
|
|
266
|
+
* DESTINATION authoritative, and `render` is documented as the way back from
|
|
267
|
+
* any hand edit (`lib/ideabox-cli.js`). Reading the file we are about to repair
|
|
268
|
+
* means a hand-corrupted heading survives the repair. Both behaviours are
|
|
269
|
+
* legitimate and they were mutually exclusive while the preamble lived only in
|
|
270
|
+
* the markdown.
|
|
271
|
+
*
|
|
272
|
+
* So it no longer lives only there. `importIdeabox` captures it at migration
|
|
273
|
+
* into a tracked SIBLING of the ideabox, `<ideabox>.preamble.md`
|
|
274
|
+
* (`ideabox-preamble.js`), and the renderer reads THAT. A sibling is a
|
|
275
|
+
* different file, so the projection stays a function of the records plus the
|
|
276
|
+
* sidecar and never of its own output: a hand edit to the heading is discarded
|
|
277
|
+
* by the next render exactly as a hand edit to an idea is.
|
|
278
|
+
*/
|
|
279
|
+
|
|
214
280
|
/**
|
|
215
281
|
* Render and write atomically (temp + rename), following the roadmap-gen
|
|
216
282
|
* pattern — a half-written projection of canonical data is worse than none.
|
|
@@ -234,16 +300,78 @@ export async function renderIdeaboxFrom(provider) {
|
|
|
234
300
|
* which is the documented state of that provider rather than a new gap.
|
|
235
301
|
*/
|
|
236
302
|
export async function writeIdeaboxProjection(provider, outPath) {
|
|
303
|
+
// THE SINGLE DOOR.
|
|
304
|
+
//
|
|
305
|
+
// Every projection write replaces the user's ideabox wholesale, so every
|
|
306
|
+
// projection write is a potential erasure — and the migration gate that
|
|
307
|
+
// prevents it used to be applied caller-by-caller. It was on the CLI render
|
|
308
|
+
// and absent from the HTTP render, which meant a button in the cockpit could
|
|
309
|
+
// do what the equivalent command refused to
|
|
310
|
+
// (COMP-IDEABOX-MIGRATE-DIALECT, Codex review finding 1). Guarding each
|
|
311
|
+
// caller is a list that has to stay complete forever; guarding the boundary
|
|
312
|
+
// they all pass through is a fact. It goes here.
|
|
313
|
+
//
|
|
314
|
+
// Idempotent for the ordinary case: after a normal mutation the store is
|
|
315
|
+
// ahead of the file, which the gate reads as already-migrated and returns
|
|
316
|
+
// without work.
|
|
317
|
+
//
|
|
318
|
+
// DELIBERATELY OUTSIDE `withDirLock`, and it must stay there. The gate can
|
|
319
|
+
// import, importing calls `provider.createRecord`, and that takes this same
|
|
320
|
+
// lock — which is a non-reentrant `mkdirSync` lock (`lib/dir-lock.js`). Moving
|
|
321
|
+
// this call under the lock to close the read-then-write window deadlocks every
|
|
322
|
+
// migration: measured, it fails after the full 30s lock timeout rather than
|
|
323
|
+
// completing.
|
|
324
|
+
//
|
|
325
|
+
// The window that leaves — the file changing between this check and the
|
|
326
|
+
// replacement below — is closed by `publishProjection` re-running the gate's
|
|
327
|
+
// PURE assessment inside the lock (FU-3). That is the shape the deadlock
|
|
328
|
+
// forces: the decision moves under the lock, the execution stays outside it.
|
|
329
|
+
const { ensureIdeaboxMigrated } = await import('./ideabox-migrate.js');
|
|
330
|
+
await ensureIdeaboxMigrated(provider, outPath);
|
|
331
|
+
|
|
237
332
|
return provider.lockPath
|
|
238
333
|
? withDirLock(provider.lockPath, () => publishProjection(provider, outPath))
|
|
239
334
|
: publishProjection(provider, outPath);
|
|
240
335
|
}
|
|
241
336
|
|
|
242
337
|
async function publishProjection(provider, outPath) {
|
|
338
|
+
// THE SECOND LOOK, INSIDE THE LOCK.
|
|
339
|
+
//
|
|
340
|
+
// The gate above ran before this lock was acquired, so the file it approved
|
|
341
|
+
// is not necessarily the file about to be replaced: an editor saving a new
|
|
342
|
+
// idea while a migration runs had it destroyed, because the projection was
|
|
343
|
+
// built from records made out of the OLDER document
|
|
344
|
+
// (COMP-IDEABOX-MIGRATE-DIALECT FU-3).
|
|
345
|
+
//
|
|
346
|
+
// Re-ASSESSING rather than comparing a hash captured at gate time. A hash
|
|
347
|
+
// comparison cannot tell an edit from an ordinary concurrent render: two
|
|
348
|
+
// legitimate writers (the CLI and the REST API) each re-read the records, and
|
|
349
|
+
// whichever lands second would refuse for no reason. The assessment asks the
|
|
350
|
+
// question that actually matters — is this file still consistent with the
|
|
351
|
+
// store? — so a concurrent render assesses as `none` and proceeds, while a
|
|
352
|
+
// hand-added idea assesses as a stray and stops the write.
|
|
353
|
+
//
|
|
354
|
+
// Safe under the lock because the assessment is pure and takes no lock: it
|
|
355
|
+
// reads `listRecords`, `readEvents` and the markdown, and `renderIdeaboxFrom`
|
|
356
|
+
// below already performs the first of those under this same lock.
|
|
357
|
+
const { assessIdeabox, IdeaboxChangedUnderLock } = await import('./ideabox-migrate.js');
|
|
358
|
+
// The exact bytes the assessment below is about to approve. Re-read
|
|
359
|
+
// immediately before the rename — see the note there.
|
|
360
|
+
const approved = existsSync(outPath) ? readFileSync(outPath, 'utf8') : null;
|
|
361
|
+
const assessment = await assessIdeabox(provider, outPath);
|
|
362
|
+
if (assessment.action === 'refuse') throw assessment.error;
|
|
363
|
+
if (assessment.action !== 'none') {
|
|
364
|
+
// `import` here means the store and the file diverged after the gate
|
|
365
|
+
// approved them — records deleted concurrently, or an interrupted migration
|
|
366
|
+
// resumable from its manifest. Either way the write is abandoned rather
|
|
367
|
+
// than replacing a document this pass never checked.
|
|
368
|
+
throw new IdeaboxChangedUnderLock(outPath, `the store and the file now need ${assessment.action}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
243
371
|
// Rendered BEFORE the destination is touched, so a render that refuses (an
|
|
244
372
|
// idea naming a cluster that does not exist) leaves the previous good file
|
|
245
373
|
// exactly where it was rather than replacing it with a partial view.
|
|
246
|
-
const markdown = await renderIdeaboxFrom(provider);
|
|
374
|
+
const markdown = await renderIdeaboxFrom(provider, { outPath });
|
|
247
375
|
mkdirSync(dirname(outPath), { recursive: true });
|
|
248
376
|
// randomUUID, not pid: two renders from one process must not collide on the
|
|
249
377
|
// temp name, and a recycled pid must not adopt a stranded file. Cleaned up on
|
|
@@ -252,6 +380,24 @@ async function publishProjection(provider, outPath) {
|
|
|
252
380
|
const tmp = join(dirname(outPath), `.ideabox.md.tmp.${randomUUID()}`);
|
|
253
381
|
try {
|
|
254
382
|
writeFileSync(tmp, markdown, 'utf8');
|
|
383
|
+
// THE LOCK DOES NOT COORDINATE THE PERSON.
|
|
384
|
+
//
|
|
385
|
+
// The assessment above closed the window between the gate and the lock, but
|
|
386
|
+
// not the window inside the lock: reading the records and rendering them
|
|
387
|
+
// takes time, and a human saving a new idea in that moment never acquires
|
|
388
|
+
// this lock — nothing invites them to. Their whole idea was still lost, one
|
|
389
|
+
// step later than before.
|
|
390
|
+
//
|
|
391
|
+
// So the destination is compared against ITSELF, over milliseconds, with
|
|
392
|
+
// the lock held. That is not the gate-time fingerprint this deliberately
|
|
393
|
+
// does not use: the lock serializes every render Compose performs, so no
|
|
394
|
+
// legitimate concurrent render can change this file between the two reads,
|
|
395
|
+
// and none can be refused by this check. Only a writer outside the lock —
|
|
396
|
+
// which means a person with the file open — trips it.
|
|
397
|
+
const current = existsSync(outPath) ? readFileSync(outPath, 'utf8') : null;
|
|
398
|
+
if (current !== approved) {
|
|
399
|
+
throw new IdeaboxChangedUnderLock(outPath, 'it was saved from outside compose mid-write');
|
|
400
|
+
}
|
|
255
401
|
renameSync(tmp, outPath);
|
|
256
402
|
} catch (err) {
|
|
257
403
|
rmSync(tmp, { force: true });
|
|
@@ -959,6 +959,12 @@ export class SmartMemoryFluidProvider extends FluidProvider {
|
|
|
959
959
|
cluster_order: input.cluster_order ?? null,
|
|
960
960
|
tags: input.tags ?? [],
|
|
961
961
|
source: input.source ?? null,
|
|
962
|
+
// Unrecognised hand-authored markdown fields, carried verbatim. This
|
|
963
|
+
// literal is one of FOUR places the record's fields are enumerated (the
|
|
964
|
+
// contract, `record-shape.js`, and both providers); a field missing from
|
|
965
|
+
// any one of them is silently dropped on write
|
|
966
|
+
// (COMP-IDEABOX-MIGRATE-DIALECT).
|
|
967
|
+
extra_fields: input.extra_fields ?? [],
|
|
962
968
|
links: input.links ?? [],
|
|
963
969
|
killed: input.killed ?? null,
|
|
964
970
|
discussion: input.discussion ?? [],
|
package/lib/gate-prompt.js
CHANGED
|
@@ -136,28 +136,32 @@ export class GateInputUnavailableError extends Error {
|
|
|
136
136
|
*
|
|
137
137
|
* @param {import('node:readline').Interface} rl
|
|
138
138
|
* @param {string} question
|
|
139
|
-
* @param {{ armGuard?: boolean, deadlineMs?: number }} [opts]
|
|
139
|
+
* @param {{ armGuard?: boolean, deadlineMs?: number, signal?: AbortSignal }} [opts]
|
|
140
140
|
* @returns {Promise<string>}
|
|
141
141
|
*/
|
|
142
|
-
export function ask(rl, question, { armGuard = false, deadlineMs = GATE_NONINTERACTIVE_TIMEOUT_MS } = {}) {
|
|
143
|
-
if (!armGuard) {
|
|
142
|
+
export function ask(rl, question, { armGuard = false, deadlineMs = GATE_NONINTERACTIVE_TIMEOUT_MS, signal } = {}) {
|
|
143
|
+
if (!armGuard && !signal) {
|
|
144
144
|
return new Promise(resolve => rl.question(question, resolve));
|
|
145
145
|
}
|
|
146
146
|
return new Promise((resolve, reject) => {
|
|
147
147
|
let settled = false;
|
|
148
148
|
const onEnd = () => finish(() => reject(new GateInputUnavailableError('stdin was closed before a decision')));
|
|
149
|
-
const
|
|
149
|
+
const onAbort = () => finish(() => reject(signal.reason));
|
|
150
|
+
const timer = armGuard ? setTimeout(
|
|
150
151
|
() => finish(() => reject(new GateInputUnavailableError(`no input arrived within ${Math.round(deadlineMs / 1000)}s`))),
|
|
151
152
|
deadlineMs,
|
|
152
|
-
);
|
|
153
|
+
) : null;
|
|
153
154
|
function finish(act) {
|
|
154
155
|
if (settled) return;
|
|
155
156
|
settled = true;
|
|
156
157
|
clearTimeout(timer);
|
|
157
158
|
rl.input.removeListener('end', onEnd);
|
|
159
|
+
signal?.removeEventListener('abort', onAbort);
|
|
158
160
|
act();
|
|
159
161
|
}
|
|
160
|
-
rl.input.once('end', onEnd);
|
|
162
|
+
if (armGuard) rl.input.once('end', onEnd);
|
|
163
|
+
if (signal?.aborted) { onAbort(); return; }
|
|
164
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
161
165
|
rl.question(question, answer => finish(() => resolve(answer)));
|
|
162
166
|
});
|
|
163
167
|
}
|
|
@@ -230,7 +234,8 @@ function drawGatePanel(out, gateDispatch, { artifact, gateExtras } = {}) {
|
|
|
230
234
|
* @param {object} [options.gateExtras] - { fromPhase, toPhase } for panel display
|
|
231
235
|
* @returns {Promise<{ outcome: string, rationale: string }>}
|
|
232
236
|
*/
|
|
233
|
-
export async function promptGate(gateDispatch, { input, output, artifact, askAgent, gateExtras, nonInteractive, timeoutMs } = {}) {
|
|
237
|
+
export async function promptGate(gateDispatch, { input, output, artifact, askAgent, gateExtras, nonInteractive, timeoutMs, signal } = {}) {
|
|
238
|
+
signal?.throwIfAborted();
|
|
234
239
|
if (nonInteractive) {
|
|
235
240
|
return { outcome: 'approve', rationale: 'auto-approved (--all mode)' };
|
|
236
241
|
}
|
|
@@ -247,6 +252,7 @@ export async function promptGate(gateDispatch, { input, output, artifact, askAge
|
|
|
247
252
|
const askGuard = {
|
|
248
253
|
armGuard: input === undefined && !process.stdin.isTTY,
|
|
249
254
|
deadlineMs: timeoutMs ?? GATE_NONINTERACTIVE_TIMEOUT_MS,
|
|
255
|
+
signal,
|
|
250
256
|
};
|
|
251
257
|
|
|
252
258
|
try {
|
|
@@ -312,6 +318,7 @@ export async function promptGate(gateDispatch, { input, output, artifact, askAge
|
|
|
312
318
|
const answer = await askAgent(trimmed, artifact);
|
|
313
319
|
rl.output.write(`\n ${answer}\n`);
|
|
314
320
|
} catch (err) {
|
|
321
|
+
if (signal?.aborted) throw err;
|
|
315
322
|
rl.output.write(` (agent error: ${err.message})\n`);
|
|
316
323
|
}
|
|
317
324
|
notes.push(trimmed);
|