aienvmp 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.37
4
+
5
+ - Added `lightSbom` to the manifest as an AI-ready package and vulnerability summary.
6
+ - Linked dependency manifests, ecosystem/group counts, vulnerable direct dependencies, and top risk packages into one compact SBOM view.
7
+ - Surfaced dependency change hints in AI-facing outputs and the dashboard so agents and humans can identify relevant manifests before edits.
8
+ - Added read-only lockfile awareness to dependency snapshots and light SBOM hints.
9
+ - Added package manager policy hints from lockfiles to reduce accidental npm/pnpm/yarn drift.
10
+
11
+ ## 0.1.36
12
+
13
+ - Added an explicit enforcement profile to the shared AI preflight contract.
14
+ - Surfaced advisory-by-default vs optional strict mode in the dashboard.
15
+ - Clarified that strict checks are intended for CI or explicit human-requested gates, not default local blocking.
16
+
3
17
  ## 0.1.35
4
18
 
5
19
  - Added a shared AI preflight contract across status, context, plan, and handoff outputs.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  It helps Codex, Claude, Gemini, and humans avoid silent Node, Python, package manager, dependency, Docker, and security drift.
12
12
 
13
- Core loop: scan once, link runtime/dependency/security context, give AI a shared decision contract with advisory priorities, and hand off safe next steps.
13
+ Core loop: scan once, link runtime/dependency/security context, give AI a shared decision contract with a light SBOM summary, and hand off safe next steps.
14
14
 
15
15
  ## Quick Start
16
16
 
@@ -105,8 +105,14 @@ aienvmp doctor --strict security # fail only scoped warnings
105
105
  - one advisory engine, optional enforcement with `doctor --ci`
106
106
  - scoped enforcement with `doctor --strict security|policy|coordination|all`
107
107
  - context and plan expose suggested strict scopes for CI
108
+ - dashboard and preflight explain advisory default vs optional strict mode
108
109
  - non-blocking unless strict mode is explicitly requested
109
110
  - security checks are opt-in and read-only
111
+ - light SBOM is generated from project files and optional scanner summaries
112
+ - dependency change hints point AI agents to the relevant manifest before edits
113
+ - lockfiles are detected read-only and shown before dependency edits
114
+ - package manager policy is inferred from lockfiles to avoid accidental npm/pnpm/yarn drift
115
+ - dashboard mirrors AI-facing SBOM hints so humans can review the same dependency context
110
116
 
111
117
  ## Development
