@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
@@ -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];
@@ -298,6 +298,9 @@ async function handleRuntimeInfo(args, cwd = process.cwd()) {
298
298
  console.log(`\nAIWG Installation:`);
299
299
  console.log(` Canonical: ${installation.identity?.method ?? 'unrecorded'} at ${installation.identity?.root ?? '(unrecorded)'}`);
300
300
  console.log(` Actual: ${installation.actualMethod} at ${installation.actualRoot}`);
301
+ if (installation.launcher) {
302
+ console.log(` Launcher: ${installation.launcher.method} at ${installation.launcher.root} (edge redirect)`);
303
+ }
301
304
  console.log(` Run mode: ${installation.identity?.runMode ?? '(unrecorded)'}`);
302
305
  console.log(` State: ${installation.state}`);
303
306
  // Scheduler backend detection
@@ -16,6 +16,8 @@ import { sandboxRegistry, normalizeSandboxEvent, } from '../../serve/sandbox-reg
16
16
  import { routeTask } from '../../serve/agent-router.js';
17
17
  import { routeDispatch } from '../../serve/dispatch-router.js';
18
18
  import { observeA2ATerminalState } from '../../serve/a2a-terminal-observer.js';
19
+ import { respondToA2AMission } from '../../serve/mission-hitl.js';
20
+ import { A2A_HITL_PROMPT_V1 } from '../../a2a/client.js';
19
21
  import { executorRegistry, validateRegisterPayload, validateDispatchPayload, validateEventEnvelope, } from '../../serve/executor-registry.js';
20
22
  import { handleWebhook, IdempotencyCache, PushSecretRegistry, } from '../../a2a/webhook.js';
21
23
  import { AiwgError, EXIT_CODES } from '../errors.js';
@@ -807,6 +809,7 @@ export async function startServer(opts) {
807
809
  const a2aProtocolPolicy = configuredA2AProtocolPolicy;
808
810
  try {
809
811
  const result = await routeDispatch(executor, payload, {
812
+ optionalExtensions: [A2A_HITL_PROMPT_V1],
810
813
  a2aProtocolPolicy,
811
814
  allowA2AProtocolFallback: configuredA2AProtocolFallback,
812
815
  allowLegacyExecutorFallback: configuredLegacyExecutorFallback,
@@ -872,7 +875,14 @@ export async function startServer(opts) {
872
875
  ...(a2aFallbackReason ? { fallbackReason: a2aFallbackReason } : {}),
873
876
  });
874
877
  }
875
- executorRegistry.assignMission(missionId, executor.executorId);
878
+ const existingMission = executorRegistry.getMission(missionId);
879
+ // An idempotent replay must not discard in-flight/accepted approvals.
880
+ if (!(dispatchPath === 'v2' && a2aTask && a2aInstanceId
881
+ && existingMission?.executorId === executor.executorId
882
+ && existingMission.a2a?.taskId === a2aTask.id
883
+ && existingMission.a2a.instanceId === a2aInstanceId)) {
884
+ executorRegistry.assignMission(missionId, executor.executorId);
885
+ }
876
886
  if (dispatchPath === 'v2' && a2aTask && a2aInstanceId) {
877
887
  void observeA2ATerminalState(executorRegistry, executor, missionId, a2aInstanceId, a2aTask, {
878
888
  onError: (err) => {
@@ -929,7 +939,7 @@ export async function startServer(opts) {
929
939
  error: mission.error,
930
940
  });
931
941
  });
932
- // POST /api/v1/missions/:id/hitl_response → 200; forwards to owning executor over WS
942
+ // POST /api/v1/missions/:id/hitl_response → owning task's negotiated transport
933
943
  app.post('/api/v1/missions/:id/hitl_response', async (c) => {
934
944
  const missionId = c.req.param('id');
935
945
  const mission = executorRegistry.getMission(missionId);
@@ -944,9 +954,17 @@ export async function startServer(opts) {
944
954
  }
945
955
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
946
956
  const payload = body;
947
- if (!payload.hitl_id || !payload.response) {
957
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)
958
+ || typeof payload.hitl_id !== 'string' || !payload.hitl_id || !Object.hasOwn(payload, 'response')) {
948
959
  return c.json({ error: 'hitl_id and response are required' }, 400);
949
960
  }
961
+ if (mission.a2a) {
962
+ const result = await respondToA2AMission(executorRegistry, missionId, payload.hitl_id, payload.response);
963
+ return c.json(result.body, result.status);
964
+ }
965
+ if (typeof payload.response !== 'string' || !payload.response) {
966
+ return c.json({ error: 'Legacy HITL response must be a non-empty string' }, 400);
967
+ }
950
968
  // Push hitl_responded event to the executor over WS
951
969
  const envelope = {
952
970
  event: 'mission.hitl_responded',
@@ -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)
@@ -273,7 +290,7 @@ async function handleSteward(args, ctx) {
273
290
  }
274
291
  throw new AiwgError({
275
292
  code: 'ERR_USAGE_UNKNOWN_PERMISSION_OPERATION',
276
- message: `Unknown permissions operation: ${operation ?? '(missing)'}`,
293
+ message: `Unknown permissions operation: ${operation}`,
277
294
  hint: 'Use audit or migrate --dry-run|--apply.',
278
295
  exitCode: EXIT_CODES.USAGE,
279
296
  });
@@ -598,6 +615,18 @@ export const stewardHandler = {
598
615
  description: 'Provider capability routing and permission normalization',
599
616
  category: 'maintenance',
600
617
  aliases: [],
618
+ // The router intercepts --help before execute(), so a handler without this
619
+ // property gets the generic "no detailed help" stub even when its own usage
620
+ // text exists. Route to the same block bare invocation prints, and keep
621
+ // sub-namespace help reachable, without executing anything (#2533).
622
+ async help(ctx) {
623
+ const positional = ctx.args.filter((arg) => !arg.startsWith('-'));
624
+ if (positional[0] === 'permissions') {
625
+ return { exitCode: 0, message: permissionsUsage(), rawOutput: true };
626
+ }
627
+ await handleSteward([], ctx);
628
+ return { exitCode: 0 };
629
+ },
601
630
  async execute(ctx) {
602
631
  try {
603
632
  await handleSteward(ctx.args, ctx);
@@ -581,6 +581,7 @@ async function mirrorStandardCommandSkills(opts) {
581
581
  projectPath: opts.target,
582
582
  dryRun: opts.dryRun,
583
583
  verbose: opts.verbose,
584
+ deployVersion: (await getVersionInfo()).version,
584
585
  nameFilter: shouldMirrorStandardCommandSkill,
585
586
  });
586
587
  count += result.translated.length;
@@ -693,8 +694,10 @@ const SESSION_RELOAD_NOTICE = {
693
694
  rationale: 'Claude Code reads .claude/agents/ at session start. A running session retains its old registry until reloaded.',
694
695
  },
695
696
  codex: {
696
- action: 'Restart/open Codex in this target workspace so it picks up newly deployed agents and .agents/skills entries.',
697
- 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.',
698
701
  },
699
702
  copilot: {
700
703
  action: 'Reload the VS Code window (`Developer: Reload Window`) so Copilot picks up the new .github/agents/ entries.',
@@ -735,7 +738,8 @@ function printSessionReloadNotice(provider) {
735
738
  if (!notice)
736
739
  return;
737
740
  const defaultSymptom = 'Until reloaded, the Agent/Task tool will report "Agent type not found" for the newly deployed agents.';
738
- 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):', [
739
743
  notice.action,
740
744
  `Why: ${notice.rationale}`,
741
745
  notice.symptom ?? defaultSymptom,
@@ -1053,6 +1057,43 @@ async function countBundleDeployedArtifacts(bundlePath, target, provider) {
1053
1057
  };
1054
1058
  }
1055
1059
  const SKILL_SUPPORT_REFERENCE = /(?:^|[\s`('"\[])((?:templates|references|scripts|assets)\/[A-Za-z0-9._@/+\-]+)(?=$|[\s`)'"\],:;])/gm;
1060
+ /** Copy one support file, preserving its executable bit. */
1061
+ async function copySkillSupportFile(source, destination) {
1062
+ await fs.mkdir(path.dirname(destination), { recursive: true });
1063
+ await fs.copyFile(source, destination);
1064
+ await fs.chmod(destination, (await fs.stat(source)).mode & 0o777);
1065
+ }
1066
+ /**
1067
+ * Materialize a directory-valued support-asset reference (#2503).
1068
+ *
1069
+ * Applies the same rules the single-file path applies, per entry: symlinks are
1070
+ * refused rather than followed (a link inside a bundle can point anywhere), and
1071
+ * file modes are preserved so script packs stay executable. Empty directories
1072
+ * are still created — a reference to an empty pack is odd but not an error.
1073
+ */
1074
+ async function copySkillSupportTree(source, destination, sourceSkillMd, reference, deployFile) {
1075
+ await fs.mkdir(destination, { recursive: true });
1076
+ const label = reference.replace(/\/+$/, '');
1077
+ const entries = await fs.readdir(source, { withFileTypes: true });
1078
+ for (const entry of entries) {
1079
+ const from = path.join(source, entry.name);
1080
+ const to = path.join(destination, entry.name);
1081
+ if (entry.isSymbolicLink()) {
1082
+ throw new Error(`unsafe skill support asset '${label}/${entry.name}' referenced by ${sourceSkillMd}: symbolic links are not deployed`);
1083
+ }
1084
+ if (entry.isDirectory()) {
1085
+ await copySkillSupportTree(from, to, sourceSkillMd, `${label}/${entry.name}`, deployFile);
1086
+ continue;
1087
+ }
1088
+ if (!entry.isFile())
1089
+ continue;
1090
+ if (deployFile) {
1091
+ deployFile(from, to);
1092
+ continue;
1093
+ }
1094
+ await copySkillSupportFile(from, to);
1095
+ }
1096
+ }
1056
1097
  /**
1057
1098
  * Skill-relative support files may live beside the skill or at the bundle root
1058
1099
  * (plugin payloads commonly share report templates). Materialize
@@ -1103,11 +1144,18 @@ async function reconcileDeployedSkillAssets(bundlePath, target, provider, option
1103
1144
  }
1104
1145
  const candidates = [path.join(sourceSkillDir, normalized), path.join(bundlePath, normalized)];
1105
1146
  let source;
1147
+ let sourceIsDirectory = false;
1106
1148
  for (const candidate of candidates) {
1107
1149
  try {
1108
1150
  const stat = await fs.lstat(candidate);
1109
- if (stat.isFile() && !stat.isSymbolicLink()) {
1151
+ if (stat.isSymbolicLink())
1152
+ continue;
1153
+ // A reference may name a whole support directory (a templates pack,
1154
+ // a references folder). Rejecting those as "missing" aborted the
1155
+ // bundle deploy over a path that was present all along (#2503).
1156
+ if (stat.isFile() || stat.isDirectory()) {
1110
1157
  source = candidate;
1158
+ sourceIsDirectory = stat.isDirectory();
1111
1159
  break;
1112
1160
  }
1113
1161
  }
@@ -1119,6 +1167,9 @@ async function reconcileDeployedSkillAssets(bundlePath, target, provider, option
1119
1167
  }
1120
1168
  continue;
1121
1169
  }
1170
+ if (sourceIsDirectory && declaredEntrypoints.has(relative)) {
1171
+ throw new Error(`skill entrypoint '${relative}' in ${sourceSkillMd} resolves to a directory; an entrypoint must be a file`);
1172
+ }
1122
1173
  let deployedSkillRoot;
1123
1174
  for (const root of deployRoots) {
1124
1175
  // The deployer may select the bulk or kernel tier; use the tier that
@@ -1131,18 +1182,36 @@ async function reconcileDeployedSkillAssets(bundlePath, target, provider, option
1131
1182
  if (!deployedSkillRoot)
1132
1183
  throw new Error(`deployed skill '${skillName}' not found while reconciling support assets`);
1133
1184
  const destination = path.join(deployedSkillRoot, skillName, ...normalized.split('/'));
1134
- if (provider === 'omp') {
1135
- const adapter = await import(pathToFileURL(path.join(await getFrameworkRoot(), 'tools/agents/providers/omp.mjs')).href);
1136
- adapter.deploySkillSupportAsset(source, destination, { quiet: true });
1185
+ const deployFile = provider === 'omp'
1186
+ ? await (async () => {
1187
+ const adapter = await import(pathToFileURL(path.join(await getFrameworkRoot(), 'tools/agents/providers/omp.mjs')).href);
1188
+ return (from, to) => adapter.deploySkillSupportAsset(from, to, { quiet: true });
1189
+ })()
1190
+ : null;
1191
+ if (sourceIsDirectory) {
1192
+ await copySkillSupportTree(source, destination, sourceSkillMd, relative, deployFile);
1193
+ continue;
1194
+ }
1195
+ if (deployFile) {
1196
+ deployFile(source, destination);
1137
1197
  continue;
1138
1198
  }
1139
- await fs.mkdir(path.dirname(destination), { recursive: true });
1140
- await fs.copyFile(source, destination);
1141
- const mode = (await fs.stat(source)).mode & 0o777;
1142
- await fs.chmod(destination, mode);
1199
+ await copySkillSupportFile(source, destination);
1143
1200
  }
1144
1201
  }
1145
1202
  }
1203
+ /**
1204
+ * Managed-marker version for a project-local bundle's deployed artifacts (#2502).
1205
+ *
1206
+ * The deployer otherwise derives this from a `package.json` in the `--source`
1207
+ * tree; project-local bundles carry a `manifest.json` instead, so every
1208
+ * artifact was stamped `vunknown`. Falls back to `unknown` only when the
1209
+ * manifest itself omits a version.
1210
+ */
1211
+ function projectLocalDeployVersion(bundle) {
1212
+ const version = bundle.manifest.version;
1213
+ return typeof version === 'string' && version.length > 0 ? version : 'unknown';
1214
+ }
1146
1215
  /**
1147
1216
  * Deploy a single project-local bundle to one provider via deploy-agents.mjs.
1148
1217
  * Runs the same script and flags used for upstream addons, with the bundle
@@ -1187,6 +1256,12 @@ async function deployOneProjectLocalBundle(opts) {
1187
1256
  // never reach <provider>/.aiwg/skills/, leaving them invisible to
1188
1257
  // both the platform and the index.
1189
1258
  '--copy-all',
1259
+ // Provenance for the managed marker (#2502). Without this the deployer
1260
+ // stamps `bundled`/`unknown`, and `aiwg refresh`'s stale-artifact prune —
1261
+ // whose desired set is the packaged framework corpus — deletes every
1262
+ // project-local agent in the same run that re-deployed it.
1263
+ '--deploy-source', 'project-local',
1264
+ '--deploy-version', projectLocalDeployVersion(bundle),
1190
1265
  ...modelArgs,
1191
1266
  ];
1192
1267
  if (dryRun)
@@ -1197,7 +1272,9 @@ async function deployOneProjectLocalBundle(opts) {
1197
1272
  args.push('--quiet');
1198
1273
  // Project-local bundles are addon-shaped — never trigger the legacy commands
1199
1274
  // migration prompt (which is only relevant for full-framework deploys).
1200
- args.push('--skip-commands-migration');
1275
+ // This is a structural opt-out, not the operator declining, so suppress the
1276
+ // duplicate-commands warning too: it fired once per bundle (#2541).
1277
+ args.push('--skip-commands-migration', '--no-commands-warning');
1201
1278
  const captureOpts = quiet && !verbose ? { capture: true } : {};
1202
1279
  // Inject AIWG_ROOT so the deploy subprocess can resolve the upstream AIWG
1203
1280
  // install root. The bundle's `--source` is its project-local path, so
@@ -2122,6 +2199,35 @@ async function mirrorProjectLocalBundleToUserScope(opts) {
2122
2199
  * Deploys framework agents, commands, and skills to the current project,
2123
2200
  * then registers them in the extension registry for discovery.
2124
2201
  */
2202
+ const USE_HELP = `Usage: aiwg use <bundle> [options]
2203
+
2204
+ Deploy an AIWG framework, addon, or extension into the current project.
2205
+
2206
+ Bundles:
2207
+ all Kernel surface only (kernel skills, rules, behaviors)
2208
+ sdlc | research | ops | forensics | marketing | media-curator | ...
2209
+ Full framework surface (agents, commands, skills, rules)
2210
+ <addon> | <extension> Any installed addon or extension name
2211
+
2212
+ Options:
2213
+ --provider <name> Target provider (default: .aiwg/aiwg.config providers)
2214
+ --target <dir> Deploy into <dir> instead of the current directory
2215
+ --scope project|user Deploy to the project (default) or the user scope
2216
+ --force Re-write every artifact, replacing files AIWG does
2217
+ not currently manage. Use this to reclaim a
2218
+ directory left behind by an older AIWG install.
2219
+ --copy-all Mirror standard-tier skills into the project instead
2220
+ of relying on index-driven discovery
2221
+ --dry-run Preview the deployment without writing files
2222
+ --verbose, -v Show per-artifact deploy decisions
2223
+ --json Emit the machine-readable deployment result
2224
+ --no-project-local Skip project-local bundles under .aiwg/
2225
+ --no-context-files Skip WORKSPACE.md / AIWG.md / AGENTS.md emission
2226
+ -h, --help Show this help without deploying
2227
+
2228
+ Deployment counts report what the run wrote or already manages. Files AIWG does
2229
+ not own are listed separately as unmanaged and are never counted as deployed.
2230
+ `;
2125
2231
  export class UseHandler {
2126
2232
  id = 'use';
2127
2233
  name = 'Use Framework';
@@ -2129,6 +2235,9 @@ export class UseHandler {
2129
2235
  category = 'framework';
2130
2236
  aliases = [];
2131
2237
  orchestrationDepth = 0;
2238
+ async help() {
2239
+ return { exitCode: 0, message: USE_HELP, rawOutput: true };
2240
+ }
2132
2241
  async execute(ctx) {
2133
2242
  const requestedBundle = firstUsePositional(ctx.args)
2134
2243
  ?? (ctx.args[0] === '--profile' ? 'all' : undefined);
@@ -3400,6 +3509,7 @@ export class UseHandler {
3400
3509
  projectPath: target,
3401
3510
  dryRun,
3402
3511
  verbose,
3512
+ deployVersion: (await getVersionInfo()).version,
3403
3513
  });
3404
3514
  if (verbose && translationResult.translated.length > 0) {
3405
3515
  ui.success(`Translated ${translationResult.translated.length} skills → commands (${provider})`);
@@ -3450,6 +3560,7 @@ export class UseHandler {
3450
3560
  projectPath: target,
3451
3561
  dryRun,
3452
3562
  verbose,
3563
+ deployVersion: (await getVersionInfo()).version,
3453
3564
  nameFilter: shouldMirrorKernelCommandSkill,
3454
3565
  });
3455
3566
  if (verbose && kernel.translated.length > 0) {
@@ -548,18 +548,34 @@ export const doctorHandler = {
548
548
  namespace: 'aiwg',
549
549
  skillsBaseDir: skillsDir,
550
550
  });
551
- const errorAndWarn = collisions.filter(r => r.severity === 'error' || r.severity === 'warn');
552
- if (errorAndWarn.length > 0) {
553
- // In doctor context we report stale skills, not deployment blocks.
554
- // Re-running `aiwg use` will auto-clean aiwg-owned stale skills.
551
+ // Two distinct causes share this scan, and conflating them mislabels
552
+ // one as the other: an `error` is a name that shadows a Claude
553
+ // built-in; a `warn` is a deployed skill this namespace does not own
554
+ // (#2504). Report each under its own heading with its own remedy.
555
+ const builtinCollisions = collisions.filter(r => r.severity === 'error');
556
+ const unownedCollisions = collisions.filter(r => r.severity === 'warn');
557
+ if (builtinCollisions.length > 0 || unownedCollisions.length > 0) {
555
558
  console.log('\n── Skill collision scan ──');
556
- console.log('');
557
- console.log('⚠ Stale skills detected (names collide with Claude built-ins):');
558
- for (const r of errorAndWarn) {
559
- console.log(` ✗ ${r.skillName}: ${r.reason}`);
559
+ if (builtinCollisions.length > 0) {
560
+ console.log('');
561
+ console.log('⚠ Stale skills detected (names collide with Claude built-ins):');
562
+ for (const r of builtinCollisions) {
563
+ console.log(` ✗ ${r.skillName}: ${r.reason}`);
564
+ }
565
+ console.log('');
566
+ console.log(' Fix: run `aiwg use <framework>` to redeploy and auto-clean stale skill directories.');
567
+ }
568
+ if (unownedCollisions.length > 0) {
569
+ console.log('');
570
+ console.log("⚠ Deployed skills not owned by namespace 'aiwg' (a redeploy would overwrite them):");
571
+ for (const r of unownedCollisions) {
572
+ console.log(` ✗ ${r.skillName}: ${r.reason}`);
573
+ }
574
+ console.log('');
575
+ console.log(" Fix: if the skill is yours, move it out of the AIWG-managed skills directory");
576
+ console.log(" or give it its own namespace. If AIWG generated it, re-run `aiwg use` to");
577
+ console.log(' restore the ownership marker.');
560
578
  }
561
- console.log('');
562
- console.log(' Fix: run `aiwg use <framework>` to redeploy and auto-clean stale skill directories.');
563
579
  }
564
580
  }
565
581
  }