@aiwg/cli 2026.9.7 → 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.
@@ -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/') ||
@@ -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,
@@ -40,6 +40,48 @@ const PROVIDER_AGENT_DIRS = {
40
40
  export async function currentBundledAgentBasenames(frameworkRoot) {
41
41
  return new Set((await collectPackagedAgentInventory(frameworkRoot)).keys());
42
42
  }
43
+ /**
44
+ * Basenames of every rule the current package can deploy.
45
+ *
46
+ * Symmetric to {@link currentBundledAgentBasenames}. A rule absent from this set
47
+ * is residue from a deploy model that no longer writes it (#2540).
48
+ *
49
+ * Every group that can ship a `rules/` directory must be listed here: the set is
50
+ * the prune's definition of "still shipped", so a missed group makes live rules
51
+ * look orphaned and deletes them. `extensions` ships 22 rules and was the group
52
+ * this nearly lost.
53
+ */
54
+ export const BUNDLED_RULE_SOURCE_GROUPS = ['frameworks', 'addons', 'plugins', 'extensions'];
55
+ export async function currentBundledRuleBasenames(frameworkRoot) {
56
+ const names = new Set();
57
+ const codeRoot = path.join(frameworkRoot, 'agentic', 'code');
58
+ for (const group of BUNDLED_RULE_SOURCE_GROUPS) {
59
+ let units;
60
+ try {
61
+ units = await fs.readdir(path.join(codeRoot, group), { withFileTypes: true });
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ for (const unit of units) {
67
+ if (!unit.isDirectory())
68
+ continue;
69
+ const rulesDir = path.join(codeRoot, group, unit.name, 'rules');
70
+ let entries;
71
+ try {
72
+ entries = await fs.readdir(rulesDir, { withFileTypes: true });
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ for (const entry of entries) {
78
+ if (entry.isFile() && entry.name.endsWith('.md'))
79
+ names.add(entry.name);
80
+ }
81
+ }
82
+ }
83
+ return names;
84
+ }
43
85
  async function readFrameworkVersion(frameworkRoot) {
44
86
  try {
45
87
  const pkg = JSON.parse(await fs.readFile(path.join(frameworkRoot, 'package.json'), 'utf8'));
@@ -186,6 +228,7 @@ export async function partitionTrackedPaths(projectRoot, relativePaths) {
186
228
  }
187
229
  export async function pruneStaleManagedAgentFiles(options) {
188
230
  const desired = await currentBundledAgentBasenames(options.frameworkRoot);
231
+ const desiredRules = await currentBundledRuleBasenames(options.frameworkRoot);
189
232
  const currentVersion = options.currentVersion ?? await readFrameworkVersion(options.frameworkRoot);
190
233
  const crossProvider = options.crossProvider ?? 'skip';
191
234
  const removals = [];
@@ -204,7 +247,10 @@ export async function pruneStaleManagedAgentFiles(options) {
204
247
  // provider only drops agents whose source no longer ships them.
205
248
  if (!isTargetProvider && crossProvider === 'skip')
206
249
  continue;
207
- const kinds = isTargetProvider ? ['agents'] : PRUNABLE_ARTIFACT_KINDS;
250
+ // Rules were excluded from the target-provider pass, so a project carried
251
+ // every rule any past version ever deployed. On long-lived projects that is
252
+ // the bulk of the startup-context budget (#2540).
253
+ const kinds = isTargetProvider ? ['agents', 'rules'] : PRUNABLE_ARTIFACT_KINDS;
208
254
  const hits = await collectManagedProviderArtifacts(options.projectRoot, provider, kinds);
209
255
  const eligible = hits.filter((hit) => {
210
256
  if (isTargetProvider) {
@@ -212,6 +258,8 @@ export async function pruneStaleManagedAgentFiles(options) {
212
258
  // marker to the top-level package version makes a successful refresh
213
259
  // delete freshly restored addon agents, so the active provider removes
214
260
  // only artifacts absent from current sources.
261
+ if (hit.kind === 'rules')
262
+ return !desiredRules.has(path.basename(hit.relativePath));
215
263
  return !desired.has(hit.artifactName);
216
264
  }
217
265
  return currentVersion !== null && isOlderManagedVersion(hit.version, currentVersion);
@@ -583,7 +631,11 @@ export const refreshHandler = {
583
631
  }
584
632
  let staleAgentRemovals = [];
585
633
  let trackedSkipped = [];
586
- if (!dryRun && deploymentFailures.length === 0) {
634
+ // A dry run still reports what a real run would remove. Skipping the pass
635
+ // entirely meant `refresh --dry-run` printed "Checking for stale deployments..."
636
+ // and nothing else, so orphaned artifacts were invisible until they had
637
+ // pushed the project over its context budget (#2540).
638
+ if (deploymentFailures.length === 0) {
587
639
  try {
588
640
  const pruneResult = await pruneStaleManagedAgentFiles({
589
641
  projectRoot: ctx.cwd,
@@ -591,6 +643,7 @@ export const refreshHandler = {
591
643
  provider: detectedProvider,
592
644
  crossProvider: pruneOtherProviders ? 'prune' : 'skip',
593
645
  allowTrackedDeletes: pruneTracked,
646
+ dryRun,
594
647
  });
595
648
  staleAgentRemovals = pruneResult.removals;
596
649
  trackedSkipped = pruneResult.trackedSkipped;
@@ -607,19 +660,24 @@ export const refreshHandler = {
607
660
  }
608
661
  if (staleAgentRemovals.length > 0 && !quiet) {
609
662
  const total = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
610
- ui.warn(`Removed ${total} stale AIWG-managed file${total === 1 ? '' : 's'} ` +
663
+ ui.warn(`${dryRun ? 'Would remove' : 'Removed'} ${total} stale AIWG-managed file${total === 1 ? '' : 's'} ` +
611
664
  `across ${staleAgentRemovals.length} provider${staleAgentRemovals.length === 1 ? '' : 's'}`);
612
665
  for (const removal of staleAgentRemovals) {
613
666
  const shown = removal.paths.slice(0, 3).join(', ');
614
667
  const remainder = removal.paths.length - 3;
615
668
  ui.dim(` ${removal.provider}: ${removal.paths.length} (${shown}${remainder > 0 ? `, ...and ${remainder} more` : ''})`);
616
669
  }
617
- ui.dim(' Review `git status` before committing — deployed artifacts may be tracked.');
670
+ ui.dim(dryRun
671
+ ? ' Re-run without --dry-run to remove them.'
672
+ : ' Review `git status` before committing — deployed artifacts may be tracked.');
618
673
  }
619
674
  // #2506: keep the recorded deployment state consistent with what the
620
675
  // prune actually left on disk, so a later run does not trust counts
621
- // for artifacts that no longer exist.
622
- await reconcileDeployedToAfterPrune(ctx.cwd, staleAgentRemovals, pruneOtherProviders ? detectedProvider : null);
676
+ // for artifacts that no longer exist. A dry run deleted nothing, so the
677
+ // recorded state is already accurate.
678
+ if (!dryRun) {
679
+ await reconcileDeployedToAfterPrune(ctx.cwd, staleAgentRemovals, pruneOtherProviders ? detectedProvider : null);
680
+ }
623
681
  }
624
682
  catch {
625
683
  if (!quiet)
@@ -685,7 +743,9 @@ export const refreshHandler = {
685
743
  // #2506: a run that deleted artifacts is not an "up to date" run.
686
744
  const removed = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
687
745
  if (!quiet) {
688
- ui.warn(`Deployments current, but ${removed} stale artifact(s) were removed this run — review the list above`);
746
+ ui.warn(dryRun
747
+ ? `Deployments current, but ${removed} stale artifact(s) would be removed — review the list above`
748
+ : `Deployments current, but ${removed} stale artifact(s) were removed this run — review the list above`);
689
749
  }
690
750
  }
691
751
  else {