112
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aienvmp",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "AI-first environment maps and lightweight runtime SBOMs for shared development machines.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,7 @@ export async function contextWorkspace(args) {
38
38
  containers: manifest.containers,
39
39
  inventory: inventorySummary(manifest.inventory),
40
40
  dependencySnapshot: dependencySummary(manifest.dependencySnapshot),
41
+ lightSbom: lightSbomSummary(manifest.lightSbom),
41
42
  security: securitySummary(manifest.security),
42
43
  projectHints: manifest.projectHints,
43
44
  warnings,
@@ -51,6 +52,31 @@ export async function contextWorkspace(args) {
51
52
  console.log(renderContext(manifest, timeline, warnings, intents, policy, actions));
52
53
  }
53
54
 
55
+ function lightSbomSummary(lightSbom = {}) {
56
+ return {
57
+ mode: lightSbom.mode || "light-sbom",
58
+ summary: lightSbom.summary || {
59
+ ecosystems: {},
60
+ managers: {},
61
+ groups: {},
62
+ manifests: [],
63
+ lockfiles: [],
64
+ packages: 0,
65
+ vulnerabilities: 0,
66
+ directVulnerablePackages: 0,
67
+ transitiveOrUnmatchedVulnerablePackages: 0
68
+ },
69
+ topRisk: (lightSbom.topRisk || []).slice(0, 8),
70
+ packageManagerPolicy: lightSbom.packageManagerPolicy || {
71
+ status: "no-lockfile",
72
+ ecosystems: {},
73
+ guidance: "No lockfile policy detected."
74
+ },
75
+ dependencyChangeHints: (lightSbom.dependencyChangeHints || []).slice(0, 8),
76
+ aiUse: lightSbom.aiUse || {}
77
+ };
78
+ }
79
+
54
80
  function dependencySummary(snapshot = {}) {
55
81
  return {
56
82
  mode: snapshot.mode || "snapshot",
@@ -9,6 +9,7 @@ import { renderDashboard } from "../render.js";
9
9
  import { loadPolicy, policyWarnings } from "../policy.js";
10
10
  import { recommendedActions } from "../actions.js";
11
11
  import { strictResult } from "../enforcement.js";
12
+ import { buildPreflight } from "../preflight.js";
12
13
 
13
14
  export async function dashWorkspace(args) {
14
15
  const dir = workspaceDir(args);
@@ -23,6 +24,7 @@ export async function dashWorkspace(args) {
23
24
  const planEnvironment = await detectedPlanEnvironment(dir);
24
25
  const html = renderDashboard({
25
26
  ...manifest,
27
+ preflight: buildPreflight(manifest, warnings, intents),
26
28
  recommendedActions: recommendedActions(manifest, { warnings, intents }),
27
29
  planArtifacts,
28
30
  planRemediation,
@@ -3,20 +3,33 @@ import path from "node:path";
3
3
  import { exists, readJson } from "./fsutil.js";
4
4
 
5
5
  const NODE_GROUPS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
6
+ const LOCKFILE_CANDIDATES = [
7
+ { file: "package-lock.json", ecosystem: "npm", manager: "npm" },
8
+ { file: "npm-shrinkwrap.json", ecosystem: "npm", manager: "npm" },
9
+ { file: "pnpm-lock.yaml", ecosystem: "npm", manager: "pnpm" },
10
+ { file: "yarn.lock", ecosystem: "npm", manager: "yarn" },
11
+ { file: "bun.lockb", ecosystem: "npm", manager: "bun" },
12
+ { file: "uv.lock", ecosystem: "python", manager: "uv" },
13
+ { file: "poetry.lock", ecosystem: "python", manager: "poetry" },
14
+ { file: "Pipfile.lock", ecosystem: "python", manager: "pipenv" }
15
+ ];
6
16
 
7
17
  export async function scanDependencySnapshot(dir) {
8
18
  const node = await scanNodeDependencies(dir);
9
19
  const python = await scanPythonDependencies(dir);
10
20
  const packages = [...node.packages, ...python.packages];
11
21
  const manifests = [...node.manifests, ...python.manifests];
22
+ const lockfiles = await scanDependencyLockfiles(dir);
12
23
  return {
13
24
  mode: "snapshot",
14
25
  enabled: true,
15
26
  note: "Read-only dependency snapshot from project files. It does not install, update, or resolve packages.",
16
27
  manifests,
28
+ lockfiles,
17
29
  summary: {
18
30
  ecosystems: [...new Set(packages.map((pkg) => pkg.ecosystem))],
19
31
  manifests: manifests.length,
32
+ lockfiles: lockfiles.length,
20
33
  packages: packages.length
21
34
  },
22
35
  packages: packages.slice(0, 80)
@@ -45,6 +58,144 @@ export function linkVulnerableDependencies(security = {}, snapshot = {}) {
45
58
  };
46
59
  }
47
60
 
61
+ export function buildLightSbom(snapshot = {}, security = {}) {
62
+ const packages = snapshot.packages || [];
63
+ const vulnerable = security.topPackages || [];
64
+ const directVulnerable = vulnerable.filter((pkg) => pkg.directDependency === true);
65
+ const transitiveOrUnmatched = vulnerable.filter((pkg) => pkg.directDependency !== true);
66
+ const topRisk = vulnerable.slice(0, 8).map((pkg) => ({
67
+ name: pkg.name,
68
+ ecosystem: scannerEcosystem(pkg.scanner),
69
+ severity: pkg.severity || "unknown",
70
+ directDependency: pkg.directDependency === true,
71
+ manifest: pkg.dependency?.manifest || "",
72
+ version: pkg.dependency?.version || pkg.version || "",
73
+ priority: pkg.remediationPriority?.level || "low",
74
+ score: pkg.remediationPriority?.score || 0,
75
+ fixAvailable: pkg.fixAvailable === true || Boolean(pkg.fixVersions?.length),
76
+ fixVersions: (pkg.fixVersions || []).slice(0, 3)
77
+ }));
78
+ return {
79
+ schemaVersion: 1,
80
+ mode: "light-sbom",
81
+ note: "AI-ready package and vulnerability summary from read-only project files and optional scanners.",
82
+ summary: {
83
+ ecosystems: countBy(packages, "ecosystem"),
84
+ managers: countBy(packages, "manager"),
85
+ groups: countBy(packages, "group"),
86
+ manifests: snapshot.manifests || [],
87
+ lockfiles: snapshot.lockfiles || [],
88
+ packages: packages.length,
89
+ vulnerabilities: Number(security.summary?.total || 0),
90
+ directVulnerablePackages: directVulnerable.length,
91
+ transitiveOrUnmatchedVulnerablePackages: transitiveOrUnmatched.length
92
+ },
93
+ topRisk,
94
+ packageManagerPolicy: packageManagerPolicy(snapshot.lockfiles || []),
95
+ dependencyChangeHints: dependencyChangeHints(packages, topRisk, snapshot.lockfiles || []),
96
+ aiUse: {
97
+ beforeDependencyChanges: "Read lightSbom.summary and lightSbom.topRisk before changing dependencies.",
98
+ securityMode: security.enabled ? "scanner-summary" : "scanner-off",
99
+ dependencySource: "project manifests only; no install or resolver is run"
100
+ }
101
+ };
102
+ }
103
+
104
+ function packageManagerPolicy(lockfiles = []) {
105
+ const byEcosystem = {};
106
+ for (const lockfile of lockfiles) {
107
+ const ecosystem = lockfile.ecosystem || "unknown";
108
+ const managers = byEcosystem[ecosystem]?.managers || new Set();
109
+ managers.add(lockfile.manager || "unknown");
110
+ byEcosystem[ecosystem] = {
111
+ ecosystem,
112
+ managers,
113
+ lockfiles: [...(byEcosystem[ecosystem]?.lockfiles || []), lockfile.file]
114
+ };
115
+ }
116
+ const ecosystems = Object.fromEntries(Object.entries(byEcosystem).map(([name, value]) => {
117
+ const managers = [...value.managers].sort();
118
+ return [name, {
119
+ managers,
120
+ lockfiles: value.lockfiles.sort(),
121
+ status: managers.length > 1 ? "mixed-lockfiles" : "single-manager",
122
+ recommendedManager: managers[0] || "not-detected",
123
+ guidance: managers.length > 1
124
+ ? "Review with the user before dependency changes; multiple package manager lockfiles are present."
125
+ : "Use the detected package manager for dependency changes unless the user says otherwise."
126
+ }];
127
+ }));
128
+ return {
129
+ status: Object.keys(ecosystems).length === 0
130
+ ? "no-lockfile"
131
+ : Object.values(ecosystems).some((item) => item.status === "mixed-lockfiles") ? "review-required" : "clear",
132
+ ecosystems,
133
+ guidance: Object.keys(ecosystems).length === 0
134
+ ? "No lockfile detected; avoid creating one with an unexpected package manager without user approval."
135
+ : "Preserve existing lockfile and package manager choices during dependency changes."
136
+ };
137
+ }
138
+
139
+ function dependencyChangeHints(packages = [], topRisk = [], lockfiles = []) {
140
+ const byManifest = new Map();
141
+ for (const pkg of packages) {
142
+ const key = pkg.manifest || "unknown";
143
+ const entry = byManifest.get(key) || {
144
+ manifest: key,
145
+ ecosystem: pkg.ecosystem || "unknown",
146
+ manager: pkg.manager || "unknown",
147
+ groups: new Set(),
148
+ packages: 0,
149
+ riskPackages: []
150
+ };
151
+ entry.groups.add(pkg.group || "unknown");
152
+ entry.packages += 1;
153
+ byManifest.set(key, entry);
154
+ }
155
+ for (const risk of topRisk) {
156
+ if (!risk.manifest || !byManifest.has(risk.manifest)) continue;
157
+ byManifest.get(risk.manifest).riskPackages.push({
158
+ name: risk.name,
159
+ severity: risk.severity,
160
+ priority: risk.priority,
161
+ fixAvailable: risk.fixAvailable
162
+ });
163
+ }
164
+ return [...byManifest.values()].map((entry) => ({
165
+ manifest: entry.manifest,
166
+ ecosystem: entry.ecosystem,
167
+ manager: entry.manager,
168
+ groups: [...entry.groups].sort(),
169
+ packages: entry.packages,
170
+ riskPackages: entry.riskPackages.slice(0, 5),
171
+ lockfiles: lockfilesForEntry(entry, lockfiles),
172
+ beforeChange: [
173
+ `Read ${entry.manifest} and the active package manager policy before editing dependencies.`,
174
+ lockfilesForEntry(entry, lockfiles).length
175
+ ? `Preserve related lockfiles: ${lockfilesForEntry(entry, lockfiles).map((item) => item.file).join(", ")}.`
176
+ : "No related lockfile was detected; do not create one with a different package manager without approval.",
177
+ "Record an intent before dependency or lockfile changes when another AI may be working."
178
+ ],
179
+ afterChange: [
180
+ "Run project tests or the narrowest relevant validation.",
181
+ "Run aienvmp sync.",
182
+ "Record the dependency change with aienvmp record."
183
+ ]
184
+ }));
185
+ }
186
+
187
+ async function scanDependencyLockfiles(dir) {
188
+ const found = [];
189
+ for (const item of LOCKFILE_CANDIDATES) {
190
+ if (await exists(path.join(dir, item.file))) found.push(item);
191
+ }
192
+ return found;
193
+ }
194
+
195
+ function lockfilesForEntry(entry, lockfiles = []) {
196
+ return lockfiles.filter((lockfile) => lockfile.ecosystem === entry.ecosystem);
197
+ }
198
+
48
199
  export function remediationPriority(pkg = {}, context = {}) {
49
200
  const severityScore = { critical: 90, high: 70, moderate: 45, low: 20, info: 5, unknown: 30 };
50
201
  const severity = String(pkg.severity || "unknown").toLowerCase();
@@ -66,6 +217,14 @@ export function remediationPriority(pkg = {}, context = {}) {
66
217
  return { level, score, reasons };
67
218
  }
68
219
 
220
+ function countBy(items = [], key) {
221
+ return Object.fromEntries(Object.entries(items.reduce((acc, item) => {
222
+ const value = item[key] || "unknown";
223
+ acc[value] = (acc[value] || 0) + 1;
224
+ return acc;
225
+ }, {})).sort(([a], [b]) => a.localeCompare(b)));
226
+ }
227
+
69
228
  async function scanNodeDependencies(dir) {
70
229
  const file = path.join(dir, "package.json");
71
230
  if (!(await exists(file))) return { manifests: [], packages: [] };
package/src/manifest.js CHANGED
@@ -6,12 +6,13 @@ import { exists } from "./fsutil.js";
6
6
  import { observedTrust } from "./trust.js";
7
7
  import { scanGlobalInventory } from "./inventory.js";
8
8
  import { scanSecurity } from "./security.js";
9
- import { linkVulnerableDependencies, scanDependencySnapshot } from "./dependencies.js";
9
+ import { buildLightSbom, linkVulnerableDependencies, scanDependencySnapshot } from "./dependencies.js";
10
10
 
11
11
  export async function buildManifest(dir, options = {}) {
12
12
  const now = new Date().toISOString();
13
13
  const dependencySnapshot = await scanDependencySnapshot(dir);
14
14
  const security = linkVulnerableDependencies(await scanSecurity(dir, { security: options.security }), dependencySnapshot);
15
+ const lightSbom = buildLightSbom(dependencySnapshot, security);
15
16
  const manifest = {
16
17
  schemaName: "aienvmp.runtime-sbom",
17
18
  schemaVersion: 1,
@@ -31,6 +32,7 @@ export async function buildManifest(dir, options = {}) {
31
32
  containers: await scanContainers(),
32
33
  projectHints: await scanProjectHints(dir),
33
34
  dependencySnapshot,
35
+ lightSbom,
34
36
  inventory: await scanGlobalInventory({ deep: options.deep }),
35
37
  security,
36
38
  agentFiles: await scanAgentFiles(dir),
package/src/preflight.js CHANGED
@@ -16,6 +16,20 @@ export function buildPreflight(manifest = {}, warnings = [], intents = []) {
16
16
  : "Review warnings or open intents before environment changes.",
17
17
  decision,
18
18
  enforcement,
19
+ enforcementProfile: {
20
+ defaultMode: "advisory",
21
+ localOperation: "non-blocking",
22
+ strictMode: "optional",
23
+ strictUse: "CI or explicit human-requested checks only",
24
+ reason: "Avoid disrupting shared servers or developer machines while still making drift visible.",
25
+ recommendedStrictCommand: enforcement.recommendedCommand,
26
+ strictCommands: [
27
+ "aienvmp doctor --strict security",
28
+ "aienvmp doctor --strict policy",
29
+ "aienvmp doctor --strict coordination",
30
+ "aienvmp doctor --strict all"
31
+ ]
32
+ },
19
33
  counts: {
20
34
  warnings: warnings.length,
21
35
  openIntents: intents.length,
package/src/render.js CHANGED
@@ -33,6 +33,8 @@ export function renderAIEnv(manifest, timeline = [], warnings = [], intents = []
33
33
  lines.push(...inventoryLines(manifest.inventory), "");
34
34
  lines.push("## Dependency Snapshot", "");
35
35
  lines.push(...dependencyLines(manifest.dependencySnapshot), "");
36
+ lines.push("## Light SBOM", "");
37
+ lines.push(...lightSbomLines(manifest.lightSbom), "");
36
38
  lines.push("## Security Summary", "");
37
39
  lines.push(...securityLines(manifest.security), "");
38
40
  lines.push("## Project Requirements And Hints", "");
@@ -284,6 +286,14 @@ const deps=manifest.dependencySnapshot||{};
284
286
  const depSummary=deps.summary||{ecosystems:[],manifests:0,packages:0};
285
287
  const depPackages=deps.packages||[];
286
288
  const depHtml=depPackages.length?'<table><tr><th>Packages</th><td><code>'+esc(depSummary.packages||0)+'</code></td></tr><tr><th>Ecosystems</th><td><code>'+esc((depSummary.ecosystems||[]).join(', ')||'none')+'</code></td></tr><tr><th>Manifests</th><td><code>'+esc((deps.manifests||[]).join(', ')||'none')+'</code></td></tr></table><div class="timeline">'+depPackages.slice(0,8).map(p=>\`<div class="event"><time>\${esc(p.ecosystem)}</time><div><b>\${esc(p.name)}</b> <code>\${esc(p.version)}</code><div class="path">\${esc(p.manifest)} / \${esc(p.group)}</div></div></div>\`).join('')+'</div>':'<div class="okline">No project dependency manifests detected.</div>';
289
+ const lightSbom=manifest.lightSbom||{};
290
+ const lightSbomSummary=lightSbom.summary||{};
291
+ const pmPolicy=lightSbom.packageManagerPolicy||{};
292
+ const topRisk=lightSbom.topRisk||[];
293
+ const dependencyHints=lightSbom.dependencyChangeHints||[];
294
+ const dependencyHintsHtml=dependencyHints.length?'<div class="timeline">'+dependencyHints.slice(0,5).map(h=>\`<div class="event"><time>\${esc(h.ecosystem||'deps')}</time><div><b>\${esc(h.manifest)}</b> <code>\${esc(h.manager||'unknown')}</code> \${esc(h.packages||0)} packages\${h.riskPackages?.length?\`<div class="path">risk: \${esc(h.riskPackages.map(p=>p.name).join(', '))}</div>\`:''}<div class="path">\${esc((h.groups||[]).join(', ')||'no groups')}\${h.lockfiles?.length?\` / lockfiles: \${esc(h.lockfiles.map(l=>l.file).join(', '))}\`:''}</div></div></div>\`).join('')+'</div>':'<div class="okline">No dependency change hints available.</div>';
295
+ const pmPolicyHtml='<table><tr><th>Status</th><td><code>'+esc(pmPolicy.status||'no-lockfile')+'</code></td></tr><tr><th>Guidance</th><td>'+esc(pmPolicy.guidance||'No lockfile policy detected.')+'</td></tr></table>';
296
+ const lightSbomHtml=\`<table><tr><th>Packages</th><td><code>\${esc(lightSbomSummary.packages||0)}</code></td></tr><tr><th>Vulnerabilities</th><td><code>\${esc(lightSbomSummary.vulnerabilities||0)}</code></td></tr><tr><th>Direct vulnerable</th><td><code>\${esc(lightSbomSummary.directVulnerablePackages||0)}</code></td></tr><tr><th>Manifests</th><td><code>\${esc((lightSbomSummary.manifests||[]).join(', ')||'none')}</code></td></tr><tr><th>Lockfiles</th><td><code>\${esc((lightSbomSummary.lockfiles||[]).map(l=>l.file).join(', ')||'none')}</code></td></tr></table><h3 style="margin-top:12px">Package manager policy</h3>\${pmPolicyHtml}\${topRisk.length?'<div class="timeline">'+topRisk.slice(0,5).map(p=>\`<div class="event"><time>\${esc(p.priority)}</time><div><b>\${esc(p.name)}</b> \${esc(p.severity)} \${p.directDependency?'<code>direct</code>':'<code>transitive</code>'}<div class="path">\${esc(p.manifest||p.ecosystem)} \${esc(p.version||'')}</div></div></div>\`).join('')+'</div>':'<div class="okline">No high-risk package summary in the current light SBOM.</div>'}<h3 style="margin-top:12px">Dependency change hints</h3>\${dependencyHintsHtml}\`;
287
297
  const sec=manifest.security||{};
288
298
  const secSummary=sec.summary||{total:0,critical:0,high:0,moderate:0,low:0,info:0};
289
299
  const secPackages=sec.topPackages||[];
@@ -313,6 +323,9 @@ const envStepsHtml=envSteps.length?'<div class="timeline">'+envSteps.map(s=>\`<d
313
323
  const ciReadiness=manifest.ciReadiness||[];
314
324
  const ciHasFailure=ciReadiness.some(s=>s.status==='fail');
315
325
  const ciReadinessHtml=ciReadiness.length?'<table>'+ciReadiness.map(s=>\`<tr><th>\${esc(s.scope)}</th><td><code>\${esc(s.status)}</code>\${s.matchedWarningCodes?.length?\` \${esc(s.matchedWarningCodes.join(', '))}\`:''}</td></tr>\`).join('')+'</table>':'<div class="okline">Run <code>aienvmp doctor --strict security|policy|coordination|all</code> to choose CI enforcement scope.</div>';
326
+ const enforcementProfile=manifest.preflight?.enforcementProfile||{};
327
+ const strictCommands=enforcementProfile.strictCommands||[];
328
+ const enforcementHtml=\`<table><tr><th>Default</th><td><code>\${esc(enforcementProfile.defaultMode||'advisory')}</code></td></tr><tr><th>Local</th><td>\${esc(enforcementProfile.localOperation||'non-blocking')}</td></tr><tr><th>Strict</th><td>\${esc(enforcementProfile.strictUse||'CI or explicit checks only')}</td></tr><tr><th>Recommended</th><td><code>\${esc(enforcementProfile.recommendedStrictCommand||'aienvmp doctor --strict all')}</code></td></tr></table><div class="timeline">\${strictCommands.slice(0,4).map(cmd=>\`<div class="event"><time>CI</time><div><code>\${esc(cmd)}</code></div></div>\`).join('')}</div><div class="path">\${esc(enforcementProfile.reason||'Warnings stay advisory unless strict mode is requested.')}</div>\`;
316
329
  const card=(title,badge,body)=>\`<section class="card"><div class="card-head"><h2>\${title}</h2>\${badge||''}</div>\${body}</section>\`;
317
330
  const reviewRequired=warnings.length>0||intents.length>0;
318
331
  const recentChanges=timeline.slice(-8).length;
@@ -350,6 +363,7 @@ document.getElementById('app').innerHTML=\`
350
363
  \${card('Project Hints',\`<span class="pill">\${entries(manifest.projectHints).length} hints</span>\`,\`<table>\${rows(manifest.projectHints)}</table>\`)}
351
364
  \${card('Global Inventory',manifest.inventory?.enabled?'<span class="pill">deep</span>':'<span class="pill off">basic</span>',inventoryHtml)}
352
365
  \${card('Dependency Snapshot','<span class="pill">'+(depSummary.packages||0)+' packages</span>',depHtml)}
366
+ \${card('Light SBOM','<span class="pill">'+(lightSbomSummary.packages||0)+' packages</span>',lightSbomHtml)}
353
367
  \${card('Security Summary',sec.enabled?'<span class="pill warn">security</span>':'<span class="pill off">basic</span>',securityHtml)}
354
368
  </div>
355
369
  <aside>
@@ -361,6 +375,8 @@ document.getElementById('app').innerHTML=\`
361
375
  <div style="height:14px"></div>
362
376
  \${card('Environment Steps',envSteps.length?'<span class="pill warn">'+envSteps.length+' items</span>':'<span class="pill off">none</span>',envStepsHtml)}
363
377
  <div style="height:14px"></div>
378
+ \${card('Enforcement Mode','<span class="pill">advisory</span>',enforcementHtml)}
379
+ <div style="height:14px"></div>
364
380
  \${card('CI Readiness',ciHasFailure?'<span class="pill warn">review</span>':'<span class="pill">ready</span>',ciReadinessHtml)}
365
381
  <div style="height:14px"></div>
366
382
  \${card('Environment Health',warnings.length?'<span class="pill warn">attention</span>':'<span class="pill">clear</span>',warnHtml)}
@@ -446,6 +462,39 @@ function dependencyLines(snapshot = {}) {
446
462
  return lines;
447
463
  }
448
464
 
465
+ function lightSbomLines(lightSbom = {}) {
466
+ const summary = lightSbom.summary || {};
467
+ const lines = [
468
+ `- Mode: ${lightSbom.mode || "light-sbom"}`,
469
+ `- Packages: ${summary.packages || 0}`,
470
+ `- Vulnerabilities: ${summary.vulnerabilities || 0}`,
471
+ `- Direct vulnerable packages: ${summary.directVulnerablePackages || 0}`,
472
+ `- Transitive or unmatched vulnerable packages: ${summary.transitiveOrUnmatchedVulnerablePackages || 0}`,
473
+ `- Lockfiles: ${(summary.lockfiles || []).map((item) => item.file).join(", ") || "none"}`
474
+ ];
475
+ if (lightSbom.packageManagerPolicy) {
476
+ lines.push(`- Package manager policy: ${lightSbom.packageManagerPolicy.status}`);
477
+ lines.push(`- Package manager guidance: ${lightSbom.packageManagerPolicy.guidance}`);
478
+ }
479
+ const risks = lightSbom.topRisk || [];
480
+ if (risks.length) {
481
+ lines.push("- Top risk:");
482
+ for (const item of risks.slice(0, 8)) {
483
+ lines.push(` - ${item.name}: ${item.severity}; ${item.priority}/${item.score}; ${item.directDependency ? "direct" : "transitive-or-unmatched"}${item.version ? `; ${item.version}` : ""}`);
484
+ }
485
+ }
486
+ const hints = lightSbom.dependencyChangeHints || [];
487
+ if (hints.length) {
488
+ lines.push("- Dependency change hints:");
489
+ for (const hint of hints.slice(0, 6)) {
490
+ const risk = hint.riskPackages?.length ? `; risk: ${hint.riskPackages.map((pkg) => pkg.name).join(", ")}` : "";
491
+ const lockfiles = hint.lockfiles?.length ? `; lockfiles: ${hint.lockfiles.map((item) => item.file).join(", ")}` : "";
492
+ lines.push(` - ${hint.manifest}: ${hint.ecosystem}/${hint.manager}; ${hint.packages} packages${risk}${lockfiles}`);
493
+ }
494
+ }
495
+ return lines;
496
+ }
497
+
449
498
  function securityLines(security = {}) {
450
499
  if (!security.enabled) return ["- Mode: basic", "- Security scan is disabled. Run `aienvmp sync --security` when vulnerability context is needed."];
451
500
  const summary = security.summary || {};