guardlink 1.3.0 → 1.4.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +39 -0
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.d.ts.map +1 -1
  5. package/dist/cli/index.js +243 -15
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/mcp/server.d.ts +1 -0
  12. package/dist/mcp/server.d.ts.map +1 -1
  13. package/dist/mcp/server.js +38 -1
  14. package/dist/mcp/server.js.map +1 -1
  15. package/dist/parser/parse-project.d.ts.map +1 -1
  16. package/dist/parser/parse-project.js +103 -0
  17. package/dist/parser/parse-project.js.map +1 -1
  18. package/dist/tui/commands.d.ts +3 -0
  19. package/dist/tui/commands.d.ts.map +1 -1
  20. package/dist/tui/commands.js +240 -1
  21. package/dist/tui/commands.js.map +1 -1
  22. package/dist/tui/index.d.ts.map +1 -1
  23. package/dist/tui/index.js +17 -1
  24. package/dist/tui/index.js.map +1 -1
  25. package/dist/types/index.d.ts +39 -0
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/workspace/index.d.ts +12 -0
  28. package/dist/workspace/index.d.ts.map +1 -0
  29. package/dist/workspace/index.js +9 -0
  30. package/dist/workspace/index.js.map +1 -0
  31. package/dist/workspace/link.d.ts +91 -0
  32. package/dist/workspace/link.d.ts.map +1 -0
  33. package/dist/workspace/link.js +581 -0
  34. package/dist/workspace/link.js.map +1 -0
  35. package/dist/workspace/merge.d.ts +104 -0
  36. package/dist/workspace/merge.d.ts.map +1 -0
  37. package/dist/workspace/merge.js +752 -0
  38. package/dist/workspace/merge.js.map +1 -0
  39. package/dist/workspace/metadata.d.ts +34 -0
  40. package/dist/workspace/metadata.d.ts.map +1 -0
  41. package/dist/workspace/metadata.js +181 -0
  42. package/dist/workspace/metadata.js.map +1 -0
  43. package/dist/workspace/types.d.ts +134 -0
  44. package/dist/workspace/types.d.ts.map +1 -0
  45. package/dist/workspace/types.js +12 -0
  46. package/dist/workspace/types.js.map +1 -0
  47. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to GuardLink CLI will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.4.0] — 2026-02-27
9
+
10
+ ### Added
11
+
12
+ - **Workspace**: Multi-repo workspace support — link N service repos into a unified threat model with cross-repo tag resolution, weekly diff tracking, and merged dashboards
13
+ - **Workspace**: `guardlink link-project <repos...> --workspace <name> --registry <url>` — scaffold workspace.yaml in each repo, auto-detect repo names from git/package.json/Cargo.toml, inject cross-repo context into agent instruction files
14
+ - **Workspace**: `guardlink link-project --add <repo> --from <existing>` — add a repo to an existing workspace with sibling auto-discovery
15
+ - **Workspace**: `guardlink link-project --remove <name> --from <existing>` — remove a repo from workspace, update all siblings found on disk
16
+ - **Workspace**: `guardlink merge <files...>` — merge N per-repo report JSONs into a unified MergedReport with tag registry, cross-repo reference resolution, stale/schema warnings, and aggregated stats
17
+ - **Workspace**: `--diff-against <prev.json>` flag on merge for week-over-week risk tracking (assets/threats/mitigations/exposures added/removed, risk trend, unresolved ref changes)
18
+ - **Workspace**: `-o <file>` dashboard HTML output + `--json <file>` merged JSON output + `--summary-only` text mode
19
+ - **CLI**: `guardlink report --format json` — JSON report output with metadata (repo, workspace, commit SHA, schema version)
20
+ - **TUI**: `/workspace` — show workspace config, sibling repos, registries
21
+ - **TUI**: `/link` — link repos with `--add`/`--remove` support
22
+ - **TUI**: `/merge` — merge reports with `--json`, `--diff-against`, `-o` flags
23
+ - **MCP**: `guardlink_workspace_info` tool — returns workspace name, this_repo identity, sibling tag prefixes, and cross-repo annotation rules for agents
24
+ - **Parser**: External reference detection — scans relationship annotations for tags with dot-prefix matching sibling repo names from workspace.yaml, populates `ThreatModel.external_refs`
25
+ - **Types**: `ExternalRef` interface, `ThreatModel.external_refs` field, `ReportMetadata` with repo/workspace/commit_sha/schema_version
26
+ - **CI**: `examples/ci/per-repo-report.yml` — per-repo workflow: validate on PRs (diff + SARIF + PR comment), generate + upload report JSON on push to main
27
+ - **CI**: `examples/ci/workspace-merge.yml` — weekly workspace merge workflow: download all repo artifacts, merge, dashboard, weekly diff, optional GitHub Pages + Slack
28
+ - **Docs**: `docs/WORKSPACE.md` — multi-repo setup guide, workspace.yaml spec, cross-repo annotation rules, merge behavior, CI integration, weekly workflow
29
+
30
+ ### Changed
31
+
32
+ - **MCP**: Server version bumped to 1.4.0
33
+
8
34
  ## [1.3.0] — 2026-02-27
