@aiwg/cli 2026.8.16 → 2026.8.17

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.
@@ -454,6 +454,7 @@ function recordForEntry(cwd, entry, graphName, dependencyGraph, privacy, schemaV
454
454
  ...(entry.searchTerms?.length
455
455
  ? { aiwg_search_terms: uniqueSorted(entry.searchTerms) }
456
456
  : {}),
457
+ ...(entry.script ? { aiwg_script: entry.script } : {}),
457
458
  },
458
459
  },
459
460
  }
@@ -494,7 +494,7 @@ async function handleBuild(args) {
494
494
  console.log('');
495
495
  console.log('Default behavior (no --graph): builds all graphs with defaultBuild: true');
496
496
  console.log('Multi-graph builds run by buildOrder/buildTier (refs → citations → bibliography before heavy graphs)');
497
- console.log(' Built-in defaults: project (always), codebase (skipped if src/test/tools absent)');
497
+ console.log(' Built-in defaults: project (always), codebase (auto-detects JavaScript/TypeScript and Python layouts)');
498
498
  console.log('');
499
499
  console.log('Examples:');
500
500
  console.log(' aiwg index build');
@@ -210,6 +210,7 @@ function artifactTypeFromRecord(record) {
210
210
  function entryFromRecord(record) {
211
211
  const indexedFrontmatter = record.search?.frontmatter ?? {};
212
212
  const indexedSearchTerms = indexedFrontmatter.aiwg_search_terms;
213
+ const indexedScript = indexedFrontmatter.aiwg_script;
213
214
  return {
214
215
  path: record.source.path,
215
216
  type: artifactTypeFromRecord(record),
@@ -245,6 +246,11 @@ function entryFromRecord(record) {
245
246
  kernel: typeof record.search?.frontmatter?.kernel === "boolean"
246
247
  ? record.search.frontmatter.kernel
247
248
  : undefined,
249
+ script: indexedScript && typeof indexedScript === "object"
250
+ && typeof indexedScript.entrypoint === "string"
251
+ && typeof indexedScript.runtime === "string"
252
+ ? indexedScript
253
+ : undefined,
248
254
  operationalState: record.operational_state,
249
255
  };
250
256
  }
@@ -195,6 +195,60 @@ export const BUILTIN_GRAPH_CONFIGS = {
195
195
  * @implements #426
196
196
  */
197
197
  export const GRAPH_CONFIGS = { ...BUILTIN_GRAPH_CONFIGS };
198
+ function freshBuiltinGraphConfig(name) {
199
+ const config = BUILTIN_GRAPH_CONFIGS[name];
200
+ return {
201
+ ...config,
202
+ scanDirs: [...config.scanDirs],
203
+ extensions: [...config.extensions],
204
+ };
205
+ }
206
+ /**
207
+ * Detect conventional Python layouts without treating every top-level folder
208
+ * as source. A Python project manifest activates `.py`/`.pyi` support; package
209
+ * roots are immediate directories containing `__init__.py`, plus the common
210
+ * `tests/` and `scripts/` roots when present.
211
+ */
212
+ function detectPythonCodebaseConfig(cwd, base) {
213
+ const hasPythonManifest = ['pyproject.toml', 'setup.py', 'setup.cfg']
214
+ .some((manifest) => fs.existsSync(path.join(cwd, manifest)));
215
+ if (!hasPythonManifest)
216
+ return base;
217
+ const detectedRoots = [];
218
+ for (const root of ['tests', 'scripts']) {
219
+ if (fs.existsSync(path.join(cwd, root)))
220
+ detectedRoots.push(root);
221
+ }
222
+ const excluded = new Set([
223
+ '.aiwg', '.git', '.github', '.venv', 'venv', 'node_modules',
224
+ 'src', 'test', 'tests', 'tools', 'scripts', 'docs', 'documentation',
225
+ ]);
226
+ try {
227
+ for (const entry of fs.readdirSync(cwd, { withFileTypes: true })) {
228
+ if (!entry.isDirectory() || excluded.has(entry.name) || entry.name.startsWith('.'))
229
+ continue;
230
+ if (fs.existsSync(path.join(cwd, entry.name, '__init__.py')))
231
+ detectedRoots.push(entry.name);
232
+ }
233
+ }
234
+ catch {
235
+ // Layout detection is best-effort; the immutable defaults still apply.
236
+ }
237
+ return {
238
+ ...base,
239
+ scanDirs: [...new Set([...base.scanDirs, ...detectedRoots])],
240
+ extensions: [...new Set([...base.extensions, '.py', '.pyi'])],
241
+ };
242
+ }
243
+ function applyBuiltinGraphOverride(base, override) {
244
+ if (!override)
245
+ return base;
246
+ return {
247
+ ...base,
248
+ scanDirs: override.scanDirs ? [...override.scanDirs] : base.scanDirs,
249
+ extensions: override.extensions ? [...override.extensions] : base.extensions,
250
+ };
251
+ }
198
252
  /**
199
253
  * Normalize metadataSupplements entries.
200
254
  *
@@ -400,6 +454,11 @@ export function loadModuleGraphConfigs(cwd, diagnostics) {
400
454
  * @implements #426 #726
401
455
  */
402
456
  export function loadUserGraphConfigs(cwd, diagnostics) {
457
+ // Built-ins are immutable in index.graphs, but codebase roots/extensions may
458
+ // be adapted through the explicitly bounded graphOverrides contract (#2123).
459
+ // Reset on every project load so a prior cwd cannot leak its override or
460
+ // detected Python package roots into a later build in the same process.
461
+ GRAPH_CONFIGS.codebase = detectPythonCodebaseConfig(cwd, freshBuiltinGraphConfig('codebase'));
403
462
  // Load module-declared graphs first (frameworks/addons)
404
463
  const moduleLoaded = loadModuleGraphConfigs(cwd, diagnostics);
405
464
  const loaded = [...moduleLoaded];
@@ -408,6 +467,7 @@ export function loadUserGraphConfigs(cwd, diagnostics) {
408
467
  // config.yaml is a
409
468
  // deprecated fallback so un-migrated corpora keep working.
410
469
  let graphs;
470
+ let graphOverrides;
411
471
  let fromDeprecatedYaml = false;
412
472
  // (a) Canonical: .aiwg/aiwg.config (JSON).
413
473
  try {
@@ -418,6 +478,9 @@ export function loadUserGraphConfigs(cwd, diagnostics) {
418
478
  const g = idx?.graphs;
419
479
  if (g && typeof g === 'object')
420
480
  graphs = g;
481
+ const overrides = idx?.graphOverrides;
482
+ if (overrides && typeof overrides === 'object')
483
+ graphOverrides = overrides;
421
484
  }
422
485
  }
423
486
  catch {
@@ -430,7 +493,7 @@ export function loadUserGraphConfigs(cwd, diagnostics) {
430
493
  });
431
494
  }
432
495
  // (b) Fallback: legacy .aiwg/config.yaml.
433
- if (!graphs) {
496
+ if (!graphs && !graphOverrides) {
434
497
  try {
435
498
  const configPath = projectAiwgPath(cwd, 'config.yaml');
436
499
  if (fs.existsSync(configPath)) {
@@ -441,12 +504,21 @@ export function loadUserGraphConfigs(cwd, diagnostics) {
441
504
  graphs = g;
442
505
  fromDeprecatedYaml = true;
443
506
  }
507
+ const overrides = idx?.graphOverrides;
508
+ if (overrides && typeof overrides === 'object') {
509
+ graphOverrides = overrides;
510
+ fromDeprecatedYaml = true;
511
+ }
444
512
  }
445
513
  }
446
514
  catch {
447
515
  // best-effort
448
516
  }
449
517
  }
518
+ const codebaseOverride = graphOverrides?.codebase;
519
+ if (codebaseOverride && typeof codebaseOverride === 'object' && !Array.isArray(codebaseOverride)) {
520
+ GRAPH_CONFIGS.codebase = applyBuiltinGraphOverride(GRAPH_CONFIGS.codebase, codebaseOverride);
521
+ }
450
522
  if (!graphs)
451
523
  return loaded;
452
524
  if (fromDeprecatedYaml && !yamlIndexDeprecationWarned) {
@@ -27,8 +27,10 @@ export const PROVIDER_CONFIGS = {
27
27
  },
28
28
  codex: {
29
29
  binary: 'codex',
30
- // Codex supports --full-auto (no approval prompts) and --approval-mode full-auto
31
- dangerousFlag: '--full-auto',
30
+ // Current Codex releases use the explicit bypass flag for unrestricted,
31
+ // non-interactive execution. Keep this centralized so every launcher maps
32
+ // AIWG's --dangerous option consistently.
33
+ dangerousFlag: '--dangerously-bypass-approvals-and-sandbox',
32
34
  name: 'OpenAI Codex',
33
35
  },
34
36
  hermes: {
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import path from 'node:path';
6
+ import { formatCockpitDoctor, runCockpitDoctor, } from '../../cockpit/doctor.js';
6
7
  export const COCKPIT_PACKAGE_NAME = '@aiwg/cockpit';
7
8
  export function cockpitHome() {
8
9
  return process.env.AIWG_COCKPIT_HOME || path.join(homedir(), '.aiwg', 'cockpit', 'package');
@@ -22,6 +23,16 @@ async function coreVersion(frameworkRoot) {
22
23
  function packageRoot(home = cockpitHome()) {
23
24
  return path.join(home, 'node_modules', '@aiwg', 'cockpit');
24
25
  }
26
+ function valueAfter(args, flag) {
27
+ const index = args.indexOf(flag);
28
+ return index >= 0 ? args[index + 1] : undefined;
29
+ }
30
+ function doctorFormat(args) {
31
+ if (args.includes('--json'))
32
+ return 'json';
33
+ const value = valueAfter(args, '--format');
34
+ return value === 'json' || value === 'markdown' ? value : 'text';
35
+ }
25
36
  export async function resolveCockpitInstall(home = cockpitHome()) {
26
37
  const root = packageRoot(home);
27
38
  const pkg = await readJson(path.join(root, 'package.json'));
@@ -95,6 +106,36 @@ export const cockpitHandler = {
95
106
  const install = await resolveCockpitInstall();
96
107
  const version = await coreVersion(ctx.frameworkRoot);
97
108
  const autoInstall = ctx.args.includes('--install') || ctx.args.includes('--yes') || ctx.args.includes('-y');
109
+ if (ctx.args[0] === 'doctor' || ctx.args.includes('--doctor')) {
110
+ const sourcePackageRoot = path.join(ctx.frameworkRoot, 'apps', 'cockpit');
111
+ const sourcePackage = !install.installed ? await readJson(path.join(sourcePackageRoot, 'package.json')) : null;
112
+ const doctorInstall = install.installed ? install : {
113
+ installed: Boolean(sourcePackage),
114
+ version: typeof sourcePackage?.version === 'string' ? sourcePackage.version : undefined,
115
+ packageRoot: sourcePackageRoot,
116
+ };
117
+ const topologyValue = valueAfter(ctx.args, '--topology');
118
+ const topology = topologyValue === 'ssh-local' || topologyValue === 'ssh-reverse'
119
+ ? topologyValue
120
+ : 'same-host';
121
+ const report = await runCockpitDoctor({
122
+ coreVersion: version,
123
+ cockpitInstalled: doctorInstall.installed,
124
+ cockpitVersion: doctorInstall.version,
125
+ cockpitPackageRoot: doctorInstall.packageRoot,
126
+ topology,
127
+ cockpitHost: valueAfter(ctx.args, '--cockpit-host'),
128
+ executorHost: valueAfter(ctx.args, '--executor-host'),
129
+ expectedExecutorVersion: valueAfter(ctx.args, '--executor-version'),
130
+ forwardEndpoint: valueAfter(ctx.args, '--forward-endpoint'),
131
+ runtimeFile: valueAfter(ctx.args, '--runtime-file'),
132
+ });
133
+ return {
134
+ exitCode: report.status === 'blocked' ? 1 : 0,
135
+ message: formatCockpitDoctor(report, doctorFormat(ctx.args)),
136
+ rawOutput: true,
137
+ };
138
+ }
98
139
  if (ctx.args.includes('--status')) {
99
140
  return {
100
141
  exitCode: 0,
@@ -91,7 +91,7 @@ function displayHelp() {
91
91
  ]);
92
92
  helpGroup('FEATURES', [
93
93
  ['features', 'Show optional feature install status'],
94
- ['cockpit [--status]', 'Launch the opt-in AIWG Cockpit control plane'],
94
+ ['cockpit [--status|doctor]', 'Launch Cockpit or diagnose its executor topology'],
95
95
  ]);
96
96
  helpGroup('VALIDATION', [
97
97
  ['validate-metadata [path]', 'Validate AIWG component metadata (defaults to agentic/code)'],
@@ -278,7 +278,8 @@ LFD LOOP CONTROLS (hard cumulative ceilings; loop stops with a best-output repor
278
278
  Spawnable: claude, opencode, codex, hermes
279
279
  --dangerous Enable unrestricted mode for the selected provider.
280
280
  Passes the provider's native flag (e.g. --dangerously-skip-permissions
281
- for claude/opencode, --full-auto for codex). No effect if the
281
+ for claude/opencode,
282
+ --dangerously-bypass-approvals-and-sandbox for codex). No effect if the
282
283
  provider doesn't have a dangerous mode flag.
283
284
  --params "<args>" Pass arbitrary args verbatim to the agent binary.
284
285
  Appended after all other flags. Quoted segments preserved.
@@ -123,7 +123,8 @@ AGENT OPTIONS:
123
123
  IDE-integrated (guidance only): copilot, cursor, factory, warp, windsurf
124
124
  --dangerous Enable unrestricted mode (skips permission prompts).
125
125
  Maps to the provider's native flag — e.g. claude gets
126
- --dangerously-skip-permissions, codex gets --full-auto.
126
+ --dangerously-skip-permissions, codex gets
127
+ --dangerously-bypass-approvals-and-sandbox.
127
128
  Has no effect on providers that don't support it.
128
129
  --params "<args>" Pass arbitrary args directly to the agent binary.
129
130
  Appended verbatim after all other flags. You are
@@ -52,7 +52,7 @@ import { generate as generateContextFiles, discoverDeployedArtifacts, } from '..
52
52
  import { verifyModelWrapperDeployment } from '../../models/wrapper-deployment.js';
53
53
  import { loadGraphIndexFile } from '../../artifacts/index-reader.js';
54
54
  import { aggregateUseDeploymentResult, buildDryRunUseResult, renderUseDeploymentResult, verifyProviderDeployment, } from '../services/deployment-verification.js';
55
- import { finalizeProviderTransformationReceipt, sourceVerificationsFromSignedWebRelease, } from '../../providers/transformation-receipt-integration.js';
55
+ import { finalizeProviderTransformationReceipt, providerReceiptHasLocalSources, sourceVerificationsFromSignedWebRelease, } from '../../providers/transformation-receipt-integration.js';
56
56
  import { loadResourceTrustRootFile, resolveWebRelease, } from '../../resources/web-release.js';
57
57
  import { createResourceCredentialProvider } from '../../auth/resource-credentials.js';
58
58
  /**
@@ -75,10 +75,16 @@ function providerReceiptWebReleaseOptions() {
75
75
  : {}),
76
76
  };
77
77
  }
78
- async function signedProviderSourceVerifications(options) {
78
+ function releaseResourceUnavailable(error) {
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ return /fetch failed|request timed out|no fetch implementation/i.test(message);
81
+ }
82
+ export async function resolveProviderReceiptSource(options) {
83
+ if (await providerReceiptHasLocalSources(options))
84
+ return { sourceDisposition: 'local-source' };
79
85
  const versionInfo = await getVersionInfo();
80
86
  if (versionInfo.devMode)
81
- return undefined;
87
+ return { sourceDisposition: 'local-source' };
82
88
  const releaseOptions = providerReceiptWebReleaseOptions();
83
89
  let release;
84
90
  try {
@@ -90,15 +96,24 @@ async function signedProviderSourceVerifications(options) {
90
96
  // Protected production resources require the authenticated release
91
97
  // credential. A configured alternate endpoint may intentionally be public.
92
98
  if (!token && releaseOptions.baseUrl === undefined)
93
- return undefined;
94
- release = await resolveWebRelease({
95
- ...releaseOptions,
96
- selector: versionInfo.version,
97
- credentialProvider: async () => token,
98
- });
99
+ return { sourceDisposition: 'source-unavailable' };
100
+ try {
101
+ release = await resolveWebRelease({
102
+ ...releaseOptions,
103
+ selector: versionInfo.version,
104
+ credentialProvider: async () => token,
105
+ });
106
+ }
107
+ catch (error) {
108
+ if (releaseResourceUnavailable(error))
109
+ return { sourceDisposition: 'source-unavailable' };
110
+ throw error;
111
+ }
99
112
  }
100
113
  const verifications = await sourceVerificationsFromSignedWebRelease(options, release);
101
- return Object.keys(verifications).length > 0 ? verifications : undefined;
114
+ return Object.keys(verifications).length > 0
115
+ ? { sourceVerifications: verifications }
116
+ : { sourceDisposition: 'verification-failed' };
102
117
  }
103
118
  /**
104
119
  * Framework name to deploy mode mapping.
@@ -1005,6 +1020,79 @@ async function countBundleDeployedArtifacts(bundlePath, target, provider) {
1005
1020
  rules: await countDeployedBundleFiles(bundlePath, 'rules', target, paths.rules, ['.md', '.mdc']),
1006
1021
  };
1007
1022
  }
1023
+ const SKILL_SUPPORT_REFERENCE = /(?:^|[\s`('"\[])((?:templates|references|scripts|assets)\/[A-Za-z0-9._@/+\-]+)(?=$|[\s`)'"\],:;])/gm;
1024
+ /**
1025
+ * Project skill-relative support files may live beside the skill or at the
1026
+ * bundle root (plugin payloads commonly share report templates). Materialize
1027
+ * only paths explicitly named by SKILL.md, and fail closed on missing or
1028
+ * unsafe sources so a deployed instruction can never point at absent assets.
1029
+ */
1030
+ async function reconcileProjectLocalSkillAssets(bundlePath, target, provider) {
1031
+ const skillsRoot = path.join(bundlePath, 'skills');
1032
+ let skillDirs;
1033
+ try {
1034
+ skillDirs = (await fs.readdir(skillsRoot, { withFileTypes: true }))
1035
+ .filter(entry => entry.isDirectory())
1036
+ .map(entry => entry.name);
1037
+ }
1038
+ catch {
1039
+ return;
1040
+ }
1041
+ const paths = getProviderPaths(provider);
1042
+ const kernelSkillsPath = getProviderKernelSkillsPath(provider);
1043
+ const deployRoots = [...new Set([
1044
+ paths.skills,
1045
+ kernelSkillsPath,
1046
+ ].filter((value) => Boolean(value)).map(value => resolveDeployPath(target, value)))];
1047
+ for (const skillName of skillDirs) {
1048
+ const sourceSkillDir = path.join(skillsRoot, skillName);
1049
+ const sourceSkillMd = path.join(sourceSkillDir, 'SKILL.md');
1050
+ let content;
1051
+ try {
1052
+ content = await fs.readFile(sourceSkillMd, 'utf8');
1053
+ }
1054
+ catch {
1055
+ continue;
1056
+ }
1057
+ const references = [...new Set([...content.matchAll(SKILL_SUPPORT_REFERENCE)].map(match => match[1]))];
1058
+ for (const relative of references) {
1059
+ const normalized = path.posix.normalize(relative);
1060
+ if (normalized !== relative || normalized.startsWith('../') || path.isAbsolute(normalized)) {
1061
+ throw new Error(`unsafe skill support reference '${relative}' in ${sourceSkillMd}`);
1062
+ }
1063
+ const candidates = [path.join(sourceSkillDir, normalized), path.join(bundlePath, normalized)];
1064
+ let source;
1065
+ for (const candidate of candidates) {
1066
+ try {
1067
+ const stat = await fs.lstat(candidate);
1068
+ if (stat.isFile() && !stat.isSymbolicLink()) {
1069
+ source = candidate;
1070
+ break;
1071
+ }
1072
+ }
1073
+ catch { /* try bundle-root fallback */ }
1074
+ }
1075
+ if (!source)
1076
+ throw new Error(`missing skill support asset '${relative}' referenced by ${sourceSkillMd}`);
1077
+ let deployedSkillRoot;
1078
+ for (const root of deployRoots) {
1079
+ // The deployer may select the bulk or kernel tier; use the tier that
1080
+ // actually contains this skill's transformed SKILL.md.
1081
+ if (await fileExists(path.join(root, skillName, 'SKILL.md'))) {
1082
+ deployedSkillRoot = root;
1083
+ break;
1084
+ }
1085
+ }
1086
+ if (!deployedSkillRoot)
1087
+ throw new Error(`deployed skill '${skillName}' not found while reconciling support assets`);
1088
+ const destination = path.join(deployedSkillRoot, skillName, ...normalized.split('/'));
1089
+ await fs.mkdir(path.dirname(destination), { recursive: true });
1090
+ await fs.copyFile(source, destination);
1091
+ const mode = (await fs.stat(source)).mode & 0o777;
1092
+ await fs.chmod(destination, mode);
1093
+ }
1094
+ }
1095
+ }
1008
1096
  /**
1009
1097
  * Deploy a single project-local bundle to one provider via deploy-agents.mjs.
1010
1098
  * Runs the same script and flags used for upstream addons, with the bundle
@@ -1072,6 +1160,15 @@ async function deployOneProjectLocalBundle(opts) {
1072
1160
  env: { AIWG_ROOT: frameworkRoot },
1073
1161
  });
1074
1162
  exitCode = result.exitCode;
1163
+ if (exitCode === 0 && !dryRun) {
1164
+ try {
1165
+ await reconcileProjectLocalSkillAssets(bundle.artifactPath, target, provider);
1166
+ }
1167
+ catch (error) {
1168
+ ui.warn(`Project-local skill asset deployment failed for '${bundle.id}': ${error.message}`);
1169
+ exitCode = 1;
1170
+ }
1171
+ }
1075
1172
  }
1076
1173
  if (exitCode === 0 && cliCommandCount > 0) {
1077
1174
  try {
@@ -2017,10 +2114,18 @@ export class UseHandler {
2017
2114
  scope: effectiveScope,
2018
2115
  requestedBundles: [requestedBundle],
2019
2116
  };
2020
- const sourceVerifications = await signedProviderSourceVerifications(receiptOptions);
2021
- await finalizeProviderTransformationReceipt({ ...receiptOptions, sourceVerifications });
2117
+ const sourceResolution = await resolveProviderReceiptSource(receiptOptions);
2118
+ await finalizeProviderTransformationReceipt({ ...receiptOptions, ...sourceResolution });
2022
2119
  }
2023
2120
  catch (error) {
2121
+ await finalizeProviderTransformationReceipt({
2122
+ projectRoot: projectDir,
2123
+ frameworkRoot,
2124
+ provider,
2125
+ scope: effectiveScope,
2126
+ requestedBundles: [requestedBundle],
2127
+ sourceDisposition: 'verification-failed',
2128
+ }).catch(() => undefined);
2024
2129
  originalConsole.warn(`Provider receipt finalization failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
2025
2130
  }
2026
2131
  }
@@ -12,6 +12,23 @@ import { createScriptRunner } from './script-runner.js';
12
12
  import { getFrameworkRoot } from '../../channel/manager.mjs';
13
13
  import { maybePrintCommunityFooter } from '../../community/footer.js';
14
14
  import { buildDeploymentStatusProbe } from '../services/deployment-verification.js';
15
+ import { detectScope } from '../scope-resolver.js';
16
+ function statusProjectRoot(args, fallback) {
17
+ const valueFlags = new Set(['--scope', '--provider', '--bundle']);
18
+ for (let index = 0; index < args.length; index += 1) {
19
+ if (valueFlags.has(args[index])) {
20
+ index += 1;
21
+ continue;
22
+ }
23
+ if (!args[index].startsWith('-'))
24
+ return args[index];
25
+ }
26
+ return fallback;
27
+ }
28
+ function statusFlagValue(args, flag) {
29
+ const index = args.indexOf(flag);
30
+ return index >= 0 ? args[index + 1] : undefined;
31
+ }
15
32
  /**
16
33
  * Handler for workspace status command
17
34
  *
@@ -31,8 +48,13 @@ export const statusHandler = {
31
48
  aliases: ['-status', '--status'],
32
49
  async execute(ctx) {
33
50
  if (ctx.args.includes('--probe')) {
34
- const projectRoot = ctx.args.find((arg) => !arg.startsWith('-')) ?? ctx.cwd ?? process.cwd();
35
- const probe = await buildDeploymentStatusProbe(projectRoot, ctx.frameworkRoot);
51
+ const projectRoot = statusProjectRoot(ctx.args, ctx.cwd ?? process.cwd());
52
+ const scope = detectScope(ctx.args);
53
+ const probe = await buildDeploymentStatusProbe(projectRoot, ctx.frameworkRoot, {
54
+ scope,
55
+ provider: statusFlagValue(ctx.args, '--provider'),
56
+ bundle: statusFlagValue(ctx.args, '--bundle'),
57
+ });
36
58
  return {
37
59
  exitCode: probe.status === 'needs-repair' ? 1 : 0,
38
60
  message: JSON.stringify(probe, null, 2),
@@ -142,6 +142,13 @@ const RECEIPT_DRIFT_POLICY = {
142
142
  severity: 'advisory',
143
143
  remediation: 'Re-run the same aiwg use command to establish provider transformation evidence.',
144
144
  },
145
+ 'policy-exempt': {
146
+ severity: 'info',
147
+ },
148
+ 'source-evidence-unavailable': {
149
+ severity: 'advisory',
150
+ remediation: 'Run aiwg auth login, then aiwg versions resolve <installed-version> once online to warm the verified cache; re-run the same aiwg use command afterward.',
151
+ },
145
152
  };
146
153
  async function collectProviderReceiptFindings(options, provider) {
147
154
  try {
@@ -497,8 +504,8 @@ export async function verifyConfiguredDeployments(projectRoot, filters = {}, fra
497
504
  }
498
505
  return aggregateUseDeploymentResult({ projectRoot, frameworkRoot, scope: filters.scope ?? 'project', requestedBundles: bundles, providers: results });
499
506
  }
500
- export async function buildDeploymentStatusProbe(projectRoot, frameworkRoot = process.env.AIWG_ROOT || projectRoot) {
501
- const result = await verifyConfiguredDeployments(projectRoot, {}, frameworkRoot);
507
+ export async function buildDeploymentStatusProbe(projectRoot, frameworkRoot = process.env.AIWG_ROOT || projectRoot, filters = {}) {
508
+ const result = await verifyConfiguredDeployments(projectRoot, filters, frameworkRoot);
502
509
  const notConfigured = result.requestedBundles.length === 0
503
510
  && result.findings.length > 0
504
511
  && result.findings.every((item) => item.id === 'deployment-not-configured');
@@ -0,0 +1,257 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile, stat } from 'node:fs/promises';
4
+ import { homedir, hostname, platform } from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { pathToFileURL } from 'node:url';
8
+ const execFile = promisify(execFileCallback);
9
+ export const COCKPIT_DOCTOR_SCHEMA = 'aiwg.cockpit-doctor/v1';
10
+ function safeText(value) {
11
+ return String(value ?? '')
12
+ .replace(/((?:bearer|token|nonce|secret|password|authorization)\s*)[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
13
+ .replace(/([?#](?:token|nonce|secret|password|authorization)=)[^&#\s]+/gi, '$1[redacted]')
14
+ .slice(0, 240);
15
+ }
16
+ function evidence(values) {
17
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [
18
+ key,
19
+ typeof value === 'string' ? safeText(value) : value,
20
+ ]));
21
+ }
22
+ function row(id, status, code, summary, values, recovery) {
23
+ return { id, status, code, summary, evidence: evidence(values), recovery: status === 'pass' ? null : recovery };
24
+ }
25
+ function overall(rows) {
26
+ if (rows.some(item => item.status === 'blocked'))
27
+ return 'blocked';
28
+ if (rows.some(item => item.status === 'warn'))
29
+ return 'warn';
30
+ return 'pass';
31
+ }
32
+ function isLoopback(host) {
33
+ return ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(host.toLowerCase());
34
+ }
35
+ function looksMock(body) {
36
+ const text = JSON.stringify({
37
+ service: body?.service,
38
+ name: body?.name,
39
+ executor: body?.executor,
40
+ implementation: body?.implementation,
41
+ mock: body?.mock,
42
+ }).toLowerCase();
43
+ return body?.mock === true || /mock[-_ ]?executor|cockpit[-_ ]?mock/.test(text);
44
+ }
45
+ function listenerRows(stdout) {
46
+ const lines = stdout.split(/\r?\n/).filter(line => /:(8120|8121|8122|8140)\b/.test(line));
47
+ const publicLines = lines.filter(line => /(?:0\.0\.0\.0|\[::\]|\*):(8120|8121|8122|8140)\b/.test(line));
48
+ if (publicLines.length > 0)
49
+ return row('listeners', 'blocked', 'public_bind', 'A Cockpit or executor listener is publicly bound.', { checked_ports: '8120-8122,8140', public_listener_count: publicLines.length }, 'Bind the named service to 127.0.0.1 and restart only that service.');
50
+ return row('listeners', lines.length > 0 ? 'pass' : 'warn', lines.length > 0 ? 'loopback_only' : 'listeners_not_observed', lines.length > 0 ? 'Observed application listeners are loopback-only.' : 'No application listeners were observed locally.', { checked_ports: '8120-8122,8140', observed_listener_count: lines.length }, 'Start the expected user services, then rerun the doctor.');
51
+ }
52
+ export function defaultCockpitDoctorProbes(cockpitPackageRoot) {
53
+ return {
54
+ async readRuntime(file) {
55
+ const [raw, info] = await Promise.all([readFile(file, 'utf8'), stat(file)]);
56
+ const record = JSON.parse(raw);
57
+ if (!record.token && record.token_ref && cockpitPackageRoot) {
58
+ try {
59
+ const keychain = await import(pathToFileURL(path.join(cockpitPackageRoot, 'shell-core', 'keychain.mjs')).href);
60
+ record.token = await keychain.readCockpitToken(record.token_ref);
61
+ }
62
+ catch { /* authentication row reports a focused failure */ }
63
+ }
64
+ return { record, mode: info.mode & 0o777, owned: info.uid === process.getuid?.() };
65
+ },
66
+ async fetchJson(url, headers = {}) {
67
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(2500) });
68
+ let body = null;
69
+ try {
70
+ body = await response.json();
71
+ }
72
+ catch {
73
+ body = null;
74
+ }
75
+ return { status: response.status, body };
76
+ },
77
+ async command(command, args) {
78
+ try {
79
+ const result = await execFile(command, args, { timeout: 3000, maxBuffer: 1024 * 1024 });
80
+ return { ok: true, stdout: result.stdout };
81
+ }
82
+ catch (error) {
83
+ return { ok: false, stdout: typeof error?.stdout === 'string' ? error.stdout : '' };
84
+ }
85
+ },
86
+ pathExists: existsSync,
87
+ hostName: hostname,
88
+ platform,
89
+ };
90
+ }
91
+ export async function runCockpitDoctor(options, probes = defaultCockpitDoctorProbes(options.cockpitPackageRoot)) {
92
+ const topology = options.topology ?? 'same-host';
93
+ const cockpitHost = options.cockpitHost ?? probes.hostName();
94
+ const executorHost = options.executorHost ?? (topology === 'same-host' ? cockpitHost : 'unspecified');
95
+ const rows = [];
96
+ rows.push(options.cockpitInstalled
97
+ ? row('package', options.cockpitVersion === options.coreVersion ? 'pass' : 'blocked', options.cockpitVersion === options.coreVersion ? 'version_lockstep' : 'version_skew', options.cockpitVersion === options.coreVersion ? 'Cockpit and AIWG versions match.' : 'Cockpit and AIWG versions differ.', {
98
+ core_version: options.coreVersion,
99
+ cockpit_version: options.cockpitVersion ?? 'unknown',
100
+ source: options.cockpitPackageRoot?.includes('node_modules') ? 'managed-package' : 'source-workspace',
101
+ location: options.cockpitPackageRoot?.includes('node_modules')
102
+ ? '$AIWG_COCKPIT_HOME/node_modules/@aiwg/cockpit'
103
+ : 'apps/cockpit',
104
+ }, 'Run `aiwg use cockpit` to install the core-matched Cockpit package.')
105
+ : row('package', 'blocked', 'cockpit_not_installed', 'Cockpit is not installed.', { core_version: options.coreVersion, cockpit_version: null, source: 'absent' }, 'Run `aiwg use cockpit`.'));
106
+ const runtimeFile = options.runtimeFile ?? path.join(homedir(), '.aiwg', 'cockpit', 'runtime', 'bridge.json');
107
+ let runtime = null;
108
+ try {
109
+ const observed = await probes.readRuntime(runtimeFile);
110
+ runtime = observed.record;
111
+ const secure = observed.mode === 0o600 && observed.owned && Boolean(runtime.port) && Boolean(runtime.token || runtime.token_ref);
112
+ rows.push(row('bridge-runtime', secure ? 'pass' : 'blocked', secure ? 'runtime_secure' : 'runtime_insecure', secure ? 'Bridge runtime metadata has the required ownership and mode.' : 'Bridge runtime metadata is missing or insecure.', { mode: observed.mode.toString(8), owned_by_current_user: observed.owned, credential_present: Boolean(runtime.token || runtime.token_ref), port: runtime.port ?? null }, 'Stop Cockpit, restrict the runtime directory to 0700 and bridge.json to 0600, then restart Cockpit.'));
113
+ }
114
+ catch {
115
+ rows.push(row('bridge-runtime', 'blocked', 'runtime_missing', 'Bridge runtime metadata is unavailable.', { runtime_file: 'default-cockpit-runtime', credential_present: false }, 'Start Cockpit as the intended user, then rerun the doctor.'));
116
+ }
117
+ let bridgeHealth = null;
118
+ if (runtime?.port) {
119
+ const base = `http://127.0.0.1:${runtime.port}`;
120
+ try {
121
+ const live = await probes.fetchJson(`${base}/healthz`);
122
+ if (live.status < 200 || live.status >= 300)
123
+ throw new Error('not live');
124
+ const token = typeof runtime.token === 'string' ? runtime.token : '';
125
+ const authed = await probes.fetchJson(`${base}/api/health`, token ? { authorization: `Bearer ${token}` } : {});
126
+ if ([401, 403].includes(authed.status)) {
127
+ rows.push(row('bridge', 'blocked', 'bridge_unauthenticated', 'Bridge is reachable but authentication failed.', { reachable: true, authenticated: false }, 'Restart the Bridge to mint fresh runtime credentials, then rerun the doctor.'));
128
+ }
129
+ else if (authed.status >= 200 && authed.status < 300) {
130
+ bridgeHealth = authed.body;
131
+ rows.push(row('bridge', 'pass', 'bridge_authenticated', 'Bridge is reachable and authenticated.', { reachable: true, authenticated: true, port: runtime.port }, null));
132
+ }
133
+ else
134
+ throw new Error('unexpected response');
135
+ }
136
+ catch {
137
+ rows.push(row('bridge', 'blocked', 'bridge_unreachable', 'Bridge is not reachable through its runtime endpoint.', { reachable: false, authenticated: false, port: runtime.port }, 'Restart the Cockpit user service and rerun the doctor.'));
138
+ }
139
+ }
140
+ else {
141
+ rows.push(row('bridge', 'blocked', 'bridge_unreachable', 'Bridge endpoint is unknown.', { reachable: false, authenticated: false }, 'Start Cockpit as the intended user, then rerun the doctor.'));
142
+ }
143
+ const executorUrlText = typeof bridgeHealth?.executor_url === 'string' ? bridgeHealth.executor_url : '';
144
+ if (executorUrlText) {
145
+ let executorUrl = null;
146
+ try {
147
+ executorUrl = new URL(executorUrlText);
148
+ }
149
+ catch { /* reported as unreachable */ }
150
+ const hostMatches = executorUrl && (topology === 'same-host'
151
+ ? isLoopback(executorUrl.hostname)
152
+ : executorHost !== 'unspecified' && executorUrl.hostname === executorHost);
153
+ if (!hostMatches) {
154
+ rows.push(row('executor', 'blocked', 'wrong_host', 'Bridge targets a host inconsistent with the declared topology.', { topology, expected_host: executorHost, configured_host: executorUrl?.hostname ?? 'invalid' }, 'Correct the declared executor host or the Bridge executor URL; do not create a tunnel until they agree.'));
155
+ }
156
+ else {
157
+ try {
158
+ const bridgeExecutor = bridgeHealth?.executor;
159
+ const deep = bridgeExecutor && typeof bridgeExecutor === 'object'
160
+ ? { status: bridgeExecutor.status === 'ok' ? 200 : 503, body: bridgeExecutor }
161
+ : await probes.fetchJson(`${executorUrlText.replace(/\/$/, '')}/healthz/deep`);
162
+ if ([401, 403].includes(deep.status)) {
163
+ rows.push(row('executor', 'blocked', 'executor_unauthenticated', 'Executor is reachable but authentication failed.', { reachable: true, authenticated: false, host_matches: true }, 'Configure the Bridge executor credential file with mode 0600, then restart the Bridge.'));
164
+ }
165
+ else if (deep.status < 200 || deep.status >= 300)
166
+ throw new Error('deep health failed');
167
+ else if (deep.body?.real_executor === false || looksMock(deep.body)) {
168
+ rows.push(row('executor', 'blocked', 'mock_executor', 'Configured executor identifies as a mock.', { reachable: true, authenticated: true, real_executor: false }, 'Point the Bridge at the real Agentic Sandbox executor and restart it without mock allowance.'));
169
+ }
170
+ else {
171
+ const observedVersion = safeText(deep.body?.version ?? deep.body?.commit ?? 'unknown');
172
+ const skew = Boolean(options.expectedExecutorVersion && observedVersion !== options.expectedExecutorVersion);
173
+ rows.push(row('executor', skew ? 'blocked' : 'pass', skew ? 'version_skew' : 'executor_ready', skew ? 'Executor identity does not match the expected version.' : 'Real executor deep health is ready.', { reachable: true, authenticated: true, real_executor: true, version_or_commit: observedVersion, auth_configured: Boolean(bridgeHealth.executor_auth_configured) }, 'Install or select the expected Agentic Sandbox release, then restart the executor and Bridge.'));
174
+ }
175
+ }
176
+ catch {
177
+ rows.push(row('executor', 'blocked', 'executor_unreachable', 'Executor deep health is unreachable.', { reachable: false, host_matches: true }, 'Start the executor on the declared host and verify only its required transport before retrying.'));
178
+ }
179
+ }
180
+ }
181
+ else {
182
+ rows.push(row('executor', 'blocked', 'executor_unreachable', 'Bridge did not report an executor endpoint.', { reachable: false, host_matches: false }, 'Configure the Bridge executor URL and restart the Bridge.'));
183
+ }
184
+ rows.push(row('host-runtime', 'pass', 'host_ready', 'Host runtime is available.', { platform: probes.platform(), node: process.version }, null));
185
+ const docker = await probes.command('docker', ['info', '--format', '{{json .ServerVersion}}']);
186
+ rows.push(row('docker-runtime', docker.ok ? 'pass' : 'warn', docker.ok ? 'docker_ready' : 'docker_unavailable', docker.ok ? 'Docker runtime is reachable.' : 'Docker runtime is not reachable.', { ready: docker.ok }, 'Start Docker or choose the host runtime tier; host readiness is independent.'));
187
+ const kvm = probes.pathExists('/dev/kvm');
188
+ rows.push(row('vm-runtime', kvm ? 'pass' : 'warn', kvm ? 'kvm_ready' : 'kvm_unavailable', kvm ? 'KVM device is available.' : 'VM readiness is not claimed because KVM is unavailable.', { kvm_available: kvm }, 'Enable KVM access only if the VM runtime tier is required.'));
189
+ const listeners = await probes.command('ss', ['-ltn']);
190
+ rows.push(listeners.ok ? listenerRows(listeners.stdout) : row('listeners', 'warn', 'listener_inspection_unavailable', 'Listener inspection is unavailable.', { checked_ports: '8120-8122,8140' }, 'Install `ss` support or inspect these listeners locally, then rerun the doctor.'));
191
+ const cockpitEnabled = await probes.command('systemctl', ['--user', 'is-enabled', 'aiwg-cockpit.service']);
192
+ const executorEnabled = await probes.command('systemctl', ['--user', 'is-enabled', 'agentic-sandbox.service']);
193
+ const cockpitUnit = await probes.command('systemctl', ['--user', 'show', 'aiwg-cockpit.service', '--property=ActiveState,After,Requires,Restart']);
194
+ const executorUnit = await probes.command('systemctl', ['--user', 'show', 'agentic-sandbox.service', '--property=ActiveState,Restart']);
195
+ const linger = await probes.command('loginctl', ['show-user', process.env.USER ?? '', '--property=Linger', '--value']);
196
+ const dependencyOrdered = topology !== 'same-host' || /(?:After|Requires)=.*agentic-sandbox\.service/.test(cockpitUnit.stdout);
197
+ const restartReady = /Restart=(?:on-failure|always)/.test(cockpitUnit.stdout)
198
+ && /Restart=(?:on-failure|always)/.test(executorUnit.stdout);
199
+ const active = /ActiveState=active/.test(cockpitUnit.stdout) && /ActiveState=active/.test(executorUnit.stdout);
200
+ const persistent = cockpitEnabled.ok && executorEnabled.ok && linger.ok && linger.stdout.trim() === 'yes'
201
+ && cockpitUnit.ok && executorUnit.ok && dependencyOrdered && restartReady && active;
202
+ rows.push(row('persistence', persistent ? 'pass' : 'warn', persistent ? 'user_systemd_ready' : 'user_systemd_incomplete', persistent ? 'User services and linger support persistence.' : 'User-service persistence is incomplete or unavailable.', {
203
+ cockpit_enabled: cockpitEnabled.ok,
204
+ executor_enabled: executorEnabled.ok,
205
+ units_active: active,
206
+ linger_enabled: linger.stdout.trim() === 'yes',
207
+ dependency_order_ready: dependencyOrdered,
208
+ restart_recovery_ready: restartReady,
209
+ }, 'Enable only the Cockpit and executor user units, then enable linger for the service account.'));
210
+ const topologyValid = topology === 'same-host' ? cockpitHost === executorHost : executorHost !== 'unspecified';
211
+ if (topology !== 'same-host' && options.forwardEndpoint) {
212
+ try {
213
+ const endpoint = new URL(options.forwardEndpoint);
214
+ const forward = await probes.fetchJson(`${options.forwardEndpoint.replace(/\/$/, '')}/healthz`);
215
+ const safeEndpoint = `${endpoint.protocol}//${endpoint.hostname}:${endpoint.port || 'default'}`;
216
+ rows.push(row('ssh-forward', forward.status >= 200 && forward.status < 300 ? 'pass' : 'blocked', forward.status >= 200 && forward.status < 300 ? 'forward_ready' : 'forward_unreachable', forward.status >= 200 && forward.status < 300 ? 'Declared SSH forward reaches the expected service.' : 'Declared SSH forward is unreachable.', { kind: topology, endpoint: safeEndpoint, reachable: forward.status >= 200 && forward.status < 300 }, 'Correct only the declared SSH forward endpoint, then rerun the doctor.'));
217
+ }
218
+ catch {
219
+ rows.push(row('ssh-forward', 'blocked', 'forward_unreachable', 'Declared SSH forward is invalid or unreachable.', { kind: topology, reachable: false }, 'Correct only the declared SSH forward endpoint, then rerun the doctor.'));
220
+ }
221
+ }
222
+ rows.push(row('topology', topologyValid ? 'pass' : 'blocked', topologyValid ? 'topology_declared' : 'topology_ambiguous', topologyValid ? 'Cockpit, executor, and operator access topology is explicit.' : 'Executor host is not explicitly declared.', { kind: topology, cockpit_host: cockpitHost, executor_host: executorHost, operator_access: topology === 'same-host' ? 'local' : topology === 'ssh-local' ? 'ssh-local-forward' : 'ssh-reverse-forward' }, 'Declare the executor host before generating or validating any forward.'));
223
+ return {
224
+ schema: COCKPIT_DOCTOR_SCHEMA,
225
+ generated_at: (options.now ?? (() => new Date()))().toISOString(),
226
+ topology: {
227
+ kind: topology,
228
+ cockpit_host: safeText(cockpitHost),
229
+ executor_host: safeText(executorHost),
230
+ operator_access: topology === 'same-host' ? 'local' : topology === 'ssh-local' ? 'ssh-local-forward' : 'ssh-reverse-forward',
231
+ },
232
+ status: overall(rows),
233
+ rows,
234
+ };
235
+ }
236
+ export function formatCockpitDoctor(report, format) {
237
+ if (format === 'json')
238
+ return JSON.stringify(report, null, 2);
239
+ if (format === 'markdown') {
240
+ return [
241
+ `# Cockpit Connection Doctor`,
242
+ '',
243
+ `Status: **${report.status}** `,
244
+ `Topology: \`${report.topology.kind}\``,
245
+ '',
246
+ '| Check | Status | Code | Summary | Recovery |',
247
+ '|---|---|---|---|---|',
248
+ ...report.rows.map(item => `| ${item.id} | ${item.status} | ${item.code} | ${item.summary} | ${item.recovery ?? '—'} |`),
249
+ ].join('\n');
250
+ }
251
+ return [
252
+ `Cockpit connection doctor: ${report.status}`,
253
+ `Topology: ${report.topology.kind} (${report.topology.cockpit_host} -> ${report.topology.executor_host})`,
254
+ ...report.rows.map(item => `${item.status.toUpperCase().padEnd(7)} ${item.id.padEnd(16)} ${item.code}: ${item.summary}${item.recovery ? ` Recovery: ${item.recovery}` : ''}`),
255
+ ].join('\n');
256
+ }
257
+ //# sourceMappingURL=doctor.js.map
@@ -158,13 +158,46 @@ export function validateIndexConfig(index) {
158
158
  if (typeof index !== 'object' || Array.isArray(index)) {
159
159
  return ['index: must be an object'];
160
160
  }
161
- const graphs = index.graphs;
161
+ const indexObject = index;
162
+ const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
163
+ const graphOverrides = indexObject.graphOverrides;
164
+ if (graphOverrides !== undefined) {
165
+ if (typeof graphOverrides !== 'object' || graphOverrides === null || Array.isArray(graphOverrides)) {
166
+ errors.push('index.graphOverrides: must be an object mapping supported built-in graph names to overrides');
167
+ }
168
+ else {
169
+ for (const [name, rawOverride] of Object.entries(graphOverrides)) {
170
+ const where = `index.graphOverrides.${name}`;
171
+ if (name !== 'codebase') {
172
+ errors.push(`${where}: unsupported built-in graph override (supported: codebase)`);
173
+ continue;
174
+ }
175
+ if (typeof rawOverride !== 'object' || rawOverride === null || Array.isArray(rawOverride)) {
176
+ errors.push(`${where}: must be an object`);
177
+ continue;
178
+ }
179
+ const override = rawOverride;
180
+ for (const field of Object.keys(override)) {
181
+ if (field !== 'scanDirs' && field !== 'extensions') {
182
+ errors.push(`${where}.${field}: unknown field (supported: scanDirs, extensions)`);
183
+ }
184
+ }
185
+ if (override.scanDirs !== undefined && (!isStringArray(override.scanDirs) || override.scanDirs.length === 0)) {
186
+ errors.push(`${where}.scanDirs: must be a non-empty array of strings`);
187
+ }
188
+ if (override.extensions !== undefined && (!isStringArray(override.extensions) || override.extensions.length === 0)) {
189
+ errors.push(`${where}.extensions: must be a non-empty array of strings`);
190
+ }
191
+ }
192
+ }
193
+ }
194
+ const graphs = indexObject.graphs;
162
195
  if (graphs === undefined)
163
196
  return errors; // index with no graphs is permissible
164
197
  if (typeof graphs !== 'object' || graphs === null || Array.isArray(graphs)) {
165
- return ['index.graphs: must be an object mapping graph names to definitions'];
198
+ errors.push('index.graphs: must be an object mapping graph names to definitions');
199
+ return errors;
166
200
  }
167
- const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
168
201
  for (const [name, rawDef] of Object.entries(graphs)) {
169
202
  const where = `index.graphs.${name}`;
170
203
  if (typeof rawDef !== 'object' || rawDef === null || Array.isArray(rawDef)) {
@@ -1,5 +1,5 @@
1
- import { createHash } from 'node:crypto';
2
- import { access, lstat, readFile, readdir } from 'node:fs/promises';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { access, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { readAiwgConfig } from '../config/aiwg-config.js';
@@ -25,6 +25,65 @@ const FRAMEWORK_BUNDLE_DIRS = {
25
25
  validation: 'validation-complete',
26
26
  'knowledge-base': 'knowledge-base',
27
27
  };
28
+ const PROVIDER_TRANSFORMATION_EVIDENCE_STATE_SCHEMA = 'aiwg.provider-transformation-evidence-state.v1';
29
+ export function providerTransformationEvidenceStatePath(projectRoot, provider, scope) {
30
+ const receiptPath = providerTransformationReceiptPath(projectRoot, provider, scope);
31
+ return receiptPath.replace(/\.json$/, '.evidence.json');
32
+ }
33
+ function validateEvidenceState(value) {
34
+ if (!value || typeof value !== 'object' || Array.isArray(value))
35
+ throw new Error('evidence state must be an object');
36
+ const state = value;
37
+ if (state.schemaVersion !== PROVIDER_TRANSFORMATION_EVIDENCE_STATE_SCHEMA)
38
+ throw new Error('unsupported evidence state schema');
39
+ if (!Number.isFinite(Date.parse(state.recordedAt)))
40
+ throw new Error('recordedAt must be an RFC 3339 date-time');
41
+ if (state.scope !== 'project' && state.scope !== 'user')
42
+ throw new Error('scope must be project or user');
43
+ if (!['local-source', 'source-unavailable', 'verification-failed'].includes(state.disposition)) {
44
+ throw new Error('unsupported source evidence disposition');
45
+ }
46
+ if (!state.provider || state.provider.includes('/') || state.provider.includes('\\'))
47
+ throw new Error('provider is invalid');
48
+ return state;
49
+ }
50
+ async function writeEvidenceState(options, disposition) {
51
+ const provider = normalizeProviderDefinitionId(options.provider) ?? options.provider;
52
+ const target = providerTransformationEvidenceStatePath(options.projectRoot, provider, options.scope);
53
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
54
+ const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${randomUUID()}.tmp`);
55
+ const state = {
56
+ schemaVersion: PROVIDER_TRANSFORMATION_EVIDENCE_STATE_SCHEMA,
57
+ recordedAt: options.generatedAt ?? new Date().toISOString(),
58
+ provider,
59
+ scope: options.scope,
60
+ disposition,
61
+ };
62
+ try {
63
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
64
+ await rename(temporary, target);
65
+ }
66
+ catch (error) {
67
+ await rm(temporary, { force: true }).catch(() => undefined);
68
+ throw error;
69
+ }
70
+ await rm(providerTransformationReceiptPath(options.projectRoot, provider, options.scope), { force: true });
71
+ return target;
72
+ }
73
+ async function readEvidenceState(options) {
74
+ const provider = normalizeProviderDefinitionId(options.provider) ?? options.provider;
75
+ try {
76
+ const state = validateEvidenceState(JSON.parse(await readFile(providerTransformationEvidenceStatePath(options.projectRoot, provider, options.scope), 'utf8')));
77
+ if (state.provider !== provider || state.scope !== options.scope)
78
+ throw new Error('evidence state identity does not match deployment');
79
+ return state;
80
+ }
81
+ catch (error) {
82
+ if (error.code === 'ENOENT')
83
+ return null;
84
+ throw error;
85
+ }
86
+ }
28
87
  function sha256(value) {
29
88
  return createHash('sha256').update(value).digest('hex');
30
89
  }
@@ -225,6 +284,17 @@ function receiptBundles(installed, options) {
225
284
  .sort();
226
285
  return deployed.length > 0 ? deployed : [...new Set(options.requestedBundles)].sort();
227
286
  }
287
+ /**
288
+ * Return whether the deployed provider surface includes project-local source
289
+ * material that cannot be authenticated by an AIWG signed web release.
290
+ */
291
+ export async function providerReceiptHasLocalSources(rawOptions) {
292
+ const provider = normalizeProviderDefinitionId(rawOptions.provider) ?? rawOptions.provider;
293
+ const options = { ...rawOptions, provider };
294
+ const installed = await installedEntries(options);
295
+ return receiptBundles(installed, options)
296
+ .some(bundle => installed[bundle]?.source === 'project-local');
297
+ }
228
298
  /**
229
299
  * Convert an already signature-verified web release into the stable verifier
230
300
  * result contract for the complete canonical bundle consumed by deployment.
@@ -375,6 +445,27 @@ export async function resolveProviderReceiptRuntimeEvidence(rawOptions) {
375
445
  };
376
446
  }
377
447
  export async function finalizeProviderTransformationReceipt(options) {
448
+ if (options.sourceDisposition) {
449
+ const evidenceStatePath = await writeEvidenceState(options, options.sourceDisposition);
450
+ if (options.sourceDisposition === 'local-source') {
451
+ return {
452
+ status: 'policy-exempt',
453
+ receiptPath: null,
454
+ evidenceStatePath,
455
+ outputCount: 0,
456
+ reason: 'local-source development deployments are exempt from signed-release receipt issuance',
457
+ };
458
+ }
459
+ return {
460
+ status: options.sourceDisposition === 'source-unavailable' ? 'source-unavailable' : 'skipped',
461
+ receiptPath: null,
462
+ evidenceStatePath,
463
+ outputCount: 0,
464
+ reason: options.sourceDisposition === 'source-unavailable'
465
+ ? 'verified signed-release source evidence is not available from cache or configured resource access'
466
+ : 'canonical source verification failed',
467
+ };
468
+ }
378
469
  if (!options.sourceVerifications) {
379
470
  return {
380
471
  status: 'skipped',
@@ -412,9 +503,11 @@ export async function finalizeProviderTransformationReceipt(options) {
412
503
  transformer: evidence.transformer,
413
504
  outputPaths: evidence.outputPaths,
414
505
  });
506
+ const receiptPath = await writeProviderTransformationReceipt(options.projectRoot, receipt);
507
+ await rm(providerTransformationEvidenceStatePath(options.projectRoot, evidence.provider, options.scope), { force: true });
415
508
  return {
416
509
  status: 'written',
417
- receiptPath: await writeProviderTransformationReceipt(options.projectRoot, receipt),
510
+ receiptPath,
418
511
  outputCount: receipt.outputs.length,
419
512
  };
420
513
  }
@@ -425,6 +518,40 @@ export async function diagnoseIntegratedProviderTransformationReceipt(options) {
425
518
  await access(receiptPath);
426
519
  }
427
520
  catch {
521
+ const state = await readEvidenceState(options);
522
+ if (state?.disposition === 'local-source') {
523
+ return {
524
+ status: 'policy-exempt',
525
+ receiptPath,
526
+ checkedOutputs: 0,
527
+ findings: [{
528
+ kind: 'policy-exempt',
529
+ message: 'This local-source development deployment is explicitly exempt from signed-release transformation receipts.',
530
+ }],
531
+ };
532
+ }
533
+ if (state?.disposition === 'source-unavailable') {
534
+ return {
535
+ status: 'source-evidence-unavailable',
536
+ receiptPath,
537
+ checkedOutputs: 0,
538
+ findings: [{
539
+ kind: 'source-evidence-unavailable',
540
+ message: 'The deployment succeeded, but verified signed-release source evidence was unavailable from cache or configured resource access.',
541
+ }],
542
+ };
543
+ }
544
+ if (state?.disposition === 'verification-failed') {
545
+ return {
546
+ status: 'drifted',
547
+ receiptPath,
548
+ checkedOutputs: 0,
549
+ findings: [{
550
+ kind: 'source-verification-failure',
551
+ message: 'Canonical source verification failed during receipt finalization.',
552
+ }],
553
+ };
554
+ }
428
555
  return {
429
556
  status: 'missing-receipt',
430
557
  receiptPath,
@@ -1,4 +1,4 @@
1
- import { ARTIFACT_TRUST_STATE_SCHEMA_VERSION, channelStateKey, decodeBase64, dssePae, isIdentityRevoked, parseTrustRoot, publicKeyFingerprint, scopeMatches, selectDelegations, sha256, validateTrustState, verifyBytes, } from './artifact-trust.js';
1
+ import { ARTIFACT_TRUST_STATE_SCHEMA_VERSION, canonicalJson, channelStateKey, decodeBase64, dssePae, isIdentityRevoked, parseTrustRoot, publicKeyFingerprint, scopeMatches, selectDelegations, sha256, validateTrustState, verifyBytes, } from './artifact-trust.js';
2
2
  export const ARTIFACT_VERIFICATION_RESULT_SCHEMA_VERSION = 'aiwg.verify.result.v1';
3
3
  export const ARTIFACT_VERIFICATION_EXIT_CODES = {
4
4
  verified: 0,
@@ -351,6 +351,12 @@ export async function verifyArtifact(input) {
351
351
  identities: allAuthenticated.map(identity => identity.id).sort(),
352
352
  });
353
353
  }
354
+ if (!Buffer.from(canonicalJson(statement), 'utf8').equals(payload)) {
355
+ return result('mismatched', input, [{ code: 'NONCANONICAL_SIGNED_PAYLOAD', message: 'Signed provenance payload is not canonical JSON' }], {
356
+ ...common,
357
+ identities: allAuthenticated.map(identity => identity.id).sort(),
358
+ });
359
+ }
354
360
  const scopeInput = {
355
361
  assetType: statement.predicate.assetType,
356
362
  namespace: statement.predicate.publisher.namespace,
@@ -695,19 +695,14 @@ export async function handlePtyConnection(sessionId, ws, command = 'aiwg', cmdAr
695
695
  }
696
696
  }
697
697
  else if (!session.exited) {
698
- // Reconnect to existing session replay buffer
698
+ // Reconnect to an existing session by replaying the complete retained
699
+ // buffer. Do not trim at the last full-screen erase: terminal erase
700
+ // sequences repaint the current viewport but xterm still needs the bytes
701
+ // that preceded them to reconstruct scrollback when a user switches away
702
+ // from a session and later returns (#2146).
699
703
  registry.addClient(sessionId, clientId, ws);
700
704
  if (session.outputBuffer) {
701
- // Trim replay to start from the last full-screen erase so that tmux's
702
- // screen-init sequences (cursor moves, status-bar paint) from before the
703
- // erase don't render as literal garbage in a fresh xterm.js context.
704
- // Everything before \x1b[2J would be cleared by the erase anyway;
705
- // everything after is the session content tmux redrew (MOTD, history, etc).
706
- // If no erase is found, replay the whole buffer unchanged.
707
- const ERASE = '\x1b[2J';
708
- const lastErase = session.outputBuffer.lastIndexOf(ERASE);
709
- const replay = lastErase !== -1 ? session.outputBuffer.slice(lastErase) : session.outputBuffer;
710
- ws.send(JSON.stringify({ type: 'data', payload: replay }));
705
+ ws.send(JSON.stringify({ type: 'data', payload: session.outputBuffer }));
711
706
  }
712
707
  }
713
708
  else {
@@ -25,6 +25,8 @@ import { spawn } from 'node:child_process';
25
25
  import { promises as fs } from 'node:fs';
26
26
  import * as path from 'node:path';
27
27
  import { resolveRuntime, supportedRuntimes } from './runtime.js';
28
+ import { recordTypeForEntry, stableRecordId } from '../artifacts/browser-export.js';
29
+ import { loadFortemiCoreMetadataEntries } from '../artifacts/fortemi-core-query-adapter.js';
28
30
  /**
29
31
  * Resolve the AIWG installation root. Prefers `$AIWG_ROOT` env, falls
30
32
  * back to the channel manager's framework-root resolver.
@@ -53,22 +55,29 @@ async function findSkillEntry(cwd, name) {
53
55
  const reader = await import('../artifacts/index-reader.js');
54
56
  const entries = [];
55
57
  for (const g of ['framework', 'project', 'codebase']) {
56
- const idx = reader.loadGraphIndexFile(cwd, 'metadata.json', g);
57
- if (idx)
58
- entries.push(...Object.values(idx.entries));
58
+ const canonical = loadFortemiCoreMetadataEntries(cwd, g);
59
+ if (canonical.entries.length > 0) {
60
+ entries.push(...canonical.entries);
61
+ }
62
+ else {
63
+ const idx = reader.loadGraphIndexFile(cwd, 'metadata.json', g);
64
+ if (idx)
65
+ entries.push(...Object.values(idx.entries));
66
+ }
59
67
  }
60
68
  if (entries.length === 0) {
61
69
  const legacy = reader.loadMetadataIndex(cwd);
62
70
  if (legacy)
63
71
  entries.push(...Object.values(legacy.entries));
64
72
  }
65
- const skills = entries.filter(e => e.type === 'skill');
73
+ const skills = entries.filter(e => e.type === 'skill' || e.type === 'aiwg.skill');
66
74
  const needle = name.trim();
67
75
  // Basename match — skills are conventionally `<dir>/SKILL.md`
68
76
  const matches = skills.filter(e => {
69
77
  const dir = path.basename(path.dirname(e.path));
70
78
  const stem = path.basename(e.path).replace(/\.[^.]+$/, '');
71
- return dir === needle || stem === needle || e.path === needle;
79
+ const id = stableRecordId(recordTypeForEntry({ ...e, type: 'skill' }, 'v2'), e.path);
80
+ return id === needle || e.name === needle || dir === needle || stem === needle || e.path === needle;
72
81
  });
73
82
  if (matches.length === 0)
74
83
  return null;
@@ -248,7 +257,7 @@ export async function main(args, env) {
248
257
  });
249
258
  }
250
259
  function printUsage() {
251
- console.log('Usage: aiwg run skill <name> [--cwd <path>] [-- <args...>]');
260
+ console.log('Usage: aiwg run skill <stable-id-or-name> [--cwd <path>] [-- <args...>]');
252
261
  console.log('');
253
262
  console.log('Examples:');
254
263
  console.log(' aiwg run skill voice-apply -- --voice technical-authority --input draft.md');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cli",
3
- "version": "2026.8.16",
3
+ "version": "2026.8.17",
4
4
  "description": "Lightweight AIWG CLI for signed, versioned web-backed resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -67,6 +67,7 @@
67
67
  "dependencies": {
68
68
  "@fortemi/core": "2026.7.15",
69
69
  "@modelcontextprotocol/sdk": "^1.30.0",
70
+ "ajv": "^8.20.0",
70
71
  "chalk": "^4.1.2",
71
72
  "chokidar": "^4.0.3",
72
73
  "commander": "^12.1.0",