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
package/src/headless.js
CHANGED
|
@@ -21,6 +21,13 @@ const { buildFoldMarker, trailingFoldMarkerRegex, generateFoldNonce } = require(
|
|
|
21
21
|
// that uses this helper. See src/utils/env-num.js for why the older `||` knobs are
|
|
22
22
|
// deliberately left alone.
|
|
23
23
|
const { envNumber } = require('./utils/env-num');
|
|
24
|
+
// v4.9 W10 (#133 piece 2): the engine's own error line for a dead session.
|
|
25
|
+
const { engineErrorForSession } = require('./utils/engine-log');
|
|
26
|
+
// v4.9 W10 (#133 piece 3): the standing engine version-skew record, if any.
|
|
27
|
+
const { currentEngineSkew, formatSkewSuffix } = require('./utils/engine-skew');
|
|
28
|
+
// v4.9 W13 Task A (PR #207 round 3, B3): the one honesty predicate every ttftMs
|
|
29
|
+
// emit gate shares — see src/utils/ttft.js for why `typeof` was not it.
|
|
30
|
+
const { isMeasuredTtft } = require('./utils/ttft');
|
|
24
31
|
|
|
25
32
|
/**
|
|
26
33
|
* Fold marker that the agent outputs when done.
|
|
@@ -158,14 +165,26 @@ function withTimeout(promise, ms, label) {
|
|
|
158
165
|
* Task 6 (#129, #133): build the NO_OUTPUT_BACKSTOP reason string. Report
|
|
159
166
|
* ONLY what the mechanism observed — a deadline passed with no substantive
|
|
160
167
|
* activity (output/reasoning/tool calls) — never a cause. At the pre-send
|
|
161
|
-
* firing site (runHeadless,
|
|
168
|
+
* firing site (runHeadless, where the backstop is armed just before
|
|
169
|
+
* `sendPromptAsync`) the backstop can win the race against
|
|
162
170
|
* sendPromptAsync before the send ever resolves, so "the endpoint accepted
|
|
163
171
|
* the request" is not even something that site observed. The previous text
|
|
164
172
|
* asserted "likely a listed-but-not-serving model or a dead endpoint" — a
|
|
165
173
|
* canned guess with no evidence gate — which sent 30 minutes of #133's
|
|
166
174
|
* debugging at model ids and API keys while the real cause (an opencode
|
|
167
|
-
* engine version skew) sat in
|
|
168
|
-
*
|
|
175
|
+
* engine version skew) sat in the engine's own log the whole time.
|
|
176
|
+
*
|
|
177
|
+
* v4.9 W10 (#133 piece 2): that log is now READ. When
|
|
178
|
+
* src/utils/engine-log.js finds an ERROR line for this leg's session,
|
|
179
|
+
* `engineLogExcerpt` carries it and ` — engine log: <excerpt>` is appended
|
|
180
|
+
* AFTER the whole sentence below — so the `NO_OUTPUT_BACKSTOP:` prefix that
|
|
181
|
+
* src/sidecar/models-probe.js classifies on stays byte-stable, and on every
|
|
182
|
+
* miss path (no dir, no file, no session match, no ERROR line) the excerpt
|
|
183
|
+
* clause is absent — a standing skew still appends its own clause, see below. NOTE the path this comment used to
|
|
184
|
+
* name (a single `opencode/log/opencode.log`) was already stale when it was
|
|
185
|
+
* written: the engine now writes one timestamped file per process, and both
|
|
186
|
+
* schemes are live on real machines — engine-log.js's header records the
|
|
187
|
+
* measurement and reads both.
|
|
169
188
|
*
|
|
170
189
|
* `fromEnv` distinguishes two ways `ms` was decided, NOT whether
|
|
171
190
|
* AMICUS_NO_OUTPUT_BACKSTOP_MS is relevant — it is relevant on both branches:
|
|
@@ -190,20 +209,54 @@ function withTimeout(promise, ms, label) {
|
|
|
190
209
|
* it governs (false on the probe) or omitting it (false/unhelpful on the
|
|
191
210
|
* retry) — true on both, and still points a user at the remedy.
|
|
192
211
|
*
|
|
212
|
+
* v4.9 W10 (#133 piece 3): `engineSkew` adds a SECOND, independent clause —
|
|
213
|
+
* the two engine versions, when src/utils/engine-skew.js has a skew standing
|
|
214
|
+
* for THIS leg's own server (the caller reads the record by client; see the
|
|
215
|
+
* firing-site comment in runHeadless). It is
|
|
216
|
+
* deliberately NOT gated on `engineLogExcerpt`: gating it would make the
|
|
217
|
+
* reliable signal (two version strings both sides already publish) depend on
|
|
218
|
+
* the unreliable one (a log file that may be absent or rotated), and in #133
|
|
219
|
+
* the skew WAS the answer. Both clauses are append-only, so with neither the
|
|
220
|
+
* string is byte-for-byte what it was before either piece existed.
|
|
221
|
+
*
|
|
193
222
|
* Kept module-scope and pure (not a closure over runHeadless locals) so it
|
|
194
223
|
* can be asserted on directly in tests without driving the poll loop; the
|
|
195
224
|
* `noOutputBackstopReason` closure inside runHeadless just forwards to this
|
|
196
|
-
* with the per-run `noOutputBackstopMs`/`backstopFromEnv
|
|
197
|
-
* firing sites there stay identical to what's tested here.
|
|
198
|
-
* @param {{ms: number, fromEnv: boolean
|
|
225
|
+
* with the per-run `noOutputBackstopMs`/`backstopFromEnv`/engine-log/skew
|
|
226
|
+
* values, so the two firing sites there stay identical to what's tested here.
|
|
227
|
+
* @param {{ms: number, fromEnv: boolean, engineLogExcerpt?: string|null,
|
|
228
|
+
* engineSkew?: {server: string, installed: string}|null}} args
|
|
199
229
|
* @returns {string}
|
|
200
230
|
*/
|
|
201
|
-
function formatNoOutputBackstopReason({ ms, fromEnv }) {
|
|
202
|
-
|
|
231
|
+
function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew }) {
|
|
232
|
+
const observed = 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
|
|
203
233
|
+ `${Math.round(ms / 1000)}s — `
|
|
204
234
|
+ (fromEnv
|
|
205
235
|
? 'the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)'
|
|
206
236
|
: 'a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default');
|
|
237
|
+
// Append-only: absent/empty excerpt ⇒ the string above, unchanged byte for byte.
|
|
238
|
+
const quoted = engineLogExcerpt ? `${observed} — engine log: ${engineLogExcerpt}` : observed;
|
|
239
|
+
// Append-only for the same reason: no skew ⇒ formatSkewSuffix returns ''.
|
|
240
|
+
return `${quoted}${formatSkewSuffix(engineSkew)}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* v4.9 W10 (#133 piece 2): the engine-log lookup, wrapped so it can never
|
|
245
|
+
* become the failure it reports on. The resolver is already best-effort
|
|
246
|
+
* internally; this second belt is at the CALL site because the caller is a
|
|
247
|
+
* leg's death report — the one place where an extra exception would replace a
|
|
248
|
+
* usable failure reason with a stack trace. Missing sessionId ⇒ skip entirely.
|
|
249
|
+
* @param {string|undefined} sessionId
|
|
250
|
+
* @param {object|undefined} engineLogOptions - test seam (options._engineLog)
|
|
251
|
+
* @returns {string|null}
|
|
252
|
+
*/
|
|
253
|
+
function engineErrorExcerptSafe(sessionId, engineLogOptions) {
|
|
254
|
+
if (!sessionId) { return null; }
|
|
255
|
+
try {
|
|
256
|
+
return engineErrorForSession(sessionId, engineLogOptions || {});
|
|
257
|
+
} catch (_e) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
207
260
|
}
|
|
208
261
|
|
|
209
262
|
/**
|
|
@@ -366,6 +419,29 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
366
419
|
const { IdleWatchdog } = require('./utils/idle-watchdog');
|
|
367
420
|
let watchdog;
|
|
368
421
|
let uninstallSignals;
|
|
422
|
+
// v4.9 W13 Task A — THE TTFT PROBE's storage. Milliseconds from the leg
|
|
423
|
+
// asking for output (`outputClockStartedAt` below — the backstop's OWN clock
|
|
424
|
+
// origin) to the first poll where `substantiveActivity` is true. `null` until
|
|
425
|
+
// then, and EMIT-WHEN-SET from there: a leg that never produced anything
|
|
426
|
+
// carries no ttftMs key on the result, on disk, or in any downstream
|
|
427
|
+
// document. `0` is a real measurement (first substantive tick inside the
|
|
428
|
+
// first poll), which is why the absence is a missing key rather than a falsy
|
|
429
|
+
// value.
|
|
430
|
+
//
|
|
431
|
+
// ⚠️ Declared HERE, beside `sessionId`/`watchdog`, and NOT inside the try
|
|
432
|
+
// below (PR #203 council round 1, finding A2). It used to live in the poll
|
|
433
|
+
// loop's scope, so it was not even in scope at the catch-all return: a leg
|
|
434
|
+
// that streamed real output and then threw discarded its own measurement.
|
|
435
|
+
// Same shape as `sessionId`, for the same reason — the outer handler is a
|
|
436
|
+
// return path like any other and must be able to report what was measured.
|
|
437
|
+
//
|
|
438
|
+
// ⚠️ PROBE ONLY — nothing derives from this number. No backstop change, no
|
|
439
|
+
// threshold, no per-model window. W13 ruling R12 is explicitly "probe first,
|
|
440
|
+
// derive later": the C2 derivation (per-model backstops from evidence) waits
|
|
441
|
+
// for real field observations, which cannot exist until this ships. Do not
|
|
442
|
+
// wire it into a decision without that evidence — and read the RESIDUAL
|
|
443
|
+
// CENSORING note on the stamp site in the poll loop before you do.
|
|
444
|
+
let ttftMs = null;
|
|
369
445
|
|
|
370
446
|
try {
|
|
371
447
|
if (!externalServer) {
|
|
@@ -535,15 +611,41 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
535
611
|
const backstopFromEnv = !Number.isFinite(options.noOutputBackstopMs);
|
|
536
612
|
const noOutputBackstopMs = backstopFromEnv
|
|
537
613
|
? resolveNoOutputBackstopMs(options._env) : options.noOutputBackstopMs;
|
|
538
|
-
|
|
614
|
+
// v4.9 W13 Task A: ONE clock origin, captured once and shared with the
|
|
615
|
+
// backstop instead of a second `Date.now()`. `startedAt` here already means
|
|
616
|
+
// "time since the leg asked for output" (see the block comment above), so
|
|
617
|
+
// the TTFT probe below measures from exactly the instant the backstop
|
|
618
|
+
// starts counting — the two can never disagree about when the wait began.
|
|
619
|
+
const outputClockStartedAt = Date.now();
|
|
620
|
+
const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: outputClockStartedAt });
|
|
539
621
|
let backstopFired = false;
|
|
540
622
|
// Single source for the reason string so the pre-send firing site below and
|
|
541
623
|
// the per-poll firing site further down (still ticking the SAME instance)
|
|
542
624
|
// can never drift apart. Forwards to the module-scope, pure
|
|
543
625
|
// formatNoOutputBackstopReason (below/exported) so tests can assert on the
|
|
544
626
|
// string shape directly without driving the whole poll loop.
|
|
627
|
+
//
|
|
628
|
+
// v4.9 W10 (#133 piece 2): the engine-log excerpt is resolved HERE, in the
|
|
629
|
+
// one shared closure, rather than separately at each firing site — the
|
|
630
|
+
// enrichment then reaches BOTH sites by construction, which is the same
|
|
631
|
+
// no-drift argument that put the reason string here in the first place.
|
|
632
|
+
// Resolved at CALL time (not once when this closure is defined) so the
|
|
633
|
+
// engine has already written the line by the time we read it, and so the
|
|
634
|
+
// read never happens on a leg that lives.
|
|
635
|
+
//
|
|
636
|
+
// v4.9 W10 (#133 piece 3): the skew record is read at call time for the
|
|
637
|
+
// same reason — `createSession` above is what puts it there, and on the
|
|
638
|
+
// shared-server path a sibling leg on THIS server may have been the one to
|
|
639
|
+
// see it. Asked for by `client`, so what comes back is THIS leg's own
|
|
640
|
+
// server's standing record and never a stranger's (W10 round-1 review A3):
|
|
641
|
+
// the record is per server and refreshed on every create, so a skew that
|
|
642
|
+
// was fixed mid-run, or one that belongs to another server this process
|
|
643
|
+
// also talks to, cannot ride out on this death report. The read is a Map
|
|
644
|
+
// lookup and does no I/O, so unlike the log read it needs no guard.
|
|
545
645
|
const noOutputBackstopReason = () => formatNoOutputBackstopReason({
|
|
546
646
|
ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
|
|
647
|
+
engineLogExcerpt: engineErrorExcerptSafe(sessionId, options._engineLog),
|
|
648
|
+
engineSkew: currentEngineSkew(client),
|
|
547
649
|
});
|
|
548
650
|
|
|
549
651
|
// Send prompt asynchronously (returns immediately, we poll for results) —
|
|
@@ -638,6 +740,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
638
740
|
let lastMessageCount = 0;
|
|
639
741
|
let lastReasoningLength = 0; // B53: track reasoning-output growth to detect thinking
|
|
640
742
|
let lastProgressAt = Date.now(); // B53: last poll where `progressed` was true
|
|
743
|
+
// v4.9 W13 Task A: `ttftMs` itself is declared at function scope (see the
|
|
744
|
+
// block comment beside `sessionId`) so the catch-all return can carry it.
|
|
641
745
|
let toolStalled = false; // B53: distinct from completed/timedOut/aborted — see resolveTerminalState
|
|
642
746
|
let lastSettledToolCount = 0; // B4: tool calls observed reaching a terminal status
|
|
643
747
|
|
|
@@ -774,6 +878,133 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
774
878
|
elapsed: Date.now() - startTime
|
|
775
879
|
});
|
|
776
880
|
|
|
881
|
+
// ---- per-poll activity, read ONCE, ABOVE every completion gate --------
|
|
882
|
+
// Activity-aware idle detection: ANY of text growth, a new tool call, a new
|
|
883
|
+
// tool result, a new message, a new assistant message id, or reasoning-output
|
|
884
|
+
// growth counts as progress. Only count toward completion when NOTHING changed
|
|
885
|
+
// (genuine idle).
|
|
886
|
+
//
|
|
887
|
+
// ⚠️ POSITION IS LOAD-BEARING (v4.9 W13, PR #203 council round 1, findings
|
|
888
|
+
// A1+B1). This block used to sit BELOW the four completion gates that
|
|
889
|
+
// follow — fold-marker, boundary-provider-error, error-with-no-output and
|
|
890
|
+
// sdk-idle — every one of which `break`s. A leg whose first substantive
|
|
891
|
+
// output and whose completion landed in the SAME poll therefore never
|
|
892
|
+
// reached the TTFT stamp, and those are exactly the FAST legs, so the
|
|
893
|
+
// censoring truncated the left tail rather than sampling at random.
|
|
894
|
+
// Hoisting is value-preserving, and the AUDIT that says so is below rather
|
|
895
|
+
// than an appeal to construction (PR #207 round 4, B2 — the earlier wording
|
|
896
|
+
// here claimed "every `last*` tracker is written only here" and "nothing
|
|
897
|
+
// reads them after the loop", and BOTH are false as written, because
|
|
898
|
+
// `lastAssistantMsgId` is a `last*` tracker this block never writes).
|
|
899
|
+
// MEASURED, every reader in this file:
|
|
900
|
+
// · `mirror` changes only at the `mirrorMessages` call above, so every
|
|
901
|
+
// delta below reads the same numbers it read at the old site.
|
|
902
|
+
// · This block WRITES exactly seven trackers — output length, tool-call
|
|
903
|
+
// count, tool-result count, message count, reasoning length, settled-
|
|
904
|
+
// tool count and `lastProgressAt`. Outside this block those seven have
|
|
905
|
+
// exactly ONE reader in the whole file: `lastProgressAt`, in the B53
|
|
906
|
+
// tool-stall gate further down. That gate sat below the block's OLD
|
|
907
|
+
// position too, so their order is unchanged. None of the seven is read
|
|
908
|
+
// after the loop.
|
|
909
|
+
// · `lastAssistantMsgId` is READ here (`newAssistant`) and written where
|
|
910
|
+
// it always was, at the BOTTOM of the loop below every gate — so the
|
|
911
|
+
// comparison still runs against the previous poll's id, and the one
|
|
912
|
+
// post-loop `last*` reader (`hasAssistantMsg` on the timeout result)
|
|
913
|
+
// reads a tracker this block does not touch.
|
|
914
|
+
// The only behaviour change is that a poll which BREAKS now advances those
|
|
915
|
+
// seven first, and nothing observes them afterwards.
|
|
916
|
+
// Hoisting the whole block (rather than adding a second stamp beside each
|
|
917
|
+
// gate) is what keeps ONE definition of the predicate and ONE stamp site.
|
|
918
|
+
const outputGrew = mirror.output.length > lastOutputLength;
|
|
919
|
+
lastOutputLength = mirror.output.length;
|
|
920
|
+
const toolActivity = mirror.toolCalls.length > lastToolCallCount;
|
|
921
|
+
lastToolCallCount = mirror.toolCalls.length;
|
|
922
|
+
const resultActivity = mirror.seenToolResultIds.size > lastToolResultCount;
|
|
923
|
+
lastToolResultCount = mirror.seenToolResultIds.size;
|
|
924
|
+
const messageActivity = messageCount > lastMessageCount;
|
|
925
|
+
lastMessageCount = messageCount;
|
|
926
|
+
const newAssistant = currentAssistantMsgId !== lastAssistantMsgId;
|
|
927
|
+
// B53: an interleaved-thinking model with a pending tool call can stream ONLY
|
|
928
|
+
// reasoning deltas for minutes with no text/tool/result/message growth — mirror
|
|
929
|
+
// the F6d treatment in conversation-mirror.js (reasoning growth = activity) so
|
|
930
|
+
// the stall clock resets instead of falsely firing "Tool call stalled".
|
|
931
|
+
const reasoningActivity = mirror.reasoningOutput.length > lastReasoningLength;
|
|
932
|
+
lastReasoningLength = mirror.reasoningOutput.length;
|
|
933
|
+
// v4.4 B4: a tool call REACHING a terminal status is real activity. Before
|
|
934
|
+
// the shape fix this could never be observed (pending never cleared), so a
|
|
935
|
+
// multi-tool leg's stall clock only reset on text growth.
|
|
936
|
+
const settleActivity = mirror.settledToolCallIds.size > lastSettledToolCount;
|
|
937
|
+
lastSettledToolCount = mirror.settledToolCallIds.size;
|
|
938
|
+
|
|
939
|
+
const progressed = outputGrew || toolActivity || resultActivity || messageActivity
|
|
940
|
+
|| newAssistant || reasoningActivity || settleActivity;
|
|
941
|
+
if (progressed) { lastProgressAt = Date.now(); }
|
|
942
|
+
|
|
943
|
+
// v4.6.2 PR2 amendment 2 (controller live smoke + debug trace): the
|
|
944
|
+
// backstop disarms only on SUBSTANTIVE activity — output, reasoning,
|
|
945
|
+
// or tool motion (the spec's "first token/reasoning/tool_use").
|
|
946
|
+
// messageActivity/newAssistant are excluded: OpenCode creates an empty
|
|
947
|
+
// assistant placeholder on prompt ACCEPTANCE, which is precisely the
|
|
948
|
+
// accepted-but-not-serving bookkeeping the backstop must not trust.
|
|
949
|
+
// `progressed` itself (and every stall/idle consumer of it above) is
|
|
950
|
+
// deliberately untouched — this is the narrower signal.
|
|
951
|
+
//
|
|
952
|
+
// v4.9 W13 Task A: it now has TWO readers — the TTFT probe on the next
|
|
953
|
+
// line and the backstop tick further down — which is why it stopped
|
|
954
|
+
// being described as "backstop-only". They read the SAME const on
|
|
955
|
+
// purpose: "the time to first token" and "the moment the backstop
|
|
956
|
+
// disarms" are the same instant BY CONSTRUCTION, and a second definition
|
|
957
|
+
// of the predicate is exactly how those two would silently drift apart.
|
|
958
|
+
const substantiveActivity = outputGrew || toolActivity || resultActivity
|
|
959
|
+
|| reasoningActivity || settleActivity;
|
|
960
|
+
|
|
961
|
+
// v4.9 W13 Task A — THE TTFT STAMP. One shot, never re-armed, reading
|
|
962
|
+
// the same `substantiveActivity` const the backstop is ticked with
|
|
963
|
+
// below. Keyed on `progressed` it would report a first-token time for
|
|
964
|
+
// OpenCode's empty acceptance placeholder — the exact lie the
|
|
965
|
+
// amendment-2 narrowing of `substantiveActivity` removed from the backstop.
|
|
966
|
+
//
|
|
967
|
+
// ⚠️ RESIDUAL CENSORING — READ THIS BEFORE DERIVING ANYTHING (the C2
|
|
968
|
+
// derivation must know the bias; PR #203 round 1, B1). With the hoist
|
|
969
|
+
// above, every leg whose activity is OBSERVED by a poll is measured.
|
|
970
|
+
// What is still unmeasurable, and therefore what an ABSENT ttftMs can
|
|
971
|
+
// mean besides "served nothing":
|
|
972
|
+
// 1. Externally aborted legs. The metadata `status: 'aborted'` check
|
|
973
|
+
// runs at the TOP of the poll body, before `getMessages`, and
|
|
974
|
+
// breaks — so anything served since the previous poll is never
|
|
975
|
+
// mirrored.
|
|
976
|
+
// 2. Poll-failure bail. `getMessages` failing
|
|
977
|
+
// `maxConsecutivePollFailures` times in a row ends the leg with
|
|
978
|
+
// whatever the last SUCCESSFUL poll saw.
|
|
979
|
+
// 3. `--timeout` expiry between polls: the while-condition ends the
|
|
980
|
+
// loop, so output that arrived during the final sleep is unseen.
|
|
981
|
+
// 4. The pre-send backstop path, which skips the poll loop entirely —
|
|
982
|
+
// a model that began streaming while `sendPromptAsync` hung is
|
|
983
|
+
// never polled at all.
|
|
984
|
+
// 5. A backward wall-clock jump before the first substantive poll
|
|
985
|
+
// (PR #207 round 3, B3). The reading is taken but is not a
|
|
986
|
+
// measurement, so the emit gates drop it — see the ruling below.
|
|
987
|
+
// And the value itself is QUANTIZED UPWARD: it is stamped at poll time,
|
|
988
|
+
// not at token time, so every measurement is an upper bound carrying up
|
|
989
|
+
// to one `pollIntervalMs` (plus the getMessages round-trip) of slack.
|
|
990
|
+
// Nothing extra is recorded for the censored cases on purpose — there is
|
|
991
|
+
// no honest number to record, and a sentinel would be read as data.
|
|
992
|
+
//
|
|
993
|
+
// ⚠️ CLOCK-SKEW RULING (PR #207 round 3, B3). This is a wall-clock
|
|
994
|
+
// delta, not a monotonic one, so a backward jump between
|
|
995
|
+
// `outputClockStartedAt` and this poll — NTP correction, a VM resuming
|
|
996
|
+
// from suspend, a manual clock set — measures NEGATIVE. Such a reading
|
|
997
|
+
// is DROPPED at the emit gates (`isMeasuredTtft`), never clamped:
|
|
998
|
+
// clamping to `0` would publish "first token inside the first poll",
|
|
999
|
+
// the most consequential value in the distribution the C2 derivation
|
|
1000
|
+
// will read, for a leg that measured nothing of the kind.
|
|
1001
|
+
//
|
|
1002
|
+
// The stamp stays one-shot and unguarded ON PURPOSE. Re-arming after a
|
|
1003
|
+
// skewed reading would let a LATER poll stamp a delta against the same
|
|
1004
|
+
// displaced origin: a bigger number, equally wrong, and no longer even
|
|
1005
|
+
// the first token. One decision point (the gate) beats two.
|
|
1006
|
+
if (ttftMs === null && substantiveActivity) { ttftMs = Date.now() - outputClockStartedAt; }
|
|
1007
|
+
|
|
777
1008
|
// Check for the completion marker as the FINAL non-empty line, carrying
|
|
778
1009
|
// THIS run's nonce (#BL-7 + 15b.3). Models may emit a bare or wrong-nonce
|
|
779
1010
|
// marker on its own line mid-output (echoing a prior sidecar, these
|
|
@@ -830,46 +1061,6 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
830
1061
|
}
|
|
831
1062
|
}
|
|
832
1063
|
|
|
833
|
-
// Activity-aware idle detection: ANY of text growth, a new tool call, a new
|
|
834
|
-
// tool result, a new message, a new assistant message id, or reasoning-output
|
|
835
|
-
// growth counts as progress. Only count toward completion when NOTHING changed
|
|
836
|
-
// (genuine idle).
|
|
837
|
-
const outputGrew = mirror.output.length > lastOutputLength;
|
|
838
|
-
lastOutputLength = mirror.output.length;
|
|
839
|
-
const toolActivity = mirror.toolCalls.length > lastToolCallCount;
|
|
840
|
-
lastToolCallCount = mirror.toolCalls.length;
|
|
841
|
-
const resultActivity = mirror.seenToolResultIds.size > lastToolResultCount;
|
|
842
|
-
lastToolResultCount = mirror.seenToolResultIds.size;
|
|
843
|
-
const messageActivity = messageCount > lastMessageCount;
|
|
844
|
-
lastMessageCount = messageCount;
|
|
845
|
-
const newAssistant = currentAssistantMsgId !== lastAssistantMsgId;
|
|
846
|
-
// B53: an interleaved-thinking model with a pending tool call can stream ONLY
|
|
847
|
-
// reasoning deltas for minutes with no text/tool/result/message growth — mirror
|
|
848
|
-
// the F6d treatment in conversation-mirror.js (reasoning growth = activity) so
|
|
849
|
-
// the stall clock resets instead of falsely firing "Tool call stalled".
|
|
850
|
-
const reasoningActivity = mirror.reasoningOutput.length > lastReasoningLength;
|
|
851
|
-
lastReasoningLength = mirror.reasoningOutput.length;
|
|
852
|
-
// v4.4 B4: a tool call REACHING a terminal status is real activity. Before
|
|
853
|
-
// the shape fix this could never be observed (pending never cleared), so a
|
|
854
|
-
// multi-tool leg's stall clock only reset on text growth.
|
|
855
|
-
const settleActivity = mirror.settledToolCallIds.size > lastSettledToolCount;
|
|
856
|
-
lastSettledToolCount = mirror.settledToolCallIds.size;
|
|
857
|
-
|
|
858
|
-
const progressed = outputGrew || toolActivity || resultActivity || messageActivity
|
|
859
|
-
|| newAssistant || reasoningActivity || settleActivity;
|
|
860
|
-
if (progressed) { lastProgressAt = Date.now(); }
|
|
861
|
-
|
|
862
|
-
// v4.6.2 PR2 amendment 2 (controller live smoke + debug trace): the
|
|
863
|
-
// backstop disarms only on SUBSTANTIVE activity — output, reasoning,
|
|
864
|
-
// or tool motion (the spec's "first token/reasoning/tool_use").
|
|
865
|
-
// messageActivity/newAssistant are excluded: OpenCode creates an empty
|
|
866
|
-
// assistant placeholder on prompt ACCEPTANCE, which is precisely the
|
|
867
|
-
// accepted-but-not-serving bookkeeping the backstop must not trust.
|
|
868
|
-
// `progressed` itself (and every stall/idle consumer of it above) is
|
|
869
|
-
// deliberately untouched — this is a narrower, backstop-only signal.
|
|
870
|
-
const substantiveActivity = outputGrew || toolActivity || resultActivity
|
|
871
|
-
|| reasoningActivity || settleActivity;
|
|
872
|
-
|
|
873
1064
|
// No-output backstop: one tick per poll. Fired is terminal — break the
|
|
874
1065
|
// loop; the post-loop block below mirrors the timeout path.
|
|
875
1066
|
if (noOutputBackstop.tick(substantiveActivity, Date.now()) === 'fired') {
|
|
@@ -1309,6 +1500,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1309
1500
|
// #133 P1: sessionId was assigned at :413/:417, well before this
|
|
1310
1501
|
// return — guaranteed set here, same as `taskId` above.
|
|
1311
1502
|
opencodeSessionId: sessionId,
|
|
1503
|
+
// v4.9 W13 Task A: emit-when-set. This return is reached by legs that
|
|
1504
|
+
// failed with no USABLE output, which is not the same as no output at
|
|
1505
|
+
// all — a leg that streamed reasoning and then errored has a real ttft
|
|
1506
|
+
// and must keep it. (PR #207 round 3, B3: emit-when-VALID too — see the
|
|
1507
|
+
// clock-skew ruling at the stamp site above.)
|
|
1508
|
+
...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
|
|
1312
1509
|
error: sessionError
|
|
1313
1510
|
};
|
|
1314
1511
|
}
|
|
@@ -1326,6 +1523,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1326
1523
|
...subtreeResult,
|
|
1327
1524
|
// #133 P1: see the comment on the sibling return above — guaranteed set.
|
|
1328
1525
|
opencodeSessionId: sessionId,
|
|
1526
|
+
// v4.9 W13 Task A: emit-when-set — see the sibling return above.
|
|
1527
|
+
...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
|
|
1329
1528
|
exitCode: 0
|
|
1330
1529
|
};
|
|
1331
1530
|
|
|
@@ -1410,6 +1609,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1410
1609
|
// outcomes explicit and already schema-shaped (run.schema.json:23
|
|
1411
1610
|
// wants string|null, never undefined).
|
|
1412
1611
|
opencodeSessionId: sessionId || null,
|
|
1612
|
+
// v4.9 W13 Task A (PR #203 round 1, A2): emit-when-set, the SAME rule as
|
|
1613
|
+
// the two returns in the try body. An exception is not evidence that
|
|
1614
|
+
// nothing was served — a leg can stream a first token and explode in the
|
|
1615
|
+
// post-loop finalization (`server.close()` is the one unguarded await on
|
|
1616
|
+
// that path), and throwing away a measurement it already made would be a
|
|
1617
|
+
// second, silent loss on top of the first. Nothing is invented: a leg
|
|
1618
|
+
// that exploded before any poll observed activity still carries no key.
|
|
1619
|
+
...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
|
|
1413
1620
|
error: error.message
|
|
1414
1621
|
};
|
|
1415
1622
|
}
|
package/src/mcp-council-bench.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @module mcp-council-bench
|
|
6
|
-
* Bench resolution for `amicus_council_run` (models XOR council preset)
|
|
7
|
-
*
|
|
6
|
+
* Bench resolution for `amicus_council_run` (models XOR council preset), plus
|
|
7
|
+
* the MCP transport's alias-shadow notice site (`auditBenchAliases`, PR #207
|
|
8
|
+
* round 2 A1 — the mirror of what cli-council-run-bench.js owns for the CLI).
|
|
9
|
+
* Split out of mcp-council-run.js (v4.6 Plan 4 Task 4b): that file sat at 298/300
|
|
8
10
|
* lines and the --dropped-members producer (the MCP→child transport-parity
|
|
9
11
|
* fix) needed the room. `resolveBenchInput` is self-contained — no dependency
|
|
10
12
|
* on the handler's validation/spawn-argv logic — so it moves verbatim to its
|
|
@@ -46,4 +48,63 @@ function resolveBenchInput(input) {
|
|
|
46
48
|
return { bench: inputModels, presetName: null, droppedMembers: [] };
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
/**
|
|
52
|
+
* THE MCP-SIDE alias-shadow notice site (PR #207 council round 2, finding A1).
|
|
53
|
+
*
|
|
54
|
+
* The CLI's twin lives in `cli-council-run-bench.js :: resolveBench`; each
|
|
55
|
+
* transport's bench module owns its own audit site, and both call the shared
|
|
56
|
+
* `auditAliasShadows` entry point and nothing else.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ WHY A SECOND SITE AT ALL. Round 1 (finding A4) measured that the CLI seam
|
|
59
|
+
* EXECUTES on the MCP path — `mcp-council-run.js` always spawns the child with
|
|
60
|
+
* an expanded `--models` — but SURFACES nothing there: `spawnSidecarProcess`
|
|
61
|
+
* gives the child `stdio: ['ignore','ignore',<fd>]` on `<runDir>/debug.log`, so
|
|
62
|
+
* its stderr is a file the client never reads. This site writes into the tool
|
|
63
|
+
* result instead, so the MCP caller actually sees it. The two copies live on
|
|
64
|
+
* different surfaces (a tool-result block here; `debug.log` there), so neither
|
|
65
|
+
* surface ever double-prints.
|
|
66
|
+
*
|
|
67
|
+
* Called from the HANDLER rather than from `resolveBenchInput` above, because
|
|
68
|
+
* only the handler has the chair and the critic — and because the handler runs
|
|
69
|
+
* on every council_run call while a wiring inside `resolveBenchInput` would sit
|
|
70
|
+
* on both of its branches but still miss nothing else. Diagnosis only: this
|
|
71
|
+
* changes no id, no exit code and no artifact.
|
|
72
|
+
*
|
|
73
|
+
* ⚠️ WHERE IN THE HANDLER, and why it moved (PR #207 round 5, A1). The call used
|
|
74
|
+
* to sit beside the bench validation, ABOVE the critic+lenses mutual exclusion,
|
|
75
|
+
* the lens-count mismatch and every later rejection. `notices` is only ever read
|
|
76
|
+
* at the very end, when the tool result is assembled, so each of those
|
|
77
|
+
* rejections computed an audit — reading the user's `config.json` off disk,
|
|
78
|
+
* comparing two alias tables, formatting lines — and then discarded every line
|
|
79
|
+
* with the rest of the result. Dead work, and it contradicted the CLI's "a
|
|
80
|
+
* rejected bench is silent by construction" as well as the suite's own
|
|
81
|
+
* rejected-run control (which passed only because it exercised the ONE rejection
|
|
82
|
+
* that returns before the audit).
|
|
83
|
+
*
|
|
84
|
+
* So the site is now immediately after the spawn succeeds — the first point from
|
|
85
|
+
* which the handler is guaranteed to return the success content. Everything left
|
|
86
|
+
* below it is best-effort (the pid write, the notify request, auto-open), and
|
|
87
|
+
* none of it can return early. "Never computed on a rejected call" is therefore
|
|
88
|
+
* structural rather than a property of which rejections happen to sit above it,
|
|
89
|
+
* which is what the previous placement got wrong.
|
|
90
|
+
*
|
|
91
|
+
* ⚠️ AND THE LINE IS RE-SHAPED FOR THIS SURFACE (PR #207 round 5, D3).
|
|
92
|
+
* `formatAliasShadow` terminates every notice with `\n`, because both of its
|
|
93
|
+
* stream writers need it to make the text a line. An MCP content block is not a
|
|
94
|
+
* stream: its sibling notices (`pack-resolve.js`) carry no trailing newline, so
|
|
95
|
+
* an un-trimmed alias-shadow block rendered with a stray blank line after it.
|
|
96
|
+
* The trim lives HERE, at the one writer whose sink is not a stream, rather than
|
|
97
|
+
* in `formatAliasShadow` — the newline is correct for the CLI and `models
|
|
98
|
+
* --check`, and the round-4 sanitizing pins assert `endsWith('\n')` on that side.
|
|
99
|
+
* @param {string[]} bench resolved bench seats (already expanded)
|
|
100
|
+
* @param {string} chair explicit or default chair alias
|
|
101
|
+
* @param {string|null} critic critic alias, when one was named
|
|
102
|
+
* @param {string[]} notices the handler's per-call notice array (tool-result blocks)
|
|
103
|
+
*/
|
|
104
|
+
function auditBenchAliases(bench, chair, critic, notices) {
|
|
105
|
+
require('./utils/alias-shadow').auditAliasShadows(
|
|
106
|
+
[...bench, chair, ...(critic ? [critic] : [])],
|
|
107
|
+
(line) => notices.push(String(line).trimEnd()));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { resolveBenchInput, auditBenchAliases };
|
package/src/mcp-council-run.js
CHANGED
|
@@ -19,7 +19,7 @@ const { isPathInside } = require('./project-root-allowlist');
|
|
|
19
19
|
const { validateOnComplete, requestMcpNotify } = require('./mcp-notify');
|
|
20
20
|
// v4.6 Plan 4 Task 4b: resolveBenchInput moved to its own leaf (size gate) —
|
|
21
21
|
// see mcp-council-bench.js's module docblock for why.
|
|
22
|
-
const { resolveBenchInput } = require('./mcp-council-bench');
|
|
22
|
+
const { resolveBenchInput, auditBenchAliases } = require('./mcp-council-bench');
|
|
23
23
|
|
|
24
24
|
function textResult(text, isError) {
|
|
25
25
|
const result = { content: [{ type: 'text', text }] };
|
|
@@ -58,7 +58,7 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
58
58
|
// for any caller that bypasses schema validation).
|
|
59
59
|
const oc = validateOnComplete(input.onComplete);
|
|
60
60
|
if (!oc.ok) { return textResult(oc.error, true); }
|
|
61
|
-
const CHAIR_DEFAULT = '
|
|
61
|
+
const { CHAIR_DEFAULT } = require('./cli-council-run-bench'); // v4.9 W13 (PR #203 A6): one owner, not a second copy
|
|
62
62
|
if (typeof input.briefingFile !== 'string' || !input.briefingFile.trim()) {
|
|
63
63
|
return textResult("amicus_council_run requires 'briefingFile' (a path to the briefing).", true);
|
|
64
64
|
}
|
|
@@ -204,6 +204,9 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
204
204
|
// v4.7 F8 (D13): the spawned CLI child's own cli-handlers-council-run.js
|
|
205
205
|
// stores the tag on the run.json seed (Task 3) — this handler only forwards.
|
|
206
206
|
if (input.tag) { args.push('--tag', input.tag); }
|
|
207
|
+
// v4.9 W5.2: emit-when-'task' — 'review' (the zod-declared default spelled
|
|
208
|
+
// out) never reaches the child's argv; review-run argv stays byte-identical.
|
|
209
|
+
if (input.intent === 'task') { args.push('--intent', 'task'); }
|
|
207
210
|
|
|
208
211
|
let child;
|
|
209
212
|
try { child = helpers.spawnFn(args, runDir); } catch (err) {
|
|
@@ -219,6 +222,9 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
219
222
|
// read-merge-write has no lock (see run-state.writeSpawnPid).
|
|
220
223
|
try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
|
|
221
224
|
catch { /* best-effort */ }
|
|
225
|
+
// PR #207 round 2 (A1) + round 5 (A1): the alias-shadow notice's MCP surface,
|
|
226
|
+
// sited past every return that discards `notices` — rationale on its own def.
|
|
227
|
+
auditBenchAliases(bench, chair, critic, notices);
|
|
222
228
|
// Task 15 (spec §5.3): the run is now known-launched under runId — mark it
|
|
223
229
|
// for a best-effort terminal notify. runWait's poll loop (mcp-wait.js) is
|
|
224
230
|
// the only code that later sees this council run reach terminal state.
|
|
@@ -273,7 +279,8 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
273
279
|
const content = [{ type: 'text', text: fenceSidecarOutput(body) }];
|
|
274
280
|
// v4.5 Task 15: pack/template notices (e.g. a bench-override) are non-fatal —
|
|
275
281
|
// surfaced as extra unfenced content blocks, same precedent as
|
|
276
|
-
// mcp-server.js's routeResult.notice (amicus_start).
|
|
282
|
+
// mcp-server.js's routeResult.notice (amicus_start). PR #207 round 2 (A1)
|
|
283
|
+
// added a third producer: the alias-shadow lines from auditBenchAliases.
|
|
277
284
|
for (const n of notices) { content.push({ type: 'text', text: n }); }
|
|
278
285
|
return { content };
|
|
279
286
|
}
|
package/src/mcp-server.js
CHANGED
|
@@ -390,8 +390,34 @@ const handlers = {
|
|
|
390
390
|
// The file itself is written just before the spawn fallback below (the
|
|
391
391
|
// shared-server path passes the prompt in-process and never reads args).
|
|
392
392
|
const briefingPath = path.join(sessionDir, 'briefing.md');
|
|
393
|
+
// ⚠️ W1-M4 (v4.9 W12), the amicus_start half of the divergence Task 7 closed
|
|
394
|
+
// for src/mcp-server.js :: amicus_fanout — same shape, same reason.
|
|
395
|
+
// `briefing.md` and the initial `metadata.briefing` below both used to be
|
|
396
|
+
// written from the RAW `input.prompt` while the shared-server branch's own
|
|
397
|
+
// metadata write recorded the rendered text, and for a `start` row
|
|
398
|
+
// `metadata.briefing` IS the `--search` corpus
|
|
399
|
+
// (src/sidecar/list-search.js :: rowMatchesSearch falls through to
|
|
400
|
+
// `row.briefing` for anything that is not a wave or a council run). A child that dies before
|
|
401
|
+
// its own createSessionMetadata therefore left the session permanently
|
|
402
|
+
// unfindable by the text the user actually sees. So: the RENDERED text goes
|
|
403
|
+
// on disk, and the child is handed the RAW input through a sibling
|
|
404
|
+
// `briefing-input.md`, keeping its own later render byte-identical and still
|
|
405
|
+
// the provenance source for `promptMeta.template`.
|
|
406
|
+
//
|
|
407
|
+
// ⚠️ Both defaults are LOAD-BEARING: with no pack-forwarded template nothing
|
|
408
|
+
// renders, so the rendered text IS the raw prompt and childPromptPath must
|
|
409
|
+
// fall back to briefingPath — otherwise every ordinary start would spawn
|
|
410
|
+
// with `--prompt-file undefined`.
|
|
411
|
+
//
|
|
412
|
+
// Hoisted here (it was local to the shared-server branch) so ONE expression
|
|
413
|
+
// serves both paths: two copies of "rendered, else raw" is the drift that
|
|
414
|
+
// produced this defect in the first place.
|
|
415
|
+
const renderedPrompt = fwd.renderedPrompt !== undefined ? fwd.renderedPrompt : input.prompt;
|
|
416
|
+
const childPromptPath = fwd.renderedPrompt !== undefined
|
|
417
|
+
? path.join(sessionDir, 'briefing-input.md')
|
|
418
|
+
: briefingPath;
|
|
393
419
|
const detectedClient = detectClient(mcpServer);
|
|
394
|
-
const args = ['start', '--prompt-file',
|
|
420
|
+
const args = ['start', '--prompt-file', childPromptPath, '--task-id', taskId, '--client', detectedClient];
|
|
395
421
|
// resolvedModel is always defined here — a routing failure already
|
|
396
422
|
// returned above — and is the router's executableId, not the raw alias.
|
|
397
423
|
args.push('--model', resolvedModel);
|
|
@@ -434,11 +460,12 @@ const handlers = {
|
|
|
434
460
|
let sessionId;
|
|
435
461
|
try {
|
|
436
462
|
// v4.5 decision 1 + Wave-1 fix (I1/I2): the template was already
|
|
437
|
-
// rendered by the shared prepareForward call above —
|
|
438
|
-
//
|
|
439
|
-
//
|
|
463
|
+
// rendered by the shared prepareForward call above — this path reuses
|
|
464
|
+
// its text (`renderedPrompt`, hoisted above with the spawn fallback in
|
|
465
|
+
// W12) instead of rendering again. Notices stay LOCAL (not
|
|
466
|
+
// packNotices): a fall-through to the spawn-fallback catch must never
|
|
467
|
+
// leak one.
|
|
440
468
|
const inProcessNotices = [...fwd.notices];
|
|
441
|
-
const renderedPrompt = fwd.renderedPrompt !== undefined ? fwd.renderedPrompt : input.prompt;
|
|
442
469
|
// v4.7 PR6: the gate used to hang off `packForward.maxCost !== undefined`,
|
|
443
470
|
// so a no-pack MCP start skipped it while the CLI (cli-handlers-run.js:90)
|
|
444
471
|
// gated unconditionally with a cfg.maxCost fallback. Same guard, both doors.
|
|
@@ -660,13 +687,18 @@ const handlers = {
|
|
|
660
687
|
}
|
|
661
688
|
|
|
662
689
|
// Feature flag disabled (or shared server failed): fall back to per-process spawn.
|
|
663
|
-
// BL-1: create the session dir and write the prompt
|
|
664
|
-
//
|
|
665
|
-
//
|
|
690
|
+
// BL-1: create the session dir and write the prompt files BEFORE the spawn so
|
|
691
|
+
// --prompt-file (built above) resolves to a real file, keeping the full
|
|
692
|
+
// prompt off the ~32KB-capped Windows command line. W1-M4: briefing.md is
|
|
693
|
+
// the RENDERED text; the sibling briefing-input.md exists only when a pack
|
|
694
|
+
// template actually rendered something (see childPromptPath above).
|
|
666
695
|
let child;
|
|
667
696
|
try {
|
|
668
697
|
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
669
|
-
fs.writeFileSync(briefingPath,
|
|
698
|
+
fs.writeFileSync(briefingPath, renderedPrompt, { mode: 0o600 });
|
|
699
|
+
if (childPromptPath !== briefingPath) {
|
|
700
|
+
fs.writeFileSync(childPromptPath, input.prompt, { mode: 0o600 });
|
|
701
|
+
}
|
|
670
702
|
child = spawnSidecarProcess(args, sessionDir);
|
|
671
703
|
} catch (err) {
|
|
672
704
|
return textResult(`Failed to start Amicus: ${err.message}`, true);
|
|
@@ -681,8 +713,11 @@ const handlers = {
|
|
|
681
713
|
headless: !!input.noUi,
|
|
682
714
|
// Seed briefing/mode so list/status are informative even before the
|
|
683
715
|
// CLI child's createSessionMetadata overwrite (or if it crashes first).
|
|
716
|
+
// W1-M4 (see the briefing.md block above): the RENDERED text, twin of
|
|
717
|
+
// the shared-server branch's own write — this field is a start row's
|
|
718
|
+
// `--search` corpus.
|
|
684
719
|
mode: input.noUi ? 'headless' : 'interactive',
|
|
685
|
-
briefing:
|
|
720
|
+
briefing: renderedPrompt,
|
|
686
721
|
// v4.5 Task 15: additive-only — absent (not null) without a pack.
|
|
687
722
|
...(packRecord ? { pack: packRecord } : {}),
|
|
688
723
|
}, null, 2), { mode: 0o600 });
|
|
@@ -1316,7 +1351,8 @@ const handlers = {
|
|
|
1316
1351
|
// ⚠️ W1-M4: briefing.md is the SEARCH CORPUS — src/sidecar/list-search.js reads it verbatim
|
|
1317
1352
|
// — and a child that aborts before fanout.js:145 never re-renders it, leaving the wave
|
|
1318
1353
|
// permanently unfindable by the text the user actually sees. Write the RENDERED text here
|
|
1319
|
-
// (parity with
|
|
1354
|
+
// (parity with src/mcp-server.js :: amicus_start, whose own `renderedPrompt` was hoisted
|
|
1355
|
+
// above both of its branches in v4.9 W12 — see its W1-M4 block) and hand the child the raw input in a
|
|
1320
1356
|
// sibling file, so its own later re-render still produces byte-identical output and
|
|
1321
1357
|
// promptMeta.template provenance survives.
|
|
1322
1358
|
const briefingText = fwd.renderedPrompt !== undefined ? fwd.renderedPrompt : input.prompt;
|
|
@@ -1424,7 +1460,11 @@ const handlers = {
|
|
|
1424
1460
|
const record = tally(input);
|
|
1425
1461
|
// Auto-append to the reliability ledger (parity with `amicus council
|
|
1426
1462
|
// tally`). Best-effort: a ledger write failure must not fail the tally.
|
|
1427
|
-
|
|
1463
|
+
// v4.9 W5.4 gate 3: task-run records never feed it — gated on the
|
|
1464
|
+
// RECORD's meta.intent (tally copies meta verbatim from the input).
|
|
1465
|
+
if (!(record.meta && record.meta.intent === 'task')) {
|
|
1466
|
+
try { require('./council/ledger').appendRun(record); } catch { /* best-effort */ }
|
|
1467
|
+
}
|
|
1428
1468
|
// v4.0 §8 (H9): fence the JSON — council output summarizes untrusted
|
|
1429
1469
|
// model prose entering the orchestrating agent's context. JSON intact
|
|
1430
1470
|
// inside the fence; CLI --json stays unfenced (the programmatic channel).
|