@aiwg/cli 2026.9.6 → 2026.9.7

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 (33) hide show
  1. package/dist/src/cli/handlers/help.js +7 -1
  2. package/dist/src/cli/handlers/installation.js +4 -0
  3. package/dist/src/cli/handlers/mc.js +13 -20
  4. package/dist/src/cli/handlers/ralph.js +14 -4
  5. package/dist/src/cli/handlers/refresh.js +298 -30
  6. package/dist/src/cli/handlers/runtime-info.js +3 -0
  7. package/dist/src/cli/handlers/serve.js +21 -3
  8. package/dist/src/cli/handlers/use.js +114 -8
  9. package/dist/src/cli/handlers/utilities.js +26 -10
  10. package/dist/src/cli/services/deployment-verification.js +117 -1
  11. package/dist/src/cli/watch-service.js +47 -4
  12. package/dist/src/config/project-artifacts-health.mjs +15 -2
  13. package/dist/src/cost/fleet-report.js +19 -5
  14. package/dist/src/extensions/project-local-doctor.js +40 -2
  15. package/dist/src/extensions/project-quickref.js +4 -0
  16. package/dist/src/installation/manager.mjs +38 -3
  17. package/dist/src/mcp/helpers.mjs +56 -22
  18. package/dist/src/mcp/registry.js +32 -22
  19. package/dist/src/mcp/registry.mjs +31 -26
  20. package/dist/src/mcp/toml-editor.mjs +117 -0
  21. package/dist/src/mcp/tools/orchestration.mjs +7 -7
  22. package/dist/src/mcp/tools/subsystems.mjs +7 -7
  23. package/dist/src/memory/context-pack.js +5 -1
  24. package/dist/src/plugin/skill-command-translator.js +70 -1
  25. package/dist/src/serve/a2a-terminal-observer.js +19 -1
  26. package/dist/src/serve/mission-hitl.js +91 -0
  27. package/dist/src/sessions/import-lease.js +5 -1
  28. package/dist/src/smiths/context-pipeline/workspace-context.js +81 -5
  29. package/dist/src/testing/fixtures/test-data-factory.js +3 -3
  30. package/dist/src/writing/pattern-library.js +29 -6
  31. package/package.json +2 -1
  32. package/tools/agents/deploy-agents.mjs +87 -5
  33. package/tools/agents/providers/base.mjs +61 -2
@@ -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;
@@ -1053,6 +1054,43 @@ async function countBundleDeployedArtifacts(bundlePath, target, provider) {
1053
1054
  };
1054
1055
  }
