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
@@ -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,12 +433,18 @@ 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')],
439
443
  mcpConfig: read('.mcp.json'),
440
444
  commandsRoot: list('.claude/commands'),
445
+ sdoCommands: list('.claude/commands/sDo'),
441
446
  skills: list('.claude/skills'),
447
+ agentFiles,
442
448
  });
443
449
  const failed = report.errors > 0 || (ctx.flags.strict === true && report.warnings > 0);
444
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
@@ -8,6 +8,9 @@ import { callMCPTool, MCPClientError } from '../mcp-client.js';
8
8
  import { storeCommand } from './transfer-store.js';
9
9
  import { existsSync, readFileSync, statSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
+ import { bridgeSearchEntries } from '../memory/memory-bridge.js';
12
+ import { selectInjectionMemories, mapSearchResultsToCandidates, extractPromptFromPayload, } from '../memory-inject/select.js';
13
+ import { classifyCommand, extractBashCommand, denyOutput } from '../hooks-recipe/command-guard.js';
11
14
  /**
12
15
  * #1686 — `?? 0` only defaults null/undefined; NaN slips through and
13
16
  * surfaces as `"NaN"` (or earlier crashed `.toFixed`) in the metrics
@@ -731,6 +734,174 @@ const routeCommand = {
731
734
  }
732
735
  }
733
736
  };
737
+ /**
738
+ * Read a Claude Code hook payload from stdin (JSON on UserPromptSubmit), with a
739
+ * short timeout so it never hangs when stdin isn't closed (a known Windows
740
+ * failure mode). Returns '' for a TTY or on any error — the caller then simply
741
+ * injects nothing.
742
+ */
743
+ async function readHookStdin(timeoutMs = 1500) {
744
+ if (process.stdin.isTTY)
745
+ return '';
746
+ return new Promise((resolve) => {
747
+ let data = '';
748
+ const finish = (v) => {
749
+ try {
750
+ process.stdin.pause();
751
+ }
752
+ catch { /* noop */ }
753
+ resolve(v);
754
+ };
755
+ const timer = setTimeout(() => finish(data), timeoutMs);
756
+ try {
757
+ process.stdin.setEncoding('utf8');
758
+ process.stdin.on('data', (c) => { data += c; });
759
+ process.stdin.on('end', () => { clearTimeout(timer); finish(data); });
760
+ process.stdin.on('error', () => { clearTimeout(timer); finish(data); });
761
+ process.stdin.resume();
762
+ }
763
+ catch {
764
+ clearTimeout(timer);
765
+ finish('');
766
+ }
767
+ });
768
+ }
769
+ const DEFAULT_INJECT_NAMESPACES = ['claude-memories', 'auto-memory', 'patterns'];
770
+ /**
771
+ * memory-inject — the #43 prompt-time semantic memory injection hook. Embeds the
772
+ * submitted prompt, vector-searches AgentDB across the memory namespaces, and
773
+ * emits the most relevant stored memories as UserPromptSubmit `additionalContext`
774
+ * under a strict token budget. Wired for the hook path: it NEVER errors out
775
+ * (that would break the user's prompt) and stays silent when nothing is relevant.
776
+ */
777
+ const memoryInjectCommand = {
778
+ name: 'memory-inject',
779
+ description: 'Inject relevant stored memories into the prompt (UserPromptSubmit hook)',
780
+ options: [
781
+ { name: 'prompt', short: 'p', description: 'Prompt text (else read the hook JSON from stdin)', type: 'string' },
782
+ { name: 'namespaces', description: 'Comma-separated namespaces to search', type: 'string', default: DEFAULT_INJECT_NAMESPACES.join(',') },
783
+ { name: 'budget', description: 'Max tokens of injected context', type: 'number', default: 800 },
784
+ { name: 'min-relevance', description: 'Minimum relevance score (0..1)', type: 'number', default: 0.35 },
785
+ { name: 'top-k', short: 'K', description: 'Max memories to inject', type: 'number', default: 5 },
786
+ { name: 'limit', description: 'Max search results to rank', type: 'number', default: 20 },
787
+ { name: 'preview', description: 'Print the block for humans instead of emitting hook JSON', type: 'boolean' },
788
+ { name: 'db', description: 'Path to the memory database', type: 'string' },
789
+ ],
790
+ examples: [
791
+ { command: 'swarmdo hooks memory-inject -p "refactor the auth module" --preview', description: 'Preview relevant memories for a prompt' },
792
+ { command: "echo '{\"prompt\":\"fix bug\"}' | swarmdo hooks memory-inject", description: 'Run as a UserPromptSubmit hook' },
793
+ ],
794
+ action: async (ctx) => {
795
+ const preview = Boolean(ctx.flags.preview);
796
+ // A hook must NEVER break the user's prompt: swallow every error and emit nothing.
797
+ try {
798
+ if (process.env.SWARMDO_MEMORY_INJECT_DISABLE === '1')
799
+ return { success: true };
800
+ let prompt = (ctx.flags.prompt || ctx.args.join(' ')).trim();
801
+ if (!prompt)
802
+ prompt = extractPromptFromPayload(await readHookStdin());
803
+ if (!prompt)
804
+ return { success: true }; // nothing to search on
805
+ const namespaces = String(ctx.flags.namespaces ?? DEFAULT_INJECT_NAMESPACES.join(','))
806
+ .split(',').map((s) => s.trim()).filter(Boolean);
807
+ const minRelevance = ctx.flags.minRelevance ?? 0.35;
808
+ const topK = ctx.flags.topK ?? 5;
809
+ const limit = Math.max(ctx.flags.limit ?? 20, topK);
810
+ const search = await bridgeSearchEntries({
811
+ query: prompt,
812
+ namespace: 'all',
813
+ limit,
814
+ threshold: minRelevance,
815
+ fullContent: true,
816
+ dbPath: ctx.flags.db,
817
+ });
818
+ const rows = (search && search.success ? search.results : []);
819
+ const candidates = mapSearchResultsToCandidates(rows, namespaces);
820
+ const result = selectInjectionMemories(candidates, {
821
+ budgetTokens: ctx.flags.budget ?? 800,
822
+ minRelevance,
823
+ topK,
824
+ });
825
+ if (preview) {
826
+ if (!result.block) {
827
+ output.printInfo('No relevant memories to inject.');
828
+ }
829
+ else {
830
+ output.writeln(result.block);
831
+ output.writeln();
832
+ output.printInfo(`Injected ${result.used.length} memor${result.used.length === 1 ? 'y' : 'ies'} ` +
833
+ `(~${result.tokensUsed} tokens${result.skipped ? `, ${result.skipped} skipped` : ''}).`);
834
+ }
835
+ return { success: true, data: { used: result.used.length, skipped: result.skipped, tokensUsed: result.tokensUsed } };
836
+ }
837
+ // Hook mode: emit additionalContext JSON ONLY when there's something to add,
838
+ // so an irrelevant prompt leaves Claude's context untouched.
839
+ if (result.block) {
840
+ process.stdout.write(JSON.stringify({
841
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: result.block },
842
+ }) + '\n');
843
+ }
844
+ return { success: true };
845
+ }
846
+ catch (err) {
847
+ if (preview)
848
+ output.printError(`Memory injection failed: ${String(err)}`);
849
+ return { success: true }; // never fail the hook
850
+ }
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
+ };
734
905
  // Explain subcommand
735
906
  const explainCommand = {
736
907
  name: 'explain',
@@ -4447,7 +4618,7 @@ const notifyCommand = {
4447
4618
  const recipeCommand = {
4448
4619
  name: 'recipe',
4449
4620
  aliases: ['recipes'],
4450
- description: 'Install ready-made Claude Code hook recipes (desktop notify) into settings — dry-run unless --apply',
4621
+ description: 'Install ready-made Claude Code hook recipes (desktop notify, memory injection) into settings — dry-run unless --apply',
4451
4622
  options: [
4452
4623
  { name: 'apply', type: 'boolean', description: 'write the change (default: preview only)', default: false },
4453
4624
  { name: 'shared', type: 'boolean', description: 'target .claude/settings.json (shared/committed) instead of settings.local.json', default: false },
@@ -4553,6 +4724,8 @@ export const hooksCommand = {
4553
4724
  sessionEndCommand,
4554
4725
  sessionRestoreCommand,
4555
4726
  routeCommand,
4727
+ memoryInjectCommand,
4728
+ guardBashCommand,
4556
4729
  explainCommand,
4557
4730
  pretrainCommand,
4558
4731
  buildAgentsCommand,
@@ -4610,6 +4783,8 @@ export const hooksCommand = {
4610
4783
  `${output.highlight('session-end')} - End current session and persist state`,
4611
4784
  `${output.highlight('session-restore')} - Restore a previous session`,
4612
4785
  `${output.highlight('route')} - Route tasks to optimal agents`,
4786
+ `${output.highlight('memory-inject')} - Inject relevant memories into the prompt (UserPromptSubmit)`,
4787
+ `${output.highlight('guard-bash')} - Block dangerous bash commands (PreToolUse deny)`,
4613
4788
  `${output.highlight('explain')} - Explain routing decisions`,
4614
4789
  `${output.highlight('pretrain')} - Bootstrap intelligence from repository`,
4615
4790
  `${output.highlight('build-agents')} - Generate optimized agent configs`,
@@ -13,7 +13,8 @@
13
13
  */
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { output } from '../output.js';
16
- import { parseGitLog, computeHotspots, formatHotspots } from '../hotspots/hotspots.js';
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) {
@@ -33,11 +34,12 @@ async function run(ctx) {
33
34
  }
34
35
  // Global --format (text|json|table); text and table both render the table.
35
36
  const asJson = ctx.flags.format === 'json';
37
+ const asCsv = ctx.flags.csv === true; // dedicated flag (global --format has no csv choice)
36
38
  // Capture history: SOH-delimited header + numstat, no merges. `%aN` (not
37
39
  // `%an`) resolves author names through `.mailmap`, so name/email variants of
38
40
  // the same person fold into one identity — otherwise the author-spread factor
39
41
  // in the risk score is silently inflated. Identical to `%an` when no .mailmap.
40
- 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'];
41
43
  if (pathArg)
42
44
  args.push('--', pathArg);
43
45
  let raw;
@@ -50,7 +52,10 @@ async function run(ctx) {
50
52
  }
51
53
  const now = Date.now();
52
54
  const spots = computeHotspots(parseGitLog(raw), now, { by, top: top > 0 ? top : undefined, minCommits });
53
- if (asJson) {
55
+ if (asCsv) {
56
+ process.stdout.write(hotspotsToCsv(spots) + '\n');
57
+ }
58
+ else if (asJson) {
54
59
  process.stdout.write(JSON.stringify({ generated: new Date(now).toISOString(), by, count: spots.length, hotspots: spots }, null, 2) + '\n');
55
60
  }
56
61
  else {
@@ -72,10 +77,12 @@ export const hotspotsCommand = {
72
77
  { name: 'top', description: 'keep only the top N files (default 20; 0 = all)', type: 'string' },
73
78
  { name: 'min-commits', description: 'drop files with fewer than N commits (default 1)', type: 'string' },
74
79
  { name: 'by', description: `sort key: ${SORT_KEYS.join('|')} (default risk)`, type: 'string' },
80
+ { name: 'csv', description: 'export the ranking as CSV (for spreadsheets)', type: 'boolean' },
75
81
  ],
76
82
  examples: [
77
83
  { command: 'swarmdo hotspots src --since 90d', description: 'Risk hotspots under src/ in the last 90 days' },
78
84
  { command: 'swarmdo hotspots --by churn --top 10 --format json', description: 'Top-10 by churn as JSON' },
85
+ { command: 'swarmdo hotspots --csv > hotspots.csv', description: 'Export the risk ranking to CSV' },
79
86
  ],
80
87
  action: run,
81
88
  };
@@ -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'),
@@ -100,6 +103,12 @@ const commandLoaders = {
100
103
  // Isolated git worktrees for parallel work (claude-squad/vibe-kanban demand)
101
104
  worktree: () => import('./worktree.js'),
102
105
  wt: () => import('./worktree.js'), // alias via loader key
106
+ // Cross-session agent mailbox (multiplayer swarms — dorsa_rohani demand)
107
+ comms: () => import('./comms.js'),
108
+ mailbox: () => import('./comms.js'), // alias via loader key
109
+ // Static audit of Claude Code permission rules (conflicts / dead / over-broad)
110
+ permissions: () => import('./permissions.js'),
111
+ perms: () => import('./permissions.js'), // alias via loader key
103
112
  // Export Claude Code sessions to Markdown (conversation-exporter demand)
104
113
  transcript: () => import('./transcript.js'),
105
114
  tx: () => import('./transcript.js'), // alias via loader key
@@ -287,7 +296,7 @@ export async function getCommandsByCategory() {
287
296
  // three slots from 'statusline' onward (completionsCmd received the
288
297
  // statusline module, analyzeCmd received completions, …), scrambling the
289
298
  // categorized help. Every load now has a named slot.
290
- 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([
291
300
  loadCommand('daemon'), loadCommand('doctor'), loadCommand('embeddings'), loadCommand('neural'),
292
301
  loadCommand('performance'), loadCommand('security'), loadCommand('swarmvector'), loadCommand('hive-mind'),
293
302
  loadCommand('config'), loadCommand('statusline'), loadCommand('compress'), loadCommand('efficiency'),
@@ -295,7 +304,7 @@ export async function getCommandsByCategory() {
295
304
  loadCommand('analyze'), loadCommand('route'), loadCommand('progress'), loadCommand('providers'),
296
305
  loadCommand('plugins'), loadCommand('deployment'), loadCommand('claims'), loadCommand('issues'),
297
306
  loadCommand('update'), loadCommand('process'), loadCommand('guidance'), loadCommand('appliance'),
298
- 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'),
299
308
  ]);
300
309
  return {
301
310
  primary: [
@@ -311,7 +320,7 @@ export async function getCommandsByCategory() {
311
320
  utility: [
312
321
  configCmd, doctorCmd, daemonCmd, completionsCmd,
313
322
  migrateCmd, workflowCmd, demoCmd,
314
- 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,
315
324
  ].filter(Boolean),
316
325
  analysis: [
317
326
  analyzeCmd, routeCmd, progressCmd, usageCmd, hudCmd, codegraphCmd,
@@ -0,0 +1,14 @@
1
+ /**
2
+ * permissions.ts — `swarmdo permissions` (alias `perms`) — audit Claude Code
3
+ * permission rules for conflicts, dead/shadowed rules, and over-broad grants.
4
+ *
5
+ * Complements `config lint` (which validates the hooks block): this reads the
6
+ * `permissions.allow` / `deny` / `ask` arrays from `.claude/settings.json` +
7
+ * `settings.local.json` (+ `~/.claude/settings.json` with `--global`), merges
8
+ * them into the effective ruleset Claude Code enforces, and runs the pure
9
+ * permissions/audit.ts analyzer. Read-only — never edits settings.
10
+ */
11
+ import type { Command } from '../types.js';
12
+ export declare const permissionsCommand: Command;
13
+ export default permissionsCommand;
14
+ //# sourceMappingURL=permissions.d.ts.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * permissions.ts — `swarmdo permissions` (alias `perms`) — audit Claude Code
3
+ * permission rules for conflicts, dead/shadowed rules, and over-broad grants.
4
+ *
5
+ * Complements `config lint` (which validates the hooks block): this reads the
6
+ * `permissions.allow` / `deny` / `ask` arrays from `.claude/settings.json` +
7
+ * `settings.local.json` (+ `~/.claude/settings.json` with `--global`), merges
8
+ * them into the effective ruleset Claude Code enforces, and runs the pure
9
+ * permissions/audit.ts analyzer. Read-only — never edits settings.
10
+ */
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { output } from '../output.js';
15
+ import { auditPermissions } from '../permissions/audit.js';
16
+ const strArray = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
17
+ /** Extract permissions.allow/deny/ask from a settings file, or null if none. */
18
+ function readPerms(file) {
19
+ try {
20
+ if (!existsSync(file))
21
+ return null;
22
+ const obj = JSON.parse(readFileSync(file, 'utf8'));
23
+ const perm = obj?.permissions;
24
+ if (!perm || typeof perm !== 'object')
25
+ return null;
26
+ const sets = { allow: strArray(perm.allow), deny: strArray(perm.deny), ask: strArray(perm.ask) };
27
+ return sets.allow.length + sets.deny.length + sets.ask.length > 0 ? sets : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ export const permissionsCommand = {
34
+ name: 'permissions',
35
+ aliases: ['perms'],
36
+ description: 'Audit Claude Code permission rules (allow/deny/ask) for conflicts, dead/shadowed rules, and over-broad grants',
37
+ options: [
38
+ { name: 'global', description: 'Also include ~/.claude/settings.json', type: 'boolean' },
39
+ { name: 'strict', description: 'Exit 1 if any error-severity finding (CI gate)', type: 'boolean' },
40
+ { name: 'json', description: 'Machine-readable output', type: 'boolean' },
41
+ ],
42
+ examples: [
43
+ { command: 'swarmdo permissions', description: "Audit this project's permission rules" },
44
+ { command: 'swarmdo permissions --global --strict', description: 'Include user-global settings; fail on conflicts' },
45
+ ],
46
+ action: async (ctx) => {
47
+ const cwd = ctx.cwd || process.cwd();
48
+ const files = [join(cwd, '.claude', 'settings.json'), join(cwd, '.claude', 'settings.local.json')];
49
+ if (ctx.flags.global === true)
50
+ files.push(join(homedir(), '.claude', 'settings.json'));
51
+ const used = [];
52
+ const merged = { allow: [], deny: [], ask: [] };
53
+ for (const file of files) {
54
+ const p = readPerms(file);
55
+ if (!p)
56
+ continue;
57
+ used.push(file);
58
+ merged.allow.push(...(p.allow ?? []));
59
+ merged.deny.push(...(p.deny ?? []));
60
+ merged.ask.push(...(p.ask ?? []));
61
+ }
62
+ const totalRules = merged.allow.length + merged.deny.length + merged.ask.length;
63
+ if (used.length === 0) {
64
+ if (ctx.flags.json === true)
65
+ output.printJson({ files: [], rules: 0, findings: [] });
66
+ else
67
+ output.printInfo('No permission rules found in .claude/settings*.json.');
68
+ return { success: true };
69
+ }
70
+ const findings = auditPermissions(merged);
71
+ const errors = findings.filter((f) => f.severity === 'error').length;
72
+ const warns = findings.filter((f) => f.severity === 'warn').length;
73
+ if (ctx.flags.json === true) {
74
+ output.printJson({ files: used, rules: totalRules, findings });
75
+ }
76
+ else if (findings.length === 0) {
77
+ output.printSuccess(`permissions audit: ${totalRules} rule(s) across ${used.length} file(s) — no issues`);
78
+ }
79
+ else {
80
+ const icon = (s) => (s === 'error' ? '✖' : s === 'warn' ? '⚠' : '·');
81
+ output.writeln(output.bold('Permission audit') + output.dim(` (${totalRules} rules, ${used.length} file${used.length === 1 ? '' : 's'})`));
82
+ for (const x of findings)
83
+ output.writeln(` ${icon(x.severity)} ${output.dim(`[${x.rule}]`)} ${x.message}`);
84
+ output.writeln('');
85
+ output.writeln(`${errors} error${errors === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'}, ${findings.length - errors - warns} info`);
86
+ }
87
+ const failed = ctx.flags.strict === true && errors > 0;
88
+ return { success: !failed, exitCode: failed ? 1 : 0, data: { rules: totalRules, findings } };
89
+ },
90
+ };
91
+ export default permissionsCommand;
92
+ //# sourceMappingURL=permissions.js.map
@@ -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;