@aiwg/cli 2026.9.6 → 2026.9.9

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.
Files changed (45) hide show
  1. package/dist/src/artifacts/index-builder.js +43 -1
  2. package/dist/src/artifacts/query-engine.js +7 -0
  3. package/dist/src/cli/handlers/help.js +7 -1
  4. package/dist/src/cli/handlers/installation.js +106 -2
  5. package/dist/src/cli/handlers/mc.js +100 -37
  6. package/dist/src/cli/handlers/ralph.js +14 -4
  7. package/dist/src/cli/handlers/refresh.js +359 -31
  8. package/dist/src/cli/handlers/repo-access.js +155 -4
  9. package/dist/src/cli/handlers/runtime-info.js +3 -0
  10. package/dist/src/cli/handlers/serve.js +21 -3
  11. package/dist/src/cli/handlers/setup.js +5 -5
  12. package/dist/src/cli/handlers/steward.js +30 -1
  13. package/dist/src/cli/handlers/use.js +123 -12
  14. package/dist/src/cli/handlers/utilities.js +26 -10
  15. package/dist/src/cli/handlers/version.js +40 -14
  16. package/dist/src/cli/handlers/workspace-context.js +8 -0
  17. package/dist/src/cli/services/deployment-verification.js +156 -7
  18. package/dist/src/cli/watch-service.js +47 -4
  19. package/dist/src/config/aiwg-config.js +95 -3
  20. package/dist/src/config/cli.js +16 -1
  21. package/dist/src/config/gitignore.js +5 -0
  22. package/dist/src/config/project-artifacts-health.mjs +15 -2
  23. package/dist/src/cost/fleet-report.js +19 -5
  24. package/dist/src/extensions/claude-hooks-installer.js +22 -6
  25. package/dist/src/extensions/project-local-doctor.js +40 -2
  26. package/dist/src/extensions/project-quickref.js +4 -0
  27. package/dist/src/installation/manager.mjs +38 -3
  28. package/dist/src/lint/runner.js +138 -0
  29. package/dist/src/mcp/helpers.mjs +56 -22
  30. package/dist/src/mcp/registry.js +32 -22
  31. package/dist/src/mcp/registry.mjs +31 -26
  32. package/dist/src/mcp/toml-editor.mjs +117 -0
  33. package/dist/src/mcp/tools/orchestration.mjs +7 -7
  34. package/dist/src/mcp/tools/subsystems.mjs +7 -7
  35. package/dist/src/memory/context-pack.js +5 -1
  36. package/dist/src/plugin/skill-command-translator.js +70 -1
  37. package/dist/src/serve/a2a-terminal-observer.js +19 -1
  38. package/dist/src/serve/mission-hitl.js +91 -0
  39. package/dist/src/sessions/import-lease.js +5 -1
  40. package/dist/src/smiths/context-pipeline/workspace-context.js +132 -6
  41. package/dist/src/testing/fixtures/test-data-factory.js +3 -3
  42. package/dist/src/writing/pattern-library.js +29 -6
  43. package/package.json +2 -1
  44. package/tools/agents/deploy-agents.mjs +91 -5
  45. package/tools/agents/providers/base.mjs +162 -6
@@ -712,6 +712,33 @@ export function parseFlowDoc(content, filePath) {
712
712
  searchTerms: [...searchTerms],
713
713
  };
714
714
  }
