amicus 4.8.0 → 4.9.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-plugin/plugin.json +1 -1
- package/CHANGELOG.md +355 -0
- package/README.md +10 -5
- package/docs/CITATIONS.md +13 -5
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +55 -5
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +39 -2
- package/electron/main.js +46 -3
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui-model.js +99 -9
- package/electron/setup-ui-styles.js +22 -0
- package/electron/setup-ui.js +244 -32
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- package/electron/workspace-ui/live-seats.js +4 -4
- 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 +2 -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 +12 -15
- 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 +64 -16
- 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.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.js +8 -1
- package/src/sidecar/read.js +34 -10
- package/src/sidecar/setup.js +124 -0
- package/src/template/render.js +16 -7
- package/src/utils/alias-audit.js +81 -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 +16 -8
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +149 -0
- 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 +10 -2
- package/src/utils/model-canonicalization.js +64 -0
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/model-shortlist.js +100 -0
- package/src/utils/provider-default-picker.js +93 -45
- package/src/utils/provider-default-prompt.js +1 -1
- package/src/utils/quick-picks.js +2 -2
- package/src/utils/remediation-hints.js +24 -0
- package/src/utils/result-schema.js +10 -0
- 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 +35 -9
- package/src/workspace/seat-space.js +10 -6
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
const { resolveTier } = require('./model-tiers');
|
|
18
18
|
const { getCostTier, loadConfig, saveConfig } = require('./config');
|
|
19
19
|
const { pairAcrossGateways } = require('./gateway-route-catalog');
|
|
20
|
-
const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
|
|
21
20
|
const { isLocalProvider } = require('./local-providers');
|
|
21
|
+
// directFormIfSafe (list-building) / directFormIfProven (persistence): see
|
|
22
|
+
// model-canonicalization.js's module docstring for why these are two
|
|
23
|
+
// separately-named functions rather than one with an optional mode.
|
|
24
|
+
const { directFormIfSafe, directFormIfProven } = require('./model-canonicalization');
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* @param {{pricing?: {prompt?: string|number|null}|null}|null|undefined} orRow
|
|
@@ -49,8 +52,10 @@ function vendorRowsIn(catalog, vendor) {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
/**
|
|
52
|
-
* Choose the
|
|
53
|
-
*
|
|
55
|
+
* Choose the id a single catalog `row` should surface as in the picker.
|
|
56
|
+
* Prefers a real, verbatim catalog id; only SYNTHESISES one (delegating to
|
|
57
|
+
* `directFormIfSafe`, model-canonicalization.js -- see its module docstring
|
|
58
|
+
* for the guard) for a non-divergent vendor with no direct twin at all.
|
|
54
59
|
*
|
|
55
60
|
* A direct-namespace row always keeps its own (real, direct-callable) id,
|
|
56
61
|
* regardless of what `pairAcrossGateways` could resolve for it -- this is
|
|
@@ -60,35 +65,55 @@ function vendorRowsIn(catalog, vendor) {
|
|
|
60
65
|
* and must not be dropped or silently merged.
|
|
61
66
|
*
|
|
62
67
|
* An OpenRouter-namespace row is collapsed onto a direct id only when
|
|
63
|
-
* `pairAcrossGateways` found exactly one (unambiguous) direct twin
|
|
64
|
-
* a real catalog id, safe to reuse for dedup. Otherwise: for
|
|
68
|
+
* `pairAcrossGateways` found exactly one (unambiguous) direct twin. For
|
|
65
69
|
* `DIVERGENT_VENDORS` (direct ids don't share a string form with
|
|
66
|
-
* OpenRouter's
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* safe because their direct and OpenRouter ids are identical once the
|
|
71
|
-
* `openrouter/<vendor>/` prefix is removed (mirrors curated-models.js's
|
|
72
|
-
* `directFormFor` policy).
|
|
70
|
+
* OpenRouter's, e.g. anthropic's dash-vs-dot versioning), the row keeps its
|
|
71
|
+
* OpenRouter-prefixed id as-is -- `directFormIfSafe` gates that set FIRST
|
|
72
|
+
* and internally (model-canonicalization.js), so stripping is never even
|
|
73
|
+
* attempted on their ids, which is not a string-safe operation.
|
|
73
74
|
* @param {string} vendor
|
|
74
75
|
* @param {boolean} isDirect whether `row.id` is itself a direct-namespace id
|
|
75
76
|
* @param {{id:string}} row
|
|
76
77
|
* @param {{direct?:string, openrouter?:string}} paired
|
|
78
|
+
* @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
|
|
77
79
|
* @returns {string|null}
|
|
78
80
|
*/
|
|
79
|
-
function chooseRowId(vendor, isDirect, row, paired) {
|
|
81
|
+
function chooseRowId(vendor, isDirect, row, paired, catalogInfo) {
|
|
80
82
|
if (isDirect) { return row.id; }
|
|
81
83
|
if (paired.direct) { return paired.direct; }
|
|
82
84
|
if (!paired.openrouter) { return null; }
|
|
83
|
-
return
|
|
85
|
+
return directFormIfSafe(vendor, paired.openrouter, catalogInfo);
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
/**
|
|
87
89
|
* Dedupe `vendor`'s catalog rows across gateways into one row per logical
|
|
88
90
|
* model, reusing `pairAcrossGateways` (never re-deriving the pairing logic).
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
91
|
+
* A direct row is never dropped even when its OpenRouter twin is ambiguous.
|
|
92
|
+
*
|
|
93
|
+
* Row ids are verbatim catalog ids whenever a real row carries them: a
|
|
94
|
+
* direct row keeps its own id, and an OpenRouter row collapses onto a
|
|
95
|
+
* direct twin only when `pairAcrossGateways` found exactly one. For a
|
|
96
|
+
* NON-divergent vendor with no direct twin at all, `chooseRowId` derives
|
|
97
|
+
* the bare form via `directFormIfSafe` -- SYNTHESISED, not verbatim, only
|
|
98
|
+
* when the vendor's namespace can't prove it invalid (issue 195). Measured
|
|
99
|
+
* 2026-08-24 against a real 601-row catalog: all 14 `deepseek` rows derive
|
|
100
|
+
* this way (empty namespace); `google` (19/69) and `openai` (51/175) rows
|
|
101
|
+
* used to derive the same unconditional way despite a populated,
|
|
102
|
+
* authoritative namespace omitting that specific id, and now stay
|
|
103
|
+
* OpenRouter-prefixed instead. Once a bare id IS synthesized (the deepseek
|
|
104
|
+
* case), it is safe for routing, but not for the reason an earlier version
|
|
105
|
+
* of this comment claimed: a bare id is NOT failure-routed direct-first-
|
|
106
|
+
* with-OpenRouter-fallback. gateway-router.js's auto-mode step 7 is
|
|
107
|
+
* `if (rq.keys[vendor] && hasForm(rq, 'direct'))`, and `hasForm` is
|
|
108
|
+
* `!req.gatewayIds || req.gatewayIds[gateway] !== undefined` -- a bare id
|
|
109
|
+
* carries no `gatewayIds` at all, so `hasForm(rq, 'direct')` is VACUOUSLY
|
|
110
|
+
* true on this path; the only real gate is `rq.keys[vendor]`. The fallback
|
|
111
|
+
* to OpenRouter is therefore KEY-ABSENCE-driven (no key for `vendor`), not
|
|
112
|
+
* a check that the direct form actually resolves -- that check only fires
|
|
113
|
+
* on the ALIAS path, where `gatewayIds` IS attached and `hasForm` can
|
|
114
|
+
* genuinely be false. It is not a catalog-confirmed direct id either way,
|
|
115
|
+
* and callers that need that distinction should consult
|
|
116
|
+
* `curated-models.js`'s `directFormProvenance()`.
|
|
92
117
|
* @param {Array<{id:string,name:string,contextLength:(number|null)}>} catalog
|
|
93
118
|
* @param {string} vendor
|
|
94
119
|
* @returns {Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}
|
|
@@ -109,7 +134,7 @@ function buildRows(catalog, vendor) {
|
|
|
109
134
|
const isDirect = row.id.startsWith(directPrefix);
|
|
110
135
|
const token = isDirect ? row.id.slice(directPrefix.length) : row.id.slice(orPrefix.length);
|
|
111
136
|
const paired = pairAcrossGateways(vendor, token, catalogInfo);
|
|
112
|
-
const chosenId = chooseRowId(vendor, isDirect, row, paired);
|
|
137
|
+
const chosenId = chooseRowId(vendor, isDirect, row, paired, catalogInfo);
|
|
113
138
|
if (!chosenId || seenIds.has(chosenId)) { continue; }
|
|
114
139
|
seenIds.add(chosenId);
|
|
115
140
|
|
|
@@ -124,7 +149,7 @@ function buildRows(catalog, vendor) {
|
|
|
124
149
|
// pricing (`sourceRow`, which for a local/direct row IS the catalog row
|
|
125
150
|
// itself) instead. Gated on `isLocal`, NOT on "no OpenRouter twin", so a
|
|
126
151
|
// direct (non-local) row with no twin still renders `pricePerMInput:
|
|
127
|
-
// null` (tests/provider-default-picker.test.js:
|
|
152
|
+
// null` (tests/provider-default-picker.test.js:60, pinned).
|
|
128
153
|
const localPrice = isLocal ? pricePerMInputFrom(sourceRow) : null;
|
|
129
154
|
|
|
130
155
|
rows.push({
|
|
@@ -140,22 +165,22 @@ function buildRows(catalog, vendor) {
|
|
|
140
165
|
|
|
141
166
|
/**
|
|
142
167
|
* Canonicalize `resolveTier`'s verbatim catalog-id output the same way row
|
|
143
|
-
* ids are built (`chooseRowId`), so it
|
|
144
|
-
* exactly. `resolveTier`
|
|
145
|
-
* the tier
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* `
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* their direct and OpenRouter ids are identical once the prefix is removed.
|
|
168
|
+
* ids are built (`chooseRowId`/`directFormIfSafe`), so it matches `rows`
|
|
169
|
+
* exactly. Only matters when `resolveTier` fell back to an OpenRouter id (no
|
|
170
|
+
* matching direct-namespace row for the tier). `directFormIfSafe` gates
|
|
171
|
+
* `DIVERGENT_VENDORS` itself and keeps that id OpenRouter-prefixed;
|
|
172
|
+
* non-divergent vendors go through the same `classifyModel`-guarded strip
|
|
173
|
+
* `chooseRowId` uses (issue 195) -- an unconditional strip here would
|
|
174
|
+
* produce a canonical id `rows` no longer carries whenever `directFormIfSafe`
|
|
175
|
+
* kept the row itself OpenRouter-prefixed, defeating the match below.
|
|
152
176
|
* @param {string} vendor
|
|
153
177
|
* @param {string|null} resolved verbatim catalog id from `resolveTier`, or null
|
|
178
|
+
* @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
|
|
154
179
|
* @returns {string|null}
|
|
155
180
|
*/
|
|
156
|
-
function canonicalizeResolved(vendor, resolved) {
|
|
181
|
+
function canonicalizeResolved(vendor, resolved, catalogInfo) {
|
|
157
182
|
if (!resolved) { return null; }
|
|
158
|
-
return
|
|
183
|
+
return directFormIfSafe(vendor, resolved, catalogInfo);
|
|
159
184
|
}
|
|
160
185
|
|
|
161
186
|
/**
|
|
@@ -173,7 +198,9 @@ function canonicalizeResolved(vendor, resolved) {
|
|
|
173
198
|
function computePreselectedId(vendor, tier, catalog, rows) {
|
|
174
199
|
const effectiveTier = tier || getCostTier();
|
|
175
200
|
const resolved = resolveTier(vendor, effectiveTier, catalog);
|
|
176
|
-
|
|
201
|
+
// F2 (B5): guard the same way applyProviderDefault does, so classifyModel
|
|
202
|
+
// never sees a non-array `models` regardless of what a caller passes.
|
|
203
|
+
const canonical = canonicalizeResolved(vendor, resolved, { models: Array.isArray(catalog) ? catalog : [] });
|
|
177
204
|
if (canonical && rows.some(r => r.id === canonical)) { return canonical; }
|
|
178
205
|
|
|
179
206
|
const priced = rows.filter(r => r.pricePerMInput !== null);
|
|
@@ -217,22 +244,35 @@ function buildProviderDefaultChoices(vendor, options = {}) {
|
|
|
217
244
|
* `config.default` on first use. Read-modify-write, NO-CLOBBER -- preserves
|
|
218
245
|
* an already-set `config.default` and every other existing alias/key.
|
|
219
246
|
*
|
|
220
|
-
* `chosenId`
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* `
|
|
227
|
-
*
|
|
228
|
-
*
|
|
247
|
+
* `chosenId` comes straight from `buildProviderDefaultChoices`: a real
|
|
248
|
+
* direct id; an `openrouter/`-prefixed id (`DIVERGENT_VENDORS`, or -- since
|
|
249
|
+
* issue 195 -- a non-divergent vendor whose bare form would classify
|
|
250
|
+
* `invalid`); or a bare id already SYNTHESISED via `directFormIfSafe`. By
|
|
251
|
+
* the time this function runs, `chosenId`'s prefix (or lack of one) IS the
|
|
252
|
+
* decision -- PERSISTENCE therefore uses `directFormIfProven`, not
|
|
253
|
+
* `directFormIfSafe`: it strips the `openrouter/` prefix only on POSITIVE
|
|
254
|
+
* evidence (`classifyModel` returns `valid` -- the bare id is an actual
|
|
255
|
+
* catalog row), never merely because the catalog couldn't disprove it.
|
|
256
|
+
*
|
|
257
|
+
* This matters because `directFormIfSafe`'s optimistic default is correct
|
|
258
|
+
* for LIST-BUILDING (an empty/absent catalog can't assert absence, so a
|
|
259
|
+
* bare guess is reasonable to offer) but wrong here: a catalog fetch that
|
|
260
|
+
* failed or came back empty (`catalog` omitted, or `[]`) must never be read
|
|
261
|
+
* as license to fabricate a direct id `chooseRowId` never actually offered
|
|
262
|
+
* -- that reintroduces the exact bug issue 195 fixed, silently, on every
|
|
263
|
+
* degraded fetch. `directFormIfProven` preserves `chosenId` verbatim
|
|
264
|
+
* whenever the catalog can't prove the bare form valid, so an empty/absent
|
|
265
|
+
* `catalog` option is inert (no canonicalization at all), not a fallback to
|
|
266
|
+
* the old unconditional strip.
|
|
229
267
|
* @param {string} vendor e.g. 'anthropic'
|
|
230
|
-
* @param {string} chosenId
|
|
231
|
-
*
|
|
268
|
+
* @param {string} chosenId id from the picker (see above -- not always
|
|
269
|
+
* catalog-verbatim)
|
|
270
|
+
* @param {{seedDefaultIfAbsent?: boolean, catalog?: Array<{id:string}>}} [options]
|
|
232
271
|
* @returns {{alias: string, setAsDefault: boolean}}
|
|
233
272
|
*/
|
|
234
|
-
function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } = {}) {
|
|
235
|
-
const
|
|
273
|
+
function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true, catalog } = {}) {
|
|
274
|
+
const catalogInfo = { models: Array.isArray(catalog) ? catalog : [] };
|
|
275
|
+
const storedId = directFormIfProven(vendor, chosenId, catalogInfo);
|
|
236
276
|
|
|
237
277
|
const config = loadConfig() || {};
|
|
238
278
|
if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
|
|
@@ -246,4 +286,12 @@ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } =
|
|
|
246
286
|
return { alias: vendor, setAsDefault };
|
|
247
287
|
}
|
|
248
288
|
|
|
249
|
-
|
|
289
|
+
// directFormIfSafe/directFormIfProven are re-exported (not just used
|
|
290
|
+
// internally) so a caller that wants the canonicalization primitives
|
|
291
|
+
// directly doesn't need to know they now live in model-canonicalization.js
|
|
292
|
+
// -- see tests/model-canonicalization.test.js for the identity pin proving
|
|
293
|
+
// this is a re-export, not a second, divergeable copy.
|
|
294
|
+
module.exports = {
|
|
295
|
+
buildProviderDefaultChoices, applyProviderDefault, pricePerMInputFrom,
|
|
296
|
+
directFormIfSafe, directFormIfProven,
|
|
297
|
+
};
|
|
@@ -111,7 +111,7 @@ async function runProviderDefaultFlow(provider, options = {}) {
|
|
|
111
111
|
chosenId = await promptForChoice(ask, print, choices);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
|
|
114
|
+
const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true, catalog });
|
|
115
115
|
const summaryLine = `\`amicus start --model ${provider}\` → ${chosenId}` +
|
|
116
116
|
(setAsDefault ? ', set as your default model' : '');
|
|
117
117
|
|
package/src/utils/quick-picks.js
CHANGED
|
@@ -79,8 +79,8 @@ function resolveQuickPicks(catalog) {
|
|
|
79
79
|
* Stripping the prefix there fabricates an id the direct API rejects, which
|
|
80
80
|
* `amicus doctor` then reports as a stale alias. The row's own direct route is
|
|
81
81
|
* used verbatim, falling back to the intact `openrouter/` form when the
|
|
82
|
-
* catalog offered no direct pick. Mirrors the guard
|
|
83
|
-
* `
|
|
82
|
+
* catalog offered no direct pick. Mirrors the DIVERGENT_VENDORS-first guard
|
|
83
|
+
* `model-canonicalization.js :: directFormIfSafe` uses internally.
|
|
84
84
|
* @param {{vendorPath?:string, routes?:Object<string,string>}} pick
|
|
85
85
|
* @returns {string|undefined}
|
|
86
86
|
*/
|
|
@@ -143,6 +143,30 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
143
143
|
*/
|
|
144
144
|
pruneSessionIndex:
|
|
145
145
|
'amicus doctor --fix (removes sessions-index.json entries whose project no longer exists on disk — liveness-based, never by age)',
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Fabricated bare alias (B3, council review of PR 198 / issue 195): v4.8.0
|
|
149
|
+
* could persist a `<vendor>/<model>` id no catalog row carries. `doctor
|
|
150
|
+
* --fix` rewrites ONLY the narrow, mechanically-unambiguous class -- an
|
|
151
|
+
* alias that classifies `invalid` on the `direct` gateway AND has an
|
|
152
|
+
* unambiguous OpenRouter twin (`pairAcrossGateways`, alias-audit.js's
|
|
153
|
+
* `findFabricatedAliasRepairs`) -- to that twin. Every other stale/drifted
|
|
154
|
+
* alias (typo, retired model, user-invented id) is left for `amicus
|
|
155
|
+
* models --check` instead, never guessed at.
|
|
156
|
+
*/
|
|
157
|
+
repairFabricatedAlias:
|
|
158
|
+
'amicus doctor --fix (rewrites a fabricated bare alias to its catalog-confirmed OpenRouter id — safe only for the narrow class doctor can prove; use `amicus models --check` for anything else)',
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A3 (council review of PR 198): same repairable class as
|
|
162
|
+
* `repairFabricatedAlias` above, but the cached catalog `evaluateAliasesCheck`
|
|
163
|
+
* would repair FROM is itself stale (older than doctor's own `catalog`
|
|
164
|
+
* check's freshness window) -- a stale catalog can be missing rows that
|
|
165
|
+
* would make a "fabricated" id look repairable when it is merely unfetched,
|
|
166
|
+
* so the repair declines rather than write on unverified evidence.
|
|
167
|
+
*/
|
|
168
|
+
repairFabricatedAliasStaleCatalog:
|
|
169
|
+
'amicus models --refresh, then amicus doctor --fix (the alias looks fabricated but the cached catalog is stale — refresh it first so the repair rests on current data, not a possibly-incomplete snapshot)',
|
|
146
170
|
});
|
|
147
171
|
|
|
148
172
|
module.exports = REMEDIATION_HINTS;
|
|
@@ -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 } : {}),
|
|
@@ -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));
|