amicus 4.8.1 → 4.9.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-plugin/plugin.json +1 -1
- package/CHANGELOG.md +316 -0
- package/README.md +8 -3
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +54 -4
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +41 -18
- package/electron/main.js +19 -5
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui.js +46 -39
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- package/electron/workspace-ui/workspace-banners.js +30 -7
- package/electron/workspace-ui/workspace-matrix.js +23 -3
- package/electron/workspace-ui/workspace-seats.js +95 -79
- package/package.json +1 -1
- package/schemas/council-run.schema.json +2 -2
- package/schemas/council-tally.schema.json +17 -1
- package/schemas/council-verdict.schema.json +12 -4
- package/schemas/run.schema.json +6 -1
- package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +88 -9
- package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
- package/skills/second-opinion/SKILL.md +151 -36
- package/src/cli-council-run-bench.js +98 -6
- package/src/cli-handlers-council-run.js +18 -6
- package/src/cli-handlers-council.js +57 -7
- package/src/cli-handlers-doctor.js +1 -1
- package/src/cli.js +3 -1
- package/src/council/anonymize.js +2 -1
- package/src/council/briefings-chair-task.js +161 -0
- package/src/council/briefings-chair.js +33 -8
- package/src/council/briefings-debate.js +79 -13
- package/src/council/briefings-stage2-task.js +236 -0
- package/src/council/briefings-stage2.js +103 -26
- package/src/council/briefings-task.js +167 -0
- package/src/council/briefings.js +41 -4
- package/src/council/chair-fallback.js +95 -0
- package/src/council/debate.js +38 -21
- package/src/council/findings.js +3 -2
- package/src/council/ledger.js +2 -2
- package/src/council/parse-stage2.js +63 -15
- package/src/council/report-cost.js +61 -0
- package/src/council/report-html.js +26 -4
- package/src/council/report-md.js +30 -2
- package/src/council/report.js +40 -37
- package/src/council/run-assemble.js +21 -6
- package/src/council/run-chair.js +44 -95
- package/src/council/run-debate-revote.js +81 -49
- package/src/council/run-debate.js +51 -34
- package/src/council/run-finish.js +5 -3
- package/src/council/run-retry-keys.js +4 -4
- package/src/council/run-retry-launch.js +4 -4
- package/src/council/run-retry-notes.js +72 -15
- package/src/council/run-stage1-launch.js +4 -4
- package/src/council/run-stage1-rows.js +9 -6
- package/src/council/run-stage2.js +81 -47
- package/src/council/run-stages.js +9 -21
- package/src/council/run-stats-entry.js +46 -1
- package/src/council/run.js +28 -13
- package/src/council/seats.js +2 -2
- package/src/council/stage1-bind.js +3 -2
- package/src/council/verdict-seat-loss.js +124 -0
- package/src/council/verdict.js +108 -99
- package/src/headless.js +256 -49
- package/src/mcp-council-bench.js +64 -3
- package/src/mcp-council-run.js +10 -3
- package/src/mcp-server.js +52 -12
- package/src/mcp-tools.js +41 -5
- package/src/observe/council-legs.js +2 -2
- package/src/opencode-client.js +19 -1
- package/src/pack/pack-forward.js +15 -12
- package/src/pack/pack-resolve.js +1 -1
- package/src/prompt-builder.js +17 -1
- package/src/sidecar/fanout-leg-fallback.js +2 -1
- package/src/sidecar/fanout-leg.js +26 -0
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/list-council.js +178 -0
- package/src/sidecar/list-limit.js +3 -1
- package/src/sidecar/list-search.js +2 -1
- package/src/sidecar/models-render.js +71 -0
- package/src/sidecar/models.js +19 -45
- package/src/sidecar/read.js +34 -10
- package/src/sidecar/reopen-spend.js +2 -1
- package/src/sidecar/setup.js +13 -4
- package/src/sidecar/start.js +2 -1
- package/src/template/render.js +16 -7
- package/src/utils/alias-audit.js +10 -3
- package/src/utils/alias-shadow-writer.js +220 -0
- package/src/utils/alias-shadow.js +294 -0
- package/src/utils/config.js +1 -1
- package/src/utils/curated-models.js +19 -9
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +2 -5
- package/src/utils/engine-log-parse.js +289 -0
- package/src/utils/engine-log-tail.js +114 -0
- package/src/utils/engine-log.js +250 -0
- package/src/utils/engine-skew-records.js +146 -0
- package/src/utils/engine-skew.js +300 -0
- package/src/utils/gateway-router.js +21 -3
- package/src/utils/model-canonicalization.js +55 -6
- package/src/utils/model-catalog.js +27 -9
- package/src/utils/model-fetcher.js +69 -16
- package/src/utils/model-shortlist.js +5 -2
- package/src/utils/provider-default-picker.js +6 -3
- package/src/utils/quick-picks.js +45 -7
- package/src/utils/result-schema.js +17 -1
- package/src/utils/text-sanitize.js +81 -0
- package/src/utils/ttft.js +57 -0
- package/src/utils/untrusted-fence.js +111 -1
- package/src/workspace/fold-format.js +28 -7
- package/src/workspace/live-normalize.js +2 -1
- package/src/workspace/matrix-model.js +6 -2
- package/src/workspace/run-detail.js +33 -7
- package/src/workspace/seat-space.js +10 -6
|
@@ -136,48 +136,85 @@ function fetchModelsFromProvider(provider, key) {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/**
|
|
139
|
-
* Perform the HTTPS fetch + normalize for a single configured provider
|
|
140
|
-
*
|
|
139
|
+
* Perform the HTTPS fetch + normalize for a single configured provider,
|
|
140
|
+
* REPORTING why it failed (issue #209). The four failure modes used to
|
|
141
|
+
* collapse to a bare `[]`, which made a rejected fetch indistinguishable from
|
|
142
|
+
* a provider that legitimately serves no models -- and that ambiguity is what
|
|
143
|
+
* lets `classifyModel` return 'unknown' for a namespace whose fetch was
|
|
144
|
+
* actually refused (see #208).
|
|
141
145
|
* @param {string} provider - Key into PROVIDER_FETCH_CONFIG
|
|
142
146
|
* @param {string} key - API key
|
|
143
|
-
* @returns {Promise<Array
|
|
147
|
+
* @returns {Promise<{rows: Array, failure: {reason: string, status?: number, detail?: string}|null}>}
|
|
144
148
|
*/
|
|
145
|
-
function
|
|
149
|
+
function fetchViaConfigDetailed(provider, key) {
|
|
146
150
|
const config = PROVIDER_FETCH_CONFIG[provider];
|
|
147
151
|
const url = config.buildUrl ? config.buildUrl(key) : config.url;
|
|
148
152
|
const headers = config.authHeader(key);
|
|
149
153
|
|
|
150
154
|
return new Promise((resolve) => {
|
|
151
155
|
let chunks = '';
|
|
156
|
+
const ok = (rows) => resolve({ rows, failure: null });
|
|
157
|
+
const fail = (failure) => resolve({ rows: [], failure });
|
|
158
|
+
|
|
152
159
|
const timer = setTimeout(() => {
|
|
153
160
|
req.destroy();
|
|
154
|
-
|
|
161
|
+
fail({ reason: 'timeout', detail: `no response within ${FETCH_TIMEOUT_MS}ms` });
|
|
155
162
|
}, FETCH_TIMEOUT_MS);
|
|
156
163
|
|
|
157
164
|
const req = https.get(url, { headers }, (res) => {
|
|
158
165
|
if (res.statusCode !== 200) {
|
|
159
166
|
clearTimeout(timer);
|
|
160
167
|
res.on('data', () => {});
|
|
161
|
-
res.on('end', () =>
|
|
168
|
+
res.on('end', () => fail({ reason: 'http-status', status: res.statusCode }));
|
|
162
169
|
return;
|
|
163
170
|
}
|
|
164
171
|
res.on('data', (chunk) => { chunks += chunk; });
|
|
165
172
|
res.on('end', () => {
|
|
166
173
|
clearTimeout(timer);
|
|
167
174
|
try {
|
|
168
|
-
|
|
169
|
-
} catch (
|
|
170
|
-
|
|
175
|
+
ok(config.normalize(chunks));
|
|
176
|
+
} catch (err) {
|
|
177
|
+
fail({ reason: 'parse-error', detail: err.message });
|
|
171
178
|
}
|
|
172
179
|
});
|
|
173
180
|
});
|
|
174
|
-
req.on('error', () => {
|
|
181
|
+
req.on('error', (err) => {
|
|
175
182
|
clearTimeout(timer);
|
|
176
|
-
|
|
183
|
+
fail({ reason: 'network-error', detail: err.message });
|
|
177
184
|
});
|
|
178
185
|
});
|
|
179
186
|
}
|
|
180
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Rows-only view of `fetchViaConfigDetailed`, preserving the historical
|
|
190
|
+
* contract (`[]` on any failure) for existing callers.
|
|
191
|
+
* @param {string} provider @param {string} key @returns {Promise<Array>}
|
|
192
|
+
*/
|
|
193
|
+
function fetchViaConfig(provider, key) {
|
|
194
|
+
return fetchViaConfigDetailed(provider, key).then(r => r.rows);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Per-provider fetch WITH failure reporting. Mirrors
|
|
199
|
+
* `fetchModelsFromProvider`'s special cases exactly:
|
|
200
|
+
* - anthropic without a key: the hardcoded floor, no network, NOT a failure.
|
|
201
|
+
* - anthropic with a key that yields nothing: floor rows, failure reported.
|
|
202
|
+
* - unknown provider: no rows, not a failure (nothing was attempted).
|
|
203
|
+
* @param {string} provider @param {string} key
|
|
204
|
+
* @returns {Promise<{rows: Array, failure: object|null}>}
|
|
205
|
+
*/
|
|
206
|
+
function fetchModelsFromProviderDetailed(provider, key) {
|
|
207
|
+
const floor = () => ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }));
|
|
208
|
+
if (provider === 'anthropic') {
|
|
209
|
+
if (!key) { return Promise.resolve({ rows: floor(), failure: null }); }
|
|
210
|
+
return fetchViaConfigDetailed('anthropic', key).then(({ rows, failure }) =>
|
|
211
|
+
(rows.length > 0 ? { rows, failure: null } : { rows: floor(), failure }));
|
|
212
|
+
}
|
|
213
|
+
const config = PROVIDER_FETCH_CONFIG[provider];
|
|
214
|
+
if (!config) { return Promise.resolve({ rows: [], failure: null }); }
|
|
215
|
+
return fetchViaConfigDetailed(provider, key);
|
|
216
|
+
}
|
|
217
|
+
|
|
181
218
|
/** Providers to fetch: every keyed provider + openrouter (keyless-capable) + anthropic. */
|
|
182
219
|
function providersToFetch(keys) {
|
|
183
220
|
const set = new Set(Object.keys(keys).filter(p => keys[p]));
|
|
@@ -190,12 +227,16 @@ function providersToFetch(keys) {
|
|
|
190
227
|
* Fetch models from all providers that have keys configured; openrouter is
|
|
191
228
|
* always included (keyless public endpoint) as is anthropic (hardcoded list).
|
|
192
229
|
* @param {Object<string, string>} keys - Map of provider → API key string
|
|
193
|
-
* @returns {Promise<Array<{
|
|
230
|
+
* @returns {Promise<{rows: Array, failures: Array<{provider: string, reason: string, status?: number, detail?: string}>}>}
|
|
194
231
|
*/
|
|
195
|
-
async function
|
|
232
|
+
async function fetchAllModelsDetailed(keys) {
|
|
196
233
|
const providers = providersToFetch(keys);
|
|
197
|
-
const results = await Promise.all(providers.map(p =>
|
|
198
|
-
|
|
234
|
+
const results = await Promise.all(providers.map(p =>
|
|
235
|
+
fetchModelsFromProviderDetailed(p, keys[p] || '').then(r => ({ provider: p, ...r }))));
|
|
236
|
+
const rows = results.flatMap(r => r.rows);
|
|
237
|
+
const failures = results
|
|
238
|
+
.filter(r => r.failure)
|
|
239
|
+
.map(r => ({ provider: r.provider, ...r.failure }));
|
|
199
240
|
// v4.2 §4.4: append local-provider rows via the scheme-aware probe (5s, [] on failure).
|
|
200
241
|
try {
|
|
201
242
|
const { getLocalProviders } = require('./local-providers');
|
|
@@ -205,7 +246,17 @@ async function fetchAllModels(keys) {
|
|
|
205
246
|
listLocalModels(e, { timeoutMs: 5000, bearer: e.apiKeyEnv ? process.env[e.apiKeyEnv] : undefined })));
|
|
206
247
|
for (const r of localResults) { rows.push(...r); }
|
|
207
248
|
} catch (_err) { /* local rows are best-effort — never break the cloud catalog */ }
|
|
208
|
-
return rows;
|
|
249
|
+
return { rows, failures };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Rows-only view of `fetchAllModelsDetailed` — the historical signature, kept
|
|
254
|
+
* so existing callers and their tests are unaffected.
|
|
255
|
+
* @param {Object<string, string>} keys
|
|
256
|
+
* @returns {Promise<Array>} Combined model list
|
|
257
|
+
*/
|
|
258
|
+
async function fetchAllModels(keys) {
|
|
259
|
+
return (await fetchAllModelsDetailed(keys)).rows;
|
|
209
260
|
}
|
|
210
261
|
|
|
211
262
|
/**
|
|
@@ -236,6 +287,8 @@ function groupModelsByFamily(models) {
|
|
|
236
287
|
module.exports = {
|
|
237
288
|
fetchModelsFromProvider,
|
|
238
289
|
fetchAllModels,
|
|
290
|
+
fetchAllModelsDetailed,
|
|
291
|
+
fetchModelsFromProviderDetailed,
|
|
239
292
|
providersToFetch,
|
|
240
293
|
groupModelsByFamily,
|
|
241
294
|
ANTHROPIC_MODELS,
|
|
@@ -60,7 +60,8 @@ function compareShortlistRows(a, b) {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* @param {string} vendor e.g. 'deepseek'
|
|
63
|
-
* @param {{catalog?: Array<object>, recommendedId?: string, limit?: number
|
|
63
|
+
* @param {{catalog?: Array<object>, recommendedId?: string, limit?: number,
|
|
64
|
+
* providerFailures?: Array<{provider:string}>}} [options]
|
|
64
65
|
* @returns {{recommendedId: (string|null), suggested: Array<object>,
|
|
65
66
|
* rest: Array<object>, total: number}}
|
|
66
67
|
*/
|
|
@@ -69,7 +70,9 @@ function buildModelShortlist(vendor, options = {}) {
|
|
|
69
70
|
const limit = Number.isInteger(options.limit) && options.limit > 0
|
|
70
71
|
? options.limit : SHORTLIST_LIMIT;
|
|
71
72
|
|
|
72
|
-
const { preselectedId, rows } = buildProviderDefaultChoices(vendor, {
|
|
73
|
+
const { preselectedId, rows } = buildProviderDefaultChoices(vendor, {
|
|
74
|
+
catalog, providerFailures: options.providerFailures,
|
|
75
|
+
});
|
|
73
76
|
if (!rows || rows.length === 0) {
|
|
74
77
|
return { recommendedId: null, suggested: [], rest: [], total: 0 };
|
|
75
78
|
}
|
|
@@ -118,9 +118,11 @@ function chooseRowId(vendor, isDirect, row, paired, catalogInfo) {
|
|
|
118
118
|
* @param {string} vendor
|
|
119
119
|
* @returns {Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}
|
|
120
120
|
*/
|
|
121
|
-
function buildRows(catalog, vendor) {
|
|
121
|
+
function buildRows(catalog, vendor, providerFailures) {
|
|
122
122
|
const byId = new Map(catalog.filter(r => r && typeof r.id === 'string').map(r => [r.id, r]));
|
|
123
|
-
|
|
123
|
+
// issue 208: providerFailures MUST ride along -- this rebuilds catalogInfo from a bare
|
|
124
|
+
// array, so dropping it leaves directFormIfSafe's namespace gate dead in production.
|
|
125
|
+
const catalogInfo = { models: catalog, providerFailures: providerFailures || [] };
|
|
124
126
|
const directPrefix = `${vendor}/`;
|
|
125
127
|
const orPrefix = `openrouter/${vendor}/`;
|
|
126
128
|
// Hoisted: `vendor` is fixed for the whole call, so this is decided once
|
|
@@ -229,7 +231,7 @@ function buildProviderDefaultChoices(vendor, options = {}) {
|
|
|
229
231
|
if (typeof vendor !== 'string' || !vendor) { return { preselectedId: null, rows: [] }; }
|
|
230
232
|
|
|
231
233
|
const catalog = Array.isArray(options.catalog) ? options.catalog : [];
|
|
232
|
-
const rows = buildRows(catalog, vendor);
|
|
234
|
+
const rows = buildRows(catalog, vendor, options.providerFailures);
|
|
233
235
|
if (rows.length === 0) { return { preselectedId: null, rows: [] }; }
|
|
234
236
|
|
|
235
237
|
const preselectedId = computePreselectedId(vendor, options.tier, catalog, rows);
|
|
@@ -271,6 +273,7 @@ function buildProviderDefaultChoices(vendor, options = {}) {
|
|
|
271
273
|
* @returns {{alias: string, setAsDefault: boolean}}
|
|
272
274
|
*/
|
|
273
275
|
function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true, catalog } = {}) {
|
|
276
|
+
// Council C4 (PR 215): NO providerFailures -- directFormIfProven strips only on POSITIVE evidence, so a failed/empty namespace ('unknown') already returns chosenId untouched.
|
|
274
277
|
const catalogInfo = { models: Array.isArray(catalog) ? catalog : [] };
|
|
275
278
|
const storedId = directFormIfProven(vendor, chosenId, catalogInfo);
|
|
276
279
|
|
package/src/utils/quick-picks.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
|
-
const { getFamilies, toDefaultAliases,
|
|
13
|
+
const { getFamilies, toDefaultAliases, DIVERGENT_VENDORS } = require('./curated-models');
|
|
14
|
+
const { directFormIfSafe } = require('./model-canonicalization');
|
|
14
15
|
|
|
15
16
|
const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
|
|
16
17
|
|
|
@@ -84,12 +85,20 @@ function resolveQuickPicks(catalog) {
|
|
|
84
85
|
* @param {{vendorPath?:string, routes?:Object<string,string>}} pick
|
|
85
86
|
* @returns {string|undefined}
|
|
86
87
|
*/
|
|
87
|
-
function toStorableRoute(pick) {
|
|
88
|
+
function toStorableRoute(pick, catalogInfo) {
|
|
88
89
|
const routes = (pick && pick.routes) || {};
|
|
89
90
|
if (pick && DIVERGENT_VENDORS.has(pick.vendorPath)) {
|
|
90
91
|
return routes[pick.vendorPath] || routes.openrouter;
|
|
91
92
|
}
|
|
92
|
-
|
|
93
|
+
const route = routes.openrouter || Object.values(routes)[0];
|
|
94
|
+
if (!route) { return undefined; }
|
|
95
|
+
// issue 214 remedy 1: this value is PERSISTED (sidecar/setup.js writes it into
|
|
96
|
+
// config.aliases; toLiveSeedAliases seeds a fresh config with it), so it must
|
|
97
|
+
// not be a blind prefix strip. directFormIfSafe keeps the optimism for a
|
|
98
|
+
// namespace that was never fetched while refusing for one the catalog
|
|
99
|
+
// disproves OR whose fetch was rejected -- the gap #208 closed on the picker
|
|
100
|
+
// path and left open here.
|
|
101
|
+
return directFormIfSafe(pick.vendorPath, route, catalogInfo || { models: [] });
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
/**
|
|
@@ -98,15 +107,44 @@ function toStorableRoute(pick) {
|
|
|
98
107
|
* overlaid value is not a raw prefix strip.
|
|
99
108
|
* @returns {Object<string,string>}
|
|
100
109
|
*/
|
|
101
|
-
function toLiveSeedAliases(
|
|
110
|
+
function toLiveSeedAliases(catalogOrInfo) {
|
|
111
|
+
// Accepts the bare models array (historical callers) or a full catalogInfo.
|
|
112
|
+
// issue 214: the evidence was always handed in and then discarded.
|
|
113
|
+
const info = Array.isArray(catalogOrInfo)
|
|
114
|
+
? { models: catalogOrInfo }
|
|
115
|
+
: (catalogOrInfo || { models: [] });
|
|
102
116
|
const seeds = toDefaultAliases();
|
|
103
|
-
for (const r of resolveQuickPicks(
|
|
117
|
+
for (const r of resolveQuickPicks(info.models || [])) {
|
|
104
118
|
if (r.source === 'live' && r.routes.openrouter) {
|
|
105
|
-
const stored = toStorableRoute(r);
|
|
119
|
+
const stored = toStorableRoute(r, info);
|
|
106
120
|
if (stored) { seeds[r.alias] = stored; }
|
|
107
121
|
}
|
|
108
122
|
}
|
|
109
123
|
return seeds;
|
|
110
124
|
}
|
|
111
125
|
|
|
112
|
-
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Per-provider SAFE storable form for a resolved quick pick (issue 214).
|
|
129
|
+
*
|
|
130
|
+
* The wizard renderer used to derive this itself, via a hand-copy of
|
|
131
|
+
* `stripGatewayPrefix` (`toBareIfDirect`) that dropped both of the real
|
|
132
|
+
* primitive's guards: it stripped `openrouter/` for DIVERGENT_VENDORS
|
|
133
|
+
* (fabricating anthropic's dot id, which the direct API rejects) and stripped
|
|
134
|
+
* for a namespace whose fetch had failed. The renderer cannot `require()`, so
|
|
135
|
+
* the decision is made here -- once, with the catalog in hand -- and shipped
|
|
136
|
+
* to the page as data.
|
|
137
|
+
* @param {{vendorPath: string, routes: Object<string,string>}} pick
|
|
138
|
+
* @param {{models: Array<{id:string}>, providerFailures?: Array<{provider:string}>}} catalogInfo
|
|
139
|
+
* @returns {Object<string,string>} provider -> safe storable id
|
|
140
|
+
*/
|
|
141
|
+
function canonicalRoutesFor(pick, catalogInfo) {
|
|
142
|
+
const out = {};
|
|
143
|
+
const routes = (pick && pick.routes) || {};
|
|
144
|
+
for (const [provider, route] of Object.entries(routes)) {
|
|
145
|
+
out[provider] = directFormIfSafe(pick.vendorPath, route, catalogInfo || { models: [] });
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { compareIdsDesc, canonicalRoutesFor, pickCurrent, resolveQuickPicks, toLiveSeedAliases, toStorableRoute };
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* split out so ./abort-result.js can depend on it without a circular require).
|
|
9
9
|
*/
|
|
10
10
|
const { SCHEMA_VERSION } = require('./result-schema-version');
|
|
11
|
+
// v4.9 W13 Task A (PR #207 round 3, B3): the shared ttftMs honesty predicate.
|
|
12
|
+
const { isMeasuredTtft } = require('./ttft');
|
|
11
13
|
|
|
12
14
|
/** Leg/run statuses that count as terminal for wave aggregation. */
|
|
13
15
|
const TERMINAL_STATUSES = ['complete', 'error', 'timeout', 'aborted', 'crashed', 'idle-timeout'];
|
|
@@ -70,6 +72,14 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
|
|
|
70
72
|
durationMs,
|
|
71
73
|
sessionDir,
|
|
72
74
|
opencodeSessionId: metadata.opencodeSessionId || null,
|
|
75
|
+
// v4.9 W13 Task A (probe only, R12): time-to-first-token, sourced straight
|
|
76
|
+
// off `metadata` like `opencodeSessionId` above. ADDITIVE and emit-when-set
|
|
77
|
+
// (the pack/tag spread form below, not the `|| null` coercion above): an
|
|
78
|
+
// absent ttft means "no substantive tick was observed", which is neither
|
|
79
|
+
// zero nor null, so it must not be coerced into either. PR #207 round 3
|
|
80
|
+
// (B3): emit-when-VALID via the shared predicate — `metadata` is read off
|
|
81
|
+
// disk, so NaN/±Infinity/negatives/fractions all reach here. See ./ttft.js.
|
|
82
|
+
...(isMeasuredTtft(metadata.ttftMs) ? { ttftMs: metadata.ttftMs } : {}),
|
|
73
83
|
usage: usage !== null ? usage : (metadata.usage || null),
|
|
74
84
|
...(metadata.pack ? { pack: metadata.pack } : {}),
|
|
75
85
|
...(metadata.tag ? { tag: metadata.tag } : {}),
|
|
@@ -221,7 +231,8 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
|
|
|
221
231
|
* probeSkipped?: string|null}} opts
|
|
222
232
|
*/
|
|
223
233
|
function buildAuditDoc({
|
|
224
|
-
stale, catalogAvailable, gatewayFindings = [], drifted = [], probe = [], probeSkipped = null
|
|
234
|
+
stale, catalogAvailable, gatewayFindings = [], drifted = [], probe = [], probeSkipped = null,
|
|
235
|
+
providerFailures = []
|
|
225
236
|
}) {
|
|
226
237
|
return {
|
|
227
238
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -236,6 +247,11 @@ function buildAuditDoc({
|
|
|
236
247
|
probeCount: probe.length,
|
|
237
248
|
probe,
|
|
238
249
|
probeSkipped,
|
|
250
|
+
// issue 209: providers ATTEMPTED and REJECTED for the catalog in hand. An
|
|
251
|
+
// empty namespace is otherwise indistinguishable from a provider that
|
|
252
|
+
// genuinely serves nothing.
|
|
253
|
+
providerFailuresCount: providerFailures.length,
|
|
254
|
+
providerFailures,
|
|
239
255
|
};
|
|
240
256
|
}
|
|
241
257
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/text-sanitize
|
|
3
|
+
* One third-party string, safe to render: no escapes, no bidi, one short line.
|
|
4
|
+
*
|
|
5
|
+
* EXTRACTED from src/utils/engine-log-parse.js (v4.9 W10 round 3). It was built
|
|
6
|
+
* there for the engine-log excerpt (round-2 review B2), but reviews B1+C2 put a
|
|
7
|
+
* SECOND caller on it — `utils/engine-skew.js` runs server-reported version
|
|
8
|
+
* strings through the same pass before they reach the stderr notice and the
|
|
9
|
+
* death-report clause. Leaving it in the parse module meant the skew detector
|
|
10
|
+
* had to depend on the log parser to sanitize a version number, which is a
|
|
11
|
+
* dependency edge that says nothing true about either module. Here, neither
|
|
12
|
+
* caller owns it and both just use it.
|
|
13
|
+
*
|
|
14
|
+
* `engine-log-parse.js` RE-EXPORTS both names, so every existing import path
|
|
15
|
+
* stays valid and the extraction pins can assert these are the SAME function
|
|
16
|
+
* objects rather than a second copy — the same shape as the round-2 splits.
|
|
17
|
+
*
|
|
18
|
+
* THE RULE, in one line: this is the ONLY sanitizer. A second implementation
|
|
19
|
+
* would be a second set of holes, and the holes are the point — every finding
|
|
20
|
+
* that has landed here (ANSI in round 2, bidi in round 3) was a class the
|
|
21
|
+
* previous pass could not see.
|
|
22
|
+
*
|
|
23
|
+
* PURE: no I/O, no throwing paths, no state.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
/** One short line: long enough for a real engine error, short enough to ride
|
|
29
|
+
* inside an error string that already carries the backstop's own sentence. */
|
|
30
|
+
const MAX_EXCERPT_CHARS = 200;
|
|
31
|
+
|
|
32
|
+
/** An ANSI escape sequence: CSI (`ESC [ … final`), OSC (`ESC ] … BEL/ST`), or a
|
|
33
|
+
* bare two-character escape. */
|
|
34
|
+
// eslint-disable-next-line no-control-regex
|
|
35
|
+
const ANSI_SEQUENCE = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[@-_])/g;
|
|
36
|
+
/** C0 and C1 control characters, DEL included. */
|
|
37
|
+
// eslint-disable-next-line no-control-regex
|
|
38
|
+
const CONTROL_CHAR = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
39
|
+
/**
|
|
40
|
+
* Unicode BIDI controls: the embedding/override set, the isolates, the marks.
|
|
41
|
+
*
|
|
42
|
+
* Round-3 review A2. PRINTABLE-range code points, so the C0/C1 sweep above never
|
|
43
|
+
* saw them — and each reorders the characters that follow, letting a third
|
|
44
|
+
* party's error string rewrite the sentence it is quoted into (a RIGHT-TO-LEFT
|
|
45
|
+
* OVERRIDE renders the rest of the line backwards). Formatting, so DROPPED.
|
|
46
|
+
*/
|
|
47
|
+
const BIDI_CONTROL = /[\u202a-\u202e\u2066-\u2069\u200e\u200f\u061c]/g;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One line, no control characters, no bidi controls, at most `maxChars`.
|
|
51
|
+
*
|
|
52
|
+
* SANITIZED, not just collapsed (round-2 review B2). This is quoted verbatim
|
|
53
|
+
* from a third party's text into output that reaches terminals and MCP
|
|
54
|
+
* surfaces; engine lines can embed provider output, colour codes and all.
|
|
55
|
+
* Escape sequences and bidi controls are DROPPED (formatting: removing them
|
|
56
|
+
* keeps `<esc>[31mred<esc>[0m` as `red`, not as spaced-out text); every
|
|
57
|
+
* remaining control byte becomes a space, which the collapse below tidies.
|
|
58
|
+
*
|
|
59
|
+
* THE CAP IS A PARAMETER because this is the house sanitizer for any
|
|
60
|
+
* third-party string we render, not only log excerpts: `utils/engine-skew.js`
|
|
61
|
+
* runs SERVER-SUPPLIED version strings through it at a shorter cap before they
|
|
62
|
+
* enter the stderr notice and the death-report clause (round-3 reviews B1+C2).
|
|
63
|
+
* One sanitizer, one dialect — a second implementation is a second set of holes.
|
|
64
|
+
* @param {*} text
|
|
65
|
+
* @param {number} [maxChars]
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function collapseExcerpt(text, maxChars = MAX_EXCERPT_CHARS) {
|
|
69
|
+
const oneLine = String(text === undefined || text === null ? '' : text)
|
|
70
|
+
.replace(ANSI_SEQUENCE, '')
|
|
71
|
+
.replace(BIDI_CONTROL, '')
|
|
72
|
+
.replace(CONTROL_CHAR, ' ')
|
|
73
|
+
.replace(/\s+/g, ' ').trim();
|
|
74
|
+
if (oneLine.length <= maxChars) { return oneLine; }
|
|
75
|
+
return `${oneLine.slice(0, maxChars - 1)}…`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
collapseExcerpt,
|
|
80
|
+
MAX_EXCERPT_CHARS,
|
|
81
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/ttft
|
|
3
|
+
* The one honesty predicate for the time-to-first-token probe (v4.9 W13).
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ The JSDoc leads this file, ahead of `'use strict'`, matching
|
|
6
|
+
* `utils/text-sanitize.js` / `utils/engine-skew.js` / `utils/alias-shadow.js`:
|
|
7
|
+
* `scripts/generate-docs.js` only reads a block comment that starts at byte
|
|
8
|
+
* zero, so a `// path` line above it would leave this module's CLAUDE.md row
|
|
9
|
+
* blank the way `utils/result-schema.js`'s already is.
|
|
10
|
+
*
|
|
11
|
+
* `ttftMs` is produced once — in `src/headless.js`'s poll loop, as a
|
|
12
|
+
* `Date.now()` delta — and then passes four EMIT GATES on its way to a
|
|
13
|
+
* document: `headless.js`'s three returns, `sidecar/fanout-leg.js`'s leg patch,
|
|
14
|
+
* `utils/result-schema.js :: buildRunResult`, and
|
|
15
|
+
* `council/run-stats-entry.js :: buildRunStatsEntry`. Every gate used to spell
|
|
16
|
+
* its own `typeof x === 'number'` test, which is four chances to disagree and
|
|
17
|
+
* four ways to publish a value both schemas forbid.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ `typeof` is not the schema's contract. `schemas/run.schema.json` and
|
|
20
|
+
* `schemas/council-tally.schema.json` both declare this field
|
|
21
|
+
* `integer, minimum 0`, and `typeof` admits four families that violate it:
|
|
22
|
+
* · NaN — `JSON.stringify` writes it as `null`, so the artifact breaks its own
|
|
23
|
+
* schema while LOOKING like the honest absence the emit-when-set rule means.
|
|
24
|
+
* · ±Infinity — not hypothetical from an artifact: `JSON.parse('1e999')` is
|
|
25
|
+
* `Infinity` (MEASURED), and it also serializes to `null`.
|
|
26
|
+
* · Negative — the probe is a wall-clock delta, so a backward jump (NTP
|
|
27
|
+
* correction, a VM resuming from suspend, a manual clock set) between the
|
|
28
|
+
* clock origin and the first substantive poll measures below zero.
|
|
29
|
+
* · Fractional — a hand-edited leg document.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ DROP, DO NOT CLAMP. A skewed −5 s reading clamped to `0` would publish
|
|
32
|
+
* "first token inside the first poll" — the most consequential value in the
|
|
33
|
+
* distribution the C2 derivation will read — for a leg that measured nothing of
|
|
34
|
+
* the kind. Emit-when-VALID is the same discipline as emit-when-set: absence
|
|
35
|
+
* already means "no honest measurement was made", and a dishonest number is
|
|
36
|
+
* exactly that. `0` itself stays a real, emittable measurement.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ Three of the four gates import this. The fourth,
|
|
39
|
+
* `council/run-stats-entry.js`, is pinned REQUIRE-FREE (P3,
|
|
40
|
+
* tests/council/run-stats-entry.test.js — the pin fires on the character
|
|
41
|
+
* sequence anywhere in that file, comments included) so require-free consumers
|
|
42
|
+
* can import it, so it spells the same expression by hand. The structural pins
|
|
43
|
+
* that keep the hand-spelled copy in step live in that same test file.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
'use strict';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Is this a real time-to-first-token measurement, fit to ride a document?
|
|
50
|
+
* @param {*} value
|
|
51
|
+
* @returns {boolean} true only for a non-negative integer millisecond count.
|
|
52
|
+
*/
|
|
53
|
+
function isMeasuredTtft(value) {
|
|
54
|
+
return Number.isInteger(value) && value >= 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { isMeasuredTtft };
|
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
* stays intact inside the fence. It must NOT be applied to CLI --json stdout
|
|
18
18
|
* (the byte-parseable programmatic channel) or amicus_read mode=metadata —
|
|
19
19
|
* structured data a caller parses, where wrapping would break the contract.
|
|
20
|
+
*
|
|
21
|
+
* This module ALSO hosts the OUTBOUND family's tag neutralizer
|
|
22
|
+
* (`defangOutboundFenceTags`, PR #200 tails B2/C2; close tags only until PR
|
|
23
|
+
* #206 round 3 B3b took it to open tags too). It lives here rather than
|
|
24
|
+
* beside either outbound builder because there are two of them — the council
|
|
25
|
+
* briefing tail (src/council/briefings-stage2-task.js :: fenceBriefing) and the
|
|
26
|
+
* parent-conversation section (src/prompt-builder.js :: buildContextSection) —
|
|
27
|
+
* in two directories that do not import each other, and this is the one module
|
|
28
|
+
* in the tree whose subject is fences and which both can reach without either
|
|
29
|
+
* depending on the other.
|
|
20
30
|
*/
|
|
21
31
|
'use strict';
|
|
22
32
|
|
|
@@ -36,4 +46,104 @@ ${body}
|
|
|
36
46
|
</untrusted_sidecar_output>`;
|
|
37
47
|
}
|
|
38
48
|
|
|
39
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Every OUTBOUND house fence's tag name. ONE list, deliberately: both surfaces
|
|
51
|
+
* run the neutralizer over their whole vocabulary, not just their own tag, so a
|
|
52
|
+
* fence added to the family is covered everywhere by editing this array — and a
|
|
53
|
+
* body that carries the SIBLING surface's tags (either end, since round 3)
|
|
54
|
+
* cannot smuggle them through the surface that does not happen to emit them.
|
|
55
|
+
* @see src/council/briefings-stage2-task.js :: BRIEFING_FENCE_CLOSE
|
|
56
|
+
* @see src/prompt-builder.js :: buildContextSection
|
|
57
|
+
*/
|
|
58
|
+
const OUTBOUND_FENCE_TAGS = ['council_briefing', 'previous_conversation'];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `</council_briefing>` / `</previous_conversation>`, case-insensitively and
|
|
62
|
+
* tolerating whitespace ANYWHERE inside the angle brackets — including between
|
|
63
|
+
* the `<` and the `/` (round 3, C1: the round-2 pattern required those two to
|
|
64
|
+
* be adjacent, so `< /council_briefing>` rode through untouched, and a reading
|
|
65
|
+
* model honours that spelling exactly as readily as the tight one). The slash
|
|
66
|
+
* and its surrounding space are INSIDE the captured group, so the author's own
|
|
67
|
+
* spelling survives into the defanged form and nothing is hidden.
|
|
68
|
+
* ⚠️ Module-level and `g`-flagged: safe ONLY because its sole use is
|
|
69
|
+
* `String#replace`, which resets `lastIndex` around the call. A `.test()` on
|
|
70
|
+
* this object would carry `lastIndex` between calls and skip matches — build a
|
|
71
|
+
* fresh regex if one is ever needed. Same warning for the OPEN pattern below.
|
|
72
|
+
*/
|
|
73
|
+
const OUTBOUND_FENCE_CLOSE_RE =
|
|
74
|
+
new RegExp(`<(\\s*\\/\\s*(?:${OUTBOUND_FENCE_TAGS.join('|')})\\s*)>`, 'gi');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The same families' OPEN tags, attributes and all (round 3, B3b).
|
|
78
|
+
*
|
|
79
|
+
* `\\b` after the name is what keeps this off tags that merely START with a
|
|
80
|
+
* house name — `<council_briefingx>` is somebody else's markup — and
|
|
81
|
+
* `[^<>]*` carries whatever attributes the author wrote through into the
|
|
82
|
+
* escaped form. It cannot match a CLOSE tag: `\\s*` does not consume the `/`.
|
|
83
|
+
*/
|
|
84
|
+
const OUTBOUND_FENCE_OPEN_RE =
|
|
85
|
+
new RegExp(`<(\\s*(?:${OUTBOUND_FENCE_TAGS.join('|')})\\b[^<>]*)>`, 'gi');
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Neutralize any outbound house fence tag inside a body about to be embedded in
|
|
89
|
+
* one (PR #200 tails B2/C2; round 3 B3b/C1 widened it from closes to both ends).
|
|
90
|
+
*
|
|
91
|
+
* Without this, untrusted text that contains the close tag ends the fence early
|
|
92
|
+
* in the reading model's eyes and everything after it reads as the engine
|
|
93
|
+
* speaking — the fence's entire purpose, undone by a string the author types.
|
|
94
|
+
* The replacement is an entity escape (`</council_briefing>`), so the
|
|
95
|
+
* text stays legible as what the author wrote while no longer being a tag:
|
|
96
|
+
* nothing is deleted and nothing is silently swallowed.
|
|
97
|
+
*
|
|
98
|
+
* OPEN TAGS TOO, and round 2's reason for skipping them was wrong (B3b). That
|
|
99
|
+
* reason — "an open tag inside a fence cannot escape it" — holds for a STRICT
|
|
100
|
+
* parser and fails for the reader this fence is actually addressed to. A model
|
|
101
|
+
* that balances tags reads the attacker's `<council_briefing …>` and the
|
|
102
|
+
* engine's REAL `</council_briefing>` as one pair: the attacker's tag gets the
|
|
103
|
+
* close, and the engine's fence is left unterminated. Everything after the
|
|
104
|
+
* attacker's open reads as fenced material, and everything the ENGINE writes
|
|
105
|
+
* after the real close reads as still inside a fence. That is the same escape
|
|
106
|
+
* one tag along. MEASURED before widening (W12, re-verified round 3): nothing
|
|
107
|
+
* in the tree PARSES these tags — the only occurrences outside test assertions
|
|
108
|
+
* are the two producers' own literals — so escaping opens breaks no consumer.
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ THE BOUNDARY IS SOFT, and this is a disclosure, not a caveat. An entity
|
|
111
|
+
* escape is a convention about how a READING MODEL should interpret bytes, not
|
|
112
|
+
* a parser guarantee: some models decode `</council_briefing>` back to
|
|
113
|
+
* the tag while reading, and a model that does is not fenced by this. What this
|
|
114
|
+
* buys is defense in depth — it removes the LITERAL tag, so the escape stops
|
|
115
|
+
* being free and starts depending on a decoding step the attacker does not
|
|
116
|
+
* control. The load-bearing protection is still the preamble both fences carry
|
|
117
|
+
* (the enclosed text is reference material, not instructions); this hardens it,
|
|
118
|
+
* and does not replace it. Deleting the tag outright would be a stronger
|
|
119
|
+
* boundary and a worse product — the reader would silently lose text the author
|
|
120
|
+
* wrote, which is the failure the whole fence exists to avoid.
|
|
121
|
+
*
|
|
122
|
+
* Total over non-strings (returns its argument unchanged): callers hand it
|
|
123
|
+
* whatever they were given, and a missing body must never be a throw inside a
|
|
124
|
+
* prompt builder.
|
|
125
|
+
* @param {string} text the untrusted body about to be fenced
|
|
126
|
+
* @returns {string} the same bytes when it carries no house tag
|
|
127
|
+
*/
|
|
128
|
+
function defangOutboundFenceTags(text) {
|
|
129
|
+
if (typeof text !== 'string') { return text; }
|
|
130
|
+
// Closes first: once a close is escaped its `<` is gone, so the OPEN pattern
|
|
131
|
+
// cannot see it. (It could not match one anyway — see the pattern's note —
|
|
132
|
+
// but the order makes that independent of the pattern staying that way.)
|
|
133
|
+
return text
|
|
134
|
+
.replace(OUTBOUND_FENCE_CLOSE_RE, '<$1>')
|
|
135
|
+
.replace(OUTBOUND_FENCE_OPEN_RE, '<$1>');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// FUNCTIONS FIRST, constant last — the PR 201 round-2 workaround for
|
|
139
|
+
// `scripts/generate-docs-helpers.js :: extractExports`, which renders EVERY
|
|
140
|
+
// export as `name()` and keeps only the first five. Ordering conceals a
|
|
141
|
+
// constant behind that cap; MEASURED here, it cannot, because this module
|
|
142
|
+
// exports three names and the cap never fires. So CLAUDE.md's Key Exports cell
|
|
143
|
+
// reads `OUTBOUND_FENCE_TAGS()` for what is an array — a known-defect instance
|
|
144
|
+
// of the generator bug, not a claim this module makes, and the second one below
|
|
145
|
+
// the workaround's floor after `utils/text-sanitize.js`. It goes away with the
|
|
146
|
+
// generator ruling filed in BACKLOG.md; padding the export list to five to hide
|
|
147
|
+
// it would not be a fix, and the list itself must stay exported —
|
|
148
|
+
// tests/utils/outbound-fence-defang.test.js reads it as the LIVE vocabulary.
|
|
149
|
+
module.exports = { fenceSidecarOutput, defangOutboundFenceTags, OUTBOUND_FENCE_TAGS };
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* (src/headless.js `formatFoldOutput`, exported from src/headless.js and
|
|
6
6
|
* re-exported from src/index.js) — byte-for-byte the same **7-line** head:
|
|
7
7
|
* marker, Model, Session, Client, CWD, Mode, `---`. ⚠️ v4.4.1 DOC-5: this said
|
|
8
|
-
* "8-line" for two releases. Line 8 (`VERDICT:`
|
|
8
|
+
* "8-line" for two releases. Line 8 (the terminal line — `VERDICT:` on a review
|
|
9
|
+
* run, `ANSWER:` on a task one since v4.9 W8) is council's OWN addition and
|
|
9
10
|
* has no counterpart in formatFoldOutput, whose 8th element is the summary
|
|
10
11
|
* body. Only the first 7 lines are the shared contract; anyone changing the
|
|
11
|
-
* shared format must sync those and leave
|
|
12
|
+
* shared format must sync those and leave the terminal line alone.
|
|
12
13
|
* src/headless.js is the SOURCE OF TRUTH; keep the head in sync with it.
|
|
13
14
|
* (Line numbers deliberately omitted — the previous `:775-789`/`:797` citation
|
|
14
15
|
* had drifted by ~500 lines.) The duplication is deliberate:
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
* The chair body is UNTRUSTED model text: it passes through stripFoldMarkers
|
|
25
26
|
* before embedding, so chair prose containing a marker can never truncate or
|
|
26
27
|
* spoof the fold (the exact hazard the nonce closure exists for).
|
|
27
|
-
* Degradation mirrors the engine's ladder: no chair →
|
|
28
|
+
* Degradation mirrors the engine's ladder: no chair → `<terminal>: none` + tally
|
|
28
29
|
* summary; pre-tally → stage/status summary. Never blocked, always labeled.
|
|
29
30
|
*/
|
|
30
31
|
'use strict';
|
|
@@ -63,14 +64,34 @@ function buildFoldText(o) {
|
|
|
63
64
|
// amicus_verdict MCP path types overallVerdict as a bare z.string().nullable()
|
|
64
65
|
// — mcp-tools.js :: amicus_verdict), so a multi-line or marker-bearing value
|
|
65
66
|
// must never reach the head verbatim: an embedded '\n' would shift every line below
|
|
66
|
-
//
|
|
67
|
+
// the terminal line (a raw string containing '\n' becomes several elements once the
|
|
67
68
|
// head array is '\n'-joined), and an embedded marker could spoof the fold.
|
|
68
|
-
// Safe on the shipped engine path (
|
|
69
|
-
//
|
|
69
|
+
// Safe on the shipped engine path (`run-chair.js :: runChair` parses the chair's
|
|
70
|
+
// terminal line with `parse-stage2.js :: parseChairTerminal`, which returns a
|
|
71
|
+
// canonical phrase of the run's OWN scale — CHAIR_VERDICTS on a review run,
|
|
72
|
+
// CHAIR_ANSWERS on a task one) — this is defense-in-depth, not a fix for a real
|
|
70
73
|
// producer.
|
|
74
|
+
// ⚠️ DE-ROT (v4.9 W8): this said "parseChairVerdict returns a canonical
|
|
75
|
+
// CHAIR_VERDICTS phrase", true only until W7 added the second scale — after
|
|
76
|
+
// which it named the wrong parser for every task run.
|
|
71
77
|
const overall = verdict && verdict.overallVerdict
|
|
72
78
|
? stripFoldMarkers(String(verdict.overallVerdict)).replace(/[\r\n]+/g, ' ').trim()
|
|
73
79
|
: null;
|
|
80
|
+
// v4.9 W8: the terminal line names the SCALE its phrase belongs to. The two
|
|
81
|
+
// scales are disjoint by pinned construction (tests/council/chair-scale-drift.test.js),
|
|
82
|
+
// so labelling a CHAIR_ANSWERS phrase `VERDICT:` asserts a scale it is not on.
|
|
83
|
+
// `intent` is emit-when-'task' (verdict.js :: buildVerdict, the W5 ruling), so
|
|
84
|
+
// absence — and an explicit 'review' — keep the review fold byte-identical.
|
|
85
|
+
// ⚠️ v4.9 fix round 2 (council B2): `run.intent` is the SECOND carrier, and it
|
|
86
|
+
// is what covers the DEGRADED fold — the one a user reaches for precisely when
|
|
87
|
+
// a task run went wrong. With no verdict.json (or a parse-failed one) there is
|
|
88
|
+
// no verdict.intent, and the fold headed `VERDICT: none` on a run that was
|
|
89
|
+
// never on that scale. `o.run` is the parsed run.json, which checkpoints
|
|
90
|
+
// `intent: 'task'` at start (src/council/run.js :: runCouncil):
|
|
91
|
+
// electron/ipc-workspace.js passes `run: detail.run` and run-detail.js ::
|
|
92
|
+
// getRunDetail reads it straight from `<runDir>/run.json`.
|
|
93
|
+
// PR #200 round-5 B3: parens on the whole disjunction — behaviour-identical.
|
|
94
|
+
const terminalLabel = ((verdict && verdict.intent === 'task') || run.intent === 'task') ? 'ANSWER' : 'VERDICT';
|
|
74
95
|
const tierCounts = (verdict && verdict.tierCounts) || (tally && tally.tierCounts) || null;
|
|
75
96
|
const cost = run.usage && run.usage.cost ? run.usage.cost : null;
|
|
76
97
|
|
|
@@ -82,7 +103,7 @@ function buildFoldText(o) {
|
|
|
82
103
|
`CWD: ${o.project}`,
|
|
83
104
|
'Mode: council',
|
|
84
105
|
'---',
|
|
85
|
-
|
|
106
|
+
`${terminalLabel}: ${overall || 'none'}`,
|
|
86
107
|
];
|
|
87
108
|
if (tierCounts) {
|
|
88
109
|
head.push(tierLine(tierCounts));
|