@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.
- package/dist/src/artifacts/index-builder.js +43 -1
- package/dist/src/artifacts/query-engine.js +7 -0
- package/dist/src/cli/handlers/help.js +7 -1
- package/dist/src/cli/handlers/installation.js +106 -2
- package/dist/src/cli/handlers/mc.js +100 -37
- package/dist/src/cli/handlers/ralph.js +14 -4
- package/dist/src/cli/handlers/refresh.js +359 -31
- package/dist/src/cli/handlers/repo-access.js +155 -4
- package/dist/src/cli/handlers/runtime-info.js +3 -0
- package/dist/src/cli/handlers/serve.js +21 -3
- package/dist/src/cli/handlers/setup.js +5 -5
- package/dist/src/cli/handlers/steward.js +30 -1
- package/dist/src/cli/handlers/use.js +123 -12
- package/dist/src/cli/handlers/utilities.js +26 -10
- package/dist/src/cli/handlers/version.js +40 -14
- package/dist/src/cli/handlers/workspace-context.js +8 -0
- package/dist/src/cli/services/deployment-verification.js +156 -7
- package/dist/src/cli/watch-service.js +47 -4
- package/dist/src/config/aiwg-config.js +95 -3
- package/dist/src/config/cli.js +16 -1
- package/dist/src/config/gitignore.js +5 -0
- package/dist/src/config/project-artifacts-health.mjs +15 -2
- package/dist/src/cost/fleet-report.js +19 -5
- package/dist/src/extensions/claude-hooks-installer.js +22 -6
- 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/lint/runner.js +138 -0
- 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 +132 -6
- 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 +91 -5
- package/tools/agents/providers/base.mjs +162 -6
|
@@ -12,7 +12,7 @@ import { getVersionInfo } from '../../channel/manager.mjs';
|
|
|
12
12
|
import { getLoggerInfo } from '../log.js';
|
|
13
13
|
import * as ui from '../ui.js';
|
|
14
14
|
import { maybePrintCommunityFooter } from '../../community/footer.js';
|
|
15
|
-
import { existsSync, statSync, readdirSync } from 'fs';
|
|
15
|
+
import { existsSync, statSync, readdirSync, readFileSync } from 'fs';
|
|
16
16
|
import path from 'path';
|
|
17
17
|
/**
|
|
18
18
|
* Version command handler
|
|
@@ -30,6 +30,18 @@ export const versionHandler = {
|
|
|
30
30
|
return { exitCode: 0 };
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Print the installation-drift notice when the canonical declaration and the
|
|
35
|
+
* running binary disagree. Keeps `aiwg version` honest about which one it is
|
|
36
|
+
* describing, and names the command that explains the rest (#2529).
|
|
37
|
+
*/
|
|
38
|
+
function printDrift(fp) {
|
|
39
|
+
if (!fp.drift)
|
|
40
|
+
return;
|
|
41
|
+
const method = fp.installation?.identity?.method ?? 'unrecorded';
|
|
42
|
+
const declared = fp.drift.canonicalVersion ? ` (${fp.drift.canonicalVersion})` : '';
|
|
43
|
+
ui.dim(` ! canonical install declares ${method} at ${fp.drift.canonicalRoot}${declared} — run \`aiwg installation show\``);
|
|
44
|
+
}
|
|
33
45
|
function collectFingerprint(versionInfo) {
|
|
34
46
|
const loggerInfo = getLoggerInfo();
|
|
35
47
|
const fp = {
|
|
@@ -62,6 +74,22 @@ function collectFingerprint(versionInfo) {
|
|
|
62
74
|
path: versionInfo.edgePath ?? versionInfo.packageRoot,
|
|
63
75
|
};
|
|
64
76
|
}
|
|
77
|
+
// `version` is the first thing anyone runs to answer "what am I on". If the
|
|
78
|
+
// canonical install declares a different root than the one executing, say so
|
|
79
|
+
// here rather than letting drift persist while every check looks correct.
|
|
80
|
+
const canonicalRoot = fp.installation?.identity?.root;
|
|
81
|
+
const actualRoot = fp.installation?.actualRoot;
|
|
82
|
+
if (canonicalRoot && actualRoot && path.resolve(canonicalRoot) !== path.resolve(actualRoot)) {
|
|
83
|
+
let canonicalVersion = null;
|
|
84
|
+
try {
|
|
85
|
+
const pkg = JSON.parse(readFileSync(path.join(canonicalRoot, 'package.json'), 'utf8'));
|
|
86
|
+
canonicalVersion = pkg.version ?? null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Canonical root may not exist or be readable; report the drift regardless.
|
|
90
|
+
}
|
|
91
|
+
fp.drift = { canonicalRoot, canonicalVersion, actualRoot };
|
|
92
|
+
}
|
|
65
93
|
// Locale / timezone are useful for timezone-dependent bug reports.
|
|
66
94
|
try {
|
|
67
95
|
fp.locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
@@ -96,13 +124,12 @@ async function displayVersion(opts) {
|
|
|
96
124
|
ui.blank();
|
|
97
125
|
console.log(` ${ui.brandMark()} ${ui.bold('aiwg')} ${ui.bold(fp.version)} ${ui.channelLabel(fp.channel)}`);
|
|
98
126
|
if (!opts.verbose) {
|
|
99
|
-
if (fp.git)
|
|
127
|
+
if (fp.git)
|
|
100
128
|
ui.dim(` git: ${fp.git.sha} (${fp.git.branch})`);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
129
|
+
// Always the root that actually executed — not the declared edge checkout,
|
|
130
|
+
// which is what made drift invisible here (#2529).
|
|
131
|
+
ui.dim(` path: ${fp.packageRoot}`);
|
|
132
|
+
printDrift(fp);
|
|
106
133
|
maybePrintCommunityFooter();
|
|
107
134
|
ui.blank();
|
|
108
135
|
return;
|
|
@@ -110,15 +137,14 @@ async function displayVersion(opts) {
|
|
|
110
137
|
// --verbose: the full environment fingerprint.
|
|
111
138
|
if (fp.git) {
|
|
112
139
|
ui.dim(` git: ${fp.git.sha} (${fp.git.branch})`);
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
else {
|
|
116
|
-
ui.dim(` path: ${fp.packageRoot}`);
|
|
140
|
+
if (fp.git.path !== fp.packageRoot)
|
|
141
|
+
ui.dim(` edge: ${fp.git.path}`);
|
|
117
142
|
}
|
|
143
|
+
ui.dim(` path: ${fp.packageRoot}`);
|
|
118
144
|
ui.dim(` channel: ${fp.channel}`);
|
|
119
|
-
ui.dim(` install: ${fp.installation
|
|
120
|
-
ui.dim(` canonical: ${fp.installation
|
|
121
|
-
ui.dim(` actual: ${fp.installation.
|
|
145
|
+
ui.dim(` install: ${fp.installation?.identity?.method ?? 'unrecorded'} (${fp.installation?.state ?? 'unknown'})`);
|
|
146
|
+
ui.dim(` canonical: ${fp.installation?.identity?.root ?? '(unrecorded)'}`);
|
|
147
|
+
ui.dim(` actual: ${fp.installation?.actualRoot ?? fp.packageRoot}`);
|
|
122
148
|
ui.dim(` node: ${fp.node}`);
|
|
123
149
|
ui.dim(` platform: ${fp.platform.os} ${fp.platform.arch} (${fp.platform.release})`);
|
|
124
150
|
ui.dim(` tty: stdin=${fp.tty.stdin} stdout=${fp.tty.stdout} stderr=${fp.tty.stderr}`);
|
|
@@ -55,6 +55,14 @@ export const workspaceContextHandler = {
|
|
|
55
55
|
console.log(JSON.stringify(result, null, 2));
|
|
56
56
|
else {
|
|
57
57
|
console.log(`${result.dryRun ? 'Migration dry run' : 'Migration applied'}: ${result.changed ? 'changes found' : 'already canonical'}`);
|
|
58
|
+
for (const entry of result.audit.plan.routing) {
|
|
59
|
+
console.log(` ${entry.source}: ${entry.operatorBytes.toLocaleString()} chars -> ${entry.destination} (${entry.scope})`);
|
|
60
|
+
}
|
|
61
|
+
for (const entry of result.audit.plan.scopeReview) {
|
|
62
|
+
console.log(` REVIEW ${entry.source} carries ${entry.operatorBytes.toLocaleString()} chars of operator content and is scoped to ${entry.scope} by filename.`);
|
|
63
|
+
console.log(' Content in .aiwg/context/providers/ is read by that provider only.');
|
|
64
|
+
console.log(" If this is project-neutral methodology, move it into WORKSPACE.md's Project Context section first.");
|
|
65
|
+
}
|
|
58
66
|
for (const file of result.written)
|
|
59
67
|
console.log(` ${result.dryRun ? 'would write' : 'wrote'} ${file}`);
|
|
60
68
|
if (result.transactionId)
|
|
@@ -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';
|
|
@@ -9,38 +9,48 @@ import { diagnoseWorkspaceContext, providerContextContract, } from '../../smiths
|
|
|
9
9
|
import { USER_SCOPE_PATHS } from '../scope-resolver.js';
|
|
10
10
|
const RESTART_NOTICES = {
|
|
11
11
|
claude: {
|
|
12
|
+
policy: 'restart-required',
|
|
12
13
|
action: 'Restart Claude Code so the running session reloads deployed agents and skills.',
|
|
13
14
|
reason: 'Claude Code reads its agent and skill registries when a session starts.',
|
|
14
15
|
},
|
|
15
16
|
codex: {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
policy: 'live-refresh',
|
|
18
|
+
action: 'Reopen Codex in this workspace if a deployed skill or agent does not appear.',
|
|
19
|
+
reason: 'Codex refreshes project skills between turns — a running session exposed newly deployed skills on the next turn without a restart (#2309).',
|
|
20
|
+
fallback: 'Codex picks up newly deployed skills on the next turn. Reopen Codex in this workspace only if a deployed skill or agent is still missing after that.',
|
|
18
21
|
},
|
|
19
22
|
copilot: {
|
|
23
|
+
policy: 'restart-required',
|
|
20
24
|
action: 'Reload the VS Code window so Copilot reloads workspace agents and instructions.',
|
|
21
25
|
reason: 'Copilot caches workspace agent definitions until the VS Code window reloads.',
|
|
22
26
|
},
|
|
23
27
|
cursor: {
|
|
28
|
+
policy: 'restart-required',
|
|
24
29
|
action: 'Reload the Cursor workspace so it reloads agents and rules.',
|
|
25
30
|
reason: 'Cursor reads workspace agents and rules when the workspace opens.',
|
|
26
31
|
},
|
|
27
32
|
factory: {
|
|
33
|
+
policy: 'restart-required',
|
|
28
34
|
action: 'Restart the Factory droid runtime so it reloads deployed droids.',
|
|
29
35
|
reason: 'Factory loads its droid registry when the runtime starts.',
|
|
30
36
|
},
|
|
31
37
|
opencode: {
|
|
38
|
+
policy: 'restart-required',
|
|
32
39
|
action: 'Restart the OpenCode session so it reloads deployed agents.',
|
|
33
40
|
reason: 'OpenCode scans its agent directory when the session starts.',
|
|
34
41
|
},
|
|
35
42
|
openclaw: {
|
|
43
|
+
policy: 'restart-required',
|
|
36
44
|
action: 'Restart OpenClaw so it reloads its home-directory registry.',
|
|
37
45
|
reason: 'OpenClaw loads its home-directory registry when the process starts.',
|
|
38
46
|
},
|
|
39
47
|
warp: {
|
|
48
|
+
policy: 'restart-required',
|
|
40
49
|
action: 'Open a fresh Warp tab so it reloads project context.',
|
|
41
50
|
reason: 'Warp reads project context when a tab starts.',
|
|
42
51
|
},
|
|
43
52
|
windsurf: {
|
|
53
|
+
policy: 'restart-required',
|
|
44
54
|
action: 'Reload Devin Desktop so it reparses project context.',
|
|
45
55
|
reason: 'Devin Desktop reads the Windsurf-compatible project context when the workspace opens.',
|
|
46
56
|
},
|
|
@@ -111,6 +121,101 @@ async function countEntries(candidate) {
|
|
|
111
121
|
function emptyCounts() {
|
|
112
122
|
return { agents: 0, commands: 0, skills: 0, rules: 0, behaviors: 0 };
|
|
113
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Flat-artifact attribution (#2507).
|
|
126
|
+
*
|
|
127
|
+
* Deployment counts used to be a plain `readdir` of the provider directory, so
|
|
128
|
+
* a run that wrote nothing still reported every pre-existing file as deployed —
|
|
129
|
+
* a no-op deploy over a stale, unmanaged tree was indistinguishable from a
|
|
130
|
+
* successful one. An artifact now counts as deployed only when this run wrote
|
|
131
|
+
* it (mtime at or after the invocation start) or AIWG owns it (sidecar entry or
|
|
132
|
+
* in-file managed marker). Everything else is reported as unmanaged.
|
|
133
|
+
*/
|
|
134
|
+
const MANAGED_SIDECAR = '.aiwg-manifest.json';
|
|
135
|
+
const MANAGED_MARKER_PATTERN = /^(?:<!--\s*aiwg:managed\s|#\s*aiwg:managed\s)/m;
|
|
136
|
+
const FLAT_ARTIFACT_KINDS = ['agents', 'commands', 'rules'];
|
|
137
|
+
const FLAT_ARTIFACT_EXTENSIONS = ['.md', '.mdc', '.toml'];
|
|
138
|
+
/** Clock skew tolerance between the recorded invocation start and file mtimes. */
|
|
139
|
+
const WRITE_ATTRIBUTION_SKEW_MS = 2_000;
|
|
140
|
+
const FLAT_ARTIFACT_NOUNS = {
|
|
141
|
+
agents: 'agent',
|
|
142
|
+
commands: 'command',
|
|
143
|
+
rules: 'rule',
|
|
144
|
+
};
|
|
145
|
+
async function readManagedSidecarNames(dir) {
|
|
146
|
+
try {
|
|
147
|
+
const raw = await readFile(path.join(dir, MANAGED_SIDECAR), 'utf8');
|
|
148
|
+
const parsed = JSON.parse(raw);
|
|
149
|
+
return new Set(Object.keys(parsed.managed ?? {}));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return new Set();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Split one flat artifact directory into artifacts this deployment accounts for
|
|
157
|
+
* and artifacts it does not. Returns `null` when the directory cannot be
|
|
158
|
+
* attributed (missing, unreadable, or no invocation boundary to compare
|
|
159
|
+
* against), so callers fall back to the plain entry count.
|
|
160
|
+
*/
|
|
161
|
+
async function tallyFlatArtifacts(dir, writtenSince) {
|
|
162
|
+
if (!dir || writtenSince === null)
|
|
163
|
+
return null;
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const managedNames = await readManagedSidecarNames(dir);
|
|
172
|
+
const tally = { deployed: 0, unmanaged: [] };
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (entry.name.startsWith('.'))
|
|
175
|
+
continue;
|
|
176
|
+
if (!entry.isFile()) {
|
|
177
|
+
// Nested directories (e.g. deployed behaviors under rules/) are counted
|
|
178
|
+
// as-is; they are not flat artifacts and have their own lifecycle.
|
|
179
|
+
tally.deployed += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const lower = entry.name.toLowerCase();
|
|
183
|
+
if (!FLAT_ARTIFACT_EXTENSIONS.some((extension) => lower.endsWith(extension)))
|
|
184
|
+
continue;
|
|
185
|
+
const absolute = path.join(dir, entry.name);
|
|
186
|
+
if (managedNames.has(entry.name)) {
|
|
187
|
+
tally.deployed += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
let writtenThisRun = false;
|
|
191
|
+
try {
|
|
192
|
+
const info = await stat(absolute);
|
|
193
|
+
writtenThisRun = info.mtimeMs + WRITE_ATTRIBUTION_SKEW_MS >= writtenSince;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
writtenThisRun = false;
|
|
197
|
+
}
|
|
198
|
+
if (writtenThisRun) {
|
|
199
|
+
tally.deployed += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
let owned = false;
|
|
203
|
+
try {
|
|
204
|
+
owned = MANAGED_MARKER_PATTERN.test(await readFile(absolute, 'utf8'));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Unreadable files are not claimed as deployed, but neither are they
|
|
208
|
+
// reported as shadowing artifacts we could not inspect.
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (owned)
|
|
212
|
+
tally.deployed += 1;
|
|
213
|
+
else
|
|
214
|
+
tally.unmanaged.push(entry.name);
|
|
215
|
+
}
|
|
216
|
+
tally.unmanaged.sort((a, b) => a.localeCompare(b));
|
|
217
|
+
return tally;
|
|
218
|
+
}
|
|
114
219
|
function phase(id, state, required, summary, evidence) {
|
|
115
220
|
return { id, state, required, summary, evidence };
|
|
116
221
|
}
|
|
@@ -232,9 +337,16 @@ export async function verifyProviderDeployment(options) {
|
|
|
232
337
|
const findings = [];
|
|
233
338
|
const counts = emptyCounts();
|
|
234
339
|
const restartNotice = RESTART_NOTICES[normalized] ?? null;
|
|
235
|
-
|
|
340
|
+
// A provider with no notice gets no restart claim, which is what it got
|
|
341
|
+
// before this field existed. The label is the absence of a known restart
|
|
342
|
+
// requirement, not a positive claim that the client refreshes live.
|
|
343
|
+
const reloadPolicy = restartNotice?.policy ?? 'live-refresh';
|
|
344
|
+
const restartRequired = reloadPolicy === 'restart-required';
|
|
345
|
+
// Only a `restart-required` provider gets an imperative restart step. A
|
|
346
|
+
// `live-refresh` provider keeps its rationale and a conditional fallback (#2309).
|
|
347
|
+
const restartAction = restartRequired ? (restartNotice?.action ?? null) : null;
|
|
236
348
|
const restartReason = restartNotice?.reason ?? null;
|
|
237
|
-
const
|
|
349
|
+
const reloadFallback = restartRequired ? null : (restartNotice?.fallback ?? null);
|
|
238
350
|
if (!definition) {
|
|
239
351
|
findings.push(finding(normalized, 'provider-unknown', 'blocking', `No provider definition is available for '${options.provider}'.`, 'Choose a supported provider or repair the project-local provider adapter.'));
|
|
240
352
|
}
|
|
@@ -249,9 +361,30 @@ export async function verifyProviderDeployment(options) {
|
|
|
249
361
|
const artifactPaths = options.scope === 'user'
|
|
250
362
|
? USER_SCOPE_PATHS[normalized] ?? definition.paths.artifacts
|
|
251
363
|
: definition.paths.artifacts;
|
|
364
|
+
const writtenSince = options.invocationStartedAt
|
|
365
|
+
? Date.parse(options.invocationStartedAt)
|
|
366
|
+
: Number.NaN;
|
|
367
|
+
const attributionBoundary = Number.isFinite(writtenSince) ? writtenSince : null;
|
|
252
368
|
for (const type of ['agents', 'commands', 'skills', 'rules', 'behaviors']) {
|
|
253
369
|
const resolved = resolveProviderPathValue(artifactPaths[type], deploymentRoot);
|
|
254
370
|
counts[type] = await countEntries(resolved);
|
|
371
|
+
// #2507: flat artifact directories report what this deployment accounts
|
|
372
|
+
// for, not whatever happens to be sitting in the directory.
|
|
373
|
+
const flatKind = FLAT_ARTIFACT_KINDS.find((kind) => kind === type);
|
|
374
|
+
if (!flatKind)
|
|
375
|
+
continue;
|
|
376
|
+
const tally = await tallyFlatArtifacts(resolved, attributionBoundary);
|
|
377
|
+
if (!tally)
|
|
378
|
+
continue;
|
|
379
|
+
counts[flatKind] = tally.deployed;
|
|
380
|
+
if (tally.unmanaged.length === 0)
|
|
381
|
+
continue;
|
|
382
|
+
const shown = tally.unmanaged.slice(0, 3).join(', ');
|
|
383
|
+
const remainder = tally.unmanaged.length - 3;
|
|
384
|
+
findings.push(finding(normalized, `unmanaged-artifacts:${flatKind}`, 'advisory', `${tally.unmanaged.length} unmanaged ${FLAT_ARTIFACT_NOUNS[flatKind]} file(s) left in place at ${artifactPaths[flatKind]}: `
|
|
385
|
+
+ `${shown}${remainder > 0 ? `, and ${remainder} more` : ''}. `
|
|
386
|
+
+ '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, `
|
|
387
|
+
+ `or delete ${artifactPaths[flatKind]} so AIWG can reclaim the directory.`, { kind: flatKind, unmanaged: tally.unmanaged }));
|
|
255
388
|
}
|
|
256
389
|
const resolvedSkillsPath = resolveProviderPathValue(artifactPaths.skills, deploymentRoot);
|
|
257
390
|
const kernelPath = options.scope === 'user'
|
|
@@ -376,6 +509,8 @@ export async function verifyProviderDeployment(options) {
|
|
|
376
509
|
restartRequired,
|
|
377
510
|
restartAction,
|
|
378
511
|
restartReason,
|
|
512
|
+
reloadPolicy,
|
|
513
|
+
reloadFallback,
|
|
379
514
|
counts,
|
|
380
515
|
phases,
|
|
381
516
|
findings,
|
|
@@ -385,7 +520,9 @@ export function buildDryRunUseResult(options) {
|
|
|
385
520
|
const providers = options.providers.map((provider) => {
|
|
386
521
|
const normalized = normalizeProviderDefinitionId(provider) ?? provider;
|
|
387
522
|
const restartNotice = RESTART_NOTICES[normalized] ?? null;
|
|
388
|
-
const
|
|
523
|
+
const reloadPolicy = restartNotice?.policy ?? 'live-refresh';
|
|
524
|
+
const restartRequired = reloadPolicy === 'restart-required';
|
|
525
|
+
const restartAction = restartRequired ? (restartNotice?.action ?? null) : null;
|
|
389
526
|
const phases = [
|
|
390
527
|
phase('resolve', 'planned', true, `Would resolve ${options.projectRoot}, ${normalized}, ${options.scope} scope.`),
|
|
391
528
|
phase('deploy', 'planned', true, 'Would deploy the requested managed artifact surface.'),
|
|
@@ -398,9 +535,11 @@ export function buildDryRunUseResult(options) {
|
|
|
398
535
|
provider: normalized,
|
|
399
536
|
scope: options.scope,
|
|
400
537
|
outcome: 'planned',
|
|
401
|
-
restartRequired
|
|
538
|
+
restartRequired,
|
|
402
539
|
restartAction,
|
|
403
540
|
restartReason: restartNotice?.reason ?? null,
|
|
541
|
+
reloadPolicy,
|
|
542
|
+
reloadFallback: restartRequired ? null : (restartNotice?.fallback ?? null),
|
|
404
543
|
counts: emptyCounts(),
|
|
405
544
|
phases,
|
|
406
545
|
findings: [],
|
|
@@ -497,6 +636,8 @@ export async function verifyConfiguredDeployments(projectRoot, filters = {}, fra
|
|
|
497
636
|
restartRequired: false,
|
|
498
637
|
restartAction: null,
|
|
499
638
|
restartReason: null,
|
|
639
|
+
reloadPolicy: 'live-refresh',
|
|
640
|
+
reloadFallback: null,
|
|
500
641
|
counts: emptyCounts(),
|
|
501
642
|
phases: [phase('verify', 'failed', true, 'No installed provider deployment could be resolved.')],
|
|
502
643
|
findings: [finding(fallback, 'deployment-not-configured', 'blocking', 'No installed provider deployment could be resolved.', 'Run aiwg use all --provider <provider>.')],
|
|
@@ -640,6 +781,14 @@ export function renderUseDeploymentResult(result, options = {}) {
|
|
|
640
781
|
lines.push(...wrapParagraph(`Framework index built: ${result.discovery.builtAt}`, width));
|
|
641
782
|
}
|
|
642
783
|
}
|
|
784
|
+
const reloadFallbacks = result.providers
|
|
785
|
+
.filter((provider) => !provider.restartRequired && provider.reloadFallback)
|
|
786
|
+
.map((provider) => provider.reloadFallback);
|
|
787
|
+
if (reloadFallbacks.length > 0) {
|
|
788
|
+
lines.push('', 'If something is missing');
|
|
789
|
+
for (const note of reloadFallbacks)
|
|
790
|
+
lines.push(...wrapParagraph(note, width));
|
|
791
|
+
}
|
|
643
792
|
const restartActions = result.providers
|
|
644
793
|
.filter((provider) => provider.restartRequired && provider.restartAction)
|
|
645
794
|
.map((provider) => provider.restartAction);
|
|
@@ -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
|
|
@@ -35,6 +35,92 @@ export const WORKSPACE_REPO_ACTIONS = [
|
|
|
35
35
|
'service-action',
|
|
36
36
|
'destructive',
|
|
37
37
|
];
|
|
38
|
+
export const PROJECT_CLASSIFICATIONS = ['private', 'sanitized', 'public'];
|
|
39
|
+
/** Normalize the string-or-object `project` field to the object form. */
|
|
40
|
+
/**
|
|
41
|
+
* Validate the `project` block. Accepts the bare-string form unconditionally so
|
|
42
|
+
* existing configs keep loading (#2535).
|
|
43
|
+
*/
|
|
44
|
+
export function validateProjectConfig(project) {
|
|
45
|
+
const errors = [];
|
|
46
|
+
if (project === undefined || typeof project === 'string')
|
|
47
|
+
return errors;
|
|
48
|
+
if (typeof project !== 'object' || project === null || Array.isArray(project)) {
|
|
49
|
+
errors.push('project: must be a string (name) or an object');
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
52
|
+
const value = project;
|
|
53
|
+
if (value.classification !== undefined
|
|
54
|
+
&& !PROJECT_CLASSIFICATIONS.includes(value.classification)) {
|
|
55
|
+
errors.push(`project.classification: must be one of ${PROJECT_CLASSIFICATIONS.join(' | ')}`);
|
|
56
|
+
}
|
|
57
|
+
for (const key of ['name', 'description']) {
|
|
58
|
+
if (value[key] !== undefined && typeof value[key] !== 'string') {
|
|
59
|
+
errors.push(`project.${key}: must be a string`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (value.pii !== undefined && typeof value.pii !== 'boolean') {
|
|
63
|
+
errors.push('project.pii: must be a boolean');
|
|
64
|
+
}
|
|
65
|
+
if (value.handling !== undefined) {
|
|
66
|
+
if (typeof value.handling !== 'object' || value.handling === null || Array.isArray(value.handling)) {
|
|
67
|
+
errors.push('project.handling: must be an object');
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const handling = value.handling;
|
|
71
|
+
for (const key of ['excerptable', 'publishable', 'mirror']) {
|
|
72
|
+
if (handling[key] !== undefined && typeof handling[key] !== 'boolean') {
|
|
73
|
+
errors.push(`project.handling.${key}: must be a boolean`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return errors;
|
|
79
|
+
}
|
|
80
|
+
export function resolveProject(project) {
|
|
81
|
+
if (project === undefined)
|
|
82
|
+
return undefined;
|
|
83
|
+
if (typeof project === 'string')
|
|
84
|
+
return { name: project };
|
|
85
|
+
return project;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Handling defaults derived from the classification when not stated explicitly.
|
|
89
|
+
* A `private` repo is closed by default; anything else stays permissive, so
|
|
90
|
+
* declaring a classification never silently tightens an existing project.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveProjectHandling(project) {
|
|
93
|
+
const closed = project?.classification === 'private';
|
|
94
|
+
return {
|
|
95
|
+
excerptable: project?.handling?.excerptable ?? !closed,
|
|
96
|
+
publishable: project?.handling?.publishable ?? !closed,
|
|
97
|
+
mirror: project?.handling?.mirror ?? !closed,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Deprecated spellings mapped to their current value. */
|
|
101
|
+
export const FORCE_PUSH_POLICY_ALIASES = {
|
|
102
|
+
'main-only-blocked': 'own-branch-only',
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The rename also narrowed the permission: `main-only-blocked` allowed force-push on
|
|
106
|
+
* *any* feature branch, `own-branch-only` restricts it to the agent's own. Callers
|
|
107
|
+
* surface this rather than migrating silently, because accepting the alias quietly
|
|
108
|
+
* would change what an agent is permitted to do.
|
|
109
|
+
*/
|
|
110
|
+
export const FORCE_PUSH_POLICY_ALIAS_NOTE = "'main-only-blocked' is a deprecated alias for 'own-branch-only'. The permission also narrowed: "
|
|
111
|
+
+ 'the old value allowed force-push on any feature branch, the new one only on the agent\'s own branch.';
|
|
112
|
+
/**
|
|
113
|
+
* Normalize a force-push policy, mapping deprecated spellings forward. Returns the
|
|
114
|
+
* canonical value and the alias it came from, if any.
|
|
115
|
+
*/
|
|
116
|
+
export function normalizeForcePushPolicy(value) {
|
|
117
|
+
if (value === undefined)
|
|
118
|
+
return { policy: undefined };
|
|
119
|
+
const alias = FORCE_PUSH_POLICY_ALIASES[value];
|
|
120
|
+
if (alias)
|
|
121
|
+
return { policy: alias, deprecatedFrom: value };
|
|
122
|
+
return { policy: value };
|
|
123
|
+
}
|
|
38
124
|
const DEFAULT_BRANCH_NAMING = {
|
|
39
125
|
prefix_by_type: {
|
|
40
126
|
feat: 'feat/{issue}-{slug}',
|
|
@@ -70,7 +156,7 @@ export function resolveDelivery(delivery) {
|
|
|
70
156
|
committer: delivery?.committer,
|
|
71
157
|
signing: delivery?.signing,
|
|
72
158
|
release_signing: delivery?.release_signing,
|
|
73
|
-
force_push_policy: delivery?.force_push_policy ?? 'never',
|
|
159
|
+
force_push_policy: normalizeForcePushPolicy(delivery?.force_push_policy).policy ?? 'never',
|
|
74
160
|
auto_close_issues: delivery?.auto_close_issues ?? true,
|
|
75
161
|
issue_comment_on_cycle: delivery?.issue_comment_on_cycle ?? true,
|
|
76
162
|
};
|
|
@@ -770,7 +856,10 @@ export async function readAiwgConfig(projectDir) {
|
|
|
770
856
|
if (authorizationErrors.length > 0) {
|
|
771
857
|
throw new Error(`Invalid .aiwg/aiwg.config:\n${authorizationErrors.map(item => item.message).join('\n')}`);
|
|
772
858
|
}
|
|
773
|
-
const threatAssessmentErrors =
|
|
859
|
+
const threatAssessmentErrors = [
|
|
860
|
+
...validateThreatAssessmentConfig(parsed.security?.threatAssessment),
|
|
861
|
+
...validateProjectConfig(parsed.project),
|
|
862
|
+
];
|
|
774
863
|
if (threatAssessmentErrors.length > 0) {
|
|
775
864
|
throw new Error(`Invalid .aiwg/aiwg.config:\n${threatAssessmentErrors.join('\n')}`);
|
|
776
865
|
}
|
|
@@ -788,7 +877,10 @@ export async function readAiwgConfig(projectDir) {
|
|
|
788
877
|
* sync so split-root health remains deterministic.
|
|
789
878
|
*/
|
|
790
879
|
export async function writeAiwgConfig(projectDir, config) {
|
|
791
|
-
const threatAssessmentErrors =
|
|
880
|
+
const threatAssessmentErrors = [
|
|
881
|
+
...validateThreatAssessmentConfig(config.security?.threatAssessment),
|
|
882
|
+
...validateProjectConfig(config.project),
|
|
883
|
+
];
|
|
792
884
|
if (threatAssessmentErrors.length > 0) {
|
|
793
885
|
throw new Error(`Invalid .aiwg/aiwg.config:\n${threatAssessmentErrors.join('\n')}`);
|
|
794
886
|
}
|
package/dist/src/config/cli.js
CHANGED
|
@@ -142,6 +142,14 @@ async function handleSet(config, args) {
|
|
|
142
142
|
// Set validates enum membership for known fields (delivery.mode,
|
|
143
143
|
// delivery.merge_style, delivery.force_push_policy, remotes.issue_provider)
|
|
144
144
|
// before writing.
|
|
145
|
+
/** Deprecated values accepted on `set` and normalized to their current spelling. */
|
|
146
|
+
const ENUM_ALIASES = {
|
|
147
|
+
'delivery.force_push_policy': { 'main-only-blocked': 'own-branch-only' },
|
|
148
|
+
};
|
|
149
|
+
/** Extra context printed alongside an alias normalization, where semantics also changed. */
|
|
150
|
+
const ENUM_ALIAS_NOTES = {
|
|
151
|
+
'delivery.force_push_policy': "The permission also narrowed: the old value allowed force-push on any feature branch, the new one only on the agent's own branch.",
|
|
152
|
+
};
|
|
145
153
|
const ENUM_RULES = {
|
|
146
154
|
'delivery.mode': ['direct', 'feature-branch', 'pr-required'],
|
|
147
155
|
'delivery.merge_style': ['rebase-merge', 'squash', 'merge', 'fast-forward-only'],
|
|
@@ -210,8 +218,15 @@ async function projectConfigGet(key, args) {
|
|
|
210
218
|
async function projectConfigSet(key, raw, args) {
|
|
211
219
|
const { readAiwgConfig, writeAiwgConfig, getProjectDir, emptyConfig, validateExternalLinks, } = await import('./aiwg-config.js');
|
|
212
220
|
const projectDir = getProjectDir(undefined, args);
|
|
213
|
-
// Validate enum fields before writing
|
|
221
|
+
// Validate enum fields before writing. Deprecated spellings normalize forward with a
|
|
222
|
+
// notice rather than failing, so a config written before a rename stays settable (#2532).
|
|
214
223
|
const allowed = ENUM_RULES[key];
|
|
224
|
+
const aliased = ENUM_ALIASES[key]?.[raw];
|
|
225
|
+
if (aliased) {
|
|
226
|
+
process.stderr.write(`Note: '${raw}' is a deprecated alias for '${aliased}'; writing '${aliased}'.\n`
|
|
227
|
+
+ (ENUM_ALIAS_NOTES[key] ? ` ${ENUM_ALIAS_NOTES[key]}\n` : ''));
|
|
228
|
+
raw = aliased;
|
|
229
|
+
}
|
|
215
230
|
if (allowed && !allowed.includes(raw)) {
|
|
216
231
|
throw new AiwgError({
|
|
217
232
|
code: 'ERR_INVALID_VALUE',
|
|
@@ -24,6 +24,9 @@ const GITIGNORE_PROBE_BASENAME = '.aiwg-ignore-probe';
|
|
|
24
24
|
*/
|
|
25
25
|
export const AIWG_RUNTIME_PATTERNS = [
|
|
26
26
|
'.aiwg/working/',
|
|
27
|
+
// Transactional preimages written before AIWG rewrites operator files.
|
|
28
|
+
// Recoverable local evidence, not project content (#2542).
|
|
29
|
+
'.aiwg/backups/',
|
|
27
30
|
// .aiwg/.index/ is the artifact graph index (JSON nodes/edges + checksum
|
|
28
31
|
// manifest). It is a pure build artifact, fully regenerable from corpus
|
|
29
32
|
// content via `aiwg index build --all`, so it is ignored rather than committed.
|
|
@@ -56,6 +59,8 @@ export const PROVIDER_CONVENTIONAL_PATTERNS = [
|
|
|
56
59
|
*/
|
|
57
60
|
export const CLAUDE_SESSION_PATTERNS = [
|
|
58
61
|
'.claude/settings.local.json',
|
|
62
|
+
// Timestamped backups AIWG writes before merging hook entries (#2542).
|
|
63
|
+
'.claude/settings.json.bak.*',
|
|
59
64
|
];
|
|
60
65
|
/** All recommended patterns combined */
|
|
61
66
|
export const ALL_RECOMMENDED_PATTERNS = [
|
|
@@ -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';
|