715
+ /**
716
+ * Best-effort absolute path to the AIWG install root, for error text that must
717
+ * name where the framework graph can actually be built (#2530).
718
+ */
719
+ function resolveInstallRootHint() {
720
+ try {
721
+ // The running module lives under the install root; walk up to the package.
722
+ let dir = path.dirname(new URL(import.meta.url).pathname);
723
+ for (let i = 0; i < 10; i += 1) {
724
+ const pkg = path.join(dir, 'package.json');
725
+ if (fs.existsSync(pkg)) {
726
+ try {
727
+ const content = JSON.parse(fs.readFileSync(pkg, 'utf8'));
728
+ if (content.name === 'aiwg' || content.name === '@aiwg/cli')
729
+ return dir;
730
+ }
731
+ catch { /* keep walking */ }
732
+ }
733
+ const parent = path.dirname(dir);
734
+ if (parent === dir)
735
+ break;
736
+ dir = parent;
737
+ }
738
+ }
739
+ catch { /* fall through */ }
740
+ return '<aiwg install root>';
741
+ }
715
742
  export async function buildIndex(cwd, options = {}) {
716
743
  const { force = false, verbose = false, scope, outputDir, graph, explicit = true } = options;
717
744
  const startTime = Date.now();
@@ -748,7 +775,22 @@ export async function buildIndex(cwd, options = {}) {
748
775
  return;
749
776
  }
750
777
  console.error(`Error: No scan directories found: ${scanDirs.join(', ')}`);
751
- console.log('Run this command from a project with the required directories.');
778
+ // The framework graph scans the AIWG corpus, which only exists at the install
779
+ // root — never in a consumer project. Saying "run from a project with the
780
+ // required directories" sends the operator looking in the wrong place (#2530).
781
+ if (graph === 'framework') {
782
+ const installRoot = resolveInstallRootHint();
783
+ console.log('The framework graph indexes the AIWG corpus and can only be built at the');
784
+ console.log('install root, not in a consumer project. Build it there, then sync here:');
785
+ console.log('');
786
+ console.log(` cd ${installRoot} && aiwg index build --graph framework --force`);
787
+ console.log(` cd ${cwd} && aiwg index sync --backend fortemi-core --graph framework`);
788
+ console.log('');
789
+ console.log("As an immediate workaround, discovery also works with '--backend local'.");
790
+ }
791
+ else {
792
+ console.log('Run this command from a project with the required directories.');
793
+ }
752
794
  process.exit(1);
753
795
  }
754
796
  // Determine output index directory
