@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.
- package/dist/src/cli/handlers/help.js +7 -1
- package/dist/src/cli/handlers/installation.js +4 -0
- package/dist/src/cli/handlers/mc.js +13 -20
- package/dist/src/cli/handlers/ralph.js +14 -4
- package/dist/src/cli/handlers/refresh.js +298 -30
- package/dist/src/cli/handlers/runtime-info.js +3 -0
- package/dist/src/cli/handlers/serve.js +21 -3
- package/dist/src/cli/handlers/use.js +114 -8
- package/dist/src/cli/handlers/utilities.js +26 -10
- package/dist/src/cli/services/deployment-verification.js +117 -1
- package/dist/src/cli/watch-service.js +47 -4
- package/dist/src/config/project-artifacts-health.mjs +15 -2
- package/dist/src/cost/fleet-report.js +19 -5
- package/dist/src/extensions/project-local-doctor.js +40 -2
- package/dist/src/extensions/project-quickref.js +4 -0
- package/dist/src/installation/manager.mjs +38 -3
- package/dist/src/mcp/helpers.mjs +56 -22
- package/dist/src/mcp/registry.js +32 -22
- package/dist/src/mcp/registry.mjs +31 -26
- package/dist/src/mcp/toml-editor.mjs +117 -0
- package/dist/src/mcp/tools/orchestration.mjs +7 -7
- package/dist/src/mcp/tools/subsystems.mjs +7 -7
- package/dist/src/memory/context-pack.js +5 -1
- package/dist/src/plugin/skill-command-translator.js +70 -1
- package/dist/src/serve/a2a-terminal-observer.js +19 -1
- package/dist/src/serve/mission-hitl.js +91 -0
- package/dist/src/sessions/import-lease.js +5 -1
- package/dist/src/smiths/context-pipeline/workspace-context.js +81 -5
- package/dist/src/testing/fixtures/test-data-factory.js +3 -3
- package/dist/src/writing/pattern-library.js +29 -6
- package/package.json +2 -1
- package/tools/agents/deploy-agents.mjs +87 -5
- 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.
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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 (
|
|
185
|
-
if (identity.method !== actualMethod
|
|
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
|
};
|