swarmdo 1.37.0 → 1.50.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 (57) hide show
  1. package/README.md +9 -5
  2. package/package.json +1 -1
  3. package/v3/@swarmdo/cli/dist/src/apply/apply.js +6 -1
  4. package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
  5. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +43 -0
  6. package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
  7. package/v3/@swarmdo/cli/dist/src/commands/config.js +37 -1
  8. package/v3/@swarmdo/cli/dist/src/commands/coupling.d.ts +17 -0
  9. package/v3/@swarmdo/cli/dist/src/commands/coupling.js +85 -0
  10. package/v3/@swarmdo/cli/dist/src/commands/hidden-coupling.d.ts +19 -0
  11. package/v3/@swarmdo/cli/dist/src/commands/hidden-coupling.js +92 -0
  12. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +55 -0
  13. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +2 -1
  14. package/v3/@swarmdo/cli/dist/src/commands/index.js +12 -3
  15. package/v3/@swarmdo/cli/dist/src/commands/ownership.d.ts +18 -0
  16. package/v3/@swarmdo/cli/dist/src/commands/ownership.js +88 -0
  17. package/v3/@swarmdo/cli/dist/src/commands/pack.js +4 -4
  18. package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
  19. package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
  20. package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
  21. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
  22. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
  23. package/v3/@swarmdo/cli/dist/src/config-lint/commands-lint.d.ts +31 -0
  24. package/v3/@swarmdo/cli/dist/src/config-lint/commands-lint.js +127 -0
  25. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +8 -0
  26. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +7 -0
  27. package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
  28. package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
  29. package/v3/@swarmdo/cli/dist/src/coupling/hidden.d.ts +45 -0
  30. package/v3/@swarmdo/cli/dist/src/coupling/hidden.js +66 -0
  31. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
  32. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
  33. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +8 -0
  34. package/v3/@swarmdo/cli/dist/src/mcp-client.js +4 -0
  35. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
  36. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -0
  37. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +2 -0
  38. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +2 -0
  39. package/v3/@swarmdo/cli/dist/src/mcp-tools/ownership-tools.d.ts +14 -0
  40. package/v3/@swarmdo/cli/dist/src/mcp-tools/ownership-tools.js +51 -0
  41. package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +2 -0
  42. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
  43. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
  44. package/v3/@swarmdo/cli/dist/src/ownership/ownership.d.ts +61 -0
  45. package/v3/@swarmdo/cli/dist/src/ownership/ownership.js +151 -0
  46. package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +1 -1
  47. package/v3/@swarmdo/cli/dist/src/pack/pack.js +12 -4
  48. package/v3/@swarmdo/cli/dist/src/redact/redact.js +47 -16
  49. package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
  50. package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
  51. package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
  52. package/v3/@swarmdo/cli/dist/src/release/release.js +28 -0
  53. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +9 -1
  54. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +14 -4
  55. package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
  56. package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
  57. package/v3/@swarmdo/cli/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![swarmdo](swarmdo/assets/brand/logo-full.svg)](https://swarmdo.com)
4
4
 
5
- [![npm version (swarmdo)](https://img.shields.io/badge/npx%20swarmdo-v1.37.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
5
+ [![npm version (swarmdo)](https://img.shields.io/badge/npx%20swarmdo-v1.50.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
6
6
  [![MIT License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](https://github.com/SwarmDo/swarmdo/blob/main/LICENSE)
7
7
  [![Website](https://img.shields.io/badge/swarmdo.com-e2a33c?style=for-the-badge&logoColor=black)](https://swarmdo.com)
8
8
  [![Star on GitHub](https://img.shields.io/github/stars/SwarmDo/swarmdo?style=for-the-badge&logo=github&color=gold)](https://github.com/SwarmDo/swarmdo)
@@ -275,12 +275,12 @@ The recent release train added a full day-to-day operations layer around the swa
275
275
  | `swarmdo usage guard` | **Budget policy** — limits for the active 5h block / today / month via flags or `SWARMDO_GUARD_*` env → ok / warn / over; `--strict` exits 1, safe for CI gates and Stop hooks |
276
276
  | `swarmdo hud` | **One-screen ops HUD** — 5h block burn, task readiness, daemon workers, memory snapshots (`--watch`, `--json`) |
277
277
  | `swarmdo repair` (alias `tdd-repair`) | **Test-Driven Repair** — a bounded, budget-capped headless `claude` loop that fixes source until a failing test passes; dry-run unless `--confirm` |
278
- | `swarmdo task … --dependencies` | **Task dependency DAG** — `task ready` lists unblocked work, `task graph` renders the graph, the dispatcher gates on readiness; `task parse-prd <spec.md>` decomposes a PRD straight into the DAG |
278
+ | `swarmdo task … --dependencies` | **Task dependency DAG** — `task ready` lists unblocked work, `task graph` renders the graph, the dispatcher gates on readiness; `task parse-prd <spec.md>` decomposes a PRD straight into the DAG; `task doctor` (alias `health`) separates a transient wait from a **permanent stall** — a task whose prerequisite failed/was cancelled/is missing will never run — and flags whole-graph **deadlock**, `--ci` so a dispatch loop can't spin forever (Airflow `upstream_failed` semantics) |
279
279
  | `swarmdo worktree` (alias `wt`) | **Parallel-agent isolation** on git worktrees — add / list / diff / merge / remove |
280
280
  | `swarmdo transcript` (alias `tx`) | **Export any Claude Code session** to clean markdown — system noise stripped, ready to share; `transcript search <query>` full-text-searches every session |
281
281
  | `swarmdo compact` | **Compress noisy command output** before it reaches an LLM — strip ANSI, collapse repeats, fold `node_modules` stack frames, window long logs. `npm test 2>&1 \| swarmdo compact` or `swarmdo compact -- npm test` (exit code propagates). Deterministic, zero tokens |
282
282
  | `swarmdo codegraph` (alias `cg`) | **Queryable symbol index** — `codegraph index` scans TS/JS for exported symbols; `query <name>` (with `--fuzzy`/`--kind`) and `file <path>` answer "where is X defined / what does this file export" from `.swarm/codegraph.json` instead of grep+read round-trips. `codegraph importers <file>` shows reverse deps ("what breaks if I change this"); `codegraph imports <file>` shows a file's dependencies. 1,786 symbols + import graph across 296 files in <1s. Also MCP tools (`codegraph_query`/`file`/`imports`/`importers`/`index`/`stats`) so agents query the graph in-session |
283
- | `swarmdo redact` | **Mask secrets before they reach an LLM/log/memory** — detect API keys, tokens, and private keys (gitleaks-style rule catalog + Shannon-entropy fallback) and redact them. Stdin filter (`cat deploy.log \| swarmdo redact`), command-wrap (`swarmdo redact -- npm run deploy`), or `--scan` to fail CI on any secret. Also MCP tools (`redact_text`/`redact_scan`). Deterministic, zero tokens |
283
+ | `swarmdo redact` | **Mask secrets before they reach an LLM/log/memory** — detect API keys, tokens, and private keys (gitleaks-style rule catalog + Shannon-entropy fallback) and redact them. Stdin filter (`cat deploy.log \| swarmdo redact`), command-wrap (`swarmdo redact -- npm run deploy`), or `--scan` to fail CI on any secret — `--scan --sarif` emits a **SARIF 2.1.0** report so leaked secrets surface as GitHub code-scanning alerts + PR annotations. Also MCP tools (`redact_text`/`redact_scan`). Deterministic, zero tokens |
284
284
  | `swarmdo pack` | **Bundle a repo into one AI-context blob** — walk the tree (respects `.gitignore` + glob `--include`/`--exclude`, skips binaries/node_modules), emit markdown/xml/json/plain with a directory tree and per-file + total token estimates. `swarmdo pack --tokens` for a budget breakdown; `--redact` masks secrets first. Deterministic |
285
285
  | `swarmdo env` | **Catch env-var drift before deploy** — statically scan code for `process.env.X` / `import.meta.env.X` / `Deno.env.get` / `os.getenv` references and reconcile against `.env` and `.env.example`: reports **missing** (used but undeclared), **unused**, and **undocumented**. `--ci` exits 1 on missing vars. Also an MCP tool (`env_check`). Deterministic |
286
286
  | `swarmdo license` | **Audit dependency licenses** — walk `node_modules`, resolve each package's SPDX license, and gate on an allow/deny policy so a GPL or unknown license can't slip into a permissive tree. `--allow MIT,Apache-2.0 --ci` fails the build; distinct from `security` (CVEs). Also an MCP tool (`license_check`). Deterministic |
@@ -289,16 +289,20 @@ The recent release train added a full day-to-day operations layer around the swa
289
289
  | `swarmdo hotspots` | **Change-risk hotspots from git history** — rank files by churn × recency × author-spread to surface the technical debt worth refactoring or testing, answered from data instead of a guess. `--since 90d`, `--by risk\|churn\|commits\|authors`, `--top N`, `--format json`. Pairs with `codegraph`; also an MCP tool (`hotspots`). Deterministic |
290
290
  | `swarmdo affected` | **Run only the tests your change touches** — from a git diff, walk `codegraph`'s import graph to list every file (and test file) a change could break (reverse-dependency closure, nx/turbo/`jest --findRelatedTests` style). `--base main`, `--tests` (pipeable list), `--format json`. Also an MCP tool (`affected`). Deterministic |
291
291
  | `swarmdo cycles` | **Find circular import dependencies** — an SCC scan over `codegraph`'s import graph surfaces the mutually-importing file groups (and self-imports) that cause temporal-dead-zone and `undefined`-export bugs, `madge --circular` style. `--ci` exits 1 on any cycle to gate a build; `--format json`. Also an MCP tool (`cycles`). Deterministic |
292
+ | `swarmdo coupling` | **Files that change together** — the empirical complement to `affected`'s static import graph: mine git history for file pairs that keep landing in the same commit (a schema and its type, a serializer split across modules) so you catch the co-edit you'd otherwise forget. `--file <path>` answers "what changes with X?", `--since`, `--min-shared`, `--csv`. Modeled on code-maat / CodeScene. Also an MCP tool (`coupling`). Deterministic |
293
+ | `swarmdo ownership` | **Who owns each file, and what breaks if they leave** — a per-file knowledge map + bus factor from git history: the dominant author, ownership concentration, and the fewest authors whose churn clears 50% (a lone owner is flagged ⚠ key-person), plus a repo-wide truck factor. `--since`, `--min-churn`, `--top`, `--csv`, `--format json`. code-maat main-dev / CodeScene "Knowledge Map"; also an MCP tool (`ownership`). Deterministic |
294
+ | `swarmdo hidden-coupling` | **Co-change with no import edge** — join the two graphs swarmdo already owns (temporal `coupling` from git + `codegraph`'s import graph) and emit the set difference: file pairs that keep changing together yet nothing in the code links them ("logical minus structural coupling" — a config and its consumers, a schema and its mirror type). The co-edit `affected` can't see. `--since`, `--min-shared`, `--csv`, `--format json`. Grounded in Gall et al. (ICSM 1998). Deterministic |
292
295
  | `swarmdo testreport` | **JUnit/TAP → failure digest** — turn raw test-result files into the exact failing test names + `file:line` + assertion messages, instead of scanning hundreds of log lines. The front-half of the test→fix loop: feed the failures straight into `repair`. Reads a file, a directory, or stdin; `--ci` exits 1 on any failure, `--format json`. Also an MCP tool (`testreport`). Deterministic |
293
296
  | `swarmdo integrations` (alias `integrate`) | **Use swarmdo from Codex CLI, GitHub Copilot CLI, and pi** — one command wires AGENTS.md + each CLI's MCP config (idempotent, dry-run first, never touches your Claude Code setup) |
294
297
  | OpenRouter model pool | **Let swarms pick from any models you configure** — declare tier-mapped OpenRouter models in `swarmdo.config.json`; the router Thompson-samples among them per task and the execution layer dispatches the winner |
295
- | `swarmdo changelog` (alias `notes`) | **Release notes from conventional commits** — `--out NOTES.md` feeds `gh release create --notes-file` |
298
+ | `swarmdo changelog` (alias `notes`) | **Release notes from conventional commits** — `--out NOTES.md` feeds `gh release create --notes-file`; `--contributors` appends a credited contributor roll |
296
299
  | `swarmdo mcp doctor` | **MCP config diagnosis** — missing binaries, bad URLs, malformed entries across `.mcp.json` + `~/.claude.json` |
300
+ | `swarmdo config lint` | **Static config validation** — the pure shape layer for `swarmdo.config.json`, the `.claude/settings*.json` hooks block, `.mcp.json`, **`.claude/agents/*.md` subagents** (missing `name`/`description`, a `name` duplicated across files — CC silently loads only one — a bad `model`, malformed frontmatter), and **custom slash commands + skills** (malformed YAML that makes CC load empty metadata, a bad `effort`, an inline `` !`cmd` `` bash-injection that's inert or not covered by `allowed-tools`). `--strict` gates CI |
297
301
  | `swarmdo permissions` (alias `perms`) | **Audit your Claude Code permission rules** — static analysis of `permissions.allow`/`deny`/`ask` in `.claude/settings*.json`: flags allow↔deny conflicts (dead rules), over-broad `Bash(*)` grants, shadowed/redundant rules, duplicates, and malformed entries. `--strict` gates CI. Read-only; the static-safety sibling of `config lint` / `mcp doctor` |
298
302
  | `swarmdo comms` (alias `mailbox`) | **Cross-session agent mailbox** — one Claude Code session messages another by name (`send -t <session>`, `-t all` broadcasts, `inbox`, `read`, `watch`); sessions on the same repo share `.swarmdo/comms/`. `inbox --hook` surfaces new mail as prompt context without polling. Also MCP tools (`comms_send`/`comms_inbox`) |
299
303
  | `swarmdo hooks memory-inject` | **Prompt-time semantic memory injection** — embeds each prompt, vector-searches your stored memories, and injects the most relevant under a token budget (recall at the moment of need); wire it with `hooks recipe memory-inject` |
300
304
  | `swarmdo hooks notify -d` | **Desktop notifications** — OS-native toast (macOS `osascript`, Linux `notify-send`) |
301
- | `swarmdo hooks recipe` | **One-command Claude Code hooks** — install `notify-done`/`notify-input` (desktop pings), `memory-inject` (relevant memories each prompt), or `comms-inbox` (new mail as context); dry-run by default, idempotent merge that never clobbers your settings |
305
+ | `swarmdo hooks recipe` | **One-command Claude Code hooks** — install `notify-done`/`notify-input` (desktop pings), `memory-inject` (relevant memories each prompt), `comms-inbox` (new mail as context), or `command-guard` (a PreToolUse deny hook that blocks dangerous bash — `rm -rf /`, pipe-to-shell, force-push to main — the net swarmdo needs when running headless); dry-run by default, idempotent merge that never clobbers your settings |
302
306
  | `swarmdo preset` + `init --preset` | **5-tier capability ladder** — `minimal` → `basic`★ → `standard` → `advanced` → `max`; one word instead of dozens of flags |
303
307
  | `swarmdo memory export/import -f obsidian` | **Obsidian vault roundtrip** — DB → markdown notes (YAML frontmatter, `[[wikilinks]]` stay live) → edit in Obsidian → sync back, re-embedded; `import --watch` keeps the vault live-synced as you edit |
304
308
  | `swarmdo memory backup` / `revectorize` | WAL-safe nightly DB snapshots · repair hash-era vectors |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmdo",
3
- "version": "1.37.0",
3
+ "version": "1.50.0",
4
4
  "description": "Swarmdo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -150,7 +150,12 @@ export function applyPatch(source, patch, opts = {}) {
150
150
  let eofNoEol;
151
151
  for (const hunk of patch.hunks) {
152
152
  const { oldBlock, newBlock } = hunkBlocks(hunk);
153
- const expected = hunk.oldStart - 1 + offset;
153
+ // For a hunk with old lines, oldStart is 1-based 0-based is oldStart-1.
154
+ // But a pure INSERTION uses `@@ -L,0 +M @@` where L is the line AFTER which
155
+ // to insert (what `diff -U0` emits), so the splice index is L (= oldStart),
156
+ // not L-1 — otherwise every zero-context insertion lands one line too early
157
+ // and still reports applied:true (silent corruption). (#91)
158
+ const expected = hunk.oldStart - (oldBlock.length === 0 ? 0 : 1) + offset;
154
159
  // Try the full block, then progressively trim up to `fuzz` context lines
155
160
  // off each end (patch fuzz) to tolerate drift.
156
161
  let placed = -1;
@@ -57,4 +57,25 @@ export declare function lastTag(git: GitRunner): string | null;
57
57
  export declare function repoUrlFromGit(git: GitRunner): string | null;
58
58
  /** Read + parse commits in a range (e.g. "v1.3.0..HEAD"). */
59
59
  export declare function collectCommits(range: string, git: GitRunner): ParsedCommit[];
60
+ export interface Contributor {
61
+ name: string;
62
+ handle: string | null;
63
+ }
64
+ /**
65
+ * GitHub no-reply author emails encode the account login:
66
+ * `<id>+<login>@users.noreply.github.com` (current form)
67
+ * `<login>@users.noreply.github.com` (legacy form)
68
+ * Return the @handle when the email is one of those, else null.
69
+ */
70
+ export declare function githubHandle(email: string): string | null;
71
+ /**
72
+ * Distinct commit authors in a range, oldest-first (git log is newest-first, so
73
+ * we reverse — the earliest contributor to the range leads). Deduped by author
74
+ * name (case-insensitive); the GitHub @handle is carried when the author email
75
+ * is a github no-reply address (back-filled if any of an author's commits has one).
76
+ * Pure but for the injected git read, mirroring collectCommits.
77
+ */
78
+ export declare function collectContributors(range: string, git: GitRunner): Contributor[];
79
+ /** Render the `### Contributors` section (empty string when there are none). */
80
+ export declare function renderContributors(contributors: Contributor[]): string;
60
81
  //# sourceMappingURL=changelog.d.ts.map
@@ -161,4 +161,47 @@ export function collectCommits(range, git) {
161
161
  const raw = git(['log', range, '--no-merges', '--pretty=format:%h%x1f%s%x1f%b%x1e']);
162
162
  return parseGitLog(raw);
163
163
  }
164
+ /**
165
+ * GitHub no-reply author emails encode the account login:
166
+ * `<id>+<login>@users.noreply.github.com` (current form)
167
+ * `<login>@users.noreply.github.com` (legacy form)
168
+ * Return the @handle when the email is one of those, else null.
169
+ */
170
+ export function githubHandle(email) {
171
+ const m = /^(?:\d+\+)?([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)@users\.noreply\.github\.com$/.exec(email.trim());
172
+ return m ? m[1] : null;
173
+ }
174
+ /**
175
+ * Distinct commit authors in a range, oldest-first (git log is newest-first, so
176
+ * we reverse — the earliest contributor to the range leads). Deduped by author
177
+ * name (case-insensitive); the GitHub @handle is carried when the author email
178
+ * is a github no-reply address (back-filled if any of an author's commits has one).
179
+ * Pure but for the injected git read, mirroring collectCommits.
180
+ */
181
+ export function collectContributors(range, git) {
182
+ const raw = git(['log', range, '--no-merges', '--format=%aN%x1f%aE']);
183
+ const seen = new Map();
184
+ const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
185
+ for (const line of lines.reverse()) {
186
+ const [name, email = ''] = line.split('\x1f');
187
+ if (!name)
188
+ continue;
189
+ const key = name.toLowerCase();
190
+ const existing = seen.get(key);
191
+ if (existing) {
192
+ if (!existing.handle)
193
+ existing.handle = githubHandle(email); // back-fill
194
+ continue;
195
+ }
196
+ seen.set(key, { name, handle: githubHandle(email) });
197
+ }
198
+ return [...seen.values()];
199
+ }
200
+ /** Render the `### Contributors` section (empty string when there are none). */
201
+ export function renderContributors(contributors) {
202
+ if (!contributors.length)
203
+ return '';
204
+ const items = contributors.map((c) => (c.handle ? `- ${c.name} (@${c.handle})` : `- ${c.name}`));
205
+ return ['### Contributors', '', ...items, ''].join('\n');
206
+ }
164
207
  //# sourceMappingURL=changelog.js.map
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import * as fs from 'node:fs';
12
12
  import { output } from '../output.js';
13
- import { makeGitRunner, lastTag, repoUrlFromGit, collectCommits, renderChangelog, } from '../changelog/changelog.js';
13
+ import { makeGitRunner, lastTag, repoUrlFromGit, collectCommits, collectContributors, renderChangelog, renderContributors, } from '../changelog/changelog.js';
14
14
  function isRepo(git) {
15
15
  try {
16
16
  git(['rev-parse', '--git-dir']);
@@ -32,6 +32,7 @@ export const changelogCommand = {
32
32
  { name: 'out', short: 'o', type: 'string', description: 'write to this file instead of stdout' },
33
33
  { name: 'all', short: 'a', type: 'boolean', description: 'include chore/test/ci/build/style + non-conventional commits', default: false },
34
34
  { name: 'no-links', type: 'boolean', description: 'omit GitHub commit links', default: false },
35
+ { name: 'contributors', short: 'c', type: 'boolean', description: 'append a ### Contributors section (commit authors in the range)', default: false },
35
36
  { name: 'date', type: 'string', description: 'override the date shown in the title (default: today)' },
36
37
  ],
37
38
  examples: [
@@ -65,7 +66,17 @@ export const changelogCommand = {
65
66
  const repoUrl = ctx.flags['no-links'] === true ? undefined : (repoUrlFromGit(git) ?? undefined);
66
67
  const date = ctx.flags.date || new Date().toISOString().slice(0, 10);
67
68
  const version = ctx.flags.version || range;
68
- const md = renderChangelog(commits, { version, date, repoUrl, includeAll: ctx.flags.all === true });
69
+ let md = renderChangelog(commits, { version, date, repoUrl, includeAll: ctx.flags.all === true });
70
+ // Opt-in `### Contributors` section — a separate git read over the SAME range,
71
+ // so the notes credit everyone whose commits shipped in the release.
72
+ let contributors = 0;
73
+ if (ctx.flags.contributors === true) {
74
+ const people = collectContributors(range, git);
75
+ contributors = people.length;
76
+ const section = renderContributors(people);
77
+ if (section)
78
+ md = `${md.replace(/\n+$/, '\n')}\n${section}`;
79
+ }
69
80
  const outFile = ctx.flags.out;
70
81
  if (outFile) {
71
82
  try {
@@ -76,10 +87,10 @@ export const changelogCommand = {
76
87
  return { success: false, exitCode: 1 };
77
88
  }
78
89
  output.printSuccess(`Wrote ${commits.length} commit(s) of release notes → ${outFile}`);
79
- return { success: true, data: { file: outFile, commits: commits.length, range } };
90
+ return { success: true, data: { file: outFile, commits: commits.length, range, contributors } };
80
91
  }
81
92
  output.writeln(md);
82
- return { success: true, data: { commits: commits.length, range } };
93
+ return { success: true, data: { commits: commits.length, range, contributors } };
83
94
  },
84
95
  };
85
96
  export default changelogCommand;
@@ -400,7 +400,7 @@ const importCommand = {
400
400
  const lintCommand = {
401
401
  name: 'lint',
402
402
  aliases: ['validate'],
403
- description: 'Statically validate swarmdo.config.json, .claude/settings*.json hooks, .mcp.json, and the post-1.4 sDo layout',
403
+ description: 'Statically validate swarmdo.config.json, .claude/settings*.json hooks, .mcp.json, .claude/agents/*.md subagents, .claude/commands + skills slash-command frontmatter/body, and the post-1.4 sDo layout',
404
404
  options: [
405
405
  { name: 'json', type: 'boolean', description: 'machine-readable findings', default: false },
406
406
  { name: 'strict', type: 'boolean', description: 'exit 1 on warnings too (default: errors only)', default: false },
@@ -433,6 +433,39 @@ const lintCommand = {
433
433
  const configRel = process.env.SWARMDO_CONFIG && !path.isAbsolute(process.env.SWARMDO_CONFIG)
434
434
  ? process.env.SWARMDO_CONFIG.replace(/^\.\//, '')
435
435
  : 'swarmdo.config.json';
436
+ const agentFiles = list('.claude/agents')
437
+ .filter((n) => n.endsWith('.md'))
438
+ .map((n) => read(`.claude/agents/${n}`))
439
+ .filter((x) => x.raw !== null);
440
+ // Custom slash commands nest (e.g. `.claude/commands/sDo/*.md`), so walk the
441
+ // tree; skills live one dir deep as `.claude/skills/<name>/SKILL.md`.
442
+ const walkMd = (rel) => {
443
+ const acc = [];
444
+ let entries;
445
+ try {
446
+ entries = fs.readdirSync(path.join(cwd, rel), { withFileTypes: true });
447
+ }
448
+ catch {
449
+ return acc;
450
+ }
451
+ for (const e of entries) {
452
+ if (e.name.startsWith('.'))
453
+ continue;
454
+ const childRel = `${rel}/${e.name}`;
455
+ if (e.isDirectory())
456
+ acc.push(...walkMd(childRel));
457
+ else if (e.isFile() && e.name.endsWith('.md')) {
458
+ const r = read(childRel);
459
+ if (r.raw !== null)
460
+ acc.push({ file: childRel, raw: r.raw });
461
+ }
462
+ }
463
+ return acc;
464
+ };
465
+ const commandFiles = walkMd('.claude/commands');
466
+ const skillFiles = list('.claude/skills')
467
+ .map((dir) => read(`.claude/skills/${dir}/SKILL.md`))
468
+ .filter((x) => x.raw !== null);
436
469
  const report = lintAll({
437
470
  swarmdoConfig: read(configRel),
438
471
  settingsFiles: [read('.claude/settings.json'), read('.claude/settings.local.json')],
@@ -440,6 +473,9 @@ const lintCommand = {
440
473
  commandsRoot: list('.claude/commands'),
441
474
  sdoCommands: list('.claude/commands/sDo'),
442
475
  skills: list('.claude/skills'),
476
+ agentFiles,
477
+ commandFiles,
478
+ skillFiles,
443
479
  });
444
480
  const failed = report.errors > 0 || (ctx.flags.strict === true && report.warnings > 0);
445
481
  if (ctx.flags.json === true) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `swarmdo coupling` — temporal (co-change) coupling mined from git history.
3
+ *
4
+ * swarmdo coupling # top co-changing file pairs
5
+ * swarmdo coupling --since 90d --min-shared 3
6
+ * swarmdo coupling --file src/auth.ts # what changes WITH auth.ts?
7
+ * swarmdo coupling --format json | --csv
8
+ *
9
+ * The EMPIRICAL complement to `affected` (which walks the static import graph):
10
+ * files that keep landing in the same commit are coupled in practice even with
11
+ * no import edge. Engine (../coupling/coupling.ts) is pure + tested; this
12
+ * captures the git log (the same `--numstat` dump `hotspots` uses).
13
+ */
14
+ import type { Command } from '../types.js';
15
+ export declare const couplingCommand: Command;
16
+ export default couplingCommand;
17
+ //# sourceMappingURL=coupling.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `swarmdo coupling` — temporal (co-change) coupling mined from git history.
3
+ *
4
+ * swarmdo coupling # top co-changing file pairs
5
+ * swarmdo coupling --since 90d --min-shared 3
6
+ * swarmdo coupling --file src/auth.ts # what changes WITH auth.ts?
7
+ * swarmdo coupling --format json | --csv
8
+ *
9
+ * The EMPIRICAL complement to `affected` (which walks the static import graph):
10
+ * files that keep landing in the same commit are coupled in practice even with
11
+ * no import edge. Engine (../coupling/coupling.ts) is pure + tested; this
12
+ * captures the git log (the same `--numstat` dump `hotspots` uses).
13
+ */
14
+ import { execFileSync } from 'node:child_process';
15
+ import { output } from '../output.js';
16
+ import { parseGitLog } from '../hotspots/hotspots.js';
17
+ import { computeCoupling, formatCoupling, couplingToCsv } from '../coupling/coupling.js';
18
+ import { normalizeSince } from '../util/since.js';
19
+ /** Read a numeric flag that the parser may deliver as a number OR a string. */
20
+ function numFlag(v, def) {
21
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? parseInt(v, 10) : NaN;
22
+ return Number.isFinite(n) ? n : def;
23
+ }
24
+ async function run(ctx) {
25
+ const root = ctx.cwd || process.cwd();
26
+ const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : '1 year ago';
27
+ const top = numFlag(ctx.flags.top, 30);
28
+ const minShared = numFlag(ctx.flags['min-shared'], 2);
29
+ const maxFiles = numFlag(ctx.flags['max-files'], 30);
30
+ const focus = typeof ctx.flags.file === 'string' ? ctx.flags.file : (ctx.args[0] || undefined);
31
+ const asJson = ctx.flags.format === 'json';
32
+ const asCsv = ctx.flags.csv === true;
33
+ // NOTE: unlike `hotspots`, we do NOT pathspec-filter the log to `focus` — that
34
+ // would strip the co-changed files out of each commit's numstat. We capture
35
+ // the full history and filter PAIRS in the engine (opts.focus) instead.
36
+ const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
37
+ let raw;
38
+ try {
39
+ raw = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
40
+ }
41
+ catch {
42
+ output.printError('git log failed — is this a git repository?');
43
+ return { success: false, exitCode: 1 };
44
+ }
45
+ const pairs = computeCoupling(parseGitLog(raw), {
46
+ minShared,
47
+ maxFiles,
48
+ top: top > 0 ? top : undefined,
49
+ focus,
50
+ });
51
+ if (asCsv) {
52
+ process.stdout.write(couplingToCsv(pairs) + '\n');
53
+ }
54
+ else if (asJson) {
55
+ process.stdout.write(JSON.stringify({ generated: new Date().toISOString(), since, minShared, count: pairs.length, coupling: pairs }, null, 2) + '\n');
56
+ }
57
+ else if (pairs.length === 0) {
58
+ output.writeln(output.dim(`no co-change coupling found${focus ? ` for ${focus}` : ''} — raise --since or lower --min-shared`));
59
+ }
60
+ else {
61
+ output.writeln(output.bold(`Co-change coupling${focus ? ` for ${focus}` : ''} (since ${since}, min-shared ${minShared})`));
62
+ output.writeln(formatCoupling(pairs));
63
+ }
64
+ return { success: true, exitCode: 0 };
65
+ }
66
+ export const couplingCommand = {
67
+ name: 'coupling',
68
+ description: 'Rank file pairs that change together in git history (temporal/co-change coupling) — the empirical complement to `affected`',
69
+ options: [
70
+ { name: 'since', description: 'history window, e.g. 90d or "3 months ago" (default 1 year)', type: 'string' },
71
+ { name: 'min-shared', description: 'drop pairs sharing fewer than N commits (default 2)', type: 'string' },
72
+ { name: 'max-files', description: 'skip commits touching more than N files (default 30; 0 = no cap)', type: 'string' },
73
+ { name: 'file', description: 'show only pairs involving this path ("what changes with X?")', type: 'string' },
74
+ { name: 'top', description: 'keep only the top N pairs (default 30; 0 = all)', type: 'string' },
75
+ { name: 'csv', description: 'export the ranking as CSV (for spreadsheets)', type: 'boolean' },
76
+ ],
77
+ examples: [
78
+ { command: 'swarmdo coupling --since 6mo --min-shared 3', description: 'Strongly co-changing pairs in the last 6 months' },
79
+ { command: 'swarmdo coupling --file src/auth.ts', description: 'What tends to change together with auth.ts?' },
80
+ { command: 'swarmdo coupling --csv > coupling.csv', description: 'Export the coupling ranking to CSV' },
81
+ ],
82
+ action: run,
83
+ };
84
+ export default couplingCommand;
85
+ //# sourceMappingURL=coupling.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `swarmdo hidden-coupling` — files that CHANGE TOGETHER but have no import edge.
3
+ *
4
+ * swarmdo hidden-coupling # co-change pairs the code doesn't explain
5
+ * swarmdo hidden-coupling --since 6mo --min-shared 3
6
+ * swarmdo hidden-coupling --format json | --csv
7
+ *
8
+ * "Logical coupling minus structural coupling": pairs with a high co-change
9
+ * `degree` (from `coupling`) yet NO import edge connecting them (from
10
+ * `codegraph`). A dependency real enough to move two files in lockstep but
11
+ * invisible in the code — the co-edit an agent following imports would miss, or
12
+ * a missing abstraction a reviewer should question. Composes the two capture
13
+ * paths verbatim: git-log + `computeCoupling`, and the codegraph index load.
14
+ * Engine (../coupling/hidden.ts) is pure + tested.
15
+ */
16
+ import type { Command } from '../types.js';
17
+ export declare const hiddenCouplingCommand: Command;
18
+ export default hiddenCouplingCommand;
19
+ //# sourceMappingURL=hidden-coupling.d.ts.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `swarmdo hidden-coupling` — files that CHANGE TOGETHER but have no import edge.
3
+ *
4
+ * swarmdo hidden-coupling # co-change pairs the code doesn't explain
5
+ * swarmdo hidden-coupling --since 6mo --min-shared 3
6
+ * swarmdo hidden-coupling --format json | --csv
7
+ *
8
+ * "Logical coupling minus structural coupling": pairs with a high co-change
9
+ * `degree` (from `coupling`) yet NO import edge connecting them (from
10
+ * `codegraph`). A dependency real enough to move two files in lockstep but
11
+ * invisible in the code — the co-edit an agent following imports would miss, or
12
+ * a missing abstraction a reviewer should question. Composes the two capture
13
+ * paths verbatim: git-log + `computeCoupling`, and the codegraph index load.
14
+ * Engine (../coupling/hidden.ts) is pure + tested.
15
+ */
16
+ import { execFileSync } from 'node:child_process';
17
+ import { output } from '../output.js';
18
+ import { parseGitLog } from '../hotspots/hotspots.js';
19
+ import { computeCoupling } from '../coupling/coupling.js';
20
+ import { computeHiddenCoupling, formatHiddenCoupling, hiddenCouplingToCsv } from '../coupling/hidden.js';
21
+ import { loadIndex, scanRepo, saveIndex } from '../codegraph/store.js';
22
+ import { normalizeSince } from '../util/since.js';
23
+ /** Read a numeric flag that the parser may deliver as a number OR a string. */
24
+ function numFlag(v, def) {
25
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? parseInt(v, 10) : NaN;
26
+ return Number.isFinite(n) ? n : def;
27
+ }
28
+ async function run(ctx) {
29
+ const root = ctx.cwd || process.cwd();
30
+ const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : '1 year ago';
31
+ const top = numFlag(ctx.flags.top, 30);
32
+ const minShared = numFlag(ctx.flags['min-shared'], 2);
33
+ const maxFiles = numFlag(ctx.flags['max-files'], 30);
34
+ const asJson = ctx.flags.format === 'json';
35
+ const asCsv = ctx.flags.csv === true;
36
+ // Capture 1: the full co-change history (same `--numstat` dump `coupling` uses;
37
+ // NO pathspec filter — that would strip co-changed files from each commit).
38
+ const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
39
+ let raw;
40
+ try {
41
+ raw = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
42
+ }
43
+ catch {
44
+ output.printError('git log failed — is this a git repository?');
45
+ return { success: false, exitCode: 1 };
46
+ }
47
+ // Rank ALL coupling pairs (no top yet — we cap the HIDDEN set after filtering).
48
+ const pairs = computeCoupling(parseGitLog(raw), { minShared, maxFiles });
49
+ // Capture 2: the static import graph (prefer a saved index; build one if absent).
50
+ let index = loadIndex(root);
51
+ if (!index) {
52
+ index = scanRepo(root);
53
+ try {
54
+ saveIndex(root, index);
55
+ }
56
+ catch { /* read-only fs — in-memory is fine */ }
57
+ }
58
+ const hidden = computeHiddenCoupling(pairs, index.imports, { top: top > 0 ? top : undefined });
59
+ if (asCsv) {
60
+ process.stdout.write(hiddenCouplingToCsv(hidden) + '\n');
61
+ }
62
+ else if (asJson) {
63
+ process.stdout.write(JSON.stringify({ generated: new Date().toISOString(), since, minShared, coupled: pairs.length, count: hidden.length, hidden }, null, 2) + '\n');
64
+ }
65
+ else if (hidden.length === 0) {
66
+ output.writeln(output.dim(`no hidden coupling found — every co-change pair has an import edge (or none met --min-shared ${minShared} / --since ${since})`));
67
+ }
68
+ else {
69
+ output.writeln(output.bold(`Hidden coupling (co-change with NO import edge, since ${since}, min-shared ${minShared})`));
70
+ output.writeln(formatHiddenCoupling(hidden));
71
+ output.writeln(output.dim(`${hidden.length} hidden of ${pairs.length} coupled pair(s) — these move together but nothing in the code links them`));
72
+ }
73
+ return { success: true, exitCode: 0 };
74
+ }
75
+ export const hiddenCouplingCommand = {
76
+ name: 'hidden-coupling',
77
+ description: 'Rank file pairs that change together in git history but have NO import edge (logical minus structural coupling) — the co-edit `affected` can\'t see',
78
+ options: [
79
+ { name: 'since', description: 'history window, e.g. 90d or "3 months ago" (default 1 year)', type: 'string' },
80
+ { name: 'min-shared', description: 'drop pairs sharing fewer than N commits (default 2)', type: 'string' },
81
+ { name: 'max-files', description: 'skip commits touching more than N files (default 30; 0 = no cap)', type: 'string' },
82
+ { name: 'top', description: 'keep only the top N hidden pairs (default 30; 0 = all)', type: 'string' },
83
+ { name: 'csv', description: 'export the ranking as CSV (for spreadsheets)', type: 'boolean' },
84
+ ],
85
+ examples: [
86
+ { command: 'swarmdo hidden-coupling --since 6mo --min-shared 3', description: 'Strong co-change pairs with no code link, last 6 months' },
87
+ { command: 'swarmdo hidden-coupling --format json', description: 'Machine-readable hidden-coupling report' },
88
+ ],
89
+ action: run,
90
+ };
91
+ export default hiddenCouplingCommand;
92
+ //# sourceMappingURL=hidden-coupling.js.map
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { bridgeSearchEntries } from '../memory/memory-bridge.js';
12
12
  import { selectInjectionMemories, mapSearchResultsToCandidates, extractPromptFromPayload, } from '../memory-inject/select.js';
13
+ import { classifyCommand, extractBashCommand, denyOutput } from '../hooks-recipe/command-guard.js';
13
14
  /**
14
15
  * #1686 — `?? 0` only defaults null/undefined; NaN slips through and
15
16
  * surfaces as `"NaN"` (or earlier crashed `.toFixed`) in the metrics
@@ -849,6 +850,58 @@ const memoryInjectCommand = {
849
850
  }
850
851
  },
851
852
  };
853
+ /**
854
+ * guard-bash — the #83 PreToolUse/Bash guardrail. Reads the hook payload from
855
+ * stdin (or --command), classifies the bash command against a conservative
856
+ * destructive-command denylist, and on a match emits Claude Code's
857
+ * `permissionDecision: "deny"` to hard-block it. This is the only safety net
858
+ * that still fires under headless `claude -p --dangerously-skip-permissions`.
859
+ * Hook-safe: it NEVER errors and stays silent (allow) for anything not on the
860
+ * denylist, so it can't break a legitimate command.
861
+ */
862
+ const guardBashCommand = {
863
+ name: 'guard-bash',
864
+ description: 'Block dangerous bash commands (PreToolUse hook): emits permissionDecision:deny on a destructive-command denylist',
865
+ options: [
866
+ { name: 'command', short: 'c', description: 'Command to classify (else read the hook JSON from stdin)', type: 'string' },
867
+ { name: 'preview', description: 'Print the human-readable verdict instead of emitting hook JSON', type: 'boolean' },
868
+ ],
869
+ examples: [
870
+ { command: 'swarmdo hooks guard-bash -c "rm -rf /" --preview', description: 'Check whether a command would be blocked' },
871
+ { command: 'echo \'{"tool_input":{"command":"rm -rf /"}}\' | swarmdo hooks guard-bash', description: 'Run as a PreToolUse/Bash hook' },
872
+ ],
873
+ action: async (ctx) => {
874
+ const preview = Boolean(ctx.flags.preview);
875
+ // A hook must NEVER break a tool call by crashing: swallow everything, default to allow.
876
+ try {
877
+ if (process.env.SWARMDO_GUARD_BASH_DISABLE === '1')
878
+ return { success: true };
879
+ let command = (ctx.flags.command || ctx.args.join(' ')).trim();
880
+ if (!command)
881
+ command = extractBashCommand(await readHookStdin());
882
+ if (!command)
883
+ return { success: true }; // nothing to classify → allow
884
+ const verdict = classifyCommand(command);
885
+ if (preview) {
886
+ if (verdict.block)
887
+ output.printError(`BLOCK: ${verdict.reason} [${verdict.rule}]`);
888
+ else
889
+ output.printSuccess('allow — no destructive pattern matched');
890
+ return { success: true, data: verdict };
891
+ }
892
+ // Hook mode: emit the deny JSON only on a block; silence = allow.
893
+ if (verdict.block) {
894
+ process.stdout.write(JSON.stringify(denyOutput(verdict.reason)) + '\n');
895
+ }
896
+ return { success: true };
897
+ }
898
+ catch (err) {
899
+ if (preview)
900
+ output.printError(`guard-bash failed: ${String(err)}`);
901
+ return { success: true }; // never fail the hook (fail-open: a broken guard must not block work)
902
+ }
903
+ },
904
+ };
852
905
  // Explain subcommand
853
906
  const explainCommand = {
854
907
  name: 'explain',
@@ -4672,6 +4725,7 @@ export const hooksCommand = {
4672
4725
  sessionRestoreCommand,
4673
4726
  routeCommand,
4674
4727
  memoryInjectCommand,
4728
+ guardBashCommand,
4675
4729
  explainCommand,
4676
4730
  pretrainCommand,
4677
4731
  buildAgentsCommand,
@@ -4730,6 +4784,7 @@ export const hooksCommand = {
4730
4784
  `${output.highlight('session-restore')} - Restore a previous session`,
4731
4785
  `${output.highlight('route')} - Route tasks to optimal agents`,
4732
4786
  `${output.highlight('memory-inject')} - Inject relevant memories into the prompt (UserPromptSubmit)`,
4787
+ `${output.highlight('guard-bash')} - Block dangerous bash commands (PreToolUse deny)`,
4733
4788
  `${output.highlight('explain')} - Explain routing decisions`,
4734
4789
  `${output.highlight('pretrain')} - Bootstrap intelligence from repository`,
4735
4790
  `${output.highlight('build-agents')} - Generate optimized agent configs`,
@@ -14,6 +14,7 @@
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { output } from '../output.js';
16
16
  import { parseGitLog, computeHotspots, formatHotspots, hotspotsToCsv } from '../hotspots/hotspots.js';
17
+ import { normalizeSince } from '../util/since.js';
17
18
  const SORT_KEYS = ['risk', 'churn', 'commits', 'authors'];
18
19
  /** Read a numeric flag that the parser may deliver as a number OR a string. */
19
20
  function numFlag(v, def) {
@@ -38,7 +39,7 @@ async function run(ctx) {
38
39
  // `%an`) resolves author names through `.mailmap`, so name/email variants of
39
40
  // the same person fold into one identity — otherwise the author-spread factor
40
41
  // in the risk score is silently inflated. Identical to `%an` when no .mailmap.
41
- const args = ['log', '--no-merges', '--numstat', `--since=${since}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
42
+ const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
42
43
  if (pathArg)
43
44
  args.push('--', pathArg);
44
45
  let raw;
@@ -51,6 +51,15 @@ const commandLoaders = {
51
51
  // Circular-import detector (madge --circular demand) — SCC scan over
52
52
  // codegraph's import graph; catches TDZ/undefined-export bugs.
53
53
  cycles: () => import('./cycles.js'),
54
+ // Temporal (co-change) coupling from git history (code-maat demand) — file
55
+ // pairs that change together; the empirical complement to `affected`.
56
+ coupling: () => import('./coupling.js'),
57
+ // Per-file knowledge map + bus factor from git history (code-maat main-dev
58
+ // demand) — who owns each file, and what breaks if they leave.
59
+ ownership: () => import('./ownership.js'),
60
+ // Hidden coupling — co-change pairs with NO import edge (logical minus
61
+ // structural coupling); joins `coupling`'s git capture with codegraph's graph.
62
+ 'hidden-coupling': () => import('./hidden-coupling.js'),
54
63
  // JUnit/TAP test-result parser (dorny/test-reporter demand) — raw results →
55
64
  // failing test + file:line + message; the front-half of the test→fix loop.
56
65
  testreport: () => import('./testreport.js'),
@@ -293,7 +302,7 @@ export async function getCommandsByCategory() {
293
302
  // three slots from 'statusline' onward (completionsCmd received the
294
303
  // statusline module, analyzeCmd received completions, …), scrambling the
295
304
  // categorized help. Every load now has a named slot.
296
- const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd, compactSnapshotCmd,] = await Promise.all([
305
+ const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, couplingCmd, ownershipCmd, hiddenCouplingCmd, testreportCmd, compactSnapshotCmd,] = await Promise.all([
297
306
  loadCommand('daemon'), loadCommand('doctor'), loadCommand('embeddings'), loadCommand('neural'),
298
307
  loadCommand('performance'), loadCommand('security'), loadCommand('swarmvector'), loadCommand('hive-mind'),
299
308
  loadCommand('config'), loadCommand('statusline'), loadCommand('compress'), loadCommand('efficiency'),
@@ -301,7 +310,7 @@ export async function getCommandsByCategory() {
301
310
  loadCommand('analyze'), loadCommand('route'), loadCommand('progress'), loadCommand('providers'),
302
311
  loadCommand('plugins'), loadCommand('deployment'), loadCommand('claims'), loadCommand('issues'),
303
312
  loadCommand('update'), loadCommand('process'), loadCommand('guidance'), loadCommand('appliance'),
304
- loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'), loadCommand('redact'), loadCommand('pack'), loadCommand('env'), loadCommand('license'), loadCommand('sbom'), loadCommand('apply'), loadCommand('hotspots'), loadCommand('affected'), loadCommand('cycles'), loadCommand('testreport'), loadCommand('compact-snapshot'),
313
+ loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'), loadCommand('redact'), loadCommand('pack'), loadCommand('env'), loadCommand('license'), loadCommand('sbom'), loadCommand('apply'), loadCommand('hotspots'), loadCommand('affected'), loadCommand('cycles'), loadCommand('coupling'), loadCommand('ownership'), loadCommand('hidden-coupling'), loadCommand('testreport'), loadCommand('compact-snapshot'),
305
314
  ]);
306
315
  return {
307
316
  primary: [
@@ -317,7 +326,7 @@ export async function getCommandsByCategory() {
317
326
  utility: [
318
327
  configCmd, doctorCmd, daemonCmd, completionsCmd,
319
328
  migrateCmd, workflowCmd, demoCmd,
320
- statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
329
+ statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, couplingCmd, ownershipCmd, hiddenCouplingCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
321
330
  ].filter(Boolean),
322
331
  analysis: [
323
332
  analyzeCmd, routeCmd, progressCmd, usageCmd, hudCmd, codegraphCmd,