@aiwg/cli 2026.9.7 → 2026.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/aiwg.mjs +55 -10
- package/dist/src/artifacts/cli.js +20 -8
- package/dist/src/artifacts/corpus-views/taxonomies.js +1 -1
- 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 +6 -5
- package/dist/src/cli/handlers/init.js +7 -2
- package/dist/src/cli/handlers/installation.js +102 -2
- package/dist/src/cli/handlers/mc.js +87 -17
- package/dist/src/cli/handlers/ralph.js +8 -8
- package/dist/src/cli/handlers/refresh.js +67 -7
- package/dist/src/cli/handlers/repo-access.js +155 -4
- package/dist/src/cli/handlers/setup.js +5 -5
- package/dist/src/cli/handlers/steward.js +39 -2
- package/dist/src/cli/handlers/use.js +13 -4
- package/dist/src/cli/handlers/version.js +49 -14
- package/dist/src/cli/handlers/workspace-context.js +8 -0
- package/dist/src/cli/services/deployment-verification.js +71 -10
- package/dist/src/cli/watch-service.js +43 -3
- 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/dataset/fortemi-capability.d.ts +70 -0
- package/dist/src/dataset/fortemi-capability.js +211 -0
- package/dist/src/dataset/fortemi-dataset-execution.d.ts +9 -0
- package/dist/src/dataset/fortemi-dataset-execution.js +49 -8
- package/dist/src/dataset/fortemi-run-receipt.d.ts +1 -0
- package/dist/src/dataset/fortemi-run-receipt.js +2 -19
- package/dist/src/dataset/fortemi-schema-path.d.ts +7 -0
- package/dist/src/dataset/fortemi-schema-path.js +24 -0
- package/dist/src/dataset/index.d.ts +1 -0
- package/dist/src/dataset/index.js +1 -0
- package/dist/src/extensions/claude-hooks-installer.js +22 -6
- package/dist/src/extensions/project-local-doctor.js +15 -1
- package/dist/src/lint/cli.js +5 -1
- package/dist/src/lint/reporters.js +17 -0
- package/dist/src/lint/runner.js +297 -4
- package/dist/src/policy/authorization.js +1 -1
- package/dist/src/smiths/context-pipeline/workspace-context.js +138 -49
- package/package.json +1 -1
- package/schemas/dataset/fortemi-capability-validation/1.0.1/authority.json +61 -0
- package/schemas/dataset/fortemi-capability-validation/1.0.1/capability.schema.json +469 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +2 -1
- package/tools/agents/deploy-agents.mjs +8 -0
- package/tools/agents/providers/base.mjs +261 -16
- package/tools/agents/providers/claude.mjs +61 -4
- package/tools/agents/providers/codex.mjs +9 -0
- package/tools/skills/deploy-skills-codex.mjs +121 -7
|
@@ -40,6 +40,48 @@ const PROVIDER_AGENT_DIRS = {
|
|
|
40
40
|
export async function currentBundledAgentBasenames(frameworkRoot) {
|
|
41
41
|
return new Set((await collectPackagedAgentInventory(frameworkRoot)).keys());
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Basenames of every rule the current package can deploy.
|
|
45
|
+
*
|
|
46
|
+
* Symmetric to {@link currentBundledAgentBasenames}. A rule absent from this set
|
|
47
|
+
* is residue from a deploy model that no longer writes it (#2540).
|
|
48
|
+
*
|
|
49
|
+
* Every group that can ship a `rules/` directory must be listed here: the set is
|
|
50
|
+
* the prune's definition of "still shipped", so a missed group makes live rules
|
|
51
|
+
* look orphaned and deletes them. `extensions` ships 22 rules and was the group
|
|
52
|
+
* this nearly lost.
|
|
53
|
+
*/
|
|
54
|
+
export const BUNDLED_RULE_SOURCE_GROUPS = ['frameworks', 'addons', 'plugins', 'extensions'];
|
|
55
|
+
export async function currentBundledRuleBasenames(frameworkRoot) {
|
|
56
|
+
const names = new Set();
|
|
57
|
+
const codeRoot = path.join(frameworkRoot, 'agentic', 'code');
|
|
58
|
+
for (const group of BUNDLED_RULE_SOURCE_GROUPS) {
|
|
59
|
+
let units;
|
|
60
|
+
try {
|
|
61
|
+
units = await fs.readdir(path.join(codeRoot, group), { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const unit of units) {
|
|
67
|
+
if (!unit.isDirectory())
|
|
68
|
+
continue;
|
|
69
|
+
const rulesDir = path.join(codeRoot, group, unit.name, 'rules');
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await fs.readdir(rulesDir, { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.isFile() && entry.name.endsWith('.md'))
|
|
79
|
+
names.add(entry.name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return names;
|
|
84
|
+
}
|
|
43
85
|
async function readFrameworkVersion(frameworkRoot) {
|
|
44
86
|
try {
|
|
45
87
|
const pkg = JSON.parse(await fs.readFile(path.join(frameworkRoot, 'package.json'), 'utf8'));
|
|
@@ -186,6 +228,7 @@ export async function partitionTrackedPaths(projectRoot, relativePaths) {
|
|
|
186
228
|
}
|
|
187
229
|
export async function pruneStaleManagedAgentFiles(options) {
|
|
188
230
|
const desired = await currentBundledAgentBasenames(options.frameworkRoot);
|
|
231
|
+
const desiredRules = await currentBundledRuleBasenames(options.frameworkRoot);
|
|
189
232
|
const currentVersion = options.currentVersion ?? await readFrameworkVersion(options.frameworkRoot);
|
|
190
233
|
const crossProvider = options.crossProvider ?? 'skip';
|
|
191
234
|
const removals = [];
|
|
@@ -204,7 +247,10 @@ export async function pruneStaleManagedAgentFiles(options) {
|
|
|
204
247
|
// provider only drops agents whose source no longer ships them.
|
|
205
248
|
if (!isTargetProvider && crossProvider === 'skip')
|
|
206
249
|
continue;
|
|
207
|
-
|
|
250
|
+
// Rules were excluded from the target-provider pass, so a project carried
|
|
251
|
+
// every rule any past version ever deployed. On long-lived projects that is
|
|
252
|
+
// the bulk of the startup-context budget (#2540).
|
|
253
|
+
const kinds = isTargetProvider ? ['agents', 'rules'] : PRUNABLE_ARTIFACT_KINDS;
|
|
208
254
|
const hits = await collectManagedProviderArtifacts(options.projectRoot, provider, kinds);
|
|
209
255
|
const eligible = hits.filter((hit) => {
|
|
210
256
|
if (isTargetProvider) {
|
|
@@ -212,6 +258,8 @@ export async function pruneStaleManagedAgentFiles(options) {
|
|
|
212
258
|
// marker to the top-level package version makes a successful refresh
|
|
213
259
|
// delete freshly restored addon agents, so the active provider removes
|
|
214
260
|
// only artifacts absent from current sources.
|
|
261
|
+
if (hit.kind === 'rules')
|
|
262
|
+
return !desiredRules.has(path.basename(hit.relativePath));
|
|
215
263
|
return !desired.has(hit.artifactName);
|
|
216
264
|
}
|
|
217
265
|
return currentVersion !== null && isOlderManagedVersion(hit.version, currentVersion);
|
|
@@ -583,7 +631,11 @@ export const refreshHandler = {
|
|
|
583
631
|
}
|
|
584
632
|
let staleAgentRemovals = [];
|
|
585
633
|
let trackedSkipped = [];
|
|
586
|
-
|
|
634
|
+
// A dry run still reports what a real run would remove. Skipping the pass
|
|
635
|
+
// entirely meant `refresh --dry-run` printed "Checking for stale deployments..."
|
|
636
|
+
// and nothing else, so orphaned artifacts were invisible until they had
|
|
637
|
+
// pushed the project over its context budget (#2540).
|
|
638
|
+
if (deploymentFailures.length === 0) {
|
|
587
639
|
try {
|
|
588
640
|
const pruneResult = await pruneStaleManagedAgentFiles({
|
|
589
641
|
projectRoot: ctx.cwd,
|
|
@@ -591,6 +643,7 @@ export const refreshHandler = {
|
|
|
591
643
|
provider: detectedProvider,
|
|
592
644
|
crossProvider: pruneOtherProviders ? 'prune' : 'skip',
|
|
593
645
|
allowTrackedDeletes: pruneTracked,
|
|
646
|
+
dryRun,
|
|
594
647
|
});
|
|
595
648
|
staleAgentRemovals = pruneResult.removals;
|
|
596
649
|
trackedSkipped = pruneResult.trackedSkipped;
|
|
@@ -607,19 +660,24 @@ export const refreshHandler = {
|
|
|
607
660
|
}
|
|
608
661
|
if (staleAgentRemovals.length > 0 && !quiet) {
|
|
609
662
|
const total = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
|
|
610
|
-
ui.warn(
|
|
663
|
+
ui.warn(`${dryRun ? 'Would remove' : 'Removed'} ${total} stale AIWG-managed file${total === 1 ? '' : 's'} ` +
|
|
611
664
|
`across ${staleAgentRemovals.length} provider${staleAgentRemovals.length === 1 ? '' : 's'}`);
|
|
612
665
|
for (const removal of staleAgentRemovals) {
|
|
613
666
|
const shown = removal.paths.slice(0, 3).join(', ');
|
|
614
667
|
const remainder = removal.paths.length - 3;
|
|
615
668
|
ui.dim(` ${removal.provider}: ${removal.paths.length} (${shown}${remainder > 0 ? `, ...and ${remainder} more` : ''})`);
|
|
616
669
|
}
|
|
617
|
-
ui.dim(
|
|
670
|
+
ui.dim(dryRun
|
|
671
|
+
? ' Re-run without --dry-run to remove them.'
|
|
672
|
+
: ' Review `git status` before committing — deployed artifacts may be tracked.');
|
|
618
673
|
}
|
|
619
674
|
// #2506: keep the recorded deployment state consistent with what the
|
|
620
675
|
// prune actually left on disk, so a later run does not trust counts
|
|
621
|
-
// for artifacts that no longer exist.
|
|
622
|
-
|
|
676
|
+
// for artifacts that no longer exist. A dry run deleted nothing, so the
|
|
677
|
+
// recorded state is already accurate.
|
|
678
|
+
if (!dryRun) {
|
|
679
|
+
await reconcileDeployedToAfterPrune(ctx.cwd, staleAgentRemovals, pruneOtherProviders ? detectedProvider : null);
|
|
680
|
+
}
|
|
623
681
|
}
|
|
624
682
|
catch {
|
|
625
683
|
if (!quiet)
|
|
@@ -685,7 +743,9 @@ export const refreshHandler = {
|
|
|
685
743
|
// #2506: a run that deleted artifacts is not an "up to date" run.
|
|
686
744
|
const removed = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
|
|
687
745
|
if (!quiet) {
|
|
688
|
-
ui.warn(
|
|
746
|
+
ui.warn(dryRun
|
|
747
|
+
? `Deployments current, but ${removed} stale artifact(s) would be removed — review the list above`
|
|
748
|
+
: `Deployments current, but ${removed} stale artifact(s) were removed this run — review the list above`);
|
|
689
749
|
}
|
|
690
750
|
}
|
|
691
751
|
else {
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { checkRepoAccess, findRepoEntry, formatRepoAccessEntry, loadRepoAccessManifest, } from '../../policy/repo-access.js';
|
|
2
2
|
import { resolveWorkspace } from '../../config/workspace.js';
|
|
3
|
+
import { getProjectDir, readAiwgConfig, writeAiwgConfig, WORKSPACE_REPO_ACTIONS, } from '../../config/aiwg-config.js';
|
|
4
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import * as nodePath from 'node:path';
|
|
3
6
|
function valueAfter(args, flag) {
|
|
4
7
|
const index = args.indexOf(flag);
|
|
5
8
|
if (index < 0)
|
|
6
9
|
return null;
|
|
7
10
|
return args[index + 1] ?? null;
|
|
8
11
|
}
|
|
9
|
-
function
|
|
10
|
-
|
|
12
|
+
function usage() {
|
|
13
|
+
return `
|
|
11
14
|
aiwg repo-access — repo authorization manifest preflight
|
|
12
15
|
|
|
13
16
|
Usage:
|
|
@@ -15,12 +18,149 @@ function printHelp() {
|
|
|
15
18
|
aiwg repo-access status
|
|
16
19
|
aiwg repo-access explain --path <repo-or-file>
|
|
17
20
|
aiwg repo-access check --path <repo-or-file> --action <read|write|commit|push|issue-comment|service-action|destructive>
|
|
21
|
+
aiwg repo-access add --path <p> --name <n> --allow <a,b,c> [--provider <gitea|github|gitlab>] [--notes "..."]
|
|
22
|
+
aiwg repo-access remove --name <n>
|
|
23
|
+
aiwg repo-access audit
|
|
18
24
|
|
|
19
25
|
Manifest:
|
|
20
26
|
.aiwg/aiwg.config workspace + repos blocks (preferred)
|
|
21
27
|
.aiwg/ops/security/repo-access.manifest.yaml
|
|
22
28
|
.aiwg/security/repo-access.manifest.yaml (fallback)
|
|
23
|
-
|
|
29
|
+
`;
|
|
30
|
+
}
|
|
31
|
+
function printHelp() {
|
|
32
|
+
console.log(usage());
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The manifest is mandatory and default-deny, but until now it had no write path:
|
|
36
|
+
* registering a repo meant hand-editing JSON, and the rule's own recovery text
|
|
37
|
+
* ("ask for a manifest update") had no supported way to be carried out (#2531).
|
|
38
|
+
*/
|
|
39
|
+
async function addRepo(ctx, args) {
|
|
40
|
+
const repoPath = valueAfter(args, '--path');
|
|
41
|
+
const name = valueAfter(args, '--name');
|
|
42
|
+
const allowRaw = valueAfter(args, '--allow');
|
|
43
|
+
if (!repoPath)
|
|
44
|
+
return { exitCode: 2, message: 'repo-access add requires --path <repo-or-file>' };
|
|
45
|
+
if (!name)
|
|
46
|
+
return { exitCode: 2, message: 'repo-access add requires --name <name>' };
|
|
47
|
+
if (!allowRaw) {
|
|
48
|
+
return { exitCode: 2, message: `repo-access add requires --allow <${WORKSPACE_REPO_ACTIONS.join('|')}>` };
|
|
49
|
+
}
|
|
50
|
+
const allowed = allowRaw.split(',').map((item) => item.trim()).filter(Boolean);
|
|
51
|
+
const invalid = allowed.filter((item) => !WORKSPACE_REPO_ACTIONS.includes(item));
|
|
52
|
+
if (invalid.length > 0) {
|
|
53
|
+
return {
|
|
54
|
+
exitCode: 2,
|
|
55
|
+
message: `Unknown action(s): ${invalid.join(', ')}. Valid: ${WORKSPACE_REPO_ACTIONS.join(', ')}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const provider = valueAfter(args, '--provider') ?? undefined;
|
|
59
|
+
if (provider && !['gitea', 'github', 'gitlab'].includes(provider)) {
|
|
60
|
+
return { exitCode: 2, message: `Unknown provider: ${provider}. Valid: gitea, github, gitlab` };
|
|
61
|
+
}
|
|
62
|
+
const projectDir = getProjectDir(ctx, args);
|
|
63
|
+
const config = await readAiwgConfig(projectDir);
|
|
64
|
+
if (!config)
|
|
65
|
+
return { exitCode: 2, message: `No .aiwg/aiwg.config found in ${projectDir}` };
|
|
66
|
+
const repos = config.repos ? [...config.repos] : [];
|
|
67
|
+
const entry = {
|
|
68
|
+
name,
|
|
69
|
+
path: repoPath,
|
|
70
|
+
allowed: allowed,
|
|
71
|
+
...(provider ? { provider: provider } : {}),
|
|
72
|
+
...(valueAfter(args, '--notes') ? { notes: valueAfter(args, '--notes') } : {}),
|
|
73
|
+
};
|
|
74
|
+
const existingIndex = repos.findIndex((repo) => repo.name === name);
|
|
75
|
+
if (existingIndex >= 0) {
|
|
76
|
+
repos[existingIndex] = { ...repos[existingIndex], ...entry };
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
repos.push(entry);
|
|
80
|
+
}
|
|
81
|
+
await writeAiwgConfig(projectDir, { ...config, repos });
|
|
82
|
+
console.log(`${existingIndex >= 0 ? 'Updated' : 'Registered'} ${name}: ${repoPath} [${allowed.join(', ')}]`);
|
|
83
|
+
return { exitCode: 0 };
|
|
84
|
+
}
|
|
85
|
+
async function removeRepo(ctx, args) {
|
|
86
|
+
const name = valueAfter(args, '--name');
|
|
87
|
+
if (!name)
|
|
88
|
+
return { exitCode: 2, message: 'repo-access remove requires --name <name>' };
|
|
89
|
+
const projectDir = getProjectDir(ctx, args);
|
|
90
|
+
const config = await readAiwgConfig(projectDir);
|
|
91
|
+
if (!config)
|
|
92
|
+
return { exitCode: 2, message: `No .aiwg/aiwg.config found in ${projectDir}` };
|
|
93
|
+
const repos = config.repos ?? [];
|
|
94
|
+
const remaining = repos.filter((repo) => repo.name !== name);
|
|
95
|
+
if (remaining.length === repos.length) {
|
|
96
|
+
return { exitCode: 1, message: `No repo named '${name}' in the manifest.` };
|
|
97
|
+
}
|
|
98
|
+
// An empty `repos` array fails config validation, so drop the key entirely
|
|
99
|
+
// when the last entry goes rather than writing a config that cannot be read back.
|
|
100
|
+
const next = { ...config };
|
|
101
|
+
if (remaining.length > 0)
|
|
102
|
+
next.repos = remaining;
|
|
103
|
+
else
|
|
104
|
+
delete next.repos;
|
|
105
|
+
await writeAiwgConfig(projectDir, next);
|
|
106
|
+
console.log(`Removed ${name}. It now falls under the default-deny policy.`);
|
|
107
|
+
return { exitCode: 0 };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Report workspace subdirectories that are git repos but carry no manifest entry.
|
|
111
|
+
* Manifests drift out of sync with reality; on the reporting workspace two
|
|
112
|
+
* actively-used repos were both unlisted and therefore formally denied (#2531).
|
|
113
|
+
*/
|
|
114
|
+
async function auditRepos(ctx, args) {
|
|
115
|
+
// A missing manifest is the most important case to audit, not a reason to fail:
|
|
116
|
+
// every repo is then unlisted and formally denied.
|
|
117
|
+
let manifest = null;
|
|
118
|
+
try {
|
|
119
|
+
manifest = loadRepoAccessManifest(ctx.cwd);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
manifest = null;
|
|
123
|
+
}
|
|
124
|
+
const root = manifest?.workspaceProjectRoot ?? getProjectDir(ctx, args);
|
|
125
|
+
let children = [];
|
|
126
|
+
try {
|
|
127
|
+
children = readdirSync(root);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { exitCode: 2, message: `Cannot read workspace root: ${root}` };
|
|
131
|
+
}
|
|
132
|
+
const unlisted = [];
|
|
133
|
+
for (const child of children.sort()) {
|
|
134
|
+
if (child.startsWith('.'))
|
|
135
|
+
continue;
|
|
136
|
+
const full = nodePath.join(root, child);
|
|
137
|
+
try {
|
|
138
|
+
if (!statSync(full).isDirectory())
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!existsSync(nodePath.join(full, '.git')))
|
|
145
|
+
continue;
|
|
146
|
+
if (manifest && findRepoEntry(manifest, full, ctx.cwd))
|
|
147
|
+
continue;
|
|
148
|
+
unlisted.push(child);
|
|
149
|
+
}
|
|
150
|
+
console.log(`Repo access manifest: ${manifest?.path ?? '(none — every repo is denied)'}`);
|
|
151
|
+
console.log(`Workspace root: ${root}`);
|
|
152
|
+
console.log(`Default policy: ${manifest?.defaultPolicy ?? 'deny'}`);
|
|
153
|
+
if (unlisted.length === 0) {
|
|
154
|
+
console.log('All git repositories under the workspace root are registered.');
|
|
155
|
+
return { exitCode: 0 };
|
|
156
|
+
}
|
|
157
|
+
console.log('');
|
|
158
|
+
console.log(`Unlisted git repositories (${unlisted.length}) — denied by default:`);
|
|
159
|
+
for (const name of unlisted) {
|
|
160
|
+
console.log(` - ${name}`);
|
|
161
|
+
console.log(` aiwg repo-access add --path ./${name} --name ${name} --allow read`);
|
|
162
|
+
}
|
|
163
|
+
return { exitCode: 1 };
|
|
24
164
|
}
|
|
25
165
|
async function handleRepoAccess(ctx) {
|
|
26
166
|
const [subcommand = 'help', ...args] = ctx.args;
|
|
@@ -28,6 +168,14 @@ async function handleRepoAccess(ctx) {
|
|
|
28
168
|
printHelp();
|
|
29
169
|
return { exitCode: 0 };
|
|
30
170
|
}
|
|
171
|
+
// add/remove write .aiwg/aiwg.config directly and must work before any manifest
|
|
172
|
+
// exists — registering the first repo is exactly the bootstrap case (#2531).
|
|
173
|
+
if (subcommand === 'add')
|
|
174
|
+
return await addRepo(ctx, args);
|
|
175
|
+
if (subcommand === 'remove')
|
|
176
|
+
return await removeRepo(ctx, args);
|
|
177
|
+
if (subcommand === 'audit')
|
|
178
|
+
return await auditRepos(ctx, args);
|
|
31
179
|
try {
|
|
32
180
|
const manifest = loadRepoAccessManifest(ctx.cwd);
|
|
33
181
|
if (subcommand === 'list' || subcommand === 'status') {
|
|
@@ -87,7 +235,7 @@ async function handleRepoAccess(ctx) {
|
|
|
87
235
|
}
|
|
88
236
|
return { exitCode: decision.allowed ? 0 : 1 };
|
|
89
237
|
}
|
|
90
|
-
return { exitCode: 2, message: `Unknown repo-access subcommand: ${subcommand}` };
|
|
238
|
+
return { exitCode: 2, message: `Unknown repo-access subcommand: ${subcommand}\n${usage()}` };
|
|
91
239
|
}
|
|
92
240
|
catch (error) {
|
|
93
241
|
return {
|
|
@@ -103,6 +251,9 @@ export const repoAccessHandler = {
|
|
|
103
251
|
description: 'Validate and query repo access manifest permissions',
|
|
104
252
|
category: 'utility',
|
|
105
253
|
aliases: [],
|
|
254
|
+
async help() {
|
|
255
|
+
return { exitCode: 0, message: usage(), rawOutput: true };
|
|
256
|
+
},
|
|
106
257
|
execute: handleRepoAccess,
|
|
107
258
|
};
|
|
108
259
|
export const repoAccessHandlers = [repoAccessHandler];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'child_process';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
|
-
import { emptyConfig, getConfigPath, getProjectDir, readAiwgConfig, resolveRemoteProvider, VALID_PROVIDERS, writeAiwgConfig, } from '../../config/aiwg-config.js';
|
|
3
|
+
import { emptyConfig, FORCE_PUSH_POLICY_ALIAS_NOTE, normalizeForcePushPolicy as normalizeForcePushPolicyShared, getConfigPath, getProjectDir, readAiwgConfig, resolveRemoteProvider, VALID_PROVIDERS, writeAiwgConfig, } from '../../config/aiwg-config.js';
|
|
4
4
|
import { AiwgError, EXIT_CODES } from '../errors.js';
|
|
5
5
|
import { askChoice, askString, askYesNo, createPromptInterface } from '../prompt-utils.js';
|
|
6
6
|
import * as ui from '../ui.js';
|
|
@@ -165,11 +165,11 @@ function secondaryRemotes(remotes, primary, issueTracker, ci) {
|
|
|
165
165
|
}));
|
|
166
166
|
}
|
|
167
167
|
function normalizeForcePushPolicy(value, warnings) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
const { policy, deprecatedFrom } = normalizeForcePushPolicyShared(value);
|
|
169
|
+
if (deprecatedFrom) {
|
|
170
|
+
warnings.push(`delivery.force_push_policy=${deprecatedFrom} is a legacy alias; setup normalized it to ${policy}. ${FORCE_PUSH_POLICY_ALIAS_NOTE}`);
|
|
171
171
|
}
|
|
172
|
-
return
|
|
172
|
+
return policy;
|
|
173
173
|
}
|
|
174
174
|
function cloneConfig(config) {
|
|
175
175
|
return JSON.parse(JSON.stringify(config));
|
|
@@ -185,6 +185,18 @@ function printFullMatrix(matrix) {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
// ── Main execution ─────────────────────────────────────────────────────────────
|
|
188
|
+
function permissionsUsage() {
|
|
189
|
+
return `
|
|
190
|
+
aiwg steward permissions — authorization model audit and normalization
|
|
191
|
+
|
|
192
|
+
Usage:
|
|
193
|
+
aiwg steward permissions audit Find normalized-model errors and legacy grants
|
|
194
|
+
aiwg steward permissions migrate --dry-run Preview legacy permission normalization
|
|
195
|
+
aiwg steward permissions migrate --apply Back up and atomically normalize config
|
|
196
|
+
|
|
197
|
+
Reads .aiwg/aiwg.config authorization block. Migration backs up before writing.
|
|
198
|
+
`;
|
|
199
|
+
}
|
|
188
200
|
async function handleSteward(args, ctx) {
|
|
189
201
|
const subcommand = args[0];
|
|
190
202
|
if (!subcommand || subcommand === '--help' || subcommand === 'help') {
|
|
@@ -221,6 +233,11 @@ async function handleSteward(args, ctx) {
|
|
|
221
233
|
}
|
|
222
234
|
if (subcommand === 'permissions') {
|
|
223
235
|
const operation = args[1];
|
|
236
|
+
// `<namespace> --help` must reach the same usage block bare invocation prints (#2533).
|
|
237
|
+
if (!operation || operation === 'help' || operation === '--help' || operation === '-h') {
|
|
238
|
+
console.log(permissionsUsage());
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
224
241
|
const projectDir = ctx ? getProjectDir(ctx, args) : process.cwd();
|
|
225
242
|
const config = await readAiwgConfig(projectDir);
|
|
226
243
|
if (!config)
|
|
@@ -259,7 +276,15 @@ async function handleSteward(args, ctx) {
|
|
|
259
276
|
return;
|
|
260
277
|
}
|
|
261
278
|
const diagnostics = await auditLegacyPermissions(projectDir, config);
|
|
262
|
-
|
|
279
|
+
const legacyCount = diagnostics.filter(d => d.code.startsWith('legacy-')).length;
|
|
280
|
+
if (legacyCount === 0 && !config.authorization) {
|
|
281
|
+
// Nothing legacy to convert: the only work is writing the initial
|
|
282
|
+
// default-deny block, which is what clears doctor's warning (#2563).
|
|
283
|
+
console.log(` ${dryRun ? 'Would write' : 'Writing'} an initial default-deny authorization block (no legacy permission sources found).`);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
console.log(` ${dryRun ? 'Would normalize' : 'Normalizing'} ${legacyCount} legacy permission source(s).`);
|
|
287
|
+
}
|
|
263
288
|
console.log(` Result: ${Object.keys(normalized.authorization?.permissions ?? {}).length} permissions, ${Object.keys(normalized.authorization?.roles ?? {}).length} roles, ${normalized.authorization?.assignments.length ?? 0} assignments; default deny.`);
|
|
264
289
|
if (apply) {
|
|
265
290
|
const backup = await backupConfig(projectDir);
|
|
@@ -273,7 +298,7 @@ async function handleSteward(args, ctx) {
|
|
|
273
298
|
}
|
|
274
299
|
throw new AiwgError({
|
|
275
300
|
code: 'ERR_USAGE_UNKNOWN_PERMISSION_OPERATION',
|
|
276
|
-
message: `Unknown permissions operation: ${operation
|
|
301
|
+
message: `Unknown permissions operation: ${operation}`,
|
|
277
302
|
hint: 'Use audit or migrate --dry-run|--apply.',
|
|
278
303
|
exitCode: EXIT_CODES.USAGE,
|
|
279
304
|
});
|
|
@@ -598,6 +623,18 @@ export const stewardHandler = {
|
|
|
598
623
|
description: 'Provider capability routing and permission normalization',
|
|
599
624
|
category: 'maintenance',
|
|
600
625
|
aliases: [],
|
|
626
|
+
// The router intercepts --help before execute(), so a handler without this
|
|
627
|
+
// property gets the generic "no detailed help" stub even when its own usage
|
|
628
|
+
// text exists. Route to the same block bare invocation prints, and keep
|
|
629
|
+
// sub-namespace help reachable, without executing anything (#2533).
|
|
630
|
+
async help(ctx) {
|
|
631
|
+
const positional = ctx.args.filter((arg) => !arg.startsWith('-'));
|
|
632
|
+
if (positional[0] === 'permissions') {
|
|
633
|
+
return { exitCode: 0, message: permissionsUsage(), rawOutput: true };
|
|
634
|
+
}
|
|
635
|
+
await handleSteward([], ctx);
|
|
636
|
+
return { exitCode: 0 };
|
|
637
|
+
},
|
|
601
638
|
async execute(ctx) {
|
|
602
639
|
try {
|
|
603
640
|
await handleSteward(ctx.args, ctx);
|
|
@@ -694,8 +694,10 @@ const SESSION_RELOAD_NOTICE = {
|
|
|
694
694
|
rationale: 'Claude Code reads .claude/agents/ at session start. A running session retains its old registry until reloaded.',
|
|
695
695
|
},
|
|
696
696
|
codex: {
|
|
697
|
-
|
|
698
|
-
|
|
697
|
+
required: false,
|
|
698
|
+
action: 'No restart needed for deployed skills — Codex exposes them on the next turn. Reopen Codex in this workspace only if a deployed skill or agent is still missing after that.',
|
|
699
|
+
rationale: 'A running Codex desktop session listed the newly deployed project skills on the very next user turn without any restart (#2309). Custom agent registry and MCP server changes were not observed to refresh live, so reopening remains the fallback for those.',
|
|
700
|
+
symptom: 'If a deployed skill or agent stays absent after the next turn, the registry did not rescan — reopen Codex in this workspace.',
|
|
699
701
|
},
|
|
700
702
|
copilot: {
|
|
701
703
|
action: 'Reload the VS Code window (`Developer: Reload Window`) so Copilot picks up the new .github/agents/ entries.',
|
|
@@ -736,7 +738,8 @@ function printSessionReloadNotice(provider) {
|
|
|
736
738
|
if (!notice)
|
|
737
739
|
return;
|
|
738
740
|
const defaultSymptom = 'Until reloaded, the Agent/Task tool will report "Agent type not found" for the newly deployed agents.';
|
|
739
|
-
|
|
741
|
+
const required = notice.required !== false;
|
|
742
|
+
ui.section(required ? 'Session reload required:' : 'Session reload (only if something is missing):', [
|
|
740
743
|
notice.action,
|
|
741
744
|
`Why: ${notice.rationale}`,
|
|
742
745
|
notice.symptom ?? defaultSymptom,
|
|
@@ -1253,6 +1256,10 @@ async function deployOneProjectLocalBundle(opts) {
|
|
|
1253
1256
|
// never reach <provider>/.aiwg/skills/, leaving them invisible to
|
|
1254
1257
|
// both the platform and the index.
|
|
1255
1258
|
'--copy-all',
|
|
1259
|
+
// That copy-all is the deployer's doing, not the operator's, so the
|
|
1260
|
+
// provider's startup listing cap still applies; overflow lands on the
|
|
1261
|
+
// standard tier instead of over the cap (#2561).
|
|
1262
|
+
'--listing-budget',
|
|
1256
1263
|
// Provenance for the managed marker (#2502). Without this the deployer
|
|
1257
1264
|
// stamps `bundled`/`unknown`, and `aiwg refresh`'s stale-artifact prune —
|
|
1258
1265
|
// whose desired set is the packaged framework corpus — deletes every
|
|
@@ -1269,7 +1276,9 @@ async function deployOneProjectLocalBundle(opts) {
|
|
|
1269
1276
|
args.push('--quiet');
|
|
1270
1277
|
// Project-local bundles are addon-shaped — never trigger the legacy commands
|
|
1271
1278
|
// migration prompt (which is only relevant for full-framework deploys).
|
|
1272
|
-
|
|
1279
|
+
// This is a structural opt-out, not the operator declining, so suppress the
|
|
1280
|
+
// duplicate-commands warning too: it fired once per bundle (#2541).
|
|
1281
|
+
args.push('--skip-commands-migration', '--no-commands-warning');
|
|
1273
1282
|
const captureOpts = quiet && !verbose ? { capture: true } : {};
|
|
1274
1283
|
// Inject AIWG_ROOT so the deploy subprocess can resolve the upstream AIWG
|
|
1275
1284
|
// install root. The bundle's `--source` is its project-local path, so
|
|
@@ -12,7 +12,7 @@ import { getVersionInfo } from '../../channel/manager.mjs';
|
|
|
12
12
|
import { getLoggerInfo } from '../log.js';
|
|
13
13
|
import * as ui from '../ui.js';
|
|
14
14
|
import { maybePrintCommunityFooter } from '../../community/footer.js';
|
|
15
|
-
import { existsSync, statSync, readdirSync } from 'fs';
|
|
15
|
+
import { existsSync, statSync, readdirSync, readFileSync } from 'fs';
|
|
16
16
|
import path from 'path';
|
|
17
17
|
/**
|
|
18
18
|
* Version command handler
|
|
@@ -30,6 +30,27 @@ export const versionHandler = {
|
|
|
30
30
|
return { exitCode: 0 };
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Print the installation-drift notice when the canonical declaration and the
|
|
35
|
+
* running binary disagree. Keeps `aiwg version` honest about which one it is
|
|
36
|
+
* describing, and names the command that explains the rest (#2529).
|
|
37
|
+
*/
|
|
38
|
+
function printDrift(fp) {
|
|
39
|
+
if (fp.drift) {
|
|
40
|
+
const method = fp.installation?.identity?.method ?? 'unrecorded';
|
|
41
|
+
const declared = fp.drift.canonicalVersion ? ` (${fp.drift.canonicalVersion})` : '';
|
|
42
|
+
ui.dim(` ! canonical install declares ${method} at ${fp.drift.canonicalRoot}${declared} — run \`aiwg installation show\``);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// Same root, different method (a source checkout recorded as npm, or the
|
|
46
|
+
// reverse) is drift too; `version` is the first recovery command an operator
|
|
47
|
+
// reaches for, so it must say so instead of looking aligned (#2559).
|
|
48
|
+
const state = fp.installation?.state;
|
|
49
|
+
if (state && state !== 'aligned' && state !== 'unrecorded') {
|
|
50
|
+
const reasons = Array.isArray(fp.installation?.drift) ? fp.installation.drift : [];
|
|
51
|
+
ui.dim(` ! installation identity ${state}: ${reasons.join('; ') || 'see aiwg installation show'} — run \`aiwg installation show\``);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
33
54
|
function collectFingerprint(versionInfo) {
|
|
34
55
|
const loggerInfo = getLoggerInfo();
|
|
35
56
|
const fp = {
|
|
@@ -62,6 +83,22 @@ function collectFingerprint(versionInfo) {
|
|
|
62
83
|
path: versionInfo.edgePath ?? versionInfo.packageRoot,
|
|
63
84
|
};
|
|
64
85
|
}
|
|
86
|
+
// `version` is the first thing anyone runs to answer "what am I on". If the
|
|
87
|
+
// canonical install declares a different root than the one executing, say so
|
|
88
|
+
// here rather than letting drift persist while every check looks correct.
|
|
89
|
+
const canonicalRoot = fp.installation?.identity?.root;
|
|
90
|
+
const actualRoot = fp.installation?.actualRoot;
|
|
91
|
+
if (canonicalRoot && actualRoot && path.resolve(canonicalRoot) !== path.resolve(actualRoot)) {
|
|
92
|
+
let canonicalVersion = null;
|
|
93
|
+
try {
|
|
94
|
+
const pkg = JSON.parse(readFileSync(path.join(canonicalRoot, 'package.json'), 'utf8'));
|
|
95
|
+
canonicalVersion = pkg.version ?? null;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Canonical root may not exist or be readable; report the drift regardless.
|
|
99
|
+
}
|
|
100
|
+
fp.drift = { canonicalRoot, canonicalVersion, actualRoot };
|
|
101
|
+
}
|
|
65
102
|
// Locale / timezone are useful for timezone-dependent bug reports.
|
|
66
103
|
try {
|
|
67
104
|
fp.locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
@@ -96,13 +133,12 @@ async function displayVersion(opts) {
|
|
|
96
133
|
ui.blank();
|
|
97
134
|
console.log(` ${ui.brandMark()} ${ui.bold('aiwg')} ${ui.bold(fp.version)} ${ui.channelLabel(fp.channel)}`);
|
|
98
135
|
if (!opts.verbose) {
|
|
99
|
-
if (fp.git)
|
|
136
|
+
if (fp.git)
|
|
100
137
|
ui.dim(` git: ${fp.git.sha} (${fp.git.branch})`);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
138
|
+
// Always the root that actually executed — not the declared edge checkout,
|
|
139
|
+
// which is what made drift invisible here (#2529).
|
|
140
|
+
ui.dim(` path: ${fp.packageRoot}`);
|
|
141
|
+
printDrift(fp);
|
|
106
142
|
maybePrintCommunityFooter();
|
|
107
143
|
ui.blank();
|
|
108
144
|
return;
|
|
@@ -110,15 +146,14 @@ async function displayVersion(opts) {
|
|
|
110
146
|
// --verbose: the full environment fingerprint.
|
|
111
147
|
if (fp.git) {
|
|
112
148
|
ui.dim(` git: ${fp.git.sha} (${fp.git.branch})`);
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
else {
|
|
116
|
-
ui.dim(` path: ${fp.packageRoot}`);
|
|
149
|
+
if (fp.git.path !== fp.packageRoot)
|
|
150
|
+
ui.dim(` edge: ${fp.git.path}`);
|
|
117
151
|
}
|
|
152
|
+
ui.dim(` path: ${fp.packageRoot}`);
|
|
118
153
|
ui.dim(` channel: ${fp.channel}`);
|
|
119
|
-
ui.dim(` install: ${fp.installation
|
|
120
|
-
ui.dim(` canonical: ${fp.installation
|
|
121
|
-
ui.dim(` actual: ${fp.installation.
|
|
154
|
+
ui.dim(` install: ${fp.installation?.identity?.method ?? 'unrecorded'} (${fp.installation?.state ?? 'unknown'})`);
|
|
155
|
+
ui.dim(` canonical: ${fp.installation?.identity?.root ?? '(unrecorded)'}`);
|
|
156
|
+
ui.dim(` actual: ${fp.installation?.actualRoot ?? fp.packageRoot}`);
|
|
122
157
|
ui.dim(` node: ${fp.node}`);
|
|
123
158
|
ui.dim(` platform: ${fp.platform.os} ${fp.platform.arch} (${fp.platform.release})`);
|
|
124
159
|
ui.dim(` tty: stdin=${fp.tty.stdin} stdout=${fp.tty.stdout} stderr=${fp.tty.stderr}`);
|
|
@@ -55,6 +55,14 @@ export const workspaceContextHandler = {
|
|
|
55
55
|
console.log(JSON.stringify(result, null, 2));
|
|
56
56
|
else {
|
|
57
57
|
console.log(`${result.dryRun ? 'Migration dry run' : 'Migration applied'}: ${result.changed ? 'changes found' : 'already canonical'}`);
|
|
58
|
+
for (const entry of result.audit.plan.routing) {
|
|
59
|
+
console.log(` ${entry.source}: ${entry.operatorBytes.toLocaleString()} chars -> ${entry.destination} (${entry.scope})`);
|
|
60
|
+
}
|
|
61
|
+
for (const entry of result.audit.plan.scopeReview) {
|
|
62
|
+
console.log(` REVIEW ${entry.source} carries ${entry.operatorBytes.toLocaleString()} chars of operator content and is scoped to ${entry.scope} by filename.`);
|
|
63
|
+
console.log(' Content in .aiwg/context/providers/ is read by that provider only.');
|
|
64
|
+
console.log(" If this is project-neutral methodology, move it into WORKSPACE.md's Project Context section first.");
|
|
65
|
+
}
|
|
58
66
|
for (const file of result.written)
|
|
59
67
|
console.log(` ${result.dryRun ? 'would write' : 'wrote'} ${file}`);
|
|
60
68
|
if (result.transactionId)
|