swarmdo 1.30.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 (88) hide show
  1. package/README.md +11 -6
  2. package/package.json +1 -1
  3. package/v3/@swarmdo/cli/dist/src/affected/affected.d.ts +6 -1
  4. package/v3/@swarmdo/cli/dist/src/affected/affected.js +7 -2
  5. package/v3/@swarmdo/cli/dist/src/apply/apply.js +24 -8
  6. package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
  7. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +47 -1
  8. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
  9. package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
  10. package/v3/@swarmdo/cli/dist/src/commands/comms.d.ts +16 -0
  11. package/v3/@swarmdo/cli/dist/src/commands/comms.js +280 -0
  12. package/v3/@swarmdo/cli/dist/src/commands/compact.js +6 -2
  13. package/v3/@swarmdo/cli/dist/src/commands/config.js +7 -1
  14. package/v3/@swarmdo/cli/dist/src/commands/coupling.d.ts +17 -0
  15. package/v3/@swarmdo/cli/dist/src/commands/coupling.js +85 -0
  16. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +176 -1
  17. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +10 -3
  18. package/v3/@swarmdo/cli/dist/src/commands/index.js +12 -3
  19. package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
  20. package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
  21. package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
  22. package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
  23. package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
  24. package/v3/@swarmdo/cli/dist/src/commands/usage.js +34 -1
  25. package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
  26. package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
  27. package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
  28. package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
  29. package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
  30. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
  31. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
  32. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
  33. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +6 -1
  34. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +14 -4
  35. package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
  36. package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
  37. package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
  38. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
  39. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
  40. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +28 -1
  41. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
  42. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
  43. package/v3/@swarmdo/cli/dist/src/index.js +8 -0
  44. package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
  45. package/v3/@swarmdo/cli/dist/src/mcp-client.js +4 -0
  46. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
  47. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
  48. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
  49. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -0
  50. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +2 -0
  51. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +2 -0
  52. package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
  53. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
  54. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
  55. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
  56. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
  57. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
  58. package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
  59. package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
  60. package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
  61. package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
  62. package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
  63. package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
  64. package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
  65. package/v3/@swarmdo/cli/dist/src/redact/redact.js +22 -5
  66. package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
  67. package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
  68. package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
  69. package/v3/@swarmdo/cli/dist/src/release/release.js +29 -1
  70. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
  71. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
  72. package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
  73. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
  74. package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
  75. package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
  76. package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
  77. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
  78. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
  79. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +1 -0
  80. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +35 -1
  81. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +58 -4
  82. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
  83. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
  84. package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
  85. package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
  86. package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
  87. package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
  88. package/v3/@swarmdo/cli/package.json +1 -1
@@ -12,6 +12,7 @@ const f = (file, severity, rule, message) => ({ file, severity, rule, message })
12
12
  export const TOPOLOGIES = ['hierarchical', 'mesh', 'hierarchical-mesh', 'ring', 'star', 'hybrid', 'adaptive'];
13
13
  export const MEMORY_BACKENDS = ['agentdb', 'sqlite', 'hybrid', 'memory'];
14
14
  import { parseOpenRouterConfig } from '../providers/openrouter-config.js';
15
+ import { lintAgents } from './agents-lint.js';
15
16
  export const KNOWN_CONFIG_KEYS = ['topology', 'maxAgents', 'strategy', 'consensus', 'memory', 'memoryBackend', 'hnsw', 'neural', 'embeddings', 'providers', 'mcp', 'logging', 'daemon', 'hooks', 'version', 'openrouter', '$schema'];
16
17
  // Current Claude Code hook events (source: code.claude.com/docs/en/hooks).
17
18
  // Kept in sync with the runtime; a stale list here false-warns on valid hooks.
@@ -158,11 +159,18 @@ export function lintMcpConfig(file, obj) {
158
159
  return out;
159
160
  }
160
161
  /** Pre-1.4 layout leftovers: flat commands / unprefixed skill duplicates. */
