@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
@@ -13,7 +13,10 @@
13
13
  * @issue #173, #174, #482, #557, #694
14
14
  */
15
15
  import { promises as fs } from 'fs';
16
+ import { execFile } from 'node:child_process';
16
17
  import path from 'path';
18
+ import { promisify } from 'node:util';
19
+ const execFileAsync = promisify(execFile);
17
20
  import { createScriptRunner } from './script-runner.js';
18
21
  import { createUseHandler } from './use.js';
19
22
  import { getFrameworkRoot } from '../../channel/manager.mjs';
@@ -21,6 +24,7 @@ import { refreshAllPackages } from '../../packages/registry.js';
21
24
  import { resolveActiveProvider } from '../provider-resolution.js';
22
25
  import { readAiwgConfig, writeAiwgConfig, hashManifest, getProviderParallelismDefaults, } from '../../config/aiwg-config.js';
23
26
  import { discoverProjectLocalBundles } from '../../extensions/project-local-discovery.js';
27
+ import { getProviderArtifactPathStrings } from '../../providers/provider-definitions.js';
24
28
  import { collectPackagedAgentInventory, normalizeAgentArtifactName, parseManagedArtifactMarker, } from '../../agents/packaged-agent-inventory.js';
25
29
  import * as ui from '../ui.js';
