@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.
@@ -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 root pointer (local/private path)',
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
- if (lines.includes(PROJECT_AIWG_LOCATION_FILE))
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 (existsSync(destination) && !(await isEmptyDirectory(destination))) {
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] [--force] [--no-reindex] [--no-sync]',
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 ? 'Would move' : 'Moved';
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
- --workspace Canonical WORKSPACE.md AIWG.md graph (default)
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 legacy = args.includes('--legacy') || args.includes('--full-inject');
73
- const workspace = args.includes('--workspace');
74
- const existingProject = args.includes('--existing-project');
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(legacy) + Number(workspace) + Number(existingProject);
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 (apply && !existingProject)
108
+ if (dryRun && apply)
106
109
  throw new AiwgError({
107
110
  code: 'ERR_USAGE_CONFLICTING_FLAGS',
108
- message: '--apply is only valid with --existing-project.',
111
+ message: 'Choose either --dry-run or --apply.',
109
112
  exitCode: EXIT_CODES.USAGE,
110
113
  });
111
- if (existingProject && dryRun && apply)
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: 'Choose either --dry-run or --apply for --existing-project.',
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
- if (!gitRoot && existsSync(resolve(current, '.git')))
1211
- gitRoot = current;
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 gitRoot;
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 { loadProjectQuickref, deployProjectQuickref } = await import('../../extensions/project-quickref.js');
1083
- const quickref = await loadProjectQuickref(projectDir);
1084
- if (quickref.exists) {
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
- catch (error) {
1091
- ui.warn(`Project quickref deployment failed: ${error.message}`);
1092
- return { deployed: 0, failed: 1, bundles: [] };
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
- // A committed `.aiwg/quickref.json` is the canonical orientation source.
1208
- // Refresh its provider kernel copy whenever project-local bundles deploy so
1209
- // `aiwg use <bundle>` keeps the always-visible surface in sync.
1210
- const { loadProjectQuickref, deployProjectQuickref } = await import('../../extensions/project-quickref.js');
1211
- const quickref = await loadProjectQuickref(projectDir);
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
- catch (error) {
1220
- failed++;
1221
- ui.warn(`Project quickref deployment failed: ${error.message}`);
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 quickrefRelPath = projectRelativePathIfInside(projectDir, projectAiwgPath(projectDir, 'quickref.json'));
58
- const ignored = quickrefRelPath
59
- ? await checkBundleManifestIgnored(projectDir, quickrefRelPath)
60
- : null;
61
- if (ignored === true && quickrefRelPath) {
62
- quickrefErrors.push(`${quickrefRelPath} is ignored by git; canonical project quickref source must be committed`);
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
- const existing = await readFile(path, 'utf8');
124
- if (!existing.split(/\r?\n/).some(line => line.trim() === '!.aiwg/quickref.json')) {
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
- await writeFile(path, `${existing}${sep}!.aiwg/quickref.json\n`, 'utf8');
127
- return { added: true, reason: 'updated managed block to track .aiwg/quickref.json' };
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 loadProjectQuickref(projectDir);
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 loadProjectQuickref(projectDir);
390
+ const loaded = await resolveProjectQuickref(projectDir);
210
391
  if (!loaded.definition)
211
- throw new Error(loaded.errors.join('\n'));
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
- const loaded = await loadProjectQuickref(projectDir);
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: loaded.errors, drift: [] };
451
+ return { exists: false, errors: [], drift: [] };
265
452
  if (!loaded.definition)
266
- return { exists: true, errors: loaded.errors, drift: [] };
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');
@@ -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 (51 additional tools):
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cli",
3
- "version": "2026.8.5",
3
+ "version": "2026.8.8",
4
4
  "description": "Lightweight AIWG CLI for signed, versioned web-backed resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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