9
35
 
10
36
  ### Added
package/README.md CHANGED
@@ -151,6 +151,7 @@ GuardLink ships an MCP server and behavioral directives for AI coding agents. Af
151
151
  | `guardlink_dashboard` | Generate HTML dashboard |
152
152
  | `guardlink_sarif` | Export SARIF 2.1.0 |
153
153
  | `guardlink_diff` | Compare threat model against a git ref |
154
+ | `guardlink_workspace_info` | Workspace config, sibling repos, tag prefixes for cross-repo annotations |
154
155
 
155
156
  **Resources:** `guardlink://model`, `guardlink://definitions`, `guardlink://config`
156
157
 
@@ -179,6 +180,11 @@ GuardLink ships an MCP server and behavioral directives for AI coding agents. Af
179
180
  | `guardlink clear [dir]` | Remove all annotations from source files (with `--dry-run` preview) |
180
181
  | `guardlink sync [dir]` | Sync agent instruction files with current threat model |
181
182
  | `guardlink unannotated [dir]` | List source files with no annotations |
183
+ | `guardlink link-project <repos...>` | Link repos into a shared workspace for cross-repo threat modeling |
184
+ | `guardlink link-project --add <repo>` | Add a repo to an existing workspace |
185
+ | `guardlink link-project --remove <name>` | Remove a repo from a workspace |
186
+ | `guardlink merge <files...>` | Merge per-repo report JSONs into a unified workspace dashboard |
187
+ | `guardlink report --format json` | Generate report JSON with metadata (repo, workspace, commit SHA) |
182
188
  | `guardlink config` | Set AI provider and API key |
183
189
  | `guardlink mcp` | Start MCP server for AI agent integration |
184
190
 
@@ -282,6 +288,10 @@ jobs:
282
288
 
283
289
  See [`examples/github-action.yml`](examples/github-action.yml) for a full example with PR comments and SARIF upload.
284
290
 
291
+ ### Multi-Repo CI
292
+
293
+ For workspace setups, GuardLink provides two additional workflow templates: a per-repo workflow that generates report JSON artifacts on every push, and a workspace merge workflow that runs weekly to combine all repos into a unified dashboard. See the [CI setup guide](examples/ci/README.md) for step-by-step instructions.
294
+
285
295
  ### What CI Catches
286
296
 
287
297
  - **New route, no annotations:** `guardlink diff` shows "+1 endpoint, 0 mitigations" — the team sees the gap.
@@ -294,6 +304,35 @@ See [`examples/github-action.yml`](examples/github-action.yml) for a full exampl
294
304
 
295
305
  ---
296
306
 