1055
1056
  const SKILL_SUPPORT_REFERENCE = /(?:^|[\s`('"\[])((?:templates|references|scripts|assets)\/[A-Za-z0-9._@/+\-]+)(?=$|[\s`)'"\],:;])/gm;
1057
+ /** Copy one support file, preserving its executable bit. */
1058
+ async function copySkillSupportFile(source, destination) {
1059
+ await fs.mkdir(path.dirname(destination), { recursive: true });
1060
+ await fs.copyFile(source, destination);
1061
+ await fs.chmod(destination, (await fs.stat(source)).mode & 0o777);
1062
+ }
1063
+ /**
1064
+ * Materialize a directory-valued support-asset reference (#2503).
1065
+ *
1066
+ * Applies the same rules the single-file path applies, per entry: symlinks are
1067
+ * refused rather than followed (a link inside a bundle can point anywhere), and
1068
+ * file modes are preserved so script packs stay executable. Empty directories
1069
+ * are still created — a reference to an empty pack is odd but not an error.
1070
+ */
1071
+ async function copySkillSupportTree(source, destination, sourceSkillMd, reference, deployFile) {
1072
+ await fs.mkdir(destination, { recursive: true });
1073
+ const label = reference.replace(/\/+$/, '');
1074
+ const entries = await fs.readdir(source, { withFileTypes: true });
1075
+ for (const entry of entries) {
1076
+ const from = path.join(source, entry.name);
1077
+ const to = path.join(destination, entry.name);
1078
+ if (entry.isSymbolicLink()) {
1079
+ throw new Error(`unsafe skill support asset '${label}/${entry.name}' referenced by ${sourceSkillMd}: symbolic links are not deployed`);
1080
+ }
1081
+ if (entry.isDirectory()) {
1082
+ await copySkillSupportTree(from, to, sourceSkillMd, `${label}/${entry.name}`, deployFile);
1083
+ continue;
1084
+ }
1085
+ if (!entry.isFile())
1086
+ continue;
1087
+ if (deployFile) {
1088
+ deployFile(from, to);
1089
+ continue;
1090
+ }
1091
+ await copySkillSupportFile(from, to);
1092
+ }
1093
+ }
1056
1094
  /**
1057
1095
  * Skill-relative support files may live beside the skill or at the bundle root
1058
1096
  * (plugin payloads commonly share report templates). Materialize
@@ -1103,11 +1141,18 @@ async function reconcileDeployedSkillAssets(bundlePath, target, provider, option
1103
1141
  }
1104
1142
  const candidates = [path.join(sourceSkillDir, normalized), path.join(bundlePath, normalized)];
1105
1143
  let source;
1144
+ let sourceIsDirectory = false;
1106
1145
  for (const candidate of candidates) {
1107
1146
  try {
1108
1147
  const stat = await fs.lstat(candidate);
1109
- if (stat.isFile() && !stat.isSymbolicLink()) {
1148
+ if (stat.isSymbolicLink())
1149
+ continue;
1150
+ // A reference may name a whole support directory (a templates pack,
1151
+ // a references folder). Rejecting those as "missing" aborted the
1152
+ // bundle deploy over a path that was present all along (#2503).
1153
+ if (stat.isFile() || stat.isDirectory()) {
1110
1154
  source = candidate;
1155
+ sourceIsDirectory = stat.isDirectory();
1111
1156
  break;
1112
1157
  }
1113
1158
  }
@@ -1119,6 +1164,9 @@ async function reconcileDeployedSkillAssets(bundlePath, target, provider, option
1119
1164
  }
1120
1165
  continue;
1121
1166
  }
1167
+ if (sourceIsDirectory && declaredEntrypoints.has(relative)) {
1168
+ throw new Error(`skill entrypoint '${relative}' in ${sourceSkillMd} resolves to a directory; an entrypoint must be a file`);
1169
+ }
1122
1170
  let deployedSkillRoot;
1123
1171
  for (const root of deployRoots) {
1124
1172
  // The deployer may select the bulk or kernel tier; use the tier that
@@ -1131,18 +1179,36 @@ async function reconcileDeployedSkillAssets(bundlePath, target, provider, option
1131
1179
  if (!deployedSkillRoot)
1132
1180
  throw new Error(`deployed skill '${skillName}' not found while reconciling support assets`);
1133
1181
  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 });
1182
+ const deployFile = provider === 'omp'
1183
+ ? await (async () => {
1184
+ const adapter = await import(pathToFileURL(path.join(await getFrameworkRoot(), 'tools/agents/providers/omp.mjs')).href);
1185
+ return (from, to) => adapter.deploySkillSupportAsset(from, to, { quiet: true });
1186
+ })()
1187
+ : null;
1188
+ if (sourceIsDirectory) {
1189
+ await copySkillSupportTree(source, destination, sourceSkillMd, relative, deployFile);
1190
+ continue;
1191
+ }
1192
+ if (deployFile) {
1193
+ deployFile(source, destination);
1137
1194
  continue;
1138
1195
  }
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);
1196
+ await copySkillSupportFile(source, destination);
1143
1197
  }
1144
1198
  }
1145
1199
  }
1200
+ /**
1201
+ * Managed-marker version for a project-local bundle's deployed artifacts (#2502).
1202
+ *
1203
+ * The deployer otherwise derives this from a `package.json` in the `--source`
1204
+ * tree; project-local bundles carry a `manifest.json` instead, so every
1205
+ * artifact was stamped `vunknown`. Falls back to `unknown` only when the
1206
+ * manifest itself omits a version.
1207
+ */
1208
+ function projectLocalDeployVersion(bundle) {
1209
+ const version = bundle.manifest.version;
1210
+ return typeof version === 'string' && version.length > 0 ? version : 'unknown';
1211
+ }
1146
1212
  /**
1147
1213
  * Deploy a single project-local bundle to one provider via deploy-agents.mjs.
1148
1214
  * Runs the same script and flags used for upstream addons, with the bundle
@@ -1187,6 +1253,12 @@ async function deployOneProjectLocalBundle(opts) {
1187
1253
  // never reach <provider>/.aiwg/skills/, leaving them invisible to
1188
1254
  // both the platform and the index.
1189
1255
  '--copy-all',
1256
+ // Provenance for the managed marker (#2502). Without this the deployer
1257
+ // stamps `bundled`/`unknown`, and `aiwg refresh`'s stale-artifact prune —
1258
+ // whose desired set is the packaged framework corpus — deletes every
1259
+ // project-local agent in the same run that re-deployed it.
1260
+ '--deploy-source', 'project-local',
1261
+ '--deploy-version', projectLocalDeployVersion(bundle),
1190
1262
  ...modelArgs,
1191
1263
  ];
1192
1264
  if (dryRun)
@@ -2122,6 +2194,35 @@ async function mirrorProjectLocalBundleToUserScope(opts) {
2122
2194
  * Deploys framework agents, commands, and skills to the current project,
2123
2195
  * then registers them in the extension registry for discovery.
2124
2196
  */
2197
+ const USE_HELP = `Usage: aiwg use <bundle> [options]
2198
+
2199
+ Deploy an AIWG framework, addon, or extension into the current project.
2200
+
2201
+ Bundles:
2202
+ all Kernel surface only (kernel skills, rules, behaviors)
2203
+ sdlc | research | ops | forensics | marketing | media-curator | ...
2204
+ Full framework surface (agents, commands, skills, rules)
2205
+ <addon> | <extension> Any installed addon or extension name
2206
+
2207
+ Options:
2208
+ --provider <name> Target provider (default: .aiwg/aiwg.config providers)
2209
+ --target <dir> Deploy into <dir> instead of the current directory
2210
+ --scope project|user Deploy to the project (default) or the user scope
2211
+ --force Re-write every artifact, replacing files AIWG does
2212
+ not currently manage. Use this to reclaim a
2213
+ directory left behind by an older AIWG install.
2214
+ --copy-all Mirror standard-tier skills into the project instead
2215
+ of relying on index-driven discovery
2216
+ --dry-run Preview the deployment without writing files
2217
+ --verbose, -v Show per-artifact deploy decisions
2218
+ --json Emit the machine-readable deployment result
2219
+ --no-project-local Skip project-local bundles under .aiwg/
2220
+ --no-context-files Skip WORKSPACE.md / AIWG.md / AGENTS.md emission
2221
+ -h, --help Show this help without deploying
2222
+
2223
+ Deployment counts report what the run wrote or already manages. Files AIWG does
2224
+ not own are listed separately as unmanaged and are never counted as deployed.
2225
+ `;
2125
2226
  export class UseHandler {
2126
2227
  id = 'use';
2127
2228
  name = 'Use Framework';
@@ -2129,6 +2230,9 @@ export class UseHandler {
2129
2230
  category = 'framework';
2130
2231
  aliases = [];
2131
2232
  orchestrationDepth = 0;
2233
+ async help() {
2234
+ return { exitCode: 0, message: USE_HELP, rawOutput: true };
2235
+ }
2132
2236
  async execute(ctx) {
2133
2237
  const requestedBundle = firstUsePositional(ctx.args)
2134
2238
  ?? (ctx.args[0] === '--profile' ? 'all' : undefined);
@@ -3400,6 +3504,7 @@ export class UseHandler {
3400
3504
  projectPath: target,
3401
3505
  dryRun,
3402
3506
  verbose,
3507
+ deployVersion: (await getVersionInfo()).version,
3403
3508
  });
3404
3509
  if (verbose && translationResult.translated.length > 0) {
3405
3510
  ui.success(`Translated ${translationResult.translated.length} skills → commands (${provider})`);
@@ -3450,6 +3555,7 @@ export class UseHandler {
3450
3555
  projectPath: target,
3451
3556
  dryRun,
3452
3557
  verbose,
3558
+ deployVersion: (await getVersionInfo()).version,
3453
3559
  nameFilter: shouldMirrorKernelCommandSkill,
3454
3560
  });
3455
3561
  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
  }
@@ -1,4 +1,4 @@
1
- import { access, readFile, readdir } from 'node:fs/promises';
1
+ import { access, readFile, readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { loadGraphIndexFile } from '../../artifacts/index-reader.js';
4
4
  import { readAiwgConfig } from '../../config/aiwg-config.js';
@@ -111,6 +111,101 @@ async function countEntries(candidate) {
111
111
  function emptyCounts() {
112
112
  return { agents: 0, commands: 0, skills: 0, rules: 0, behaviors: 0 };
113
113
  }
114
+ /**
115
+ * Flat-artifact attribution (#2507).
116
+ *
117
+ * Deployment counts used to be a plain `readdir` of the provider directory, so
118
+ * a run that wrote nothing still reported every pre-existing file as deployed —
119
+ * a no-op deploy over a stale, unmanaged tree was indistinguishable from a
120
+ * successful one. An artifact now counts as deployed only when this run wrote
121
+ * it (mtime at or after the invocation start) or AIWG owns it (sidecar entry or
122
+ * in-file managed marker). Everything else is reported as unmanaged.
123
+ */
124
+ const MANAGED_SIDECAR = '.aiwg-manifest.json';
125
+ const MANAGED_MARKER_PATTERN = /^(?:<!--\s*aiwg:managed\s|#\s*aiwg:managed\s)/m;
126
+ const FLAT_ARTIFACT_KINDS = ['agents', 'commands', 'rules'];
127
+ const FLAT_ARTIFACT_EXTENSIONS = ['.md', '.mdc', '.toml'];
128
+ /** Clock skew tolerance between the recorded invocation start and file mtimes. */
129
+ const WRITE_ATTRIBUTION_SKEW_MS = 2_000;
130
+ const FLAT_ARTIFACT_NOUNS = {
131
+ agents: 'agent',
132
+ commands: 'command',
133
+ rules: 'rule',
134
+ };
135
+ async function readManagedSidecarNames(dir) {
136
+ try {
137
+ const raw = await readFile(path.join(dir, MANAGED_SIDECAR), 'utf8');
138
+ const parsed = JSON.parse(raw);
139
+ return new Set(Object.keys(parsed.managed ?? {}));
140
+ }
141
+ catch {
142
+ return new Set();
143
+ }
144
+ }
145
+ /**
146
+ * Split one flat artifact directory into artifacts this deployment accounts for
147
+ * and artifacts it does not. Returns `null` when the directory cannot be
148
+ * attributed (missing, unreadable, or no invocation boundary to compare
149
+ * against), so callers fall back to the plain entry count.
150
+ */
151
+ async function tallyFlatArtifacts(dir, writtenSince) {
152
+ if (!dir || writtenSince === null)
153
+ return null;
154
+ let entries;
155
+ try {
156
+ entries = await readdir(dir, { withFileTypes: true });
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ const managedNames = await readManagedSidecarNames(dir);
162
+ const tally = { deployed: 0, unmanaged: [] };
163
+ for (const entry of entries) {
164
+ if (entry.name.startsWith('.'))
165
+ continue;
166
+ if (!entry.isFile()) {
167
+ // Nested directories (e.g. deployed behaviors under rules/) are counted
168
+ // as-is; they are not flat artifacts and have their own lifecycle.
169
+ tally.deployed += 1;
170
+ continue;
171
+ }
172
+ const lower = entry.name.toLowerCase();
173
+ if (!FLAT_ARTIFACT_EXTENSIONS.some((extension) => lower.endsWith(extension)))
174
+ continue;
175
+ const absolute = path.join(dir, entry.name);
176
+ if (managedNames.has(entry.name)) {
177
+ tally.deployed += 1;
178
+ continue;
179
+ }
180
+ let writtenThisRun = false;
181
+ try {
182
+ const info = await stat(absolute);
183
+ writtenThisRun = info.mtimeMs + WRITE_ATTRIBUTION_SKEW_MS >= writtenSince;
184
+ }
185
+ catch {
186
+ writtenThisRun = false;
187
+ }
188
+ if (writtenThisRun) {
189
+ tally.deployed += 1;
190
+ continue;
191
+ }
192
+ let owned = false;
193
+ try {
194
+ owned = MANAGED_MARKER_PATTERN.test(await readFile(absolute, 'utf8'));
195
+ }
196
+ catch {
197
+ // Unreadable files are not claimed as deployed, but neither are they
198
+ // reported as shadowing artifacts we could not inspect.
199
+ continue;
200
+ }
201
+ if (owned)
202
+ tally.deployed += 1;
203
+ else
204
+ tally.unmanaged.push(entry.name);
205
+ }
206
+ tally.unmanaged.sort((a, b) => a.localeCompare(b));
207
+ return tally;
208
+ }
114
209
  function phase(id, state, required, summary, evidence) {
115
210
  return { id, state, required, summary, evidence };
116
211
  }
@@ -249,9 +344,30 @@ export async function verifyProviderDeployment(options) {
249
344
  const artifactPaths = options.scope === 'user'
250
345
  ? USER_SCOPE_PATHS[normalized] ?? definition.paths.artifacts
251
346
  : definition.paths.artifacts;
347
+ const writtenSince = options.invocationStartedAt
348
+ ? Date.parse(options.invocationStartedAt)
349
+ : Number.NaN;
350
+ const attributionBoundary = Number.isFinite(writtenSince) ? writtenSince : null;
252
351
  for (const type of ['agents', 'commands', 'skills', 'rules', 'behaviors']) {
253
352
  const resolved = resolveProviderPathValue(artifactPaths[type], deploymentRoot);
254
353
  counts[type] = await countEntries(resolved);
354
+ // #2507: flat artifact directories report what this deployment accounts
355
+ // for, not whatever happens to be sitting in the directory.
356
+ const flatKind = FLAT_ARTIFACT_KINDS.find((kind) => kind === type);
357
+ if (!flatKind)
358
+ continue;
359
+ const tally = await tallyFlatArtifacts(resolved, attributionBoundary);
360
+ if (!tally)
361
+ continue;
362
+ counts[flatKind] = tally.deployed;
363
+ if (tally.unmanaged.length === 0)
364
+ continue;
365
+ const shown = tally.unmanaged.slice(0, 3).join(', ');
366
+ const remainder = tally.unmanaged.length - 3;
367
+ findings.push(finding(normalized, `unmanaged-artifacts:${flatKind}`, 'advisory', `${tally.unmanaged.length} unmanaged ${FLAT_ARTIFACT_NOUNS[flatKind]} file(s) left in place at ${artifactPaths[flatKind]}: `
368
+ + `${shown}${remainder > 0 ? `, and ${remainder} more` : ''}. `
369
+ + 'They are not managed by AIWG and were not counted as deployed.', `Re-run aiwg use ${options.requestedBundles[0] ?? 'all'} --provider ${normalized} --force to replace them, `
370
+ + `or delete ${artifactPaths[flatKind]} so AIWG can reclaim the directory.`, { kind: flatKind, unmanaged: tally.unmanaged }));
255
371
  }
256
372
  const resolvedSkillsPath = resolveProviderPathValue(artifactPaths.skills, deploymentRoot);
257
373
  const kernelPath = options.scope === 'user'
@@ -46,10 +46,6 @@ export class WatchService {
46
46
  this.watcher.on('add', (path) => this.handleEvent('add', path));
47
47
  this.watcher.on('change', (path) => this.handleEvent('change', path));
48
48
  this.watcher.on('unlink', (path) => this.handleEvent('unlink', path));
49
- this.watcher.on('ready', () => {
50
- const watched = this.watcher?.getWatched() || {};
51
- this.stats.filesWatched = Object.values(watched).reduce((sum, files) => sum + files.length, 0);
52
- });
53
49
  this.watcher.on('error', (error) => {
54
50
  this.stats.errors++;
55
51
  console.error('Watch error:', error);
@@ -64,6 +60,53 @@ export class WatchService {
64
60
  resolve();
65
61
  }
66
62
  });
63
+ // `ready` only means chokidar finished its initial scan. Because the
64
+ // watcher runs with `ignoreInitial: true`, a file created between the scan
65
+ // and the watch actually being armed is reported by neither — the event is
66
+ // absent rather than late, so no caller-side wait can recover it (#2518).
67
+ // Resolving `start()` only once every target appears in `getWatched()`
68
+ // makes readiness mean armed.
69
+ await this.waitUntilArmed(patterns);
70
+ const watched = this.watcher?.getWatched() ?? {};
71
+ this.stats.filesWatched = Object.values(watched).reduce((sum, files) => sum + files.length, 0);
72
+ }
73
+ /**
74
+ * Poll `getWatched()` until every requested target is present, or the budget
75
+ * expires. Bounded on purpose: a target that does not exist on disk can never
76
+ * be armed, and `start()` must not hang waiting for one.
77
+ *
78
+ * The healthy path satisfies the first synchronous check and never awaits, so
79
+ * a test that mocks the watcher under fake timers must report its targets
80
+ * from `getWatched()` — otherwise the poll waits on a clock nothing advances.
81
+ */
82
+ async waitUntilArmed(patterns, timeoutMs = 500, pollIntervalMs = 25) {
83
+ const deadline = Date.now() + timeoutMs;
84
+ while (!this.allTargetsArmed(patterns)) {
85
+ if (Date.now() >= deadline) {
86
+ return;
87
+ }
88
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
89
+ }
90
+ }
91
+ /** True when chokidar reports a watch covering every requested target. */
92
+ allTargetsArmed(patterns) {
93
+ if (!this.watcher) {
94
+ return true;
95
+ }
96
+ const watched = this.watcher.getWatched();
97
+ // chokidar keys `getWatched()` with the spelling it was given, so resolve
98
+ // both sides before comparing.
99
+ const armed = new Set();
100
+ for (const [dir, entries] of Object.entries(watched)) {
101
+ const absoluteDir = path.resolve(dir);
102
+ armed.add(absoluteDir);
103
+ for (const entry of entries) {
104
+ armed.add(path.join(absoluteDir, entry));
105
+ }
106
+ }
107
+ // A directory target is keyed directly; a file target is listed under its
108
+ // parent. Either spelling resolves into the same set.
109
+ return patterns.every((pattern) => armed.has(path.resolve(pattern)));
67
110
  }
68
111
  /**
69
112
  * Stop watching
@@ -89,10 +89,23 @@ export function auditProjectArtifactHealth(projectDir, env = process.env) {
89
89
  severity = 'error';
90
90
  repairable = controls.filter((item) => !item.local).every((item) => item.external);
91
91
  action = 'Run `aiwg artifacts repair --dry-run`, then `aiwg artifacts repair --apply` after reviewing the plan.';
92
- } else if (divergentControl.length || divergentPayload.length) {
92
+ } else if (divergentControl.length) {
93
+ // Only control-plane divergence is genuinely manual: `repairProjectArtifacts`
94
+ // refuses outright on it, because AIWG.md / aiwg.config / registry.json have
95
+ // no safe automatic winner (#2516).
93
96
  classification = 'duplicated-divergent';
94
97
  severity = 'error';
95
- action = 'Reconcile the reported local and external files manually; no automatic repair will overwrite divergent content.';
98
+ action = 'Reconcile the reported control-plane files manually; automatic repair refuses while they diverge.';
99
+ } else if (divergentPayload.length) {
100
+ // Divergent *payload* is repairable and always has been — repair archives the
101
+ // local variant under archive/local-corpus-migration/conflicts/local/, leaves
102
+ // the external variant untouched, and removes local only after byte
103
+ // verification. Reporting this as manual-only steered operators away from a
104
+ // working automatic path and into hand-migrating corpora (#2516).
105
+ classification = 'duplicated-divergent-payload';
106
+ severity = 'warning';
107
+ repairable = true;
108
+ action = 'Run `aiwg artifacts repair --dry-run`, then `aiwg artifacts repair --apply`; divergent local variants are archived, never overwritten.';
96
109
  } else if (localPayload.length) {
97
110
  classification = 'duplicated-identical';
98
111
  severity = 'warning';
@@ -179,13 +179,27 @@ function requestSignal(parent) {
179
179
  }
180
180
  async function openRouterGet(endpoint, key, options) {
181
181
  const fetchImpl = options.fetchImpl ?? fetch;
182
- const response = await fetchImpl(`${options.apiBaseUrl ?? OPENROUTER_API}${endpoint}`, {
183
- headers: { Authorization: `Bearer ${key}` },
184
- signal: requestSignal(options.signal),
185
- });
182
+ let response;
183
+ try {
184
+ response = await fetchImpl(`${options.apiBaseUrl ?? OPENROUTER_API}${endpoint}`, {
185
+ headers: { Authorization: `Bearer ${key}` },
186
+ signal: requestSignal(options.signal),
187
+ });
188
+ }
189
+ catch {
190
+ // Transport diagnostics can embed Authorization headers or request bodies.
191
+ throw new Error('OpenRouter request could not complete. Check connectivity, timeout, or cancellation.');
192
+ }
186
193
  if (!response.ok)
187
194
  throw new Error(`OpenRouter request failed with status ${response.status}.`);
188
- const payload = await response.json();
195
+ let payload;
196
+ try {
197
+ payload = await response.json();
198
+ }
199
+ catch {
200
+ // Decoder exceptions can quote arbitrary response content.
201
+ throw new Error('OpenRouter returned unreadable JSON. Check the API response format.');
202
+ }
189
203
  if (!payload || typeof payload !== 'object' || !payload.data)
190
204
  throw new Error('OpenRouter returned an invalid response.');
191
205
  return payload.data;
@@ -67,7 +67,7 @@ export async function buildProjectLocalDoctorSection(opts) {
67
67
  }
68
68
  // No project-local content → no section at all
69
69
  if (discovery.isEmpty && discovery.errors.length === 0 && !quickrefAudit.exists && quickrefErrors.length === 0) {
70
- return { output: '', validationErrors: 0, denylistViolations: 0, driftCount: 0, hasFailures: false };
70
+ return { output: '', validationErrors: 0, denylistViolations: 0, driftCount: 0, undeployedCount: 0, hasFailures: false };
71
71
  }
72
72
  const lines = ['', '── Project-local artifacts ────────────────────────────────────'];
73
73
  // Counts
@@ -108,12 +108,17 @@ export async function buildProjectLocalDoctorSection(opts) {
108
108
  lines.push('');
109
109
  // Shadows + denylist
110
110
  let denylistViolations = 0;
111
+ // Bundles the resolver deliberately refused are already reported below as
112
+ // denylist violations; the undeployed check must not double-count them.
113
+ const refusedBundleIds = new Set();
111
114
  if (discovery.bundles.length > 0) {
112
115
  try {
113
116
  const upstream = await buildUpstreamRegistry({ frameworkRoot });
114
117
  const shadowResult = await resolveShadows(discovery.bundles, upstream);
115
118
  const refusals = shadowResult.resolutions.filter(r => r.verdict === 'refuse-unsafe' || r.verdict === 'refuse-phantom' || r.verdict === 'refuse-duplicate');
116
119
  denylistViolations = refusals.length;
120
+ for (const refusal of refusals)
121
+ refusedBundleIds.add(refusal.bundleId);
117
122
  if (!quiet) {
118
123
  const informational = shadowResult.shadows.filter(s => s.verdict === 'deploy-with-warning' || s.verdict === 'deploy-acknowledged');
119
124
  if (informational.length > 0) {
@@ -188,6 +193,37 @@ export async function buildProjectLocalDoctorSection(opts) {
188
193
  }
189
194
  lines.push('');
190
195
  }
196
+ // Undeployed bundles (#2503).
197
+ //
198
+ // A bundle whose deploy aborted (a bad support-asset reference, a failed CLI
199
+ // contribution) leaves no `installed` entry, so every check above — manifest
200
+ // validation, drift — silently skips it and reports a clean bill of health
201
+ // for a bundle that is not actually available. The only prior signal was a
202
+ // WARN line in `aiwg use` output, long scrolled away by the time anyone
203
+ // wonders where the skill went.
204
+ const undeployed = config
205
+ ? discovery.bundles.filter((bundle) => {
206
+ if (refusedBundleIds.has(bundle.id))
207
+ return false;
208
+ const entry = config.installed[bundle.id];
209
+ return !entry || entry.source !== 'project-local';
210
+ })
211
+ : [];
212
+ if (undeployed.length > 0) {
213
+ lines.push(` Deployment: ✗ ${undeployed.length} discovered bundle${undeployed.length === 1 ? '' : 's'} not deployed`);
214
+ for (const bundle of undeployed.slice(0, 5)) {
215
+ lines.push(` ✗ ${bundle.type}/${bundle.id} (${bundle.localPath}) — no deployment recorded`);
216
+ }
217
+ if (undeployed.length > 5)
218
+ lines.push(` + ${undeployed.length - 5} more`);
219
+ lines.push(` Run \`aiwg use ${undeployed[0].id}\` and read the output — a deploy that`);
220
+ lines.push(' fails reports the reason there.');
221
+ lines.push('');
222
+ }
223
+ else if (!quiet && config && discovery.bundles.length > 0) {
224
+ lines.push(' Deployment: ✓ all discovered bundles deployed');
225
+ lines.push('');
226
+ }
191
227
  // Provider deployment matrix
192
228
  if (!quiet && config) {
193
229
  const projectLocalEntries = Object.entries(config.installed).filter(([, e]) => e.source === 'project-local');
@@ -252,12 +288,14 @@ export async function buildProjectLocalDoctorSection(opts) {
252
288
  lines.push('');
253
289
  }
254
290
  }
255
- const hasFailures = validationErrors > 0 || denylistViolations > 0 || driftCount > 0 || gitignoredCount > 0;
291
+ const hasFailures = validationErrors > 0 || denylistViolations > 0 || driftCount > 0
292
+ || gitignoredCount > 0 || undeployed.length > 0;
256
293
  return {
257
294
  output: lines.join('\n'),
258
295
  validationErrors,
259
296
  denylistViolations,
260
297
  driftCount,
298
+ undeployedCount: undeployed.length,
261
299
  hasFailures,
262
300
  };
263
301
  }
@@ -258,6 +258,10 @@ export function renderProjectQuickref(definition) {
258
258
  '---',
259
259
  `name: ${skillName}`,
260
260
  `description: ${JSON.stringify(`Project-specific orientation for ${definition.project.name}`)}`,
261
+ // AIWG generates and deploys this skill, so it must declare AIWG ownership.
262
+ // Without it the collision scan treats every redeploy of AIWG's own artifact
263
+ // as an unowned overwrite and warns permanently (#2504).
264
+ 'namespace: aiwg',
261
265
  'kernel: true',
262
266
  'platforms: [all]',
263
267
  '---',
@@ -176,13 +176,44 @@ export function inspectInstallation(options = {}) {
176
176
  const actualRoot = canonicalPath(options.actualRoot);
177
177
  const actualMethod = options.actualMethod ?? inferInstallationMethod(actualRoot);
178
178
  const identity = options.identity ?? loadInstallationIdentity({ ...options, actualRoot });
179
- if (!identity) return { state: 'unrecorded', identity: null, actualRoot, actualMethod, drift: ['installation identity is not recorded'] };
179
+ if (!identity) {
180
+ return {
181
+ state: 'unrecorded',
182
+ identity: null,
183
+ actualRoot,
184
+ actualMethod,
185
+ frameworkRoot: actualRoot,
186
+ launcher: null,
187
+ drift: ['installation identity is not recorded'],
188
+ };
189
+ }
180
190
 
181
191
  const drift = [];
182
192
  const canonicalRoot = canonicalPath(identity.root);
193
+
194
+ // Edge/customize mode deliberately separates the *launcher* (the executable
195
+ // that ran, typically an npm-global install) from the *framework root* (the
196
+ // local clone named by `edgePath`). Comparing the launcher's package root
197
+ // against the canonical root then reports the supported configuration as
198
+ // drift, and does so only for the commands that happen to run from the
199
+ // npm-global copy — `aiwg doctor`, loaded through the redirect, saw the
200
+ // clone and reported aligned for the same workspace (#2505).
201
+ //
202
+ // The redirect is only trusted when the identity actually declares it: the
203
+ // channel is `edge` and `edgePath` resolves to the canonical root.
204
+ const edgePath = identity.edgePath ? canonicalPath(identity.edgePath) : null;
205
+ const rootsDiffer = canonicalRoot !== actualRoot;
206
+ const launcherRedirect = identity.channel === 'edge' && edgePath !== null && edgePath === canonicalRoot && rootsDiffer;
207
+ const launcher = launcherRedirect ? { root: actualRoot, method: actualMethod } : null;
208
+ // The framework root is what AIWG actually reads its corpus from, and it is
209
+ // the single value that drives `state`.
210
+ const frameworkRoot = launcherRedirect ? canonicalRoot : actualRoot;
211
+
183
212
  if (!existsSync(canonicalRoot)) drift.push(`canonical root does not exist: ${canonicalRoot}`);
184
- if (canonicalRoot !== actualRoot) drift.push(`actual root ${actualRoot} differs from canonical root ${canonicalRoot}`);
185
- if (identity.method !== actualMethod) drift.push(`actual method ${actualMethod} differs from canonical method ${identity.method}`);
213
+ if (rootsDiffer && !launcherRedirect) drift.push(`actual root ${actualRoot} differs from canonical root ${canonicalRoot}`);
214
+ if (identity.method !== actualMethod && !launcherRedirect) {
215
+ drift.push(`actual method ${actualMethod} differs from canonical method ${identity.method}`);
216
+ }
186
217
  if (identity.method !== 'web' && !identity.managerExecutable) {
187
218
  drift.push(`canonical ${identity.method} installation has no recorded manager executable`);
188
219
  }
@@ -214,6 +245,10 @@ export function inspectInstallation(options = {}) {
214
245
  canonicalRoot,
215
246
  actualRoot,
216
247
  actualMethod,
248
+ /** Where the corpus is read from. Equals actualRoot unless a launcher redirect applies. */
249
+ frameworkRoot,
250
+ /** Non-null only in edge/customize mode: the executable's own package root. */
251
+ launcher,
217
252
  drift,
218
253
  managerProbe,
219
254
  };