@aiwg/cli 2026.8.5 → 2026.8.8
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/move.js +55 -7
- package/dist/src/cli/handlers/artifacts.js +8 -3
- package/dist/src/cli/handlers/regenerate.js +19 -9
- package/dist/src/cli/handlers/sessions.js +5 -4
- package/dist/src/cli/handlers/subcommands.js +30 -0
- package/dist/src/cli/handlers/use.js +16 -19
- package/dist/src/cli/regenerate-selector.js +94 -0
- package/dist/src/extensions/project-local-doctor.js +10 -6
- package/dist/src/extensions/project-local-gitignore.js +8 -4
- package/dist/src/extensions/project-quickref.js +197 -10
- package/dist/src/mcp/cli.mjs +5 -2
- package/dist/src/mcp/server.mjs +1 -1
- package/dist/src/mcp/tools/agentic-sandbox.mjs +232 -0
- package/dist/src/mcp/tools/subsystems.mjs +2 -0
- package/package.json +1 -1
- package/tools/plugin/package-plugins.mjs +53 -0
|
@@ -6,10 +6,24 @@ import { syncFortemiCoreIndex } from './fortemi-core-sync.js';
|
|
|
6
6
|
import { PROJECT_AIWG_LOCATION_FILE, expandProjectArtifactPath, resolveProjectAiwgDir, } from '../config/project-artifacts.js';
|
|
7
7
|
const GITIGNORE_BLOCK = [
|
|
8
8
|
'',
|
|
9
|
-
'# AIWG artifact
|
|
9
|
+
'# AIWG external artifact corpus (retain the project-local control plane)',
|
|
10
|
+
'!.aiwg/',
|
|
11
|
+
'.aiwg/*',
|
|
12
|
+
'!.aiwg/AIWG.md',
|
|
13
|
+
'!.aiwg/aiwg.config',
|
|
14
|
+
'!.aiwg/frameworks/',
|
|
15
|
+
'.aiwg/frameworks/*',
|
|
16
|
+
'!.aiwg/frameworks/registry.json',
|
|
17
|
+
'',
|
|
18
|
+
'# AIWG artifact root pointer (machine-local/private path)',
|
|
10
19
|
PROJECT_AIWG_LOCATION_FILE,
|
|
11
20
|
'',
|
|
12
21
|
].join('\n');
|
|
22
|
+
const LOCAL_CONTROL_PLANE_FILES = [
|
|
23
|
+
'AIWG.md',
|
|
24
|
+
'aiwg.config',
|
|
25
|
+
path.join('frameworks', 'registry.json'),
|
|
26
|
+
];
|
|
13
27
|
async function exists(filePath) {
|
|
14
28
|
try {
|
|
15
29
|
await access(filePath);
|
|
@@ -49,7 +63,8 @@ async function ensureGitignorePointer(projectDir, dryRun) {
|
|
|
49
63
|
throw error;
|
|
50
64
|
}
|
|
51
65
|
const lines = current.split(/\r?\n/).map(line => line.trim());
|
|
52
|
-
|
|
66
|
+
const required = GITIGNORE_BLOCK.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
67
|
+
if (required.every(line => lines.includes(line)))
|
|
53
68
|
return false;
|
|
54
69
|
if (!dryRun) {
|
|
55
70
|
const separator = current.length === 0 || current.endsWith('\n') ? '' : '\n';
|
|
@@ -57,6 +72,28 @@ async function ensureGitignorePointer(projectDir, dryRun) {
|
|
|
57
72
|
}
|
|
58
73
|
return true;
|
|
59
74
|
}
|
|
75
|
+
async function materializeLocalControlPlane(projectDir, artifactRoot, dryRun) {
|
|
76
|
+
const localRoot = path.join(projectDir, '.aiwg');
|
|
77
|
+
for (const relativePath of LOCAL_CONTROL_PLANE_FILES) {
|
|
78
|
+
const sourcePath = path.join(artifactRoot, relativePath);
|
|
79
|
+
if (!(await exists(sourcePath)))
|
|
80
|
+
continue;
|
|
81
|
+
const destinationPath = path.join(localRoot, relativePath);
|
|
82
|
+
const sourceContent = await readFile(sourcePath);
|
|
83
|
+
if (await exists(destinationPath)) {
|
|
84
|
+
const destinationContent = await readFile(destinationPath);
|
|
85
|
+
if (!sourceContent.equals(destinationContent)) {
|
|
86
|
+
throw new Error(`Local AIWG control-plane file differs from the external artifact root: ${destinationPath}. `
|
|
87
|
+
+ 'Reconcile the files before attaching the corpus.');
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!dryRun) {
|
|
92
|
+
await mkdir(path.dirname(destinationPath), { recursive: true });
|
|
93
|
+
await writeFile(destinationPath, sourceContent);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
60
97
|
async function writePointer(projectDir, pointerValue, dryRun) {
|
|
61
98
|
const pointerPath = path.join(projectDir, PROJECT_AIWG_LOCATION_FILE);
|
|
62
99
|
if (!dryRun) {
|
|
@@ -108,23 +145,33 @@ export async function moveProjectArtifacts(options) {
|
|
|
108
145
|
const source = path.resolve(options.from ? expandProjectArtifactPath(options.from, projectDir) : resolveProjectAiwgDir(projectDir));
|
|
109
146
|
const destination = path.resolve(expandProjectArtifactPath(options.to, projectDir));
|
|
110
147
|
const dryRun = options.dryRun === true;
|
|
148
|
+
const attach = options.attach === true;
|
|
111
149
|
const reindex = options.reindex !== false;
|
|
112
150
|
const syncFortemi = options.syncFortemi !== false;
|
|
113
|
-
if (samePath(source, destination)) {
|
|
151
|
+
if (!attach && samePath(source, destination)) {
|
|
114
152
|
throw new Error(`Source and destination are the same directory: ${source}`);
|
|
115
153
|
}
|
|
116
|
-
if (!existsSync(source)) {
|
|
154
|
+
if (!attach && !existsSync(source)) {
|
|
117
155
|
throw new Error(`Source AIWG artifact directory does not exist: ${source}`);
|
|
118
156
|
}
|
|
119
|
-
if (
|
|
157
|
+
if (attach) {
|
|
158
|
+
if (!existsSync(destination) || !(await stat(destination)).isDirectory()) {
|
|
159
|
+
throw new Error(`Artifact root to attach does not exist or is not a directory: ${destination}`);
|
|
160
|
+
}
|
|
161
|
+
if (!existsSync(path.join(destination, 'aiwg.config'))) {
|
|
162
|
+
throw new Error(`Artifact root to attach has no aiwg.config: ${destination}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (existsSync(destination) && !(await isEmptyDirectory(destination))) {
|
|
120
166
|
throw new Error(`Destination already exists and is not empty: ${destination}`);
|
|
121
167
|
}
|
|
122
168
|
const pointerValue = pointerValueFor(projectDir, destination);
|
|
123
169
|
const pointerPath = path.join(projectDir, PROJECT_AIWG_LOCATION_FILE);
|
|
124
170
|
const gitignoreUpdated = await ensureGitignorePointer(projectDir, dryRun);
|
|
125
|
-
if (!dryRun) {
|
|
171
|
+
if (!dryRun && !attach) {
|
|
126
172
|
await moveDirectory(source, destination);
|
|
127
173
|
}
|
|
174
|
+
await materializeLocalControlPlane(projectDir, destination, dryRun);
|
|
128
175
|
await writePointer(projectDir, pointerValue, dryRun);
|
|
129
176
|
let reindexed = false;
|
|
130
177
|
let fortemiSynced = false;
|
|
@@ -144,7 +191,8 @@ export async function moveProjectArtifacts(options) {
|
|
|
144
191
|
to: destination,
|
|
145
192
|
pointerPath,
|
|
146
193
|
pointerValue,
|
|
147
|
-
moved: !dryRun,
|
|
194
|
+
moved: !dryRun && !attach,
|
|
195
|
+
attached: attach && !dryRun,
|
|
148
196
|
gitignoreUpdated,
|
|
149
197
|
reindexed,
|
|
150
198
|
fortemiSynced,
|
|
@@ -5,9 +5,11 @@ function usage() {
|
|
|
5
5
|
'aiwg artifacts — Manage the project AIWG artifact root',
|
|
6
6
|
'',
|
|
7
7
|
'Usage:',
|
|
8
|
-
' aiwg artifacts move --to <path> [--from <path>] [--dry-run] [--
|
|
8
|
+
' aiwg artifacts move --to <path> [--from <path>] [--dry-run] [--no-reindex] [--no-sync]',
|
|
9
|
+
' aiwg artifacts attach --to <existing-path> [--dry-run] [--no-reindex] [--no-sync]',
|
|
9
10
|
'',
|
|
10
11
|
'Notes:',
|
|
12
|
+
' move relocates a local artifact root; attach adopts an existing populated root.',
|
|
11
13
|
' --to points at the artifact directory itself, not its parent.',
|
|
12
14
|
' AIWG_ARTIFACTS_PATH overrides the generated .aiwg-location pointer.',
|
|
13
15
|
].join('\n');
|
|
@@ -30,7 +32,7 @@ export const artifactsHandler = {
|
|
|
30
32
|
if (action === 'help' || ctx.args.includes('--help') || ctx.args.includes('-h')) {
|
|
31
33
|
return { exitCode: 0, message: usage() };
|
|
32
34
|
}
|
|
33
|
-
if (action !== 'move') {
|
|
35
|
+
if (action !== 'move' && action !== 'attach') {
|
|
34
36
|
return { exitCode: 1, message: `Unknown artifacts action: ${action}\n\n${usage()}` };
|
|
35
37
|
}
|
|
36
38
|
const to = valueAfter(ctx.args, '--to');
|
|
@@ -42,12 +44,15 @@ export const artifactsHandler = {
|
|
|
42
44
|
projectDir: getProjectDir(ctx, ctx.args),
|
|
43
45
|
from: valueAfter(ctx.args, '--from'),
|
|
44
46
|
to,
|
|
47
|
+
attach: action === 'attach',
|
|
45
48
|
dryRun: ctx.dryRun || ctx.args.includes('--dry-run'),
|
|
46
49
|
force: ctx.args.includes('--force'),
|
|
47
50
|
reindex: !ctx.args.includes('--no-reindex'),
|
|
48
51
|
syncFortemi: !ctx.args.includes('--no-sync'),
|
|
49
52
|
});
|
|
50
|
-
const verb = result.dryRun
|
|
53
|
+
const verb = result.dryRun
|
|
54
|
+
? (action === 'attach' ? 'Would attach' : 'Would move')
|
|
55
|
+
: (result.attached ? 'Attached' : 'Moved');
|
|
51
56
|
return {
|
|
52
57
|
exitCode: 0,
|
|
53
58
|
message: [
|
|
@@ -26,6 +26,7 @@ import { generate as generateContextFiles, discoverDeployedArtifacts, shouldEmit
|
|
|
26
26
|
import { resolveActiveProvider } from '../provider-resolution.js';
|
|
27
27
|
import { getProviderContextDiscoveryPathStrings } from '../../providers/provider-definitions.js';
|
|
28
28
|
import { projectAiwgPath } from '../../config/project-artifacts.js';
|
|
29
|
+
import { selectRegenerateBranch } from '../regenerate-selector.js';
|
|
29
30
|
async function handleRegenerate(args, cwd) {
|
|
30
31
|
if (args.includes('--help') || args.includes('-h')) {
|
|
31
32
|
console.log(`
|
|
@@ -40,7 +41,8 @@ async function handleRegenerate(args, cwd) {
|
|
|
40
41
|
or commands — use 'aiwg refresh' for that.
|
|
41
42
|
|
|
42
43
|
Options:
|
|
43
|
-
|
|
44
|
+
(no branch flag) Intelligently select workspace refresh or adoption preview
|
|
45
|
+
--workspace Explicit canonical WORKSPACE.md → AIWG.md graph
|
|
44
46
|
--existing-project Transactionally extract an established project into WORKSPACE.md
|
|
45
47
|
--legacy, --full-inject Legacy inline compatibility branch
|
|
46
48
|
--apply Apply --existing-project after its mandatory preflight
|
|
@@ -57,6 +59,7 @@ async function handleRegenerate(args, cwd) {
|
|
|
57
59
|
aiwg regenerate --workspace
|
|
58
60
|
aiwg regenerate --existing-project --dry-run
|
|
59
61
|
aiwg regenerate --existing-project --apply
|
|
62
|
+
aiwg regenerate --apply
|
|
60
63
|
aiwg regenerate --full-inject
|
|
61
64
|
aiwg regenerate --dry-run
|
|
62
65
|
aiwg regenerate --provider codex
|
|
@@ -69,9 +72,9 @@ async function handleRegenerate(args, cwd) {
|
|
|
69
72
|
const skipAiwgMd = args.includes('--no-aiwg-md');
|
|
70
73
|
const skipAgentsMd = args.includes('--no-agents-md');
|
|
71
74
|
const skipWorkspaceMd = args.includes('--no-workspace-md');
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
+
const requestedLegacy = args.includes('--legacy') || args.includes('--full-inject');
|
|
76
|
+
const requestedWorkspace = args.includes('--workspace');
|
|
77
|
+
const requestedExistingProject = args.includes('--existing-project');
|
|
75
78
|
const apply = args.includes('--apply');
|
|
76
79
|
const valueFlags = new Set(['--provider']);
|
|
77
80
|
const booleanFlags = new Set([
|
|
@@ -95,23 +98,27 @@ async function handleRegenerate(args, cwd) {
|
|
|
95
98
|
});
|
|
96
99
|
}
|
|
97
100
|
}
|
|
98
|
-
const selectedBranches = Number(
|
|
101
|
+
const selectedBranches = Number(requestedLegacy) + Number(requestedWorkspace) + Number(requestedExistingProject);
|
|
99
102
|
if (selectedBranches > 1)
|
|
100
103
|
throw new AiwgError({
|
|
101
104
|
code: 'ERR_USAGE_CONFLICTING_FLAGS',
|
|
102
105
|
message: 'Choose exactly one regenerate branch: --workspace, --existing-project, or --full-inject.',
|
|
103
106
|
exitCode: EXIT_CODES.USAGE,
|
|
104
107
|
});
|
|
105
|
-
if (
|
|
108
|
+
if (dryRun && apply)
|
|
106
109
|
throw new AiwgError({
|
|
107
110
|
code: 'ERR_USAGE_CONFLICTING_FLAGS',
|
|
108
|
-
message: '
|
|
111
|
+
message: 'Choose either --dry-run or --apply.',
|
|
109
112
|
exitCode: EXIT_CODES.USAGE,
|
|
110
113
|
});
|
|
111
|
-
|
|
114
|
+
const selection = await selectRegenerateBranch(cwd, args);
|
|
115
|
+
const legacy = selection.branch === 'legacy';
|
|
116
|
+
const existingProject = selection.branch === 'existing-project';
|
|
117
|
+
if (apply && !existingProject)
|
|
112
118
|
throw new AiwgError({
|
|
113
119
|
code: 'ERR_USAGE_CONFLICTING_FLAGS',
|
|
114
|
-
message: '
|
|
120
|
+
message: '--apply is only valid when the existing-project branch is selected.',
|
|
121
|
+
hint: 'Use `aiwg regenerate --existing-project --apply`, or run without --apply to inspect the selected branch.',
|
|
115
122
|
exitCode: EXIT_CODES.USAGE,
|
|
116
123
|
});
|
|
117
124
|
if (existingProject && (force || skipAiwgMd || skipAgentsMd || skipWorkspaceMd))
|
|
@@ -140,6 +147,9 @@ async function handleRegenerate(args, cwd) {
|
|
|
140
147
|
console.log(` Provider: ${provider}`);
|
|
141
148
|
console.log(` Target: ${target}`);
|
|
142
149
|
console.log(` Branch: ${legacy ? 'legacy full injection' : existingProject ? 'canonical existing-project extraction' : 'canonical workspace graph'}`);
|
|
150
|
+
console.log(` Selected: ${selection.explicit ? 'explicit' : 'inferred'} — ${selection.reason}`);
|
|
151
|
+
if (selection.evidence.length > 0)
|
|
152
|
+
console.log(` Evidence: ${selection.evidence.join(', ')}`);
|
|
143
153
|
if (existingProject) {
|
|
144
154
|
const preflight = await migrateWorkspaceContext(target, {
|
|
145
155
|
dryRun: true,
|
|
@@ -1203,15 +1203,16 @@ function projectRootCandidate(start) {
|
|
|
1203
1203
|
catch {
|
|
1204
1204
|
current = resolve(start);
|
|
1205
1205
|
}
|
|
1206
|
-
let gitRoot = null;
|
|
1207
1206
|
while (true) {
|
|
1208
1207
|
if (existsSync(resolve(current, '.aiwg', 'aiwg.config')))
|
|
1209
1208
|
return current;
|
|
1210
|
-
|
|
1211
|
-
|
|
1209
|
+
// A repository root is the project boundary. Do not let an unrelated
|
|
1210
|
+
// ancestor workspace configuration capture session catalog reads.
|
|
1211
|
+
if (existsSync(resolve(current, '.git')))
|
|
1212
|
+
return current;
|
|
1212
1213
|
const parent = dirname(current);
|
|
1213
1214
|
if (parent === current)
|
|
1214
|
-
return
|
|
1215
|
+
return null;
|
|
1215
1216
|
current = parent;
|
|
1216
1217
|
}
|
|
1217
1218
|
}
|
|
@@ -47,6 +47,8 @@ export const quickrefHandler = {
|
|
|
47
47
|
console.log(`${verb} ${result.skillName}`);
|
|
48
48
|
console.log(` Source: ${result.sourcePath}`);
|
|
49
49
|
console.log(` Output: ${result.outputPath}`);
|
|
50
|
+
for (const warning of result.warnings)
|
|
51
|
+
console.log(` Warning: ${warning}`);
|
|
50
52
|
if (ctx.dryRun) {
|
|
51
53
|
console.log('\n--- preview ---\n');
|
|
52
54
|
console.log(result.content);
|
|
@@ -850,6 +852,26 @@ export const promoteHandler = {
|
|
|
850
852
|
console.log(`✓ Promoted '${positional}' → ${result.plan?.destination}`);
|
|
851
853
|
if (cleanup) {
|
|
852
854
|
console.log(' Source removed from .aiwg/');
|
|
855
|
+
try {
|
|
856
|
+
const { deployProjectQuickref, generateProjectQuickref, hasProjectQuickref } = await import('../../extensions/project-quickref.js');
|
|
857
|
+
if (await hasProjectQuickref(projectDir)) {
|
|
858
|
+
if (config.providers.length > 0) {
|
|
859
|
+
for (const provider of config.providers)
|
|
860
|
+
await deployProjectQuickref(projectDir, provider);
|
|
861
|
+
console.log(` Managed project quickref refreshed for: ${config.providers.join(', ')}.`);
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
await generateProjectQuickref(projectDir);
|
|
865
|
+
console.log(' Managed project quickref generated; no providers are configured for deployment.');
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
console.log(' Managed project quickref is now empty; run `aiwg doctor --project-local` to inspect deployed stale copies.');
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
catch (error) {
|
|
873
|
+
console.log(` Managed project quickref refresh failed: ${error.message}`);
|
|
874
|
+
}
|
|
853
875
|
}
|
|
854
876
|
return { exitCode: 0 };
|
|
855
877
|
}
|
|
@@ -956,6 +978,14 @@ export const newBundleHandler = {
|
|
|
956
978
|
catch {
|
|
957
979
|
// .gitignore management is best-effort; don't fail the scaffold
|
|
958
980
|
}
|
|
981
|
+
try {
|
|
982
|
+
const { generateProjectQuickref } = await import('../../extensions/project-quickref.js');
|
|
983
|
+
await generateProjectQuickref(ctx.cwd);
|
|
984
|
+
console.log(' → Managed project quickref refreshed from discovered capabilities.');
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
console.log(` → Managed project quickref refresh deferred: ${error.message}`);
|
|
988
|
+
}
|
|
959
989
|
// #1235 / #1758 — auto-rebuild the project graph and refresh the
|
|
960
990
|
// Fortemi Core static cache so the new bundle is immediately
|
|
961
991
|
// discoverable via top-level `aiwg discover` / `aiwg show`.
|
|
@@ -1079,18 +1079,17 @@ async function deployProjectLocalBundles(opts) {
|
|
|
1079
1079
|
: discovery.bundles.filter(b => b.type !== 'provider');
|
|
1080
1080
|
if (targetBundles.length === 0) {
|
|
1081
1081
|
if (!onlyBundleId) {
|
|
1082
|
-
const {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
try {
|
|
1082
|
+
const { hasProjectQuickref, deployProjectQuickref } = await import('../../extensions/project-quickref.js');
|
|
1083
|
+
try {
|
|
1084
|
+
if (await hasProjectQuickref(projectDir)) {
|
|
1086
1085
|
await deployProjectQuickref(projectDir, provider, { dryRun });
|
|
1087
1086
|
if (verbose || dryRun)
|
|
1088
1087
|
ui.dim(` + project quickref -> ${provider}`);
|
|
1089
1088
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
}
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
ui.warn(`Project quickref deployment failed: ${error.message}`);
|
|
1092
|
+
return { deployed: 0, failed: 1, bundles: [] };
|
|
1094
1093
|
}
|
|
1095
1094
|
}
|
|
1096
1095
|
return { deployed: 0, failed: 0, bundles: [] };
|
|
@@ -1204,22 +1203,20 @@ async function deployProjectLocalBundles(opts) {
|
|
|
1204
1203
|
}
|
|
1205
1204
|
}
|
|
1206
1205
|
}
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
if (quickref.exists) {
|
|
1213
|
-
try {
|
|
1206
|
+
// Refresh the project kernel quickref from either legacy operator input or
|
|
1207
|
+
// managed project-local discovery whenever bundles deploy.
|
|
1208
|
+
const { hasProjectQuickref, deployProjectQuickref } = await import('../../extensions/project-quickref.js');
|
|
1209
|
+
try {
|
|
1210
|
+
if (await hasProjectQuickref(projectDir)) {
|
|
1214
1211
|
const quickrefResult = await deployProjectQuickref(projectDir, provider, { dryRun });
|
|
1215
1212
|
if (verbose || dryRun) {
|
|
1216
1213
|
ui.dim(` + project quickref -> ${quickrefResult.provider}${quickrefResult.emulated ? ' (emulated)' : ''}`);
|
|
1217
1214
|
}
|
|
1218
1215
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
}
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
failed++;
|
|
1219
|
+
ui.warn(`Project quickref deployment failed: ${error.message}`);
|
|
1223
1220
|
}
|
|
1224
1221
|
return { deployed, failed, bundles: targetBundles };
|
|
1225
1222
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { auditWorkspaceContext, PROJECT_EXTRACTION_END, PROJECT_EXTRACTION_START, WORKSPACE_MANAGED_END, WORKSPACE_MANAGED_START, WORKSPACE_OPERATOR_END, WORKSPACE_OPERATOR_START, } from '../smiths/context-pipeline/index.js';
|
|
4
|
+
import { AiwgError, EXIT_CODES } from './errors.js';
|
|
5
|
+
function markerPair(content, start, end) {
|
|
6
|
+
const startIndex = content.indexOf(start);
|
|
7
|
+
const endIndex = content.indexOf(end);
|
|
8
|
+
const present = startIndex >= 0 || endIndex >= 0;
|
|
9
|
+
return {
|
|
10
|
+
present,
|
|
11
|
+
valid: !present || (startIndex >= 0 && endIndex > startIndex
|
|
12
|
+
&& content.indexOf(start, startIndex + start.length) < 0
|
|
13
|
+
&& content.indexOf(end, endIndex + end.length) < 0),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function malformedWorkspace(message) {
|
|
17
|
+
throw new AiwgError({
|
|
18
|
+
code: 'ERR_USAGE_REGENERATE_STATE_MALFORMED',
|
|
19
|
+
message,
|
|
20
|
+
hint: 'Repair the managed marker pair or restore WORKSPACE.md from version control, then rerun `aiwg regenerate --dry-run`.',
|
|
21
|
+
exitCode: EXIT_CODES.USAGE,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async function readOptional(filePath) {
|
|
25
|
+
try {
|
|
26
|
+
return await fs.readFile(filePath, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code === 'ENOENT')
|
|
30
|
+
return null;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function selectRegenerateBranch(cwd, args) {
|
|
35
|
+
const legacy = args.includes('--legacy') || args.includes('--full-inject');
|
|
36
|
+
const workspace = args.includes('--workspace');
|
|
37
|
+
const existingProject = args.includes('--existing-project');
|
|
38
|
+
if (legacy)
|
|
39
|
+
return { branch: 'legacy', state: 'legacy-context', reason: 'explicit legacy compatibility branch', evidence: ['--legacy/--full-inject'], explicit: true };
|
|
40
|
+
if (workspace)
|
|
41
|
+
return { branch: 'workspace', state: 'fresh', reason: 'explicit canonical workspace branch', evidence: ['--workspace'], explicit: true };
|
|
42
|
+
if (existingProject)
|
|
43
|
+
return { branch: 'existing-project', state: 'established-unextracted', reason: 'explicit existing-project adoption branch', evidence: ['--existing-project'], explicit: true };
|
|
44
|
+
const workspaceContent = await readOptional(path.join(cwd, 'WORKSPACE.md'));
|
|
45
|
+
let workspaceMarkers = null;
|
|
46
|
+
if (workspaceContent !== null) {
|
|
47
|
+
workspaceMarkers = {
|
|
48
|
+
managed: markerPair(workspaceContent, WORKSPACE_MANAGED_START, WORKSPACE_MANAGED_END),
|
|
49
|
+
operator: markerPair(workspaceContent, WORKSPACE_OPERATOR_START, WORKSPACE_OPERATOR_END),
|
|
50
|
+
extraction: markerPair(workspaceContent, PROJECT_EXTRACTION_START, PROJECT_EXTRACTION_END),
|
|
51
|
+
};
|
|
52
|
+
const { managed, operator, extraction } = workspaceMarkers;
|
|
53
|
+
if (!managed.valid || !operator.valid || !extraction.valid) {
|
|
54
|
+
malformedWorkspace('WORKSPACE.md contains an incomplete, duplicated, or out-of-order AIWG managed marker pair.');
|
|
55
|
+
}
|
|
56
|
+
if (extraction.present && !managed.present)
|
|
57
|
+
malformedWorkspace('WORKSPACE.md contains a project-extraction block without the canonical workspace managed graph.');
|
|
58
|
+
if (managed.present !== operator.present)
|
|
59
|
+
malformedWorkspace('WORKSPACE.md contains only part of the canonical managed/operator structure.');
|
|
60
|
+
}
|
|
61
|
+
const audit = await auditWorkspaceContext(cwd);
|
|
62
|
+
const projectSources = audit.plan.projectSources;
|
|
63
|
+
const operatorSources = audit.sources
|
|
64
|
+
.filter((source) => source.path !== 'WORKSPACE.md' && source.operatorContent.trim().length > 0)
|
|
65
|
+
.map((source) => source.path);
|
|
66
|
+
if (workspaceContent !== null) {
|
|
67
|
+
const { managed, extraction } = workspaceMarkers;
|
|
68
|
+
if (managed.present && extraction.present)
|
|
69
|
+
return {
|
|
70
|
+
branch: 'workspace', state: 'adopted', reason: 'canonical workspace already contains an extracted project snapshot', evidence: ['WORKSPACE.md project-extraction marker'], explicit: false,
|
|
71
|
+
};
|
|
72
|
+
if (managed.present && projectSources.length > 0)
|
|
73
|
+
return {
|
|
74
|
+
branch: 'existing-project', state: 'canonical-unextracted', reason: 'canonical workspace exists but stable project metadata has not been adopted', evidence: projectSources, explicit: false,
|
|
75
|
+
};
|
|
76
|
+
if (managed.present)
|
|
77
|
+
return {
|
|
78
|
+
branch: 'workspace', state: 'fresh', reason: 'canonical workspace exists and no stable project sources were detected', evidence: ['WORKSPACE.md managed graph'], explicit: false,
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
branch: 'existing-project', state: 'operator-owned-workspace', reason: 'operator-owned WORKSPACE.md requires transactional adoption before canonical refresh', evidence: ['WORKSPACE.md without AIWG managed markers', ...projectSources], explicit: false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (projectSources.length > 0)
|
|
85
|
+
return {
|
|
86
|
+
branch: 'existing-project', state: 'established-unextracted', reason: 'stable existing-project sources were detected without an extracted workspace snapshot', evidence: projectSources, explicit: false,
|
|
87
|
+
};
|
|
88
|
+
if (audit.legacyCompatible || operatorSources.length > 0)
|
|
89
|
+
return {
|
|
90
|
+
branch: 'existing-project', state: 'legacy-context', reason: 'operator-authored provider context requires transactional adoption', evidence: operatorSources, explicit: false,
|
|
91
|
+
};
|
|
92
|
+
return { branch: 'workspace', state: 'fresh', reason: 'no prior workspace setup or stable project sources were detected', evidence: [], explicit: false };
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=regenerate-selector.js.map
|
|
@@ -54,12 +54,15 @@ export async function buildProjectLocalDoctorSection(opts) {
|
|
|
54
54
|
const quickrefAudit = await auditProjectQuickref(projectDir, config?.providers ?? []);
|
|
55
55
|
const quickrefErrors = [...quickrefAudit.errors];
|
|
56
56
|
if (quickrefAudit.exists) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
for (const name of ['quickref.json', 'quickref.config.json']) {
|
|
58
|
+
const sourcePath = projectAiwgPath(projectDir, name);
|
|
59
|
+
const quickrefRelPath = projectRelativePathIfInside(projectDir, sourcePath);
|
|
60
|
+
const ignored = quickrefRelPath
|
|
61
|
+
? await checkBundleManifestIgnored(projectDir, quickrefRelPath)
|
|
62
|
+
: null;
|
|
63
|
+
if (ignored === true && quickrefRelPath) {
|
|
64
|
+
quickrefErrors.push(`${quickrefRelPath} is ignored by git; operator project quickref input must be committed`);
|
|
65
|
+
}
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
// No project-local content → no section at all
|
|
@@ -236,6 +239,7 @@ export async function buildProjectLocalDoctorSection(opts) {
|
|
|
236
239
|
}
|
|
237
240
|
lines.push(' Project-local bundle source should be tracked. Add to .gitignore:');
|
|
238
241
|
lines.push(' !.aiwg/quickref.json');
|
|
242
|
+
lines.push(' !.aiwg/quickref.config.json');
|
|
239
243
|
lines.push(' !.aiwg/addons/');
|
|
240
244
|
lines.push(' !.aiwg/extensions/');
|
|
241
245
|
lines.push(' !.aiwg/frameworks/');
|
|
@@ -39,6 +39,7 @@ export const AIWG_GITIGNORE_BLOCK = [
|
|
|
39
39
|
AIWG_GITIGNORE_SENTINEL,
|
|
40
40
|
'!.aiwg/aiwg.config',
|
|
41
41
|
'!.aiwg/quickref.json',
|
|
42
|
+
'!.aiwg/quickref.config.json',
|
|
42
43
|
'!.aiwg/addons/',
|
|
43
44
|
'!.aiwg/extensions/',
|
|
44
45
|
'!.aiwg/frameworks/',
|
|
@@ -120,11 +121,14 @@ export async function appendAiwgSourceTrackBlock(projectDir) {
|
|
|
120
121
|
// runs.
|
|
121
122
|
if (report.hasManagedBlock) {
|
|
122
123
|
const path = join(projectDir, '.gitignore');
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
let existing = await readFile(path, 'utf8');
|
|
125
|
+
const required = ['!.aiwg/quickref.json', '!.aiwg/quickref.config.json'];
|
|
126
|
+
const missing = required.filter(negation => !existing.split(/\r?\n/).some(line => line.trim() === negation));
|
|
127
|
+
if (missing.length > 0) {
|
|
125
128
|
const sep = existing.endsWith('\n') ? '' : '\n';
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
existing = `${existing}${sep}${missing.join('\n')}\n`;
|
|
130
|
+
await writeFile(path, existing, 'utf8');
|
|
131
|
+
return { added: true, reason: `updated managed block to track ${missing.join(', ')}` };
|
|
128
132
|
}
|
|
129
133
|
return { added: false, reason: 'block already present — no change' };
|
|
130
134
|
}
|
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from 'crypto';
|
|
9
9
|
import { access, mkdir, readFile, readdir, rm, writeFile } from 'fs/promises';
|
|
10
|
-
import { dirname, isAbsolute, join, resolve } from 'path';
|
|
10
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'path';
|
|
11
11
|
import { homedir } from 'os';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { getProviderDefinition, normalizeProviderDefinitionId, } from '../providers/provider-definitions.js';
|
|
14
14
|
import { OPERATIONAL_SHOW_TYPES } from '../artifacts/types.js';
|
|
15
15
|
import { projectAiwgPath } from '../config/project-artifacts.js';
|
|
16
16
|
import { appendAiwgSourceTrackBlock } from './project-local-gitignore.js';
|
|
17
|
+
import { discoverProjectLocalBundles } from './project-local-discovery.js';
|
|
18
|
+
import { enumerateBundleArtifacts } from './shadow-resolver.js';
|
|
17
19
|
const OWNERSHIP_MARKER = '.aiwg-project-quickref.json';
|
|
18
20
|
const ShowHintSchema = z.object({
|
|
19
21
|
type: z.enum(OPERATIONAL_SHOW_TYPES),
|
|
@@ -35,6 +37,25 @@ export const ProjectQuickrefSchema = z.object({
|
|
|
35
37
|
precedence: z.string().min(1).max(1024),
|
|
36
38
|
entries: z.array(QuickrefEntrySchema).min(1).max(50),
|
|
37
39
|
}).strict();
|
|
40
|
+
const QuickrefOverrideSchema = z.object({
|
|
41
|
+
title: z.string().min(1).max(128).optional(),
|
|
42
|
+
summary: z.string().min(1).max(512).optional(),
|
|
43
|
+
discover: z.array(z.string().min(1).max(256)).max(10).optional(),
|
|
44
|
+
show: z.array(ShowHintSchema).max(20).optional(),
|
|
45
|
+
hidden: z.boolean().optional(),
|
|
46
|
+
order: z.number().int().optional(),
|
|
47
|
+
}).strict();
|
|
48
|
+
export const ProjectQuickrefConfigSchema = z.object({
|
|
49
|
+
version: z.literal('1'),
|
|
50
|
+
project: ProjectQuickrefSchema.shape.project.optional(),
|
|
51
|
+
precedence: z.string().min(1).max(1024).optional(),
|
|
52
|
+
entries: z.array(QuickrefEntrySchema).max(50).default([]),
|
|
53
|
+
discovery: z.object({
|
|
54
|
+
enabled: z.boolean().default(true),
|
|
55
|
+
excludeBundles: z.array(z.string().min(1)).max(200).default([]),
|
|
56
|
+
overrides: z.record(z.string(), QuickrefOverrideSchema).default({}),
|
|
57
|
+
}).strict().default({ enabled: true, excludeBundles: [], overrides: {} }),
|
|
58
|
+
}).strict();
|
|
38
59
|
function sha256(content) {
|
|
39
60
|
return createHash('sha256').update(content).digest('hex');
|
|
40
61
|
}
|
|
@@ -78,6 +99,158 @@ export async function loadProjectQuickref(projectDir) {
|
|
|
78
99
|
return { sourcePath, errors: [`${sourcePath}: invalid JSON: ${error.message}`], exists: true };
|
|
79
100
|
}
|
|
80
101
|
}
|
|
102
|
+
function slug(value) {
|
|
103
|
+
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
104
|
+
return normalized || 'project';
|
|
105
|
+
}
|
|
106
|
+
async function inferredProject(projectDir) {
|
|
107
|
+
let packageName = basename(resolve(projectDir));
|
|
108
|
+
let description = `Managed project-specific orientation for ${packageName}.`;
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'));
|
|
111
|
+
if (parsed.name)
|
|
112
|
+
packageName = parsed.name;
|
|
113
|
+
if (parsed.description)
|
|
114
|
+
description = parsed.description.slice(0, 512);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// package metadata is optional
|
|
118
|
+
}
|
|
119
|
+
const id = slug(packageName);
|
|
120
|
+
const name = packageName
|
|
121
|
+
.replace(/^@[^/]+\//, '')
|
|
122
|
+
.split(/[-_]/)
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.map(part => part[0]?.toUpperCase() + part.slice(1))
|
|
125
|
+
.join(' ') || 'Project';
|
|
126
|
+
return { id, name, description };
|
|
127
|
+
}
|
|
128
|
+
async function loadManagedConfig(projectDir) {
|
|
129
|
+
const path = projectAiwgPath(projectDir, 'quickref.config.json');
|
|
130
|
+
const raw = await readIfPresent(path);
|
|
131
|
+
if (raw === null)
|
|
132
|
+
return { path };
|
|
133
|
+
let json;
|
|
134
|
+
try {
|
|
135
|
+
json = JSON.parse(raw);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
throw new Error(`${path}: invalid JSON: ${error.message}`);
|
|
139
|
+
}
|
|
140
|
+
const parsed = ProjectQuickrefConfigSchema.safeParse(json);
|
|
141
|
+
if (!parsed.success) {
|
|
142
|
+
throw new Error(parsed.error.issues.map(issue => `${path}: ${issue.path.join('.') || '(root)'}: ${issue.message}`).join('\n'));
|
|
143
|
+
}
|
|
144
|
+
return { path, config: parsed.data };
|
|
145
|
+
}
|
|
146
|
+
function dedupe(values) {
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
return values.filter(value => {
|
|
149
|
+
const key = value.trim().toLowerCase();
|
|
150
|
+
if (!key || seen.has(key))
|
|
151
|
+
return false;
|
|
152
|
+
seen.add(key);
|
|
153
|
+
return true;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function dedupeShow(values) {
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
return values.filter(value => {
|
|
159
|
+
const key = `${value.type}:${value.name.toLowerCase()}`;
|
|
160
|
+
if (seen.has(key))
|
|
161
|
+
return false;
|
|
162
|
+
seen.add(key);
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** Resolve legacy operator input or synthesize a managed definition from project-local bundles. */
|
|
167
|
+
export async function resolveProjectQuickref(projectDir) {
|
|
168
|
+
const legacy = await loadProjectQuickref(projectDir);
|
|
169
|
+
if (legacy.exists) {
|
|
170
|
+
if (!legacy.definition)
|
|
171
|
+
throw new Error(legacy.errors.join('\n'));
|
|
172
|
+
return {
|
|
173
|
+
definition: legacy.definition,
|
|
174
|
+
exists: true,
|
|
175
|
+
sourcePath: legacy.sourcePath,
|
|
176
|
+
provenance: 'legacy',
|
|
177
|
+
warnings: [],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const managed = await loadManagedConfig(projectDir);
|
|
181
|
+
const discovery = await discoverProjectLocalBundles(projectDir);
|
|
182
|
+
if (discovery.errors.length > 0) {
|
|
183
|
+
throw new Error(discovery.errors.map(error => `${error.path}: ${error.field}: ${error.actual}`).join('\n'));
|
|
184
|
+
}
|
|
185
|
+
if (!managed.config && discovery.bundles.length === 0) {
|
|
186
|
+
return { exists: false, sourcePath: managed.path, provenance: 'managed', warnings: [] };
|
|
187
|
+
}
|
|
188
|
+
const config = managed.config ?? ProjectQuickrefConfigSchema.parse({ version: '1' });
|
|
189
|
+
const excluded = new Set(config.discovery.excludeBundles.map(id => id.toLowerCase()));
|
|
190
|
+
const warnings = [];
|
|
191
|
+
const candidates = [];
|
|
192
|
+
if (config.discovery.enabled) {
|
|
193
|
+
for (const bundle of discovery.bundles) {
|
|
194
|
+
if (excluded.has(bundle.id.toLowerCase()))
|
|
195
|
+
continue;
|
|
196
|
+
const override = config.discovery.overrides[bundle.id];
|
|
197
|
+
if (override?.hidden)
|
|
198
|
+
continue;
|
|
199
|
+
const artifacts = (await enumerateBundleArtifacts(bundle.artifactPath ?? bundle.bundlePath))
|
|
200
|
+
.sort((a, b) => a.type.localeCompare(b.type) || a.id.localeCompare(b.id));
|
|
201
|
+
const inferredShow = dedupeShow(artifacts.map(artifact => ({
|
|
202
|
+
type: artifact.type,
|
|
203
|
+
name: artifact.id,
|
|
204
|
+
}))).slice(0, 8);
|
|
205
|
+
if (artifacts.length > inferredShow.length && inferredShow.length === 8) {
|
|
206
|
+
warnings.push(`${bundle.id}: show hints truncated from ${artifacts.length} artifacts to 8`);
|
|
207
|
+
}
|
|
208
|
+
let discover = override?.discover ?? dedupe([
|
|
209
|
+
bundle.id,
|
|
210
|
+
bundle.manifest.name,
|
|
211
|
+
...(bundle.manifest.keywords ?? []),
|
|
212
|
+
]).slice(0, 3);
|
|
213
|
+
const show = override?.show ? dedupeShow(override.show) : inferredShow;
|
|
214
|
+
if (discover.length === 0 && show.length === 0)
|
|
215
|
+
discover = [bundle.id];
|
|
216
|
+
candidates.push({
|
|
217
|
+
order: override?.order ?? 0,
|
|
218
|
+
type: bundle.type,
|
|
219
|
+
id: bundle.id,
|
|
220
|
+
entry: {
|
|
221
|
+
title: override?.title ?? bundle.manifest.name,
|
|
222
|
+
summary: override?.summary ?? bundle.manifest.description,
|
|
223
|
+
discover,
|
|
224
|
+
show,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
candidates.sort((a, b) => a.order - b.order || a.type.localeCompare(b.type) || a.id.localeCompare(b.id));
|
|
230
|
+
const discoveredEntries = candidates.map(candidate => candidate.entry);
|
|
231
|
+
const entries = [...discoveredEntries, ...config.entries];
|
|
232
|
+
if (entries.length > 50)
|
|
233
|
+
warnings.push(`quickref entries truncated from ${entries.length} to 50`);
|
|
234
|
+
const bounded = entries.slice(0, 50);
|
|
235
|
+
if (bounded.length === 0) {
|
|
236
|
+
return { exists: false, sourcePath: managed.path, provenance: 'managed', warnings };
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
definition: {
|
|
240
|
+
version: '1',
|
|
241
|
+
project: config.project ?? await inferredProject(projectDir),
|
|
242
|
+
precedence: config.precedence ?? 'Use project-local capabilities before generic AIWG workflows when they apply.',
|
|
243
|
+
entries: bounded,
|
|
244
|
+
},
|
|
245
|
+
exists: true,
|
|
246
|
+
sourcePath: managed.path,
|
|
247
|
+
provenance: 'managed',
|
|
248
|
+
warnings,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
export async function hasProjectQuickref(projectDir) {
|
|
252
|
+
return (await resolveProjectQuickref(projectDir)).exists;
|
|
253
|
+
}
|
|
81
254
|
export function renderProjectQuickref(definition) {
|
|
82
255
|
const skillName = projectQuickrefSkillName(definition.project.id);
|
|
83
256
|
const lines = [
|
|
@@ -111,11 +284,9 @@ export function renderProjectQuickref(definition) {
|
|
|
111
284
|
return lines.join('\n');
|
|
112
285
|
}
|
|
113
286
|
export async function generateProjectQuickref(projectDir, options = {}) {
|
|
114
|
-
const loaded = await
|
|
115
|
-
if (!loaded.exists)
|
|
287
|
+
const loaded = await resolveProjectQuickref(projectDir);
|
|
288
|
+
if (!loaded.exists || !loaded.definition)
|
|
116
289
|
throw new Error(`Project quickref source not found: ${loaded.sourcePath}`);
|
|
117
|
-
if (!loaded.definition)
|
|
118
|
-
throw new Error(loaded.errors.join('\n'));
|
|
119
290
|
if (!options.dryRun)
|
|
120
291
|
await appendAiwgSourceTrackBlock(projectDir);
|
|
121
292
|
const skillName = projectQuickrefSkillName(loaded.definition.project.id);
|
|
@@ -127,6 +298,14 @@ export async function generateProjectQuickref(projectDir, options = {}) {
|
|
|
127
298
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
128
299
|
await writeFile(outputPath, content, 'utf8');
|
|
129
300
|
}
|
|
301
|
+
if (!options.dryRun && loaded.provenance === 'managed') {
|
|
302
|
+
const snapshotPath = projectAiwgPath(projectDir, 'generated', 'project-quickref', 'definition.json');
|
|
303
|
+
const snapshot = JSON.stringify(loaded.definition, null, 2) + '\n';
|
|
304
|
+
if (await readIfPresent(snapshotPath) !== snapshot) {
|
|
305
|
+
await mkdir(dirname(snapshotPath), { recursive: true });
|
|
306
|
+
await writeFile(snapshotPath, snapshot, 'utf8');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
130
309
|
if (!options.dryRun) {
|
|
131
310
|
const generatedRoot = projectAiwgPath(projectDir, 'generated', 'project-quickref');
|
|
132
311
|
try {
|
|
@@ -149,6 +328,8 @@ export async function generateProjectQuickref(projectDir, options = {}) {
|
|
|
149
328
|
content,
|
|
150
329
|
changed,
|
|
151
330
|
dryRun: options.dryRun ?? false,
|
|
331
|
+
provenance: loaded.provenance,
|
|
332
|
+
warnings: loaded.warnings,
|
|
152
333
|
};
|
|
153
334
|
}
|
|
154
335
|
function resolveProviderSkillsRoot(provider, projectDir, homeDir) {
|
|
@@ -206,9 +387,9 @@ async function findStaleOwnedQuickrefs(root, projectDir, keepName, global) {
|
|
|
206
387
|
}
|
|
207
388
|
export async function deployProjectQuickref(projectDir, provider, options = {}) {
|
|
208
389
|
const generated = await generateProjectQuickref(projectDir, { dryRun: options.dryRun });
|
|
209
|
-
const loaded = await
|
|
390
|
+
const loaded = await resolveProjectQuickref(projectDir);
|
|
210
391
|
if (!loaded.definition)
|
|
211
|
-
throw new Error(loaded.
|
|
392
|
+
throw new Error(`Project quickref source not found: ${loaded.sourcePath}`);
|
|
212
393
|
const target = resolveProviderSkillsRoot(provider, projectDir, options.homeDir ?? homedir());
|
|
213
394
|
const targetDir = join(target.root, generated.skillName);
|
|
214
395
|
const targetPath = join(targetDir, 'SKILL.md');
|
|
@@ -259,11 +440,17 @@ export async function deployProjectQuickref(projectDir, provider, options = {})
|
|
|
259
440
|
};
|
|
260
441
|
}
|
|
261
442
|
export async function auditProjectQuickref(projectDir, providers, options = {}) {
|
|
262
|
-
|
|
443
|
+
let loaded;
|
|
444
|
+
try {
|
|
445
|
+
loaded = await resolveProjectQuickref(projectDir);
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
return { exists: true, errors: [error.message], drift: [] };
|
|
449
|
+
}
|
|
263
450
|
if (!loaded.exists)
|
|
264
|
-
return { exists: false, errors:
|
|
451
|
+
return { exists: false, errors: [], drift: [] };
|
|
265
452
|
if (!loaded.definition)
|
|
266
|
-
return { exists: true, errors:
|
|
453
|
+
return { exists: true, errors: ['project quickref definition unavailable'], drift: [] };
|
|
267
454
|
const content = renderProjectQuickref(loaded.definition);
|
|
268
455
|
const skillName = projectQuickrefSkillName(loaded.definition.project.id);
|
|
269
456
|
const generatedPath = projectAiwgPath(projectDir, 'generated', 'project-quickref', skillName, 'SKILL.md');
|
package/dist/src/mcp/cli.mjs
CHANGED
|
@@ -415,7 +415,7 @@ CORE TOOLS (15, always registered):
|
|
|
415
415
|
command-run Allow-listed CLI dispatch; confirmation-gated when needed
|
|
416
416
|
artifact-read / artifact-write Project .aiwg/ artifact IO
|
|
417
417
|
|
|
418
|
-
OPT-IN TOOLSETS (
|
|
418
|
+
OPT-IN TOOLSETS (60 additional tools):
|
|
419
419
|
flows flow-list / flow-show / flow-run
|
|
420
420
|
missions mission-guide / mission-dispatch / mission-status
|
|
421
421
|
memory memory-* and reflections-* storage operations
|
|
@@ -426,9 +426,10 @@ OPT-IN TOOLSETS (51 additional tools):
|
|
|
426
426
|
ralph start / status / abort / attach
|
|
427
427
|
mc start / dispatch / status / stop / list
|
|
428
428
|
ops status / list / use / push
|
|
429
|
+
sandbox fleet inventory / mutation / reconciliation and governed activity
|
|
429
430
|
|
|
430
431
|
Enable opt-in tools:
|
|
431
|
-
AIWG_MCP_TOOLSETS=flows,missions,memory,kb,ralph aiwg mcp serve
|
|
432
|
+
AIWG_MCP_TOOLSETS=flows,missions,memory,kb,ralph,sandbox aiwg mcp serve
|
|
432
433
|
aiwg mcp serve --toolsets=all
|
|
433
434
|
|
|
434
435
|
RESOURCES:
|
|
@@ -451,6 +452,8 @@ TRANSPORTS:
|
|
|
451
452
|
ENVIRONMENT:
|
|
452
453
|
AIWG_ROOT Path to AIWG installation (default: ~/.local/share/ai-writing-guide)
|
|
453
454
|
AIWG_MCP_TOOLSETS Comma-separated opt-in toolsets; use all for every toolset
|
|
455
|
+
AIWG_SANDBOX_MANAGEMENT_URL Sandbox management API origin (HTTPS except loopback)
|
|
456
|
+
AIWG_SANDBOX_MANAGEMENT_TOKEN_FILE Mode-0600 management bearer file for sandbox tools
|
|
454
457
|
|
|
455
458
|
Docs:
|
|
456
459
|
docs/integrations/mcp-capability-audit.md
|
package/dist/src/mcp/server.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function createServer() {
|
|
|
68
68
|
// Opt-in subsystem toolsets (#1322-#1332)
|
|
69
69
|
//
|
|
70
70
|
// Enabled via AIWG_MCP_TOOLSETS env or `aiwg mcp serve --toolsets=`.
|
|
71
|
-
// Known: flows, missions, memory, kb, research, activity-log, index, ralph, mc, ops, all
|
|
71
|
+
// Known: flows, missions, memory, kb, research, activity-log, index, ralph, mc, ops, sandbox, all
|
|
72
72
|
// Default: none (core only — discovery + command-run)
|
|
73
73
|
// ============================================
|
|
74
74
|
const requested = parseToolsets(process.env.AIWG_MCP_TOOLSETS || '');
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed Agentic Sandbox fleet and activity MCP surface.
|
|
3
|
+
*
|
|
4
|
+
* Credentials are file-backed server configuration, never tool arguments.
|
|
5
|
+
* Every tool in this module uses the management bearer domain; executor-plane
|
|
6
|
+
* credentials are deliberately out of scope and cannot be substituted.
|
|
7
|
+
*
|
|
8
|
+
* @implements #2015
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { mcpError, mcpJson } from '../helpers.mjs';
|
|
14
|
+
|
|
15
|
+
const FLEET_VERSION = 'agentic-orchestration/v1';
|
|
16
|
+
const ACTIVITY_VERSION = 'activity.event/v1';
|
|
17
|
+
const SCOPE_HEADERS = {
|
|
18
|
+
tenant_id: 'x-agentic-tenant-id',
|
|
19
|
+
host_id: 'x-agentic-host-id',
|
|
20
|
+
instance_id: 'x-agentic-instance-id',
|
|
21
|
+
agent_id: 'x-agentic-agent-id',
|
|
22
|
+
};
|
|
23
|
+
const RESTRICTED_KEY = /(?:^|_)(?:content|terminal|prompt|environment|env|credential|secret|password|authorization|bearer|token|private_key|certificate|restricted_(?:url|uri|link))(?:$|_)/i;
|
|
24
|
+
|
|
25
|
+
function configuredBaseUrl(env = process.env) {
|
|
26
|
+
const raw = String(env.AIWG_SANDBOX_MANAGEMENT_URL ?? '').trim();
|
|
27
|
+
if (!raw) throw new Error('AIWG_SANDBOX_MANAGEMENT_URL is required for the sandbox toolset');
|
|
28
|
+
const url = new URL(raw);
|
|
29
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
30
|
+
throw new Error('AIWG_SANDBOX_MANAGEMENT_URL must be an HTTP(S) origin without credentials, query, or fragment');
|
|
31
|
+
}
|
|
32
|
+
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) {
|
|
33
|
+
throw new Error('AIWG_SANDBOX_MANAGEMENT_URL requires HTTPS outside loopback');
|
|
34
|
+
}
|
|
35
|
+
return url.toString().replace(/\/$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function managementBearer(env = process.env) {
|
|
39
|
+
const tokenFile = String(env.AIWG_SANDBOX_MANAGEMENT_TOKEN_FILE ?? '').trim();
|
|
40
|
+
if (!tokenFile) throw new Error('AIWG_SANDBOX_MANAGEMENT_TOKEN_FILE is required for the sandbox toolset');
|
|
41
|
+
const metadata = await stat(tokenFile);
|
|
42
|
+
if (!metadata.isFile()) throw new Error('sandbox management token path is not a regular file');
|
|
43
|
+
if (process.platform !== 'win32' && (metadata.mode & 0o077) !== 0) {
|
|
44
|
+
throw new Error('sandbox management token file must not be accessible by group or other users');
|
|
45
|
+
}
|
|
46
|
+
const token = String(await readFile(tokenFile, 'utf8')).trim();
|
|
47
|
+
if (!token || /[\r\n]/.test(token)) throw new Error('sandbox management token file must contain one non-empty bearer token');
|
|
48
|
+
return token;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function containsRestricted(value) {
|
|
52
|
+
if (Array.isArray(value)) return value.some(containsRestricted);
|
|
53
|
+
if (!value || typeof value !== 'object') return false;
|
|
54
|
+
return Object.entries(value).some(([key, child]) => RESTRICTED_KEY.test(key) || containsRestricted(child));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function requireSafePayload(value, label) {
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
59
|
+
if (containsRestricted(value)) throw new Error(`${label} contains credential or restricted-content fields`);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeResponse(value) {
|
|
64
|
+
if (containsRestricted(value)) throw Object.assign(new Error('sandbox response contained prohibited credential or restricted-content fields'), { code: 'sandbox_restricted_response' });
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function requireFleetRecord(record) {
|
|
69
|
+
requireSafePayload(record, 'fleet workload');
|
|
70
|
+
if (record.document_type !== 'workload' || record.api_version !== FLEET_VERSION) throw new Error(`fleet workload must use ${FLEET_VERSION}`);
|
|
71
|
+
if (!record.lineage || typeof record.lineage.child_id !== 'string' || !record.lineage.child_id) throw new Error('fleet workload requires lineage.child_id');
|
|
72
|
+
if (!record.status || !Number.isInteger(record.status.revision) || record.status.revision < 0) throw new Error('fleet workload requires a non-negative status.revision');
|
|
73
|
+
return record;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requireInventory(value) {
|
|
77
|
+
safeResponse(value);
|
|
78
|
+
if (value?.document_type !== 'inventory' || value?.api_version !== FLEET_VERSION || !Number.isInteger(value?.inventory_revision) || !Array.isArray(value?.records)) {
|
|
79
|
+
throw new Error('invalid fleet inventory envelope');
|
|
80
|
+
}
|
|
81
|
+
value.records.forEach(requireFleetRecord);
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function requireReconciliation(value) {
|
|
86
|
+
safeResponse(value);
|
|
87
|
+
if (value?.document_type !== 'reconciliation' || value?.api_version !== FLEET_VERSION || !Number.isInteger(value?.before_revision) || !Number.isInteger(value?.after_revision) || !Array.isArray(value?.rows)) {
|
|
88
|
+
throw new Error('invalid fleet reconciliation envelope');
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireActivityEnvelope(value, scope, { eventsRequired = false, exportEnvelope = false } = {}) {
|
|
94
|
+
safeResponse(value);
|
|
95
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid activity envelope');
|
|
96
|
+
if (!exportEnvelope && value.schema_version !== ACTIVITY_VERSION) throw new Error(`activity envelope must use ${ACTIVITY_VERSION}`);
|
|
97
|
+
if (!exportEnvelope && (!Array.isArray(value.coverage) || typeof value.completeness?.complete !== 'boolean')) throw new Error('activity envelope requires coverage and completeness');
|
|
98
|
+
if (eventsRequired && !Array.isArray(value.events)) throw new Error('activity timeline requires events');
|
|
99
|
+
for (const event of value.events ?? []) {
|
|
100
|
+
if (
|
|
101
|
+
event?.schema_version !== ACTIVITY_VERSION
|
|
102
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(event?.event_id ?? '')
|
|
103
|
+
|| !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(event?.event_name ?? '')
|
|
104
|
+
|| !['session', 'action', 'network', 'runtime', 'system', 'integrity'].includes(event?.plane)
|
|
105
|
+
|| Number.isNaN(Date.parse(event?.occurred_at ?? ''))
|
|
106
|
+
|| Number.isNaN(Date.parse(event?.observed_at ?? ''))
|
|
107
|
+
|| !event?.source || typeof event.source.collector !== 'string' || !event.source.collector
|
|
108
|
+
|| !['guest', 'runtime', 'host', 'control-plane', 'provider'].includes(event.source.layer)
|
|
109
|
+
|| !['qemu-kvm', 'cloud-hypervisor', 'docker', 'host', 'unknown'].includes(event.source.runtime)
|
|
110
|
+
|| !['observed', 'attested', 'self-reported', 'derived'].includes(event.source.trust)
|
|
111
|
+
|| event?.sensitivity !== 'metadata'
|
|
112
|
+
|| !['standard', 'security', 'forensic-hold', 'ephemeral'].includes(event?.retention_class)
|
|
113
|
+
|| !event?.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)
|
|
114
|
+
|| !Number.isInteger(event?.integrity?.collector_sequence) || event.integrity.collector_sequence < 1
|
|
115
|
+
) throw new Error('activity event violates schema or sensitivity policy');
|
|
116
|
+
for (const [key, expected] of Object.entries(scope)) if (event?.correlation?.[key] !== expected) throw new Error(`activity event ${key} scope mismatch`);
|
|
117
|
+
}
|
|
118
|
+
if (exportEnvelope) {
|
|
119
|
+
const manifest = value.manifest;
|
|
120
|
+
if (!manifest || manifest.tenant_id !== scope.tenant_id || !Number.isInteger(manifest.event_count) || manifest.event_count < 0 || !/^[0-9a-f]{64}$/.test(manifest.merkle_root ?? '') || typeof manifest.key_id !== 'string' || !manifest.key_id || typeof manifest.signature !== 'string' || !manifest.signature) {
|
|
121
|
+
throw new Error('activity export manifest is malformed or out of scope');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function typedResult(body, status, validate) {
|
|
128
|
+
if (status === 404 || status === 405) return mcpJson({ supported: false, reason: 'capability_absent', status });
|
|
129
|
+
if (status < 200 || status >= 300) {
|
|
130
|
+
const safe = safeResponse(body);
|
|
131
|
+
return {
|
|
132
|
+
...mcpJson({ supported: true, ok: false, status, error_code: safe?.error ?? safe?.code ?? `http_${status}`, details: safe }),
|
|
133
|
+
isError: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return mcpJson({ supported: true, ok: true, status, data: validate(body) });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class AgenticSandboxMcpClient {
|
|
140
|
+
constructor({ env = process.env, fetch = globalThis.fetch.bind(globalThis) } = {}) {
|
|
141
|
+
this.env = env;
|
|
142
|
+
this.fetch = fetch;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async request(path, { method = 'GET', body, headers = {}, validate = (value) => safeResponse(value) } = {}) {
|
|
146
|
+
const baseUrl = configuredBaseUrl(this.env);
|
|
147
|
+
const token = await managementBearer(this.env);
|
|
148
|
+
const response = await this.fetch(`${baseUrl}${path}`, {
|
|
149
|
+
method,
|
|
150
|
+
headers: { accept: 'application/json', authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers },
|
|
151
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
152
|
+
});
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = await response.json();
|
|
156
|
+
} catch {
|
|
157
|
+
if (response.status === 404 || response.status === 405) parsed = {};
|
|
158
|
+
else return mcpError(`sandbox returned non-JSON HTTP ${response.status}`);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
return typedResult(parsed, response.status, validate);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return mcpError(`${error.code ?? 'sandbox_malformed_response'}: ${error.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const scopeSchema = {
|
|
169
|
+
tenant_id: z.string().min(1).max(255).regex(/^[^\r\n]+$/),
|
|
170
|
+
host_id: z.string().min(1).max(255).regex(/^[^\r\n]+$/),
|
|
171
|
+
instance_id: z.string().min(1).max(255).regex(/^[^\r\n]+$/),
|
|
172
|
+
agent_id: z.string().min(1).max(255).regex(/^[^\r\n]+$/),
|
|
173
|
+
};
|
|
174
|
+
const activityFilterSchema = z.object({
|
|
175
|
+
event_name: z.string().min(1).max(255).optional(), collector: z.string().min(1).max(255).optional(),
|
|
176
|
+
trust: z.string().min(1).max(255).optional(), plane: z.string().min(1).max(255).optional(),
|
|
177
|
+
outcome: z.string().min(1).max(255).optional(), session_id: z.string().min(1).max(255).optional(),
|
|
178
|
+
mission_id: z.string().min(1).max(255).optional(), task_id: z.string().min(1).max(255).optional(),
|
|
179
|
+
tool_call_id: z.string().min(1).max(255).optional(), command_id: z.string().min(1).max(255).optional(),
|
|
180
|
+
process_id: z.string().min(1).max(255).optional(), trace_id: z.string().regex(/^[0-9a-f]{32}$/).optional(),
|
|
181
|
+
since: z.string().datetime().optional(), until: z.string().datetime().optional(), limit: z.number().int().min(1).max(1000).optional(),
|
|
182
|
+
}).strict().optional();
|
|
183
|
+
|
|
184
|
+
function scopeHeaders(args) {
|
|
185
|
+
return Object.fromEntries(Object.entries(SCOPE_HEADERS).map(([key, header]) => [header, args[key]]));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function activityQuery(filter = {}) {
|
|
189
|
+
const query = new URLSearchParams();
|
|
190
|
+
for (const [key, value] of Object.entries(filter)) query.set(key, String(value));
|
|
191
|
+
return query.size ? `?${query}` : '';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function confirmationError(name) {
|
|
195
|
+
return mcpError(`${name} requires confirmed=true`, { requiresConfirmation: true, remediation: 'Review the exact scope and payload, then re-invoke with confirmed=true.' });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function registerAgenticSandboxToolset(server, { client = new AgenticSandboxMcpClient() } = {}) {
|
|
199
|
+
const register = (name, config, handler) => server.registerTool(name, config, async (args) => {
|
|
200
|
+
try { return await handler(args); } catch (error) { return mcpError(`${name}: ${error.message}`); }
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
register('sandbox-fleet-list', { title: 'List Agentic Sandbox fleet workloads', description: 'Read revisioned v2026.8.3+ fleet inventory using the management credential domain.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION) }, annotations: { readOnlyHint: true, destructiveHint: false } },
|
|
204
|
+
() => client.request('/api/v2/fleet/workloads', { validate: requireInventory }));
|
|
205
|
+
register('sandbox-fleet-get', { title: 'Get Agentic Sandbox fleet workload', description: 'Read one revisioned fleet workload by child identity.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), child_id: z.string().min(1).max(255) }, annotations: { readOnlyHint: true, destructiveHint: false } },
|
|
206
|
+
({ child_id }) => client.request(`/api/v2/fleet/workloads/${encodeURIComponent(child_id)}`, { validate: requireFleetRecord }));
|
|
207
|
+
register('sandbox-fleet-reconcile-preview', { title: 'Preview fleet reconciliation', description: 'Compute a read-only reconciliation preview from inventory; never calls POST /reconcile.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), before_revision: z.number().int().min(0), child_ids: z.array(z.string().min(1).max(255)).max(1000) }, annotations: { readOnlyHint: true, destructiveHint: false } },
|
|
208
|
+
async ({ before_revision, child_ids }) => client.request('/api/v2/fleet/workloads', { validate: (value) => {
|
|
209
|
+
const inventory = requireInventory(value);
|
|
210
|
+
const byId = new Map(inventory.records.map((record) => [record.lineage.child_id, record]));
|
|
211
|
+
return { document_type: 'reconciliation-preview', api_version: FLEET_VERSION, before_revision, inventory_revision: inventory.inventory_revision, stale: before_revision !== inventory.inventory_revision, rows: child_ids.map((child_id) => ({ child_id, present: byId.has(child_id), observed_state: byId.get(child_id)?.status?.observed_state ?? 'unknown', revision: byId.get(child_id)?.status?.revision ?? null })) };
|
|
212
|
+
} }));
|
|
213
|
+
register('sandbox-fleet-admit', { title: 'Admit Agentic Sandbox fleet workload', description: 'Mutating fleet admission; requires an exact v1 workload and confirmed=true.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), workload: z.record(z.unknown()), confirmed: z.boolean().default(false) }, annotations: { readOnlyHint: false, destructiveHint: true } },
|
|
214
|
+
({ workload, confirmed }) => confirmed ? client.request('/api/v2/fleet/workloads', { method: 'POST', body: requireFleetRecord(workload), validate: (value) => ({ replayed: value?.replayed === true, workload: requireFleetRecord(value?.workload) }) }) : confirmationError('sandbox-fleet-admit'));
|
|
215
|
+
register('sandbox-fleet-observe', { title: 'Record fleet workload observation', description: 'Mutating monotonic observation update; requires confirmed=true and expected revision.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), child_id: z.string().min(1).max(255), expected_revision: z.number().int().min(0), status: z.record(z.unknown()), runtime_identity: z.object({ session_id: z.string().optional(), task_id: z.string().optional(), command_id: z.string().optional() }).strict().optional(), confirmed: z.boolean().default(false) }, annotations: { readOnlyHint: false, destructiveHint: true } },
|
|
216
|
+
({ child_id, expected_revision, status, runtime_identity, confirmed }) => confirmed ? client.request(`/api/v2/fleet/workloads/${encodeURIComponent(child_id)}/observations`, { method: 'POST', body: requireSafePayload({ expected_revision, status, ...(runtime_identity ? { runtime_identity } : {}) }, 'fleet observation'), validate: requireFleetRecord }) : confirmationError('sandbox-fleet-observe'));
|
|
217
|
+
register('sandbox-fleet-reconcile', { title: 'Reconcile fleet workloads', description: 'Mutating restart reconciliation; requires confirmed=true.', inputSchema: { contract_version: z.literal(FLEET_VERSION).default(FLEET_VERSION), before_revision: z.number().int().min(0), child_ids: z.array(z.string().min(1).max(255)).max(1000), confirmed: z.boolean().default(false) }, annotations: { readOnlyHint: false, destructiveHint: true } },
|
|
218
|
+
({ before_revision, child_ids, confirmed }) => confirmed ? client.request('/api/v2/fleet/reconcile', { method: 'POST', body: { before_revision, child_ids }, validate: requireReconciliation }) : confirmationError('sandbox-fleet-reconcile'));
|
|
219
|
+
|
|
220
|
+
for (const [kind, eventsRequired] of [['coverage', false], ['timeline', true]]) {
|
|
221
|
+
register(`sandbox-activity-${kind}`, { title: `${kind === 'coverage' ? 'Inspect coverage for' : 'Read timeline from'} Agentic Sandbox activity`, description: `Read governed, exactly scoped activity ${kind}; preserves capability and authorization status.`, inputSchema: { contract_version: z.literal(ACTIVITY_VERSION).default(ACTIVITY_VERSION), ...scopeSchema, filter: activityFilterSchema }, annotations: { readOnlyHint: true, destructiveHint: false } },
|
|
222
|
+
(args) => { const scope = Object.fromEntries(Object.keys(SCOPE_HEADERS).map((key) => [key, args[key]])); return client.request(`/api/v2/activity/${kind}${activityQuery(args.filter)}`, { headers: scopeHeaders(args), validate: (value) => requireActivityEnvelope(value, scope, { eventsRequired }) }); });
|
|
223
|
+
}
|
|
224
|
+
register('sandbox-activity-export', { title: 'Export signed Agentic Sandbox activity evidence', description: 'Evidence export requires confirmed=true even though it does not mutate server state.', inputSchema: { contract_version: z.literal(ACTIVITY_VERSION).default(ACTIVITY_VERSION), ...scopeSchema, filter: activityFilterSchema, confirmed: z.boolean().default(false) }, annotations: { readOnlyHint: false, destructiveHint: true } },
|
|
225
|
+
(args) => { if (!args.confirmed) return confirmationError('sandbox-activity-export'); const scope = Object.fromEntries(Object.keys(SCOPE_HEADERS).map((key) => [key, args[key]])); return client.request('/api/v2/activity/export', { method: 'POST', headers: scopeHeaders(args), body: args.filter ?? {}, validate: (value) => requireActivityEnvelope(value, scope, { eventsRequired: true, exportEnvelope: true }) }); });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const AGENTIC_SANDBOX_TOOL_NAMES = [
|
|
229
|
+
'sandbox-fleet-list', 'sandbox-fleet-get', 'sandbox-fleet-reconcile-preview',
|
|
230
|
+
'sandbox-fleet-admit', 'sandbox-fleet-observe', 'sandbox-fleet-reconcile',
|
|
231
|
+
'sandbox-activity-coverage', 'sandbox-activity-timeline', 'sandbox-activity-export',
|
|
232
|
+
];
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
import { runAiwgCli, mcpError, mcpJson } from '../helpers.mjs';
|
|
19
19
|
import { registerFlowToolset, registerMissionToolset } from './orchestration.mjs';
|
|
20
|
+
import { registerAgenticSandboxToolset } from './agentic-sandbox.mjs';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Wrap an `aiwg <subsystem> <verb>` CLI call as an MCP tool.
|
|
@@ -586,6 +587,7 @@ const TOOLSET_REGISTRY = {
|
|
|
586
587
|
ralph: registerRalphToolset,
|
|
587
588
|
mc: registerMcToolset,
|
|
588
589
|
ops: registerOpsToolset,
|
|
590
|
+
sandbox: registerAgenticSandboxToolset,
|
|
589
591
|
};
|
|
590
592
|
|
|
591
593
|
/**
|
package/package.json
CHANGED
|
@@ -657,6 +657,13 @@ for (const [id, sourceRoot] of MANIFEST_PLUGIN_SOURCES) {
|
|
|
657
657
|
keywords: manifest.keywords || manifest.tags || [],
|
|
658
658
|
category: manifest.category || 'productivity',
|
|
659
659
|
sourceRoot,
|
|
660
|
+
declaredSkills: manifest.skills ?? [],
|
|
661
|
+
// Plugin payloads run outside the checkout. Rewrite references back to
|
|
662
|
+
// their packaged root, including the pre-rename Ralph path retained by
|
|
663
|
+
// older Agent Loop documentation.
|
|
664
|
+
selfReferenceRoots: id === 'agent-loop'
|
|
665
|
+
? [sourceRoot, 'agentic/code/addons/ralph']
|
|
666
|
+
: [sourceRoot],
|
|
660
667
|
};
|
|
661
668
|
}
|
|
662
669
|
|
|
@@ -774,6 +781,48 @@ function copyDir(src, dest, dryRun = false, filter = null) {
|
|
|
774
781
|
return copied;
|
|
775
782
|
}
|
|
776
783
|
|
|
784
|
+
function rewritePackagedSelfReferences(pluginDir, roots = []) {
|
|
785
|
+
if (roots.length === 0) return;
|
|
786
|
+
const pending = [pluginDir];
|
|
787
|
+
while (pending.length > 0) {
|
|
788
|
+
const current = pending.pop();
|
|
789
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
790
|
+
const target = path.join(current, entry.name);
|
|
791
|
+
if (entry.isDirectory()) {
|
|
792
|
+
pending.push(target);
|
|
793
|
+
} else if (entry.isFile() && /\.(?:md|json|ya?ml)$/.test(entry.name)) {
|
|
794
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
795
|
+
let rewritten = original;
|
|
796
|
+
for (const root of roots) {
|
|
797
|
+
rewritten = rewritten.replaceAll(`${root}/`, '${CLAUDE_PLUGIN_ROOT}/');
|
|
798
|
+
}
|
|
799
|
+
if (rewritten !== original) fs.writeFileSync(target, rewritten, 'utf8');
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function pruneUndeclaredPackagedSkills(pluginDir, declaredSkills) {
|
|
806
|
+
if (!declaredSkills) return;
|
|
807
|
+
const skillsRoot = path.join(pluginDir, 'skills');
|
|
808
|
+
if (!fs.existsSync(skillsRoot)) return;
|
|
809
|
+
const allowed = new Set(declaredSkills);
|
|
810
|
+
for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
|
|
811
|
+
if (entry.isDirectory() && !allowed.has(entry.name)) {
|
|
812
|
+
fs.rmSync(path.join(skillsRoot, entry.name), { recursive: true, force: true });
|
|
813
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
814
|
+
const skillId = path.basename(entry.name, '.md');
|
|
815
|
+
if (allowed.has(skillId)) {
|
|
816
|
+
const skillDir = path.join(skillsRoot, skillId);
|
|
817
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
818
|
+
fs.renameSync(path.join(skillsRoot, entry.name), path.join(skillDir, 'SKILL.md'));
|
|
819
|
+
} else {
|
|
820
|
+
fs.rmSync(path.join(skillsRoot, entry.name), { force: true });
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
777
826
|
// Clean plugin directory (except .claude-plugin)
|
|
778
827
|
function cleanPlugin(pluginDir) {
|
|
779
828
|
if (!fs.existsSync(pluginDir)) return;
|
|
@@ -811,6 +860,10 @@ function packagePlugin(name, config, options) {
|
|
|
811
860
|
console.log(` 📁 Copying self-contained source from ${config.sourceRoot}...`);
|
|
812
861
|
const count = copyDir(config.sourceRoot, pluginDir, options.dryRun);
|
|
813
862
|
console.log(` ${count} files`);
|
|
863
|
+
if (!options.dryRun) {
|
|
864
|
+
rewritePackagedSelfReferences(pluginDir, config.selfReferenceRoots);
|
|
865
|
+
pruneUndeclaredPackagedSkills(pluginDir, config.declaredSkills);
|
|
866
|
+
}
|
|
814
867
|
}
|
|
815
868
|
|
|
816
869
|
// Copy sources
|