307
+ ## Multi-Repo Workspaces
308
+
309
+ In microservices architectures, a single repo only has part of the security picture. `PaymentService` is defined in `repo-payments`, exposed in `repo-gateway`, mitigated in `repo-auth-lib`. GuardLink workspaces link these repos so the threat model spans service boundaries.
310
+
311
+ ```bash
312
+ # Link three repos into a workspace
313
+ guardlink link-project ./payment-svc ./auth-lib ./api-gateway \
314
+ --workspace acme-platform
315
+
316
+ # Each repo gets .guardlink/workspace.yaml + agent files updated with cross-repo context
317
+ # Agents now know about sibling services and use tag prefixes like #payment-svc.refund
318
+
319
+ # Generate per-repo JSON reports (in each repo or in CI)
320
+ guardlink report --format json -o guardlink-report.json
321
+
322
+ # Merge all reports into a unified dashboard
323
+ guardlink merge payment-svc.json auth-lib.json api-gateway.json \
324
+ -o dashboard.html --json merged.json
325
+
326
+ # Week-over-week diff for security leads
327
+ guardlink merge *.json --diff-against last-week.json --json merged.json
328
+ ```
329
+
330
+ Annotations reference sibling repos by tag prefix — `@flows #request from #api-gateway.router to #payment-svc.refund` — and these references resolve during merge. `guardlink validate` flags them as external refs locally, but they're expected and won't block CI.
331
+
332
+ For automated weekly dashboards, see the [CI setup guide](examples/ci/README.md). Full workspace documentation: [docs/WORKSPACE.md](docs/WORKSPACE.md).
333
+
334
+ ---
335
+
297
336
  ## Real-World Results
298
337
 