@@ -59,6 +59,13 @@ function canonicalLocalityRank(entryPath) {
59
59
  const normalized = entryPath.replace(/\\/g, '/');
60
60
  if (normalized.startsWith('.aiwg/') || normalized.includes('/.aiwg/'))
61
61
  return 0;
62
+ // Top-level persona mirrors are the least canonical source for a name a bundle
63
+ // also owns. Without this they fell into the catch-all below and scored 1,
64
+ // beating the bundle copy at 2 — the opposite of #1643. It only ever looked
65
+ // correct because a populated user index supplied provenance and `scopeRank`
66
+ // never reached this fallback (#2544).
67
+ if (normalized.startsWith('agentic/code/agents/') || normalized.includes('/agentic/code/agents/'))
68
+ return 4;
62
69
  if (normalized.includes('/plugins/') || normalized.startsWith('agentic/code/plugins/'))
63
70
  return 3;
64
71
  if (normalized.includes('/frameworks/') ||
@@ -10,6 +10,7 @@
10
10
  import * as ui from '../ui.js';
11
11
  import { maybePrintCommunityFooter } from '../../community/footer.js';
12
12
  import { listProviderDefinitions } from '../../providers/provider-definitions.js';
13
+ import { getCommandIds } from '../../extensions/commands/definitions.js';
13
14
  /**
14
15
  * Help command handler
15
16
  */
@@ -19,7 +20,12 @@ export const helpHandler = {
19
20
  description: 'Show CLI help message',
20
21
  category: 'maintenance',
21
22
  aliases: ['-h', '-help', '--help'],
22
- async execute(_ctx) {
23
+ async execute(ctx) {
24
+ if (ctx.args.includes('--json')) {
25
+ // Canonical IDs only: aliases and example prose are not registry entries.
26
+ console.log(JSON.stringify({ schema: 'aiwg.command-registry.v1', commandIds: getCommandIds() }));
27
+ return { exitCode: 0 };
28
+ }
23
29
  displayHelp();
24
30
  return { exitCode: 0 };
25
31
  },
@@ -20,21 +20,93 @@ function display(status, json) {
20
20
  console.log(`Release channel: ${status.identity?.channel ?? '(unrecorded)'}`);
21
21
  console.log(`Actual method: ${status.actualMethod}`);
22
22
  console.log(`Actual root: ${status.actualRoot}`);
23
+ console.log(`Framework root: ${status.frameworkRoot}`);
24
+ if (status.launcher) {
25
+ console.log(`Launcher: ${status.launcher.method} at ${status.launcher.root} (edge redirect — expected)`);
26
+ }
23
27
  if (status.drift.length > 0) {
24
28
  console.log('Drift:');
25
29
  for (const item of status.drift)
26
30
  console.log(` - ${item}`);
27
31
  }
32
+ const remedy = remediation(status);
33
+ if (remedy) {
34
+ console.log('');
35
+ console.log('Resolve:');
36
+ for (const line of remedy)
37
+ console.log(` ${line}`);
38
+ }
28
39
  console.log('');
29
40
  }
41
+ /**
42
+ * `switch` and `adopt` only write installation.json — they cannot change which
43
+ * binary is on PATH. So when the declaration and reality disagree, neither command
44
+ * resolves it and the operator needs a shell step this output never mentioned.
45
+ * Print it, tailored to the direction of the drift (#2534).
46
+ */
47
+ export function remediation(status) {
48
+ if (status.state !== 'mismatch')
49
+ return null;
50
+ const canonicalMethod = status.identity?.method;
51
+ const canonicalRoot = status.identity?.root;
52
+ const actualMethod = status.actualMethod;
53
+ if (canonicalMethod === 'source' && canonicalRoot && actualMethod !== 'source') {
54
+ return [
55
+ `The declared source install is not what runs. Put it on PATH:`,
56
+ ` cd ${canonicalRoot} && npm link`,
57
+ `Then re-run 'aiwg installation show' to confirm State: aligned.`,
58
+ `('aiwg installation switch' would only rewrite the declaration, which already says source.)`,
59
+ ];
60
+ }
61
+ if (canonicalMethod === 'npm' && actualMethod === 'source') {
62
+ return [
63
+ `The declared npm package is not what runs. Restore it:`,
64
+ ` npm install -g aiwg`,
65
+ `If a source checkout was linked, unlink it first: npm unlink -g aiwg`,
66
+ ];
67
+ }
68
+ return [
69
+ `Declaration and reality disagree (${canonicalMethod ?? 'unrecorded'} vs ${actualMethod}).`,
70
+ `'switch' and 'adopt' are declaration-only and cannot change PATH.`,
71
+ `Install or link the intended root, then re-run 'aiwg installation show'.`,
72
+ ];
73
+ }
74
+ function usage() {
75
+ return `
76
+ aiwg installation — inspect, adopt, or switch the canonical global installation
77
+
78
+ Usage:
79
+ aiwg installation show [--json]
80
+ aiwg installation adopt --method <npm|web|source> [--run-mode <normal|development>] [--yes]
81
+ aiwg installation switch --root <path> --method <npm|web|source> [--manager <absolute-path>]
82
+
83
+ Options:
84
+ --json Machine-readable output
85
+ --config-dir Override the installation config directory
86
+ --manager Absolute path to the package manager executable
87
+ --channel Release channel (stable|edge)
88
+ --run-mode normal|development (derived from --method when omitted)
89
+ --yes Confirm an adopt that abandons the declared install
90
+
91
+ Notes:
92
+ These commands are declaration-only: they record which installation is
93
+ canonical, they do not change which binary is on PATH. When \`show\` reports
94
+ State: mismatch, it prints the concrete command that resolves it.
95
+ `;
96
+ }
30
97
  export const installationHandler = {
31
98
  id: 'installation',
32
99
  name: 'Installation',
33
100
  description: 'Inspect, adopt, or deliberately switch the canonical global installation',
34
101
  category: 'maintenance',
35
102
  aliases: [],
103
+ async help() {
104
+ return { exitCode: 0, message: usage(), rawOutput: true };
105
+ },
36
106
  async execute(ctx) {
37
107
  const [action = 'show'] = ctx.args;
108
+ if (action === 'help')
109
+ return { exitCode: 0, message: usage(), rawOutput: true };
38
110
  const json = ctx.args.includes('--json');
39
111
  const actualRoot = getPackageRoot();
40
112
  const common = {
@@ -50,11 +122,43 @@ export const installationHandler = {
50
122
  }
51
123
  if (action === 'adopt') {
52
124
  const method = valueAfter(ctx.args, '--method');
125
+ // adopt resolves a mismatch by rewriting canonical to match whatever is
126
+ // running — i.e. by abandoning the declared install. That is the opposite
127
+ // of what an operator standardizing on a source checkout wants, so make it
128
+ // a deliberate choice rather than a silent capitulation (#2534).
129
+ const before = inspectInstallation({
130
+ ...common,
131
+ identity: loadInstallationIdentity({ ...common, createIfMissing: true }),
132
+ });
133
+ const declaredMethod = before.identity?.method;
134
+ const abandoning = before.state === 'mismatch'
135
+ && declaredMethod
136
+ && declaredMethod !== before.actualMethod;
137
+ if (abandoning && !ctx.args.includes('--yes')) {
138
+ return {
139
+ exitCode: 2,
140
+ rawOutput: true,
141
+ message: [
142
+ `Refusing to adopt: this would abandon the declared ${declaredMethod} install.`,
143
+ ``,
144
+ ` declared: ${declaredMethod} at ${before.identity?.root ?? '(unrecorded)'}`,
145
+ ` running: ${before.actualMethod} at ${before.actualRoot}`,
146
+ ``,
147
+ `adopt rewrites the declaration to match what is running; it does not change`,
148
+ `which binary is on PATH. If you meant to keep the declared install, run`,
149
+ `'aiwg installation show' for the command that puts it back on PATH.`,
150
+ `If you really mean to abandon it, re-run with --yes.`,
151
+ ].join('\n'),
152
+ };
153
+ }
53
154
  const status = adoptInstallation({
54
155
  ...common,
55
156
  method,
56
157
  runMode: valueAfter(ctx.args, '--run-mode'),
57
158
  });
159
+ if (abandoning) {
160
+ console.log(`Warning: adopted the running ${status.actualMethod} install; the previously declared ${declaredMethod} install is no longer canonical.`);
161
+ }
58
162
  display(status, json);
59
163
  return { exitCode: 0 };
60
164
  }
@@ -62,7 +166,7 @@ export const installationHandler = {
62
166
  const root = valueAfter(ctx.args, '--root');
63
167
  const method = valueAfter(ctx.args, '--method');
64
168
  if (!root || !method) {
65
- return { exitCode: 2, message: 'Usage: aiwg installation switch --root <path> --method <npm|web|source> [--manager <absolute-path>]' };
169
+ return { exitCode: 2, message: `switch requires --root and --method\n${usage()}`, rawOutput: true };
66
170
  }
67
171
  const status = switchInstallation({
68
172
  ...common,
@@ -73,7 +177,7 @@ export const installationHandler = {
73
177
  display(status, json);
74
178
  return { exitCode: 0 };
75
179
  }
76
- return { exitCode: 2, message: 'Usage: aiwg installation <show|adopt|switch> [options]' };
180
+ return { exitCode: 2, message: `Unknown installation action: ${action}\n${usage()}`, rawOutput: true };
77
181
  },
78
182
  };
79
183
  //# sourceMappingURL=installation.js.map
@@ -169,12 +169,15 @@ function hasFlag(args, flag) {
169
169
  * dispatch missions with no ceiling while the operator believed one applied
170
170
  * (#1770).
171
171
  */
172
- function parseNumberFlag(args, flag, invalidSink) {
173
- const raw = parseFlag(args, flag);
174
- if (raw === undefined)
172
+ function parseNumberFlag(args, flag, invalidSink, integer = false) {
173
+ if (!args.some(arg => arg === flag || arg.startsWith(`${flag}=`)))
175
174
  return undefined;
176
- const value = Number(raw);
177
- if (!Number.isFinite(value) || value <= 0) {
175
+ const raw = parseFlag(args, flag);
176
+ // Presence and validity are distinct: a trailing flag must not silently
177
+ // disappear, and counter limits must not accept fractional/unsafe values.
178
+ const decimal = typeof raw === 'string' && /^[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw);
179
+ const value = decimal ? Number(raw) : NaN;
180
+ if (!Number.isFinite(value) || value <= 0 || (integer && !Number.isSafeInteger(value))) {
178
181
  invalidSink?.push(`${flag} (got '${raw}')`);
179
182
  return undefined;
180
183
  }
@@ -225,7 +228,13 @@ Drain queued missions in a session by launching each as a ralph loop. Missions
225
228
  without --completion criteria are skipped with a warning.
226
229
 
227
230
  --accept-cost Skip the cost-warning gate (required for non-TTY contexts
228
- when estimated cumulative cost exceeds $5). See #1450.`,
231
+ when estimated cumulative cost exceeds $5). See #1450.
232
+
233
+ The estimate is the cumulative iteration floor (missions x max-iterations x
234
+ ~$1.60 cache cost per headless iteration). A mission's --max-total-cost caps its
235
+ share of that estimate only on a provider that reports spend; on a provider that
236
+ reports none the ceiling is inert (#1766), so it is reported but not subtracted.
237
+ See #2522.`,
229
238
  status: `Usage: aiwg mc status [<session-id>] [--json]
230
239
 
231
240
  Show mission status for a session. Auto-syncs from ralph loop state files.
@@ -330,23 +339,13 @@ async function mcDispatch(ctx) {
330
339
  const completion = parseFlag(ctx.args, '--completion');
331
340
  const priority = parseFlag(ctx.args, '--priority') || 'normal';
332
341
  const invalidFlags = [];
333
- const maxIterationsRaw = parseFlag(ctx.args, '--max-iterations');
334
- let maxIterations = 10;
335
- if (maxIterationsRaw !== undefined) {
336
- const parsedIterations = parseInt(maxIterationsRaw, 10);
337
- if (!Number.isFinite(parsedIterations) || parsedIterations <= 0) {
338
- invalidFlags.push(`--max-iterations (got '${maxIterationsRaw}')`);
339
- }
340
- else {
341
- maxIterations = parsedIterations;
342
- }
343
- }
344
- const maxTotalTokens = parseNumberFlag(ctx.args, '--max-total-tokens', invalidFlags);
345
- const maxOutputTokens = parseNumberFlag(ctx.args, '--max-output-tokens', invalidFlags);
346
- const maxToolCalls = parseNumberFlag(ctx.args, '--max-tool-calls', invalidFlags);
342
+ const maxIterations = parseNumberFlag(ctx.args, '--max-iterations', invalidFlags, true) ?? 10;
343
+ const maxTotalTokens = parseNumberFlag(ctx.args, '--max-total-tokens', invalidFlags, true);
344
+ const maxOutputTokens = parseNumberFlag(ctx.args, '--max-output-tokens', invalidFlags, true);
345
+ const maxToolCalls = parseNumberFlag(ctx.args, '--max-tool-calls', invalidFlags, true);
347
346
  const maxTotalCost = parseNumberFlag(ctx.args, '--max-total-cost', invalidFlags);
348
347
  const maxWallClockMinutes = parseNumberFlag(ctx.args, '--max-wall-clock-minutes', invalidFlags);
349
- const explorationQuota = parseNumberFlag(ctx.args, '--exploration-quota', invalidFlags);
348
+ const explorationQuota = parseNumberFlag(ctx.args, '--exploration-quota', invalidFlags, true);
350
349
  const budgetStopPolicyRaw = parseFlag(ctx.args, '--budget-stop-policy');
351
350
  let budgetStopPolicy;
352
351
  if (budgetStopPolicyRaw !== undefined) {
@@ -486,6 +485,71 @@ async function mcDispatch(ctx) {
486
485
  * detached process, so they DO run in parallel after launch — the loop here
487
486
  * is just for orderly dispatch, not for parallel scheduling)
488
487
  */
488
+ /**
489
+ * Per-iteration cache-creation floor for a headless claude session (sonnet
490
+ * baseline; opus is ~$3.90).
491
+ */
492
+ export const SONNET_CACHE_USD = 1.60;
493
+ /** Estimate at or above which `mc run` warns and refuses in non-TTY contexts. */
494
+ export const COST_WARNING_THRESHOLD_USD = 5.0;
495
+ /**
496
+ * Providers whose headless stream reports spend, so a declared `--max-total-cost`
497
+ * ceiling can actually fire mid-loop.
498
+ *
499
+ * Only `claude` emits cost on its stream-json events, which is what
500
+ * `SessionLauncher._extractUsageStats` reads. Adapters without stream-json
501
+ * (codex, factory, opencode, deepseek) emit no usage events at all — #1766
502
+ * recorded that a spend ceiling on those is inert and never fires. Adapters that
503
+ * do stream JSON but have not been observed carrying cost fields (omp, pi) stay
504
+ * out of this set deliberately: assuming an observability we have not seen would
505
+ * weaken the gate in exactly the case where the operator has least protection.
506
+ */
507
+ export const COST_REPORTING_PROVIDERS = new Set(['claude']);
508
+ /**
509
+ * Estimate cumulative spend for a set of queued missions (#2522).
510
+ *
511
+ * The floor for each mission is `maxIterations × SONNET_CACHE_USD`. A declared
512
+ * `maxTotalCost` bounds that floor **only** when the target provider reports
513
+ * spend — on a provider that reports none, the ceiling is inert (#1766), so
514
+ * subtracting it would make the warning weaker precisely where the operator has
515
+ * no enforced protection. Those ceilings are counted and surfaced instead.
516
+ */
517
+ export function estimateMissionRunCost(missions, provider) {
518
+ const spendObservable = COST_REPORTING_PROVIDERS.has(provider);
519
+ let estimateUsd = 0;
520
+ let iterationFloorUsd = 0;
521
+ let declaredCeilingUsd = 0;
522
+ let cappedMissions = 0;
523
+ let inertCeilingMissions = 0;
524
+ for (const mission of missions) {
525
+ const floor = mission.maxIterations * SONNET_CACHE_USD;
526
+ iterationFloorUsd += floor;
527
+ const ceiling = typeof mission.maxTotalCost === 'number' && Number.isFinite(mission.maxTotalCost)
528
+ ? mission.maxTotalCost
529
+ : undefined;
530
+ if (ceiling === undefined) {
531
+ estimateUsd += floor;
532
+ continue;
533
+ }
534
+ declaredCeilingUsd += ceiling;
535
+ if (spendObservable) {
536
+ cappedMissions += 1;
537
+ estimateUsd += Math.min(floor, ceiling);
538
+ }
539
+ else {
540
+ inertCeilingMissions += 1;
541
+ estimateUsd += floor;
542
+ }
543
+ }
544
+ return {
545
+ estimateUsd,
546
+ iterationFloorUsd,
547
+ declaredCeilingUsd,
548
+ cappedMissions,
549
+ inertCeilingMissions,
550
+ spendObservable,
551
+ };
552
+ }
489
553
  async function mcRun(ctx) {
490
554
  const positional = getPositionalArgs(ctx.args);
491
555
  const sessionId = positional[0];
@@ -500,24 +564,28 @@ async function mcRun(ctx) {
500
564
  ui.info(`No queued missions in session ${session.id}. Run \`aiwg mc dispatch ${session.id} "<objective>"\` to add one.`);
501
565
  return { exitCode: 0 };
502
566
  }
503
- // #1450 P0: cost warning gate.
567
+ // #1450 P0: cost warning gate, with declared ceilings honored (#2522).
504
568
  //
505
569
  // Each headless claude session pays a ~$1.60 cache-creation cost on iteration
506
570
  // 1 before any user-meaningful work (sonnet baseline; opus is ~$3.90). Across
507
571
  // N missions × M iterations the floor compounds quickly. Warn before launch
508
572
  // and refuse in non-TTY contexts unless --accept-cost is set.
509
- //
510
- // Estimate is intentionally conservative: cumulative iteration floor =
511
- // missions × max_iterations × sonnet_cache_cost. Real spend may be lower if
512
- // missions complete in fewer iterations.
513
573
  const eligible = queued.filter(m => m.mode !== 'pty-orchestrator' && !!m.completion);
514
- const SONNET_CACHE_USD = 1.60;
515
- const iterFloor = eligible.reduce((sum, m) => sum + m.maxIterations, 0);
516
- const estimateUsd = iterFloor * SONNET_CACHE_USD;
517
- const COST_WARNING_THRESHOLD_USD = 5.0;
518
- if (estimateUsd >= COST_WARNING_THRESHOLD_USD && !acceptCost) {
574
+ const projectRoot = ctx.cwd || process.cwd();
575
+ const frameworkRoot = ctx.frameworkRoot;
576
+ const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
577
+ const cfg = await readAiwgConfig(projectRoot).catch(() => null);
578
+ const provider = cfg?.providers[0] ?? 'unknown';
579
+ const estimate = estimateMissionRunCost(eligible, provider);
580
+ if (estimate.estimateUsd >= COST_WARNING_THRESHOLD_USD && !acceptCost) {
519
581
  ui.blank();
520
- ui.warn(`Cost estimate: ~$${estimateUsd.toFixed(2)} (${eligible.length} missions × iteration floors × ~$${SONNET_CACHE_USD.toFixed(2)} cache cost per claude headless iter).`);
582
+ ui.warn(`Cost estimate: ~$${estimate.estimateUsd.toFixed(2)} (${eligible.length} missions × iteration floors × ~$${SONNET_CACHE_USD.toFixed(2)} cache cost per claude headless iter).`);
583
+ if (estimate.cappedMissions > 0) {
584
+ ui.warn(`Declared ceilings: $${estimate.declaredCeilingUsd.toFixed(2)} across ${estimate.cappedMissions} mission(s) — counted toward the estimate because ${provider} reports spend.`);
585
+ }
586
+ if (estimate.inertCeilingMissions > 0) {
587
+ ui.warn(`${estimate.inertCeilingMissions} mission(s) declare --max-total-cost totalling $${estimate.declaredCeilingUsd.toFixed(2)}, but ${provider} does not report spend — those ceilings cannot fire and are NOT subtracted from this estimate (#1766).`);
588
+ }
521
589
  ui.warn('Actual spend may be lower if missions complete early, higher if model is opus or context grows.');
522
590
  if (!process.stdout.isTTY) {
523
591
  ui.error('Refusing to launch in non-interactive context. Re-run with `--accept-cost` to proceed.');
@@ -538,12 +606,7 @@ async function mcRun(ctx) {
538
606
  let launched = 0;
539
607
  let skipped = 0;
540
608
  let failed = 0;
541
- const projectRoot = ctx.cwd || process.cwd();
542
- const frameworkRoot = ctx.frameworkRoot;
543
- const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
544
609
  const { FileAdmissionStore, SharedHostScheduler } = await import('../../serve/shared-host-scheduler.js');
545
- const cfg = await readAiwgConfig(projectRoot).catch(() => null);
546
- const provider = cfg?.providers[0] ?? 'unknown';
547
610
  const maxConcurrent = resolveParallelism(cfg?.parallelism, provider).max_parallel_mc_missions;
548
611
  const scheduler = new SharedHostScheduler(new FileAdmissionStore(join(projectRoot, MC_ROOT, 'admission.json')), {
549
612
  maxConcurrent,
@@ -13,6 +13,13 @@
13
13
  import { createScriptRunner } from './script-runner.js';
14
14
  import { launchExternalRalph, getLoopStatuses, abortLoop, resumeLoop, attachToLoopOutput, } from './ralph-launcher.js';
15
15
  import { handlerResultFromError } from '../errors.js';
16
+ /** Parse a complete positive decimal value without truncating counter limits. */
17
+ function parsePositiveLimit(raw, integer = false) {
18
+ if (typeof raw !== 'string' || !/^[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw))
19
+ return undefined;
20
+ const value = Number(raw);
21
+ return Number.isFinite(value) && value > 0 && (!integer || Number.isSafeInteger(value)) ? value : undefined;
22
+ }
16
23
  /**
17
24
  * Parse Ralph command arguments
18
25
  */
@@ -22,8 +29,8 @@ function parseRalphArgs(args) {
22
29
  // Present-but-invalid numeric values are a hard usage error: an operator who
23
30
  // typed --max-total-cost expects a ceiling to exist (#1770).
24
31
  const positiveNumber = (flag, raw, integer = false) => {
25
- const value = integer ? parseInt(raw ?? '', 10) : parseFloat(raw ?? '');
26
- if (!Number.isFinite(value) || value <= 0) {
32
+ const value = parsePositiveLimit(raw, integer);
33
+ if (value === undefined) {
27
34
  result.invalidFlags.push(`${flag} (got '${raw ?? ''}')`);
28
35
  return undefined;
29
36
  }
@@ -431,8 +438,11 @@ export class RalphResumeHandler {
431
438
  if (arg === '--loop-id' && ctx.args[i + 1]) {
432
439
  loopId = ctx.args[++i];
433
440
  }
434
- else if (arg === '--max-iterations' && ctx.args[i + 1]) {
435
- maxIterations = parseInt(ctx.args[++i], 10);
441
+ else if (arg === '--max-iterations') {
442
+ maxIterations = parsePositiveLimit(ctx.args[++i], true);
443
+ if (maxIterations === undefined) {
444
+ return { exitCode: 1, message: 'Error: --max-iterations requires a positive safe integer. Loop not resumed.' };
445
+ }
436
446
  }
437
447
  }
438
448
  try {