amicus 4.5.1 → 4.5.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 +75 -0
- package/README.md +142 -93
- package/bin/amicus.js +23 -0
- package/docs/configuration.md +16 -0
- package/docs/usage.md +1 -1
- package/package.json +3 -2
- package/schemas/council-verdict.schema.json +183 -29
- package/src/cli.js +21 -5
- package/src/council/run-assemble.js +7 -3
- package/src/council/run.js +2 -1
- package/src/council/verdict.js +44 -1
- package/src/headless.js +6 -0
- package/src/opencode-client.js +79 -2
- package/src/sidecar/session-utils.js +4 -0
- package/src/sidecar/unzip.js +16 -1
- package/src/utils/known-flags.js +90 -0
- package/src/utils/server-setup.js +74 -7
|
@@ -4,53 +4,207 @@
|
|
|
4
4
|
"title": "amicus council-verdict document",
|
|
5
5
|
"description": "Verdict record (`council verdict --json`, amicus_verdict, verdict.json). overallVerdict is the chair's parsed VERDICT line — null in every Stage-4 manual path, populated by the headless engine.",
|
|
6
6
|
"type": "object",
|
|
7
|
-
"required": [
|
|
7
|
+
"required": [
|
|
8
|
+
"schemaVersion",
|
|
9
|
+
"type",
|
|
10
|
+
"runId",
|
|
11
|
+
"council",
|
|
12
|
+
"overallVerdict",
|
|
13
|
+
"findings",
|
|
14
|
+
"streetCred",
|
|
15
|
+
"runStats",
|
|
16
|
+
"tierCounts"
|
|
17
|
+
],
|
|
8
18
|
"properties": {
|
|
9
|
-
"schemaVersion": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
19
|
+
"schemaVersion": {
|
|
20
|
+
"const": 2
|
|
21
|
+
},
|
|
22
|
+
"type": {
|
|
23
|
+
"const": "council-verdict"
|
|
24
|
+
},
|
|
25
|
+
"runId": {
|
|
26
|
+
"type": "string"
|
|
27
|
+
},
|
|
28
|
+
"runType": {
|
|
29
|
+
"type": [
|
|
30
|
+
"string",
|
|
31
|
+
"null"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"date": {
|
|
35
|
+
"type": [
|
|
36
|
+
"string",
|
|
37
|
+
"null"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"chair": {
|
|
41
|
+
"type": [
|
|
42
|
+
"string",
|
|
43
|
+
"null"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"council": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"items": {
|
|
49
|
+
"type": "string"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"claudeInCouncil": {
|
|
53
|
+
"type": "boolean"
|
|
54
|
+
},
|
|
17
55
|
"overallVerdict": {
|
|
18
56
|
"oneOf": [
|
|
19
|
-
{
|
|
20
|
-
|
|
57
|
+
{
|
|
58
|
+
"enum": [
|
|
59
|
+
"Ship it",
|
|
60
|
+
"Fix these first",
|
|
61
|
+
"Fundamental rethink"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"type": "null"
|
|
66
|
+
}
|
|
21
67
|
]
|
|
22
68
|
},
|
|
23
69
|
"findings": {
|
|
24
70
|
"type": "array",
|
|
25
71
|
"items": {
|
|
26
72
|
"type": "object",
|
|
27
|
-
"required": [
|
|
73
|
+
"required": [
|
|
74
|
+
"id",
|
|
75
|
+
"tier",
|
|
76
|
+
"decision",
|
|
77
|
+
"applied"
|
|
78
|
+
],
|
|
28
79
|
"properties": {
|
|
29
|
-
"id": {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
80
|
+
"id": {
|
|
81
|
+
"type": "string"
|
|
82
|
+
},
|
|
83
|
+
"raiser": {
|
|
84
|
+
"type": [
|
|
85
|
+
"string",
|
|
86
|
+
"null"
|
|
87
|
+
]
|
|
88
|
+
},
|
|
89
|
+
"severity": {
|
|
90
|
+
"type": [
|
|
91
|
+
"string",
|
|
92
|
+
"null"
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
"tier": {
|
|
96
|
+
"enum": [
|
|
97
|
+
"Confirmed",
|
|
98
|
+
"Contested",
|
|
99
|
+
"Singleton",
|
|
100
|
+
"Disputed"
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
"basis": {
|
|
104
|
+
"type": "object"
|
|
105
|
+
},
|
|
106
|
+
"confidence": {
|
|
107
|
+
"enum": [
|
|
108
|
+
"thin",
|
|
109
|
+
"solid"
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
"tierOverride": {
|
|
113
|
+
"type": [
|
|
114
|
+
"object",
|
|
115
|
+
"null"
|
|
116
|
+
]
|
|
117
|
+
},
|
|
118
|
+
"duplicateOf": {
|
|
119
|
+
"type": [
|
|
120
|
+
"string",
|
|
121
|
+
"null"
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
"adjudications": {
|
|
125
|
+
"type": "array"
|
|
126
|
+
},
|
|
127
|
+
"decision": {
|
|
128
|
+
"type": [
|
|
129
|
+
"string",
|
|
130
|
+
"null"
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
"applied": {
|
|
134
|
+
"type": "boolean"
|
|
135
|
+
},
|
|
40
136
|
"debate": {
|
|
41
137
|
"type": "object",
|
|
42
138
|
"properties": {
|
|
43
|
-
"action": {
|
|
44
|
-
|
|
139
|
+
"action": {
|
|
140
|
+
"enum": [
|
|
141
|
+
"defended",
|
|
142
|
+
"amended",
|
|
143
|
+
"withdrawn",
|
|
144
|
+
"no-response"
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
"previousTier": {
|
|
148
|
+
"type": [
|
|
149
|
+
"string",
|
|
150
|
+
"null"
|
|
151
|
+
]
|
|
152
|
+
}
|
|
45
153
|
},
|
|
46
|
-
"required": [
|
|
154
|
+
"required": [
|
|
155
|
+
"action"
|
|
156
|
+
],
|
|
47
157
|
"additionalProperties": false
|
|
48
158
|
}
|
|
49
159
|
}
|
|
50
160
|
}
|
|
51
161
|
},
|
|
52
|
-
"streetCred": {
|
|
53
|
-
|
|
54
|
-
|
|
162
|
+
"streetCred": {
|
|
163
|
+
"type": "array",
|
|
164
|
+
"items": {
|
|
165
|
+
"type": "object"
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"runStats": {
|
|
169
|
+
"type": "array",
|
|
170
|
+
"items": {
|
|
171
|
+
"type": "object"
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
"tierCounts": {
|
|
175
|
+
"type": "object"
|
|
176
|
+
},
|
|
177
|
+
"seatLoss": {
|
|
178
|
+
"type": "object",
|
|
179
|
+
"description": "Present only when --critic was requested. Records whether the adversarial seat actually reviewed: a dead critic wave is survivable (the quorum gate guards only the bench), so a run can otherwise reach a full verdict with the critic silently absent.",
|
|
180
|
+
"properties": {
|
|
181
|
+
"criticRequested": {
|
|
182
|
+
"type": "string",
|
|
183
|
+
"description": "The model asked for as critic."
|
|
184
|
+
},
|
|
185
|
+
"criticSeated": {
|
|
186
|
+
"type": "boolean",
|
|
187
|
+
"description": "False when the critic wave died before producing legs."
|
|
188
|
+
},
|
|
189
|
+
"reason": {
|
|
190
|
+
"type": [
|
|
191
|
+
"string",
|
|
192
|
+
"null"
|
|
193
|
+
],
|
|
194
|
+
"description": "Why the critic wave died, when it did."
|
|
195
|
+
},
|
|
196
|
+
"deadBenchSeats": {
|
|
197
|
+
"type": "array",
|
|
198
|
+
"items": {
|
|
199
|
+
"type": "string"
|
|
200
|
+
},
|
|
201
|
+
"description": "Bench models lost to dead waves, excluding the critic."
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
"required": [
|
|
205
|
+
"criticRequested",
|
|
206
|
+
"criticSeated"
|
|
207
|
+
]
|
|
208
|
+
}
|
|
55
209
|
}
|
|
56
210
|
}
|
package/src/cli.js
CHANGED
|
@@ -122,10 +122,12 @@ function parseArgs(argv) {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
125
|
+
* Flags that take no value. Module-level (not rebuilt per call) so
|
|
126
|
+
* `getBooleanFlags()` can hand the same list to src/utils/known-flags.js —
|
|
127
|
+
* which needs it to tell a real boolean flag from an unknown token, and must
|
|
128
|
+
* not keep a second copy that can rot out of sync with this one.
|
|
126
129
|
*/
|
|
127
|
-
|
|
128
|
-
const booleanFlags = [
|
|
130
|
+
const BOOLEAN_FLAGS = [
|
|
129
131
|
'no-ui',
|
|
130
132
|
'no-mcp',
|
|
131
133
|
'no-context',
|
|
@@ -156,8 +158,21 @@ function isBooleanFlag(key) {
|
|
|
156
158
|
'ui', // watch: open the Council Workspace window; v4.4 seam (v4.3 Task 11)
|
|
157
159
|
'follow', // fanout / council run: stream this run's own events to stderr (v4.3 Task 13)
|
|
158
160
|
'fallback', // fanout / council run: opt-in cheaper-model substitution (v4.3 Task 18, spec 6.2); --no-fallback negates via the generic no-* catch-all below
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if a flag is boolean (doesn't take a value)
|
|
165
|
+
*/
|
|
166
|
+
function isBooleanFlag(key) {
|
|
167
|
+
return BOOLEAN_FLAGS.includes(key);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The boolean-flag names, for the unknown-flag check.
|
|
172
|
+
* @returns {string[]} copy — callers must not mutate the source list
|
|
173
|
+
*/
|
|
174
|
+
function getBooleanFlags() {
|
|
175
|
+
return [...BOOLEAN_FLAGS];
|
|
161
176
|
}
|
|
162
177
|
|
|
163
178
|
/**
|
|
@@ -765,5 +780,6 @@ module.exports = {
|
|
|
765
780
|
validateStartArgs,
|
|
766
781
|
getUsage,
|
|
767
782
|
getCommandNames,
|
|
783
|
+
getBooleanFlags,
|
|
768
784
|
DEFAULTS
|
|
769
785
|
};
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
const fs = require('fs');
|
|
20
20
|
const path = require('path');
|
|
21
21
|
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
22
|
-
const { buildVerdict, writeVerdictAtomic } = require('./verdict');
|
|
22
|
+
const { buildVerdict, summarizeSeatLoss, writeVerdictAtomic } = require('./verdict');
|
|
23
23
|
const { buildReport } = require('./report');
|
|
24
24
|
const { validateFindings } = require('./findings');
|
|
25
25
|
const { toGlobalFindings } = require('./anonymize');
|
|
@@ -178,8 +178,12 @@ function writeTallyFiles({ runDir, tallyInput, record }) {
|
|
|
178
178
|
* buildVerdict's own signature.
|
|
179
179
|
* @returns {object} the verdict written to disk
|
|
180
180
|
*/
|
|
181
|
-
function writeVerdictFiles({ runDir, record, overallVerdict, chairText }) {
|
|
182
|
-
|
|
181
|
+
function writeVerdictFiles({ runDir, record, overallVerdict, chairText, critic, deadWaves }) {
|
|
182
|
+
// v4.5.2 — computed here rather than in run.js so verdict assembly stays in
|
|
183
|
+
// one place; see summarizeSeatLoss in ./verdict for why a lost critic has to
|
|
184
|
+
// reach the verdict at all.
|
|
185
|
+
const seatLoss = summarizeSeatLoss({ runId: record.meta.runId, critic, deadWaves });
|
|
186
|
+
const verdict = buildVerdict(record, [], { seatLoss });
|
|
183
187
|
verdict.overallVerdict = (overallVerdict === undefined) ? null : overallVerdict;
|
|
184
188
|
writeVerdictAtomic(path.join(runDir, 'verdict.json'), verdict);
|
|
185
189
|
const html = buildReport({ verdict }, { format: 'html' });
|
package/src/council/run.js
CHANGED
|
@@ -284,7 +284,8 @@ async function runCouncil(options, deps = {}) {
|
|
|
284
284
|
runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
|
|
285
285
|
emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
|
|
286
286
|
emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
|
|
287
|
-
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText
|
|
287
|
+
asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
|
|
288
|
+
critic: o.critic, deadWaves });
|
|
288
289
|
runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
|
|
289
290
|
emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
|
|
290
291
|
emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
|
package/src/council/verdict.js
CHANGED
|
@@ -16,6 +16,44 @@ const VERDICT_SCHEMA_VERSION = 2;
|
|
|
16
16
|
* @param {{overallVerdict?: (string|null)}} [opts] engine hook (Plan B): the
|
|
17
17
|
* parsed chair `VERDICT:` line; omitted/undefined → null.
|
|
18
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* Describe which requested seats actually reviewed, for the verdict's own face.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ ADDED v4.5.2 from a field report. The critic is a SOLO wave with one leg,
|
|
23
|
+
* so losing it loses 100% of the adversarial role — and unlike a dead bench wave
|
|
24
|
+
* (which trips the quorum gate and fails the run loudly) a dead critic is
|
|
25
|
+
* survivable, so the run continues to a full verdict, tally and chair synthesis
|
|
26
|
+
* that never saw the critic's findings. Run `dfb6a692` did exactly that and the
|
|
27
|
+
* only record was `deadWaves` in run.json, a file nobody opens when the verdict
|
|
28
|
+
* reads clean. A user who typed `--critic` asked for adversarial review; a
|
|
29
|
+
* verdict produced without it must say so where the verdict is read.
|
|
30
|
+
*
|
|
31
|
+
* Returns null when no critic was requested — there is nothing to report, and an
|
|
32
|
+
* always-present block would train readers to ignore it.
|
|
33
|
+
*
|
|
34
|
+
* @param {{runId: string, critic: ?string,
|
|
35
|
+
* deadWaves: Array<{waveId: string, models: string[], reason: string}>}} o
|
|
36
|
+
* @returns {?{criticRequested: string, criticSeated: boolean, reason: ?string,
|
|
37
|
+
* deadBenchSeats: string[]}}
|
|
38
|
+
*/
|
|
39
|
+
function summarizeSeatLoss({ runId, critic, deadWaves = [] } = {}) {
|
|
40
|
+
if (!critic) { return null; }
|
|
41
|
+
// Match on EITHER carrier. The `-c1` suffix is the convention run-stages.js
|
|
42
|
+
// uses, but a wave that names the critic model is the critic wave whatever it
|
|
43
|
+
// is called — and relying on the id alone would silently under-report if that
|
|
44
|
+
// convention ever changes.
|
|
45
|
+
const isCriticWave = w =>
|
|
46
|
+
w.waveId === `${runId}-c1` || (w.models || []).includes(critic);
|
|
47
|
+
const dead = deadWaves.find(isCriticWave) || null;
|
|
48
|
+
return {
|
|
49
|
+
criticRequested: critic,
|
|
50
|
+
criticSeated: !dead,
|
|
51
|
+
reason: dead ? dead.reason : null,
|
|
52
|
+
deadBenchSeats: deadWaves.filter(w => !isCriticWave(w))
|
|
53
|
+
.flatMap(w => w.models || []),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
19
57
|
function buildVerdict(record, decisions = [], opts = {}) {
|
|
20
58
|
const byId = new Map(decisions.map(d => [d.id, d]));
|
|
21
59
|
return {
|
|
@@ -46,6 +84,9 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
46
84
|
streetCred: record.streetCred.map(s => ({ model: s.model, withSelf: s.withSelf, peersOnly: s.peersOnly })),
|
|
47
85
|
runStats: record.runStats,
|
|
48
86
|
tierCounts: record.tierCounts,
|
|
87
|
+
// Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
|
|
88
|
+
// was requested, so its absence never has to be interpreted.
|
|
89
|
+
...(opts.seatLoss ? { seatLoss: opts.seatLoss } : {}),
|
|
49
90
|
};
|
|
50
91
|
}
|
|
51
92
|
|
|
@@ -93,4 +134,6 @@ function writeVerdictAtomic(filePath, verdict) {
|
|
|
93
134
|
fs.renameSync(tmp, filePath);
|
|
94
135
|
}
|
|
95
136
|
|
|
96
|
-
module.exports = {
|
|
137
|
+
module.exports = {
|
|
138
|
+
buildVerdict, summarizeSeatLoss, readOverallVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION,
|
|
139
|
+
};
|
package/src/headless.js
CHANGED
|
@@ -272,6 +272,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
272
272
|
if (options.mcp) {
|
|
273
273
|
serverOptions.mcp = options.mcp;
|
|
274
274
|
}
|
|
275
|
+
// v4.5.2: explicit per-call override only — see the note at the matching
|
|
276
|
+
// hop in src/sidecar/session-utils.js. The default and the env knob both
|
|
277
|
+
// resolve downstream in buildServerOptions.
|
|
278
|
+
if (options.timeout !== undefined) {
|
|
279
|
+
serverOptions.timeout = options.timeout;
|
|
280
|
+
}
|
|
275
281
|
// v4.4.1 fix wave (F5): this is the OTHER server-start site. It calls
|
|
276
282
|
// startServer directly rather than going through startOpenCodeServer, so
|
|
277
283
|
// the lock-class retry added for the concurrent-start race never covered
|
package/src/opencode-client.js
CHANGED
|
@@ -402,11 +402,67 @@ async function getSessionStatus(client, sessionId, directory) {
|
|
|
402
402
|
return result.data || {};
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
/**
|
|
406
|
+
* How long to wait for OpenCode to announce it is listening, per platform.
|
|
407
|
+
*
|
|
408
|
+
* ⚠️ ADDED v4.5.2 from a field report. `@opencode-ai/sdk` defaults this to
|
|
409
|
+
* 5000ms (`dist/server.js:4-8`) and lets the caller override it; amicus never
|
|
410
|
+
* passed one, so every start on every platform ran on the SDK's 5s — untunable
|
|
411
|
+
* and invisible to `amicus doctor`. A reporter's Windows box (project on a
|
|
412
|
+
* OneDrive-synced volume, Defender active) blew through it on a cold
|
|
413
|
+
* OpenCode/SQLite open: the council's shared server failed to acquire, the run
|
|
414
|
+
* degraded to the per-wave configuration `src/council/run-server.js` exists to
|
|
415
|
+
* eliminate, and the whole Stage-1 bench died (`COUNCIL_QUORUM: Only 0 …`).
|
|
416
|
+
*
|
|
417
|
+
* The asymmetry decides the number: a slow start costs LATENCY, a failed start
|
|
418
|
+
* costs a REVIEW SEAT. win32 gets the widest window because that is where
|
|
419
|
+
* sync-backed volumes and always-on AV filter drivers are the norm.
|
|
420
|
+
*
|
|
421
|
+
* This is a ceiling, not a sleep — a healthy start still resolves in well under
|
|
422
|
+
* a second and pays none of it.
|
|
423
|
+
*/
|
|
424
|
+
const SERVER_START_TIMEOUT_MS = Object.freeze({ win32: 30000, default: 15000 });
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Resolve the start timeout: explicit option → env override → platform default.
|
|
428
|
+
*
|
|
429
|
+
* `0` and negatives are REJECTED rather than honored. For most amicus knobs `0`
|
|
430
|
+
* is a documented disable switch (see src/utils/env-num.js), but a 0ms start
|
|
431
|
+
* timeout disables nothing — it fails every start instantly. That is an own-goal
|
|
432
|
+
* an operator can only reach by accident, so it falls back to the default.
|
|
433
|
+
*
|
|
434
|
+
* @param {object} [options] - Server options ({timeout} respected if positive)
|
|
435
|
+
* @param {object} [env] - Environment (test seam; defaults to process.env)
|
|
436
|
+
* @param {string} [platform] - Platform (test seam; defaults to process.platform)
|
|
437
|
+
* @returns {number} milliseconds
|
|
438
|
+
*/
|
|
439
|
+
function resolveServerStartTimeoutMs(options = {}, env, platform) {
|
|
440
|
+
const plat = platform || process.platform;
|
|
441
|
+
const dflt = SERVER_START_TIMEOUT_MS[plat] || SERVER_START_TIMEOUT_MS.default;
|
|
442
|
+
const positive = (v) => {
|
|
443
|
+
const n = Number(v);
|
|
444
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
445
|
+
};
|
|
446
|
+
if (options.timeout !== undefined) {
|
|
447
|
+
const explicit = positive(options.timeout);
|
|
448
|
+
if (explicit) { return explicit; }
|
|
449
|
+
}
|
|
450
|
+
const raw = (env || process.env).AMICUS_SERVER_START_TIMEOUT_MS;
|
|
451
|
+
if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
|
|
452
|
+
const fromEnv = positive(raw);
|
|
453
|
+
if (fromEnv) { return fromEnv; }
|
|
454
|
+
}
|
|
455
|
+
return dflt;
|
|
456
|
+
}
|
|
457
|
+
|
|
405
458
|
/**
|
|
406
459
|
* Build the server options object for createOpencodeServer.
|
|
407
460
|
* Extracted for testability (no SDK dependency).
|
|
408
461
|
*
|
|
409
462
|
* @param {object} [options] - Server options
|
|
463
|
+
* @param {number} [options.timeout] - Start timeout in ms. Omit to use
|
|
464
|
+
* AMICUS_SERVER_START_TIMEOUT_MS or the platform default; NEVER omitted from
|
|
465
|
+
* the object handed to the SDK, so the SDK's own 5000ms default is unreachable.
|
|
410
466
|
* @param {number} [options.port] - Port to run on
|
|
411
467
|
* @param {string} [options.hostname='127.0.0.1'] - Hostname to bind to
|
|
412
468
|
* @param {AbortSignal} [options.signal] - Abort signal to stop server
|
|
@@ -544,6 +600,9 @@ function buildServerOptions(options = {}) {
|
|
|
544
600
|
|
|
545
601
|
const serverOptions = {
|
|
546
602
|
hostname: options.hostname || '127.0.0.1',
|
|
603
|
+
// ALWAYS set — unlike port/signal below, an omitted timeout is not a
|
|
604
|
+
// harmless "let the SDK decide", it is the 5000ms that cost a bench.
|
|
605
|
+
timeout: resolveServerStartTimeoutMs(options),
|
|
547
606
|
};
|
|
548
607
|
|
|
549
608
|
// Only include port/signal when explicitly set — passing undefined
|
|
@@ -660,11 +719,27 @@ async function startServer(options = {}) {
|
|
|
660
719
|
}
|
|
661
720
|
}
|
|
662
721
|
|
|
663
|
-
|
|
722
|
+
// `_createOpencodeServer` is a test seam, matching `_hasOpencodeBinary` /
|
|
723
|
+
// `_ensureEngine` / `_opencodeRoots` above: the SDK arrives through a dynamic
|
|
724
|
+
// `import()`, which `jest.mock` cannot intercept under CommonJS, so the start
|
|
725
|
+
// path is otherwise unreachable from a unit test.
|
|
726
|
+
const createOpencodeServer = options._createOpencodeServer
|
|
727
|
+
|| await getCreateOpencodeServer();
|
|
664
728
|
const serverOptions = buildServerOptions(options);
|
|
665
729
|
|
|
730
|
+
// Measure the healthy path. The v4.5.2 timeout had to be sized from the
|
|
731
|
+
// asymmetry of the failure (a slow start costs latency, a failed one costs a
|
|
732
|
+
// review seat) because nothing recorded how long a GOOD start takes — so the
|
|
733
|
+
// margin against the ceiling was unmeasurable on exactly the slow boxes that
|
|
734
|
+
// needed it. Now it is one debug line, not an inference.
|
|
735
|
+
const startedAt = Date.now();
|
|
666
736
|
const sdkServer = await createOpencodeServer(serverOptions);
|
|
667
|
-
const
|
|
737
|
+
const { logger } = require('./utils/logger');
|
|
738
|
+
logger.debug('OpenCode server started', {
|
|
739
|
+
startMs: Date.now() - startedAt,
|
|
740
|
+
timeoutMs: serverOptions.timeout,
|
|
741
|
+
});
|
|
742
|
+
const client = await (options._createClient || createClient)(sdkServer.url);
|
|
668
743
|
|
|
669
744
|
// Capture the Go server PID once so close() can force-kill it cross-platform
|
|
670
745
|
// (F3 #15). Prefer a PID the SDK exposes; fall back to the port listener.
|
|
@@ -822,6 +897,8 @@ module.exports = {
|
|
|
822
897
|
abortSession,
|
|
823
898
|
checkHealth,
|
|
824
899
|
buildServerOptions,
|
|
900
|
+
resolveServerStartTimeoutMs,
|
|
901
|
+
SERVER_START_TIMEOUT_MS,
|
|
825
902
|
buildServerHandle,
|
|
826
903
|
startServer,
|
|
827
904
|
loadMcpConfig,
|
|
@@ -253,6 +253,10 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
|
|
|
253
253
|
if (options.models) { serverOptions.models = options.models; }
|
|
254
254
|
if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
|
|
255
255
|
if (options.agentName) { serverOptions.agentName = options.agentName; }
|
|
256
|
+
// Explicit per-call override only. Unset is the normal case and is correct:
|
|
257
|
+
// buildServerOptions resolves AMICUS_SERVER_START_TIMEOUT_MS / the platform
|
|
258
|
+
// default downstream, so forwarding `undefined` here would change nothing.
|
|
259
|
+
if (options.timeout !== undefined) { serverOptions.timeout = options.timeout; }
|
|
256
260
|
|
|
257
261
|
// v4.4.1 Task 0.5: a LOCK-CLASS start failure is retried (5 attempts,
|
|
258
262
|
// 250/500/1000/2000ms — widened from 3/750ms by Step 10.5, see server-setup).
|
package/src/sidecar/unzip.js
CHANGED
|
@@ -173,7 +173,22 @@ async function robustExtract(zip, opts = {}) {
|
|
|
173
173
|
deps = {},
|
|
174
174
|
} = opts;
|
|
175
175
|
const fs = deps.fs || fsDefault;
|
|
176
|
-
|
|
176
|
+
// GUARDED (v4.5.2). This `require` used to be bare, and `extract-zip` was
|
|
177
|
+
// never declared in dependencies — it resolved in the dev tree only because
|
|
178
|
+
// `puppeteer` (a devDependency) pulls it transitively, so a published install
|
|
179
|
+
// threw MODULE_NOT_FOUND here and took the WHOLE function with it: the native
|
|
180
|
+
// fallback below, the bounded idle/max timers, and `doctor --fix` all became
|
|
181
|
+
// unreachable. The dependency is now declared, so this should never fire —
|
|
182
|
+
// but Strategy 1 being unavailable is precisely what the native strategies
|
|
183
|
+
// exist for, so it must degrade into them rather than out of the function.
|
|
184
|
+
let extractZip = deps.extractZip;
|
|
185
|
+
if (!extractZip) {
|
|
186
|
+
try {
|
|
187
|
+
extractZip = require('extract-zip');
|
|
188
|
+
} catch (e) {
|
|
189
|
+
extractZip = () => { throw new Error(`extract-zip unavailable: ${e.message}`); };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
177
192
|
const spawn = deps.spawn || spawnSync;
|
|
178
193
|
const setTimer = deps.setTimeout || setTimeout;
|
|
179
194
|
const clearTimer = deps.clearTimeout || clearTimeout;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// src/utils/known-flags.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module utils/known-flags
|
|
6
|
+
* The set of `--flags` amicus accepts, and the check that rejects the rest.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS. `parseArgs` (src/cli.js) treats ANY `--token` as a flag: an
|
|
9
|
+
* unrecognized one lands on the parsed object, no handler reads it, and the
|
|
10
|
+
* command proceeds as if it were never typed. Found in the field while smoke-
|
|
11
|
+
* testing v4.5.2 — `amicus start -m deepseek --prompt "…" --headless` printed no
|
|
12
|
+
* error and exited 0, but `start` has no `--headless`. The run silently took the
|
|
13
|
+
* INTERACTIVE path, ignored `-m`, and left a session running against the default
|
|
14
|
+
* model. A typo (`--modl`), a flag borrowed from another command, or an invented
|
|
15
|
+
* one all behave the same way, and an unknown flag followed by a positional
|
|
16
|
+
* SWALLOWS it as its value.
|
|
17
|
+
*
|
|
18
|
+
* amicus already handles an unknown COMMAND correctly — error, "Did you mean",
|
|
19
|
+
* usage, exit 1. This gives flags the same treatment.
|
|
20
|
+
*
|
|
21
|
+
* DERIVED, NOT HAND-MAINTAINED. The bulk of the set is scraped from the usage
|
|
22
|
+
* text, which is the same source `getCommandNames()` uses and for the same
|
|
23
|
+
* stated reason: a second hand-maintained list would rot out of sync with the
|
|
24
|
+
* first. Only flags that are deliberately absent from usage are listed here, and
|
|
25
|
+
* each says why.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Real flags that appear in NO usage block. Rejecting any of these would break
|
|
30
|
+
* working callers, so they are enumerated deliberately rather than derived.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ The first four are spawned by the MCP server onto its own CLI children
|
|
33
|
+
* (src/mcp-server.js, src/mcp-council-run.js). They are not user-facing and are
|
|
34
|
+
* intentionally undocumented — but they are on the argv of every MCP-launched
|
|
35
|
+
* run, so rejecting them would break the entire MCP surface.
|
|
36
|
+
*/
|
|
37
|
+
const INTERNAL_FLAGS = new Set([
|
|
38
|
+
'task-id', // MCP → `start`/`continue`: pins the child's task id
|
|
39
|
+
'run-id', // MCP → `council run`: pins the child's run id
|
|
40
|
+
'council-name', // MCP → `council run`: preset name for ledger attribution
|
|
41
|
+
'cowork-process', // MCP → `start`: Cowork process handle for context capture
|
|
42
|
+
|
|
43
|
+
// User-facing but undocumented, and read by real handlers today. Listed so the
|
|
44
|
+
// rejection is a bug fix and not a silent removal of working behaviour; if any
|
|
45
|
+
// of these should be dropped, that is a separate, deliberate change.
|
|
46
|
+
'briefing', // src/cli-handlers-resume-continue.js — alias for --prompt
|
|
47
|
+
'mode', // src/cli-handlers-run.js — legacy alias for --agent
|
|
48
|
+
'quiet', // src/cli-handlers.js — suppresses the interactive prompt
|
|
49
|
+
'help', // handled before dispatch; never reaches a usage block body
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every flag amicus accepts: usage-derived ∪ boolean flags ∪ INTERNAL_FLAGS.
|
|
54
|
+
*
|
|
55
|
+
* Computed on each call rather than cached at module load, because `getUsage()`
|
|
56
|
+
* is itself composed at call time and a cached copy would pin whatever the first
|
|
57
|
+
* caller happened to see.
|
|
58
|
+
*
|
|
59
|
+
* @returns {Set<string>} kebab-case flag names, without the leading `--`
|
|
60
|
+
*/
|
|
61
|
+
function getKnownFlags() {
|
|
62
|
+
// Required lazily: src/cli.js is the module that will consume this one, and a
|
|
63
|
+
// top-level require here would close a cycle.
|
|
64
|
+
const { getUsage, getBooleanFlags } = require('../cli');
|
|
65
|
+
const known = new Set(INTERNAL_FLAGS);
|
|
66
|
+
for (const m of getUsage().matchAll(/--([a-z][a-z0-9-]*)/gi)) {
|
|
67
|
+
known.add(m[1].toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
for (const f of getBooleanFlags()) { known.add(f); }
|
|
70
|
+
return known;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The unknown flags present on a parsed-argv object, in the order they were
|
|
75
|
+
* typed.
|
|
76
|
+
*
|
|
77
|
+
* Reads `__explicit` — the set parseArgs fills with every key it saw on the
|
|
78
|
+
* command line — so defaults (which are merged in from DEFAULTS and were never
|
|
79
|
+
* typed) are correctly ignored.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} parsed result of parseArgs()
|
|
82
|
+
* @returns {string[]} kebab-case flag names, without the leading `--`
|
|
83
|
+
*/
|
|
84
|
+
function unknownFlags(parsed) {
|
|
85
|
+
if (!parsed || !parsed.__explicit) { return []; }
|
|
86
|
+
const known = getKnownFlags();
|
|
87
|
+
return [...parsed.__explicit].filter(f => !known.has(String(f).toLowerCase()));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { getKnownFlags, unknownFlags, INTERNAL_FLAGS };
|