swarmdo 1.37.0 → 1.45.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 (42) hide show
  1. package/README.md +7 -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 +6 -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/hooks.js +55 -0
  11. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +2 -1
  12. package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -3
  13. package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
  14. package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
  15. package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
  16. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
  17. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
  18. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +3 -0
  19. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +3 -0
  20. package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
  21. package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
  22. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
  23. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
  24. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +8 -0
  25. package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
  26. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
  27. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -0
  28. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
  29. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
  30. package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
  31. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
  32. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
  33. package/v3/@swarmdo/cli/dist/src/redact/redact.js +22 -10
  34. package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
  35. package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
  36. package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
  37. package/v3/@swarmdo/cli/dist/src/release/release.js +28 -0
  38. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +9 -1
  39. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +14 -4
  40. package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
  41. package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
  42. 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.45.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,18 @@ 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 |
292
293
  | `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
294
  | `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
295
  | 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` |
296
+ | `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
297
  | `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 |
297
299
  | `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
300
  | `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
301
  | `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
302
  | `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 |
303
+ | `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
304
  | `swarmdo preset` + `init --preset` | **5-tier capability ladder** — `minimal` → `basic`★ → `standard` → `advanced` → `max`; one word instead of dozens of flags |
303
305
  | `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
306
  | `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.45.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, 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,10 @@ 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);
