@aiwg/cli 2026.9.7 → 2026.9.10

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 (48) hide show
  1. package/bin/aiwg.mjs +55 -10
  2. package/dist/src/artifacts/cli.js +20 -8
  3. package/dist/src/artifacts/corpus-views/taxonomies.js +1 -1
  4. package/dist/src/artifacts/index-builder.js +43 -1
  5. package/dist/src/artifacts/query-engine.js +7 -0
  6. package/dist/src/cli/handlers/help.js +6 -5
  7. package/dist/src/cli/handlers/init.js +7 -2
  8. package/dist/src/cli/handlers/installation.js +102 -2
  9. package/dist/src/cli/handlers/mc.js +87 -17
  10. package/dist/src/cli/handlers/ralph.js +8 -8
  11. package/dist/src/cli/handlers/refresh.js +67 -7
  12. package/dist/src/cli/handlers/repo-access.js +155 -4
  13. package/dist/src/cli/handlers/setup.js +5 -5
  14. package/dist/src/cli/handlers/steward.js +39 -2
  15. package/dist/src/cli/handlers/use.js +13 -4
  16. package/dist/src/cli/handlers/version.js +49 -14
  17. package/dist/src/cli/handlers/workspace-context.js +8 -0
  18. package/dist/src/cli/services/deployment-verification.js +71 -10
  19. package/dist/src/cli/watch-service.js +43 -3
  20. package/dist/src/config/aiwg-config.js +95 -3
  21. package/dist/src/config/cli.js +16 -1
  22. package/dist/src/config/gitignore.js +5 -0
  23. package/dist/src/dataset/fortemi-capability.d.ts +70 -0
  24. package/dist/src/dataset/fortemi-capability.js +211 -0
  25. package/dist/src/dataset/fortemi-dataset-execution.d.ts +9 -0
  26. package/dist/src/dataset/fortemi-dataset-execution.js +49 -8
  27. package/dist/src/dataset/fortemi-run-receipt.d.ts +1 -0
  28. package/dist/src/dataset/fortemi-run-receipt.js +2 -19
  29. package/dist/src/dataset/fortemi-schema-path.d.ts +7 -0
  30. package/dist/src/dataset/fortemi-schema-path.js +24 -0
  31. package/dist/src/dataset/index.d.ts +1 -0
  32. package/dist/src/dataset/index.js +1 -0
  33. package/dist/src/extensions/claude-hooks-installer.js +22 -6
  34. package/dist/src/extensions/project-local-doctor.js +15 -1
  35. package/dist/src/lint/cli.js +5 -1
  36. package/dist/src/lint/reporters.js +17 -0
  37. package/dist/src/lint/runner.js +297 -4
  38. package/dist/src/policy/authorization.js +1 -1
  39. package/dist/src/smiths/context-pipeline/workspace-context.js +138 -49
  40. package/package.json +1 -1
  41. package/schemas/dataset/fortemi-capability-validation/1.0.1/authority.json +61 -0
  42. package/schemas/dataset/fortemi-capability-validation/1.0.1/capability.schema.json +469 -0
  43. package/schemas/security/threat-assessment-report.v1.schema.json +2 -1
  44. package/tools/agents/deploy-agents.mjs +8 -0
  45. package/tools/agents/providers/base.mjs +261 -16
  46. package/tools/agents/providers/claude.mjs +61 -4
  47. package/tools/agents/providers/codex.mjs +9 -0
  48. package/tools/skills/deploy-skills-codex.mjs +121 -7