161
- export function lintLegacyLayout(commandsRoot, skills) {
162
+ export function lintLegacyLayout(commandsRoot, skills, sdoCommands = []) {
162
163
  const out = [];
163
- const flat = commandsRoot.filter((n) => n !== 'sDo');
164
+ // A flat command is a pre-1.4 swarmdo leftover ONLY if the same command also
165
+ // exists under sDo/ today (a twin) — mirroring the skill twin-check below.
166
+ // `.claude/commands/` is Claude Code's shared, ecosystem-wide slash-command
167
+ // dir, so a user's own command namespace or another plugin's coexisting with
168
+ // sDo/ must NOT be flagged merely for not being `sDo` (that was #66).
169
+ const base = (n) => n.replace(/\.md$/, '');
170
+ const sdoBase = new Set(sdoCommands.map(base));
171
+ const flat = commandsRoot.filter((n) => n !== 'sDo' && sdoBase.has(base(n)));
164
172
  if (flat.length > 0) {
165
- out.push(f('.claude/commands', 'warn', 'pre-1.4-commands', `${flat.length} entr${flat.length === 1 ? 'y' : 'ies'} outside the sDo/ namespace (${flat.slice(0, 5).join(', ')}${flat.length > 5 ? ', …' : ''}) — swarmdo commands moved to /sDo:* in v1.4.0; re-run \`swarmdo init --force\` to migrate swarmdo-owned ones`));
173
+ out.push(f('.claude/commands', 'warn', 'pre-1.4-commands', `${flat.length} swarmdo command${flat.length === 1 ? '' : 's'} left flat outside the sDo/ namespace (${flat.slice(0, 5).join(', ')}${flat.length > 5 ? ', …' : ''}) — swarmdo commands moved to /sDo:* in v1.4.0; re-run \`swarmdo init --force\` to migrate them`));
166
174
  }
167
175
  const skillSet = new Set(skills);
168
176
  const dupes = skills.filter((s) => !s.startsWith('sdo-') && skillSet.has(`sdo-${s}`));
@@ -186,7 +194,9 @@ export function lintAll(input) {
186
194
  const { obj, findings: jf } = lintJson(input.mcpConfig.file, input.mcpConfig.raw);
187
195
  findings.push(...jf, ...lintMcpConfig(input.mcpConfig.file, obj));
188
196
  }
189
- findings.push(...lintLegacyLayout(input.commandsRoot ?? [], input.skills ?? []));
197
+ findings.push(...lintLegacyLayout(input.commandsRoot ?? [], input.skills ?? [], input.sdoCommands ?? []));
198
+ if (input.agentFiles?.length)
199
+ findings.push(...lintAgents(input.agentFiles));
190
200
  return {
191
201
  findings,
192
202
  errors: findings.filter((x) => x.severity === 'error').length,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * coupling.ts — temporal (co-change) coupling mined from git history.
3
+ *
4
+ * "Which files change together?" — the EMPIRICAL complement to `affected`'s
5
+ * STATIC import graph. Files that repeatedly land in the same commit are
6
+ * coupled in practice even when no import edge connects them (a JSON schema and
7
+ * its TS type, a serializer/deserializer split across modules, a feature flag
8
+ * and its consumers, a doc that must track an API). Surfacing that lets an agent
9
+ * or a maintainer catch the edit they'd otherwise forget.
10
+ *
11
+ * This is code-maat's `coupling`/`soc` analysis (Tornhill, *Your Code as a
12
+ * Crime Scene*), productized as "Change Coupling" in CodeScene. The scoring core
13
+ * is a pure fold over the SAME `git log --numstat` dump `hotspots` already
14
+ * parses, so it reuses that Commit type + parseGitLog and is unit-tested without
15
+ * a repo.
16
+ */
17
+ import type { Commit } from '../hotspots/hotspots.js';
18
+ export interface CouplingPair {
19
+ /** the lexicographically smaller path of the pair */
20
+ a: string;
21
+ /** the lexicographically larger path of the pair */
22
+ b: string;
23
+ /** commits touching BOTH a and b */
24
+ shared: number;
25
+ /** commits touching a (within the file cap) */
26
+ aCommits: number;
27
+ /** commits touching b (within the file cap) */
28
+ bCommits: number;
29
+ /** shared / min(aCommits, bCommits) ∈ (0,1]; 1.0 = they never move apart */
30
+ degree: number;
31
+ }
32
+ export interface CouplingOptions {
33
+ /** drop pairs sharing fewer than this many commits (default 2) — support threshold */
34
+ minShared?: number;
35
+ /**
36
+ * skip commits touching more than this many files (default 30; 0 = no cap).
37
+ * A sweeping rename/format/license commit would otherwise couple everything
38
+ * it touches — the code-maat noise convention drops those wide commits.
39
+ */
40
+ maxFiles?: number;
41
+ /** keep only the top N pairs after ranking (default: all) */
42
+ top?: number;
43
+ /** keep only pairs involving this exact path (a "what couples with X?" query) */
44
+ focus?: string;
45
+ }
46
+ /**
47
+ * Rank co-changing file pairs. Pure + deterministic.
48
+ *
49
+ * degree(A,B) = shared / min(commits(A), commits(B)) — the fraction of the
50
+ * rarer file's commits that also touched the other. Symmetric, in (0,1].
51
+ */
52
+ export declare function computeCoupling(commits: Commit[], opts?: CouplingOptions): CouplingPair[];
53
+ export declare function couplingToCsv(pairs: CouplingPair[]): string;
54
+ /** Human-readable table. Pure. */
55
+ export declare function formatCoupling(pairs: CouplingPair[]): string;
56
+ //# sourceMappingURL=coupling.d.ts.map
@@ -0,0 +1,86 @@
1
+ /**
2
+ * coupling.ts — temporal (co-change) coupling mined from git history.
3
+ *
4
+ * "Which files change together?" — the EMPIRICAL complement to `affected`'s
5
+ * STATIC import graph. Files that repeatedly land in the same commit are
6
+ * coupled in practice even when no import edge connects them (a JSON schema and
7
+ * its TS type, a serializer/deserializer split across modules, a feature flag
8
+ * and its consumers, a doc that must track an API). Surfacing that lets an agent
9
+ * or a maintainer catch the edit they'd otherwise forget.
10
+ *
11
+ * This is code-maat's `coupling`/`soc` analysis (Tornhill, *Your Code as a
12
+ * Crime Scene*), productized as "Change Coupling" in CodeScene. The scoring core
13
+ * is a pure fold over the SAME `git log --numstat` dump `hotspots` already
14
+ * parses, so it reuses that Commit type + parseGitLog and is unit-tested without
15
+ * a repo.
16
+ */
17
+ import { toCsv } from '../util/csv.js';
18
+ const US = '\x1f'; // pair-key separator (never appears in a file path)
19
+ /**
20
+ * Rank co-changing file pairs. Pure + deterministic.
21
+ *
22
+ * degree(A,B) = shared / min(commits(A), commits(B)) — the fraction of the
23
+ * rarer file's commits that also touched the other. Symmetric, in (0,1].
24
+ */
25
+ export function computeCoupling(commits, opts = {}) {
26
+ const minShared = opts.minShared ?? 2;
27
+ const maxFiles = opts.maxFiles ?? 30;
28
+ const fileCommits = new Map(); // path → # commits touching it
29
+ const pairShared = new Map(); // "a\x1fb" → # commits touching both
30
+ for (const c of commits) {
31
+ const paths = [...new Set(c.files.map((f) => f.path))].filter(Boolean);
32
+ if (maxFiles > 0 && paths.length > maxFiles)
33
+ continue; // skip sweeping commits (both counts + pairs)
34
+ for (const p of paths)
35
+ fileCommits.set(p, (fileCommits.get(p) ?? 0) + 1);
36
+ const sorted = paths.slice().sort(); // canonical (a<b) pair keys
37
+ for (let i = 0; i < sorted.length; i++) {
38
+ for (let j = i + 1; j < sorted.length; j++) {
39
+ const key = sorted[i] + US + sorted[j];
40
+ pairShared.set(key, (pairShared.get(key) ?? 0) + 1);
41
+ }
42
+ }
43
+ }
44
+ const pairs = [];
45
+ for (const [key, shared] of pairShared) {
46
+ if (shared < minShared)
47
+ continue;
48
+ const sep = key.indexOf(US);
49
+ const a = key.slice(0, sep);
50
+ const b = key.slice(sep + 1);
51
+ if (opts.focus && a !== opts.focus && b !== opts.focus)
52
+ continue;
53
+ const aCommits = fileCommits.get(a) ?? 0;
54
+ const bCommits = fileCommits.get(b) ?? 0;
55
+ const denom = Math.min(aCommits, bCommits) || 1;
56
+ const degree = Math.round((shared / denom) * 100) / 100;
57
+ pairs.push({ a, b, shared, aCommits, bCommits, degree });
58
+ }
59
+ // deterministic: strongest coupling first, then most-shared, then path order
60
+ pairs.sort((x, y) => y.degree - x.degree ||
61
+ y.shared - x.shared ||
62
+ (x.a < y.a ? -1 : x.a > y.a ? 1 : 0) ||
63
+ (x.b < y.b ? -1 : x.b > y.b ? 1 : 0));
64
+ return opts.top && opts.top > 0 ? pairs.slice(0, opts.top) : pairs;
65
+ }
66
+ export function couplingToCsv(pairs) {
67
+ const headers = ['fileA', 'fileB', 'degree', 'shared', 'commitsA', 'commitsB'];
68
+ const rows = pairs.map((p) => [p.a, p.b, p.degree, p.shared, p.aCommits, p.bCommits]);
69
+ return toCsv(headers, rows);
70
+ }
71
+ /** Human-readable table. Pure. */
72
+ export function formatCoupling(pairs) {
73
+ if (pairs.length === 0)
74
+ return 'no co-change coupling found (raise --since or lower --min-shared)';
75
+ const lines = [];
76
+ lines.push('rank degree shared commits(A/B) files');
77
+ pairs.forEach((p, i) => {
78
+ const rank = String(i + 1).padStart(4);
79
+ const degree = `${Math.round(p.degree * 100)}%`.padStart(6);
80
+ const shared = String(p.shared).padStart(6);
81
+ const ab = `${p.aCommits}/${p.bCommits}`.padStart(11);
82
+ lines.push(`${rank} ${degree} ${shared} ${ab} ${p.a} ↔ ${p.b}`);
83
+ });
84
+ return lines.join('\n');
85
+ }
86
+ //# sourceMappingURL=coupling.js.map
@@ -36,6 +36,26 @@ const VITE_BUILTINS = new Set(['MODE', 'BASE_URL', 'PROD', 'DEV', 'SSR']);
36
36
  export function extractEnvRefs(source, file) {
37
37
  const out = [];
38
38
  const lines = source.split('\n');
39
+ // Map a byte offset in `source` to its 1-based line, for the whole-source
40
+ // destructure scan below (which can span lines). lineStart[i] = the offset
41
+ // where line i+1 begins; binary-search for the last start <= offset.
42
+ const lineStart = [0];
43
+ for (let k = 0; k < source.length; k++)
44
+ if (source[k] === '\n')
45
+ lineStart.push(k + 1);
46
+ const lineAt = (index) => {
47
+ let lo = 0, hi = lineStart.length - 1;
48
+ while (lo < hi) {
49
+ const mid = (lo + hi + 1) >> 1;
50
+ if (lineStart[mid] <= index)
51
+ lo = mid;
52
+ else
53
+ hi = mid - 1;
54
+ }
55
+ return lo + 1;
56
+ };
57
+ // Member-style reads (process.env.X, os.getenv('X'), …) are inherently
58
+ // single-line — scan line by line so each ref carries its own line number.
39
59
  for (let i = 0; i < lines.length; i++) {
40
60
  const line = lines[i];
41
61
  if (!line.includes('env') && !line.includes('getenv'))
@@ -50,21 +70,27 @@ export function extractEnvRefs(source, file) {
50
70
  out.push({ key, file, line: i + 1 });
51
71
  }
52
72
  }
53
- // Destructuring reads: `const { PORT, DB_URL } = process.env` (each property
54
- // key is an env-var reference). The `(?![.[])` guard keeps it to the bare
55
- // `process.env` object, not `= process.env.FOO`. Single-line, like the rest.
56
- DESTRUCTURE_RE.lastIndex = 0;
57
- let dm;
58
- while ((dm = DESTRUCTURE_RE.exec(line)) !== null) {
59
- for (const part of dm[1].split(',')) {
60
- // The env-var name is the property key left of a rename `:` or default `=`.
61
- const key = part.trim().split(/[:=]/)[0].trim();
62
- if (!/^[A-Za-z_$][\w$]*$/.test(key))
63
- continue; // skip `...rest`, empties
64
- if (VITE_BUILTINS.has(key))
65
- continue;
66
- out.push({ key, file, line: i + 1 });
73
+ }
74
+ // Destructuring reads: `const { PORT, DB_URL } = process.env`. Scanned over the
75
+ // WHOLE source, not per line — a Prettier-wrapped list spans lines:
76
+ // const {\n PORT,\n DB_URL,\n} = process.env
77
+ // The regex's `\s*`/`[^}]+` already match newlines, so running it per line was
78
+ // exactly what hid multi-line destructures (a false-clean `env --ci`). The
79
+ // `(?![.[])` guard keeps it to the bare object, not `= process.env.FOO`; each
80
+ // property key maps back to its own line via its offset.
81
+ DESTRUCTURE_RE.lastIndex = 0;
82
+ let dm;
83
+ while ((dm = DESTRUCTURE_RE.exec(source)) !== null) {
84
+ const bodyStart = dm.index + dm[0].indexOf(dm[1]);
85
+ let partOffset = 0;
86
+ for (const part of dm[1].split(',')) {
87
+ // The env-var name is the property key — left of a rename `:` or default `=`.
88
+ const key = part.trim().split(/[:=]/)[0].trim();
89
+ if (/^[A-Za-z_$][\w$]*$/.test(key) && !VITE_BUILTINS.has(key)) { // skip `...rest`, empties, builtins
90
+ const kOff = part.indexOf(key);
91
+ out.push({ key, file, line: lineAt(bodyStart + partOffset + (kOff < 0 ? 0 : kOff)) });
67
92
  }
93
+ partOffset += part.length + 1; // + the comma consumed by split
68
94
  }
69
95
  }
70
96
  return out;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * command-guard.ts — classify a bash command as safe or blockable, for a
3
+ * PreToolUse/Bash hook that emits Claude Code's `permissionDecision: "deny"`.
4
+ *
5
+ * swarmdo runs headless `claude -p --dangerously-skip-permissions` in its
6
+ * daemon / worker / repair flows; that flag skips the interactive approval
7
+ * prompt but NOT hooks, so a PreToolUse deny is the only remaining guardrail.
8
+ * The denylist is deliberately CONSERVATIVE — it blocks a small set of clearly
9
+ * destructive / irreversible / remote-exec commands and lets everything else
10
+ * through, so a routine `rm -rf ./build`, `rm -rf node_modules`, or a
11
+ * feature-branch force-push is never blocked. Pure + injection-free → fully
12
+ * unit-tested. This is defense-in-depth, not a sandbox: a determined command
13
+ * (indirection through a variable, an obscure tool) can still get through.
14
+ */
15
+ export interface GuardVerdict {
16
+ block: boolean;
17
+ /** why it was blocked (present only when block === true) */
18
+ reason?: string;
19
+ /** the matched rule id (stable identifier, useful for tests/telemetry) */
20
+ rule?: string;
21
+ }
22
+ interface Rule {
23
+ id: string;
24
+ reason: string;
25
+ test: (c: string) => boolean;
26
+ }
27
+ /** The denylist. Order determines which reason is reported when several match. */
28
+ export declare const GUARD_RULES: Rule[];
29
+ /** Classify a bash command; blocks on the first matching danger rule, else allows. */
30
+ export declare function classifyCommand(command: string): GuardVerdict;
31
+ /**
32
+ * Extract the Bash command from a PreToolUse hook payload (the JSON Claude Code
33
+ * pipes on stdin: `{ "tool_name": "Bash", "tool_input": { "command": "…" } }`).
34
+ * Tolerant — returns '' when absent or unparseable, so the hook simply allows.
35
+ */
36
+ export declare function extractBashCommand(payload: string): string;
37
+ /** The PreToolUse deny payload Claude Code honors to hard-block a tool call. */
38
+ export declare function denyOutput(reason: string): {
39
+ hookSpecificOutput: {
40
+ hookEventName: 'PreToolUse';
41
+ permissionDecision: 'deny';
42
+ permissionDecisionReason: string;
43
+ };
44
+ };
45
+ export {};
46
+ //# sourceMappingURL=command-guard.d.ts.map
@@ -0,0 +1,120 @@
1
+ /**
2
+ * command-guard.ts — classify a bash command as safe or blockable, for a
3
+ * PreToolUse/Bash hook that emits Claude Code's `permissionDecision: "deny"`.
4
+ *
5
+ * swarmdo runs headless `claude -p --dangerously-skip-permissions` in its
6
+ * daemon / worker / repair flows; that flag skips the interactive approval
7
+ * prompt but NOT hooks, so a PreToolUse deny is the only remaining guardrail.
8
+ * The denylist is deliberately CONSERVATIVE — it blocks a small set of clearly
9
+ * destructive / irreversible / remote-exec commands and lets everything else
10
+ * through, so a routine `rm -rf ./build`, `rm -rf node_modules`, or a
11
+ * feature-branch force-push is never blocked. Pure + injection-free → fully
12
+ * unit-tested. This is defense-in-depth, not a sandbox: a determined command
13
+ * (indirection through a variable, an obscure tool) can still get through.
14
+ */
15
+ /** `rm` invoked with BOTH a recursive and a force flag (combined `-rf`/`-fr` or separate). */
16
+ function isRecursiveForceRm(c) {
17
+ if (!/\brm\b/.test(c))
18
+ return false;
19
+ const recursive = /(?:^|\s)-[a-z]*r[a-z]*(?=\s|$)/i.test(c) || /(?:^|\s)--recursive\b/.test(c);
20
+ const force = /(?:^|\s)-[a-z]*f[a-z]*(?=\s|$)/i.test(c) || /(?:^|\s)--force\b/.test(c);
21
+ return recursive && force;
22
+ }
23
+ /** Does the command target a root / home / top-level system path (the only rm targets we block)? */
24
+ function hasCriticalTarget(c) {
25
+ if (/--no-preserve-root\b/.test(c))
26
+ return true;
27
+ // a bare `/`, `~`, `$HOME`/`${HOME}` token, optionally with a trailing `/` or `/*` or `*`
28
+ if (/(?:^|\s)(?:\/|~|\$\{?HOME\}?)(?:\/\s|\/$|\/\*|\*|\s|$)/.test(c))
29
+ return true;
30
+ // a top-level system directory
31
+ if (/(?:^|\s)\/(?:etc|usr|bin|sbin|var|lib|lib64|boot|sys|proc|dev|root|opt)(?:\/|\s|\*|$)/.test(c))
32
+ return true;
33
+ return false;
34
+ }
35
+ /** The denylist. Order determines which reason is reported when several match. */
36
+ export const GUARD_RULES = [
37
+ {
38
+ id: 'rm-rf-critical',
39
+ reason: 'recursive force-delete of a root, home, or system path (rm -rf /, ~, or --no-preserve-root)',
40
+ test: (c) => isRecursiveForceRm(c) && hasCriticalTarget(c),
41
+ },
42
+ {
43
+ id: 'pipe-to-shell',
44
+ reason: 'piping remote content straight into a shell (curl/wget … | sh) — remote code execution',
45
+ test: (c) => /(?:\bcurl\b|\bwget\b)[^|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish)\b/.test(c),
46
+ },
47
+ {
48
+ id: 'force-push-protected',
49
+ reason: 'force-push to a protected branch (main/master) — use --force-with-lease',
50
+ test: (c) => /\bgit\s+push\b/.test(c) && /(?:--force(?!-with-lease)\b|(?:^|\s)-f\b)/.test(c) && /\b(?:main|master)\b/.test(c),
51
+ },
52
+ {
53
+ id: 'chmod-world-writable',
54
+ reason: 'world-writable permissions (chmod 777)',
55
+ test: (c) => /\bchmod\s+(?:-[a-z]+\s+)*(?:0?777|a\+rwx|ugo\+rwx)\b/i.test(c),
56
+ },
57
+ {
58
+ id: 'dd-to-device',
59
+ reason: 'dd writing directly to a block device (of=/dev/…) — data-destroying',
60
+ test: (c) => /\bdd\b[^|;&]*\bof=\/dev\/(?:sd|nvme|disk|hd|mmcblk|vd|xvd)/.test(c),
61
+ },
62
+ {
63
+ id: 'mkfs',
64
+ reason: 'formatting a filesystem (mkfs) — data-destroying',
65
+ test: (c) => /\bmkfs(?:\.\w+)?\b/.test(c),
66
+ },
67
+ {
68
+ id: 'redirect-to-device',
69
+ reason: 'redirecting output onto a raw block device (> /dev/sd…) — data-destroying',
70
+ test: (c) => /(?:^|\s)>{1,2}\s*\/dev\/(?:sd|nvme|disk|hd|mmcblk|vd|xvd)/.test(c),
71
+ },
72
+ {
73
+ id: 'fork-bomb',
74
+ reason: 'shell fork bomb',
75
+ test: (c) => /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(c.replace(/\s+/g, ' ')),
76
+ },
77
+ ];
78
+ /** Classify a bash command; blocks on the first matching danger rule, else allows. */
79
+ export function classifyCommand(command) {
80
+ const c = (command ?? '').trim();
81
+ if (!c)
82
+ return { block: false };
83
+ for (const r of GUARD_RULES) {
84
+ // a bad regex must never crash the hook (and thus never wrongly block)
85
+ try {
86
+ if (r.test(c))
87
+ return { block: true, reason: r.reason, rule: r.id };
88
+ }
89
+ catch { /* skip */ }
90
+ }
91
+ return { block: false };
92
+ }
93
+ /**
94
+ * Extract the Bash command from a PreToolUse hook payload (the JSON Claude Code
95
+ * pipes on stdin: `{ "tool_name": "Bash", "tool_input": { "command": "…" } }`).
96
+ * Tolerant — returns '' when absent or unparseable, so the hook simply allows.
97
+ */
98
+ export function extractBashCommand(payload) {
99
+ if (!payload || !payload.trim())
100
+ return '';
101
+ try {
102
+ const o = JSON.parse(payload);
103
+ const cmd = o?.tool_input?.command ?? o?.command;
104
+ return typeof cmd === 'string' ? cmd : '';
105
+ }
106
+ catch {
107
+ return '';
108
+ }
109
+ }
110
+ /** The PreToolUse deny payload Claude Code honors to hard-block a tool call. */
111
+ export function denyOutput(reason) {
112
+ return {
113
+ hookSpecificOutput: {
114
+ hookEventName: 'PreToolUse',
115
+ permissionDecision: 'deny',
116
+ permissionDecisionReason: reason,
117
+ },
118
+ };
119
+ }
120
+ //# sourceMappingURL=command-guard.js.map
@@ -23,6 +23,28 @@ export const RECIPES = [
23
23
  title: 'Desktop ping when Claude needs input',
24
24
  description: 'Fires a desktop notification when Claude is waiting for input or a permission decision (Notification hook).',
25
25
  },
26
+ {
27
+ name: 'memory-inject',
28
+ event: 'UserPromptSubmit',
29
+ command: 'swarmdo hooks memory-inject',
30
+ title: 'Inject relevant memories into each prompt',
31
+ description: 'On every prompt, semantically searches your stored memories (claude-memories/auto-memory/patterns) and injects the most relevant ones as context under a token budget (UserPromptSubmit hook). Powered by `swarmdo hooks memory-inject`. Opt-in: adds a small per-prompt search.',
32
+ },
33
+ {
34
+ name: 'comms-inbox',
35
+ event: 'UserPromptSubmit',
36
+ command: 'swarmdo comms inbox --hook',
37
+ title: 'Surface new cross-session messages each prompt',
38
+ description: 'On every prompt, injects any unread messages other sessions sent to this one (multiplayer swarms) as context, then marks them read — so mail surfaces without polling (UserPromptSubmit hook). Powered by `swarmdo comms inbox --hook`. Opt-in.',
39
+ },
40
+ {
41
+ name: 'command-guard',
42
+ event: 'PreToolUse',
43
+ matcher: 'Bash',
44
+ command: 'swarmdo hooks guard-bash',
45
+ title: 'Block dangerous bash commands before they run',
46
+ description: 'Inspects every Bash tool call and hard-blocks a conservative denylist of destructive commands (rm -rf / , curl|sh, force-push to main, chmod 777, dd to a device, mkfs, fork bombs) via Claude Code’s PreToolUse `permissionDecision: "deny"`. Powered by `swarmdo hooks guard-bash`. Opt-in — the one guardrail that still fires under `--dangerously-skip-permissions`.',
47
+ },
26
48
  ];
27
49
  export function findRecipe(name) {
28
50
  const n = (name ?? '').trim().toLowerCase();
@@ -37,7 +59,12 @@ export function hasRecipe(settings, r) {
37
59
  const events = hooks?.[r.event];
38
60
  if (!Array.isArray(events))
39
61
  return false;
40
- return events.some((e) => e && typeof e === 'object' && Array.isArray(e.hooks) &&
62
+ // Match on matcher too: the same command under a DIFFERENT matcher is a
63
+ // distinct binding, so a matcher-scoped recipe isn't "already installed" just
64
+ // because its command appears elsewhere (matcher-less recipes compare
65
+ // undefined === undefined, unchanged).
66
+ return events.some((e) => e && typeof e === 'object' && e.matcher === r.matcher &&
67
+ Array.isArray(e.hooks) &&
41
68
  e.hooks.some((h) => h?.command === r.command));
42
69
  }
43
70
  /** Additively merge a recipe into a settings object. Pure: returns a new object,
@@ -64,6 +64,9 @@ export declare function resolveRenamePath(raw: string): string;
64
64
  export declare function parseGitLog(raw: string): Commit[];
65
65
  /** Aggregate commits into per-file hotspots, scored and ranked. Pure. */
66
66
  export declare function computeHotspots(commits: Commit[], now: number, opts?: HotspotOptions): FileHotspot[];
67
+ /** Export the ranking as CSV for spreadsheets / tech-debt review. Epoch dates
68
+ * render as ISO `YYYY-MM-DD` (UTC). Pure. */
69
+ export declare function hotspotsToCsv(spots: FileHotspot[]): string;
67
70
  /** Human-readable table. Pure. */
68
71
  export declare function formatHotspots(spots: FileHotspot[], now: number): string;
69
72
  //# sourceMappingURL=hotspots.d.ts.map
@@ -15,6 +15,7 @@
15
15
  * <SOH>%H<US>%an<US>%aI ← one header line per commit
16
16
  * <added>\t<deleted>\t<path> ← one numstat line per file ('-' for binary)
17
17
  */
18
+ import { toCsv } from '../util/csv.js';
18
19
  const SOH = '\x01'; // start-of-commit marker (git --format=format:%x01...)
19
20
  const US = '\x1f'; // field separator (%x1f)
20
21
  /**
@@ -134,6 +135,14 @@ export function computeHotspots(commits, now, opts = {}) {
134
135
  out = out.slice(0, opts.top);
135
136
  return out;
136
137
  }
138
+ /** Export the ranking as CSV for spreadsheets / tech-debt review. Epoch dates
139
+ * render as ISO `YYYY-MM-DD` (UTC). Pure. */
140
+ export function hotspotsToCsv(spots) {
141
+ const isoDay = (ms) => (ms > 0 ? new Date(ms).toISOString().slice(0, 10) : '');
142
+ const headers = ['path', 'risk', 'commits', 'churn', 'authors', 'firstTouched', 'lastTouched'];
143
+ const rows = spots.map((s) => [s.path, s.risk, s.commits, s.churn, s.authors, isoDay(s.firstTouched), isoDay(s.lastTouched)]);
144
+ return toCsv(headers, rows);
145
+ }
137
146
  /** Human-readable table. Pure. */
138
147
  export function formatHotspots(spots, now) {
139
148
  if (spots.length === 0)
@@ -20,6 +20,7 @@ import { commands, getCommandsByCategory, getCommand, getCommandAsync, getComman
20
20
  import { suggestCommand } from './suggest.js';
21
21
  import { runStartupUpdateCheck } from './update/index.js';
22
22
  import { migrateStorageDir } from './storage-migration.js';
23
+ import { guardStreamEpipe } from './util/stdout.js';
23
24
  // Read version from package.json at runtime
24
25
  function getPackageVersion() {
25
26
  try {
@@ -68,6 +69,13 @@ export class CLI {
68
69
  */
69
70
  async run(args = process.argv.slice(2)) {
70
71
  try {
72
+ // #78 — Arm the EPIPE guard on stdout/stderr before ANY write happens, so
73
+ // `swarmdo <cmd> | head` (reader closes the pipe early) terminates cleanly
74
+ // instead of crashing with an unhandled 'error' event + raw stack trace.
75
+ // Covers every write path (output.writeln, console, writeStdout), not just
76
+ // writeStdout callers. Idempotent per stream.
77
+ guardStreamEpipe(process.stdout);
78
+ guardStreamEpipe(process.stderr);
71
79
  // One-time storage migration: rename a legacy `.claude-flow/` data dir
72
80
  // to `.swarmdo/` so users upgrading across the clean-break rename keep
73
81
  // their agent registry, swarm state, task store, and sessions. Cheap
@@ -39,7 +39,10 @@ export function classifyLicense(pkg) {
39
39
  if (Array.isArray(arr) && arr.length) {
40
40
  const types = arr
41
41
  .map((e) => (typeof e === 'string' ? e : e?.type))
42
- .filter((t) => typeof t === 'string' && !!t.trim());
42
+ .filter((t) => typeof t === 'string' && !!t.trim())
43
+ // Normalize each entry like the singular `license`/`{type}` branches above,
44
+ // so npm sentinels (`SEE LICENSE IN …`, `UNLICENSED`) become UNKNOWN here too.
45
+ .map(normalizeNpmLicense);
43
46
  if (types.length)
44
47
  return types.join(' OR ');
45
48
  }
@@ -15,6 +15,7 @@ import { memoryTools } from './mcp-tools/memory-tools.js';
15
15
  import { configTools } from './mcp-tools/config-tools.js';
16
16
  import { hooksTools } from './mcp-tools/hooks-tools.js';
17
17
  import { taskTools } from './mcp-tools/task-tools.js';
18
+ import { commsTools } from './mcp-tools/comms-tools.js';
18
19
  import { sessionTools } from './mcp-tools/session-tools.js';
19
20
  import { hiveMindTools } from './mcp-tools/hive-mind-tools.js';
20
21
  import { workflowTools } from './mcp-tools/workflow-tools.js';
@@ -25,6 +26,7 @@ import { envTools } from './mcp-tools/env-tools.js';
25
26
  import { licenseTools } from './mcp-tools/license-tools.js';
26
27
  import { applyTools } from './mcp-tools/apply-tools.js';
27
28
  import { hotspotsTools } from './mcp-tools/hotspots-tools.js';
29
+ import { couplingTools } from './mcp-tools/coupling-tools.js';
28
30
  import { affectedTools } from './mcp-tools/affected-tools.js';
29
31
  import { cyclesTools } from './mcp-tools/cycles-tools.js';
30
32
  import { testreportTools } from './mcp-tools/testreport-tools.js';
@@ -107,6 +109,7 @@ const TOOL_GROUPS = {
107
109
  config: () => configTools,
108
110
  hooks: () => hooksTools,
109
111
  task: () => taskTools,
112
+ comms: () => commsTools,
110
113
  session: () => sessionTools,
111
114
  'hive-mind': () => hiveMindTools,
112
115
  workflow: () => workflowTools,
@@ -117,6 +120,7 @@ const TOOL_GROUPS = {
117
120
  license: () => licenseTools,
118
121
  apply: () => applyTools,
119
122
  hotspots: () => hotspotsTools,
123
+ coupling: () => couplingTools,
120
124
  affected: () => affectedTools,
121
125
  cycles: () => cyclesTools,
122
126
  testreport: () => testreportTools,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Comms MCP Tools — cross-session agent mailbox (#44).
3
+ *
4
+ * Thin wrappers over the pure comms/mailbox.ts engine + comms/store.ts fs
5
+ * persistence, so an in-Claude session can message another session (or read its
6
+ * own inbox) without shelling out. Same `.swarmdo/comms/store.json` the
7
+ * `swarmdo comms` CLI uses.
8
+ */
9
+ import { type MCPTool } from './types.js';
10
+ export declare const commsTools: MCPTool[];
11
+ //# sourceMappingURL=comms-tools.d.ts.map