amicus 4.6.1 → 4.6.3
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 +152 -0
- package/README.md +7 -8
- package/docs/ROADMAP.md +38 -4
- package/docs/configuration.md +18 -12
- package/docs/council.md +7 -2
- package/docs/troubleshooting.md +49 -20
- package/docs/usage.md +30 -3
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +146 -2
- package/electron/workspace-ui/workspace-app.js +8 -3
- package/electron/workspace-ui/workspace-panels.js +9 -10
- package/electron/workspace-ui/workspace-render.js +9 -3
- package/electron/workspace-ui/workspace-seats.js +132 -0
- package/electron/workspace-ui/workspace-verbs.js +2 -1
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +1 -1
- package/schemas/alias-audit.schema.json +6 -1
- package/schemas/council-run.schema.json +14 -0
- package/src/cli-handlers-council.js +9 -0
- package/src/cli-handlers-doctor.js +25 -7
- package/src/cli.js +4 -0
- package/src/council/presets-cli.js +6 -2
- package/src/council/run-chair.js +55 -6
- package/src/headless.js +119 -9
- package/src/mcp-council-awareness.js +1 -0
- package/src/opencode-client.js +21 -0
- package/src/session-manager.js +6 -2
- package/src/sidecar/fanout-leg.js +2 -2
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/models-probe.js +119 -0
- package/src/sidecar/models.js +81 -6
- package/src/utils/alias-audit.js +71 -1
- package/src/utils/base-url-classify.js +74 -0
- package/src/utils/council-presets.js +6 -2
- package/src/utils/curated-models.js +71 -16
- package/src/utils/doctor-base-url-check.js +41 -0
- package/src/utils/gateway-route-audit.js +16 -3
- package/src/utils/model-fetcher.js +9 -6
- package/src/utils/model-tiers.js +28 -7
- package/src/utils/no-output-backstop.js +48 -0
- package/src/utils/remediation-hints.js +14 -0
- package/src/utils/result-schema.js +29 -2
- package/src/utils/session-metadata-tmp-sweep.js +136 -0
- package/src/workspace/live-normalize.js +1 -0
|
@@ -102,6 +102,9 @@
|
|
|
102
102
|
<script src="./live-model.js"></script>
|
|
103
103
|
<script src="./workspace-render.js"></script>
|
|
104
104
|
<script src="./workspace-matrix.js"></script>
|
|
105
|
+
<!-- ⚠️ D8 extraction (Task 1, v4.6.2 PR4): workspace-seats.js must load BEFORE
|
|
106
|
+
workspace-panels.js, whose renderSeatsPanel delegates to it. -->
|
|
107
|
+
<script src="./workspace-seats.js"></script>
|
|
105
108
|
<!-- ⚠️ DE-ROT (F05): panels + verbs are the Task 13 split of workspace-app.js.
|
|
106
109
|
Load order matters: workspace-app.js boots last and owns `state`. -->
|
|
107
110
|
<script src="./workspace-panels.js"></script>
|
|
@@ -37,9 +37,17 @@
|
|
|
37
37
|
return (state.visible && state.focused) ? 1500 : 5000;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** True when `status` is a terminal run status. The single consumption
|
|
41
|
+
* point for TERMINAL_STATUSES membership (v4.6.3 PR2 dedup) — the array
|
|
42
|
+
* itself stays exported and byte-identical to src/workspace/run-detail.js
|
|
43
|
+
* (drift-pinned). */
|
|
44
|
+
function isTerminal(status) {
|
|
45
|
+
return TERMINAL_STATUSES.indexOf(status) !== -1;
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
/** Blind default per spec resolved Q2: ON live, OFF terminal. */
|
|
41
49
|
function defaultBlind(status) {
|
|
42
|
-
return
|
|
50
|
+
return !isTerminal(status);
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
function dash(v) {
|
|
@@ -104,9 +112,145 @@
|
|
|
104
112
|
});
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
/**
|
|
116
|
+
* D6 (v4.6.2 PR4 Task 2, "dead-seat rows"): announced-dead seats, unioned
|
|
117
|
+
* from the run's own `degrades[]` (dead-leg/dead-wave channels — the live
|
|
118
|
+
* announcement; shapes verified against src/council/run-retry-notes.js and
|
|
119
|
+
* the skipped-path notes at src/council/run-stages.js:155-174) and the
|
|
120
|
+
* verdict's derived `seatLoss` (verdict.js summarizeSeatLoss/deriveSeatLoss
|
|
121
|
+
* — the critic-loss backstop, kept for verdicts written before `degrades[]`
|
|
122
|
+
* existed, v4.5.2 precedent). De-duped by seat model: `degrades` is scanned
|
|
123
|
+
* FIRST, so a real record's retry marker always beats the backstop's
|
|
124
|
+
* no-data guess for the same seat.
|
|
125
|
+
*
|
|
126
|
+
* `retried` reads `data.retryWaveId` / `data.firstFailure`: every
|
|
127
|
+
* still-dead-after-retry note (run-retry-notes.js's four builders) carries
|
|
128
|
+
* `retryWaveId`; the two run-stages.js skipped-path notes (fired when the
|
|
129
|
+
* once-only retry pass never even attempted the seat — an unmappable lens
|
|
130
|
+
* loss, an out-of-range index, or a zero-model unit) carry neither, so they
|
|
131
|
+
* correctly fall back to the plain phrasing.
|
|
132
|
+
*
|
|
133
|
+
* D6 filter (zero usable legs ONLY, "no ghost when a retry succeeded"): a
|
|
134
|
+
* candidate already present in `liveSeats` (it has a cost row — SL-2 healed
|
|
135
|
+
* it) is dropped. This is the one thing standing between a recovered seat
|
|
136
|
+
* and a duplicate/ghost row — same failure family as the F37 debate-role
|
|
137
|
+
* collision and the RN-11 keyed-row lessons just above (seatsFromRunStats,
|
|
138
|
+
* seatCells): an identity that is not carefully matched silently
|
|
139
|
+
* duplicates or overwrites instead of failing loud.
|
|
140
|
+
*
|
|
141
|
+
* Role-aware D6 (v4.6.3 PR2, spec D3): a bare model match used to suppress
|
|
142
|
+
* regardless of what the LIVE row's role was — so a model that died as
|
|
143
|
+
* critic but whose chair-fallback walk happened to land on that same alias
|
|
144
|
+
* (and succeeded, producing a live `role: 'chair'` cost row) silently
|
|
145
|
+
* erased the dead-critic row it was never a replacement for (spec §5, the
|
|
146
|
+
* PR 102 rider). Candidates now carry a `role` (`'critic'` via alias equality
|
|
147
|
+
* with `runMeta.critic` — mirroring `deriveSeatLoss`, verdict.js:72 — or
|
|
148
|
+
* `null`), and only REVIEWING-role live legs (`seat`/`critic`/`lens:*`)
|
|
149
|
+
* suppress at all; a `'critic'` candidate is cleared only by a live
|
|
150
|
+
* CRITIC-role leg for that alias, never by a chair/judge/rebuttal/revote
|
|
151
|
+
* row landing on the same model. Hidden dependency: the recovered-critic
|
|
152
|
+
* suppression below (`byRole[alias + '|critic']`) relies on `roleFor`'s
|
|
153
|
+
* critic branch (src/council/run-stages.js), which only fires when lenses
|
|
154
|
+
* are absent — safe today only because --critic and --lenses are mutually
|
|
155
|
+
* exclusive (cli-handlers-council-run.js:196); if that exclusion ever
|
|
156
|
+
* loosens, a healed critic on a lens run would carry role 'seat' and this
|
|
157
|
+
* suppression would render a ghost dead row for it.
|
|
158
|
+
*
|
|
159
|
+
* Old-run resilience (v4.6.3 PR2, spec D4): pre-`degrades[]` runs (v4.5.2)
|
|
160
|
+
* carry the BENCH half of a seat loss only in `seatLoss.deadBenchSeats`
|
|
161
|
+
* (string[] of aliases, verdict.js deriveSeatLoss) — `degrades[]` never
|
|
162
|
+
* existed on either doc for these runs. Consumed after the critic backstop,
|
|
163
|
+
* candidates get `role: null` (deadBenchSeats carries no critic/bench
|
|
164
|
+
* distinction beyond what `criticRequested` already covers above) and flow
|
|
165
|
+
* through the same `seen`-keyed dedup and role-aware suppression as every
|
|
166
|
+
* other candidate — deriveSeatLoss does not dedup its own array, so `seen`
|
|
167
|
+
* is what keeps a repeated alias (or one also named by a real degrade
|
|
168
|
+
* record) from rendering twice.
|
|
169
|
+
*
|
|
170
|
+
* @param {Array<object>} degrades run.json's `degrades[]` (may be absent)
|
|
171
|
+
* @param {?object} seatLoss verdict.json's `seatLoss` (may be absent) —
|
|
172
|
+
* `criticRequested`/`criticSeated` back the critic candidate above,
|
|
173
|
+
* `deadBenchSeats` (string[] of aliases) feeds the bench candidates below
|
|
174
|
+
* @param {Array<{model: string}>} liveSeats seatsFromRunStats(...)'s output
|
|
175
|
+
* (or any seat list keyed the same way — the live seat map)
|
|
176
|
+
* @param {?{critic: ?string}} runMeta run.critic (alias, or null/absent
|
|
177
|
+
* when no critic was requested) — degrade records carry no role field, so
|
|
178
|
+
* this is the ONLY way a degrade-sourced candidate is identified as critic
|
|
179
|
+
* @returns {Array<{model: string, statusText: string, role: ?string}>}
|
|
180
|
+
*/
|
|
181
|
+
function deadSeats(degrades, seatLoss, liveSeats, runMeta) {
|
|
182
|
+
var critic = runMeta && runMeta.critic ? runMeta.critic : null;
|
|
183
|
+
var seen = {};
|
|
184
|
+
var order = [];
|
|
185
|
+
function add(model, retried, role) {
|
|
186
|
+
if (!model || seen[model]) { return; }
|
|
187
|
+
seen[model] = true;
|
|
188
|
+
order.push({
|
|
189
|
+
model: model,
|
|
190
|
+
role: role || null,
|
|
191
|
+
statusText: retried ? 'did not review — retried once' : 'did not review',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
(degrades || []).forEach(function (d) {
|
|
195
|
+
if (!d || d.kind !== 'degrade') { return; }
|
|
196
|
+
if (d.channel !== 'dead-leg' && d.channel !== 'dead-wave') { return; }
|
|
197
|
+
var data = d.data || {};
|
|
198
|
+
var retried = !!(data.retryWaveId || data.firstFailure);
|
|
199
|
+
// Critic identification mirrors deriveSeatLoss (verdict.js): alias
|
|
200
|
+
// equality with run.critic — degrade records carry no role field.
|
|
201
|
+
if (d.channel === 'dead-leg') {
|
|
202
|
+
add(data.seat, retried, critic && data.seat === critic ? 'critic' : null);
|
|
203
|
+
} else {
|
|
204
|
+
(data.models || []).forEach(function (m) {
|
|
205
|
+
add(m, retried, critic && m === critic ? 'critic' : null);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
if (seatLoss && seatLoss.criticRequested && !seatLoss.criticSeated) {
|
|
210
|
+
add(seatLoss.criticRequested, false, 'critic');
|
|
211
|
+
}
|
|
212
|
+
if (seatLoss) {
|
|
213
|
+
// Pre-degrades[] era (v4.5.2): the bench half of a seat loss lives
|
|
214
|
+
// only here. Alias strings; deriveSeatLoss does not dedup — `seen`
|
|
215
|
+
// absorbs repeats and degrade-sourced duplicates.
|
|
216
|
+
(seatLoss.deadBenchSeats || []).forEach(function (m) { add(m, false, null); });
|
|
217
|
+
}
|
|
218
|
+
// Role-aware D6 (v4.6.3 PR2): only REVIEWING-role live legs suppress —
|
|
219
|
+
// a chair/judge/rebuttal/revote row must not hide a dead reviewer, and
|
|
220
|
+
// a dead-critic candidate is cleared only by a live CRITIC leg. A null
|
|
221
|
+
// role is NOT reviewing: counting it would suppress silently, the exact
|
|
222
|
+
// class the announcement invariant forbids.
|
|
223
|
+
function isReviewing(role) {
|
|
224
|
+
return role === 'seat' || role === 'critic' ||
|
|
225
|
+
(typeof role === 'string' && role.indexOf('lens:') === 0);
|
|
226
|
+
}
|
|
227
|
+
var reviewing = {};
|
|
228
|
+
var byRole = {};
|
|
229
|
+
// ⚠️ Fable review (PR4b fix wave): same F34/F36 alias-selection seatCells already uses
|
|
230
|
+
// (`seat.modelInput || seat.model`, above) — a LIVE payload seat's `model` is the RESOLVED
|
|
231
|
+
// executable id, not the alias a degrade record names; `modelInput` carries the alias.
|
|
232
|
+
// Keying this map on `s.model` alone meant a dead-leg seat whose errored roster row was
|
|
233
|
+
// still in the active stage's `liveSeats` never matched its alias-keyed degrade candidate,
|
|
234
|
+
// so D6 failed to suppress it — both rows rendered until the stage boundary dropped the
|
|
235
|
+
// errored row. Terminal-path cost rows (seatsFromRunStats) carry no `modelInput` at all and
|
|
236
|
+
// are already alias-only, so `|| s.model` leaves that path unchanged.
|
|
237
|
+
(liveSeats || []).forEach(function (s) {
|
|
238
|
+
if (!isReviewing(s.role)) { return; }
|
|
239
|
+
var alias = s.modelInput || s.model; // F36: alias space, never resolved ids
|
|
240
|
+
reviewing[alias] = true;
|
|
241
|
+
byRole[alias + '|' + s.role] = true;
|
|
242
|
+
});
|
|
243
|
+
return order.filter(function (s) {
|
|
244
|
+
if (s.role === 'critic') { return !byRole[s.model + '|critic']; }
|
|
245
|
+
return !reviewing[s.model];
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
107
249
|
// ⚠️ DE-ROT (F41): STAGE_LABELS is exported so applyLive() can label post-open stages.
|
|
108
250
|
var api = { pollDelay: pollDelay, seatCells: seatCells, seatsFromRunStats: seatsFromRunStats,
|
|
109
|
-
|
|
251
|
+
deadSeats: deadSeats,
|
|
252
|
+
defaultBlind: defaultBlind, isTerminal: isTerminal, dash: dash,
|
|
253
|
+
TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
|
|
110
254
|
if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
|
|
111
255
|
if (typeof window !== 'undefined') { window.AmicusLive = api; }
|
|
112
256
|
})();
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* ⚠️ DE-ROT (F05) / PRE-FLIGHT (P2): this file is the last of the three-way
|
|
7
7
|
* split (workspace-app.js / workspace-panels.js / workspace-verbs.js). It
|
|
8
8
|
* loads LAST (md-lite → live-model → workspace-render → workspace-matrix →
|
|
9
|
-
* workspace-panels → workspace-verbs → workspace-app), so
|
|
10
|
-
* already exist and may be captured once, here, at load time.
|
|
9
|
+
* workspace-seats → workspace-panels → workspace-verbs → workspace-app), so
|
|
10
|
+
* panels/verbs already exist and may be captured once, here, at load time.
|
|
11
11
|
*/
|
|
12
12
|
(function () {
|
|
13
13
|
'use strict';
|
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
state.runId = runId;
|
|
68
68
|
state.debate = null;
|
|
69
69
|
return invoke('workspace:get-run', runId).then(function (detail) {
|
|
70
|
+
// F09 guard (v4.6.3 PR2): a reply for a run the user has since
|
|
71
|
+
// navigated away from must never overwrite the run now open. Guard on
|
|
72
|
+
// runId movement ONLY — a same-run re-open (the live loop's terminal
|
|
73
|
+
// refresh) must still apply its fresher reply.
|
|
74
|
+
if (state.runId !== runId) { return; }
|
|
70
75
|
state.detail = detail;
|
|
71
76
|
// ⚠️ DE-ROT (F38): debate.json is the re-vote index the matrix drill-in needs — fetched
|
|
72
77
|
// once per run-open (never per render), fire-and-forget. An aborted or cost-ceiling
|
|
@@ -146,7 +151,7 @@
|
|
|
146
151
|
P.renderVerdictPanel();
|
|
147
152
|
R.renderCost($('cost-body'), d.derived.cost, state.blind, labelOf);
|
|
148
153
|
P.wireLazyPanels();
|
|
149
|
-
var isTerminal = window.AmicusLive.
|
|
154
|
+
var isTerminal = window.AmicusLive.isTerminal(d.run.status);
|
|
150
155
|
$('abort-btn').hidden = isTerminal;
|
|
151
156
|
V.startLiveLoop();
|
|
152
157
|
}
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* (v4.4 §5, ⚠️ DE-ROT F05 split of workspace-app.js).
|
|
4
4
|
*
|
|
5
5
|
* Loads BEFORE workspace-app.js (md-lite → live-model → workspace-render →
|
|
6
|
-
* workspace-matrix → workspace-
|
|
7
|
-
* every function here reads `window.AmicusApp` /
|
|
8
|
-
* time (never captured at this file's own load
|
|
9
|
-
* exists yet when this IIFE runs). window.AmicusApp
|
|
10
|
-
* at the top of its own boot, before calling into this
|
|
11
|
-
* any function below actually executes, both are present.
|
|
6
|
+
* workspace-matrix → workspace-seats → workspace-panels → workspace-verbs →
|
|
7
|
+
* workspace-app), so every function here reads `window.AmicusApp` /
|
|
8
|
+
* `window.AmicusVerbs` at CALL time (never captured at this file's own load
|
|
9
|
+
* time — neither namespace exists yet when this IIFE runs). window.AmicusApp
|
|
10
|
+
* publishes its namespace at the top of its own boot, before calling into this
|
|
11
|
+
* file, so by the time any function below actually executes, both are present.
|
|
12
12
|
*/
|
|
13
13
|
(function () {
|
|
14
14
|
'use strict';
|
|
@@ -37,11 +37,10 @@
|
|
|
37
37
|
return kind + '-' + sanitizeName(model) + '.md';
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// ⚠️ D8 extraction (Task 1): body moved verbatim to workspace-seats.js
|
|
41
|
+
// (window.AmicusSeats), which loads immediately before this file.
|
|
40
42
|
function renderSeatsPanel() {
|
|
41
|
-
|
|
42
|
-
var d = A.state.detail;
|
|
43
|
-
var seats = window.AmicusLive.seatsFromRunStats(d.derived.cost.rows);
|
|
44
|
-
window.AmicusRender.renderSeats(A.$('seats-body'), seats, A.state.blind, A.labelOf);
|
|
43
|
+
window.AmicusSeats.renderSeatsPanel();
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
function renderMatrixPanel() {
|
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
return el('span', { className: 'chip ' + (kind || ''), title: text }, [text]);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** td className for seat-table cell index i — one source for the three
|
|
40
|
+
* call sites (renderSeats create + update, renderDeadSeatRows). */
|
|
41
|
+
function seatCellClass(i) {
|
|
42
|
+
return i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '');
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
/** Dual-name display flip (blind mode). pair = {model, label}. */
|
|
40
46
|
function display(pair, blind) {
|
|
41
47
|
if (!pair) { return '—'; }
|
|
@@ -197,7 +203,7 @@
|
|
|
197
203
|
var cells = window.AmicusLive.seatCells(view, blindOn, labelOf);
|
|
198
204
|
if (!row) {
|
|
199
205
|
row = el('tr', { dataset: { key: key } }, cells.map(function (c, i) {
|
|
200
|
-
return el('td', { className:
|
|
206
|
+
return el('td', { className: seatCellClass(i) }, [c]);
|
|
201
207
|
}));
|
|
202
208
|
tbody.appendChild(row);
|
|
203
209
|
return;
|
|
@@ -205,7 +211,7 @@
|
|
|
205
211
|
cells.forEach(function (c, i) {
|
|
206
212
|
var td = row.children[i];
|
|
207
213
|
if (td && td.textContent !== c) { td.textContent = c; }
|
|
208
|
-
if (td) { td.className =
|
|
214
|
+
if (td) { td.className = seatCellClass(i); }
|
|
209
215
|
});
|
|
210
216
|
});
|
|
211
217
|
// RN-11 (v4.5): the keyed update added and removed rows but never MOVED
|
|
@@ -282,6 +288,6 @@
|
|
|
282
288
|
el: el, chip: chip, display: display, relTime: relTime, renderRunList: renderRunList,
|
|
283
289
|
renderHeaderChips: renderHeaderChips, renderGauge: renderGauge, renderStageRail: renderStageRail,
|
|
284
290
|
renderSeats: renderSeats, renderBanner: renderBanner, renderCost: renderCost,
|
|
285
|
-
renderProseSections: renderProseSections,
|
|
291
|
+
renderProseSections: renderProseSections, seatCellClass: seatCellClass,
|
|
286
292
|
};
|
|
287
293
|
})();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — seats panel painter (v4.4 §5). D8 extraction (Task 1,
|
|
3
|
+
* v4.6.2 PR4): moved verbatim out of workspace-panels.js, which was pressed
|
|
4
|
+
* up against the 300-line size gate — this file is where Task 2 adds
|
|
5
|
+
* dead-seat rows. Loads immediately before workspace-panels.js (index.html),
|
|
6
|
+
* which keeps a thin delegate; reads `window.AmicusApp` at CALL time, same
|
|
7
|
+
* discipline as every sibling renderer file (workspace-app.js boots last and
|
|
8
|
+
* owns `state`).
|
|
9
|
+
*
|
|
10
|
+
* Task 2 ("dead-seat rows"): `state.detail.run` and `state.detail.verdict`
|
|
11
|
+
* are the raw run.json/verdict.json docs (src/workspace/run-detail.js —
|
|
12
|
+
* `getRunDetail` returns them wholesale, unfiltered), so `run.degrades` and
|
|
13
|
+
* `verdict.seatLoss` are already on `state.detail` today; no data-layer
|
|
14
|
+
* threading was needed. Only the derivation (window.AmicusLive.deadSeats,
|
|
15
|
+
* live-model.js) and this file's painting are new.
|
|
16
|
+
*
|
|
17
|
+
* NOTE (scope, matches the plan's file list): renderSeatsPanel() (below) is reached from
|
|
18
|
+
* renderDetail() — called both from openRun() (a fresh run open) and from the blind toggle
|
|
19
|
+
* (workspace-app.js:197-227), which must repaint dead rows too so the mask flip reaches
|
|
20
|
+
* them. A dead-leg/dead-wave degrade is checkpointed to run.json as soon as Stage 1's
|
|
21
|
+
* once-only retry pass resolves for that seat — which can be well before the rest of the run
|
|
22
|
+
* reaches a terminal status, so a seat CAN be "announced dead" in the data while the run is
|
|
23
|
+
* still live-polling.
|
|
24
|
+
*
|
|
25
|
+
* HISTORY: dead rows first shipped gated on terminal status (Task 2's fix wave, task
|
|
26
|
+
* review, controller ruling) — appended ONLY when window.AmicusLive.TERMINAL_STATUSES
|
|
27
|
+
* matched d.run.status, the same predicate startLiveLoop() uses at workspace-verbs.js:69 to
|
|
28
|
+
* decide whether a run is even worth polling. Reason at the time: renderDetail()'s
|
|
29
|
+
* unconditional V.startLiveLoop() call (workspace-app.js:151) schedules a
|
|
30
|
+
* setTimeout(tick, 0) on any still-running run; that tick's first resolution repainted
|
|
31
|
+
* #seats-body via applyLive's direct renderSeats() call (workspace-verbs.js:130), whose own
|
|
32
|
+
* leaver-removal (workspace-render.js:220-222) immediately deleted the `dead:`-keyed row
|
|
33
|
+
* renderSeatsPanel had just appended — a one-frame flash-then-vanish that read as a glitch,
|
|
34
|
+
* not a feature — so gating on terminal simply hid dead rows until no further tick could
|
|
35
|
+
* un-paint them. PR4b (Christian's mid-poll ruling on PR 102) replaced that gate with tick
|
|
36
|
+
* re-append: applyLive() now calls appendDeadRows() (below) immediately after every
|
|
37
|
+
* renderSeats() repaint (workspace-verbs.js:130-131), restoring the row the SAME tick that
|
|
38
|
+
* just wiped it instead of leaving it hidden. renderSeatsPanel() below no longer checks
|
|
39
|
+
* TERMINAL_STATUSES at all — dead rows paint unconditionally, on a live run or a done one.
|
|
40
|
+
*/
|
|
41
|
+
(function () {
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
function renderSeatsPanel() {
|
|
45
|
+
var A = window.AmicusApp;
|
|
46
|
+
var d = A.state.detail;
|
|
47
|
+
var seats = window.AmicusLive.seatsFromRunStats(d.derived.cost.rows);
|
|
48
|
+
var tbody = A.$('seats-body');
|
|
49
|
+
window.AmicusRender.renderSeats(tbody, seats, A.state.blind, A.labelOf);
|
|
50
|
+
var seatLoss = d.verdict && d.verdict.seatLoss;
|
|
51
|
+
var runMeta = { critic: (d.run && d.run.critic) || null };
|
|
52
|
+
// Source-selection (v4.6.3 PR2, spec D4): run-degrade.js swallows checkpoint failures, so
|
|
53
|
+
// verdict.json can carry degrade records run.json's own checkpoint lost — fall back to it
|
|
54
|
+
// ONLY when run.degrades is empty/absent. A fallback, never a union: both docs can carry
|
|
55
|
+
// records for the SAME run, and the persisted run.json copy is authoritative when present.
|
|
56
|
+
var deg = (d.run && d.run.degrades && d.run.degrades.length) ? d.run.degrades
|
|
57
|
+
: ((d.verdict && d.verdict.degrades) || []);
|
|
58
|
+
var dead = window.AmicusLive.deadSeats(deg, seatLoss, seats, runMeta);
|
|
59
|
+
renderDeadSeatRows(tbody, dead, A.state.blind, A.labelOf);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Paints the dead-seat rows appended after live rows. Deliberately NOT
|
|
64
|
+
* folded into workspace-render.js's renderSeats (293/300 — must not grow)
|
|
65
|
+
* and NOT run through its keyed diff: dead rows carry no per-tick-changing
|
|
66
|
+
* field, so a full rebuild every call is correct and cheap, and renderSeats
|
|
67
|
+
* just above already self-cleans any PRIOR dead row as an unrecognized
|
|
68
|
+
* `data-key` (its own seen-set only knows about the live `seats` it was
|
|
69
|
+
* just given), so nothing here needs to track dead rows across calls.
|
|
70
|
+
*
|
|
71
|
+
* Cells route through window.AmicusLive.seatCells(...) — the SAME function
|
|
72
|
+
* live rows use — so name masking (and every other column's blank/em-dash
|
|
73
|
+
* convention) matches exactly, not a reimplementation. Two overrides after
|
|
74
|
+
* the call: index 0 (name, blind-ON-and-unlabeled dead seats only — see the
|
|
75
|
+
* comment at that line) and index 6 (cost). seatCells would dash() a
|
|
76
|
+
* missing costDisplay to '—', indistinguishable from a seat that ran but
|
|
77
|
+
* whose cost is merely unmeasured (see cost-unknown-display.test.js) — a
|
|
78
|
+
* dead seat has no cost concept at all, so that cell renders empty instead
|
|
79
|
+
* (D6: "no cost cell").
|
|
80
|
+
*/
|
|
81
|
+
function renderDeadSeatRows(tbody, dead, blindOn, labelOf) {
|
|
82
|
+
(dead || []).forEach(function (seat) {
|
|
83
|
+
var cells = window.AmicusLive.seatCells(
|
|
84
|
+
{ model: seat.model, role: seat.role, status: seat.statusText, stalled: false }, blindOn, labelOf);
|
|
85
|
+
// Fix wave 2 (smoke-caught, GUI smoke on real degraded run 12c96b6b): dead seats never
|
|
86
|
+
// produce a review, so state.labelByModel (built from the run's names derivation — models
|
|
87
|
+
// that DID review) never carries them; seatCells' own `blindOn && label ? label : alias`
|
|
88
|
+
// fallback is LOAD-BEARING for LIVE rows (RN-9/F36, live-model.js) and stays untouched, but
|
|
89
|
+
// for a dead seat that fallback leaks the raw model name under blind — precisely the seat
|
|
90
|
+
// blind mode most needs to hide. Placeholder ONLY when blind is on AND no label resolved;
|
|
91
|
+
// a label that DOES resolve (possible in principle) still wins via seatCells' own cell.
|
|
92
|
+
if (blindOn && !(labelOf && labelOf(seat.model))) { cells[0] = '(masked)'; }
|
|
93
|
+
cells[6] = '';
|
|
94
|
+
var row = window.AmicusRender.el('tr',
|
|
95
|
+
{ className: 'seat-dead', dataset: { key: 'dead:' + seat.model } },
|
|
96
|
+
cells.map(function (c, i) {
|
|
97
|
+
return window.AmicusRender.el('td',
|
|
98
|
+
{ className: window.AmicusRender.seatCellClass(i) }, [c]);
|
|
99
|
+
}));
|
|
100
|
+
tbody.appendChild(row);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Live-tick twin of renderSeatsPanel's dead block (PR4b, Christian's mid-poll
|
|
106
|
+
* ruling on PR 102): applyLive's renderSeats repaint wipes dead:-keyed rows
|
|
107
|
+
* (leaver-removal), so every tick re-appends from the tick's own payload.
|
|
108
|
+
* seatLoss comes from state.detail (absent mid-run — the critic's own
|
|
109
|
+
* dead-leg degrade covers it live; the terminal refresh unions the rest).
|
|
110
|
+
*/
|
|
111
|
+
function appendDeadRows(live) {
|
|
112
|
+
var A = window.AmicusApp;
|
|
113
|
+
var d = A.state.detail;
|
|
114
|
+
var seatLoss = d && d.verdict ? d.verdict.seatLoss : null;
|
|
115
|
+
var runMeta = { critic: (d && d.run && d.run.critic) || null };
|
|
116
|
+
// Source-selection (v4.6.3 PR2, spec D4), live-path twin of renderSeatsPanel's fallback
|
|
117
|
+
// above: the tick's own live.degrades wins when non-empty; state.detail.verdict.degrades is
|
|
118
|
+
// usually absent mid-run (verdict.json doesn't exist until the run finishes) — fine, this
|
|
119
|
+
// branch only matters for the rare same-run reopen where a prior terminal fetch already
|
|
120
|
+
// populated state.detail.verdict.
|
|
121
|
+
var deg = (live.degrades && live.degrades.length) ? live.degrades
|
|
122
|
+
: ((d && d.verdict && d.verdict.degrades) || []);
|
|
123
|
+
var dead = window.AmicusLive.deadSeats(deg, seatLoss, live.seats || [], runMeta);
|
|
124
|
+
renderDeadSeatRows(A.$('seats-body'), dead, A.state.blind, A.labelOf);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
window.AmicusSeats = {
|
|
128
|
+
renderSeatsPanel: renderSeatsPanel,
|
|
129
|
+
renderDeadSeatRows: renderDeadSeatRows,
|
|
130
|
+
appendDeadRows: appendDeadRows,
|
|
131
|
+
};
|
|
132
|
+
})();
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
var A = window.AmicusApp;
|
|
67
67
|
stopLiveLoop();
|
|
68
68
|
var d = A.state.detail;
|
|
69
|
-
if (!d || !d.run || window.AmicusLive.
|
|
69
|
+
if (!d || !d.run || window.AmicusLive.isTerminal(d.run.status)) { return; }
|
|
70
70
|
var epoch = A.state.liveEpoch; // F42: stopLiveLoop() above just bumped it; this chain owns it
|
|
71
71
|
var tick = function () {
|
|
72
72
|
// F42: pin the id PER TICK and SEND the pinned id — matching the reply against
|
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
// `live.ok` is true, so this is simply "always paint," empty roster included.
|
|
129
129
|
if (live.seats) {
|
|
130
130
|
R.renderSeats(A.$('seats-body'), live.seats, A.state.blind, A.labelOf);
|
|
131
|
+
window.AmicusSeats.appendDeadRows(live);
|
|
131
132
|
}
|
|
132
133
|
// F42: state.detail can be swapped/absent under a tick — never deref .derived unguarded.
|
|
133
134
|
var derived = A.state.detail && A.state.detail.derived ? A.state.detail.derived : null;
|
|
@@ -112,6 +112,12 @@ mark { background: var(--gold-soft); color: var(--gold-400); border-radius: var(
|
|
|
112
112
|
.table td { border-bottom: var(--bd); padding: var(--space-3) var(--space-4); }
|
|
113
113
|
.table td.num, .table th.num { text-align: right; font-family: var(--font-mono); }
|
|
114
114
|
.stalled-flag { color: var(--warn); }
|
|
115
|
+
/* Task 2 (v4.6.2 PR4, "dead-seat rows"): an announced-dead seat's row —
|
|
116
|
+
muted like every other "nothing to see here" surface (.empty-note,
|
|
117
|
+
.chip.aborted, .stage.skipped, .rail-project all key off --text-3), never
|
|
118
|
+
the warn/danger colors those two-line notes use — a dead seat is a fact
|
|
119
|
+
already fully explained in the banner/verdict, not a NEW alarm here. */
|
|
120
|
+
tr.seat-dead td { color: var(--text-3); }
|
|
115
121
|
|
|
116
122
|
/* ---- matrix tier rows (report-html light-ground pairs, as token vars) -- */
|
|
117
123
|
.matrix-wrap { overflow-x: auto; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.6.
|
|
3
|
+
"version": "4.6.3",
|
|
4
4
|
"mcpName": "io.github.BourbonDog/amicus",
|
|
5
5
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
6
6
|
"keywords": [
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"staleCount": { "type": "number" },
|
|
13
13
|
"stale": { "type": "array", "items": { "type": "object" } },
|
|
14
14
|
"gatewayFindingsCount": { "type": "number" },
|
|
15
|
-
"gatewayFindings": { "type": "array", "items": { "type": "object" } }
|
|
15
|
+
"gatewayFindings": { "type": "array", "items": { "type": "object" } },
|
|
16
|
+
"driftedCount": { "type": "number" },
|
|
17
|
+
"drifted": { "type": "array", "items": { "type": "object" } },
|
|
18
|
+
"probeCount": { "type": "number" },
|
|
19
|
+
"probe": { "type": "array", "items": { "type": "object" } },
|
|
20
|
+
"probeSkipped": { "type": ["string", "null"] }
|
|
16
21
|
}
|
|
17
22
|
}
|
|
@@ -102,6 +102,20 @@
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
},
|
|
105
|
+
"chairAttempts": {
|
|
106
|
+
"description": "v4.6.2 PR5 (LC-5): one entry per resolved chair fallback-walk attempt (ch1/ch2/ch3 — the ch4 VERDICT-line repair is not an attempt, see classifyChairAttempt), checkpointed incrementally so a mid-walk kill preserves the attempts already resolved. Additive; absent on chairless (cost-skipped) runs and on every pre-PR5 run.",
|
|
107
|
+
"type": "array",
|
|
108
|
+
"items": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"required": ["waveId", "model", "outcome"],
|
|
111
|
+
"properties": {
|
|
112
|
+
"waveId": { "type": "string" },
|
|
113
|
+
"model": { "type": "string" },
|
|
114
|
+
"outcome": { "enum": ["completed", "error", "timeout", "no-output"] },
|
|
115
|
+
"reason": { "type": ["string", "null"] }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
105
119
|
"debate": {
|
|
106
120
|
"type": "object",
|
|
107
121
|
"properties": {
|
|
@@ -162,6 +162,15 @@ function runVerdict(args, useJson) {
|
|
|
162
162
|
hint: 'pass a valid decisions.json array or omit --decisions' });
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
+
// R1 (v4.6.3): parseArgs records a valueless trailing -o/--out as boolean
|
|
166
|
+
// true (and --out= as ''). The boolean crashes writeVerdictAtomic mid-write
|
|
167
|
+
// (renameSync TypeError on a non-string path) leaving an orphaned
|
|
168
|
+
// true.tmp-<pid>; the empty string silently falls through to the default
|
|
169
|
+
// path. Name the flag and refuse both — the unknown-flag precedent.
|
|
170
|
+
if (args.out !== undefined && (typeof args.out !== 'string' || args.out === '')) {
|
|
171
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: '-o/--out requires a value',
|
|
172
|
+
hint: 'amicus council verdict <tally.json> [--decisions <decisions.json>] [-o|--out <out.json>]' });
|
|
173
|
+
}
|
|
165
174
|
const outPath = args.out || './verdict.json';
|
|
166
175
|
let verdict;
|
|
167
176
|
try {
|
|
@@ -12,6 +12,8 @@ const electronMcpCheck = require('./utils/doctor-electron-mcp-check');
|
|
|
12
12
|
// local-providers check body (v4.2 §4.7 C8) — split out to keep this file
|
|
13
13
|
// under the gate (mirrors the engineCheck/mcpChecks split above).
|
|
14
14
|
const localProvidersCheck = require('./utils/doctor-local-providers-check');
|
|
15
|
+
// v4.6.2 PR1 (spec §4) — the 'anthropic-base-url' check body.
|
|
16
|
+
const baseUrlCheck = require('./utils/doctor-base-url-check');
|
|
15
17
|
|
|
16
18
|
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
|
|
17
19
|
|
|
@@ -38,6 +40,7 @@ function realDeps() {
|
|
|
38
40
|
readCache: () => require('./utils/model-catalog').readCache(),
|
|
39
41
|
collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
|
|
40
42
|
findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
|
|
43
|
+
findDriftedStoredAliases: (s, c) => require('./utils/alias-audit').findDriftedStoredAliases(s, c),
|
|
41
44
|
hasOpencodeBinary: () => {
|
|
42
45
|
// Single source of truth shared with the runtime server-start guard.
|
|
43
46
|
const { ensureNodeModulesBinInPath, hasOpencodeBinary } = require('./utils/path-setup');
|
|
@@ -77,10 +80,14 @@ function realDeps() {
|
|
|
77
80
|
now: () => Date.now(),
|
|
78
81
|
listSessionIndexTmpFiles: () => tmpSweep.listSessionIndexTmpFiles(), // B15
|
|
79
82
|
unlinkSessionIndexTmp: (n) => tmpSweep.unlinkSessionIndexTmp(n),
|
|
83
|
+
listSessionMetadataTmpFiles: () => metaSweep.listSessionMetadataTmpFiles(), // D8
|
|
84
|
+
unlinkSessionMetadataTmp: (n) => metaSweep.unlinkSessionMetadataTmp(n),
|
|
80
85
|
};
|
|
81
86
|
}
|
|
82
87
|
// B15: sweep logic in utils/session-index-tmp-sweep.js (mirrors mcp-legacy's split).
|
|
83
88
|
const tmpSweep = require('./utils/session-index-tmp-sweep');
|
|
89
|
+
// D8: per-session metadata.json sibling sweep — utils/session-metadata-tmp-sweep.js.
|
|
90
|
+
const metaSweep = require('./utils/session-metadata-tmp-sweep');
|
|
84
91
|
|
|
85
92
|
/** Run one guarded check; a thrown fn becomes an error line. */
|
|
86
93
|
function guard(id, name, fn) {
|
|
@@ -105,10 +112,10 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
105
112
|
const checks = [];
|
|
106
113
|
|
|
107
114
|
checks.push(guard('node', 'Node.js', () => {
|
|
108
|
-
const
|
|
109
|
-
return
|
|
115
|
+
const [maj, min] = String(d.nodeVersion).replace(/^v/, '').split('.').map(n => parseInt(n, 10));
|
|
116
|
+
return (maj > 22 || (maj === 22 && min >= 12))
|
|
110
117
|
? { id: 'node', name: 'Node.js', status: 'ok', message: d.nodeVersion, hint: null }
|
|
111
|
-
: { id: 'node', name: 'Node.js', status: 'error', message: `${d.nodeVersion} (need >=
|
|
118
|
+
: { id: 'node', name: 'Node.js', status: 'error', message: `${d.nodeVersion} (need >=22.12)`, hint: 'Install Node 22.12 or newer from https://nodejs.org' };
|
|
112
119
|
}));
|
|
113
120
|
|
|
114
121
|
checks.push(guard('config-dir', 'Config directory', () => (
|
|
@@ -148,12 +155,21 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
148
155
|
checks.push(guard('aliases', 'Model aliases', () => {
|
|
149
156
|
const cache = d.readCache();
|
|
150
157
|
const catalog = (cache && cache.models) || [];
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
const sources = d.collectAliasSources();
|
|
159
|
+
const stale = d.findStaleAliases(sources, catalog);
|
|
160
|
+
const drifted = d.findDriftedStoredAliases(sources, catalog);
|
|
161
|
+
if (stale.length === 0 && drifted.length === 0) {
|
|
162
|
+
return { id: 'aliases', name: 'Model aliases', status: 'ok', message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null };
|
|
163
|
+
}
|
|
164
|
+
const parts = [];
|
|
165
|
+
if (stale.length) { parts.push(`${stale.length} stale: ${stale.map(s => s.alias).join(', ')}`); }
|
|
166
|
+
if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map(s => s.alias).join(', ')}`); }
|
|
167
|
+
return { id: 'aliases', name: 'Model aliases', status: 'warn', message: parts.join('; '), hint: 'amicus models --check' };
|
|
155
168
|
}));
|
|
156
169
|
|
|
170
|
+
checks.push(guard('anthropic-base-url', 'ANTHROPIC_BASE_URL',
|
|
171
|
+
() => baseUrlCheck.evaluateAnthropicBaseUrl(d)));
|
|
172
|
+
|
|
157
173
|
checks.push(guard('opencode-bin', 'OpenCode binary', () => (
|
|
158
174
|
d.hasOpencodeBinary()
|
|
159
175
|
? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
|
|
@@ -189,6 +205,8 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
189
205
|
|
|
190
206
|
checks.push(guard('sessions-index-tmp', 'Session index tmp files', () => tmpSweep.evaluateSessionIndexTmpSweep(d)));
|
|
191
207
|
|
|
208
|
+
checks.push(guard('session-metadata-tmp', 'Session metadata tmp files', () => metaSweep.evaluateSessionMetadataTmpSweep(d)));
|
|
209
|
+
|
|
192
210
|
// #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
|
|
193
211
|
checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
|
|
194
212
|
const values = d.readApiKeyValues() || {};
|
package/src/cli.js
CHANGED
|
@@ -149,6 +149,7 @@ const BOOLEAN_FLAGS = [
|
|
|
149
149
|
'md', // council report: emit Markdown (default)
|
|
150
150
|
'fix', // doctor: self-heal fixable checks in place (#56)
|
|
151
151
|
'strict', // models --check: exit non-zero on curated per-gateway drift (#gwid Task 6)
|
|
152
|
+
'live', // models --check: opt-in probe of stored aliases with real engine legs (v4.6.2 PR3, spec §6)
|
|
152
153
|
'render', // council verdict: also refresh report.html next to the decided verdict
|
|
153
154
|
'claude', // init: register for Claude Code only (Task 15)
|
|
154
155
|
'desktop', // init: register for Claude Desktop only (Task 15)
|
|
@@ -501,6 +502,9 @@ Options for 'models':
|
|
|
501
502
|
--strict With --check: also exit non-zero on curated
|
|
502
503
|
per-gateway drift (stale/divergent direct or
|
|
503
504
|
openrouter forms). Informational without it.
|
|
505
|
+
--live With --check: probe every stored alias with one real
|
|
506
|
+
engine leg (spends) — served / accepted-but-silent /
|
|
507
|
+
error. Requires --check.
|
|
504
508
|
--json Machine-readable output
|
|
505
509
|
`,
|
|
506
510
|
list: `
|
|
@@ -45,18 +45,22 @@ function runSave(name, modelsArg, useJson) {
|
|
|
45
45
|
hint: 'each member must be a known alias (see `amicus models`) or a provider/model id containing "/"' });
|
|
46
46
|
}
|
|
47
47
|
const overwritten = !!getCouncil(name);
|
|
48
|
+
const shadowsBuiltin = listBuiltinCouncilNames().includes(name);
|
|
48
49
|
const cfg = loadConfig() || {};
|
|
49
50
|
if (!cfg.councils) { cfg.councils = {}; }
|
|
50
51
|
cfg.councils[name] = members;
|
|
51
52
|
saveConfig(cfg);
|
|
52
|
-
const doc = { ok: true, name, models: members, overwritten };
|
|
53
|
+
const doc = { ok: true, name, models: members, overwritten, shadowsBuiltin };
|
|
53
54
|
process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderSave(doc));
|
|
54
55
|
return 0;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
function renderSave(doc) {
|
|
58
59
|
const notice = doc.overwritten ? ' (overwritten)' : '';
|
|
59
|
-
|
|
60
|
+
const shadow = doc.shadowsBuiltin
|
|
61
|
+
? ` note: '${doc.name}' now shadows the built-in bench of the same name — 'amicus council list' shows both\n`
|
|
62
|
+
: '';
|
|
63
|
+
return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n` + shadow +
|
|
60
64
|
" for full run configuration — chair, options, templates — see 'amicus pack'\n";
|
|
61
65
|
}
|
|
62
66
|
|