package/bin/aiwg.mjs CHANGED
@@ -166,11 +166,12 @@ const FAST_HELP_TEXT = `
166
166
  PROMOTE
167
167
  promote <name> Graduate project-local bundle to upstream (--to upstream|corpus, --dry-run, --cleanup)
168
168
 
169
- RALPH LOOP
170
- ralph "<task>" Execute iterative task loop (--completion, --max-iterations)
171
- ralph-status Check current loop status
172
- ralph-abort Abort running loop
173
- ralph-resume Resume interrupted loop
169
+ AGENT LOOP
170
+ agent-loop "<task>" Execute iterative task loop (--completion, --max-iterations)
171
+ agent-loop-status Check current loop status
172
+ agent-loop-abort Abort running loop
173
+ agent-loop-resume Resume interrupted loop
174
+ (legacy ralph* names remain accepted as aliases)
174
175
 
175
176
  MAINTENANCE
176
177
  doctor Check installation health
@@ -382,6 +383,29 @@ async function applyVerbosityFromArgs(args, routerPath) {
382
383
  return level;
383
384
  }
384
385
 
386
+ /**
387
+ * Commands that only read state and therefore stay available while the
388
+ * recorded installation identity disagrees with the executing checkout
389
+ * (#2559). Everything else fails closed until the operator adopts or
390
+ * switches the identity. `index` is limited to its inspection subcommands;
391
+ * `index build` rewrites the capability index and is treated as a write.
392
+ */
393
+ const RECOVERY_SAFE_COMMANDS = new Set([
394
+ 'version', 'status', 'doctor', 'runtime-info', 'discover', 'show', 'help',
395
+ ]);
396
+ const RECOVERY_SAFE_INDEX_SUBCOMMANDS = new Set(['query', 'deps', 'stats']);
397
+
398
+ function isRecoverySafeCommand(args) {
399
+ const command = args.find((arg) => !arg.startsWith('-'));
400
+ if (!command) return false;
401
+ if (RECOVERY_SAFE_COMMANDS.has(command)) return true;
402
+ if (command === 'index') {
403
+ const sub = args.slice(args.indexOf(command) + 1).find((arg) => !arg.startsWith('-'));
404
+ return sub !== undefined && RECOVERY_SAFE_INDEX_SUBCOMMANDS.has(sub);
405
+ }
406
+ return false;
407
+ }
408
+
385
409
  async function main() {
386
410
  const args = process.argv.slice(2);
387
411
 
@@ -423,11 +447,32 @@ async function main() {
423
447
  }
424
448
  const { assertCanonicalInstallation } = await import(pathToFileURL(identityPath).href);
425
449
  const readOnlyPreview = args.includes('--dry-run');
426
- assertCanonicalInstallation({
427
- actualRoot: activePackageRoot,
428
- createIfMissing: !readOnlyPreview,
429
- allowUnrecorded: readOnlyPreview,
430
- });
450
+ try {
451
+ assertCanonicalInstallation({
452
+ actualRoot: activePackageRoot,
453
+ createIfMissing: !readOnlyPreview,
454
+ allowUnrecorded: readOnlyPreview,
455
+ });
456
+ } catch (error) {
457
+ if (error?.code !== 'AIWG_INSTALLATION_DRIFT' || !isRecoverySafeCommand(args)) throw error;
458
+ // Read-only diagnostics stay reachable under drift so the documented
459
+ // recovery ladder (status → doctor → runtime-info → version → local
460
+ // discover) can run before the identity is repaired. Every mutating
461
+ // command still fails closed above (#2559).
462
+ const drift = Array.isArray(error.status?.drift) ? error.status.drift : [];
463
+ process.env['AIWG_INSTALLATION_DRIFT'] = JSON.stringify({
464
+ state: error.status?.state ?? 'mismatch',
465
+ drift,
466
+ });
467
+ process.stderr.write(
468
+ `aiwg: warning: installation identity drift detected; \`${args[0]}\` is read-only and continues, ` +
469
+ 'but update, refresh, and deployment stay blocked until the identity is repaired.\n' +
470
+ drift.map((item) => `- ${item}\n`).join('') +
471
+ 'Inspect: aiwg installation show\n' +
472
+ 'Adopt this installation: aiwg installation adopt\n' +
473
+ 'Switch deliberately: aiwg installation switch --root <path> --method <npm|web|source> [--manager <absolute-path>]\n',
474
+ );
475
+ }
431
476
  }
432
477
 
433
478
  // Wire up the logger level from -v/-vv/--quiet/AIWG_LOG_LEVEL before any
@@ -1374,6 +1374,10 @@ async function handleSetQuery(args) {
1374
1374
  backend,
1375
1375
  });
