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.
- package/README.md +11 -6
- package/package.json +1 -1
- package/v3/@swarmdo/cli/dist/src/affected/affected.d.ts +6 -1
- package/v3/@swarmdo/cli/dist/src/affected/affected.js +7 -2
- package/v3/@swarmdo/cli/dist/src/apply/apply.js +24 -8
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +47 -1
- package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
- package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
- package/v3/@swarmdo/cli/dist/src/commands/comms.d.ts +16 -0
- package/v3/@swarmdo/cli/dist/src/commands/comms.js +280 -0
- package/v3/@swarmdo/cli/dist/src/commands/compact.js +6 -2
- package/v3/@swarmdo/cli/dist/src/commands/config.js +7 -1
- package/v3/@swarmdo/cli/dist/src/commands/coupling.d.ts +17 -0
- package/v3/@swarmdo/cli/dist/src/commands/coupling.js +85 -0
- package/v3/@swarmdo/cli/dist/src/commands/hooks.js +176 -1
- package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +10 -3
- package/v3/@swarmdo/cli/dist/src/commands/index.js +12 -3
- package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
- package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
- package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
- package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
- package/v3/@swarmdo/cli/dist/src/commands/usage.js +34 -1
- package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
- package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
- package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
- package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
- package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
- package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
- package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +6 -1
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +14 -4
- package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
- package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
- package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +28 -1
- package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
- package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
- package/v3/@swarmdo/cli/dist/src/index.js +8 -0
- package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
- package/v3/@swarmdo/cli/dist/src/mcp-client.js +4 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +2 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
- package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
- package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
- package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
- package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
- package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
- package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
- package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
- package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +22 -5
- package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
- package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
- package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
- package/v3/@swarmdo/cli/dist/src/release/release.js +29 -1
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
- package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
- package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
- package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
- package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
- package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
- package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
- package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +1 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +35 -1
- package/v3/@swarmdo/cli/dist/src/usage/reflect.js +58 -4
- package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
- package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
- package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
- package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
- package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
- package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
- 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 (
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|