299
338
  We tested GuardLink + Claude Code on [vuln-node.js-express.js-app](https://github.com/SirAppSec/vuln-node.js-express.js-app), a deliberately vulnerable Express.js application with 37 documented vulnerability types.
@@ -18,6 +18,8 @@
18
18
  * guardlink mcp Start MCP server (stdio) for Claude Code, Cursor, etc.
19
19
  * guardlink tui [dir] Interactive TUI with slash commands + AI chat
20
20
  * guardlink gal Display GAL annotation language quick reference
21
+ * guardlink link-project <repos...> Link repos into a shared workspace
22
+ * guardlink merge <files...> Merge repo reports into unified dashboard
21
23
  *
22
24
  * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve"
23
25
  * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design"
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG"}
package/dist/cli/index.js CHANGED
@@ -18,6 +18,8 @@
18
18
  * guardlink mcp Start MCP server (stdio) for Claude Code, Cursor, etc.
19
19
  * guardlink tui [dir] Interactive TUI with slash commands + AI chat
20
20
  * guardlink gal Display GAL annotation language quick reference
21
+ * guardlink link-project <repos...> Link repos into a shared workspace
22
+ * guardlink merge <files...> Merge repo reports into unified dashboard
21
23
  *
22
24
  * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve"
23
25
  * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design"
@@ -46,6 +48,7 @@ import { generateDashboardHTML } from '../dashboard/index.js';
46
48
  import { AGENTS, agentFromOpts, launchAgent, launchAgentInline, buildAnnotatePrompt } from '../agents/index.js';
47
49
  import { resolveConfig, saveProjectConfig, saveGlobalConfig, loadProjectConfig, loadGlobalConfig, maskKey, describeConfigSource } from '../agents/config.js';
48
50
  import { getReviewableExposures, applyReviewAction, formatExposureForReview, summarizeReview } from '../review/index.js';
51
+ import { populateMetadata, mergeReports, formatMergeSummary, diffMergedReports, formatDiffSummary, linkProject, addToWorkspace, removeFromWorkspace } from '../workspace/index.js';
49
52
  import gradient from 'gradient-string';
50
53
  const program = new Command();
51
54
  const ASCII_LOGO = `
@@ -266,9 +269,10 @@ program
266
269
  .description('Generate a threat model report with Mermaid diagram')
267
270
  .argument('[dir]', 'Project directory to scan', '.')
268
271
  .option('-p, --project <n>', 'Project name', 'unknown')
269
- .option('-o, --output <file>', 'Write report to file (default: threat-model.md)')
272
+ .option('-o, --output <file>', 'Write report to file')
273
+ .option('-f, --format <fmt>', 'Output format: md, json, or both (default: md)', 'md')
270
274
  .option('--diagram-only', 'Output only the Mermaid diagram, no report wrapper')
271
- .option('--json', 'Also output threat-model.json alongside the report')
275
+ .option('--json', 'Also output threat-model.json alongside the report (legacy; prefer --format)')
272
276
  .action(async (dir, opts) => {
273
277
  const root = resolve(dir);
274
278
  const { model, diagnostics } = await parseProject({ root, project: opts.project });
@@ -278,9 +282,11 @@ program
278
282
  printDiagnostics(errors);
279
283
  console.error(`Fix errors above before generating report.\n`);
280
284
  }
285
+ // Enrich with provenance metadata (git SHA, branch, workspace, schema version)
286
+ const enrichedModel = populateMetadata(model, root);
281
287
  if (opts.diagramOnly) {
282
288
  // Just output Mermaid
283
- const mermaid = generateMermaid(model);
289
+ const mermaid = generateMermaid(enrichedModel);
284
290
  if (opts.output) {
285
291
  const { writeFile } = await import('node:fs/promises');
286
292
  await writeFile(opts.output, mermaid + '\n');
@@ -289,19 +295,23 @@ program
289
295
  else {
290
296
  console.log(mermaid);
291
297
  }
298
+ return;
292
299
  }
293
- else {
294
- // Full report
295
- const report = generateReport(model);
296
- const outFile = opts.output || 'threat-model.md';
297
- const { writeFile } = await import('node:fs/promises');
298
- await writeFile(resolve(root, outFile), report + '\n');
299
- console.error(`✓ Wrote threat model report to ${outFile}`);
300
- if (opts.json) {
301
- const jsonFile = outFile.replace(/\.md$/, '.json');
302
- await writeFile(resolve(root, jsonFile), JSON.stringify(model, null, 2) + '\n');
303
- console.error(`✓ Wrote threat model JSON to ${jsonFile}`);
304
- }
300
+ const { writeFile } = await import('node:fs/promises');
301
+ const wantJson = opts.format === 'json' || opts.format === 'both' || opts.json;
302
+ const wantMd = opts.format === 'md' || opts.format === 'both' || opts.json;
303
+ if (wantMd) {
304
+ const report = generateReport(enrichedModel);
305
+ const mdFile = opts.output || (opts.format === 'md' ? 'threat-model.md' : 'threat-model.md');
306
+ await writeFile(resolve(root, mdFile), report + '\n');
307
+ console.error(`✓ Wrote threat model report to ${mdFile}`);
308
+ }
309
+ if (wantJson) {
310
+ const jsonFile = opts.output && opts.format === 'json'
311
+ ? opts.output
312
+ : (opts.output || 'threat-model').replace(/\.md$/, '') + '.json';
313
+ await writeFile(resolve(root, jsonFile), JSON.stringify(enrichedModel, null, 2) + '\n');
314
+ console.error(`✓ Wrote threat model JSON to ${jsonFile} (schema v${enrichedModel.metadata?.schema_version})`);
305
315
  }
306
316
  });
307
317
  // ─── diff ────────────────────────────────────────────────────────────
@@ -971,6 +981,224 @@ program
971
981
  console.error(`✓ Dashboard generated: ${outFile}`);
972
982
  console.error(` Open in browser to view. Toggle ☀️/🌙 for light/dark mode.`);
973
983
  });
984
+ // ─── link-project ────────────────────────────────────────────────────
985
+ program
986
+ .command('link-project')
987
+ .description('Link repos into a shared workspace for cross-repo threat modeling')
988
+ .argument('[repos...]', 'Repo directories to link (fresh setup: 2+ paths)')
989
+ .option('-w, --workspace <n>', 'Workspace name (fresh link only)', 'workspace')
990
+ .option('-r, --registry <url>', 'GitHub/GitLab org base URL (e.g. github.com/unstructured)')
991
+ .option('--add <path>', 'Add a new repo to an existing workspace (provide path to new repo)')
992
+ .option('--remove <name>', 'Remove a repo from the workspace by name')
993
+ .option('--from <path>', 'Existing workspace repo to read config from (used with --add or --remove)')
994
+ .action(async (repos, opts) => {
995
+ let result;
996
+ if (opts.remove) {
997
+ // ── Remove mode: remove a repo from an existing workspace ──
998
+ if (!opts.from) {
999
+ console.error('⚠ --remove requires --from <existing-workspace-repo-path>');
1000
+ console.error(' Example: guardlink link-project --remove payment-svc --from ./api-gateway');
1001
+ process.exit(1);
1002
+ }
1003
+ console.error(`Removing "${opts.remove}" from workspace...`);
1004
+ console.error(` Reference repo: ${opts.from}`);
1005
+ console.error('');
1006
+ result = removeFromWorkspace({
1007
+ repoName: opts.remove,
1008
+ existingRepoPath: opts.from,
1009
+ });
1010
+ for (const name of result.updated) {
1011
+ console.error(` ↻ ${name} — workspace.yaml updated`);
1012
+ }
1013
+ for (const f of result.agentFilesUpdated) {
1014
+ if (f.includes('(cleaned)'))
1015
+ console.error(` 🧹 ${f}`);
1016
+ }
1017
+ for (const s of result.skipped) {
1018
+ console.error(` ✗ ${s.name} — ${s.reason}`);
1019
+ }
1020
+ console.error('');
1021
+ if (result.updated.length > 0) {
1022
+ console.error(`✓ Removed "${opts.remove}", updated ${result.updated.length} remaining repo(s)`);
1023
+ console.error('');
1024
+ console.error('Next steps:');
1025
+ console.error(' 1. Review and commit changes in each updated repo');
1026
+ }
1027
+ else if (result.skipped.length > 0) {
1028
+ process.exit(1);
1029
+ }
1030
+ }
1031
+ else if (opts.add) {
1032
+ // ── Add mode: add a new repo to an existing workspace ──
1033
+ if (!opts.from) {
1034
+ console.error('⚠ --add requires --from <existing-workspace-repo-path>');
1035
+ console.error(' Example: guardlink link-project --add ./new-service --from ./api-gateway');
1036
+ process.exit(1);
1037
+ }
1038
+ console.error(`Adding repo to existing workspace...`);
1039
+ console.error(` New repo: ${opts.add}`);
1040
+ console.error(` From workspace: ${opts.from}`);
1041
+ console.error('');
1042
+ result = addToWorkspace({
1043
+ newRepoPath: opts.add,
1044
+ existingRepoPath: opts.from,
1045
+ registry: opts.registry,
1046
+ });
1047
+ // Report results
1048
+ for (const name of result.initialized) {
1049
+ console.error(` ⚡ ${name} — auto-initialized (no prior guardlink setup)`);
1050
+ }
1051
+ for (const name of result.linked) {
1052
+ console.error(` ✓ ${name} — linked to workspace`);
1053
+ }
1054
+ for (const name of result.updated) {
1055
+ console.error(` ↻ ${name} — workspace.yaml updated`);
1056
+ }
1057
+ for (const s of result.skipped) {
1058
+ console.error(` ✗ ${s.name} — skipped: ${s.reason}`);
1059
+ }
1060
+ console.error('');
1061
+ if (result.linked.length > 0 || result.updated.length > 0) {
1062
+ const total = result.linked.length + result.updated.length;
1063
+ console.error(`✓ ${result.linked.length} repo(s) added, ${result.updated.length} existing repo(s) updated`);
1064
+ if (result.agentFilesUpdated.length > 0) {
1065
+ console.error(` ↻ Updated ${result.agentFilesUpdated.length} agent instruction file(s)`);
1066
+ }
1067
+ // Warn about repos not found on disk
1068
+ // (they're in workspace.yaml but we couldn't locate their directory)
1069
+ console.error('');
1070
+ console.error('Next steps:');
1071
+ console.error(' 1. Review and commit changes in each updated repo');
1072
+ console.error(' 2. Add "guardlink report --format json" to the new repo\'s CI');
1073
+ console.error(' 3. Run "guardlink merge" with all repo reports');
1074
+ }
1075
+ else {
1076
+ console.error('✗ No repos were added. Check the paths provided.');
1077
+ process.exit(1);
1078
+ }
1079
+ }
1080
+ else {
1081
+ // ── Fresh link mode: link N repos together ──
1082
+ if (repos.length < 2) {
1083
+ console.error('⚠ Fresh linking requires at least 2 repo paths.');
1084
+ console.error(' To add a repo to an existing workspace, use:');
1085
+ console.error(' guardlink link-project --add <new-repo> --from <existing-repo>');
1086
+ process.exit(1);
1087
+ }
1088
+ console.error(`Linking ${repos.length} repos into workspace "${opts.workspace}"...`);
1089
+ console.error('');
1090
+ result = linkProject({
1091
+ workspace: opts.workspace,
1092
+ repoPaths: repos,
1093
+ registry: opts.registry,
1094
+ });
1095
+ for (const name of result.initialized) {
1096
+ console.error(` ⚡ ${name} — auto-initialized (no prior guardlink setup)`);
1097
+ }
1098
+ for (const name of result.linked) {
1099
+ console.error(` ✓ ${name} — workspace.yaml written, agent files updated`);
1100
+ }
1101
+ for (const s of result.skipped) {
1102
+ console.error(` ✗ ${s.name} — skipped: ${s.reason}`);
1103
+ }
1104
+ console.error('');
1105
+ if (result.linked.length > 0) {
1106
+ console.error(`✓ Linked ${result.linked.length} repo(s) into "${opts.workspace}"`);
1107
+ if (result.agentFilesUpdated.length > 0) {
1108
+ console.error(` ↻ Updated ${result.agentFilesUpdated.length} agent instruction file(s)`);
1109
+ }
1110
+ console.error('');
1111
+ console.error('Next steps:');
1112
+ console.error(' 1. Review and commit .guardlink/workspace.yaml in each repo');
1113
+ console.error(' 2. Add "guardlink report --format json -o guardlink-report.json" to each repo\'s CI');
1114
+ console.error(' 3. Use "guardlink merge" to combine reports into a unified dashboard');
1115
+ }
1116
+ else {
1117
+ console.error('✗ No repos were linked. Check the paths provided.');
1118
+ process.exit(1);
1119
+ }
1120
+ }
1121
+ });
1122
+ // ─── merge ───────────────────────────────────────────────────────────
1123
+ program
1124
+ .command('merge')
1125
+ .description('Merge multiple repo report JSONs into a unified workspace threat model')
1126
+ .argument('<files...>', 'Report JSON file paths (glob supported)')
1127
+ .option('-o, --output <file>', 'Output file for merged dashboard HTML (default: workspace-dashboard.html)')
1128
+ .option('--json <file>', 'Also write merged report JSON to this file')
1129
+ .option('--diff-against <file>', 'Compare against a previous merged JSON for weekly summary')
1130
+ .option('-w, --workspace <name>', 'Workspace name (auto-detected from reports if not set)')
1131
+ .option('--summary-only', 'Print only the text summary, skip dashboard generation')
1132
+ .action(async (files, opts) => {
1133
+ const { writeFile, readFile } = await import('node:fs/promises');
1134
+ const { resolve: resolvePath } = await import('node:path');
1135
+ // Resolve file paths (support globs via shell expansion — files already expanded by shell)
1136
+ const resolvedFiles = files.map(f => resolvePath(f));
1137
+ if (resolvedFiles.length === 0) {
1138
+ console.error('✗ No report files provided.');
1139
+ process.exit(1);
1140
+ }
1141
+ console.error(`Merging ${resolvedFiles.length} report(s)...`);
1142
+ // Run merge
1143
+ const merged = await mergeReports(resolvedFiles, {
1144
+ workspace: opts.workspace,
1145
+ });
1146
+ const t = merged.totals;
1147
+ // Print summary to stderr
1148
+ console.error('');
1149
+ console.error(`✓ ${merged.workspace} — ${t.repos_loaded}/${t.repos} repos loaded`);
1150
+ console.error(` ${t.annotations} annotations | ${t.assets} assets | ${t.threats} threats | ${t.controls} controls`);
1151
+ console.error(` ${t.mitigations} mitigations | ${t.exposures} exposures | ${t.unmitigated_exposures} unmitigated`);
1152
+ console.error(` ${t.flows} flows | ${t.external_refs_resolved} refs resolved | ${t.external_refs_unresolved} unresolved`);
1153
+ // Print warnings
1154
+ for (const w of merged.warnings) {
1155
+ const icon = w.level === 'error' ? '✗' : w.level === 'warning' ? '⚠' : 'ℹ';
1156
+ console.error(` ${icon} ${w.message}`);
1157
+ }
1158
+ console.error('');
1159
+ // Write merged JSON
1160
+ if (opts.json) {
1161
+ const jsonPath = resolvePath(opts.json);
1162
+ await writeFile(jsonPath, JSON.stringify(merged, null, 2) + '\n');
1163
+ console.error(`✓ Wrote merged JSON to ${opts.json}`);
1164
+ }
1165
+ // Diff against previous
1166
+ if (opts.diffAgainst) {
1167
+ try {
1168
+ const prevRaw = await readFile(resolvePath(opts.diffAgainst), 'utf-8');
1169
+ const previous = JSON.parse(prevRaw);
1170
+ const diff = diffMergedReports(merged, previous);
1171
+ const diffMd = formatDiffSummary(diff, merged.workspace);
1172
+ // Write diff markdown
1173
+ const diffFile = (opts.json || 'workspace-merge').replace(/\.json$/, '') + '-weekly-diff.md';
1174
+ await writeFile(resolvePath(diffFile), diffMd + '\n');
1175
+ console.error(`✓ Wrote weekly diff to ${diffFile}`);
1176
+ // Also print a compact version to stderr
1177
+ const riskIcon = diff.risk_delta === 'increased' ? '🔴'
1178
+ : diff.risk_delta === 'decreased' ? '🟢' : '⚪';
1179
+ console.error(` ${riskIcon} Risk ${diff.risk_delta} since last merge`);
1180
+ if (diff.new_unmitigated > 0)
1181
+ console.error(` 🔴 +${diff.new_unmitigated} new unmitigated exposure(s)`);
1182
+ if (diff.resolved_unmitigated > 0)
1183
+ console.error(` 🟢 ${diff.resolved_unmitigated} exposure(s) now mitigated`);
1184
+ if (diff.mitigations_removed > 0)
1185
+ console.error(` ⚠️ ${diff.mitigations_removed} mitigation(s) removed`);
1186
+ console.error('');
1187
+ }
1188
+ catch (err) {
1189
+ console.error(`⚠ Could not diff against ${opts.diffAgainst}: ${err instanceof Error ? err.message : err}`);
1190
+ }
1191
+ }
1192
+ // Generate dashboard HTML (unless --summary-only)
1193
+ if (!opts.summaryOnly) {
1194
+ const html = generateDashboardHTML(merged.model);
1195
+ const outFile = opts.output || 'workspace-dashboard.html';
1196
+ await writeFile(resolvePath(outFile), html);
1197
+ console.error(`✓ Wrote workspace dashboard to ${outFile}`);
1198
+ }
1199
+ // Print full markdown summary to stdout (pipeable)
1200
+ console.log(formatMergeSummary(merged));
1201
+ });
974
1202
  // ─── mcp ─────────────────────────────────────────────────────────────
975
1203
  program
976
1204
  .command('mcp')