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.
@@ -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": ["schemaVersion", "type", "runId", "council", "overallVerdict", "findings", "streetCred", "runStats", "tierCounts"],
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": { "const": 2 },
10
- "type": { "const": "council-verdict" },
11
- "runId": { "type": "string" },
12
- "runType": { "type": ["string", "null"] },
13
- "date": { "type": ["string", "null"] },
14
- "chair": { "type": ["string", "null"] },
15
- "council": { "type": "array", "items": { "type": "string" } },
16
- "claudeInCouncil": { "type": "boolean" },
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
- { "enum": ["Ship it", "Fix these first", "Fundamental rethink"] },
20
- { "type": "null" }
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": ["id", "tier", "decision", "applied"],
73
+ "required": [
74
+ "id",
75
+ "tier",
76
+ "decision",
77
+ "applied"
78
+ ],
28
79
  "properties": {
29
- "id": { "type": "string" },
30
- "raiser": { "type": ["string", "null"] },
31
- "severity": { "type": ["string", "null"] },
32
- "tier": { "enum": ["Confirmed", "Contested", "Singleton", "Disputed"] },
33
- "basis": { "type": "object" },
34
- "confidence": { "enum": ["thin", "solid"] },
35
- "tierOverride": { "type": ["object", "null"] },
36
- "duplicateOf": { "type": ["string", "null"] },
37
- "adjudications": { "type": "array" },
38
- "decision": { "type": ["string", "null"] },
39
- "applied": { "type": "boolean" },
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": { "enum": ["defended", "amended", "withdrawn", "no-response"] },
44
- "previousTier": { "type": ["string", "null"] }
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": ["action"],
154
+ "required": [
155
+ "action"
156
+ ],
47
157
  "additionalProperties": false
48
158
  }
49
159
  }
50
160
  }
51
161
  },
52
- "streetCred": { "type": "array", "items": { "type": "object" } },
53
- "runStats": { "type": "array", "items": { "type": "object" } },
54
- "tierCounts": { "type": "object" }
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
- * Check if a flag is boolean (doesn't take a value)
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
- function isBooleanFlag(key) {
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
- return booleanFlags.includes(key);
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
- const verdict = buildVerdict(record, []);
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' });
@@ -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);
@@ -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 = { buildVerdict, readOverallVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION };
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
@@ -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
- const createOpencodeServer = await getCreateOpencodeServer();
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 client = await createClient(sdkServer.url);
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).
@@ -173,7 +173,22 @@ async function robustExtract(zip, opts = {}) {
173
173
  deps = {},
174
174
  } = opts;
175
175
  const fs = deps.fs || fsDefault;
176
- const extractZip = deps.extractZip || require('extract-zip');
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 };