@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
@@ -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 {
@@ -1,13 +1,16 @@
1
1
  import { checkRepoAccess, findRepoEntry, formatRepoAccessEntry, loadRepoAccessManifest, } from '../../policy/repo-access.js';
2
2
  import { resolveWorkspace } from '../../config/workspace.js';
3
+ import { getProjectDir, readAiwgConfig, writeAiwgConfig, WORKSPACE_REPO_ACTIONS, } from '../../config/aiwg-config.js';
4
+ import { existsSync, readdirSync, statSync } from 'node:fs';
5
+ import * as nodePath from 'node:path';
3
6
  function valueAfter(args, flag) {
4
7
  const index = args.indexOf(flag);
5
8
  if (index < 0)
6
9
  return null;
7
10
  return args[index + 1] ?? null;
8
11
  }
9
- function printHelp() {
10
- console.log(`
12
+ function usage() {
13
+ return `
11
14
  aiwg repo-access — repo authorization manifest preflight
12
15
 
13
16
  Usage:
@@ -15,12 +18,149 @@ function printHelp() {
15
18
  aiwg repo-access status
16
19
  aiwg repo-access explain --path <repo-or-file>
17
20
  aiwg repo-access check --path <repo-or-file> --action <read|write|commit|push|issue-comment|service-action|destructive>
21
+ aiwg repo-access add --path <p> --name <n> --allow <a,b,c> [--provider <gitea|github|gitlab>] [--notes "..."]
22
+ aiwg repo-access remove --name <n>
23
+ aiwg repo-access audit
18
24
 
19
25
  Manifest:
20
26
  .aiwg/aiwg.config workspace + repos blocks (preferred)
21
27
  .aiwg/ops/security/repo-access.manifest.yaml
22
28
  .aiwg/security/repo-access.manifest.yaml (fallback)
23
- `);
29
+ `;
30
+ }
31
+ function printHelp() {
32
+ console.log(usage());
33
+ }
34
+ /**
35
+ * The manifest is mandatory and default-deny, but until now it had no write path:
36
+ * registering a repo meant hand-editing JSON, and the rule's own recovery text
37
+ * ("ask for a manifest update") had no supported way to be carried out (#2531).
38
+ */
39
+ async function addRepo(ctx, args) {
40
+ const repoPath = valueAfter(args, '--path');
41
+ const name = valueAfter(args, '--name');
42
+ const allowRaw = valueAfter(args, '--allow');
43
+ if (!repoPath)
44
+ return { exitCode: 2, message: 'repo-access add requires --path <repo-or-file>' };
45
+ if (!name)
46
+ return { exitCode: 2, message: 'repo-access add requires --name <name>' };
47
+ if (!allowRaw) {
48
+ return { exitCode: 2, message: `repo-access add requires --allow <${WORKSPACE_REPO_ACTIONS.join('|')}>` };
49
+ }
50
+ const allowed = allowRaw.split(',').map((item) => item.trim()).filter(Boolean);
51
+ const invalid = allowed.filter((item) => !WORKSPACE_REPO_ACTIONS.includes(item));
52
+ if (invalid.length > 0) {
53
+ return {
54
+ exitCode: 2,
55
+ message: `Unknown action(s): ${invalid.join(', ')}. Valid: ${WORKSPACE_REPO_ACTIONS.join(', ')}`,
56
+ };
57
+ }
58
+ const provider = valueAfter(args, '--provider') ?? undefined;
59
+ if (provider && !['gitea', 'github', 'gitlab'].includes(provider)) {
60
+ return { exitCode: 2, message: `Unknown provider: ${provider}. Valid: gitea, github, gitlab` };
61
+ }
62
+ const projectDir = getProjectDir(ctx, args);
63
+ const config = await readAiwgConfig(projectDir);
64
+ if (!config)
65
+ return { exitCode: 2, message: `No .aiwg/aiwg.config found in ${projectDir}` };
66
+ const repos = config.repos ? [...config.repos] : [];
67
+ const entry = {
68
+ name,
69
+ path: repoPath,
70
+ allowed: allowed,
71
+ ...(provider ? { provider: provider } : {}),
72
+ ...(valueAfter(args, '--notes') ? { notes: valueAfter(args, '--notes') } : {}),
73
+ };
74
+ const existingIndex = repos.findIndex((repo) => repo.name === name);
75
+ if (existingIndex >= 0) {
76
+ repos[existingIndex] = { ...repos[existingIndex], ...entry };
77
+ }
78
+ else {
79
+ repos.push(entry);
80
+ }
81
+ await writeAiwgConfig(projectDir, { ...config, repos });
82
+ console.log(`${existingIndex >= 0 ? 'Updated' : 'Registered'} ${name}: ${repoPath} [${allowed.join(', ')}]`);
83
+ return { exitCode: 0 };
84
+ }
85
+ async function removeRepo(ctx, args) {
86
+ const name = valueAfter(args, '--name');
87
+ if (!name)
88
+ return { exitCode: 2, message: 'repo-access remove requires --name <name>' };
89
+ const projectDir = getProjectDir(ctx, args);
90
+ const config = await readAiwgConfig(projectDir);
91
+ if (!config)
92
+ return { exitCode: 2, message: `No .aiwg/aiwg.config found in ${projectDir}` };
93
+ const repos = config.repos ?? [];
94
+ const remaining = repos.filter((repo) => repo.name !== name);
95
+ if (remaining.length === repos.length) {
96
+ return { exitCode: 1, message: `No repo named '${name}' in the manifest.` };
97
+ }
98
+ // An empty `repos` array fails config validation, so drop the key entirely
99
+ // when the last entry goes rather than writing a config that cannot be read back.
100
+ const next = { ...config };
101
+ if (remaining.length > 0)
102
+ next.repos = remaining;
103
+ else
104
+ delete next.repos;
105
+ await writeAiwgConfig(projectDir, next);
106
+ console.log(`Removed ${name}. It now falls under the default-deny policy.`);
107
+ return { exitCode: 0 };
108
+ }
109
+ /**
110
+ * Report workspace subdirectories that are git repos but carry no manifest entry.
111
+ * Manifests drift out of sync with reality; on the reporting workspace two
112
+ * actively-used repos were both unlisted and therefore formally denied (#2531).
113
+ */
114
+ async function auditRepos(ctx, args) {
115
+ // A missing manifest is the most important case to audit, not a reason to fail:
116
+ // every repo is then unlisted and formally denied.
117
+ let manifest = null;
118
+ try {
119
+ manifest = loadRepoAccessManifest(ctx.cwd);
120
+ }
121
+ catch {
122
+ manifest = null;
123
+ }
124
+ const root = manifest?.workspaceProjectRoot ?? getProjectDir(ctx, args);
125
+ let children = [];
126
+ try {
127
+ children = readdirSync(root);
128
+ }
129
+ catch {
130
+ return { exitCode: 2, message: `Cannot read workspace root: ${root}` };
131
+ }
132
+ const unlisted = [];
133
+ for (const child of children.sort()) {
134
+ if (child.startsWith('.'))
135
+ continue;
136
+ const full = nodePath.join(root, child);
137
+ try {
138
+ if (!statSync(full).isDirectory())
139
+ continue;
140
+ }
141
+ catch {
142
+ continue;
143
+ }
144
+ if (!existsSync(nodePath.join(full, '.git')))
145
+ continue;
146
+ if (manifest && findRepoEntry(manifest, full, ctx.cwd))
147
+ continue;
148
+ unlisted.push(child);
149
+ }
150
+ console.log(`Repo access manifest: ${manifest?.path ?? '(none — every repo is denied)'}`);
151
+ console.log(`Workspace root: ${root}`);
152
+ console.log(`Default policy: ${manifest?.defaultPolicy ?? 'deny'}`);
153
+ if (unlisted.length === 0) {
154
+ console.log('All git repositories under the workspace root are registered.');
155
+ return { exitCode: 0 };
156
+ }
157
+ console.log('');
158
+ console.log(`Unlisted git repositories (${unlisted.length}) — denied by default:`);
159
+ for (const name of unlisted) {
160
+ console.log(` - ${name}`);
161
+ console.log(` aiwg repo-access add --path ./${name} --name ${name} --allow read`);
162
+ }
163
+ return { exitCode: 1 };
24
164
  }
25
165
  async function handleRepoAccess(ctx) {
26
166
  const [subcommand = 'help', ...args] = ctx.args;
@@ -28,6 +168,14 @@ async function handleRepoAccess(ctx) {
28
168
  printHelp();
29
169
  return { exitCode: 0 };
30
170
  }
171
+ // add/remove write .aiwg/aiwg.config directly and must work before any manifest
172
+ // exists — registering the first repo is exactly the bootstrap case (#2531).
173
+ if (subcommand === 'add')
174
+ return await addRepo(ctx, args);
175
+ if (subcommand === 'remove')
176
+ return await removeRepo(ctx, args);
177
+ if (subcommand === 'audit')
178
+ return await auditRepos(ctx, args);
31
179
  try {
32
180
  const manifest = loadRepoAccessManifest(ctx.cwd);
33
181
  if (subcommand === 'list' || subcommand === 'status') {
@@ -87,7 +235,7 @@ async function handleRepoAccess(ctx) {
87
235
  }
88
236
  return { exitCode: decision.allowed ? 0 : 1 };
89
237
  }
90
- return { exitCode: 2, message: `Unknown repo-access subcommand: ${subcommand}` };
238
+ return { exitCode: 2, message: `Unknown repo-access subcommand: ${subcommand}\n${usage()}` };
91
239
  }
92
240
  catch (error) {
93
241
  return {
@@ -103,6 +251,9 @@ export const repoAccessHandler = {
103
251
  description: 'Validate and query repo access manifest permissions',
104
252
  category: 'utility',
105
253
  aliases: [],
254
+ async help() {
255
+ return { exitCode: 0, message: usage(), rawOutput: true };
256
+ },
106
257
  execute: handleRepoAccess,
107
258
  };
108
259
  export const repoAccessHandlers = [repoAccessHandler];
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from 'child_process';
2
2
  import { existsSync } from 'fs';
3
- import { emptyConfig, getConfigPath, getProjectDir, readAiwgConfig, resolveRemoteProvider, VALID_PROVIDERS, writeAiwgConfig, } from '../../config/aiwg-config.js';
3
+ import { emptyConfig, FORCE_PUSH_POLICY_ALIAS_NOTE, normalizeForcePushPolicy as normalizeForcePushPolicyShared, getConfigPath, getProjectDir, readAiwgConfig, resolveRemoteProvider, VALID_PROVIDERS, writeAiwgConfig, } from '../../config/aiwg-config.js';
4
4
  import { AiwgError, EXIT_CODES } from '../errors.js';
5
5
  import { askChoice, askString, askYesNo, createPromptInterface } from '../prompt-utils.js';
6
6
  import * as ui from '../ui.js';
@@ -165,11 +165,11 @@ function secondaryRemotes(remotes, primary, issueTracker, ci) {
165
165
  }));
166
166
  }
167
167
  function normalizeForcePushPolicy(value, warnings) {
168
- if (value === 'main-only-blocked') {
169
- warnings.push('delivery.force_push_policy=main-only-blocked is a legacy alias; setup normalized it to own-branch-only.');
170
- return 'own-branch-only';
168
+ const { policy, deprecatedFrom } = normalizeForcePushPolicyShared(value);
169
+ if (deprecatedFrom) {
170
+ warnings.push(`delivery.force_push_policy=${deprecatedFrom} is a legacy alias; setup normalized it to ${policy}. ${FORCE_PUSH_POLICY_ALIAS_NOTE}`);
171
171
  }
172
- return value;
172
+ return policy;
173
173
  }
174
174
  function cloneConfig(config) {
175
175
  return JSON.parse(JSON.stringify(config));
@@ -185,6 +185,18 @@ function printFullMatrix(matrix) {
185
185
  }
186
186
  }
187
187
  // ── Main execution ─────────────────────────────────────────────────────────────
188
+ function permissionsUsage() {
189
+ return `
190
+ aiwg steward permissions — authorization model audit and normalization
191
+
192
+ Usage:
193
+ aiwg steward permissions audit Find normalized-model errors and legacy grants
194
+ aiwg steward permissions migrate --dry-run Preview legacy permission normalization
195
+ aiwg steward permissions migrate --apply Back up and atomically normalize config
196
+
197
+ Reads .aiwg/aiwg.config authorization block. Migration backs up before writing.
198
+ `;
199
+ }
188
200
  async function handleSteward(args, ctx) {
189
201
  const subcommand = args[0];
190
202
  if (!subcommand || subcommand === '--help' || subcommand === 'help') {
@@ -221,6 +233,11 @@ async function handleSteward(args, ctx) {
221
233
  }
222
234
  if (subcommand === 'permissions') {
223
235
  const operation = args[1];
236
+ // `<namespace> --help` must reach the same usage block bare invocation prints (#2533).
237
+ if (!operation || operation === 'help' || operation === '--help' || operation === '-h') {
238
+ console.log(permissionsUsage());
239
+ return;
240
+ }
224
241
  const projectDir = ctx ? getProjectDir(ctx, args) : process.cwd();
225
242
  const config = await readAiwgConfig(projectDir);
226
243
  if (!config)
@@ -259,7 +276,15 @@ async function handleSteward(args, ctx) {
259
276
  return;
260
277
  }
261
278
  const diagnostics = await auditLegacyPermissions(projectDir, config);
262
- console.log(` ${dryRun ? 'Would normalize' : 'Normalizing'} ${diagnostics.filter(d => d.code.startsWith('legacy-')).length} legacy permission source(s).`);
279
+ const legacyCount = diagnostics.filter(d => d.code.startsWith('legacy-')).length;
280
+ if (legacyCount === 0 && !config.authorization) {
281
+ // Nothing legacy to convert: the only work is writing the initial
282
+ // default-deny block, which is what clears doctor's warning (#2563).
283
+ console.log(` ${dryRun ? 'Would write' : 'Writing'} an initial default-deny authorization block (no legacy permission sources found).`);
284
+ }
285
+ else {
286
+ console.log(` ${dryRun ? 'Would normalize' : 'Normalizing'} ${legacyCount} legacy permission source(s).`);
287
+ }
263
288
  console.log(` Result: ${Object.keys(normalized.authorization?.permissions ?? {}).length} permissions, ${Object.keys(normalized.authorization?.roles ?? {}).length} roles, ${normalized.authorization?.assignments.length ?? 0} assignments; default deny.`);
264
289
  if (apply) {
265
290
  const backup = await backupConfig(projectDir);
@@ -273,7 +298,7 @@ async function handleSteward(args, ctx) {
273
298
  }
274
299
  throw new AiwgError({
275
300
  code: 'ERR_USAGE_UNKNOWN_PERMISSION_OPERATION',
276
- message: `Unknown permissions operation: ${operation ?? '(missing)'}`,
301
+ message: `Unknown permissions operation: ${operation}`,
277
302
  hint: 'Use audit or migrate --dry-run|--apply.',
278
303
  exitCode: EXIT_CODES.USAGE,
279
304
  });
@@ -598,6 +623,18 @@ export const stewardHandler = {
598
623
  description: 'Provider capability routing and permission normalization',
599
624
  category: 'maintenance',
600
625
  aliases: [],
626
+ // The router intercepts --help before execute(), so a handler without this
627
+ // property gets the generic "no detailed help" stub even when its own usage
628
+ // text exists. Route to the same block bare invocation prints, and keep
629
+ // sub-namespace help reachable, without executing anything (#2533).
630
+ async help(ctx) {
631
+ const positional = ctx.args.filter((arg) => !arg.startsWith('-'));
632
+ if (positional[0] === 'permissions') {
633
+ return { exitCode: 0, message: permissionsUsage(), rawOutput: true };
634
+ }
635
+ await handleSteward([], ctx);
636
+ return { exitCode: 0 };
637
+ },
601
638
  async execute(ctx) {
602
639
  try {
603
640
  await handleSteward(ctx.args, ctx);
@@ -694,8 +694,10 @@ const SESSION_RELOAD_NOTICE = {
694
694
  rationale: 'Claude Code reads .claude/agents/ at session start. A running session retains its old registry until reloaded.',
695
695
  },
696
696
  codex: {
697
- action: 'Restart/open Codex in this target workspace so it picks up newly deployed agents and .agents/skills entries.',
698
- rationale: 'Codex caches its agent and skill registry per session. Project .agents/skills/ and .codex/agents/ are scanned from the Codex working directory up to the repo root on startup.',
697
+ required: false,
698
+ action: 'No restart needed for deployed skills — Codex exposes them on the next turn. Reopen Codex in this workspace only if a deployed skill or agent is still missing after that.',
699
+ rationale: 'A running Codex desktop session listed the newly deployed project skills on the very next user turn without any restart (#2309). Custom agent registry and MCP server changes were not observed to refresh live, so reopening remains the fallback for those.',
700
+ symptom: 'If a deployed skill or agent stays absent after the next turn, the registry did not rescan — reopen Codex in this workspace.',
699
701
  },
700
702
  copilot: {
701
703
  action: 'Reload the VS Code window (`Developer: Reload Window`) so Copilot picks up the new .github/agents/ entries.',
@@ -736,7 +738,8 @@ function printSessionReloadNotice(provider) {
736
738
  if (!notice)
737
739
  return;
738
740
  const defaultSymptom = 'Until reloaded, the Agent/Task tool will report "Agent type not found" for the newly deployed agents.';
739
- ui.section('Session reload required:', [
741
+ const required = notice.required !== false;
742
+ ui.section(required ? 'Session reload required:' : 'Session reload (only if something is missing):', [
740
743
  notice.action,
741
744
  `Why: ${notice.rationale}`,
742
745
  notice.symptom ?? defaultSymptom,
@@ -1253,6 +1256,10 @@ async function deployOneProjectLocalBundle(opts) {
1253
1256
  // never reach <provider>/.aiwg/skills/, leaving them invisible to
1254
1257
  // both the platform and the index.
1255
1258
  '--copy-all',
1259
+ // That copy-all is the deployer's doing, not the operator's, so the
1260
+ // provider's startup listing cap still applies; overflow lands on the
1261
+ // standard tier instead of over the cap (#2561).
1262
+ '--listing-budget',
1256
1263
  // Provenance for the managed marker (#2502). Without this the deployer
1257
1264
  // stamps `bundled`/`unknown`, and `aiwg refresh`'s stale-artifact prune —
1258
1265
  // whose desired set is the packaged framework corpus — deletes every
@@ -1269,7 +1276,9 @@ async function deployOneProjectLocalBundle(opts) {
1269
1276
  args.push('--quiet');
1270
1277
  // Project-local bundles are addon-shaped — never trigger the legacy commands
1271
1278
  // migration prompt (which is only relevant for full-framework deploys).
1272
- args.push('--skip-commands-migration');
1279
+ // This is a structural opt-out, not the operator declining, so suppress the
1280
+ // duplicate-commands warning too: it fired once per bundle (#2541).
1281
+ args.push('--skip-commands-migration', '--no-commands-warning');
1273
1282
  const captureOpts = quiet && !verbose ? { capture: true } : {};
1274
1283
  // Inject AIWG_ROOT so the deploy subprocess can resolve the upstream AIWG
1275
1284
  // install root. The bundle's `--source` is its project-local path, so
@@ -12,7 +12,7 @@ import { getVersionInfo } from '../../channel/manager.mjs';
12
12
  import { getLoggerInfo } from '../log.js';
13
13
  import * as ui from '../ui.js';
14
14
  import { maybePrintCommunityFooter } from '../../community/footer.js';
15
- import { existsSync, statSync, readdirSync } from 'fs';
15
+ import { existsSync, statSync, readdirSync, readFileSync } from 'fs';
16
16
  import path from 'path';
17
17
  /**
18
18
  * Version command handler
@@ -30,6 +30,27 @@ export const versionHandler = {
30
30
  return { exitCode: 0 };
31
31
  },
32
32
  };
33
+ /**
34
+ * Print the installation-drift notice when the canonical declaration and the
35
+ * running binary disagree. Keeps `aiwg version` honest about which one it is
36
+ * describing, and names the command that explains the rest (#2529).
37
+ */
38
+ function printDrift(fp) {
39
+ if (fp.drift) {
40
+ const method = fp.installation?.identity?.method ?? 'unrecorded';
41
+ const declared = fp.drift.canonicalVersion ? ` (${fp.drift.canonicalVersion})` : '';
42
+ ui.dim(` ! canonical install declares ${method} at ${fp.drift.canonicalRoot}${declared} — run \`aiwg installation show\``);
43
+ return;
44
+ }
45
+ // Same root, different method (a source checkout recorded as npm, or the
46
+ // reverse) is drift too; `version` is the first recovery command an operator
47
+ // reaches for, so it must say so instead of looking aligned (#2559).
48
+ const state = fp.installation?.state;
49
+ if (state && state !== 'aligned' && state !== 'unrecorded') {
50
+ const reasons = Array.isArray(fp.installation?.drift) ? fp.installation.drift : [];
51
+ ui.dim(` ! installation identity ${state}: ${reasons.join('; ') || 'see aiwg installation show'} — run \`aiwg installation show\``);
52
+ }
53
+ }
33
54
  function collectFingerprint(versionInfo) {
34
55
  const loggerInfo = getLoggerInfo();
35
56
  const fp = {
@@ -62,6 +83,22 @@ function collectFingerprint(versionInfo) {
62
83
  path: versionInfo.edgePath ?? versionInfo.packageRoot,
63
84
  };
64
85
  }
86
+ // `version` is the first thing anyone runs to answer "what am I on". If the
87
+ // canonical install declares a different root than the one executing, say so
88
+ // here rather than letting drift persist while every check looks correct.
89
+ const canonicalRoot = fp.installation?.identity?.root;
90
+ const actualRoot = fp.installation?.actualRoot;
91
+ if (canonicalRoot && actualRoot && path.resolve(canonicalRoot) !== path.resolve(actualRoot)) {
92
+ let canonicalVersion = null;
93
+ try {
94
+ const pkg = JSON.parse(readFileSync(path.join(canonicalRoot, 'package.json'), 'utf8'));
95
+ canonicalVersion = pkg.version ?? null;
96
+ }
97
+ catch {
98
+ // Canonical root may not exist or be readable; report the drift regardless.
99
+ }
100
+ fp.drift = { canonicalRoot, canonicalVersion, actualRoot };
101
+ }
65
102
  // Locale / timezone are useful for timezone-dependent bug reports.
66
103
  try {
67
104
  fp.locale = Intl.DateTimeFormat().resolvedOptions().locale;
@@ -96,13 +133,12 @@ async function displayVersion(opts) {
96
133
  ui.blank();
97
134
  console.log(` ${ui.brandMark()} ${ui.bold('aiwg')} ${ui.bold(fp.version)} ${ui.channelLabel(fp.channel)}`);
98
135
  if (!opts.verbose) {
99
- if (fp.git) {
136
+ if (fp.git)
100
137
  ui.dim(` git: ${fp.git.sha} (${fp.git.branch})`);
101
- ui.dim(` path: ${fp.git.path}`);
102
- }
103
- else {
104
- ui.dim(` path: ${fp.packageRoot}`);
105
- }
138
+ // Always the root that actually executed — not the declared edge checkout,
139
+ // which is what made drift invisible here (#2529).
140
+ ui.dim(` path: ${fp.packageRoot}`);
141
+ printDrift(fp);
106
142
  maybePrintCommunityFooter();
107
143
  ui.blank();
108
144
  return;
@@ -110,15 +146,14 @@ async function displayVersion(opts) {
110
146
  // --verbose: the full environment fingerprint.
111
147
  if (fp.git) {
112
148
  ui.dim(` git: ${fp.git.sha} (${fp.git.branch})`);
113
- ui.dim(` path: ${fp.git.path}`);
114
- }
115
- else {
116
- ui.dim(` path: ${fp.packageRoot}`);
149
+ if (fp.git.path !== fp.packageRoot)
150
+ ui.dim(` edge: ${fp.git.path}`);
117
151
  }
152
+ ui.dim(` path: ${fp.packageRoot}`);
118
153
  ui.dim(` channel: ${fp.channel}`);
119
- ui.dim(` install: ${fp.installation.identity?.method ?? 'unrecorded'} (${fp.installation.state})`);
120
- ui.dim(` canonical: ${fp.installation.identity?.root ?? '(unrecorded)'}`);
121
- ui.dim(` actual: ${fp.installation.actualRoot}`);
154
+ ui.dim(` install: ${fp.installation?.identity?.method ?? 'unrecorded'} (${fp.installation?.state ?? 'unknown'})`);
155
+ ui.dim(` canonical: ${fp.installation?.identity?.root ?? '(unrecorded)'}`);
156
+ ui.dim(` actual: ${fp.installation?.actualRoot ?? fp.packageRoot}`);
122
157
  ui.dim(` node: ${fp.node}`);
123
158
  ui.dim(` platform: ${fp.platform.os} ${fp.platform.arch} (${fp.platform.release})`);
124
159
  ui.dim(` tty: stdin=${fp.tty.stdin} stdout=${fp.tty.stdout} stderr=${fp.tty.stderr}`);
@@ -55,6 +55,14 @@ export const workspaceContextHandler = {
55
55
  console.log(JSON.stringify(result, null, 2));
56
56
  else {
57
57
  console.log(`${result.dryRun ? 'Migration dry run' : 'Migration applied'}: ${result.changed ? 'changes found' : 'already canonical'}`);
58
+ for (const entry of result.audit.plan.routing) {
59
+ console.log(` ${entry.source}: ${entry.operatorBytes.toLocaleString()} chars -> ${entry.destination} (${entry.scope})`);
60
+ }
61
+ for (const entry of result.audit.plan.scopeReview) {
62
+ console.log(` REVIEW ${entry.source} carries ${entry.operatorBytes.toLocaleString()} chars of operator content and is scoped to ${entry.scope} by filename.`);
63
+ console.log(' Content in .aiwg/context/providers/ is read by that provider only.');
64
+ console.log(" If this is project-neutral methodology, move it into WORKSPACE.md's Project Context section first.");
65
+ }
58
66
  for (const file of result.written)
59
67
  console.log(` ${result.dryRun ? 'would write' : 'wrote'} ${file}`);
60
68
  if (result.transactionId)