llm-cli-gateway 1.17.9 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -143,8 +143,8 @@ function loadSkills() {
143
143
  const loadedSkills = loadSkills();
144
144
  const SERVER_INSTRUCTIONS = `llm-cli-gateway: Multi-LLM orchestration via MCP.
145
145
 
146
- Tools: claude_request, codex_request, gemini_request, grok_request, mistral_request (sync) | *_request_async (async)
147
- Validation: validate_with_models, second_opinion, compare_answers, red_team_review, consensus_check, ask_model, synthesize_validation
146
+ Tools: claude_request, codex_request, gemini_request, grok_request, mistral_request (sync) | *_request_async (async) | codex_fork_session (fork a Codex session into a new branch)
147
+ Validation: validate_with_models, second_opinion, compare_answers, red_team_review, consensus_check, ask_model, synthesize_validation, list_available_models | job_status/job_result (validation jobs)
148
148
  Jobs: llm_job_status, llm_job_result, llm_job_cancel
149
149
  Sessions: session_create, session_list, session_set_active, session_get, session_delete, session_clear_all
150
150
  Other: list_models, cli_versions, upstream_contracts (use --probe-installed after CLI upgrades to detect drift), cli_upgrade, approval_list, llm_process_health, llm_request_result (read back any persisted request — sync or async — by correlationId)
@@ -159,7 +159,7 @@ Key behaviors:
159
159
  Skills (full docs via MCP resources):
160
160
  ${loadedSkills.map(s => `- skills://${s.name} — ${s.description}`).join("\n")}`;
161
161
  function newGatewayMcpServer() {
162
- return new McpServer({ name: "llm-cli-gateway", version: "1.0.0" }, { instructions: SERVER_INSTRUCTIONS });
162
+ return new McpServer({ name: "llm-cli-gateway", version: packageVersion() }, { instructions: SERVER_INSTRUCTIONS });
163
163
  }
164
164
  let sessionManager;
165
165
  let db = null;
@@ -1474,6 +1474,9 @@ export function prepareGrokRequest(params, runtime = resolveGatewayServerRuntime
1474
1474
  if (params.restoreCode) {
1475
1475
  args.push("--restore-code");
1476
1476
  }
1477
+ if (params.leaderSocket) {
1478
+ args.push("--leader-socket", params.leaderSocket);
1479
+ }
1477
1480
  if (params.nativeWorktree === true) {
1478
1481
  args.push("--worktree");
1479
1482
  }
@@ -1976,6 +1979,7 @@ export async function handleGrokRequest(deps, params) {
1976
1979
  noSubagents: params.noSubagents,
1977
1980
  oauth: params.oauth,
1978
1981
  restoreCode: params.restoreCode,
1982
+ leaderSocket: params.leaderSocket,
1979
1983
  nativeWorktree: params.nativeWorktree,
1980
1984
  }, runtime);
1981
1985
  if (!("args" in prep))
@@ -2133,6 +2137,7 @@ export async function handleGrokRequestAsync(deps, params) {
2133
2137
  noSubagents: params.noSubagents,
2134
2138
  oauth: params.oauth,
2135
2139
  restoreCode: params.restoreCode,
2140
+ leaderSocket: params.leaderSocket,
2136
2141
  nativeWorktree: params.nativeWorktree,
2137
2142
  }, runtime);
2138
2143
  if (!("args" in prep))