436
440
  const report = lintAll({
437
441
  swarmdoConfig: read(configRel),
438
442
  settingsFiles: [read('.claude/settings.json'), read('.claude/settings.local.json')],
@@ -440,6 +444,7 @@ const lintCommand = {
440
444
  commandsRoot: list('.claude/commands'),
441
445
  sdoCommands: list('.claude/commands/sDo'),
442
446
  skills: list('.claude/skills'),
447
+ agentFiles,
443
448
  });
444
449
  const failed = report.errors > 0 || (ctx.flags.strict === true && report.warnings > 0);
445
450
  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
@@ -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,9 @@ 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'),
54
57
  // JUnit/TAP test-result parser (dorny/test-reporter demand) — raw results →
55
58
  // failing test + file:line + message; the front-half of the test→fix loop.
56
59
  testreport: () => import('./testreport.js'),
@@ -293,7 +296,7 @@ export async function getCommandsByCategory() {
293
296
  // three slots from 'statusline' onward (completionsCmd received the
294
297
  // statusline module, analyzeCmd received completions, …), scrambling the
295
298
  // 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([
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([
297
300
  loadCommand('daemon'), loadCommand('doctor'), loadCommand('embeddings'), loadCommand('neural'),
298
301
  loadCommand('performance'), loadCommand('security'), loadCommand('swarmvector'), loadCommand('hive-mind'),
299
302
  loadCommand('config'), loadCommand('statusline'), loadCommand('compress'), loadCommand('efficiency'),
@@ -301,7 +304,7 @@ export async function getCommandsByCategory() {
301
304
  loadCommand('analyze'), loadCommand('route'), loadCommand('progress'), loadCommand('providers'),
302
305
  loadCommand('plugins'), loadCommand('deployment'), loadCommand('claims'), loadCommand('issues'),
303
306
  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'),
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'),
305
308
  ]);
306
309
  return {
307
310
  primary: [
@@ -317,7 +320,7 @@ export async function getCommandsByCategory() {
317
320
  utility: [
318
321
  configCmd, doctorCmd, daemonCmd, completionsCmd,
319
322
  migrateCmd, workflowCmd, demoCmd,
320
- statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
323
+ statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, couplingCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
321
324
  ].filter(Boolean),
322
325
  analysis: [
323
326
  analyzeCmd, routeCmd, progressCmd, usageCmd, hudCmd, codegraphCmd,
@@ -15,6 +15,7 @@
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { output } from '../output.js';
17
17
  import { redactText, scanText, formatFindingsSummary } from '../redact/redact.js';
18
+ import { toSarif } from '../redact/sarif.js';
18
19
  import { writeStdout } from '../util/stdout.js';
19
20
  function readStdin() {
20
21
  return new Promise((resolve) => {
@@ -74,6 +75,14 @@ async function run(ctx) {
74
75
  // Scan mode: report findings, never rewrite; exit non-zero if any secret.
75
76
  if (scanMode) {
76
77
  const findings = scanText(input, opts);
78
+ if (ctx.flags.sarif === true) {
79
+ const source = typeof ctx.flags.source === 'string' ? ctx.flags.source : undefined;
80
+ process.stdout.write(toSarif(findings, { artifactUri: source }) + '\n');
81
+ // Same gate as text --scan: exit 1 on any secret (a CI author piping to
82
+ // upload-sarif adds `|| true` so the upload step still runs).
83
+ const code = findings.length > 0 ? 1 : wrappedCode;
84
+ return { success: code === 0, exitCode: code };
85
+ }
77
86
  if (asJson) {
78
87
  process.stdout.write(JSON.stringify({ count: findings.length, findings }, null, 2) + '\n');
79
88
  }
@@ -110,6 +119,8 @@ export const redactCommand = {
110
119
  options: [
111
120
  { name: 'scan', description: 'scan only: report findings and exit 1 if any secret is present (CI gate), never rewrite', type: 'boolean' },
112
121
  { name: 'json', description: 'emit findings as JSON', type: 'boolean' },
122
+ { name: 'sarif', description: 'scan only: emit findings as a SARIF 2.1.0 report (pipe to github/codeql-action/upload-sarif for code-scanning alerts)', type: 'boolean' },
123
+ { name: 'source', description: 'with --sarif: artifact URI/path the findings are anchored to (a stream has no file by default)', type: 'string' },
113
124
  { name: 'keep', description: 'keep this many leading chars of each secret (default 3; 0 = full mask)', type: 'string' },
114
125
  { name: 'token', description: 'replacement token after the kept prefix (default [REDACTED])', type: 'string' },
115
126
  { name: 'no-entropy', description: 'disable the high-entropy keyword=value fallback', type: 'boolean' },
@@ -14,7 +14,7 @@ import * as path from 'node:path';
14
14
  import * as os from 'node:os';
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { output } from '../output.js';
17
- import { planRelease, renderStep } from '../release/release.js';
17
+ import { planRelease, renderStep, resolveSiteIdentity, siteCommitArgs } from '../release/release.js';
18
18
  /** Escape every regex metacharacter (incl. backslash) so a value is a literal in `new RegExp`. */
19
19
  function escapeRegExp(s) {
20
20
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -125,13 +125,35 @@ async function executePlan(plan, root) {
125
125
  }
126
126
  const g = (args) => { execFileSync('git', args, { cwd: siteDir, stdio: 'inherit' }); };
127
127
  g(['add', '-A']);
128
- try {
129
- g(['commit', '-m', `release: sync site for v${plan.next}\n\nCo-Authored-By: Swarmdo <maintainers@swarmdo.com>`]);
130
- }
131
- catch {
128
+ // Only a genuinely empty index is the benign "in sync" case. Detect it
129
+ // explicitly `git diff --cached --quiet` exits 0 iff nothing is staged
130
+ // — instead of treating EVERY commit failure (a missing git identity, a
131
+ // rejected hook) as a no-op, which silently skipped the whole deploy. (#82)
132
+ const nothingStaged = (() => {
133
+ try {
134
+ execFileSync('git', ['diff', '--cached', '--quiet'], { cwd: siteDir, stdio: ['ignore', 'pipe', 'pipe'] });
135
+ return true;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ })();
141
+ if (nothingStaged) {
132
142
  output.writeln(output.dim(' site already in sync — nothing to commit'));
133
143
  break;
134
144
  }
145
+ // The throwaway clone inherits no git identity; inject the operator's
146
+ // (falling back to the Swarmdo bot) so the commit doesn't die on a
147
+ // machine whose git user is configured only per-repo, not globally. (#82)
148
+ const readCfg = (key) => {
149
+ try {
150
+ return execFileSync('git', ['-C', root, 'config', key], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
151
+ }
152
+ catch {
153
+ return '';
154
+ }
155
+ };
156
+ g(siteCommitArgs(plan.next, resolveSiteIdentity(readCfg)));
135
157
  g(['push', 'origin', 'main']);
136
158
  // poll production (GitHub Pages deploys in ~1min)
137
159
  let live = false;