26
30
  const PROVIDER_AGENT_DIRS = {
@@ -36,6 +40,48 @@ const PROVIDER_AGENT_DIRS = {
36
40
  export async function currentBundledAgentBasenames(frameworkRoot) {
37
41
  return new Set((await collectPackagedAgentInventory(frameworkRoot)).keys());
38
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
+ }
39
85
  async function readFrameworkVersion(frameworkRoot) {
40
86
  try {
41
87
  const pkg = JSON.parse(await fs.readFile(path.join(frameworkRoot, 'package.json'), 'utf8'));
@@ -69,12 +115,33 @@ function isOlderManagedVersion(deployedVersion, currentVersion) {
69
115
  return true;
70
116
  return deployed.prerelease.localeCompare(current.prerelease, undefined, { numeric: true }) < 0;
71
117
  }
72
- export async function pruneStaleManagedAgentFiles(options) {
73
- const desired = await currentBundledAgentBasenames(options.frameworkRoot);
74
- const currentVersion = options.currentVersion ?? await readFrameworkVersion(options.frameworkRoot);
75
- const removals = [];
76
- for (const [provider, relDir] of Object.entries(PROVIDER_AGENT_DIRS)) {
77
- const dir = path.join(options.projectRoot, relDir);
118
+ const PRUNABLE_ARTIFACT_KINDS = ['agents', 'commands', 'rules'];
119
+ /**
120
+ * Resolve the on-disk artifact directories for a provider.
121
+ *
122
+ * `PROVIDER_AGENT_DIRS` remains the enumeration of providers AIWG can prune;
123
+ * the command/rule directories come from the provider definitions so the
124
+ * unit-prune path never drifts from what `aiwg use` actually wrote.
125
+ */
126
+ function providerArtifactDirs(provider) {
127
+ const agents = PROVIDER_AGENT_DIRS[provider] ?? null;
128
+ const declared = getProviderArtifactPathStrings(provider);
129
+ const usable = (value) => typeof value === 'string' && value.length > 0 && !path.isAbsolute(value) ? value : null;
130
+ return {
131
+ agents,
132
+ commands: usable(declared?.commands),
133
+ rules: usable(declared?.rules),
134
+ };
135
+ }
136
+ /** Collect every AIWG-managed (`source: bundled`) artifact in one provider tree. */
137
+ async function collectManagedProviderArtifacts(projectRoot, provider, kinds) {
138
+ const dirs = providerArtifactDirs(provider);
139
+ const hits = [];
140
+ for (const kind of kinds) {
141
+ const relDir = dirs[kind];
142
+ if (!relDir)
143
+ continue;
144
+ const dir = path.join(projectRoot, relDir);
78
145
  let entries;
79
146
  try {
80
147
  entries = await fs.readdir(dir, { withFileTypes: true });
@@ -85,6 +152,9 @@ export async function pruneStaleManagedAgentFiles(options) {
85
152
  for (const entry of entries) {
86
153
  if (!entry.isFile() || !entry.name.endsWith('.md'))
87
154
  continue;
155
+ // Generated indexes are rewritten on every deploy, never orphaned.
156
+ if (entry.name === 'RULES-INDEX.md' || entry.name === 'RULES-ONDEMAND.md')
157
+ continue;
88
158
  const file = path.join(dir, entry.name);
89
159
  let content;
90
160
  try {
@@ -96,32 +166,198 @@ export async function pruneStaleManagedAgentFiles(options) {
96
166
  const marker = parseManagedArtifactMarker(content);
97
167
  if (marker?.source !== 'bundled')
98
168
  continue;
99
- const artifactName = normalizeAgentArtifactName(entry.name);
100
- const missingFromCurrentPackage = !desired.has(artifactName);
101
- // Addons have independent manifest versions. Comparing their managed
102
- // marker to the top-level package version makes a successful refresh
103
- // delete freshly restored addon agents. Version-based cleanup remains
104
- // valid for other provider trees that were not refreshed, while the
105
- // active provider removes only artifacts absent from current sources.
106
- const fromOlderPackage = provider !== options.provider
107
- && currentVersion !== null
108
- && isOlderManagedVersion(marker.version, currentVersion);
109
- if (!missingFromCurrentPackage && !fromOlderPackage)
169
+ hits.push({
170
+ kind,
171
+ absolutePath: file,
172
+ relativePath: path.relative(projectRoot, file),
173
+ version: marker.version,
174
+ artifactName: normalizeAgentArtifactName(entry.name),
175
+ });
176
+ }
177
+ }
178
+ return hits;
179
+ }
180
+ export async function detectStaleProviderTrees(options) {
181
+ const currentVersion = options.currentVersion ?? await readFrameworkVersion(options.frameworkRoot);
182
+ if (currentVersion === null)
183
+ return [];
184
+ const trees = [];
185
+ for (const provider of Object.keys(PROVIDER_AGENT_DIRS)) {
186
+ if (provider === options.provider)
187
+ continue;
188
+ const hits = (await collectManagedProviderArtifacts(options.projectRoot, provider, PRUNABLE_ARTIFACT_KINDS))
189
+ .filter((hit) => isOlderManagedVersion(hit.version, currentVersion));
190
+ if (hits.length === 0)
191
+ continue;
192
+ const counts = { agents: 0, commands: 0, rules: 0 };
193
+ for (const hit of hits)
194
+ counts[hit.kind] += 1;
195
+ const oldest = hits
196
+ .map((hit) => hit.version)
197
+ .reduce((a, b) => (isOlderManagedVersion(a, b) ? a : b));
198
+ trees.push({ provider, version: oldest, counts, total: hits.length });
199
+ }
200
+ trees.sort((a, b) => a.provider.localeCompare(b.provider));
201
+ return trees;
202
+ }
203
+ /**
204
+ * Split project-relative paths into the ones git tracks and the ones it does not (#2509).
205
+ *
206
+ * Deleting an ignored, regenerable artifact and deleting a committed file are
207
+ * not the same act. A project that is not a git repo — or a machine without
208
+ * git — reports everything as untracked, so the caller behaves as before.
209
+ */
210
+ export async function partitionTrackedPaths(projectRoot, relativePaths) {
211
+ if (relativePaths.length === 0)
212
+ return { tracked: [], untracked: [] };
213
+ let trackedSet;
214
+ try {
215
+ const { stdout } = await execFileAsync('git', ['ls-files', '-z', '--', ...relativePaths], { cwd: projectRoot, maxBuffer: 32 * 1024 * 1024 });
216
+ trackedSet = new Set(stdout.split('\0').filter(Boolean));
217
+ }
218
+ catch {
219
+ return { tracked: [], untracked: [...relativePaths] };
220
+ }
221
+ const tracked = [];
222
+ const untracked = [];
223
+ for (const relativePath of relativePaths) {
224
+ // git reports POSIX separators regardless of platform.
225
+ (trackedSet.has(relativePath.split(path.sep).join('/')) ? tracked : untracked).push(relativePath);
226
+ }
227
+ return { tracked, untracked };
228
+ }
229
+ export async function pruneStaleManagedAgentFiles(options) {
230
+ const desired = await currentBundledAgentBasenames(options.frameworkRoot);
231
+ const desiredRules = await currentBundledRuleBasenames(options.frameworkRoot);
232
+ const currentVersion = options.currentVersion ?? await readFrameworkVersion(options.frameworkRoot);
233
+ const crossProvider = options.crossProvider ?? 'skip';
234
+ const removals = [];
235
+ const trackedSkipped = [];
236
+ const record = (list, provider, relativePath) => {
237
+ let entry = list.find((item) => item.provider === provider);
238
+ if (!entry) {
239
+ entry = { provider, paths: [] };
240
+ list.push(entry);
241
+ }
242
+ entry.paths.push(relativePath);
243
+ };
244
+ for (const provider of Object.keys(PROVIDER_AGENT_DIRS)) {
245
+ const isTargetProvider = provider === options.provider;
246
+ // Non-target trees are pruned as a unit or not at all; the refreshed
247
+ // provider only drops agents whose source no longer ships them.
248
+ if (!isTargetProvider && crossProvider === 'skip')
249
+ continue;
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;
254
+ const hits = await collectManagedProviderArtifacts(options.projectRoot, provider, kinds);
255
+ const eligible = hits.filter((hit) => {
256
+ if (isTargetProvider) {
257
+ // Addons have independent manifest versions. Comparing their managed
258
+ // marker to the top-level package version makes a successful refresh
259
+ // delete freshly restored addon agents, so the active provider removes
260
+ // only artifacts absent from current sources.
261
+ if (hit.kind === 'rules')
262
+ return !desiredRules.has(path.basename(hit.relativePath));
263
+ return !desired.has(hit.artifactName);
264
+ }
265
+ return currentVersion !== null && isOlderManagedVersion(hit.version, currentVersion);
266
+ });
267
+ if (eligible.length === 0)
268
+ continue;
269
+ // Only the cross-provider unit prune defers to VCS state.
270
+ let protectedPaths = new Set();
271
+ if (!isTargetProvider && !options.allowTrackedDeletes) {
272
+ const { tracked } = await partitionTrackedPaths(options.projectRoot, eligible.map((hit) => hit.relativePath));
273
+ protectedPaths = new Set(tracked);
274
+ }
275
+ for (const hit of eligible) {
276
+ if (protectedPaths.has(hit.relativePath)) {
277
+ record(trackedSkipped, provider, hit.relativePath);
110
278
  continue;
111
- const relFile = path.relative(options.projectRoot, file);
279
+ }
112
280
  if (!options.dryRun)
113
- await fs.rm(file, { force: true });
114
- let providerRemoval = removals.find((item) => item.provider === provider);
115
- if (!providerRemoval) {
116
- providerRemoval = { provider, paths: [] };
117
- removals.push(providerRemoval);
281
+ await fs.rm(hit.absolutePath, { force: true });
282
+ record(removals, provider, hit.relativePath);
283
+ }
284
+ }
285
+ for (const list of [removals, trackedSkipped]) {
286
+ for (const entry of list)
287
+ entry.paths.sort((a, b) => a.localeCompare(b));
288
+ }
289
+ return { removals, trackedSkipped };
290
+ }
291
+ /**
292
+ * Reconcile `installed.deployedTo` with the artifacts a prune pass left behind (#2506).
293
+ *
294
+ * Recorded counts are written by `aiwg use` before the prune runs, so a run
295
+ * that deletes artifacts otherwise leaves the config permanently claiming
296
+ * files that are gone. Two cases:
297
+ *
298
+ * - **Unit-pruned provider tree** (a non-refreshed provider removed wholesale):
299
+ * every managed agent/command/rule for that provider is gone, so those
300
+ * counts drop to zero across all installed bundles. Skills are not part of
301
+ * the prune pass and keep their recorded values.
302
+ * - **Refreshed provider** (agent orphans only): subtract the removed count
303
+ * from the recorded agent totals, largest contributor first, clamped at 0.
304
+ */
305
+ async function reconcileDeployedToAfterPrune(projectRoot, removals, refreshedProvider) {
306
+ if (removals.length === 0)
307
+ return;
308
+ const config = await readAiwgConfig(projectRoot);
309
+ if (!config)
310
+ return;
311
+ let changed = false;
312
+ for (const removal of removals) {
313
+ const unitPruned = removal.provider !== refreshedProvider;
314
+ const entries = Object.values(config.installed)
315
+ .filter((entry) => entry.deployedTo?.[removal.provider]);
316
+ if (entries.length === 0)
317
+ continue;
318
+ if (unitPruned) {
319
+ for (const entry of entries) {
320
+ const counts = entry.deployedTo[removal.provider];
321
+ if (counts.agents === 0 && counts.commands === 0 && counts.rules === 0)
322
+ continue;
323
+ counts.agents = 0;
324
+ counts.commands = 0;
325
+ counts.rules = 0;
326
+ changed = true;
118
327
  }
119
- providerRemoval.paths.push(relFile);
328
+ continue;
329
+ }
330
+ let outstanding = removal.paths.length;
331
+ const byAgentsDesc = entries
332
+ .slice()
333
+ .sort((a, b) => b.deployedTo[removal.provider].agents - a.deployedTo[removal.provider].agents);
334
+ for (const entry of byAgentsDesc) {
335
+ if (outstanding <= 0)
336
+ break;
337
+ const counts = entry.deployedTo[removal.provider];
338
+ const deduct = Math.min(counts.agents, outstanding);
339
+ if (deduct <= 0)
340
+ continue;
341
+ counts.agents -= deduct;
342
+ outstanding -= deduct;
343
+ changed = true;
120
344
  }
121
345
  }
122
- for (const removal of removals)
123
- removal.paths.sort((a, b) => a.localeCompare(b));
124
- return removals;
346
+ if (changed)
347
+ await writeAiwgConfig(projectRoot, config);
348
+ }
349
+ /**
350
+ * Whether this run may delete git-tracked artifacts during a cross-provider
351
+ * prune (#2514).
352
+ *
353
+ * Deliberately not derived from `--force`. `--force` governs what gets
354
+ * *written* — it replaces artifacts AIWG does not currently manage. Deleting
355
+ * files someone committed, in a provider tree this run was not asked to touch,
356
+ * is a different decision and gets its own switch, so habitual `--force` use
357
+ * can never authorise it.
358
+ */
359
+ export function allowsTrackedDeletes(args) {
360
+ return args.includes('--prune-tracked');
125
361
  }
126
362
  /**
127
363
  * Parse --flag value pairs from args
@@ -161,6 +397,17 @@ Options:
161
397
  --skip-update Skip the installation update
162
398
  --packages-only Refresh remote packages only
163
399
  --provider <name> Override provider auto-detection
400
+ --prune-other-providers Remove stale AIWG-managed trees belonging to
401
+ providers this run did not refresh. Off by default:
402
+ a provider-scoped refresh never mutates another
403
+ provider's deployed surface. Git-tracked files are
404
+ always left in place unless --prune-tracked is given.
405
+ --prune-tracked Allow --prune-other-providers to delete git-tracked
406
+ files. Deleting committed files is a separate
407
+ decision from --force, which only governs writes.
408
+ --force Re-write every deployed artifact, replacing files
409
+ AIWG does not currently manage. Never authorises
410
+ deleting tracked files.
164
411
  --channel <name> Select the update channel (stable or main)
165
412
  --frameworks <list> Re-deploy a comma-separated installed subset
166
413
  --model <name> Override all deployed agent model tiers
@@ -192,6 +439,14 @@ export const refreshHandler = {
192
439
  const quiet = hasFlag(ctx.args, '--quiet');
193
440
  const skipUpdate = hasFlag(ctx.args, '--skip-update');
194
441
  const packagesOnly = hasFlag(ctx.args, '--packages-only');
442
+ // #2506: cross-provider pruning is opt-in. A provider-scoped refresh must
443
+ // not silently delete another provider's deployed surface.
444
+ const pruneOtherProviders = hasFlag(ctx.args, '--prune-other-providers');
445
+ const forceDeploy = hasFlag(ctx.args, '--force');
446
+ // #2514: deleting committed files in a tree this run was not asked to touch
447
+ // is its own decision, not a consequence of asking for a forceful re-write.
448
+ // `--force` governs what gets written; this governs what gets destroyed.
449
+ const pruneTracked = allowsTrackedDeletes(ctx.args);
195
450
  const provider = parseFlag(ctx.args, '--provider');
196
451
  const channel = parseFlag(ctx.args, '--channel');
197
452
  const frameworksArg = parseFlag(ctx.args, '--frameworks');
@@ -313,6 +568,9 @@ export const refreshHandler = {
313
568
  '--target', ctx.cwd,
314
569
  '--yes',
315
570
  '--json',
571
+ // #2507: give operators a reclaim path for artifacts an older
572
+ // AIWG left behind without a managed marker.
573
+ ...(forceDeploy ? ['--force'] : []),
316
574
  ...modelDeployArgs,
317
575
  ],
318
576
  rawArgs: ['use', fw],
@@ -364,32 +622,93 @@ export const refreshHandler = {
364
622
  catch {
365
623
  // Non-fatal — refresh continues
366
624
  }
367
- // Step 4.5: Stale deployment check (#621, #1460, #1799)
625
+ // Step 4.5: Stale deployment check (#621, #1460, #1799, #2506)
368
626
  if (!quiet)
369
627
  ui.info('Checking for stale deployments...');
628
+ // A modifier with nothing to modify is almost always a mistyped intent.
629
+ if (pruneTracked && !pruneOtherProviders && !quiet) {
630
+ ui.warn('--prune-tracked has no effect without --prune-other-providers; nothing was removed.');
631
+ }
370
632
  let staleAgentRemovals = [];
371
- if (!dryRun && deploymentFailures.length === 0) {
633
+ let trackedSkipped = [];
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) {
372
639
  try {
373
- staleAgentRemovals = await pruneStaleManagedAgentFiles({
640
+ const pruneResult = await pruneStaleManagedAgentFiles({
374
641
  projectRoot: ctx.cwd,
375
642
  frameworkRoot,
376
643
  provider: detectedProvider,
644
+ crossProvider: pruneOtherProviders ? 'prune' : 'skip',
645
+ allowTrackedDeletes: pruneTracked,
646
+ dryRun,
377
647
  });
648
+ staleAgentRemovals = pruneResult.removals;
649
+ trackedSkipped = pruneResult.trackedSkipped;
650
+ if (trackedSkipped.length > 0 && !quiet) {
651
+ const total = trackedSkipped.reduce((sum, item) => sum + item.paths.length, 0);
652
+ ui.warn(`Left ${total} git-tracked AIWG-managed file${total === 1 ? '' : 's'} in place ` +
653
+ `across ${trackedSkipped.length} provider${trackedSkipped.length === 1 ? '' : 's'}`);
654
+ for (const skipped of trackedSkipped) {
655
+ const shown = skipped.paths.slice(0, 3).join(', ');
656
+ const remainder = skipped.paths.length - 3;
657
+ ui.dim(` ${skipped.provider}: ${skipped.paths.length} (${shown}${remainder > 0 ? `, ...and ${remainder} more` : ''})`);
658
+ }
659
+ ui.dim(" These are committed files. Add --prune-tracked to remove them as well.");
660
+ }
378
661
  if (staleAgentRemovals.length > 0 && !quiet) {
379
662
  const total = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
380
- ui.success(`Removed ${total} stale AIWG-managed agent file${total === 1 ? '' : 's'} ` +
663
+ ui.warn(`${dryRun ? 'Would remove' : 'Removed'} ${total} stale AIWG-managed file${total === 1 ? '' : 's'} ` +
381
664
  `across ${staleAgentRemovals.length} provider${staleAgentRemovals.length === 1 ? '' : 's'}`);
382
665
  for (const removal of staleAgentRemovals) {
383
666
  const shown = removal.paths.slice(0, 3).join(', ');
384
667
  const remainder = removal.paths.length - 3;
385
668
  ui.dim(` ${removal.provider}: ${removal.paths.length} (${shown}${remainder > 0 ? `, ...and ${remainder} more` : ''})`);
386
669
  }
670
+ ui.dim(dryRun
671
+ ? ' Re-run without --dry-run to remove them.'
672
+ : ' Review `git status` before committing — deployed artifacts may be tracked.');
673
+ }
674
+ // #2506: keep the recorded deployment state consistent with what the
675
+ // prune actually left on disk, so a later run does not trust counts
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);
387
680
  }
388
681
  }
389
682
  catch {
390
683
  if (!quiet)
391
684
  ui.dim(' Agent orphan cleanup skipped (non-critical)');
392
685
  }
686
+ // #2506: a provider-scoped refresh reports other providers' stale trees
687
+ // instead of mutating them. Silent cross-provider deletion destroyed
688
+ // git-tracked artifacts and left half-deployed surfaces behind.
689
+ if (!pruneOtherProviders) {
690
+ try {
691
+ const staleTrees = await detectStaleProviderTrees({
692
+ projectRoot: ctx.cwd,
693
+ frameworkRoot,
694
+ provider: detectedProvider,
695
+ });
696
+ for (const tree of staleTrees) {
697
+ const breakdown = ['agents', 'commands', 'rules']
698
+ .filter((kind) => tree.counts[kind] > 0)
699
+ .map((kind) => `${tree.counts[kind]} ${kind}`)
700
+ .join(', ');
701
+ ui.warn(`Stale ${tree.provider} deployment: ${tree.total} AIWG-managed file(s) from v${tree.version} ` +
702
+ `(${breakdown}) — this run refreshed ${detectedProvider ?? 'the active provider'} only`);
703
+ ui.dim(` Refresh it with 'aiwg refresh --provider ${tree.provider}', ` +
704
+ `or remove it with 'aiwg refresh --prune-other-providers'`);
705
+ }
706
+ }
707
+ catch {
708
+ if (!quiet)
709
+ ui.dim(' Stale provider tree check skipped (non-critical)');
710
+ }
711
+ }
393
712
  try {
394
713
  const { getFrameworkRoot } = await import('../../channel/manager.mjs');
395
714
  const { join } = await import('path');
@@ -420,6 +739,15 @@ export const refreshHandler = {
420
739
  ui.warn(`Stale deployment: ${name} — run 'aiwg use ${name}' to redeploy`);
421
740
  }
422
741
  }
742
+ else if (staleAgentRemovals.length > 0) {
743
+ // #2506: a run that deleted artifacts is not an "up to date" run.
744
+ const removed = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
745
+ if (!quiet) {
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`);
749
+ }
750
+ }
423
751
  else {
424
752
  if (!quiet)
425
753
  ui.success('All deployments up to date');