swarmdo 1.45.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 (27) hide show
  1. package/README.md +4 -2
  2. package/package.json +1 -1
  3. package/v3/@swarmdo/cli/dist/src/commands/config.js +32 -1
  4. package/v3/@swarmdo/cli/dist/src/commands/hidden-coupling.d.ts +19 -0
  5. package/v3/@swarmdo/cli/dist/src/commands/hidden-coupling.js +92 -0
  6. package/v3/@swarmdo/cli/dist/src/commands/index.js +9 -3
  7. package/v3/@swarmdo/cli/dist/src/commands/ownership.d.ts +18 -0
  8. package/v3/@swarmdo/cli/dist/src/commands/ownership.js +88 -0
  9. package/v3/@swarmdo/cli/dist/src/commands/pack.js +4 -4
  10. package/v3/@swarmdo/cli/dist/src/config-lint/commands-lint.d.ts +31 -0
  11. package/v3/@swarmdo/cli/dist/src/config-lint/commands-lint.js +127 -0
  12. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +5 -0
  13. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +4 -0
  14. package/v3/@swarmdo/cli/dist/src/coupling/hidden.d.ts +45 -0
  15. package/v3/@swarmdo/cli/dist/src/coupling/hidden.js +66 -0
  16. package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
  17. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
  18. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
  19. package/v3/@swarmdo/cli/dist/src/mcp-tools/ownership-tools.d.ts +14 -0
  20. package/v3/@swarmdo/cli/dist/src/mcp-tools/ownership-tools.js +51 -0
  21. package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
  22. package/v3/@swarmdo/cli/dist/src/ownership/ownership.d.ts +61 -0
  23. package/v3/@swarmdo/cli/dist/src/ownership/ownership.js +151 -0
  24. package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +1 -1
  25. package/v3/@swarmdo/cli/dist/src/pack/pack.js +12 -4
  26. package/v3/@swarmdo/cli/dist/src/redact/redact.js +28 -9
  27. 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.45.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)
@@ -290,12 +290,14 @@ The recent release train added a full day-to-day operations layer around the swa
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
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 |
293
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 |
294
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) |
295
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 |
296
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 |
297
299
  | `swarmdo mcp doctor` | **MCP config diagnosis** — missing binaries, bad URLs, malformed entries across `.mcp.json` + `~/.claude.json` |