1376
1376
  }
1377
+ /** `index discover` flags that consume the following argument as a value. */
1378
+ const DISCOVER_VALUE_FLAGS = new Set([
1379
+ '--type', '--limit', '--format', '--graph', '--backend', '--resource-source', '--aiwg-version',
1380
+ ]);
1377
1381
  /**
1378
1382
  * Handle 'index discover' command — capability-search for AIWG operational
1379
1383
  * assets.
@@ -1391,17 +1395,25 @@ async function handleSetQuery(args) {
1391
1395
  async function handleDiscover(args) {
1392
1396
  const { discoverCapability } = await import('./query-engine.js');
1393
1397
  const cwd = process.cwd();
1394
- // Parse positional phrase (everything before flags)
1398
+ // Parse the positional phrase. Flags may appear before or after it — the
1399
+ // documented local-backend fallback is written flag-first
1400
+ // (`aiwg discover --backend local "<phrase>"`, #2530), so a phrase after a
1401
+ // value flag must not be swallowed as that flag's argument (#2559).
1395
1402
  const textParts = [];
1396
1403
  const flags = [];
1397
- let inFlags = false;
1398
- for (const arg of args) {
1399
- if (arg.startsWith('--'))
1400
- inFlags = true;
1401
- if (inFlags)
1402
- flags.push(arg);
1403
- else
1404
+ for (let index = 0; index < args.length; index += 1) {
1405
+ const arg = args[index];
1406
+ if (!arg.startsWith('--')) {
1404
1407
  textParts.push(arg);
1408
+ continue;
1409
+ }
1410
+ flags.push(arg);
1411
+ const bare = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
1412
+ const next = args[index + 1];
1413
+ if (DISCOVER_VALUE_FLAGS.has(bare) && !arg.includes('=') && next !== undefined && !next.startsWith('--')) {
1414
+ flags.push(next);
1415
+ index += 1;
1416
+ }
1405
1417
  }
1406
1418
  const phrase = textParts.join(' ').trim();
1407
1419
  if (!phrase) {
@@ -110,7 +110,7 @@ export const PIPELINE_STAGES = [
110
110
  ['4. Mixture-of-Experts & Sparse Architectures', 'Sparsely activated models as a parallel pretraining strategy.', ['REF-007']],
111
111
  ['5. Instruction Tuning & SFT', 'Supervised fine-tuning on instructions to convert base models into assistants.', ['REF-055', 'REF-435', 'REF-470']],
112
112
  ['6. RLHF, DPO, and Preference Optimization', 'Aligning models with human and AI feedback.', ['REF-025', 'REF-055', 'REF-449']],
113
- ['7. RLVR & Reasoning RL', 'Verifiable-reward RL for reasoning, math, and code.', ['REF-819', 'REF-957', 'REF-457']],
113
+ ['7. RLVR & Reasoning RL', 'Verifiable-reward RL for reasoning, math, and code.', ['REF-394', 'REF-957', 'REF-457']],
114
114
  ['8. PEFT / LoRA / Adapter-Based Adaptation', 'Parameter-efficient fine-tuning when full SFT is impractical.', ['REF-049']],
115
115
  ['9. Quantization, Pruning & Compression', 'Shrinking trained models for inference deployment.', ['REF-051']],
116
116
  ['10. Inference Serving & Optimization', 'Productionizing trained models: batching, KV cache, throughput.', ['REF-101', 'REF-102', 'REF-312', 'REF-313']],
@@ -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/') ||
@@ -134,11 +134,12 @@ function displayHelp() {
134
134
  helpGroup('PROMOTE', [
135
135
  ['promote <name>', 'Graduate project-local bundle to upstream (--to upstream|corpus, --dry-run, --cleanup)'],
136
136
  ]);
137
- helpGroup('RALPH LOOP', [
138
- ['ralph "<task>"', 'Execute iterative task loop (--completion, --max-iterations)'],
139
- ['ralph-status', 'Check current loop status'],
140
- ['ralph-abort', 'Abort running loop'],
141
- ['ralph-resume', 'Resume interrupted loop'],
137
+ helpGroup('AGENT LOOP', [
138
+ ['agent-loop "<task>"', 'Execute iterative task loop (--completion, --max-iterations)'],
139
+ ['agent-loop-status', 'Check current loop status'],
140
+ ['agent-loop-abort', 'Abort running loop'],
141
+ ['agent-loop-resume', 'Resume interrupted loop'],
142
+ ['', 'Legacy ralph* names remain accepted as aliases'],
142
143
  ]);
143
144
  helpGroup('MAINTENANCE', [
144
145
  ['doctor', 'Check installation health'],
@@ -17,6 +17,7 @@ import readline from 'readline';
17
17
  import { readAiwgConfig, writeAiwgConfig, emptyConfig, populateDeployedTo, VALID_PROVIDERS, getConfigPath, } from '../../config/aiwg-config.js';
18
18
  import * as ui from '../ui.js';
19
19
  import { askString as sharedAskString, askYesNo as sharedAskYesNo } from '../prompt-utils.js';
20
+ import { normalizeProviderDefinitionId } from '../../providers/provider-definitions.js';
20
21
  import { writeNormalizedAiwgMd } from '../../smiths/context-pipeline/finalization.js';
21
22
  import { ensureWorkspaceContext } from '../../smiths/context-pipeline/workspace-context.js';
22
23
  const PROVIDER_LABELS = {
@@ -61,9 +62,13 @@ async function askProviders(rl, signal) {
61
62
  const num = parseInt(part, 10);
62
63
  if (!isNaN(num) && num >= 1 && num <= VALID_PROVIDERS.length) {
63
64
  selected.push(VALID_PROVIDERS[num - 1]);
65
+ continue;
64
66
  }
65
- else if (VALID_PROVIDERS.includes(part)) {
66
- selected.push(part);
67
+ // Accept canonical ids and registered aliases (e.g. `dsh` for
68
+ // `deepseek-harness`) so the picker honours the same names as `--provider`.
69
+ const canonical = normalizeProviderDefinitionId(part);
70
+ if (canonical && VALID_PROVIDERS.includes(canonical)) {
71
+ selected.push(canonical);
67
72
  }
68
73
  else {
69
74
  ui.warn(` Unknown provider '${part}' — skipped`);
@@ -29,16 +29,84 @@ function display(status, json) {
29
29
  for (const item of status.drift)
30
30
  console.log(` - ${item}`);
31
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
+ }
32
39
  console.log('');
33
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
+ }
34
97
  export const installationHandler = {
35
98
  id: 'installation',
36
99
  name: 'Installation',
37
100
  description: 'Inspect, adopt, or deliberately switch the canonical global installation',
38
101
  category: 'maintenance',
39
102
  aliases: [],
103
+ async help() {
104
+ return { exitCode: 0, message: usage(), rawOutput: true };
105
+ },
40
106
  async execute(ctx) {
41
107
  const [action = 'show'] = ctx.args;
108
+ if (action === 'help')
109
+ return { exitCode: 0, message: usage(), rawOutput: true };
42
110
  const json = ctx.args.includes('--json');
43
111
  const actualRoot = getPackageRoot();
44
112
  const common = {
@@ -54,11 +122,43 @@ export const installationHandler = {
54
122
  }
55
123
  if (action === 'adopt') {
56
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
+ }
57
154
  const status = adoptInstallation({
58
155
  ...common,
59
156
  method,
60
157
  runMode: valueAfter(ctx.args, '--run-mode'),
61
158
  });
159
+ if (abandoning) {
160
+ console.log(`Warning: adopted the running ${status.actualMethod} install; the previously declared ${declaredMethod} install is no longer canonical.`);
161
+ }
62
162
  display(status, json);
63
163
  return { exitCode: 0 };
64
164
  }
@@ -66,7 +166,7 @@ export const installationHandler = {
66
166
  const root = valueAfter(ctx.args, '--root');
67
167
  const method = valueAfter(ctx.args, '--method');
68
168
  if (!root || !method) {
69
- 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 };
70
170
  }
71
171
  const status = switchInstallation({
72
172
  ...common,
@@ -77,7 +177,7 @@ export const installationHandler = {
77
177
  display(status, json);
78
178
  return { exitCode: 0 };
79
179
  }
80
- return { exitCode: 2, message: 'Usage: aiwg installation <show|adopt|switch> [options]' };
180
+ return { exitCode: 2, message: `Unknown installation action: ${action}\n${usage()}`, rawOutput: true };
81
181
  },
82
182
  };
83
183
  //# sourceMappingURL=installation.js.map
@@ -228,7 +228,13 @@ Drain queued missions in a session by launching each as a ralph loop. Missions
228
228
  without --completion criteria are skipped with a warning.
229
229
 
230
230
  --accept-cost Skip the cost-warning gate (required for non-TTY contexts
231
- 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.`,
232
238
  status: `Usage: aiwg mc status [<session-id>] [--json]
233
239
 
234
240
  Show mission status for a session. Auto-syncs from ralph loop state files.
@@ -479,6 +485,71 @@ async function mcDispatch(ctx) {
479
485
  * detached process, so they DO run in parallel after launch — the loop here
480
486
  * is just for orderly dispatch, not for parallel scheduling)
481
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
+ }
482
553
  async function mcRun(ctx) {
483
554
  const positional = getPositionalArgs(ctx.args);
484
555
  const sessionId = positional[0];
@@ -493,24 +564,28 @@ async function mcRun(ctx) {
493
564
  ui.info(`No queued missions in session ${session.id}. Run \`aiwg mc dispatch ${session.id} "<objective>"\` to add one.`);
494
565
  return { exitCode: 0 };
495
566
  }
496
- // #1450 P0: cost warning gate.
567
+ // #1450 P0: cost warning gate, with declared ceilings honored (#2522).
497
568
  //
498
569
  // Each headless claude session pays a ~$1.60 cache-creation cost on iteration
499
570
  // 1 before any user-meaningful work (sonnet baseline; opus is ~$3.90). Across
500
571
  // N missions × M iterations the floor compounds quickly. Warn before launch
501
572
  // and refuse in non-TTY contexts unless --accept-cost is set.
502
- //
503
- // Estimate is intentionally conservative: cumulative iteration floor =
504
- // missions × max_iterations × sonnet_cache_cost. Real spend may be lower if
505
- // missions complete in fewer iterations.
506
573
  const eligible = queued.filter(m => m.mode !== 'pty-orchestrator' && !!m.completion);
507
- const SONNET_CACHE_USD = 1.60;
508
- const iterFloor = eligible.reduce((sum, m) => sum + m.maxIterations, 0);
509
- const estimateUsd = iterFloor * SONNET_CACHE_USD;
510
- const COST_WARNING_THRESHOLD_USD = 5.0;
511
- 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) {
512
581
  ui.blank();
513
- 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
+ }
514
589
  ui.warn('Actual spend may be lower if missions complete early, higher if model is opus or context grows.');
515
590
  if (!process.stdout.isTTY) {
516
591
  ui.error('Refusing to launch in non-interactive context. Re-run with `--accept-cost` to proceed.');
@@ -531,12 +606,7 @@ async function mcRun(ctx) {
531
606
  let launched = 0;
532
607
  let skipped = 0;
533
608
  let failed = 0;
534
- const projectRoot = ctx.cwd || process.cwd();
535
- const frameworkRoot = ctx.frameworkRoot;
536
- const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
537
609
  const { FileAdmissionStore, SharedHostScheduler } = await import('../../serve/shared-host-scheduler.js');
538
- const cfg = await readAiwgConfig(projectRoot).catch(() => null);
539
- const provider = cfg?.providers[0] ?? 'unknown';
540
610
  const maxConcurrent = resolveParallelism(cfg?.parallelism, provider).max_parallel_mc_missions;
541
611
  const scheduler = new SharedHostScheduler(new FileAdmissionStore(join(projectRoot, MC_ROOT, 'admission.json')), {
542
612
  maxConcurrent,
@@ -156,7 +156,7 @@ export class RalphHandler {
156
156
  name = 'Agent Loop';
157
157
  description = 'Execute iterative task loop with automatic completion detection';
158
158
  category = 'ralph';
159
- aliases = ['ralph', '-ralph', '--ralph'];
159
+ aliases = ['agent-loop', '--agent-loop', 'ralph', '-ralph', '--ralph'];
160
160
  async execute(ctx) {
161
161
  const parsed = parseRalphArgs(ctx.args);
162
162
  // Show help
@@ -334,7 +334,7 @@ export class RalphStatusHandler {
334
334
  name = 'Ralph Status';
335
335
  description = 'Show Agent loop status and iteration history';
336
336
  category = 'ralph';
337
- aliases = ['ralph-status'];
337
+ aliases = ['agent-loop-status', 'ralph-status'];
338
338
  async execute(ctx) {
339
339
  const showAll = ctx.args.includes('--all') || ctx.args.includes('-a');
340
340
  const statuses = getLoopStatuses(process.cwd());
@@ -402,7 +402,7 @@ export class RalphAbortHandler {
402
402
  name = 'Ralph Abort';
403
403
  description = 'Abort currently running Agent loop';
404
404
  category = 'ralph';
405
- aliases = ['ralph-abort'];
405
+ aliases = ['agent-loop-abort', 'ralph-abort'];
406
406
  async execute(ctx) {
407
407
  // Parse --loop-id if provided
408
408
  let loopId;
@@ -428,7 +428,7 @@ export class RalphResumeHandler {
428
428
  name = 'Ralph Resume';
429
429
  description = 'Resume previously aborted Agent loop';
430
430
  category = 'ralph';
431
- aliases = ['ralph-resume'];
431
+ aliases = ['agent-loop-resume', 'ralph-resume'];
432
432
  async execute(ctx) {
433
433
  // Parse arguments
434
434
  let loopId;
@@ -493,7 +493,7 @@ export const ralphExternalHandler = {
493
493
  name: 'Agent Loop External',
494
494
  description: 'Crash-resilient external loop with state persistence and CI/CD integration',
495
495
  category: 'ralph',
496
- aliases: ['ralph-external', '--ralph-external', '--agent-loop-ext'],
496
+ aliases: ['agent-loop-ext', '--agent-loop-ext', 'ralph-external', '--ralph-external'],
497
497
  async execute(ctx) {
498
498
  const runner = createScriptRunner(ctx.frameworkRoot);
499
499
  // Delegate to the external Ralph supervisor script
@@ -510,7 +510,7 @@ export const ralphMemoryHandler = {
510
510
  name: 'Ralph Memory',
511
511
  description: 'Manage Ralph semantic memory entries (list, query, clear)',
512
512
  category: 'ralph',
513
- aliases: ['--ralph-memory'],
513
+ aliases: ['--agent-loop-memory', '--ralph-memory'],
514
514
  async execute(ctx) {
515
515
  const runner = createScriptRunner(ctx.frameworkRoot);
516
516
  return runner.run('tools/ralph-external/memory-manager.mjs', ['--cli', ...ctx.args]);
@@ -526,7 +526,7 @@ export const ralphConfigHandler = {
526
526
  name: 'Ralph Config',
527
527
  description: 'View and configure Agent loop settings (show, set, reset, preset)',
528
528
  category: 'ralph',
529
- aliases: ['--ralph-config'],
529
+ aliases: ['--agent-loop-config', '--ralph-config'],
530
530
  async execute(ctx) {
531
531
  const runner = createScriptRunner(ctx.frameworkRoot);
532
532
  return runner.run('tools/ralph-external/orchestrator.mjs', ['--config', ...ctx.args]);
@@ -543,7 +543,7 @@ export class RalphAttachHandler {
543
543
  name = 'Ralph Attach';
544
544
  description = 'Attach to a running Agent loop\'s live output stream';
545
545
  category = 'ralph';
546
- aliases = ['ralph-attach'];
546
+ aliases = ['agent-loop-attach', 'ralph-attach'];
547
547
  async execute(ctx) {
548
548
  let loopId;
549
549
  const loopIdIndex = ctx.args.indexOf('--loop-id');