@@ -3488,12 +3493,17 @@ export function createGatewayServer(deps = {}) {
3488
3493
  .boolean()
3489
3494
  .optional()
3490
3495
  .describe("Grok --restore-code: check out the original session commit when resuming."),
3496
+ leaderSocket: z
3497
+ .string()
3498
+ .min(1)
3499
+ .optional()
3500
+ .describe("Grok 0.2.32+ --leader-socket <PATH>: custom leader socket path (default ~/.grok/leader.sock). Targets an isolated leader process, e.g. a local/branch Grok build; name it ~/.grok/leader-*.sock to keep `grok leader list/kill` discovery working."),
3491
3501
  nativeWorktree: z
3492
3502
  .union([z.boolean(), z.string().min(1)])
3493
3503
  .optional()
3494
3504
  .describe("Grok -w/--worktree: native CLI worktree flag (`true` → bare `--worktree`, string → named). NOT gateway slice λ `worktree`."),
3495
3505
  worktree: WORKTREE_SCHEMA.optional(),
3496
- }, async ({ prompt, promptParts, model, outputFormat, sessionId, resumeLatest, createNewSession, alwaysApprove, permissionMode, effort, reasoningEffort, approvalStrategy, approvalPolicy, mcpServers, allowedTools, disallowedTools, correlationId, optimizePrompt, optimizeResponse, idleTimeoutMs, forceRefresh, maxTurns, workingDir, sandbox, rules, systemPromptOverride, allow, deny, compactionMode, compactionDetail, agent, bestOfN, check, disableWebSearch, todoGate, verbatim, agents, promptFile, promptJson, single, experimentalMemory, noAltScreen, noMemory, noPlan, noSubagents, oauth, restoreCode, nativeWorktree, worktree, }) => {
3506
+ }, async ({ prompt, promptParts, model, outputFormat, sessionId, resumeLatest, createNewSession, alwaysApprove, permissionMode, effort, reasoningEffort, approvalStrategy, approvalPolicy, mcpServers, allowedTools, disallowedTools, correlationId, optimizePrompt, optimizeResponse, idleTimeoutMs, forceRefresh, maxTurns, workingDir, sandbox, rules, systemPromptOverride, allow, deny, compactionMode, compactionDetail, agent, bestOfN, check, disableWebSearch, todoGate, verbatim, agents, promptFile, promptJson, single, experimentalMemory, noAltScreen, noMemory, noPlan, noSubagents, oauth, restoreCode, leaderSocket, nativeWorktree, worktree, }) => {
3497
3507
  return handleGrokRequest({ sessionManager, logger, runtime }, {
3498
3508
  prompt,
3499
3509
  promptParts,
@@ -3542,6 +3552,7 @@ export function createGatewayServer(deps = {}) {
3542
3552
  noSubagents,
3543
3553
  oauth,
3544
3554
  restoreCode,
3555
+ leaderSocket,
3545
3556
  nativeWorktree,
3546
3557
  worktree,
3547
3558
  });
@@ -4298,12 +4309,17 @@ export function createGatewayServer(deps = {}) {
4298
4309
  .boolean()
4299
4310
  .optional()
4300
4311
  .describe("Grok --restore-code: check out the original session commit when resuming."),
4312
+ leaderSocket: z
4313
+ .string()
4314
+ .min(1)
4315
+ .optional()
4316
+ .describe("Grok 0.2.32+ --leader-socket <PATH>: custom leader socket path (default ~/.grok/leader.sock). Targets an isolated leader process, e.g. a local/branch Grok build; name it ~/.grok/leader-*.sock to keep `grok leader list/kill` discovery working."),
4301
4317
  nativeWorktree: z
4302
4318
  .union([z.boolean(), z.string().min(1)])
4303
4319
  .optional()
4304
4320
  .describe("Grok -w/--worktree: native CLI worktree flag (`true` → bare `--worktree`, string → named). NOT gateway slice λ `worktree`."),
4305
4321
  worktree: WORKTREE_SCHEMA.optional(),
4306
- }, async ({ prompt, promptParts, model, outputFormat, sessionId, resumeLatest, createNewSession, alwaysApprove, permissionMode, effort, reasoningEffort, approvalStrategy, approvalPolicy, mcpServers, allowedTools, disallowedTools, correlationId, optimizePrompt, idleTimeoutMs, forceRefresh, maxTurns, workingDir, sandbox, rules, systemPromptOverride, allow, deny, compactionMode, compactionDetail, agent, bestOfN, check, disableWebSearch, todoGate, verbatim, agents, promptFile, promptJson, single, experimentalMemory, noAltScreen, noMemory, noPlan, noSubagents, oauth, restoreCode, nativeWorktree, worktree, }) => {
4322
+ }, async ({ prompt, promptParts, model, outputFormat, sessionId, resumeLatest, createNewSession, alwaysApprove, permissionMode, effort, reasoningEffort, approvalStrategy, approvalPolicy, mcpServers, allowedTools, disallowedTools, correlationId, optimizePrompt, idleTimeoutMs, forceRefresh, maxTurns, workingDir, sandbox, rules, systemPromptOverride, allow, deny, compactionMode, compactionDetail, agent, bestOfN, check, disableWebSearch, todoGate, verbatim, agents, promptFile, promptJson, single, experimentalMemory, noAltScreen, noMemory, noPlan, noSubagents, oauth, restoreCode, leaderSocket, nativeWorktree, worktree, }) => {
4307
4323
  return handleGrokRequestAsync({ sessionManager, asyncJobManager, logger, runtime }, {
4308
4324
  prompt,
4309
4325
  promptParts,
@@ -4351,6 +4367,7 @@ export function createGatewayServer(deps = {}) {
4351
4367
  noSubagents,
4352
4368
  oauth,
4353
4369
  restoreCode,
4370
+ leaderSocket,
4354
4371
  nativeWorktree,
4355
4372
  worktree,
4356
4373
  });
package/dist/job-store.js CHANGED
@@ -1,8 +1,8 @@
1
- import { chmodSync, existsSync, mkdirSync } from "fs";
1
+ import { chmodSync } from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { createHash } from "crypto";
5
- import { createRequire } from "module";
5
+ import { openDatabase } from "./sqlite-driver.js";
6
6
  import { noopLogger } from "./logger.js";
7
7
  export function resolveJobStoreDbPath() {
8
8
  const configured = process.env.LLM_GATEWAY_JOBS_DB ?? process.env.LLM_GATEWAY_LOGS_DB;
@@ -74,13 +74,7 @@ export class SqliteJobStore {
74
74
  deleteExpiredStmt;
75
75
  constructor(dbPath, logger = noopLogger, options = {}) {
76
76
  this.logger = logger;
77
- const require = createRequire(import.meta.url);
78
- const BetterSqlite3 = require("better-sqlite3");
79
- const directory = path.dirname(dbPath);
80
- if (!existsSync(directory)) {
81
- mkdirSync(directory, { recursive: true });
82
- }
83
- this.db = new BetterSqlite3(dbPath);
77
+ this.db = openDatabase(dbPath);
84
78
  this.db.exec("PRAGMA journal_mode = WAL");
85
79
  this.db.exec("PRAGMA synchronous = NORMAL");
86
80
  this.db.exec(`
@@ -211,7 +205,7 @@ export class SqliteJobStore {
211
205
  markOrphanedOnStartup() {
212
206
  const now = new Date().toISOString();
213
207
  const expiresAt = new Date(Date.now() + this.retentionMs).toISOString();
214
- const rows = (this.selectRunningOrphansStmt.all?.() ?? []);
208
+ const rows = this.selectRunningOrphansStmt.all();
215
209
  const orphaned = rows.map(row => ({
216
210
  id: row.id,
217
211
  correlationId: row.correlation_id,
@@ -221,12 +215,12 @@ export class SqliteJobStore {
221
215
  exitCode: row.exit_code,
222
216
  }));
223
217
  const result = this.markOrphanedStmt.run(now, expiresAt);
224
- return { count: result?.changes ?? 0, orphaned };
218
+ return { count: Number(result.changes), orphaned };
225
219
  }
226
220
  evictExpired() {
227
221
  const now = new Date().toISOString();
228
222
  const result = this.deleteExpiredStmt.run(now);
229
- return result?.changes ?? 0;
223
+ return Number(result.changes);
230
224
  }
231
225
  close() {
232
226
  try {
@@ -0,0 +1,16 @@
1
+ export interface GatewayStatement {
2
+ run(...args: unknown[]): {
3
+ changes: number;
4
+ lastInsertRowid: number | bigint;
5
+ };
6
+ get(...args: unknown[]): unknown;
7
+ all(...args: unknown[]): unknown[];
8
+ }
9
+ export interface GatewayDatabase {
10
+ exec(sql: string): void;
11
+ prepare(sql: string): GatewayStatement;
12
+ withTransaction<A extends unknown[]>(fn: (...args: A) => void): (...args: A) => void;
13
+ close(): void;
14
+ }
15
+ export declare function openDatabase(dbPath: string): GatewayDatabase;
16
+ export declare function openReadOnly(dbPath: string): GatewayDatabase;
@@ -0,0 +1,149 @@
1
+ import { existsSync, mkdirSync } from "fs";
2
+ import path from "path";
3
+ import { createRequire } from "module";
4
+ function loadNodeSqlite() {
5
+ const require = createRequire(import.meta.url);
6
+ return require("node:sqlite");
7
+ }
8
+ function wrapStatement(stmt) {
9
+ return {
10
+ run(...args) {
11
+ return stmt.run(...args);
12
+ },
13
+ get(...args) {
14
+ return stmt.get(...args);
15
+ },
16
+ all(...args) {
17
+ return stmt.all(...args);
18
+ },
19
+ };
20
+ }
21
+ function statementLeadingKeywords(sql) {
22
+ const keywords = [];
23
+ let i = 0;
24
+ const skipTrivia = () => {
25
+ for (;;) {
26
+ while (i < sql.length && /\s|;/.test(sql[i] ?? ""))
27
+ i++;
28
+ if (sql.startsWith("--", i)) {
29
+ i += 2;
30
+ while (i < sql.length && sql[i] !== "\n")
31
+ i++;
32
+ continue;
33
+ }
34
+ if (sql.startsWith("/*", i)) {
35
+ const end = sql.indexOf("*/", i + 2);
36
+ i = end === -1 ? sql.length : end + 2;
37
+ continue;
38
+ }
39
+ break;
40
+ }
41
+ };
42
+ const skipQuoted = (quote) => {
43
+ i++;
44
+ while (i < sql.length) {
45
+ if (sql[i] === quote) {
46
+ if (sql[i + 1] === quote) {
47
+ i += 2;
48
+ continue;
49
+ }
50
+ i++;
51
+ return;
52
+ }
53
+ i++;
54
+ }
55
+ };
56
+ while (i < sql.length) {
57
+ skipTrivia();
58
+ const m = /^[a-zA-Z]+/.exec(sql.slice(i));
59
+ if (m) {
60
+ keywords.push(m[0].toUpperCase());
61
+ }
62
+ while (i < sql.length && sql[i] !== ";") {
63
+ if (sql.startsWith("--", i)) {
64
+ i += 2;
65
+ while (i < sql.length && sql[i] !== "\n")
66
+ i++;
67
+ }
68
+ else if (sql.startsWith("/*", i)) {
69
+ const end = sql.indexOf("*/", i + 2);
70
+ i = end === -1 ? sql.length : end + 2;
71
+ }
72
+ else if (sql[i] === "'" || sql[i] === '"' || sql[i] === "`") {
73
+ skipQuoted(sql[i]);
74
+ }
75
+ else if (sql[i] === "[") {
76
+ i++;
77
+ while (i < sql.length && sql[i] !== "]")
78
+ i++;
79
+ if (i < sql.length)
80
+ i++;
81
+ }
82
+ else {
83
+ i++;
84
+ }
85
+ }
86
+ }
87
+ return keywords;
88
+ }
89
+ class GatewayDatabaseImpl {
90
+ db;
91
+ readOnly;
92
+ inTransaction = false;
93
+ constructor(db, readOnly = false) {
94
+ this.db = db;
95
+ this.readOnly = readOnly;
96
+ }
97
+ guardReadOnly(sql) {
98
+ if (this.readOnly && statementLeadingKeywords(sql).includes("VACUUM")) {
99
+ throw new Error("read-only connection rejects VACUUM (writes to disk despite readOnly)");
100
+ }
101
+ }
102
+ exec(sql) {
103
+ this.guardReadOnly(sql);
104
+ this.db.exec(sql);
105
+ }
106
+ prepare(sql) {
107
+ this.guardReadOnly(sql);
108
+ return wrapStatement(this.db.prepare(sql));
109
+ }
110
+ withTransaction(fn) {
111
+ return (...args) => {
112
+ if (this.inTransaction) {
113
+ throw new Error("nested transaction");
114
+ }
115
+ this.db.exec("BEGIN");
116
+ this.inTransaction = true;
117
+ try {
118
+ fn(...args);
119
+ this.db.exec("COMMIT");
120
+ }
121
+ catch (error) {
122
+ try {
123
+ this.db.exec("ROLLBACK");
124
+ }
125
+ catch {
126
+ }
127
+ throw error;
128
+ }
129
+ finally {
130
+ this.inTransaction = false;
131
+ }
132
+ };
133
+ }
134
+ close() {
135
+ this.db.close();
136
+ }
137
+ }
138
+ export function openDatabase(dbPath) {
139
+ const { DatabaseSync } = loadNodeSqlite();
140
+ const directory = path.dirname(dbPath);
141
+ if (!existsSync(directory)) {
142
+ mkdirSync(directory, { recursive: true });
143
+ }
144
+ return new GatewayDatabaseImpl(new DatabaseSync(dbPath));
145
+ }
146
+ export function openReadOnly(dbPath) {
147
+ const { DatabaseSync } = loadNodeSqlite();
148
+ return new GatewayDatabaseImpl(new DatabaseSync(dbPath, { readOnly: true }), true);
149
+ }
@@ -5,6 +5,7 @@ export interface CliFlagContract {
5
5
  values?: readonly string[];
6
6
  pattern?: RegExp;
7
7
  description: string;
8
+ hiddenFromHelp?: boolean;
8
9
  }
9
10
  export interface CliUpstreamMetadata {
10
11
  sourceUrls: readonly string[];
@@ -32,6 +33,7 @@ export interface CliContract {
32
33
  resumeMaxPositionals?: number;
33
34
  resumeOnlyFlags?: readonly string[];
34
35
  resumeForbiddenFlags?: readonly string[];
36
+ acknowledgedUpstreamFlags?: readonly string[];
35
37
  upstreamMetadata?: CliUpstreamMetadata;
36
38
  }
37
39
  export interface CliContractFixture {
@@ -57,6 +59,13 @@ export declare function assertUpstreamCliArgs(cli: CliType, args: readonly strin
57
59
  export declare function validateUpstreamCliEnv(cli: CliType, env: Record<string, string> | undefined): ContractValidationResult;
58
60
  export declare function assertUpstreamCliEnv(cli: CliType, env: Record<string, string> | undefined): void;
59
61
  export declare function extractDiscoveredFlags(helpText: string): readonly string[];
62
+ export interface FlagDriftResult {
63
+ missingFlags: string[];
64
+ extraFlags: readonly string[];
65
+ acknowledgedExtraFlags: readonly string[];
66
+ warnings: string[];
67
+ }
68
+ export declare function computeFlagDrift(contract: CliContract, helpText: string, discoveredFlags: readonly string[]): FlagDriftResult;
60
69
  export interface InstalledCliContractProbe {
61
70
  cli: CliType;
62
71
  executable: string;
@@ -66,6 +75,7 @@ export interface InstalledCliContractProbe {
66
75
  checkedHelpCommands: string[][];
67
76
  missingFlags: string[];
68
77
  extraFlags: readonly string[];
78
+ acknowledgedExtraFlags: readonly string[];
69
79
  discoveredFlags: readonly string[];
70
80
  helpHash?: string;
71
81
  versionHint?: string;
@@ -99,7 +99,12 @@ export const UPSTREAM_CLI_CONTRACTS = {
99
99
  pattern: /^[0-9]+(?:\.[0-9]+)?$/,
100
100
  description: "Budget cap in USD",
101
101
  },
102
- "--max-turns": { arity: "one", pattern: /^[1-9][0-9]*$/, description: "Turn cap" },
102
+ "--max-turns": {
103
+ arity: "one",
104
+ pattern: /^[1-9][0-9]*$/,
105
+ description: "Turn cap",
106
+ hiddenFromHelp: true,
107
+ },
103
108
  "--effort": { arity: "one", values: EFFORT_LEVELS, description: "Reasoning effort" },
104
109
  "--exclude-dynamic-system-prompt-sections": {
105
110
  arity: "none",
@@ -136,6 +141,37 @@ export const UPSTREAM_CLI_CONTRACTS = {
136
141
  description: 'Restrict the available built-in tool set ("" disables all)',
137
142
  },
138
143
  },
144
+ acknowledgedUpstreamFlags: [
145
+ "--allow-dangerously-skip-permissions",
146
+ "--allowed",
147
+ "--bare",
148
+ "--betas",
149
+ "--brief",
150
+ "--chrome",
151
+ "--dangerously-skip-permissions",
152
+ "--debug",
153
+ "--debug-file",
154
+ "--disable-slash-commands",
155
+ "--disallowed",
156
+ "--file",
157
+ "--from-pr",
158
+ "--ide",
159
+ "--include-hook-events",
160
+ "--mcp-debug",
161
+ "--name",
162
+ "--no-chrome",
163
+ "--plugin-dir",
164
+ "--plugin-url",
165
+ "--print",
166
+ "--prompt-suggestions",
167
+ "--remote-control",
168
+ "--remote-control-session-name-prefix",
169
+ "--replay-user-messages",
170
+ "--resume",
171
+ "--tmux",
172
+ "--version",
173
+ "--worktree",
174
+ ],
139
175
  env: {},
140
176
  conformanceFixtures: [
141
177
  {
@@ -518,6 +554,26 @@ export const UPSTREAM_CLI_CONTRACTS = {
518
554
  description: "Auto-approve all actions (gemini -y/--yolo). Functionally equivalent to --approval-mode yolo; the gateway emits at most one of the two.",
519
555
  },
520
556
  },
557
+ acknowledgedUpstreamFlags: [
558
+ "--accept-raw-output-risk",
559
+ "--acp",
560
+ "--debug",
561
+ "--delete-session",
562
+ "--experimental-acp",
563
+ "--extensions",
564
+ "--list-extensions",
565
+ "--list-sessions",
566
+ "--output-format",
567
+ "--prompt",
568
+ "--prompt-interactive",
569
+ "--raw-output",
570
+ "--sandbox",
571
+ "--screen-reader",
572
+ "--session-file",
573
+ "--session-id",
574
+ "--version",
575
+ "--worktree",
576
+ ],
521
577
  env: {},
522
578
  conformanceFixtures: [
523
579
  {
@@ -612,6 +668,7 @@ export const UPSTREAM_CLI_CONTRACTS = {
612
668
  "noSubagents",
613
669
  "oauth",
614
670
  "restoreCode",
671
+ "leaderSocket",
615
672
  "nativeWorktree",
616
673
  ],
617
674
  flags: {
@@ -693,6 +750,10 @@ export const UPSTREAM_CLI_CONTRACTS = {
693
750
  arity: "none",
694
751
  description: "Check out the original session commit when resuming",
695
752
  },
753
+ "--leader-socket": {
754
+ arity: "one",
755
+ description: "Custom leader socket path (isolated leader, Grok 0.2.32+)",
756
+ },
696
757
  "--single": { arity: "one", description: "Single-turn prompt" },
697
758
  "--todo-gate": { arity: "none", description: "Enable runtime turn-end TodoGate" },
698
759
  "--verbatim": { arity: "none", description: "Send prompt exactly as given" },
@@ -843,6 +904,18 @@ export const UPSTREAM_CLI_CONTRACTS = {
843
904
  ],
844
905
  expect: "pass",
845
906
  },
907
+ {
908
+ id: "grok-leader-socket",
909
+ description: "Grok 0.2.32: --leader-socket <PATH> is accepted",
910
+ args: ["-p", "hello", "--leader-socket", "/home/user/.grok/leader-branch.sock"],
911
+ expect: "pass",
912
+ },
913
+ {
914
+ id: "grok-leader-socket-missing-value",
915
+ description: "Grok 0.2.32: --leader-socket without a path is rejected (arity one)",
916
+ args: ["-p", "hello", "--leader-socket"],
917
+ expect: "fail",
918
+ },
846
919
  ],
847
920
  },
848
921
  mistral: {
@@ -1220,6 +1293,42 @@ export function extractDiscoveredFlags(helpText) {
1220
1293
  }
1221
1294
  return Array.from(discovered).sort();
1222
1295
  }
1296
+ export function computeFlagDrift(contract, helpText, discoveredFlags) {
1297
+ const warnings = [];
1298
+ const missingFlags = [];
1299
+ for (const [flag, spec] of Object.entries(contract.flags)) {
1300
+ const inHelp = helpText.includes(flag);
1301
+ if (spec.hiddenFromHelp) {
1302
+ if (inHelp) {
1303
+ warnings.push(`${flag} is marked hiddenFromHelp but now appears in ${contract.executable} help output; remove the hiddenFromHelp marker from the contract`);
1304
+ }
1305
+ continue;
1306
+ }
1307
+ if (!inHelp)
1308
+ missingFlags.push(flag);
1309
+ }
1310
+ const contractFlagSet = new Set(Object.keys(contract.flags));
1311
+ const acknowledged = new Set(contract.acknowledgedUpstreamFlags ?? []);
1312
+ const extraFlags = [];
1313
+ const acknowledgedExtraFlags = [];
1314
+ for (const flag of discoveredFlags) {
1315
+ if (contractFlagSet.has(flag))
1316
+ continue;
1317
+ if (acknowledged.has(flag)) {
1318
+ acknowledgedExtraFlags.push(flag);
1319
+ }
1320
+ else {
1321
+ extraFlags.push(flag);
1322
+ }
1323
+ }
1324
+ const discoveredSet = new Set(discoveredFlags);
1325
+ for (const flag of acknowledged) {
1326
+ if (!discoveredSet.has(flag)) {
1327
+ warnings.push(`acknowledged upstream flag ${flag} no longer appears in ${contract.executable} help output; remove it from acknowledgedUpstreamFlags`);
1328
+ }
1329
+ }
1330
+ return { missingFlags, extraFlags, acknowledgedExtraFlags, warnings };
1331
+ }
1223
1332
  export function probeInstalledCliContract(cli, timeoutMs = 5_000) {
1224
1333
  const contract = UPSTREAM_CLI_CONTRACTS[cli];
1225
1334
  const outputs = [];
@@ -1252,6 +1361,7 @@ export function probeInstalledCliContract(cli, timeoutMs = 5_000) {
1252
1361
  checkedHelpCommands: contract.helpArgs,
1253
1362
  missingFlags: [],
1254
1363
  extraFlags: [],
1364
+ acknowledgedExtraFlags: [],
1255
1365
  discoveredFlags: [],
1256
1366
  helpHash: undefined,
1257
1367
  versionHint: undefined,
@@ -1265,10 +1375,9 @@ export function probeInstalledCliContract(cli, timeoutMs = 5_000) {
1265
1375
  }
1266
1376
  }
1267
1377
  const helpText = outputs.join("\n");
1268
- const missingFlags = Object.keys(contract.flags).filter(flag => !helpText.includes(flag));
1269
1378
  const discoveredFlags = extractDiscoveredFlags(helpText);
1270
- const contractFlagSet = new Set(Object.keys(contract.flags));
1271
- const extraFlags = discoveredFlags.filter(f => !contractFlagSet.has(f));
1379
+ const drift = computeFlagDrift(contract, helpText, discoveredFlags);
1380
+ warnings.push(...drift.warnings);
1272
1381
  const versionMatch = helpText.match(/^\s*(?:[A-Za-z][\w .-]+)?v?\d+\.\d+\S*/m);
1273
1382
  const versionHint = versionMatch ? versionMatch[0].trim().slice(0, 80) : undefined;
1274
1383
  const helpHash = createHash("sha256").update(helpText).digest("hex");
@@ -1279,8 +1388,9 @@ export function probeInstalledCliContract(cli, timeoutMs = 5_000) {
1279
1388
  resolvedArgs,
1280
1389
  available: true,
1281
1390
  checkedHelpCommands: contract.helpArgs,
1282
- missingFlags,
1283
- extraFlags,
1391
+ missingFlags: drift.missingFlags,
1392
+ extraFlags: drift.extraFlags,
1393
+ acknowledgedExtraFlags: drift.acknowledgedExtraFlags,
1284
1394
  discoveredFlags,
1285
1395
  helpHash,
1286
1396
  versionHint,