auditor-lambda 0.3.26 → 0.3.28

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.
@@ -399,21 +399,122 @@ async function writeGeneratedMarkdown(targetPath, content) {
399
399
  };
400
400
  }
401
401
 
402
- async function removeGeneratedMarkdownIfMatches(targetPath, expectedContent) {
403
- const existing = await readTextIfExists(targetPath);
404
- if (existing === null) {
405
- return null;
402
+ function looksLikeAuditCodeSkill(content) {
403
+ const normalized = normalizeNewlines(content);
404
+ return (
405
+ /^name:\s*audit-code\b/mu.test(normalized)
406
+ || normalized.includes('Conversation-first autonomous code auditing workflow for the /audit-code command.')
407
+ || normalized.includes('The canonical entrypoint is `/audit-code` in conversation.')
408
+ );
409
+ }
410
+
411
+ function looksLikeAuditCodePrompt(content) {
412
+ const normalized = normalizeNewlines(content);
413
+ return (
414
+ normalized.includes('# `/audit-code`')
415
+ && (
416
+ normalized.includes('audit-code orchestrator')
417
+ || normalized.includes('Autonomous local loop code auditing')
418
+ || normalized.includes('Conversation-first autonomous code auditing workflow')
419
+ )
420
+ );
421
+ }
422
+
423
+ function looksLikeAuditCodeInterfaceMetadata(content) {
424
+ const normalized = normalizeNewlines(content);
425
+ return (
426
+ normalized.includes('audit-code')
427
+ && (
428
+ normalized.includes('display_name:')
429
+ || normalized.includes('short_description:')
430
+ || normalized.includes('default_prompt:')
431
+ )
432
+ && (
433
+ normalized.includes('/audit-code')
434
+ || normalized.includes('Start /audit-code')
435
+ )
436
+ );
437
+ }
438
+
439
+ async function buildLegacyAuditCodeSurfaceTargets(root) {
440
+ const targets = [
441
+ {
442
+ host: 'codex',
443
+ surface: 'skill',
444
+ path: join(root, '.codex', 'skills', 'audit-code', 'SKILL.md'),
445
+ matches: looksLikeAuditCodeSkill,
446
+ },
447
+ {
448
+ host: 'codex',
449
+ surface: 'prompt',
450
+ path: join(root, '.codex', 'skills', 'audit-code', 'audit-code.prompt.md'),
451
+ matches: looksLikeAuditCodePrompt,
452
+ },
453
+ {
454
+ host: 'opencode',
455
+ surface: 'command',
456
+ path: join(root, '.opencode', 'commands', 'audit-code.md'),
457
+ matches: looksLikeAuditCodePrompt,
458
+ },
459
+ {
460
+ host: 'opencode',
461
+ surface: 'skill',
462
+ path: join(root, '.opencode', 'skills', 'audit-code', 'SKILL.md'),
463
+ matches: looksLikeAuditCodeSkill,
464
+ },
465
+ {
466
+ host: 'opencode',
467
+ surface: 'prompt',
468
+ path: join(root, '.opencode', 'skills', 'audit-code', 'audit-code.prompt.md'),
469
+ matches: looksLikeAuditCodePrompt,
470
+ },
471
+ {
472
+ host: 'claude',
473
+ surface: 'command',
474
+ path: join(root, '.claude', 'commands', 'audit-code.md'),
475
+ matches: looksLikeAuditCodePrompt,
476
+ },
477
+ ];
478
+
479
+ const codexAgentDir = join(root, '.codex', 'skills', 'audit-code', 'agents');
480
+ const codexAgentEntries = await readdir(codexAgentDir).catch(() => []);
481
+ for (const entry of codexAgentEntries) {
482
+ targets.push({
483
+ host: 'codex',
484
+ surface: 'interface-metadata',
485
+ path: join(codexAgentDir, entry),
486
+ matches: looksLikeAuditCodeInterfaceMetadata,
487
+ });
406
488
  }
407
489
 
408
- if (normalizeNewlines(existing) !== normalizeNewlines(expectedContent)) {
409
- return null;
490
+ return targets;
491
+ }
492
+
493
+ async function findLegacyAuditCodeSurfaceFiles(root) {
494
+ const matches = [];
495
+ for (const target of await buildLegacyAuditCodeSurfaceTargets(root)) {
496
+ const existing = await readTextIfExists(target.path);
497
+ if (existing !== null && target.matches(existing)) {
498
+ matches.push(target.path);
499
+ }
410
500
  }
501
+ return matches;
502
+ }
411
503
 
412
- await unlink(targetPath);
413
- return {
414
- path: targetPath,
415
- mode: 'removed',
416
- };
504
+ async function removeLegacyAuditCodeSurfaceFiles(root) {
505
+ const removed = [];
506
+ for (const target of await buildLegacyAuditCodeSurfaceTargets(root)) {
507
+ const existing = await readTextIfExists(target.path);
508
+ if (existing === null || !target.matches(existing)) {
509
+ continue;
510
+ }
511
+ await unlink(target.path);
512
+ removed.push({
513
+ path: target.path,
514
+ mode: 'removed',
515
+ });
516
+ }
517
+ return removed;
417
518
  }
418
519
 
419
520
  async function writeGeneratedJson(targetPath, value) {
@@ -608,9 +709,7 @@ const OPENCODE_MCP_COMMAND_TEMPLATE = [
608
709
  'Use the auditor MCP tools as the primary interface to the audit workflow.',
609
710
  '',
610
711
  '1. Call `auditor_start_audit` to initialize and receive the first step.',
611
- '2. Check `step_kind` in the response:',
612
- ' - If `step_kind` is `"capability_check"`: immediately call `auditor_report_capability` with `can_dispatch_subagents: true` and `can_select_subagent_model: true`. Do not run shell commands or inspect prompt_content for this step.',
613
- ' - Otherwise: read `prompt_content` and follow it.',
712
+ '2. Read `prompt_content` in the response and follow it.',
614
713
  '3. When a step completes (not blocked), call `auditor_continue_audit` to advance.',
615
714
  '4. Stop when the step instructions say to stop.',
616
715
  '',
@@ -620,18 +719,9 @@ const OPENCODE_MCP_COMMAND_TEMPLATE = [
620
719
  ].join('\n');
621
720
 
622
721
  function renderOpenCodeProjectConfig(_root) {
623
- const launcher = `.audit-code/install/${MCP_LAUNCHER_FILENAME}`;
624
722
  const auditPermission = renderOpenCodePermissionConfig();
625
723
  return {
626
724
  $schema: 'https://opencode.ai/config.json',
627
- mcp: {
628
- auditor: {
629
- type: 'local',
630
- command: ['node', launcher],
631
- enabled: true,
632
- timeout: 10000,
633
- },
634
- },
635
725
  permission: auditPermission,
636
726
  agent: {
637
727
  auditor: {
@@ -803,14 +893,13 @@ function assertOpenCodeAuditPermissionConfig(permissionConfig, label) {
803
893
 
804
894
  function buildMergedOpenCodeProjectConfig(existing, root) {
805
895
  const generated = renderOpenCodeProjectConfig(root);
896
+ const mergedMcp = objectValue(existing.mcp);
897
+ delete mergedMcp.auditor;
806
898
  return {
807
899
  ...existing,
808
900
  $schema: existing.$schema ?? generated.$schema,
809
901
  command: removeManagedOpenCodeCommand(existing.command),
810
- mcp: {
811
- ...objectValue(existing.mcp),
812
- auditor: generated.mcp.auditor,
813
- },
902
+ mcp: mergedMcp,
814
903
  permission: {
815
904
  ...mergeOpenCodePermissionConfig(existing.permission, generated.permission),
816
905
  external_directory: { '*': 'allow' },
@@ -907,6 +996,21 @@ function renderAntigravityPlanningGuide(root) {
907
996
  ].join('\n');
908
997
  }
909
998
 
999
+ function renderGeminiCommandToml(promptBody) {
1000
+ const escapedBody = promptBody.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1001
+ return [
1002
+ '# /audit-code \u2014 Autonomous local-loop code auditing',
1003
+ '# Registered as a Gemini/Antigravity slash command.',
1004
+ '',
1005
+ 'description = "Autonomous local-loop code auditing \u2014 loads one backend-rendered audit step at a time"',
1006
+ '',
1007
+ 'prompt = """',
1008
+ promptBody.trimEnd(),
1009
+ '"""',
1010
+ '',
1011
+ ].join('\n');
1012
+ }
1013
+
910
1014
  function renderSharedMcpLauncher(sourcePackageRoot) {
911
1015
  return [
912
1016
  "import { access, readFile } from 'node:fs/promises';",
@@ -1339,19 +1443,20 @@ const INSTALL_HOST_DEFINITIONS = {
1339
1443
  antigravity: {
1340
1444
  host: 'antigravity',
1341
1445
  label: 'Antigravity',
1342
- support_level: 'guided',
1343
- setup_kind: 'planning-guide+mcp-ready',
1446
+ support_level: 'supported',
1447
+ setup_kind: 'gemini-command+planning-guide+mcp-ready',
1344
1448
  summary:
1345
- 'Start in Planning mode with the generated guide and AGENTS instructions, then use the shared MCP launcher once Antigravity is ready to call structured tools.',
1346
- primary_path_key: 'antigravityPlanningGuidePath',
1449
+ 'Use the repo-local .gemini/commands/audit-code.toml slash command, the planning guide, and AGENTS instructions. The shared MCP launcher is available for structured tool calls.',
1450
+ primary_path_key: 'geminiCommandPath',
1347
1451
  supporting_path_keys: [
1452
+ 'antigravityPlanningGuidePath',
1348
1453
  'agentsInstructionsPath',
1349
1454
  'mcpLauncherPath',
1350
1455
  'installedPromptPath',
1351
1456
  ],
1352
1457
  steps: [
1353
- 'Open this repository in Antigravity Planning mode.',
1354
- 'Load the generated planning guide and AGENTS instructions before starting the audit.',
1458
+ 'Open this repository in Antigravity.',
1459
+ 'The /audit-code slash command is automatically discovered from .gemini/commands/audit-code.toml.',
1355
1460
  'Use the shared auditor MCP server when Antigravity needs structured audit state instead of free-form shell guesses.',
1356
1461
  ],
1357
1462
  profile: {
@@ -1857,6 +1962,18 @@ async function verifyInstalledBootstrap(argv) {
1857
1962
  };
1858
1963
  });
1859
1964
 
1965
+ await collectVerifyCheck(generalChecks, 'legacy_local_surfaces', async () => {
1966
+ const legacySurfaces = await findLegacyAuditCodeSurfaceFiles(root);
1967
+ if (legacySurfaces.length > 0) {
1968
+ throw new Error(
1969
+ `Legacy local /audit-code surfaces are still present: ${legacySurfaces.join(', ')}. Run "audit-code install" from ${root}.`,
1970
+ );
1971
+ }
1972
+ return {
1973
+ summary: 'No legacy local /audit-code command or skill surfaces were found.',
1974
+ };
1975
+ });
1976
+
1860
1977
  await collectVerifyCheck(generalChecks, 'shared_launcher_file', async () => {
1861
1978
  const launcher = await readFile(assetPaths.mcpLauncherPath, 'utf8');
1862
1979
  if (!launcher.includes('Unable to locate an audit-code executable')) {
@@ -2005,23 +2122,16 @@ async function verifyInstalledBootstrap(argv) {
2005
2122
  case 'opencode':
2006
2123
  await collectVerifyCheck(checks, 'opencode_config', async () => {
2007
2124
  const config = await readJson(assetPaths.opencodeConfigPath, 'OpenCode project config');
2008
- const mcpCommand = config?.mcp?.auditor?.command;
2009
- if (!Array.isArray(mcpCommand) || mcpCommand[0] !== 'node') {
2010
- throw new Error('OpenCode config must set mcp.auditor.command as a Node command array.');
2011
- }
2012
- if (!mcpCommand[1]?.includes(MCP_LAUNCHER_FILENAME)) {
2013
- throw new Error(`OpenCode config must reference ${MCP_LAUNCHER_FILENAME}, got ${mcpCommand[1] ?? 'missing'}.`);
2014
- }
2015
- if (config?.mcp?.auditor?.type !== 'local') {
2016
- throw new Error(`OpenCode config must set mcp.auditor.type to "local", got ${config?.mcp?.auditor?.type ?? 'missing'}.`);
2017
- }
2018
2125
  if (config?.command?.['audit-code']) {
2019
2126
  throw new Error('OpenCode project config must not define command["audit-code"]; the slash command is global npm-installed state. Run "audit-code install --host opencode" to remove the stale local command.');
2020
2127
  }
2128
+ if (config?.mcp?.auditor) {
2129
+ throw new Error('OpenCode project config must not define mcp.auditor; the MCP server is supplied by the global npm-installed config. Run "audit-code install --host opencode" to remove the stale project-level MCP entry.');
2130
+ }
2021
2131
  assertOpenCodeAuditPermissionConfig(config?.permission, 'permission');
2022
2132
  assertOpenCodeAuditPermissionConfig(config?.agent?.auditor?.permission, 'agent.auditor.permission');
2023
2133
  return {
2024
- summary: 'OpenCode project config has MCP server and audit permissions; /audit-code is supplied by the global npm-installed OpenCode command.',
2134
+ summary: 'OpenCode project config has audit permissions; MCP server and /audit-code command are supplied by the global npm-installed config.',
2025
2135
  path: assetPaths.opencodeConfigPath,
2026
2136
  };
2027
2137
  });
@@ -2149,7 +2259,11 @@ async function detectBootstrapRefreshReason(root, host) {
2149
2259
  );
2150
2260
 
2151
2261
  if (hostCatalog.has('codex') && (assetPaths.codexSkillPath || assetPaths.codexPromptPath)) {
2152
- return 'legacy_repo_local_codex_skill';
2262
+ return 'legacy_local_audit_code_surface';
2263
+ }
2264
+
2265
+ if ((await findLegacyAuditCodeSurfaceFiles(root)).length > 0) {
2266
+ return 'legacy_local_audit_code_surface';
2153
2267
  }
2154
2268
 
2155
2269
  for (const hostKey of getInstallHostKeys(host)) {
@@ -2199,6 +2313,9 @@ async function detectBootstrapRefreshReason(root, host) {
2199
2313
  if (opencodeConfig?.command?.['audit-code']) {
2200
2314
  return 'stale_host_asset:opencode:local_command';
2201
2315
  }
2316
+ if (opencodeConfig?.mcp?.auditor) {
2317
+ return 'stale_host_asset:opencode:project_mcp';
2318
+ }
2202
2319
  try {
2203
2320
  assertOpenCodeAuditPermissionConfig(opencodeConfig?.permission, 'permission');
2204
2321
  assertOpenCodeAuditPermissionConfig(opencodeConfig?.agent?.auditor?.permission, 'agent.auditor.permission');
@@ -2350,6 +2467,9 @@ async function installBootstrap(argv, options = {}) {
2350
2467
  antigravityPlanningGuidePath: profile.writeAntigravity
2351
2468
  ? join(root, '.audit-code', 'install', 'antigravity', 'PLANNING-MODE.md')
2352
2469
  : null,
2470
+ geminiCommandPath: profile.writeAntigravity
2471
+ ? join(root, '.gemini', 'commands', 'audit-code.toml')
2472
+ : null,
2353
2473
  };
2354
2474
 
2355
2475
  const results = [];
@@ -2384,22 +2504,7 @@ async function installBootstrap(argv, options = {}) {
2384
2504
  );
2385
2505
  }
2386
2506
 
2387
- if (!profile.writeCodex) {
2388
- const legacyCodexSkillRemoval = await removeGeneratedMarkdownIfMatches(
2389
- join(root, '.codex', 'skills', 'audit-code', 'SKILL.md'),
2390
- skillSource,
2391
- );
2392
- if (legacyCodexSkillRemoval) {
2393
- results.push(legacyCodexSkillRemoval);
2394
- }
2395
- const legacyCodexPromptRemoval = await removeGeneratedMarkdownIfMatches(
2396
- join(root, '.codex', 'skills', 'audit-code', 'audit-code.prompt.md'),
2397
- promptSource,
2398
- );
2399
- if (legacyCodexPromptRemoval) {
2400
- results.push(legacyCodexPromptRemoval);
2401
- }
2402
- }
2507
+ results.push(...await removeLegacyAuditCodeSurfaceFiles(root));
2403
2508
 
2404
2509
  if (profile.writeCodex) {
2405
2510
  results.push(
@@ -2447,19 +2552,6 @@ async function installBootstrap(argv, options = {}) {
2447
2552
  }
2448
2553
 
2449
2554
  if (profile.writeOpenCode) {
2450
- // Remove legacy command/skill/prompt files unconditionally — these paths are exclusively
2451
- // owned by the auditor-lambda installer and keeping any version of them causes OpenCode to
2452
- // load the wrong slash command regardless of prompt content.
2453
- for (const legacyPath of [
2454
- join(root, '.opencode', 'commands', 'audit-code.md'),
2455
- join(root, '.opencode', 'skills', 'audit-code', 'SKILL.md'),
2456
- join(root, '.opencode', 'skills', 'audit-code', 'audit-code.prompt.md'),
2457
- ]) {
2458
- if (await fileExists(legacyPath)) {
2459
- await unlink(legacyPath);
2460
- results.push({ path: legacyPath, mode: 'removed' });
2461
- }
2462
- }
2463
2555
  results.push(
2464
2556
  await writeMergedGeneratedJson(
2465
2557
  assetPaths.opencodeConfigPath,
@@ -2505,6 +2597,12 @@ async function installBootstrap(argv, options = {}) {
2505
2597
  renderAntigravityPlanningGuide(root),
2506
2598
  ),
2507
2599
  );
2600
+ results.push(
2601
+ await writeGeneratedMarkdown(
2602
+ assetPaths.geminiCommandPath,
2603
+ renderGeminiCommandToml(promptBody),
2604
+ ),
2605
+ );
2508
2606
  }
2509
2607
 
2510
2608
  const hostGuidance = buildHostCatalog({
@@ -2566,6 +2664,7 @@ async function installBootstrap(argv, options = {}) {
2566
2664
  slash_command_surfaces: {
2567
2665
  vscode_prompt: assetPaths.vscodePromptPath,
2568
2666
  opencode_config: assetPaths.opencodeConfigPath,
2667
+ gemini_command: assetPaths.geminiCommandPath,
2569
2668
  },
2570
2669
  instruction_surfaces: {
2571
2670
  agents: assetPaths.agentsInstructionsPath,
@@ -2625,6 +2724,22 @@ async function runDistCommand(commandName, argv, { ensureArtifactsDir = false }
2625
2724
  await run(nodeExecutable(), [distEntry, commandName, ...commandArgs]);
2626
2725
  }
2627
2726
 
2727
+ async function runDistCommandInline(commandName, argv) {
2728
+ const commandArgs = [...argv];
2729
+ const rootValue = resolve(getFlag(commandArgs, '--root') ?? '.');
2730
+ const artifactsDir = resolve(getFlag(commandArgs, '--artifacts-dir') ?? join(rootValue, '.audit-artifacts'));
2731
+
2732
+ setDefaultFlag(commandArgs, '--root', rootValue);
2733
+ setDefaultFlag(commandArgs, '--artifacts-dir', artifactsDir);
2734
+
2735
+ await mkdir(artifactsDir, { recursive: true });
2736
+ await ensureBuilt();
2737
+
2738
+ const distUrl = new URL(`file:///${distEntry.replace(/\\/g, '/')}`);
2739
+ const cli = await import(distUrl.href);
2740
+ await cli.runCli([process.execPath, distEntry, commandName, ...commandArgs]);
2741
+ }
2742
+
2628
2743
  export async function runAuditCodeWrapper({
2629
2744
  usageName,
2630
2745
  argv = process.argv.slice(2),
@@ -2683,7 +2798,7 @@ export async function runAuditCodeWrapper({
2683
2798
  }
2684
2799
 
2685
2800
  if (argv[0] === 'mcp') {
2686
- await runDistCommand('mcp', argv.slice(1), { ensureArtifactsDir: true });
2801
+ await runDistCommandInline('mcp', argv.slice(1));
2687
2802
  return;
2688
2803
  }
2689
2804
 
package/dist/cli.js CHANGED
@@ -32,7 +32,7 @@ import { buildReviewPackets, orderTasksForPacketReview, } from "./orchestrator/r
32
32
  import { buildFileAnchorSummary, } from "./orchestrator/fileAnchors.js";
33
33
  import { LOCAL_SUBPROCESS_PROVIDER_NAME } from "./providers/constants.js";
34
34
  import { runAuditCodeMcpServer } from "./mcp/server.js";
35
- import { scheduleWave, buildProviderModelKey, readQuotaState, recordWaveOutcome, resolveLimits, probeProvider, computeMaxSafeConcurrency, getQuotaStatePath, } from "./quota/index.js";
35
+ import { scheduleWave, buildProviderModelKey, readQuotaState, recordWaveOutcome, resolveLimits, resolveHostActiveSubagentLimit, probeProvider, computeMaxSafeConcurrency, getQuotaStatePath, } from "./quota/index.js";
36
36
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
37
37
  const ADVANCE_AUDIT_CONTRACT_VERSION = "audit-code/v1alpha1";
38
38
  const WORKER_RESULT_CONTRACT_VERSION = "audit-code-worker-result/v1alpha1";
@@ -188,6 +188,9 @@ function getExplicitProvider(argv) {
188
188
  function getHostModel(argv) {
189
189
  return getFlag(argv, "--host-model") ?? null;
190
190
  }
191
+ function getHostMaxActiveSubagents(argv) {
192
+ return parsePositiveIntegerFlag(argv, "--host-max-active-subagents") ?? null;
193
+ }
191
194
  function getQuotaProbeMode(argv, sessionConfig) {
192
195
  const raw = getFlag(argv, "--quota-probe") ?? sessionConfig.quota?.probe ?? "auto";
193
196
  if (raw === "auto" || raw === "never" || raw === "force")
@@ -539,6 +542,7 @@ function renderCapabilityCheckPrompt(params) {
539
542
  "- `can_dispatch_subagents: true` if the `task` tool or equivalent subagent dispatch is available",
540
543
  "- `can_dispatch_subagents: false` if not",
541
544
  "- Optionally `can_restrict_subagent_tools: true` and/or `can_select_subagent_model: true`",
545
+ "- If the host documents or exposes a hard cap on simultaneously active subagents, include `max_active_subagents`.",
542
546
  "",
543
547
  "Read the `prompt_content` field in the tool response and follow it.",
544
548
  "",
@@ -554,6 +558,8 @@ function renderCapabilityCheckPrompt(params) {
554
558
  "",
555
559
  "If the host can also restrict tools per subagent or select models per subagent, add the matching `--host-can-restrict-subagent-tools true` or `--host-can-select-subagent-model true` flags to the same command. Omit those flags when unsure.",
556
560
  "",
561
+ "If the host has a known active-subagent ceiling, add `--host-max-active-subagents <n>` to the same command. For Codex Desktop, use 6.",
562
+ "",
557
563
  "After the command writes the next step, read and follow only its `prompt_path`.",
558
564
  "",
559
565
  ].join("\n");
@@ -576,6 +582,8 @@ function renderDispatchReviewPrompt(params) {
576
582
  "",
577
583
  "Use the `wave_size` from `dispatch_quota`. If `cooldown_until` is non-null, wait until that timestamp before starting the first wave.",
578
584
  "",
585
+ "`dispatch_quota.host_concurrency_limit` records any detected hard host cap that contributed to `wave_size`.",
586
+ "",
579
587
  "For each wave: use the `task` tool (or equivalent subagent dispatch) to launch up to `wave_size` subagents in parallel (one per entry), wait for all to finish, then start the next wave.",
580
588
  "",
581
589
  "**Fallback — if auditor MCP tools are not available:** Read both of these files:",
@@ -1134,6 +1142,7 @@ async function cmdNextStep(argv) {
1134
1142
  const hostCanRestrictSubagentTools = getOptionalBooleanFlag(argv, "--host-can-restrict-subagent-tools") ??
1135
1143
  false;
1136
1144
  const hostCanSelectSubagentModel = getOptionalBooleanFlag(argv, "--host-can-select-subagent-model") ?? false;
1145
+ const hostMaxActiveSubagents = getHostMaxActiveSubagents(argv);
1137
1146
  let sessionConfig;
1138
1147
  try {
1139
1148
  sessionConfig = await loadSessionConfig(artifactsDir);
@@ -1260,6 +1269,7 @@ async function cmdNextStep(argv) {
1260
1269
  runId: result.activeReviewRun.run_id,
1261
1270
  artifactsDir,
1262
1271
  root,
1272
+ hostActiveSubagentLimit: hostMaxActiveSubagents,
1263
1273
  });
1264
1274
  const mergeCommand = mergeAndIngestCommand(artifactsDir, result.activeReviewRun.run_id);
1265
1275
  const continueCommand = nextStepCommand(root, artifactsDir);
@@ -2238,8 +2248,18 @@ async function prepareDispatchArtifacts(params) {
2238
2248
  const lensDefsPath = join(packageRoot, "dispatch", "lens-definitions.json");
2239
2249
  const lensDefs = await readJsonFile(lensDefsPath);
2240
2250
  await mkdir(taskResultsDir, { recursive: true });
2241
- const lineIndex = Object.fromEntries(tasks.flatMap((task) => Object.entries(task.file_line_counts ?? {})));
2242
- const orderedTasks = orderTasksForPacketReview(tasks, {
2251
+ // On resume: skip tasks whose result files already exist from a prior dispatch.
2252
+ const priorResultTaskIds = new Set();
2253
+ for (const task of tasks) {
2254
+ if (existsSync(taskResultPath(taskResultsDir, task.task_id))) {
2255
+ priorResultTaskIds.add(task.task_id);
2256
+ }
2257
+ }
2258
+ const dispatchTasks = priorResultTaskIds.size > 0
2259
+ ? tasks.filter((task) => !priorResultTaskIds.has(task.task_id))
2260
+ : tasks;
2261
+ const lineIndex = Object.fromEntries(dispatchTasks.flatMap((task) => Object.entries(task.file_line_counts ?? {})));
2262
+ const orderedTasks = orderTasksForPacketReview(dispatchTasks, {
2243
2263
  graphBundle: bundle.graph_bundle,
2244
2264
  lineIndex,
2245
2265
  });
@@ -2258,6 +2278,15 @@ async function prepareDispatchArtifacts(params) {
2258
2278
  }
2259
2279
  const plan = [];
2260
2280
  const resultMapEntries = [];
2281
+ for (const task of tasks) {
2282
+ if (priorResultTaskIds.has(task.task_id)) {
2283
+ resultMapEntries.push({
2284
+ packet_id: "__prior_dispatch__",
2285
+ task_id: task.task_id,
2286
+ result_path: taskResultPath(taskResultsDir, task.task_id),
2287
+ });
2288
+ }
2289
+ }
2261
2290
  let largestPacketId = null;
2262
2291
  let largestLines = 0;
2263
2292
  let largestEstimatedTokens = 0;
@@ -2469,13 +2498,18 @@ async function prepareDispatchArtifacts(params) {
2469
2498
  const quotaProviderKey = buildProviderModelKey(quotaProviderName, hostModel);
2470
2499
  const quotaState = await readQuotaState().catch(() => ({ version: 1, entries: {} }));
2471
2500
  const quotaStateEntry = quotaState.entries[quotaProviderKey] ?? null;
2501
+ const hostConcurrencyLimit = resolveHostActiveSubagentLimit({
2502
+ explicitLimit: params.hostActiveSubagentLimit,
2503
+ sessionConfig,
2504
+ });
2472
2505
  const waveSchedule = scheduleWave({
2473
2506
  providerName: quotaProviderName,
2474
2507
  sessionConfig,
2475
2508
  hostModel,
2476
- requestedConcurrency: sessionConfig.parallel_workers ?? 1,
2509
+ requestedConcurrency: sessionConfig.parallel_workers ?? plan.length,
2477
2510
  estimatedPacketTokens: avgPacketTokens,
2478
2511
  quotaStateEntry,
2512
+ hostConcurrencyLimit,
2479
2513
  });
2480
2514
  const dispatchQuota = {
2481
2515
  contract_version: "audit-code-dispatch-quota/v1alpha1",
@@ -2484,6 +2518,7 @@ async function prepareDispatchArtifacts(params) {
2484
2518
  resolved_limits: waveSchedule.resolved_limits,
2485
2519
  confidence: waveSchedule.confidence,
2486
2520
  source: waveSchedule.source,
2521
+ host_concurrency_limit: waveSchedule.host_concurrency_limit,
2487
2522
  wave_size: waveSchedule.wave_size,
2488
2523
  estimated_wave_tokens: waveSchedule.estimated_wave_tokens,
2489
2524
  cooldown_until: waveSchedule.cooldown_until,
@@ -2518,6 +2553,7 @@ async function prepareDispatchArtifacts(params) {
2518
2553
  dispatch_quota_path: dispatchQuotaPath,
2519
2554
  packet_count: plan.length,
2520
2555
  task_count: orderedTasks.length,
2556
+ skipped_task_count: priorResultTaskIds.size,
2521
2557
  largest_packet: largestPacketId
2522
2558
  ? {
2523
2559
  packet_id: largestPacketId,
@@ -2538,6 +2574,7 @@ async function cmdPrepareDispatch(argv) {
2538
2574
  artifactsDir: getArtifactsDir(argv),
2539
2575
  root: getFlag(argv, "--root") ? getRootDir(argv) : undefined,
2540
2576
  hostModel: getHostModel(argv),
2577
+ hostActiveSubagentLimit: getHostMaxActiveSubagents(argv),
2541
2578
  });
2542
2579
  console.log(JSON.stringify(result, null, 2));
2543
2580
  }
@@ -3214,12 +3251,17 @@ async function cmdQuota(argv) {
3214
3251
  const quotaState = await readQuotaState().catch(() => ({ version: 1, entries: {} }));
3215
3252
  const quotaStateEntry = quotaState.entries[providerModelKey] ?? null;
3216
3253
  const halfLifeHours = sessionConfig.quota?.empirical_half_life_hours ?? 24;
3254
+ const hostConcurrencyLimit = resolveHostActiveSubagentLimit({
3255
+ explicitLimit: getHostMaxActiveSubagents(argv),
3256
+ sessionConfig,
3257
+ });
3217
3258
  const waveSchedule = scheduleWave({
3218
3259
  providerName,
3219
3260
  sessionConfig,
3220
3261
  hostModel,
3221
3262
  requestedConcurrency: sessionConfig.parallel_workers ?? 1,
3222
3263
  quotaStateEntry,
3264
+ hostConcurrencyLimit,
3223
3265
  });
3224
3266
  console.log(JSON.stringify({
3225
3267
  provider: providerName,
@@ -3228,6 +3270,7 @@ async function cmdQuota(argv) {
3228
3270
  resolved_limits: limits,
3229
3271
  confidence,
3230
3272
  source,
3273
+ host_concurrency_limit: hostConcurrencyLimit,
3231
3274
  probe: probeResult,
3232
3275
  learned_caps: quotaStateEntry
3233
3276
  ? {
@@ -364,6 +364,10 @@ async function handleToolCall(name, params, defaults) {
364
364
  if (canSelect !== undefined) {
365
365
  extraArgs.push("--host-can-select-subagent-model", String(Boolean(canSelect)));
366
366
  }
367
+ const maxActiveSubagents = params?.max_active_subagents ?? params?.maxActiveSubagents;
368
+ if (maxActiveSubagents !== undefined) {
369
+ extraArgs.push("--host-max-active-subagents", String(maxActiveSubagents));
370
+ }
367
371
  return toolResult(await runContinueAudit(context, ["next-step", ...extraArgs]));
368
372
  }
369
373
  default:
@@ -522,6 +526,11 @@ function toolDefinitions() {
522
526
  type: "boolean",
523
527
  description: "Whether this host can select a model per subagent.",
524
528
  },
529
+ max_active_subagents: {
530
+ type: "integer",
531
+ minimum: 1,
532
+ description: "Known hard cap on simultaneously active subagents for this host, if available.",
533
+ },
525
534
  root: { type: "string", description: "Repository root override." },
526
535
  artifacts_dir: {
527
536
  type: "string",
@@ -0,0 +1,8 @@
1
+ import type { SessionConfig } from "../types/sessionConfig.js";
2
+ import type { HostConcurrencyLimit } from "./types.js";
3
+ export declare function detectHostActiveSubagentLimit(env?: NodeJS.ProcessEnv): HostConcurrencyLimit | null;
4
+ export declare function resolveHostActiveSubagentLimit(options: {
5
+ explicitLimit?: number | null;
6
+ sessionConfig: SessionConfig;
7
+ env?: NodeJS.ProcessEnv;
8
+ }): HostConcurrencyLimit | null;
@@ -0,0 +1,50 @@
1
+ const CODEX_DESKTOP_ACTIVE_SUBAGENT_LIMIT = 6;
2
+ function parsePositiveInteger(value) {
3
+ if (typeof value === "number") {
4
+ return Number.isInteger(value) && value > 0 ? value : null;
5
+ }
6
+ if (typeof value !== "string")
7
+ return null;
8
+ const trimmed = value.trim();
9
+ if (!/^\d+$/.test(trimmed))
10
+ return null;
11
+ const parsed = Number(trimmed);
12
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
13
+ }
14
+ export function detectHostActiveSubagentLimit(env = process.env) {
15
+ const explicitEnvLimit = parsePositiveInteger(env.AUDIT_CODE_HOST_MAX_ACTIVE_SUBAGENTS ??
16
+ env.CODEX_MAX_ACTIVE_SUBAGENTS);
17
+ if (explicitEnvLimit !== null) {
18
+ return {
19
+ active_subagents: explicitEnvLimit,
20
+ source: "environment",
21
+ description: "Host active subagent limit from environment.",
22
+ };
23
+ }
24
+ if (env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE === "Codex Desktop") {
25
+ return {
26
+ active_subagents: CODEX_DESKTOP_ACTIVE_SUBAGENT_LIMIT,
27
+ source: "environment",
28
+ description: "Codex Desktop active subagent limit.",
29
+ };
30
+ }
31
+ return null;
32
+ }
33
+ export function resolveHostActiveSubagentLimit(options) {
34
+ if (options.explicitLimit !== undefined && options.explicitLimit !== null) {
35
+ return {
36
+ active_subagents: options.explicitLimit,
37
+ source: "cli_flags",
38
+ description: "Host active subagent limit reported by the conversation host.",
39
+ };
40
+ }
41
+ const configuredLimit = parsePositiveInteger(options.sessionConfig.quota?.host_active_subagent_limit);
42
+ if (configuredLimit !== null) {
43
+ return {
44
+ active_subagents: configuredLimit,
45
+ source: "session_config",
46
+ description: "Host active subagent limit from session-config quota settings.",
47
+ };
48
+ }
49
+ return detectHostActiveSubagentLimit(options.env);
50
+ }
@@ -1,8 +1,9 @@
1
1
  export { resolveLimits, lookupKnownModel, classifyProvider } from "./limits.js";
2
2
  export type { LimitResolutionResult, ResolveLimitsOptions, ProviderType } from "./limits.js";
3
+ export { detectHostActiveSubagentLimit, resolveHostActiveSubagentLimit, } from "./hostLimits.js";
3
4
  export { readQuotaState, writeQuotaState, computeMaxSafeConcurrency, recordWaveOutcome, getQuotaStatePath, decayWeight, applyDecayToEntry, } from "./state.js";
4
5
  export { scheduleWave, buildProviderModelKey } from "./scheduler.js";
5
6
  export type { ScheduleWaveOptions } from "./scheduler.js";
6
7
  export { probeProvider } from "./probe.js";
7
8
  export type { ProbeResult } from "./probe.js";
8
- export type { ResolvedLimits, LimitSource, LimitConfidence, QuotaState, QuotaStateEntry, ConcurrencyBucket, WaveSchedule, DispatchQuota, ObservedWaveOutcome, } from "./types.js";
9
+ export type { ResolvedLimits, LimitSource, LimitConfidence, HostConcurrencyLimit, HostConcurrencyLimitSource, QuotaState, QuotaStateEntry, ConcurrencyBucket, WaveSchedule, DispatchQuota, ObservedWaveOutcome, } from "./types.js";
@@ -1,4 +1,5 @@
1
1
  export { resolveLimits, lookupKnownModel, classifyProvider } from "./limits.js";
2
+ export { detectHostActiveSubagentLimit, resolveHostActiveSubagentLimit, } from "./hostLimits.js";
2
3
  export { readQuotaState, writeQuotaState, computeMaxSafeConcurrency, recordWaveOutcome, getQuotaStatePath, decayWeight, applyDecayToEntry, } from "./state.js";
3
4
  export { scheduleWave, buildProviderModelKey } from "./scheduler.js";
4
5
  export { probeProvider } from "./probe.js";
@@ -1,5 +1,5 @@
1
1
  import type { ResolvedProviderName, SessionConfig } from "../types/sessionConfig.js";
2
- import type { QuotaStateEntry, WaveSchedule } from "./types.js";
2
+ import type { HostConcurrencyLimit, QuotaStateEntry, WaveSchedule } from "./types.js";
3
3
  export interface ScheduleWaveOptions {
4
4
  providerName: ResolvedProviderName;
5
5
  sessionConfig: SessionConfig;
@@ -8,6 +8,7 @@ export interface ScheduleWaveOptions {
8
8
  /** Average estimated tokens per packet/worker. Used for TPM budget. */
9
9
  estimatedPacketTokens?: number;
10
10
  quotaStateEntry?: QuotaStateEntry | null;
11
+ hostConcurrencyLimit?: HostConcurrencyLimit | null;
11
12
  }
12
13
  export declare function scheduleWave(options: ScheduleWaveOptions): WaveSchedule;
13
14
  /** Build the state key used for indexing quota-state.json entries. */
@@ -1,9 +1,15 @@
1
1
  import { resolveLimits } from "./limits.js";
2
2
  import { computeMaxSafeConcurrency } from "./state.js";
3
3
  export function scheduleWave(options) {
4
- const { providerName, sessionConfig, hostModel, requestedConcurrency, estimatedPacketTokens = 0, quotaStateEntry = null, } = options;
4
+ const { providerName, sessionConfig, hostModel, requestedConcurrency, estimatedPacketTokens = 0, quotaStateEntry = null, hostConcurrencyLimit = null, } = options;
5
5
  const quota = sessionConfig.quota ?? {};
6
+ const applyHostConcurrencyLimit = (waveSize) => {
7
+ if (hostConcurrencyLimit === null)
8
+ return waveSize;
9
+ return Math.min(waveSize, hostConcurrencyLimit.active_subagents);
10
+ };
6
11
  if (quota.enabled === false) {
12
+ const waveSize = Math.max(1, applyHostConcurrencyLimit(requestedConcurrency));
7
13
  const limits = {
8
14
  context_tokens: quota.default_context_tokens ?? 32_000,
9
15
  output_tokens: quota.reserved_output_tokens ?? 4_096,
@@ -12,12 +18,13 @@ export function scheduleWave(options) {
12
18
  output_tokens_per_minute: null,
13
19
  };
14
20
  return {
15
- wave_size: requestedConcurrency,
16
- estimated_wave_tokens: requestedConcurrency * estimatedPacketTokens,
21
+ wave_size: waveSize,
22
+ estimated_wave_tokens: waveSize * estimatedPacketTokens,
17
23
  cooldown_until: null,
18
24
  confidence: "high",
19
25
  source: "default",
20
26
  resolved_limits: limits,
27
+ host_concurrency_limit: hostConcurrencyLimit,
21
28
  model: hostModel,
22
29
  };
23
30
  }
@@ -51,6 +58,7 @@ export function scheduleWave(options) {
51
58
  }
52
59
  // No learned data: use requestedConcurrency and let 429 outcomes train the cap
53
60
  }
61
+ waveSize = applyHostConcurrencyLimit(waveSize);
54
62
  waveSize = Math.max(1, waveSize);
55
63
  return {
56
64
  wave_size: waveSize,
@@ -59,6 +67,7 @@ export function scheduleWave(options) {
59
67
  confidence,
60
68
  source,
61
69
  resolved_limits: limits,
70
+ host_concurrency_limit: hostConcurrencyLimit,
62
71
  model: hostModel,
63
72
  };
64
73
  }
@@ -1,5 +1,11 @@
1
1
  export type LimitSource = "explicit_config" | "cli_flags" | "known_metadata" | "learned" | "default";
2
2
  export type LimitConfidence = "high" | "medium" | "low";
3
+ export type HostConcurrencyLimitSource = "cli_flags" | "session_config" | "environment";
4
+ export interface HostConcurrencyLimit {
5
+ active_subagents: number;
6
+ source: HostConcurrencyLimitSource;
7
+ description: string;
8
+ }
3
9
  export interface ResolvedLimits {
4
10
  context_tokens: number;
5
11
  output_tokens: number;
@@ -28,6 +34,7 @@ export interface WaveSchedule {
28
34
  confidence: LimitConfidence;
29
35
  source: LimitSource;
30
36
  resolved_limits: ResolvedLimits;
37
+ host_concurrency_limit: HostConcurrencyLimit | null;
31
38
  model: string | null;
32
39
  }
33
40
  export interface DispatchQuota {
@@ -37,6 +44,7 @@ export interface DispatchQuota {
37
44
  resolved_limits: ResolvedLimits;
38
45
  confidence: LimitConfidence;
39
46
  source: LimitSource;
47
+ host_concurrency_limit: HostConcurrencyLimit | null;
40
48
  wave_size: number;
41
49
  estimated_wave_tokens: number;
42
50
  cooldown_until: string | null;
@@ -1,6 +1,35 @@
1
1
  function normalizeText(value) {
2
2
  return (value ?? "").trim().toLowerCase();
3
3
  }
4
+ function wordSet(text) {
5
+ return new Set(text
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9\s]/g, " ")
8
+ .split(/\s+/)
9
+ .filter(Boolean));
10
+ }
11
+ function wordJaccard(a, b) {
12
+ const sa = wordSet(a);
13
+ const sb = wordSet(b);
14
+ let intersection = 0;
15
+ for (const w of sa) {
16
+ if (sb.has(w))
17
+ intersection++;
18
+ }
19
+ const union = sa.size + sb.size - intersection;
20
+ return union === 0 ? 0 : intersection / union;
21
+ }
22
+ function filePathOverlap(a, b) {
23
+ const setA = new Set(a.affected_files.map((f) => f.path));
24
+ const setB = new Set(b.affected_files.map((f) => f.path));
25
+ let intersection = 0;
26
+ for (const path of setA) {
27
+ if (setB.has(path))
28
+ intersection++;
29
+ }
30
+ const union = setA.size + setB.size - intersection;
31
+ return union === 0 ? 0 : intersection / union;
32
+ }
4
33
  function primaryPath(finding) {
5
34
  return finding.affected_files[0]?.path ?? "";
6
35
  }
@@ -63,6 +92,62 @@ function mergeAffectedFiles(existing, incoming) {
63
92
  }
64
93
  existing.affected_files.sort((a, b) => a.path.localeCompare(b.path) || (a.line_start ?? 0) - (b.line_start ?? 0));
65
94
  }
95
+ function deduplicateCrossLens(findings) {
96
+ const groups = new Map();
97
+ for (const finding of findings) {
98
+ const key = primaryPath(finding);
99
+ const group = groups.get(key);
100
+ if (group) {
101
+ group.push(finding);
102
+ }
103
+ else {
104
+ groups.set(key, [finding]);
105
+ }
106
+ }
107
+ const removed = new Set();
108
+ for (const group of groups.values()) {
109
+ if (group.length < 2)
110
+ continue;
111
+ for (let i = 0; i < group.length; i++) {
112
+ if (removed.has(group[i]))
113
+ continue;
114
+ for (let j = i + 1; j < group.length; j++) {
115
+ if (removed.has(group[j]))
116
+ continue;
117
+ const a = group[i];
118
+ const b = group[j];
119
+ if (normalizeText(a.lens) === normalizeText(b.lens))
120
+ continue;
121
+ const titleSim = wordJaccard(a.title, b.title);
122
+ const catMatch = normalizeText(a.category) === normalizeText(b.category);
123
+ const threshold = catMatch ? 0.4 : 0.5;
124
+ if (titleSim < threshold)
125
+ continue;
126
+ if (filePathOverlap(a, b) < 0.5)
127
+ continue;
128
+ const aSev = severityRank(a.severity);
129
+ const bSev = severityRank(b.severity);
130
+ const aConf = confidenceRank(a.confidence);
131
+ const bConf = confidenceRank(b.confidence);
132
+ const keepA = aSev > bSev || (aSev === bSev && aConf >= bConf);
133
+ const [survivor, absorbed] = keepA ? [a, b] : [b, a];
134
+ mergeAffectedFiles(survivor, absorbed);
135
+ survivor.evidence = [
136
+ ...new Set([
137
+ ...(survivor.evidence ?? []),
138
+ ...(absorbed.evidence ?? []),
139
+ ]),
140
+ ];
141
+ survivor.systemic = Boolean(survivor.systemic || absorbed.systemic);
142
+ if (absorbed.summary.length > survivor.summary.length) {
143
+ survivor.summary = absorbed.summary;
144
+ }
145
+ removed.add(absorbed);
146
+ }
147
+ }
148
+ }
149
+ return findings.filter((f) => !removed.has(f));
150
+ }
66
151
  export function mergeFindings(results, runtimeReport, externalAnalyzerResults) {
67
152
  const merged = new Map();
68
153
  const runtimeEvidence = runtimeSummary(runtimeReport);
@@ -109,7 +194,7 @@ export function mergeFindings(results, runtimeReport, externalAnalyzerResults) {
109
194
  ];
110
195
  }
111
196
  }
112
- return [...merged.values()].sort((a, b) => {
197
+ return deduplicateCrossLens([...merged.values()]).sort((a, b) => {
113
198
  const severityDelta = severityRank(b.severity) - severityRank(a.severity);
114
199
  if (severityDelta !== 0)
115
200
  return severityDelta;
@@ -44,6 +44,8 @@ export interface QuotaConfig {
44
44
  reserved_output_tokens?: number;
45
45
  /** Half-life of empirical success/failure evidence in hours (default: 24). */
46
46
  empirical_half_life_hours?: number;
47
+ /** Hard host ceiling for simultaneously active conversation subagents. */
48
+ host_active_subagent_limit?: number;
47
49
  /** Per-model overrides keyed by "provider/model". */
48
50
  models?: Record<string, QuotaModelLimits>;
49
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditor-lambda",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
4
  "private": false,
5
5
  "description": "Portable hybrid code-auditing framework for arbitrary repositories.",
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  "resolved_limits",
12
12
  "confidence",
13
13
  "source",
14
+ "host_concurrency_limit",
14
15
  "wave_size",
15
16
  "estimated_wave_tokens",
16
17
  "cooldown_until"
@@ -58,6 +59,30 @@
58
59
  "enum": ["explicit_config", "cli_flags", "known_metadata", "learned", "default"],
59
60
  "description": "Where the resolved limits came from."
60
61
  },
62
+ "host_concurrency_limit": {
63
+ "type": ["object", "null"],
64
+ "description": "A hard host ceiling on simultaneously active subagents, if detected or reported.",
65
+ "required": [
66
+ "active_subagents",
67
+ "source",
68
+ "description"
69
+ ],
70
+ "additionalProperties": false,
71
+ "properties": {
72
+ "active_subagents": {
73
+ "type": "integer",
74
+ "minimum": 1
75
+ },
76
+ "source": {
77
+ "type": "string",
78
+ "enum": ["cli_flags", "session_config", "environment"]
79
+ },
80
+ "description": {
81
+ "type": "string",
82
+ "minLength": 1
83
+ }
84
+ }
85
+ },
61
86
  "wave_size": {
62
87
  "type": "integer",
63
88
  "minimum": 1,
@@ -283,9 +283,7 @@ const OPENCODE_MCP_COMMAND_TEMPLATE = [
283
283
  'Use the auditor MCP tools as the primary interface to the audit workflow.',
284
284
  '',
285
285
  '1. Call `auditor_start_audit` to initialize and receive the first step.',
286
- '2. Check `step_kind` in the response:',
287
- ' - If `step_kind` is `"capability_check"`: immediately call `auditor_report_capability` with `can_dispatch_subagents: true` and `can_select_subagent_model: true`. Do not run shell commands or inspect prompt_content for this step.',
288
- ' - Otherwise: read `prompt_content` and follow it.',
286
+ '2. Read `prompt_content` in the response and follow it.',
289
287
  '3. When a step completes (not blocked), call `auditor_continue_audit` to advance.',
290
288
  '4. Stop when the step instructions say to stop.',
291
289
  '',