298
- | `swarmdo config lint` | **Static config validation** — the pure shape layer for `swarmdo.config.json`, the `.claude/settings*.json` hooks block, `.mcp.json`, and **`.claude/agents/*.md` subagents** (missing `name`/`description`, a `name` duplicated across files — CC silently loads only one — a bad `model`, malformed frontmatter). `--strict` gates CI |
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 |
299
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` |
300
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`) |
301
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` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmdo",
3
- "version": "1.45.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",
@@ -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, .claude/agents/*.md subagents, 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 },
@@ -437,6 +437,35 @@ const lintCommand = {
437
437
  .filter((n) => n.endsWith('.md'))
438
438
  .map((n) => read(`.claude/agents/${n}`))
439
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);
440
469
  const report = lintAll({
441
470
  swarmdoConfig: read(configRel),
442
471
  settingsFiles: [read('.claude/settings.json'), read('.claude/settings.local.json')],
@@ -445,6 +474,8 @@ const lintCommand = {
445
474
  sdoCommands: list('.claude/commands/sDo'),
446
475
  skills: list('.claude/skills'),
447
476
  agentFiles,
477
+ commandFiles,
478
+ skillFiles,
448
479
  });
449
480
  const failed = report.errors > 0 || (ctx.flags.strict === true && report.warnings > 0);
450
481
  if (ctx.flags.json === true) {
@@ -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
@@ -54,6 +54,12 @@ const commandLoaders = {
54
54
  // Temporal (co-change) coupling from git history (code-maat demand) — file
55
55
  // pairs that change together; the empirical complement to `affected`.
56
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'),
57
63
  // JUnit/TAP test-result parser (dorny/test-reporter demand) — raw results →
58
64
  // failing test + file:line + message; the front-half of the test→fix loop.
59
65
  testreport: () => import('./testreport.js'),
@@ -296,7 +302,7 @@ export async function getCommandsByCategory() {
296
302
  // three slots from 'statusline' onward (completionsCmd received the
297
303
  // statusline module, analyzeCmd received completions, …), scrambling the
298
304
  // categorized help. Every load now has a named slot.
299
- 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, 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([
300
306
  loadCommand('daemon'), loadCommand('doctor'), loadCommand('embeddings'), loadCommand('neural'),
301
307
  loadCommand('performance'), loadCommand('security'), loadCommand('swarmvector'), loadCommand('hive-mind'),
302
308
  loadCommand('config'), loadCommand('statusline'), loadCommand('compress'), loadCommand('efficiency'),
@@ -304,7 +310,7 @@ export async function getCommandsByCategory() {
304
310
  loadCommand('analyze'), loadCommand('route'), loadCommand('progress'), loadCommand('providers'),
305
311
  loadCommand('plugins'), loadCommand('deployment'), loadCommand('claims'), loadCommand('issues'),
306
312
  loadCommand('update'), loadCommand('process'), loadCommand('guidance'), loadCommand('appliance'),
307
- 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('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'),
308
314
  ]);
309
315
  return {
310
316
  primary: [
@@ -320,7 +326,7 @@ export async function getCommandsByCategory() {
320
326
  utility: [
321
327
  configCmd, doctorCmd, daemonCmd, completionsCmd,
322
328
  migrateCmd, workflowCmd, demoCmd,
323
- statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, couplingCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
329
+ statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, couplingCmd, ownershipCmd, hiddenCouplingCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
324
330
  ].filter(Boolean),
325
331
  analysis: [
326
332
  analyzeCmd, routeCmd, progressCmd, usageCmd, hudCmd, codegraphCmd,
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `swarmdo ownership` — per-file knowledge map + BUS FACTOR mined from git history.
3
+ *
4
+ * swarmdo ownership # knowledge map, most fragile files first
5
+ * swarmdo ownership src --since 90d # scope to a path + window
6
+ * swarmdo ownership --top 10 --format json
7
+ * swarmdo ownership --csv > ownership.csv
8
+ *
9
+ * "Who owns each file, and what breaks if they leave?" — the code-maat
10
+ * main-dev / bus-factor analysis. Files where one author owns nearly all the
11
+ * churn (bus factor 1) are key-person risks. Pairs with `hotspots` (change-risk)
12
+ * and `coupling` (co-change). Engine (../ownership/ownership.ts) is pure +
13
+ * tested; this captures the git log (the same `--numstat` dump those use).
14
+ */
15
+ import type { Command } from '../types.js';
16
+ export declare const ownershipCommand: Command;
17
+ export default ownershipCommand;
18
+ //# sourceMappingURL=ownership.d.ts.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `swarmdo ownership` — per-file knowledge map + BUS FACTOR mined from git history.
3
+ *
4
+ * swarmdo ownership # knowledge map, most fragile files first
5
+ * swarmdo ownership src --since 90d # scope to a path + window
6
+ * swarmdo ownership --top 10 --format json
7
+ * swarmdo ownership --csv > ownership.csv
8
+ *
9
+ * "Who owns each file, and what breaks if they leave?" — the code-maat
10
+ * main-dev / bus-factor analysis. Files where one author owns nearly all the
11
+ * churn (bus factor 1) are key-person risks. Pairs with `hotspots` (change-risk)
12
+ * and `coupling` (co-change). Engine (../ownership/ownership.ts) is pure +
13
+ * tested; this captures the git log (the same `--numstat` dump those use).
14
+ */
15
+ import { execFileSync } from 'node:child_process';
16
+ import { output } from '../output.js';
17
+ import { parseGitLog } from '../hotspots/hotspots.js';
18
+ import { computeOwnership, repoBusFactor, formatOwnership, ownershipToCsv } from '../ownership/ownership.js';
19
+ import { normalizeSince } from '../util/since.js';
20
+ /** Read a numeric flag that the parser may deliver as a number OR a string. */
21
+ function numFlag(v, def) {
22
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? parseInt(v, 10) : NaN;
23
+ return Number.isFinite(n) ? n : def;
24
+ }
25
+ async function run(ctx) {
26
+ const root = ctx.cwd || process.cwd();
27
+ const pathArg = ctx.args[0];
28
+ const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : '1 year ago';
29
+ const top = numFlag(ctx.flags.top, 40);
30
+ const minChurn = numFlag(ctx.flags['min-churn'], 1);
31
+ const asJson = ctx.flags.format === 'json';
32
+ const asCsv = ctx.flags.csv === true;
33
+ // Same SOH-delimited numstat capture `hotspots` uses. `%aN` folds author
34
+ // name/email variants through `.mailmap` so one person is one owner. A
35
+ // positional pathspec scopes BOTH the file rows and the truck factor to that
36
+ // subtree (unlike `coupling`, per-file ownership survives a pathspec fine).
37
+ const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
38
+ if (pathArg)
39
+ args.push('--', pathArg);
40
+ let raw;
41
+ try {
42
+ raw = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
43
+ }
44
+ catch {
45
+ output.printError('git log failed — is this a git repository?');
46
+ return { success: false, exitCode: 1 };
47
+ }
48
+ const commits = parseGitLog(raw);
49
+ const files = computeOwnership(commits, { minChurn, top: top > 0 ? top : undefined });
50
+ const repo = repoBusFactor(commits);
51
+ if (asCsv) {
52
+ process.stdout.write(ownershipToCsv(files) + '\n');
53
+ }
54
+ else if (asJson) {
55
+ process.stdout.write(JSON.stringify({ generated: new Date().toISOString(), since, minChurn, repoBusFactor: repo, count: files.length, ownership: files }, null, 2) + '\n');
56
+ }
57
+ else if (files.length === 0) {
58
+ output.writeln(output.dim('no ownership data found — no matching git history in the window'));
59
+ }
60
+ else {
61
+ output.writeln(output.bold(`File ownership & bus factor (since ${since})`));
62
+ if (repo.factor > 0) {
63
+ const who = repo.authors.length <= 3 ? repo.authors.join(', ') : `${repo.authors.slice(0, 3).join(', ')} +${repo.authors.length - 3}`;
64
+ const flag = repo.factor === 1 ? ' ⚠ single point of knowledge' : '';
65
+ output.writeln(output.dim(`Repo truck factor: ${repo.factor} (${who} own >50% of churn)${flag}`));
66
+ }
67
+ output.writeln(formatOwnership(files));
68
+ }
69
+ return { success: true, exitCode: 0 };
70
+ }
71
+ export const ownershipCommand = {
72
+ name: 'ownership',
73
+ description: 'Map per-file authorship concentration + bus factor from git history — find the key-person risks (code-maat main-dev / knowledge map)',
74
+ options: [
75
+ { name: 'since', description: 'history window, e.g. 90d or "3 months ago" (default 1 year)', type: 'string' },
76
+ { name: 'top', description: 'keep only the top N files (default 40; 0 = all)', type: 'string' },
77
+ { name: 'min-churn', description: 'drop files with total churn below N (default 1)', type: 'string' },
78
+ { name: 'csv', description: 'export the knowledge map as CSV (for spreadsheets)', type: 'boolean' },
79
+ ],
80
+ examples: [
81
+ { command: 'swarmdo ownership src --since 6mo', description: 'Knowledge map under src/ in the last 6 months' },
82
+ { command: 'swarmdo ownership --top 10 --format json', description: 'Ten most fragile files as JSON, with repo truck factor' },
83
+ { command: 'swarmdo ownership --csv > ownership.csv', description: 'Export the ownership map to CSV' },
84
+ ],
85
+ action: run,
86
+ };
87
+ export default ownershipCommand;
88
+ //# sourceMappingURL=ownership.js.map
@@ -78,13 +78,13 @@ function walk(o) {
78
78
  if (e.isDirectory()) {
79
79
  if (SKIP_DIRS.has(e.name))
80
80
  continue;
81
- if (o.gitignore?.(rel + '/'))
82
- continue;
81
+ if (o.gitignore?.(rel, true))
82
+ continue; // isDir=true → dir-only patterns (build/) prune the dir + its contents
83
83
  stack.push(full);
84
84
  }
85
85
  else if (e.isFile()) {
86
- if (o.gitignore?.(rel))
87
- continue;
86
+ if (o.gitignore?.(rel, false))
87
+ continue; // isDir=false → a dir-only pattern (build/) must NOT drop a file named `build`
88
88
  if (o.exclude?.(rel))
89
89
  continue;
90
90
  if (o.include && !o.include(rel))
@@ -0,0 +1,31 @@
1
+ /**
2
+ * commands-lint.ts — static validation of Claude Code custom slash commands
3
+ * (the `.claude/commands` tree) and skills (`.claude/skills/<name>/SKILL.md`).
4
+ *
5
+ * Slash commands / skills are the most-copied Claude Code power-user artifact,
6
+ * and CC swallows their footguns silently: malformed YAML frontmatter makes CC
7
+ * load the body with EMPTY metadata (so `/cmd` "works" typed but Claude can
8
+ * never auto-match it), and two body rules bite in ways manual review misses —
9
+ * an inline `` !`cmd` `` that isn't at a line start / after whitespace is left as
10
+ * literal text and NEVER runs, and one whose command no `allowed-tools` Bash
11
+ * rule covers prompts/blocks instead of running pre-approved. This catches those
12
+ * before CC does. Sibling of agents-lint; same `Finding` shape.
13
+ *
14
+ * Pure: parsed text in, findings out. Reuses the permission-rule matcher
15
+ * (parseRule/covers) so allowed-tools coverage matches Claude Code's own glob
16
+ * semantics. Only truly-bounded fields are enum-checked (NOT `model`, which
17
+ * accepts aliases and full IDs) to stay false-positive-safe.
18
+ */
19
+ import type { Finding } from './lint.js';
20
+ /** Reasoning-effort levels a command/skill `effort:` field may name. */
21
+ export declare const EFFORT_LEVELS: string[];
22
+ /** Lint ONE command/skill file's raw text. `kind` toggles the skill-only rule
23
+ * that frontmatter (with name + description) is REQUIRED. Pure. */
24
+ export declare function lintCommandFile(file: string, raw: string, kind?: 'command' | 'skill'): Finding[];
25
+ export interface CommandFile {
26
+ file: string;
27
+ raw: string;
28
+ }
29
+ /** Lint a set of command files and skill files. Pure. */
30
+ export declare function lintCommandFiles(commands: CommandFile[], skills?: CommandFile[]): Finding[];
31
+ //# sourceMappingURL=commands-lint.d.ts.map
@@ -0,0 +1,127 @@
1
+ /**
2
+ * commands-lint.ts — static validation of Claude Code custom slash commands
3
+ * (the `.claude/commands` tree) and skills (`.claude/skills/<name>/SKILL.md`).
4
+ *
5
+ * Slash commands / skills are the most-copied Claude Code power-user artifact,
6
+ * and CC swallows their footguns silently: malformed YAML frontmatter makes CC
7
+ * load the body with EMPTY metadata (so `/cmd` "works" typed but Claude can
8
+ * never auto-match it), and two body rules bite in ways manual review misses —
9
+ * an inline `` !`cmd` `` that isn't at a line start / after whitespace is left as
10
+ * literal text and NEVER runs, and one whose command no `allowed-tools` Bash
11
+ * rule covers prompts/blocks instead of running pre-approved. This catches those
12
+ * before CC does. Sibling of agents-lint; same `Finding` shape.
13
+ *
14
+ * Pure: parsed text in, findings out. Reuses the permission-rule matcher
15
+ * (parseRule/covers) so allowed-tools coverage matches Claude Code's own glob
16
+ * semantics. Only truly-bounded fields are enum-checked (NOT `model`, which
17
+ * accepts aliases and full IDs) to stay false-positive-safe.
18
+ */
19
+ import { parse as parseYaml } from 'yaml';
20
+ import { parseRule, covers } from '../permissions/audit.js';
21
+ const f = (file, severity, rule, message) => ({ file, severity, rule, message });
22
+ /** Reasoning-effort levels a command/skill `effort:` field may name. */
23
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
24
+ const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
25
+ function parseFile(raw) {
26
+ const text = raw.replace(/^\uFEFF/, '');
27
+ const m = text.match(FRONTMATTER_RE);
28
+ if (!m) {
29
+ // An opening `---` with no closing fence is malformed; anything else is a
30
+ // plain-markdown command (no frontmatter — perfectly valid).
31
+ if (/^---[ \t]*\r?\n/.test(text))
32
+ return { meta: null, body: text, malformed: 'has an opening `---` but no closing `---` fence' };
33
+ return { meta: null, body: text };
34
+ }
35
+ const body = m[2] ?? '';
36
+ let fm;
37
+ try {
38
+ fm = parseYaml(m[1]);
39
+ }
40
+ catch (e) {
41
+ return { meta: null, body, malformed: `frontmatter is not valid YAML: ${e.message}` };
42
+ }
43
+ if (fm === null || typeof fm !== 'object' || Array.isArray(fm)) {
44
+ return { meta: null, body, malformed: 'frontmatter must be a YAML mapping of key: value' };
45
+ }
46
+ return { meta: fm, body };
47
+ }
48
+ /**
49
+ * Inline bash-injection placeholders `` !`cmd` `` in the body. Claude Code only
50
+ * RUNS one whose `!` is at a line start or immediately after whitespace; if `!`
51
+ * follows another character (e.g. `` KEY=!`cmd` ``) it is left as literal text.
52
+ */
53
+ function bashInjections(body) {
54
+ const out = [];
55
+ const re = /!`([^`\n]+)`/g;
56
+ let m;
57
+ while ((m = re.exec(body)) !== null) {
58
+ const at = m.index;
59
+ const active = at === 0 || /\s/.test(body[at - 1]);
60
+ out.push({ cmd: m[1].trim(), active });
61
+ }
62
+ return out;
63
+ }
64
+ /** Parse the `allowed-tools` frontmatter (string list or YAML array) into rules;
65
+ * null = the key is absent (no pre-approval declared, so coverage isn't checked). */
66
+ function allowedToolRules(meta) {
67
+ const at = meta['allowed-tools'] ?? meta['allowedTools'];
68
+ if (at === undefined)
69
+ return null;
70
+ const parts = Array.isArray(at) ? at.map(String) : typeof at === 'string' ? at.split(',') : [];
71
+ const rules = [];
72
+ for (const p of parts) {
73
+ const r = parseRule(p.trim());
74
+ if (r.tool !== undefined)
75
+ rules.push(r);
76
+ }
77
+ return rules;
78
+ }
79
+ /** Lint ONE command/skill file's raw text. `kind` toggles the skill-only rule
80
+ * that frontmatter (with name + description) is REQUIRED. Pure. */
81
+ export function lintCommandFile(file, raw, kind = 'command') {
82
+ const p = parseFile(raw);
83
+ const out = [];
84
+ if (p.malformed) {
85
+ out.push(f(file, 'error', 'command-malformed-frontmatter', p.malformed));
86
+ }
87
+ else if (kind === 'skill' && !p.meta) {
88
+ out.push(f(file, 'error', 'skill-missing-frontmatter', 'a SKILL.md must start with a `---` … `---` frontmatter block declaring `name` and `description`'));
89
+ }
90
+ const meta = p.meta;
91
+ if (meta) {
92
+ if (kind === 'skill' && (typeof meta.name !== 'string' || meta.name.trim() === '')) {
93
+ out.push(f(file, 'error', 'skill-missing-name', 'frontmatter is missing a non-empty `name`'));
94
+ }
95
+ if (typeof meta.description !== 'string' || meta.description.trim() === '') {
96
+ const how = kind === 'skill' ? 'decide when to invoke the skill' : 'list the command and model-invoke it';
97
+ out.push(f(file, kind === 'skill' ? 'error' : 'warn', 'command-missing-description', `frontmatter has no non-empty \`description\` (Claude Code uses it to ${how})`));
98
+ }
99
+ if (meta.effort !== undefined && !EFFORT_LEVELS.includes(String(meta.effort))) {
100
+ out.push(f(file, 'error', 'command-bad-effort', `effort "${String(meta.effort)}" is not one of: ${EFFORT_LEVELS.join(', ')}`));
101
+ }
102
+ }
103
+ // Body cross-checks (both kinds). Coverage is only checked when allowed-tools
104
+ // is declared — otherwise the author never opted into pre-approval.
105
+ const rules = meta ? allowedToolRules(meta) : null;
106
+ for (const inj of bashInjections(p.body)) {
107
+ const tok = '!`' + inj.cmd + '`';
108
+ if (!inj.active) {
109
+ out.push(f(file, 'warn', 'command-inert-bash-injection', `inline bash ${tok} follows a non-whitespace character — Claude Code leaves it as literal text and never runs it (put \`!\` at a line start or after whitespace)`));
110
+ continue;
111
+ }
112
+ if (rules && rules.length > 0 && !rules.some((r) => covers(r, { tool: 'Bash', specifier: inj.cmd }))) {
113
+ out.push(f(file, 'warn', 'command-uncovered-bash-injection', `inline bash ${tok} is not covered by any \`allowed-tools\` Bash rule — it will prompt/block instead of running pre-approved`));
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+ /** Lint a set of command files and skill files. Pure. */
119
+ export function lintCommandFiles(commands, skills = []) {
120
+ const out = [];
121
+ for (const c of commands)
122
+ out.push(...lintCommandFile(c.file, c.raw, 'command'));
123
+ for (const s of skills)
124
+ out.push(...lintCommandFile(s.file, s.raw, 'skill'));
125
+ return out;
126
+ }
127
+ //# sourceMappingURL=commands-lint.js.map
@@ -18,6 +18,7 @@ export interface Finding {
18
18
  export declare const TOPOLOGIES: string[];
19
19
  export declare const MEMORY_BACKENDS: string[];
20
20
  import { type AgentFile } from './agents-lint.js';
21
+ import { type CommandFile } from './commands-lint.js';
21
22
  export declare const KNOWN_CONFIG_KEYS: string[];
22
23
  export declare const HOOK_EVENTS: string[];
23
24
  /** Parse a JSON file's raw text; a null raw means "file absent" (fine). */
@@ -52,6 +53,10 @@ export interface LintInput {
52
53
  skills?: string[];
53
54
  /** `.claude/agents/*.md` subagent definitions (raw text per file) */
54
55
  agentFiles?: AgentFile[];
56
+ /** custom slash commands under the `.claude/commands` tree (raw text per file) */
57
+ commandFiles?: CommandFile[];
58
+ /** skill definitions at `.claude/skills/<name>/SKILL.md` (raw text per file) */
59
+ skillFiles?: CommandFile[];
55
60
  }
56
61
  export interface LintReport {
57
62
  findings: Finding[];
@@ -13,6 +13,7 @@ export const TOPOLOGIES = ['hierarchical', 'mesh', 'hierarchical-mesh', 'ring',
13
13
  export const MEMORY_BACKENDS = ['agentdb', 'sqlite', 'hybrid', 'memory'];
14
14
  import { parseOpenRouterConfig } from '../providers/openrouter-config.js';
15
15
  import { lintAgents } from './agents-lint.js';
16
+ import { lintCommandFiles } from './commands-lint.js';
16
17
  export const KNOWN_CONFIG_KEYS = ['topology', 'maxAgents', 'strategy', 'consensus', 'memory', 'memoryBackend', 'hnsw', 'neural', 'embeddings', 'providers', 'mcp', 'logging', 'daemon', 'hooks', 'version', 'openrouter', '$schema'];
17
18
  // Current Claude Code hook events (source: code.claude.com/docs/en/hooks).
18
19
  // Kept in sync with the runtime; a stale list here false-warns on valid hooks.
@@ -197,6 +198,9 @@ export function lintAll(input) {
197
198
  findings.push(...lintLegacyLayout(input.commandsRoot ?? [], input.skills ?? [], input.sdoCommands ?? []));
198
199
  if (input.agentFiles?.length)
199
200
  findings.push(...lintAgents(input.agentFiles));
201
+ if (input.commandFiles?.length || input.skillFiles?.length) {
202
+ findings.push(...lintCommandFiles(input.commandFiles ?? [], input.skillFiles ?? []));
203
+ }
200
204
  return {
201
205
  findings,
202
206
  errors: findings.filter((x) => x.severity === 'error').length,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * hidden.ts — HIDDEN coupling: files that change together but have no import
3
+ * edge to explain it. "Logical coupling minus structural coupling."
4
+ *
5
+ * `coupling` mines temporal (co-change) coupling from git; `codegraph` builds
6
+ * the static import graph. Join them and emit the SET DIFFERENCE: pairs with a
7
+ * high co-change degree yet NO import edge connecting them in either direction.
8
+ * That's a dependency real enough to move two files in lockstep but invisible in
9
+ * the code — a JSON schema and the type that mirrors it, a serializer/parser
10
+ * split across modules, a config file and its consumers, a doc that must track
11
+ * an API. An agent editing A won't reach B by following imports, so it forgets
12
+ * the co-edit; a reviewer sees a missing abstraction / architectural smell.
13
+ *
14
+ * This is the divergence between evolutionary coupling (Gall et al., "Detection
15
+ * of Logical Coupling," ICSM 1998) and structural coupling — CodeScene flags
16
+ * change-coupling that crosses architectural boundaries as "surprising." swarmdo
17
+ * is uniquely positioned to compute it because it already owns BOTH captures.
18
+ *
19
+ * Pure + deterministic: a set-difference over two already-built inputs (the
20
+ * ranked CouplingPair[] from computeCoupling + the ImportEdge[] from the index).
21
+ * No git, no fs — fully fixture-testable.
22
+ */
23
+ import type { CouplingPair } from './coupling.js';
24
+ import type { ImportEdge } from '../codegraph/codegraph.js';
25
+ export interface AnnotatedPair extends CouplingPair {
26
+ /** true iff an import edge connects a↔b in EITHER direction (a structural link
27
+ * that explains the co-change); the returned pairs are all `false`. */
28
+ importLinked: boolean;
29
+ }
30
+ export interface HiddenCouplingOptions {
31
+ /** keep only the top N hidden pairs after ranking (default: all) */
32
+ top?: number;
33
+ }
34
+ /**
35
+ * Return the co-change pairs that NO import edge explains, ranked as they came
36
+ * from `computeCoupling` (degree desc, shared desc, path order). A type-only
37
+ * import still counts as a structural link — it's a real code reference that
38
+ * explains the co-change — so those pairs are excluded too. Pure.
39
+ */
40
+ export declare function computeHiddenCoupling(pairs: CouplingPair[], imports: ImportEdge[], opts?: HiddenCouplingOptions): AnnotatedPair[];
41
+ /** Export the hidden-coupling ranking as CSV for review. Pure. */
42
+ export declare function hiddenCouplingToCsv(pairs: AnnotatedPair[]): string;
43
+ /** Human-readable table. Pure. */
44
+ export declare function formatHiddenCoupling(pairs: AnnotatedPair[]): string;
45
+ //# sourceMappingURL=hidden.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * hidden.ts — HIDDEN coupling: files that change together but have no import
3
+ * edge to explain it. "Logical coupling minus structural coupling."
4
+ *
5
+ * `coupling` mines temporal (co-change) coupling from git; `codegraph` builds
6
+ * the static import graph. Join them and emit the SET DIFFERENCE: pairs with a
7
+ * high co-change degree yet NO import edge connecting them in either direction.
8
+ * That's a dependency real enough to move two files in lockstep but invisible in
9
+ * the code — a JSON schema and the type that mirrors it, a serializer/parser
10
+ * split across modules, a config file and its consumers, a doc that must track
11
+ * an API. An agent editing A won't reach B by following imports, so it forgets
12
+ * the co-edit; a reviewer sees a missing abstraction / architectural smell.
13
+ *
14
+ * This is the divergence between evolutionary coupling (Gall et al., "Detection
15
+ * of Logical Coupling," ICSM 1998) and structural coupling — CodeScene flags
16
+ * change-coupling that crosses architectural boundaries as "surprising." swarmdo
17
+ * is uniquely positioned to compute it because it already owns BOTH captures.
18
+ *
19
+ * Pure + deterministic: a set-difference over two already-built inputs (the
20
+ * ranked CouplingPair[] from computeCoupling + the ImportEdge[] from the index).
21
+ * No git, no fs — fully fixture-testable.
22
+ */
23
+ import { toCsv } from '../util/csv.js';
24
+ const US = '\x1f'; // pair-key separator (never appears in a file path)
25
+ /** Canonical undirected pair key so direction doesn't matter. */
26
+ const pairKey = (a, b) => (a < b ? a + US + b : b + US + a);
27
+ /**
28
+ * Return the co-change pairs that NO import edge explains, ranked as they came
29
+ * from `computeCoupling` (degree desc, shared desc, path order). A type-only
30
+ * import still counts as a structural link — it's a real code reference that
31
+ * explains the co-change — so those pairs are excluded too. Pure.
32
+ */
33
+ export function computeHiddenCoupling(pairs, imports, opts = {}) {
34
+ // Every file pair connected by a resolved import edge, either direction.
35
+ const linked = new Set();
36
+ for (const e of imports) {
37
+ if (e.resolved)
38
+ linked.add(pairKey(e.from, e.resolved));
39
+ }
40
+ const hidden = pairs
41
+ .map((p) => ({ ...p, importLinked: linked.has(pairKey(p.a, p.b)) }))
42
+ .filter((p) => !p.importLinked);
43
+ return opts.top && opts.top > 0 ? hidden.slice(0, opts.top) : hidden;
44
+ }
45
+ /** Export the hidden-coupling ranking as CSV for review. Pure. */
46
+ export function hiddenCouplingToCsv(pairs) {
47
+ const headers = ['fileA', 'fileB', 'degree', 'shared', 'commitsA', 'commitsB'];
48
+ const rows = pairs.map((p) => [p.a, p.b, p.degree, p.shared, p.aCommits, p.bCommits]);
49
+ return toCsv(headers, rows);
50
+ }
51
+ /** Human-readable table. Pure. */
52
+ export function formatHiddenCoupling(pairs) {
53
+ if (pairs.length === 0)
54
+ return 'no hidden coupling found (every co-change pair has an import edge, or none met the threshold)';
55
+ const lines = [];
56
+ lines.push('rank degree shared commits(A/B) files (co-change, no import edge)');
57
+ pairs.forEach((p, i) => {
58
+ const rank = String(i + 1).padStart(4);
59
+ const degree = `${Math.round(p.degree * 100)}%`.padStart(6);
60
+ const shared = String(p.shared).padStart(6);
61
+ const ab = `${p.aCommits}/${p.bCommits}`.padStart(11);
62
+ lines.push(`${rank} ${degree} ${shared} ${ab} ${p.a} ⇢ ${p.b}`);
63
+ });
64
+ return lines.join('\n');
65
+ }
66
+ //# sourceMappingURL=hidden.js.map
@@ -27,6 +27,7 @@ import { licenseTools } from './mcp-tools/license-tools.js';
27
27
  import { applyTools } from './mcp-tools/apply-tools.js';
28
28
  import { hotspotsTools } from './mcp-tools/hotspots-tools.js';
29
29
  import { couplingTools } from './mcp-tools/coupling-tools.js';
30
+ import { ownershipTools } from './mcp-tools/ownership-tools.js';
30
31
  import { affectedTools } from './mcp-tools/affected-tools.js';
31
32
  import { cyclesTools } from './mcp-tools/cycles-tools.js';
32
33
  import { testreportTools } from './mcp-tools/testreport-tools.js';
@@ -121,6 +122,7 @@ const TOOL_GROUPS = {
121
122
  apply: () => applyTools,
122
123
  hotspots: () => hotspotsTools,
123
124
  coupling: () => couplingTools,
125
+ ownership: () => ownershipTools,
124
126
  affected: () => affectedTools,
125
127
  cycles: () => cyclesTools,
126
128
  testreport: () => testreportTools,
@@ -23,6 +23,7 @@ export { licenseTools } from './license-tools.js';
23
23
  export { applyTools } from './apply-tools.js';
24
24
  export { hotspotsTools } from './hotspots-tools.js';
25
25
  export { couplingTools } from './coupling-tools.js';
26
+ export { ownershipTools } from './ownership-tools.js';
26
27
  export { affectedTools } from './affected-tools.js';
27
28
  export { cyclesTools } from './cycles-tools.js';
28
29
  export { testreportTools } from './testreport-tools.js';
@@ -22,6 +22,7 @@ export { licenseTools } from './license-tools.js';
22
22
  export { applyTools } from './apply-tools.js';
23
23
  export { hotspotsTools } from './hotspots-tools.js';
24
24
  export { couplingTools } from './coupling-tools.js';
25
+ export { ownershipTools } from './ownership-tools.js';
25
26
  export { affectedTools } from './affected-tools.js';
26
27
  export { cyclesTools } from './cycles-tools.js';
27
28
  export { testreportTools } from './testreport-tools.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Ownership MCP Tool
3
+ *
4
+ * Let an agent ask "who owns this file, and is it a bus-factor-1 key-person
5
+ * file?" and get ranked JSON — the risk/reviewer dimension it is otherwise
6
+ * blind to. An agent about to refactor a fragile single-owner file gets the
7
+ * signal that the change needs extra care, and "who should review this" gets a
8
+ * data-backed answer (the file's main-dev). Completes the git-mining trio's
9
+ * agent-callable set (hotspots + coupling + ownership). Shares the pure engine
10
+ * in ../ownership/ownership.ts with the CLI; captures git history via subprocess.
11
+ */
12
+ import type { MCPTool } from './types.js';
13
+ export declare const ownershipTools: MCPTool[];
14
+ //# sourceMappingURL=ownership-tools.d.ts.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Ownership MCP Tool
3
+ *
4
+ * Let an agent ask "who owns this file, and is it a bus-factor-1 key-person
5
+ * file?" and get ranked JSON — the risk/reviewer dimension it is otherwise
6
+ * blind to. An agent about to refactor a fragile single-owner file gets the
7
+ * signal that the change needs extra care, and "who should review this" gets a
8
+ * data-backed answer (the file's main-dev). Completes the git-mining trio's
9
+ * agent-callable set (hotspots + coupling + ownership). Shares the pure engine
10
+ * in ../ownership/ownership.ts with the CLI; captures git history via subprocess.
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { parseGitLog } from '../hotspots/hotspots.js';
14
+ import { computeOwnership, repoBusFactor } from '../ownership/ownership.js';
15
+ import { normalizeSince } from '../util/since.js';
16
+ const ownershipTool = {
17
+ name: 'ownership',
18
+ description: 'Map per-file authorship concentration + bus factor from git history — "who owns each file, and what breaks if they leave?" (code-maat main-dev / knowledge map). Returns ranked JSON, most-fragile-first, so an agent editing a file knows if it is a single-owner (bus factor 1) key-person risk that needs extra care, and who its main-dev reviewer is. Includes a repo-level truck factor. Runs in a git repository.',
19
+ category: 'ownership',
20
+ tags: ['git', 'ownership', 'bus-factor', 'knowledge-map', 'risk', 'review'],
21
+ inputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ path: { type: 'string', description: 'repo path to run in (default cwd)' },
25
+ since: { type: 'string', description: 'history window, e.g. 90d or "3 months ago" (default 1 year)' },
26
+ top: { type: 'number', description: 'keep only the top N files (default 40; 0 = all)' },
27
+ minChurn: { type: 'number', description: 'drop files with total churn below N (default 1)' },
28
+ },
29
+ },
30
+ handler: async (params) => {
31
+ const cwd = typeof params.path === 'string' ? params.path : process.cwd();
32
+ const since = typeof params.since === 'string' ? params.since : '1 year ago';
33
+ const top = typeof params.top === 'number' ? params.top : 40;
34
+ const minChurn = typeof params.minChurn === 'number' ? params.minChurn : 1;
35
+ // Same `--numstat` dump `hotspots`/`coupling` parse; `%aN` folds author
36
+ // name/email variants through .mailmap so one person is one owner.
37
+ const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
38
+ let raw;
39
+ try {
40
+ raw = execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
41
+ }
42
+ catch {
43
+ return { error: true, message: 'git log failed — not a git repository?' };
44
+ }
45
+ const commits = parseGitLog(raw);
46
+ const ownership = computeOwnership(commits, { minChurn, top: top > 0 ? top : undefined });
47
+ return { since, minChurn, repoBusFactor: repoBusFactor(commits), count: ownership.length, ownership };
48
+ },
49
+ };
50
+ export const ownershipTools = [ownershipTool];
51
+ //# sourceMappingURL=ownership-tools.js.map
@@ -28,6 +28,7 @@ const LEAN_GROUPS = [
28
28
  'apply', // apply_patch — fuzzy unified-diff applier for agent edits
29
29
  'hotspots', // hotspots — git-history change-risk ranking ("where's the debt?")
30
30
  'coupling', // coupling — files that change together (temporal co-change), complements affected
31
+ 'ownership', // ownership — per-file bus factor / knowledge map (who owns it, key-person risk)
31
32
  'affected', // affected — impacted files/tests from a change via the import graph
32
33
  'cycles', // cycles — circular-import detection via the import graph
33
34
  'testreport', // testreport — JUnit/TAP → structured failure digest (pairs w/ repair)
@@ -0,0 +1,61 @@
1
+ /**
2
+ * ownership.ts — per-file authorship concentration + BUS FACTOR from git history.
3
+ *
4
+ * "Who owns this file, and what breaks if they leave?" answered from data. For
5
+ * every file we compute the dominant author (main-dev), how concentrated the
6
+ * churn is in that one person (ownership share), how many hands have touched it,
7
+ * and its bus factor — the smallest set of top authors whose combined churn
8
+ * clears half the file. A bus factor of 1 is a key-person risk: one departure
9
+ * orphans the file. A repo-level "truck factor" answers the same question for
10
+ * the whole codebase.
11
+ *
12
+ * This is the code-maat `main-dev` / `entity-ownership` analysis (Tornhill,
13
+ * *Your Code as a Crime Scene*), surfaced as CodeScene's "Knowledge Map" and
14
+ * "Bus Factor". The scoring core is a pure fold over the SAME `git log --numstat`
15
+ * dump `hotspots`/`coupling` already parse — it reuses that Commit type +
16
+ * parseGitLog and is unit-tested without a repo. `%aN` (mailmap-folded author)
17
+ * means name/email variants of one person count as one owner.
18
+ */
19
+ import type { Commit } from '../hotspots/hotspots.js';
20
+ export interface FileOwnership {
21
+ path: string;
22
+ /** dominant author: most churn, tie → most commits, tie → name ascending */
23
+ owner: string;
24
+ /** owner churn / total file churn ∈ (0,1]; 1.0 = a single hand */
25
+ ownership: number;
26
+ /** distinct authors who touched the file */
27
+ authors: number;
28
+ /** total added+deleted across all commits */
29
+ churn: number;
30
+ /** commits touching the file */
31
+ commits: number;
32
+ /** smallest # of top-churn authors whose cumulative churn EXCEEDS 50% */
33
+ busFactor: number;
34
+ /** busFactor === 1 — a single point of knowledge (one departure orphans it) */
35
+ keyPersonRisk: boolean;
36
+ }
37
+ export interface RepoBusFactor {
38
+ /** the covering set: fewest top authors whose cumulative churn exceeds 50% */
39
+ authors: string[];
40
+ /** authors.length — the repo "truck factor" */
41
+ factor: number;
42
+ }
43
+ export interface OwnershipOptions {
44
+ /** drop files with total churn below this (default 1 → skips pure-binary edits) */
45
+ minChurn?: number;
46
+ /** keep only the top N files after ranking (default: all) */
47
+ top?: number;
48
+ }
49
+ /** Aggregate commits into per-file ownership, scored and ranked. Pure. */
50
+ export declare function computeOwnership(commits: Commit[], opts?: OwnershipOptions): FileOwnership[];
51
+ /**
52
+ * Repo-level truck factor: the fewest top authors whose combined churn across
53
+ * the WHOLE history exceeds half the total. If that set is one person, a single
54
+ * departure takes the majority of the codebase's institutional knowledge. Pure.
55
+ */
56
+ export declare function repoBusFactor(commits: Commit[]): RepoBusFactor;
57
+ /** Export the knowledge map as CSV for spreadsheets / staffing review. Pure. */
58
+ export declare function ownershipToCsv(files: FileOwnership[]): string;
59
+ /** Human-readable knowledge map. Pure. */
60
+ export declare function formatOwnership(files: FileOwnership[]): string;
61
+ //# sourceMappingURL=ownership.d.ts.map
@@ -0,0 +1,151 @@
1
+ /**
2
+ * ownership.ts — per-file authorship concentration + BUS FACTOR from git history.
3
+ *
4
+ * "Who owns this file, and what breaks if they leave?" answered from data. For
5
+ * every file we compute the dominant author (main-dev), how concentrated the
6
+ * churn is in that one person (ownership share), how many hands have touched it,
7
+ * and its bus factor — the smallest set of top authors whose combined churn
8
+ * clears half the file. A bus factor of 1 is a key-person risk: one departure
9
+ * orphans the file. A repo-level "truck factor" answers the same question for
10
+ * the whole codebase.
11
+ *
12
+ * This is the code-maat `main-dev` / `entity-ownership` analysis (Tornhill,
13
+ * *Your Code as a Crime Scene*), surfaced as CodeScene's "Knowledge Map" and
14
+ * "Bus Factor". The scoring core is a pure fold over the SAME `git log --numstat`
15
+ * dump `hotspots`/`coupling` already parse — it reuses that Commit type +
16
+ * parseGitLog and is unit-tested without a repo. `%aN` (mailmap-folded author)
17
+ * means name/email variants of one person count as one owner.
18
+ */
19
+ import { toCsv } from '../util/csv.js';
20
+ /**
21
+ * Bus factor of a churn distribution: the smallest number of top-churn authors
22
+ * whose cumulative churn STRICTLY exceeds half the total. An author sitting at
23
+ * exactly 50% is not enough on their own (you'd need one more), which is what
24
+ * makes an even two-way split a bus factor of 2. Pure.
25
+ */
26
+ function busFactorOf(authors, total) {
27
+ const byChurn = authors.slice().sort((x, y) => y.churn - x.churn || (x.name < y.name ? -1 : x.name > y.name ? 1 : 0));
28
+ const threshold = total / 2;
29
+ let cum = 0;
30
+ let k = 0;
31
+ for (const a of byChurn) {
32
+ cum += a.churn;
33
+ k++;
34
+ if (cum > threshold)
35
+ break;
36
+ }
37
+ return Math.max(1, k);
38
+ }
39
+ /** Aggregate commits into per-file ownership, scored and ranked. Pure. */
40
+ export function computeOwnership(commits, opts = {}) {
41
+ const minChurn = opts.minChurn ?? 1;
42
+ const acc = new Map();
43
+ for (const c of commits) {
44
+ // Fold this commit's numstat lines into per-path churn first, so a path that
45
+ // appears twice in one commit (rename collisions) counts as ONE commit.
46
+ const perPath = new Map();
47
+ for (const f of c.files) {
48
+ if (!f.path)
49
+ continue;
50
+ perPath.set(f.path, (perPath.get(f.path) ?? 0) + f.added + f.deleted);
51
+ }
52
+ for (const [path, churn] of perPath) {
53
+ let fa = acc.get(path);
54
+ if (!fa) {
55
+ fa = { churn: 0, commits: 0, byAuthor: new Map() };
56
+ acc.set(path, fa);
57
+ }
58
+ fa.churn += churn;
59
+ fa.commits += 1;
60
+ let a = fa.byAuthor.get(c.author);
61
+ if (!a) {
62
+ a = { name: c.author, churn: 0, commits: 0 };
63
+ fa.byAuthor.set(c.author, a);
64
+ }
65
+ a.churn += churn;
66
+ a.commits += 1;
67
+ }
68
+ }
69
+ const out = [];
70
+ for (const [path, fa] of acc) {
71
+ if (fa.churn < minChurn)
72
+ continue;
73
+ const authors = [...fa.byAuthor.values()];
74
+ // owner: most churn, tie → most commits (sustained involvement), tie → name asc
75
+ const owner = authors
76
+ .slice()
77
+ .sort((x, y) => y.churn - x.churn || y.commits - x.commits || (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))[0];
78
+ const ownership = fa.churn > 0 ? Math.round((owner.churn / fa.churn) * 100) / 100 : 0;
79
+ const busFactor = busFactorOf(authors, fa.churn);
80
+ out.push({
81
+ path,
82
+ owner: owner.name,
83
+ ownership,
84
+ authors: authors.length,
85
+ churn: fa.churn,
86
+ commits: fa.commits,
87
+ busFactor,
88
+ keyPersonRisk: busFactor === 1,
89
+ });
90
+ }
91
+ // Deterministic ranking: most fragile first — lowest bus factor, then most
92
+ // concentrated (ownership desc), then most churn, then path asc for stable ties.
93
+ out.sort((x, y) => x.busFactor - y.busFactor ||
94
+ y.ownership - x.ownership ||
95
+ y.churn - x.churn ||
96
+ (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
97
+ return opts.top && opts.top > 0 ? out.slice(0, opts.top) : out;
98
+ }
99
+ /**
100
+ * Repo-level truck factor: the fewest top authors whose combined churn across
101
+ * the WHOLE history exceeds half the total. If that set is one person, a single
102
+ * departure takes the majority of the codebase's institutional knowledge. Pure.
103
+ */
104
+ export function repoBusFactor(commits) {
105
+ const byAuthor = new Map();
106
+ let total = 0;
107
+ for (const c of commits) {
108
+ for (const f of c.files) {
109
+ const ch = f.added + f.deleted;
110
+ byAuthor.set(c.author, (byAuthor.get(c.author) ?? 0) + ch);
111
+ total += ch;
112
+ }
113
+ }
114
+ const sorted = [...byAuthor.entries()].sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
115
+ const threshold = total / 2;
116
+ const authors = [];
117
+ let cum = 0;
118
+ for (const [name, ch] of sorted) {
119
+ authors.push(name);
120
+ cum += ch;
121
+ if (cum > threshold)
122
+ break;
123
+ }
124
+ return { authors, factor: authors.length };
125
+ }
126
+ /** Export the knowledge map as CSV for spreadsheets / staffing review. Pure. */
127
+ export function ownershipToCsv(files) {
128
+ const headers = ['path', 'owner', 'ownership', 'busFactor', 'authors', 'churn', 'commits', 'keyPersonRisk'];
129
+ const rows = files.map((f) => [f.path, f.owner, f.ownership, f.busFactor, f.authors, f.churn, f.commits, f.keyPersonRisk]);
130
+ return toCsv(headers, rows);
131
+ }
132
+ /** Human-readable knowledge map. Pure. */
133
+ export function formatOwnership(files) {
134
+ if (files.length === 0)
135
+ return 'no ownership data found (no matching history)';
136
+ const lines = [];
137
+ lines.push('rank bus own% authors churn commits owner file');
138
+ files.forEach((f, i) => {
139
+ const rank = String(i + 1).padStart(4);
140
+ const bus = String(f.busFactor).padStart(3);
141
+ const own = `${Math.round(f.ownership * 100)}%`.padStart(5);
142
+ const authors = String(f.authors).padStart(7);
143
+ const churn = String(f.churn).padStart(6);
144
+ const commits = String(f.commits).padStart(7);
145
+ const owner = (f.owner.length > 20 ? f.owner.slice(0, 19) + '…' : f.owner).padEnd(20);
146
+ const risk = f.keyPersonRisk ? ' ⚠ key-person' : '';
147
+ lines.push(`${rank} ${bus} ${own} ${authors} ${churn} ${commits} ${owner} ${f.path}${risk}`);
148
+ });
149
+ return lines.join('\n');
150
+ }
151
+ //# sourceMappingURL=ownership.js.map
@@ -54,5 +54,5 @@ export declare function packFiles(input: PackFile[], opts?: PackOptions): PackRe
54
54
  * `dir/`, `*.ext` globs, `**` depth globs (gitignore(5) semantics), leading `/`
55
55
  * anchors, and `!` negation. Not the full spec (no nested ignore files).
56
56
  */
57
- export declare function makeIgnoreMatcher(patterns: string[]): (relPath: string) => boolean;
57
+ export declare function makeIgnoreMatcher(patterns: string[]): (relPath: string, isDir?: boolean) => boolean;
58
58
  //# sourceMappingURL=pack.d.ts.map
@@ -201,19 +201,24 @@ export function makeIgnoreMatcher(patterns) {
201
201
  // Only a slash-free pattern (`dir`, `*.ext`) matches at any depth.
202
202
  const anchored = leadingSlash || body.includes('/');
203
203
  const re = new RegExp('^' + globToRegExp(body) + (dirOnly ? '(/|$)' : '($|/)'));
204
- return { negate, anchored, re };
204
+ return { negate, anchored, dirOnly, re };
205
205
  });
206
206
  // Ignored status of a single path (last-match-wins over the ordered rules).
207
- const ruleVerdict = (path) => {
207
+ // `isDirComponent` is whether the entry named by the path's LAST segment is a
208
+ // directory: a trailing-slash (dir-only) pattern matches directories ONLY per
209
+ // gitignore(5), so `build/` must NOT fire on a regular file named `build`.
210
+ const ruleVerdict = (path, isDirComponent) => {
208
211
  let ignored = false;
209
212
  for (const r of rules) {
213
+ if (r.dirOnly && !isDirComponent)
214
+ continue; // dir-only pattern can't match a file
210
215
  const candidates = r.anchored ? [path] : [path, ...path.split('/').map((_, i, a) => a.slice(i).join('/'))];
211
216
  if (candidates.some((c) => r.re.test(c)))
212
217
  ignored = !r.negate;
213
218
  }
214
219
  return ignored;
215
220
  };
216
- return (relPath) => {
221
+ return (relPath, isDir = false) => {
217
222
  // Walk ancestor dirs top-down: once a parent directory is excluded, the file
218
223
  // stays excluded — a negation cannot re-include under an excluded parent
219
224
  // (gitignore(5): "not possible to re-include a file if a parent directory of
@@ -227,7 +232,10 @@ export function makeIgnoreMatcher(patterns) {
227
232
  return true;
228
233
  continue; // excluded ancestor carries down
229
234
  }
230
- const verdict = ruleVerdict(parts.slice(0, i + 1).join('/'));
235
+ // Ancestor prefixes are directories by construction; only the final entry's
236
+ // type comes from the caller (a filesystem walker knows dir vs file).
237
+ const componentIsDir = isLast ? isDir : true;
238
+ const verdict = ruleVerdict(parts.slice(0, i + 1).join('/'), componentIsDir);
231
239
  if (isLast)
232
240
  return verdict;
233
241
  parentIgnored = verdict; // this directory prefix carries forward
@@ -53,9 +53,11 @@ export const RULES = [
53
53
  * values (paths, short flags) out. The value class also stops at the URL/query
54
54
  * delimiters `&`, `?`, `#` (absent from base64/base64url/hex token alphabets):
55
55
  * otherwise a query-string secret like `client_secret=…&api_key=…` over-captures
56
- * past the `&` into the next secret, and that inflated span overlapping a range
57
- * the high-confidence RULES pass already claimed gets dropped entirely, leaving
58
- * the first secret unredacted.
56
+ * past the `&` into the next secret. It CANNOT stop at `/ : . = + ~`, though —
57
+ * base64/base64url/connection-string secrets contain thoseso a recognized
58
+ * token glued on by one of them still over-captures; the entropy pass in
59
+ * `collectHits` handles that by CLIPPING the span at the claimed range rather
60
+ * than dropping the finding (which would leak the leading secret, #100).
59
61
  */
60
62
  const ASSIGNMENT_RE = /(?<![A-Za-z])(?:pass(?:word|phrase|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;&?#]{8,})/gi;
61
63
  /** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
@@ -121,15 +123,32 @@ function collectHits(text, opts) {
121
123
  while ((m = ASSIGNMENT_RE.exec(text)) !== null) {
122
124
  const value = m[1];
123
125
  const start = m.index + m[0].indexOf(value);
124
- const end = start + value.length;
125
- if (overlaps(start, end))
126
- continue;
127
- if (isAllowlisted(value, opts.allowlist))
126
+ let end = start + value.length;
127
+ let match = value;
128
+ // The value class must include `/ : . = + ~` for base64/base64url/conn-string
129
+ // secrets, so a recognized RULES token (GitHub PAT, AWS key, …) glued on by
130
+ // one of those separators gets over-captured into this span. When that
131
+ // happens, DON'T drop the finding on overlap — the leading high-entropy
132
+ // secret would leak in cleartext (only the trailing token got masked, #100).
133
+ // Instead clip the span to end at the earliest claimed range within it and
134
+ // re-gate the leading fragment; the trailing token keeps its RULES redaction.
135
+ const overlapping = claimed.filter(([s, e]) => start < e && end > s);
136
+ if (overlapping.length > 0) {
137
+ // value's own start already sits inside a claimed secret → fully covered
138
+ if (overlapping.some(([s, e]) => s <= start && start < e))
139
+ continue;
140
+ const clipTo = Math.min(...overlapping.map(([s]) => s));
141
+ match = text.slice(start, clipTo).replace(/[^A-Za-z0-9]+$/, ''); // drop the trailing separator
142
+ end = start + match.length;
143
+ if (match.length < 8)
144
+ continue; // clipped leading fragment too short to be a secret
145
+ }
146
+ if (isAllowlisted(match, opts.allowlist))
128
147
  continue;
129
- if (shannonEntropy(value) < threshold)
148
+ if (shannonEntropy(match) < threshold)
130
149
  continue;
131
150
  claimed.push([start, end]);
132
- hits.push({ ruleId: 'high-entropy-assignment', description: 'High-entropy secret assignment', index: start, match: value });
151
+ hits.push({ ruleId: 'high-entropy-assignment', description: 'High-entropy secret assignment', index: start, match });
133
152
  }
134
153
  }
135
154
  return hits.sort((a, b) => a.index - b.index);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmdo/cli",
3
- "version": "1.45.0",
3
+ "version": "1.50.0",
4
4
  "type": "module",
5
5
  "description": "Swarmdo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",