amicus 4.7.0 → 4.8.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 +948 -1
- package/README.md +3 -2
- package/docs/CITATIONS.md +114 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +181 -34
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +6 -4
- package/docs/usage.md +15 -11
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +6 -3
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/skills/sidecar/SKILL.md +3 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +56 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +179 -162
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +83 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/sidecar/setup.js +13 -2
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/remediation-hints.js +42 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +27 -1
- package/src/workspace/seat-space.js +143 -0
package/src/council/tally.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// src/council/tally.js
|
|
2
2
|
'use strict';
|
|
3
|
+
const { peersOf, unattributedPeerDrops } = require('./peer-split');
|
|
4
|
+
// The street-cred half (rankPositions / credSeats / computeStreetCred) lives in
|
|
5
|
+
// ./street-cred (v4.8 T3.3 size-gate split; this file stood at 301 of 300 with
|
|
6
|
+
// the seat-keying in it — release Constraint 6 is EXTRACT, never shave).
|
|
7
|
+
// computeStreetCred is re-exported below, so no existing import path moved.
|
|
8
|
+
const { computeStreetCred } = require('./street-cred');
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* Peers-only tier cascade. a/d are agree/dispute counts among PEER judges
|
|
@@ -26,49 +32,9 @@ function assignTier(a, d) {
|
|
|
26
32
|
return { tier, confidence };
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
function mean(arr) { return arr.reduce((s, x) => s + x, 0) / arr.length; }
|
|
30
|
-
|
|
31
|
-
/** Map each model to its (possibly fractional) rank position in one judge's order. */
|
|
32
|
-
function rankPositions(order) {
|
|
33
|
-
const pos = new Map();
|
|
34
|
-
let p = 1;
|
|
35
|
-
for (const slot of order) {
|
|
36
|
-
const group = Array.isArray(slot) ? slot : [slot];
|
|
37
|
-
const meanPos = p + (group.length - 1) / 2;
|
|
38
|
-
for (const m of group) { pos.set(m, meanPos); }
|
|
39
|
-
p += group.length;
|
|
40
|
-
}
|
|
41
|
-
return pos;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Both-numbers street-cred. Lower mean rank = better.
|
|
46
|
-
* @param {Array<{judge:string, order:Array<string|string[]>}>} rankings
|
|
47
|
-
* @param {string[]} models all reviewed models (incl. claude when in-council)
|
|
48
|
-
*/
|
|
49
|
-
function computeStreetCred(rankings, models) {
|
|
50
|
-
const judgePos = rankings.map(r => ({ judge: r.judge, pos: rankPositions(r.order) }));
|
|
51
|
-
return models.map(m => {
|
|
52
|
-
const all = [], peers = [], perJudgeRank = {};
|
|
53
|
-
for (const { judge, pos } of judgePos) {
|
|
54
|
-
if (!pos.has(m)) { continue; } // absent from this judge's ranking → skip
|
|
55
|
-
const rank = pos.get(m);
|
|
56
|
-
perJudgeRank[judge] = rank;
|
|
57
|
-
all.push(rank);
|
|
58
|
-
if (judge !== m) { peers.push(rank); }
|
|
59
|
-
}
|
|
60
|
-
return {
|
|
61
|
-
model: m,
|
|
62
|
-
withSelf: all.length ? mean(all) : null,
|
|
63
|
-
peersOnly: peers.length ? mean(peers) : null,
|
|
64
|
-
perJudgeRank,
|
|
65
|
-
};
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
35
|
// v4.0 §7: council family v2 — every council doc carries {schemaVersion, type}.
|
|
70
36
|
const COUNCIL_SCHEMA_VERSION = 2;
|
|
71
|
-
const VERDICTS = { agree: 'a', dispute: 'd', neutral: 'n' };
|
|
37
|
+
const VERDICTS = { __proto__: null, agree: 'a', dispute: 'd', neutral: 'n' };
|
|
72
38
|
|
|
73
39
|
function countTiers(findings) {
|
|
74
40
|
const counts = { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 };
|
|
@@ -86,31 +52,111 @@ function tally(input) {
|
|
|
86
52
|
const byFinding = new Map();
|
|
87
53
|
for (const adj of adjudications) {
|
|
88
54
|
if (!byFinding.has(adj.findingId)) { byFinding.set(adj.findingId, []); }
|
|
89
|
-
byFinding.get(adj.findingId).push({ judge: adj.judge, verdict: adj.verdict });
|
|
55
|
+
byFinding.get(adj.findingId).push({ judge: adj.judge, verdict: adj.verdict, ...(adj.seat ? { seat: adj.seat } : {}) });
|
|
90
56
|
}
|
|
91
57
|
const outFindings = findings.map(f => {
|
|
92
58
|
const votes = byFinding.get(f.id) || [];
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
59
|
+
const peers = peersOf(f, votes);
|
|
60
|
+
// v4.8 T-B2: how many votes `peersOf` excluded without being able to
|
|
61
|
+
// attribute them — the one-sided alias fallback, plus (T-B4) the falsy
|
|
62
|
+
// judges of a falsy raiser. ⚠️ NOT the votes the SEAT ids attributed: when
|
|
63
|
+
// both sides carry a seat id the engine knows whose vote it is, so that
|
|
64
|
+
// exclusion is announced by nothing. Same function and same emit rule
|
|
65
|
+
// (> 0 only) as debate.js :: debateTargets, so this document and the
|
|
66
|
+
// defense brief can never announce different numbers.
|
|
67
|
+
// ⚠️ On the SEATED shapes — tally.test.js T1 and T2, the one-sided twin
|
|
68
|
+
// pair — `basis` deliberately does NOT move: counting the ambiguous vote
|
|
69
|
+
// reproduces the naive filter's outcome, measured Confirmed on both, which
|
|
70
|
+
// re-arms #137. Announced, not counted. ⚠️ T-B4 is the deliberate exception
|
|
71
|
+
// and the scope of that sentence narrowed with it: on a finding whose
|
|
72
|
+
// raiser is FALSY, `basis` DOES move, because there the ambiguous vote is
|
|
73
|
+
// the raiser's own inflating its own basis rather than a twin's signal
|
|
74
|
+
// going uncounted (council C1 — see peer-split.js :: peersOf).
|
|
75
|
+
const drops = unattributedPeerDrops(f, votes);
|
|
97
76
|
const basis = { a: 0, d: 0, n: 0 };
|
|
98
77
|
// Skip unknown verdict strings so a stray value can't corrupt the basis via
|
|
99
|
-
// basis[undefined] = NaN (L9).
|
|
78
|
+
// basis[undefined] = NaN (L9). Guaranteed by VERDICTS's `__proto__: null`,
|
|
79
|
+
// not `!== undefined` alone — measured, all four Object.prototype keys
|
|
80
|
+
// (toString/__proto__/constructor/valueOf) read undefined on it. VERDICTS
|
|
81
|
+
// is module-local, absent from module.exports, so this isn't observable
|
|
82
|
+
// outside this file.
|
|
100
83
|
for (const v of peers) {
|
|
101
84
|
const key = VERDICTS[v.verdict];
|
|
102
85
|
if (key !== undefined) { basis[key] += 1; }
|
|
103
86
|
}
|
|
104
87
|
const { tier, confidence } = assignTier(basis.a, basis.d);
|
|
88
|
+
// v4.8 PR4c §3.3 (R8): `peers` has already excluded the raiser BY SEAT, so a
|
|
89
|
+
// surviving peer whose ALIAS equals the raiser's is a different seat of the
|
|
90
|
+
// same model — corroboration that is not independent. Emitted only when TRUE
|
|
91
|
+
// (an unconditional `false` would change every document's shape).
|
|
92
|
+
// ⚠️ The leading `f.raiser &&` is LOAD-BEARING, not decoration — and the
|
|
93
|
+
// interesting part is that this sentence was FALSE for one commit inside
|
|
94
|
+
// v4.8 T-B4, so it is written with its measurement rather than its
|
|
95
|
+
// adjective. It is load-bearing because `peersOf` can hand back a
|
|
96
|
+
// seat-carrying vote whose `judge` is falsy, and then `v.judge === f.raiser`
|
|
97
|
+
// reads `undefined === undefined` on the CLI path (cli-handlers-council.js
|
|
98
|
+
// is a raw JSON.parse with no schema) and `'' === ''` on the MCP path
|
|
99
|
+
// (mcp-tools.js's z.string() accepts the empty string) — so without the
|
|
100
|
+
// guard this stamp fires on documents that name no models at all.
|
|
101
|
+
// MEASURED at each step over the 768-shape cross-product of (f.raiser,
|
|
102
|
+
// f.raiserSeat, v.judge, v.seat, verdict): deleting the guard flipped 8
|
|
103
|
+
// shapes at 64b835b8, ZERO after T-B4 round 1 — which had made `peersOf`
|
|
104
|
+
// drop every falsy-judge vote of a falsy raiser, briefly disarming the very
|
|
105
|
+
// pins that guarded this — and 4 after round 2, whose P0 rule counts the
|
|
106
|
+
// ones the SEAT ids prove are real peers. The 4 are the seat-DIFFER shapes
|
|
107
|
+
// of the T7b and T7d families, i.e. exactly those two tests. Re-run this
|
|
108
|
+
// after any edit to peer-split.js :: peersOf; do not infer it.
|
|
109
|
+
// ⚠️ Alias-only in BOTH directions, and the CHANGELOG says so: it misses
|
|
110
|
+
// `gpt-5,openai/gpt-5` (one model, two aliases — votes carry no
|
|
111
|
+
// resolvedModel) and it fires falsely on a SPLIT alias, whose two seats
|
|
112
|
+
// resolved to different executables. PR4b's ledger treats
|
|
113
|
+
// (alias, resolvedModel) as identity; this stamp is same-ALIAS only.
|
|
114
|
+
// SI-23 (R10): `location` is declared on the MCP schema now
|
|
115
|
+
// (mcp-tools.js :: getTools) so it survives zod validation, but
|
|
116
|
+
// surviving validation and reaching THIS document are two different
|
|
117
|
+
// properties — the round-trip pin below only holds if this map also
|
|
118
|
+
// forwards it, emit-when-present, the same convention as `raiserSeat`
|
|
119
|
+
// two lines up. Scoped to `location` alone AT R10: `claim` was already
|
|
120
|
+
// reaching this function on both the CLI and MCP paths (declared on the
|
|
121
|
+
// MCP schema since before R10) and was ALREADY not forwarded here — R10
|
|
122
|
+
// named the gap and left it for a separate PR rather than widening its
|
|
123
|
+
// own mandate to close it. SI-23 fix round 1 (paid council on PR #183,
|
|
124
|
+
// findings A1/B1, two independent raisers) ruled that indefensible one
|
|
125
|
+
// line from the fix it sits beside: `claim` is forwarded below too,
|
|
126
|
+
// same round.
|
|
127
|
+
// A2 (nit) / C1 (major, contested a0/d1/n2, thin): the truthiness checks
|
|
128
|
+
// below drop an empty string that zod would accept. Deliberate, not a
|
|
129
|
+
// bug — `raiserSeat` above uses the identical pattern, and diverging
|
|
130
|
+
// `location`/`claim` from their own immediate sibling is worse than the
|
|
131
|
+
// edge case, so "" is treated as absent on all three fields.
|
|
105
132
|
return { id: f.id, raiser: f.raiser, severity: f.severity, tier, basis, confidence,
|
|
106
|
-
tierOverride: null, adjudications: votes }
|
|
133
|
+
tierOverride: null, adjudications: votes, ...(f.raiserSeat ? { raiserSeat: f.raiserSeat } : {}),
|
|
134
|
+
...(f.location ? { location: f.location } : {}),
|
|
135
|
+
...(f.claim ? { claim: f.claim } : {}),
|
|
136
|
+
...(f.raiser
|
|
137
|
+
&& peers.some(v => v.seat && f.raiserSeat && VERDICTS[v.verdict] === 'a' && v.judge === f.raiser)
|
|
138
|
+
? { sameModelCorroboration: true } : {}),
|
|
139
|
+
...(drops > 0 ? { unattributedPeerDrops: drops } : {}) };
|
|
107
140
|
});
|
|
108
141
|
return {
|
|
109
142
|
schemaVersion: COUNCIL_SCHEMA_VERSION,
|
|
110
143
|
type: 'council-tally',
|
|
111
144
|
meta,
|
|
112
145
|
judged: Array.isArray(rankings) && rankings.length >= 2,
|
|
113
|
-
|
|
146
|
+
// v4.8 T3.3: `meta.seats` joins BY VALUE inside computeStreetCred (never
|
|
147
|
+
// positionally against meta.models — run-assemble.js :: buildTallyInput
|
|
148
|
+
// forbids that). The ENGINE emits it only when the bench repeats an alias,
|
|
149
|
+
// so a unique-alias run leaves the rows alias-driven and byte-identical.
|
|
150
|
+
// ⚠️ DO NOT READ THAT AS "hand-assembled input is always alias-driven" —
|
|
151
|
+
// this sentence said so until fix round 1 and it was false, in the way that
|
|
152
|
+
// hides a defect rather than merely misinforming. `meta` is copied verbatim
|
|
153
|
+
// from user JSON on both hand-assembled appendRun paths, and
|
|
154
|
+
// mcp-tools.js :: amicus_council_tally DECLARES `meta.seats`, so such a
|
|
155
|
+
// record produces SEAT-driven street-cred rows here while its `runStats`
|
|
156
|
+
// rows — declared `z.array(z.record(z.any()))`, so never asked for a seat —
|
|
157
|
+
// carry none. That asymmetry is a live quadrant, not a hypothetical; it is
|
|
158
|
+
// what ledger-join.js :: credFor's second lookup exists for.
|
|
159
|
+
streetCred: computeStreetCred(rankings || [], meta.models, meta.seats),
|
|
114
160
|
findings: outFindings,
|
|
115
161
|
runStats: (runStats || []).map(r => ({
|
|
116
162
|
model: r.model, role: r.role, wasChair: !!r.wasChair, conformance: r.conformance || 'clean',
|
|
@@ -128,6 +174,11 @@ function tally(input) {
|
|
|
128
174
|
...(r.repairRefused ? { repairRefused: r.repairRefused } : {}),
|
|
129
175
|
...(r.waveId ? { waveId: r.waveId } : {}),
|
|
130
176
|
...(r.resolvedModel ? { resolvedModel: r.resolvedModel } : {}),
|
|
177
|
+
// v4.8 PR4c §3.1: `seat` rides the same slot. It is emitted upstream only
|
|
178
|
+
// when the bench repeats that alias (run-assemble.js's buildRunStatsEntry),
|
|
179
|
+
// so a unique-alias run is byte-for-byte unchanged here and in verdict.json,
|
|
180
|
+
// which copies this array verbatim (verdict.js :: buildVerdict).
|
|
181
|
+
...(r.seat ? { seat: r.seat } : {}),
|
|
131
182
|
status: r.status || 'unknown',
|
|
132
183
|
durationMs: typeof r.durationMs === 'number' ? r.durationMs : null,
|
|
133
184
|
usage: r.usage || null,
|
package/src/council/verdict.js
CHANGED
|
@@ -108,6 +108,13 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
108
108
|
date: record.meta.date,
|
|
109
109
|
chair: record.meta.chair,
|
|
110
110
|
council: record.meta.models,
|
|
111
|
+
// v4.8 PR4c §3.2: this projection RENAMES meta.models to `council`, so
|
|
112
|
+
// nothing from meta reaches verdict.json unless it is named here — which is
|
|
113
|
+
// why the seat table needs its own line rather than riding tally's verbatim
|
|
114
|
+
// meta copy. Emitted only when the record carries one (a twin bench), so a
|
|
115
|
+
// unique-alias verdict is byte-for-byte unchanged. The key is `seats`,
|
|
116
|
+
// matching the `seatLoss` sibling below; PR5 codes against that name.
|
|
117
|
+
...(record.meta.seats ? { seats: record.meta.seats } : {}),
|
|
111
118
|
claudeInCouncil: record.meta.claudeInCouncil,
|
|
112
119
|
overallVerdict: opts.overallVerdict === undefined ? null : opts.overallVerdict,
|
|
113
120
|
findings: record.findings.map(f => {
|
|
@@ -121,11 +128,52 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
121
128
|
adjudications: f.adjudications,
|
|
122
129
|
decision: d.decision || null,
|
|
123
130
|
applied: d.applied === true,
|
|
131
|
+
// v4.8 PR4c §3.4: this literal is CLOSED — it names every key and copies
|
|
132
|
+
// nothing else off `f` — so the two fields tally() stamps need their own
|
|
133
|
+
// lines or verdict.json names seats it cannot resolve (§1.2). Appended as
|
|
134
|
+
// a pure TAIL, leaving the shipped eleven-key order untouched.
|
|
135
|
+
// ⚠️ NOT `|| null`, even though `duplicateOf` and `decision` above are:
|
|
136
|
+
// `JSON.stringify({raiserSeat: null})` still WRITES `"raiserSeat":`, so
|
|
137
|
+
// that idiom changes the shape of every unique-alias verdict.json and
|
|
138
|
+
// fails seat-parity-ondisk's needles. `applied` is the sibling to copy —
|
|
139
|
+
// it computes a value rather than defaulting one. Emit-when-set matches
|
|
140
|
+
// both producers (tally.js) and keeps a non-twin verdict byte-identical.
|
|
141
|
+
...(f.raiserSeat ? { raiserSeat: f.raiserSeat } : {}),
|
|
142
|
+
...(f.sameModelCorroboration ? { sameModelCorroboration: true } : {}),
|
|
124
143
|
};
|
|
125
144
|
if (f.debate) { out.debate = f.debate; } // v4.1: additive debate decoration carry-through (spec §5.6)
|
|
126
145
|
return out;
|
|
127
146
|
}),
|
|
128
|
-
|
|
147
|
+
// v4.8 fix round 1 (review finding): emit-when-DIFFERENT, adapted to this
|
|
148
|
+
// row's flat {model, seat} shape — model is the alias, seat is the seat
|
|
149
|
+
// id, so on a unique-alias bench they are byte-equal and nothing is
|
|
150
|
+
// emitted (same semantics as `seat.id !== seat.alias` one layer up,
|
|
151
|
+
// run-stats-entry.js:64). NOT a plain pass-through like `raiserSeat`
|
|
152
|
+
// above (:141) — that field's upstream producer already holds a real
|
|
153
|
+
// {id, alias} seat OBJECT at its own decision point (run.js:202:
|
|
154
|
+
// `r.seat && r.seat.id !== r.seat.alias`), so passing its verdict
|
|
155
|
+
// through here is safe. The street-cred producer never has such an object
|
|
156
|
+
// at this point, only a flat row, so a pass-through here would leak `seat`
|
|
157
|
+
// onto every unique-alias verdict.json the moment that producer's own
|
|
158
|
+
// guard slipped — silently, since nothing else guards this closed literal.
|
|
159
|
+
// This check is deliberate defense in depth: buildVerdict is also reachable
|
|
160
|
+
// on externally-supplied records that never touched computeStreetCred
|
|
161
|
+
// in-process at all — the MCP `record` param of mcp-tools.js ::
|
|
162
|
+
// amicus_verdict is `z.record(z.any())`, fully permissive — so this
|
|
163
|
+
// literal's own byte-identity cannot be contingent on that producer alone;
|
|
164
|
+
// this file's own tests exercise that exact shape (hand-built rec objects,
|
|
165
|
+
// never calling tally()). tally.json keeps its own pin regardless — a
|
|
166
|
+
// genuine producer bug still reds at seat-parity-ondisk.test.js — this
|
|
167
|
+
// check exists so verdict.json is never the ONE document such a bug (or an
|
|
168
|
+
// externally-supplied record) masks.
|
|
169
|
+
// ⚠️ NO LONGER INERT. This comment said "computeStreetCred emits no `seat`
|
|
170
|
+
// at all yet" until v4.8 T3.3 shipped that producer — street-cred.js ::
|
|
171
|
+
// computeStreetCred, one row per SEAT with the id emitted when it differs
|
|
172
|
+
// from the alias. Both guards now fire on the same real documents, and the
|
|
173
|
+
// pin that proves this literal carries the field through lives at
|
|
174
|
+
// seat-parity-ondisk.test.js on a real runCouncil twin bench.
|
|
175
|
+
streetCred: record.streetCred.map(s => ({ model: s.model, withSelf: s.withSelf, peersOnly: s.peersOnly,
|
|
176
|
+
...(s.seat && s.seat !== s.model ? { seat: s.seat } : {}) })),
|
|
129
177
|
runStats: record.runStats,
|
|
130
178
|
tierCounts: record.tierCounts,
|
|
131
179
|
// Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
|
package/src/headless.js
CHANGED
|
@@ -154,6 +154,58 @@ function withTimeout(promise, ms, label) {
|
|
|
154
154
|
]);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Task 6 (#129, #133): build the NO_OUTPUT_BACKSTOP reason string. Report
|
|
159
|
+
* ONLY what the mechanism observed — a deadline passed with no substantive
|
|
160
|
+
* activity (output/reasoning/tool calls) — never a cause. At the pre-send
|
|
161
|
+
* firing site (runHeadless, ~:506-518) the backstop can win the race against
|
|
162
|
+
* sendPromptAsync before the send ever resolves, so "the endpoint accepted
|
|
163
|
+
* the request" is not even something that site observed. The previous text
|
|
164
|
+
* asserted "likely a listed-but-not-serving model or a dead endpoint" — a
|
|
165
|
+
* canned guess with no evidence gate — which sent 30 minutes of #133's
|
|
166
|
+
* debugging at model ids and API keys while the real cause (an opencode
|
|
167
|
+
* engine version skew) sat in ~/.local/share/opencode/log/opencode.log the
|
|
168
|
+
* whole time.
|
|
169
|
+
*
|
|
170
|
+
* `fromEnv` distinguishes two ways `ms` was decided, NOT whether
|
|
171
|
+
* AMICUS_NO_OUTPUT_BACKSTOP_MS is relevant — it is relevant on both branches:
|
|
172
|
+
* - fromEnv=true: `ms` IS the live env-resolved value (or its documented
|
|
173
|
+
* default) — the message says so directly, "(0 disables)" included,
|
|
174
|
+
* because raising the env var changes exactly this window.
|
|
175
|
+
* - fromEnv=false: `ms` arrived as a direct, caller-set numeric option.
|
|
176
|
+
* Task 6 review (Important finding): this is NOT synonymous with "the
|
|
177
|
+
* env var doesn't apply" — `run-retry.js :: retryStage1Losses`'s
|
|
178
|
+
* `escalatedBackstopMs` computes a Stage-1 retry's window as
|
|
179
|
+
* `2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs :
|
|
180
|
+
* resolveNoOutputBackstopMs())` and forwards it on `common` as a direct
|
|
181
|
+
* `noOutputBackstopMs` — so a 600s retry-fired backstop (the 300s env
|
|
182
|
+
* default, doubled) is "caller-set" by this predicate while still being
|
|
183
|
+
* *derived from* the env default. Only src/sidecar/models-probe.js:79's hardcoded,
|
|
184
|
+
* non-tunable 30s (PROBE_WINDOW_MS; docs/usage.md:406 promises it's "not
|
|
185
|
+
* tunable") is truly independent of the env var. Because a real
|
|
186
|
+
* `fromEnv` flag distinguishing those two cases would have to ride the
|
|
187
|
+
* same value through src/sidecar/fanout.js, which is line-locked at
|
|
188
|
+
* EXACTLY 300/300 this release, the caller-set branch instead names the
|
|
189
|
+
* var as something this window *overrides* rather than either claiming
|
|
190
|
+
* it governs (false on the probe) or omitting it (false/unhelpful on the
|
|
191
|
+
* retry) — true on both, and still points a user at the remedy.
|
|
192
|
+
*
|
|
193
|
+
* Kept module-scope and pure (not a closure over runHeadless locals) so it
|
|
194
|
+
* can be asserted on directly in tests without driving the poll loop; the
|
|
195
|
+
* `noOutputBackstopReason` closure inside runHeadless just forwards to this
|
|
196
|
+
* with the per-run `noOutputBackstopMs`/`backstopFromEnv` values, so the two
|
|
197
|
+
* firing sites there stay identical to what's tested here.
|
|
198
|
+
* @param {{ms: number, fromEnv: boolean}} args
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
function formatNoOutputBackstopReason({ ms, fromEnv }) {
|
|
202
|
+
return 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
|
|
203
|
+
+ `${Math.round(ms / 1000)}s — `
|
|
204
|
+
+ (fromEnv
|
|
205
|
+
? 'the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)'
|
|
206
|
+
: 'a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default');
|
|
207
|
+
}
|
|
208
|
+
|
|
157
209
|
/**
|
|
158
210
|
* Wait for the OpenCode server to be ready using SDK health check
|
|
159
211
|
*/
|
|
@@ -473,16 +525,26 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
473
525
|
// deadline `nowMs >= deadline` can never satisfy — the backstop would
|
|
474
526
|
// silently never fire. Finite zero (the documented explicit-disable
|
|
475
527
|
// value) still takes the direct branch: Number.isFinite(0) === true.
|
|
476
|
-
|
|
477
|
-
|
|
528
|
+
// Capture the source-of-truth predicate ONCE and reuse it (do not call
|
|
529
|
+
// Number.isFinite a second time below) — `backstopFromEnv` is just its
|
|
530
|
+
// complement: options.noOutputBackstopMs is a direct, caller-set value
|
|
531
|
+
// exactly when Number.isFinite is true, so the window came from
|
|
532
|
+
// resolveNoOutputBackstopMs's env-resolution seam exactly when it's false.
|
|
533
|
+
// Reused both to pick noOutputBackstopMs and to decide whether the reason
|
|
534
|
+
// string below may name the env var, so the two can never drift apart.
|
|
535
|
+
const backstopFromEnv = !Number.isFinite(options.noOutputBackstopMs);
|
|
536
|
+
const noOutputBackstopMs = backstopFromEnv
|
|
537
|
+
? resolveNoOutputBackstopMs(options._env) : options.noOutputBackstopMs;
|
|
478
538
|
const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: Date.now() });
|
|
479
539
|
let backstopFired = false;
|
|
480
540
|
// Single source for the reason string so the pre-send firing site below and
|
|
481
541
|
// the per-poll firing site further down (still ticking the SAME instance)
|
|
482
|
-
// can never drift apart.
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
542
|
+
// can never drift apart. Forwards to the module-scope, pure
|
|
543
|
+
// formatNoOutputBackstopReason (below/exported) so tests can assert on the
|
|
544
|
+
// string shape directly without driving the whole poll loop.
|
|
545
|
+
const noOutputBackstopReason = () => formatNoOutputBackstopReason({
|
|
546
|
+
ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
|
|
547
|
+
});
|
|
486
548
|
|
|
487
549
|
// Send prompt asynchronously (returns immediately, we poll for results) —
|
|
488
550
|
// bounded by the backstop: an endpoint that accepts but never answers must
|
|
@@ -1244,6 +1306,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1244
1306
|
...settleResult,
|
|
1245
1307
|
...subtreeFlags,
|
|
1246
1308
|
...subtreeResult,
|
|
1309
|
+
// #133 P1: sessionId was assigned at :413/:417, well before this
|
|
1310
|
+
// return — guaranteed set here, same as `taskId` above.
|
|
1311
|
+
opencodeSessionId: sessionId,
|
|
1247
1312
|
error: sessionError
|
|
1248
1313
|
};
|
|
1249
1314
|
}
|
|
@@ -1259,6 +1324,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1259
1324
|
...settleResult,
|
|
1260
1325
|
...subtreeFlags,
|
|
1261
1326
|
...subtreeResult,
|
|
1327
|
+
// #133 P1: see the comment on the sibling return above — guaranteed set.
|
|
1328
|
+
opencodeSessionId: sessionId,
|
|
1262
1329
|
exitCode: 0
|
|
1263
1330
|
};
|
|
1264
1331
|
|
|
@@ -1334,6 +1401,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1334
1401
|
aborted: false,
|
|
1335
1402
|
taskId,
|
|
1336
1403
|
usage: emptyUsageTotals(),
|
|
1404
|
+
// #133 P1: measured, not assumed — `sessionId` (:365) is in scope
|
|
1405
|
+
// through this whole catch (it is already read at the `if (sessionId)`
|
|
1406
|
+
// abort-on-error above) but, unlike the two returns in the try body,
|
|
1407
|
+
// is NOT guaranteed assigned here: an exception thrown before session
|
|
1408
|
+
// creation (e.g. during the pre-session server-readiness wait) reaches
|
|
1409
|
+
// this same catch with sessionId still unset. `|| null` makes both
|
|
1410
|
+
// outcomes explicit and already schema-shaped (run.schema.json:23
|
|
1411
|
+
// wants string|null, never undefined).
|
|
1412
|
+
opencodeSessionId: sessionId || null,
|
|
1337
1413
|
error: error.message
|
|
1338
1414
|
};
|
|
1339
1415
|
}
|
|
@@ -1413,6 +1489,7 @@ module.exports = {
|
|
|
1413
1489
|
extractSummary,
|
|
1414
1490
|
findTrailingFoldMarker,
|
|
1415
1491
|
formatFoldOutput,
|
|
1492
|
+
formatNoOutputBackstopReason,
|
|
1416
1493
|
DEFAULT_TIMEOUT,
|
|
1417
1494
|
FOLD_MARKER,
|
|
1418
1495
|
COMPLETE_MARKER,
|
package/src/mcp-tools.js
CHANGED
|
@@ -390,19 +390,89 @@ function getTools() {
|
|
|
390
390
|
'returns the tally record immediately. No subprocess, no polling. Claude ' +
|
|
391
391
|
'assembles the input and may override margin tiers afterward.',
|
|
392
392
|
inputSchema: {
|
|
393
|
+
// v4.8 PR4c R4c-5: the three seat keys are declared here or zod SILENTLY
|
|
394
|
+
// STRIPS them, and `amicus_council_tally` stays permanently on the #137
|
|
395
|
+
// behaviour while `amicus council tally` (a raw JSON.parse with no schema)
|
|
396
|
+
// gets the fix. Deliberately PERMISSIVE — validate the envelope, let
|
|
397
|
+
// tally() be the single arbiter of shape on both paths, matching
|
|
398
|
+
// amicus_verdict's own `record: z.record(z.any())`. `z.array(z.record())`
|
|
399
|
+
// would reject a string seat table the CLI accepts, and a bare
|
|
400
|
+
// `.optional()` would reject the `|| null` a hand-assembling caller
|
|
401
|
+
// writes, which serializes byte-identically to omitting the key.
|
|
402
|
+
// ⚠️ ALL THREE keys carry `.nullable()`, not two of three (council C1).
|
|
403
|
+
// Bare `.optional()` on `seats` failed the WHOLE call with `seats:
|
|
404
|
+
// Expected array, received null` where `amicus council tally` — the raw
|
|
405
|
+
// JSON.parse at cli-handlers-council.js:24 — accepts it and every
|
|
406
|
+
// seat-space reader treats it as absent (`Array.isArray(null)` is
|
|
407
|
+
// false). That is the same silent fork in the same schema, one key
|
|
408
|
+
// later. `amicus_verdict` below already spells its array/record
|
|
409
|
+
// envelope keys this way (`seatLoss` :461, `degrades` :465).
|
|
393
410
|
meta: z.object({
|
|
394
411
|
runId: z.string(), runType: z.string().optional(), date: z.string().optional(),
|
|
395
412
|
models: z.array(z.string()).min(1), chair: z.string().optional(),
|
|
396
|
-
claudeInCouncil: z.boolean().optional(),
|
|
413
|
+
claudeInCouncil: z.boolean().optional(), seats: z.array(z.any()).nullable().optional(),
|
|
397
414
|
}).describe('Run metadata; meta.models lists every reviewed model.'),
|
|
398
415
|
findings: z.array(z.object({
|
|
399
416
|
id: z.string(), raiser: z.string(), severity: z.string(), claim: z.string().optional(),
|
|
417
|
+
raiserSeat: z.string().nullable().optional(),
|
|
418
|
+
// SI-23 (R10): `location` was silently stripped by this closed
|
|
419
|
+
// z.object too — the same #137-shaped fork as the seat keys above,
|
|
420
|
+
// one key later. R10 (as relayed) also named `evidence`/`file`/`line`
|
|
421
|
+
// as siblings to fix here; MEASURED against findings.js :: REQUIRED
|
|
422
|
+
// (Stage-1's required-field list), briefings.js ::
|
|
423
|
+
// FINDINGS_JSON_SHAPE (the JSON contract every reviewer is briefed
|
|
424
|
+
// on), and every `f.<key>` read under src/ — none of those three
|
|
425
|
+
// exist anywhere in this codebase's finding shape. Only `location`
|
|
426
|
+
// does: anonymize.js :: toGlobalFindings forwards it alongside
|
|
427
|
+
// `claim` into the exact "run-global findings" shape this tool
|
|
428
|
+
// already documents below, and briefings-debate.js ::
|
|
429
|
+
// findingBlockDefense / debate.js :: debateTargets read it on the
|
|
430
|
+
// engine's native debate stage — unreachable from this MCP tool
|
|
431
|
+
// today (no MCP debate step exists), so this is a document-shape /
|
|
432
|
+
// round-trip fix here, not an unlocked renderer. `rationale` is also
|
|
433
|
+
// Stage-1-required but toGlobalFindings does not forward it past
|
|
434
|
+
// Stage-1 either; adding it here would invent a shape the engine's
|
|
435
|
+
// own path never produces, so it stays out.
|
|
436
|
+
// Plain `.optional()`, matching `claim` immediately above — a
|
|
437
|
+
// same-shape string sibling, not a seat field, so the `.nullable()`
|
|
438
|
+
// seat idiom explained above `meta` doesn't apply here.
|
|
439
|
+
// tally.js :: tally's `outFindings` map was ALSO dropping
|
|
440
|
+
// `claim`/`location` from every finding regardless of what survived
|
|
441
|
+
// validation, on the CLI and MCP paths alike — declaring the field
|
|
442
|
+
// here alone would validate but never reach the document, so that
|
|
443
|
+
// map forwards both, the same emit-when-present way it already
|
|
444
|
+
// forwards `raiserSeat`. R10 shipped only `location`'s forward and
|
|
445
|
+
// left `claim`'s for later (`claim` was already declared above,
|
|
446
|
+
// pre-R10); SI-23 fix round 1 (PR #183 council, A1/B1) closed that
|
|
447
|
+
// gap in the same map, same convention.
|
|
448
|
+
location: z.string().optional(),
|
|
400
449
|
})).describe('Run-global findings (ids already A1/B2/C3-prefixed by Claude).'),
|
|
401
450
|
adjudications: z.array(z.object({
|
|
402
451
|
judge: z.string(), findingId: z.string(), verdict: z.enum(['agree', 'dispute', 'neutral']),
|
|
452
|
+
seat: z.string().nullable().optional(),
|
|
403
453
|
})).describe('One row per (judge × finding).'),
|
|
454
|
+
// v4.8 T3.2: `seat` added here to match `adjudications.seat` immediately
|
|
455
|
+
// above — the channel run-assemble.js's buildTallyInput now puts on
|
|
456
|
+
// rankings[] (the judge's own seat, emit-when-DIFFERENT) is otherwise
|
|
457
|
+
// unreachable from a hand-assembled MCP call: zod strips unknown keys
|
|
458
|
+
// by default. NOT R10's territory — R10 covers SI-23 (findings[]
|
|
459
|
+
// losing evidence/file/line) as its own document-shape PR; this is a
|
|
460
|
+
// different field on a different array, and the adjudications.seat
|
|
461
|
+
// precedent immediately above is this repo's own pattern of adding a
|
|
462
|
+
// seat field in the PR that introduces it, not deferring to a later
|
|
463
|
+
// schema PR.
|
|
464
|
+
// v4.8 T3.3 adds `orderSeats` for the same reason and by the same rule.
|
|
465
|
+
// It is the seat-valued PARALLEL of `order` (street-cred.js ::
|
|
466
|
+
// rankPositions keys on it), so without the declaration an MCP caller
|
|
467
|
+
// that sends it gets the alias collapse SI-20 exists to fix, silently.
|
|
468
|
+
// `nullable()` inside the arrays is load-bearing, not defensive:
|
|
469
|
+
// anonymize.js :: rankingToOrder emits `null` for every slot with no
|
|
470
|
+
// distinguishing seat, so a mixed bench legitimately sends nulls.
|
|
404
471
|
rankings: z.array(z.object({
|
|
405
472
|
judge: z.string(), order: z.array(z.union([z.string(), z.array(z.string())])),
|
|
473
|
+
seat: z.string().nullable().optional(),
|
|
474
|
+
orderSeats: z.array(z.union([z.string().nullable(),
|
|
475
|
+
z.array(z.string().nullable())])).nullable().optional(),
|
|
406
476
|
})).describe("Each judge's preference order over the reviews (ties = nested array)."),
|
|
407
477
|
runStats: z.array(z.record(z.any())).optional().describe('Optional per-model run stats (status/duration/usage).'),
|
|
408
478
|
project: z.string().optional().describe('Optional project directory path.'),
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* em-dash) and blind mode's labelOf(alias) lookup never matches (real model
|
|
23
23
|
* id leaks). The alias IS on disk per-leg, though: every council leg goes
|
|
24
24
|
* through src/sidecar/fanout-leg.js's runSingleAttempt, which calls
|
|
25
|
-
* `writeLegPatch(legDir, { parentWave, modelInput })` synchronously,
|
|
25
|
+
* `writeLegPatch(legDir, { parentWave, modelInput, seat })` synchronously,
|
|
26
26
|
* immediately after leg creation (fanout-leg.js:101) — well before any
|
|
27
27
|
* status poll could reasonably observe it missing. So this module reads
|
|
28
28
|
* `modelInput` straight off the leg's own metadata.json, no run.json join
|
|
@@ -127,6 +127,12 @@ function buildLegRow(project, legId, runCtx) {
|
|
|
127
127
|
const row = {
|
|
128
128
|
taskId: legId, model: meta.model || null, status: meta.status || 'unknown',
|
|
129
129
|
modelInput, role,
|
|
130
|
+
// v4.8 R5: the leg's seat id (`alias#N`), written at launch by
|
|
131
|
+
// fanout-leg.js :: runSingleAttempt. Null on a unique-alias bench, where
|
|
132
|
+
// buildSeats sets id === alias and the shared emit-when-DIFFERENT predicate
|
|
133
|
+
// therefore stamps nothing — the same rule run-stats-entry.js ::
|
|
134
|
+
// buildRunStatsEntry applies on the terminal path.
|
|
135
|
+
seat: meta.seat || null,
|
|
130
136
|
};
|
|
131
137
|
let stalledMs = null;
|
|
132
138
|
let p = null;
|
package/src/observe/live-doc.js
CHANGED
|
@@ -26,9 +26,9 @@ const { resolveUsage, sumWaveUsage } = require('../utils/pricing');
|
|
|
26
26
|
// a timed-out single session never exited (watch-render.js:138 polls until
|
|
27
27
|
// TERMINAL.has(doc.status), and amicus_status stamps metadata.status straight onto
|
|
28
28
|
// the doc — mcp-server.js:687); a timed-out leg skipped the "prefer metadata.usage
|
|
29
|
-
// over the stale progress.json snapshot" branch in council-legs.js
|
|
30
|
-
// an under-counted cost; and markLive kept stamping view:'live' on a
|
|
31
|
-
// single-session doc.
|
|
29
|
+
// over the stale progress.json snapshot" branch in council-legs.js :: buildLegRow
|
|
30
|
+
// and reported an under-counted cost; and markLive kept stamping view:'live' on a
|
|
31
|
+
// finished single-session doc.
|
|
32
32
|
// NOTE this is deliberately NOT the same list as src/utils/result-schema.js:13 TERMINAL_STATUSES
|
|
33
33
|
// (the leg set, no 'partial'). Two mirrors of THIS list exist — src/workspace/run-detail.js:26 and
|
|
34
34
|
// electron/workspace-ui/live-model.js:14 — byte-identical, held by drift pins. Edit all three.
|
package/src/sidecar/continue.js
CHANGED
|
@@ -88,7 +88,7 @@ Build on the previous sidecar's findings. The user wants to continue or extend t
|
|
|
88
88
|
|
|
89
89
|
/** Create session metadata for continuation */
|
|
90
90
|
function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
91
|
-
const { model, briefing, headless, agent, gateway, resolutionVersion } = options;
|
|
91
|
+
const { model, briefing, headless, agent, gateway, resolutionVersion, tag } = options;
|
|
92
92
|
|
|
93
93
|
const sessionDir = SessionPaths.sessionDir(project, taskId);
|
|
94
94
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
@@ -102,7 +102,11 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
|
102
102
|
agent: agent || (headless ? 'build' : 'chat'),
|
|
103
103
|
status: 'running',
|
|
104
104
|
createdAt: new Date().toISOString(),
|
|
105
|
-
continuesFrom: oldTaskId
|
|
105
|
+
continuesFrom: oldTaskId,
|
|
106
|
+
// v4.7.1 Task 7 (D13): absent-not-null, same idiom as start-metadata.js:50
|
|
107
|
+
// — a continuation inherits the parent's tag so a continue chain never
|
|
108
|
+
// scatters into `(unattributed)`.
|
|
109
|
+
...(tag ? { tag } : {}),
|
|
106
110
|
};
|
|
107
111
|
// #61 Task 5.2 (best-effort provenance): only present when THIS continue
|
|
108
112
|
// call freshly routed an explicit --model through the gateway router — the
|
|
@@ -116,26 +120,6 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
|
116
120
|
return sessionDir;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
/**
|
|
120
|
-
* Resolve a reopened session's usage, write it onto metadata, and append one
|
|
121
|
-
* attributed ledger row. Mirrors start.js's finalize (the only sites that
|
|
122
|
-
* dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
|
|
123
|
-
* @returns {{usage: object|null}}
|
|
124
|
-
*/
|
|
125
|
-
function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
|
|
126
|
-
const { resolveUsage } = require('../utils/pricing');
|
|
127
|
-
const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
|
|
128
|
-
if (usage) {
|
|
129
|
-
metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
|
|
130
|
-
try {
|
|
131
|
-
const { appendSpend } = require('../utils/spend-ledger');
|
|
132
|
-
const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
|
|
133
|
-
appendSpend({ taskId, model, mode, usage, op, status, project, gateway }, ctx);
|
|
134
|
-
} catch { /* best-effort */ }
|
|
135
|
-
}
|
|
136
|
-
return { usage };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
123
|
/**
|
|
140
124
|
* Continue from a previous sidecar session - Spec Reference: §4.4, §8.5
|
|
141
125
|
* @returns {Promise<number>} process exit code
|
|
@@ -196,6 +180,7 @@ async function continueSidecar(options) {
|
|
|
196
180
|
|
|
197
181
|
const sessionDir = createContinueSessionMetadata(newTaskId, project, {
|
|
198
182
|
model, briefing, headless, agent: effectiveAgent, gateway, resolutionVersion,
|
|
183
|
+
tag: oldMetadata.tag, // v4.7.1 Task 7: inherit the parent's tag (absent if the parent had none).
|
|
199
184
|
}, oldTaskId);
|
|
200
185
|
|
|
201
186
|
// Lock the NEW continuation session dir too — not just the previous one — so a
|
|
@@ -270,6 +255,7 @@ async function continueSidecar(options) {
|
|
|
270
255
|
// ledger row (status: statusFromResult, matching start.js — not terminal.status).
|
|
271
256
|
{
|
|
272
257
|
const { statusFromResult } = require('../utils/result-schema');
|
|
258
|
+
const { finalizeSpendForReopen } = require('./reopen-spend');
|
|
273
259
|
const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
274
260
|
const { usage } = finalizeSpendForReopen({
|
|
275
261
|
taskId: newTaskId, model, mode: headless ? 'headless' : 'interactive',
|
|
@@ -292,6 +278,5 @@ module.exports = {
|
|
|
292
278
|
loadPreviousSession,
|
|
293
279
|
buildContinuationContext,
|
|
294
280
|
createContinueSessionMetadata,
|
|
295
|
-
finalizeSpendForReopen,
|
|
296
281
|
continueSidecar
|
|
297
282
|
};
|
|
@@ -98,7 +98,7 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
98
98
|
});
|
|
99
99
|
waveDir = getSessionDir(project, waveId);
|
|
100
100
|
emitLegStarted(waveDir, waveId, legId, leg.model, leg.modelInput, follow);
|
|
101
|
-
writeLegPatch(legDir, { parentWave: waveId, modelInput: leg.modelInput });
|
|
101
|
+
writeLegPatch(legDir, { parentWave: waveId, modelInput: leg.modelInput, seat: leg.seat });
|
|
102
102
|
saveInitialContext(legDir, systemPrompt, userMessage);
|
|
103
103
|
|
|
104
104
|
// Per-leg watchdog: a BACKSTOP strictly behind runHeadless's own deadline
|
|
@@ -158,6 +158,27 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
158
158
|
reason: result.error || undefined,
|
|
159
159
|
completedAt: new Date().toISOString(),
|
|
160
160
|
usage: usage || undefined,
|
|
161
|
+
// #133 P1: `|| undefined`, matching status/reason/usage above — NOT the
|
|
162
|
+
// `toolSettleAborted` carve-out below, because a session id has no
|
|
163
|
+
// meaningful-falsy value the way `false` does there. A clean/failed
|
|
164
|
+
// attempt (no session ever created) therefore carries neither key on
|
|
165
|
+
// disk, rather than writing an explicit `null` that could clobber a
|
|
166
|
+
// PRIOR attempt's real session id: fallback substitution reruns
|
|
167
|
+
// runSingleAttempt against the SAME legId/legDir (fanout-leg-fallback.js
|
|
168
|
+
// "SAME leg dir under the SAME legId"), and writeLegPatch's read-merge-
|
|
169
|
+
// write only drops keys that are `undefined`, not ones explicitly set to
|
|
170
|
+
// `null`. This does not disagree with result-schema.js:72's
|
|
171
|
+
// `metadata.opencodeSessionId || null` — that coercion runs downstream,
|
|
172
|
+
// on the per-request OUTPUT run document, where run.schema.json requires
|
|
173
|
+
// the field always present as string|null. The two layers do different
|
|
174
|
+
// jobs (omit-if-absent on disk here vs. always-present in the emitted
|
|
175
|
+
// doc there) and compose without conflict.
|
|
176
|
+
//
|
|
177
|
+
// Raised again in the PR #185 council round (findings A3/D2) as a
|
|
178
|
+
// possible conflation of "no session" with "empty session id"; kept
|
|
179
|
+
// as-is per the reasoning above — this is `legPatch`'s existing
|
|
180
|
+
// convention (matching status/reason/usage), not a gap opened by #133 P1.
|
|
181
|
+
opencodeSessionId: result.opencodeSessionId || undefined,
|
|
161
182
|
// v4.4 B4 part 1: the leg completed with tool calls still live, so its
|
|
162
183
|
// OpenCode session may have kept working (and billing) afterwards. Travels
|
|
163
184
|
// with the leg so it is readable long after the run's stderr is gone.
|