swarmdo 1.37.0 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/package.json +1 -1
- package/v3/@swarmdo/cli/dist/src/apply/apply.js +6 -1
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +43 -0
- package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
- package/v3/@swarmdo/cli/dist/src/commands/config.js +37 -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/hidden-coupling.d.ts +19 -0
- package/v3/@swarmdo/cli/dist/src/commands/hidden-coupling.js +92 -0
- package/v3/@swarmdo/cli/dist/src/commands/hooks.js +55 -0
- package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +2 -1
- package/v3/@swarmdo/cli/dist/src/commands/index.js +12 -3
- package/v3/@swarmdo/cli/dist/src/commands/ownership.d.ts +18 -0
- package/v3/@swarmdo/cli/dist/src/commands/ownership.js +88 -0
- package/v3/@swarmdo/cli/dist/src/commands/pack.js +4 -4
- 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/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/commands-lint.d.ts +31 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/commands-lint.js +127 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +8 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +7 -0
- 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/coupling/hidden.d.ts +45 -0
- package/v3/@swarmdo/cli/dist/src/coupling/hidden.js +66 -0
- 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 +8 -0
- package/v3/@swarmdo/cli/dist/src/mcp-client.js +4 -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/ownership-tools.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/ownership-tools.js +51 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +2 -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/ownership/ownership.d.ts +61 -0
- package/v3/@swarmdo/cli/dist/src/ownership/ownership.js +151 -0
- package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +1 -1
- package/v3/@swarmdo/cli/dist/src/pack/pack.js +12 -4
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +47 -16
- 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 +28 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +9 -1
- package/v3/@swarmdo/cli/dist/src/usage/reflect.js +14 -4
- 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/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swarmdo ownership` — per-file knowledge map + BUS FACTOR mined from git history.
|
|
3
|
+
*
|
|
4
|
+
* swarmdo ownership # knowledge map, most fragile files first
|
|
5
|
+
* swarmdo ownership src --since 90d # scope to a path + window
|
|
6
|
+
* swarmdo ownership --top 10 --format json
|
|
7
|
+
* swarmdo ownership --csv > ownership.csv
|
|
8
|
+
*
|
|
9
|
+
* "Who owns each file, and what breaks if they leave?" — the code-maat
|
|
10
|
+
* main-dev / bus-factor analysis. Files where one author owns nearly all the
|
|
11
|
+
* churn (bus factor 1) are key-person risks. Pairs with `hotspots` (change-risk)
|
|
12
|
+
* and `coupling` (co-change). Engine (../ownership/ownership.ts) is pure +
|
|
13
|
+
* tested; this captures the git log (the same `--numstat` dump those use).
|
|
14
|
+
*/
|
|
15
|
+
import type { Command } from '../types.js';
|
|
16
|
+
export declare const ownershipCommand: Command;
|
|
17
|
+
export default ownershipCommand;
|
|
18
|
+
//# sourceMappingURL=ownership.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swarmdo ownership` — per-file knowledge map + BUS FACTOR mined from git history.
|
|
3
|
+
*
|
|
4
|
+
* swarmdo ownership # knowledge map, most fragile files first
|
|
5
|
+
* swarmdo ownership src --since 90d # scope to a path + window
|
|
6
|
+
* swarmdo ownership --top 10 --format json
|
|
7
|
+
* swarmdo ownership --csv > ownership.csv
|
|
8
|
+
*
|
|
9
|
+
* "Who owns each file, and what breaks if they leave?" — the code-maat
|
|
10
|
+
* main-dev / bus-factor analysis. Files where one author owns nearly all the
|
|
11
|
+
* churn (bus factor 1) are key-person risks. Pairs with `hotspots` (change-risk)
|
|
12
|
+
* and `coupling` (co-change). Engine (../ownership/ownership.ts) is pure +
|
|
13
|
+
* tested; this captures the git log (the same `--numstat` dump those use).
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { output } from '../output.js';
|
|
17
|
+
import { parseGitLog } from '../hotspots/hotspots.js';
|
|
18
|
+
import { computeOwnership, repoBusFactor, formatOwnership, ownershipToCsv } from '../ownership/ownership.js';
|
|
19
|
+
import { normalizeSince } from '../util/since.js';
|
|
20
|
+
/** Read a numeric flag that the parser may deliver as a number OR a string. */
|
|
21
|
+
function numFlag(v, def) {
|
|
22
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' ? parseInt(v, 10) : NaN;
|
|
23
|
+
return Number.isFinite(n) ? n : def;
|
|
24
|
+
}
|
|
25
|
+
async function run(ctx) {
|
|
26
|
+
const root = ctx.cwd || process.cwd();
|
|
27
|
+
const pathArg = ctx.args[0];
|
|
28
|
+
const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : '1 year ago';
|
|
29
|
+
const top = numFlag(ctx.flags.top, 40);
|
|
30
|
+
const minChurn = numFlag(ctx.flags['min-churn'], 1);
|
|
31
|
+
const asJson = ctx.flags.format === 'json';
|
|
32
|
+
const asCsv = ctx.flags.csv === true;
|
|
33
|
+
// Same SOH-delimited numstat capture `hotspots` uses. `%aN` folds author
|
|
34
|
+
// name/email variants through `.mailmap` so one person is one owner. A
|
|
35
|
+
// positional pathspec scopes BOTH the file rows and the truck factor to that
|
|
36
|
+
// subtree (unlike `coupling`, per-file ownership survives a pathspec fine).
|
|
37
|
+
const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
|
|
38
|
+
if (pathArg)
|
|
39
|
+
args.push('--', pathArg);
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
output.printError('git log failed — is this a git repository?');
|
|
46
|
+
return { success: false, exitCode: 1 };
|
|
47
|
+
}
|
|
48
|
+
const commits = parseGitLog(raw);
|
|
49
|
+
const files = computeOwnership(commits, { minChurn, top: top > 0 ? top : undefined });
|
|
50
|
+
const repo = repoBusFactor(commits);
|
|
51
|
+
if (asCsv) {
|
|
52
|
+
process.stdout.write(ownershipToCsv(files) + '\n');
|
|
53
|
+
}
|
|
54
|
+
else if (asJson) {
|
|
55
|
+
process.stdout.write(JSON.stringify({ generated: new Date().toISOString(), since, minChurn, repoBusFactor: repo, count: files.length, ownership: files }, null, 2) + '\n');
|
|
56
|
+
}
|
|
57
|
+
else if (files.length === 0) {
|
|
58
|
+
output.writeln(output.dim('no ownership data found — no matching git history in the window'));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
output.writeln(output.bold(`File ownership & bus factor (since ${since})`));
|
|
62
|
+
if (repo.factor > 0) {
|
|
63
|
+
const who = repo.authors.length <= 3 ? repo.authors.join(', ') : `${repo.authors.slice(0, 3).join(', ')} +${repo.authors.length - 3}`;
|
|
64
|
+
const flag = repo.factor === 1 ? ' ⚠ single point of knowledge' : '';
|
|
65
|
+
output.writeln(output.dim(`Repo truck factor: ${repo.factor} (${who} own >50% of churn)${flag}`));
|
|
66
|
+
}
|
|
67
|
+
output.writeln(formatOwnership(files));
|
|
68
|
+
}
|
|
69
|
+
return { success: true, exitCode: 0 };
|
|
70
|
+
}
|
|
71
|
+
export const ownershipCommand = {
|
|
72
|
+
name: 'ownership',
|
|
73
|
+
description: 'Map per-file authorship concentration + bus factor from git history — find the key-person risks (code-maat main-dev / knowledge map)',
|
|
74
|
+
options: [
|
|
75
|
+
{ name: 'since', description: 'history window, e.g. 90d or "3 months ago" (default 1 year)', type: 'string' },
|
|
76
|
+
{ name: 'top', description: 'keep only the top N files (default 40; 0 = all)', type: 'string' },
|
|
77
|
+
{ name: 'min-churn', description: 'drop files with total churn below N (default 1)', type: 'string' },
|
|
78
|
+
{ name: 'csv', description: 'export the knowledge map as CSV (for spreadsheets)', type: 'boolean' },
|
|
79
|
+
],
|
|
80
|
+
examples: [
|
|
81
|
+
{ command: 'swarmdo ownership src --since 6mo', description: 'Knowledge map under src/ in the last 6 months' },
|
|
82
|
+
{ command: 'swarmdo ownership --top 10 --format json', description: 'Ten most fragile files as JSON, with repo truck factor' },
|
|
83
|
+
{ command: 'swarmdo ownership --csv > ownership.csv', description: 'Export the ownership map to CSV' },
|
|
84
|
+
],
|
|
85
|
+
action: run,
|
|
86
|
+
};
|
|
87
|
+
export default ownershipCommand;
|
|
88
|
+
//# sourceMappingURL=ownership.js.map
|
|
@@ -78,13 +78,13 @@ function walk(o) {
|
|
|
78
78
|
if (e.isDirectory()) {
|
|
79
79
|
if (SKIP_DIRS.has(e.name))
|
|
80
80
|
continue;
|
|
81
|
-
if (o.gitignore?.(rel
|
|
82
|
-
continue;
|
|
81
|
+
if (o.gitignore?.(rel, true))
|
|
82
|
+
continue; // isDir=true → dir-only patterns (build/) prune the dir + its contents
|
|
83
83
|
stack.push(full);
|
|
84
84
|
}
|
|
85
85
|
else if (e.isFile()) {
|
|
86
|
-
if (o.gitignore?.(rel))
|
|
87
|
-
continue;
|
|
86
|
+
if (o.gitignore?.(rel, false))
|
|
87
|
+
continue; // isDir=false → a dir-only pattern (build/) must NOT drop a file named `build`
|
|
88
88
|
if (o.exclude?.(rel))
|
|
89
89
|
continue;
|
|
90
90
|
if (o.include && !o.include(rel))
|
|
@@ -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;
|
|
@@ -759,6 +759,60 @@ const graphCommand = {
|
|
|
759
759
|
return { success: true };
|
|
760
760
|
}
|
|
761
761
|
};
|
|
762
|
+
// Doctor subcommand — classify blocked tasks live vs dead + a global deadlock
|
|
763
|
+
// verdict (task-deps.ts::taskDagHealth). Airflow's upstream_failed + DAG
|
|
764
|
+
// deadlock detection: surfaces a task that can NEVER run because a prerequisite
|
|
765
|
+
// failed/was cancelled/is missing, and a whole-graph stall a dispatch loop would
|
|
766
|
+
// otherwise spin on forever.
|
|
767
|
+
const doctorCommand = {
|
|
768
|
+
name: 'doctor',
|
|
769
|
+
aliases: ['health'],
|
|
770
|
+
description: 'Diagnose the task DAG: which blocked tasks can still run (live) vs are permanently stuck because a dependency failed/was cancelled/is missing (dead), and whether the whole graph is deadlocked',
|
|
771
|
+
options: [
|
|
772
|
+
{ name: 'json', description: 'Machine-readable output', type: 'boolean', default: false },
|
|
773
|
+
{ name: 'ci', description: 'Exit 1 if the DAG is deadlocked or any task is permanently (dead) blocked — gate a dispatch loop', type: 'boolean', default: false }
|
|
774
|
+
],
|
|
775
|
+
examples: [
|
|
776
|
+
{ command: 'swarmdo task doctor', description: 'Show live-blocked vs dead-blocked tasks and any deadlock' },
|
|
777
|
+
{ command: 'swarmdo task doctor --ci', description: 'Fail (exit 1) when the task graph is stuck' }
|
|
778
|
+
],
|
|
779
|
+
action: async (ctx) => {
|
|
780
|
+
const { loadTaskStore } = await import('../mcp-tools/task-tools.js');
|
|
781
|
+
const { taskDagHealth } = await import('../mcp-tools/task-deps.js');
|
|
782
|
+
const store = loadTaskStore(ctx.cwd);
|
|
783
|
+
const health = taskDagHealth(store);
|
|
784
|
+
const stuck = health.deadlocked || health.deadBlocked.length > 0;
|
|
785
|
+
const code = ctx.flags.ci === true && stuck ? 1 : 0;
|
|
786
|
+
if (ctx.flags.json === true) {
|
|
787
|
+
output.printJson({
|
|
788
|
+
deadlocked: health.deadlocked,
|
|
789
|
+
liveBlocked: health.liveBlocked.map(t => t.taskId),
|
|
790
|
+
deadBlocked: health.deadBlocked.map(d => ({ taskId: d.task.taskId, rootCause: d.rootCause })),
|
|
791
|
+
});
|
|
792
|
+
return { success: code === 0, exitCode: code, data: health };
|
|
793
|
+
}
|
|
794
|
+
output.writeln();
|
|
795
|
+
if (health.deadlocked) {
|
|
796
|
+
output.writeln(output.error('⚠ DEADLOCKED') + output.dim(' — pending work exists but nothing is ready and nothing is running'));
|
|
797
|
+
output.writeln();
|
|
798
|
+
}
|
|
799
|
+
output.writeln(output.bold(`Live-blocked (${health.liveBlocked.length})`) + output.dim(' — waiting on deps that can still complete'));
|
|
800
|
+
for (const t of health.liveBlocked) {
|
|
801
|
+
output.writeln(` ${output.dim('○')} ${t.taskId} ${t.description.slice(0, 60)}`);
|
|
802
|
+
}
|
|
803
|
+
output.writeln();
|
|
804
|
+
output.writeln(output.bold(`Dead-blocked (${health.deadBlocked.length})`) + output.dim(' — a prerequisite failed/was cancelled/is missing; will NEVER run without intervention'));
|
|
805
|
+
for (const d of health.deadBlocked) {
|
|
806
|
+
output.writeln(` ${output.error('✗')} ${d.task.taskId} ⇐ dead: ${d.rootCause.join(', ') || '(cycle)'}`);
|
|
807
|
+
}
|
|
808
|
+
if (!stuck) {
|
|
809
|
+
output.writeln();
|
|
810
|
+
output.printSuccess('task graph healthy — no permanent blocks or deadlock');
|
|
811
|
+
}
|
|
812
|
+
output.writeln();
|
|
813
|
+
return { success: code === 0, exitCode: code, data: health };
|
|
814
|
+
}
|
|
815
|
+
};
|
|
762
816
|
// parse-prd subcommand — decompose a PRD/spec doc into the task DAG (Task Master
|
|
763
817
|
// parity). The LLM decomposition lives in ../task/parse-prd.ts (injectable +
|
|
764
818
|
// unit-tested); this layer gates the billable call and creates the linked tasks.
|
|
@@ -890,7 +944,7 @@ const parsePrdCommand = {
|
|
|
890
944
|
export const taskCommand = {
|
|
891
945
|
name: 'task',
|
|
892
946
|
description: 'Task management commands',
|
|
893
|
-
subcommands: [createCommand, parsePrdCommand, listCommand, statusCommand, cancelCommand, assignCommand, retryCommand, dispatchCommand, readyCommand, graphCommand],
|
|
947
|
+
subcommands: [createCommand, parsePrdCommand, listCommand, statusCommand, cancelCommand, assignCommand, retryCommand, dispatchCommand, readyCommand, graphCommand, doctorCommand],
|
|
894
948
|
options: [],
|
|
895
949
|
examples: [
|
|
896
950
|
{ command: 'swarmdo task create -t implementation -d "Add user auth"', description: 'Create a task' },
|
|
@@ -917,7 +971,8 @@ export const taskCommand = {
|
|
|
917
971
|
`${output.highlight('assign')} - Assign task to agent(s)`,
|
|
918
972
|
`${output.highlight('retry')} - Retry a failed task`,
|
|
919
973
|
`${output.highlight('ready')} - List ready vs dependency-blocked tasks`,
|
|
920
|
-
`${output.highlight('graph')} - Show the dependency graph
|
|
974
|
+
`${output.highlight('graph')} - Show the dependency graph`,
|
|
975
|
+
`${output.highlight('doctor')} - Diagnose live vs dead blocks + deadlock (--ci gates)`
|
|
921
976
|
]);
|
|
922
977
|
output.writeln();
|
|
923
978
|
output.writeln('Run "swarmdo task <subcommand> --help" for subcommand help');
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents-lint.ts — static validation of Claude Code subagent definitions
|
|
3
|
+
* (`.claude/agents/*.md`).
|
|
4
|
+
*
|
|
5
|
+
* Each subagent file is YAML frontmatter (`name` + `description` required;
|
|
6
|
+
* `tools`, `model` optional) plus a system-prompt body. Claude Code SILENTLY
|
|
7
|
+
* ignores a subagent whose frontmatter is malformed, whose `name` collides with
|
|
8
|
+
* another file, or whose `model` is invalid — so "why isn't my subagent
|
|
9
|
+
* loading?" is an undebuggable footgun, and users mass-copy these files from
|
|
10
|
+
* large community collections where typos ride along. This catches those
|
|
11
|
+
* before CC does.
|
|
12
|
+
*
|
|
13
|
+
* Pure: parsed text in, findings out — same `Finding` shape + report machinery
|
|
14
|
+
* as the rest of config-lint; the command layer only reads files.
|
|
15
|
+
*/
|
|
16
|
+
import type { Finding } from './lint.js';
|
|
17
|
+
/** Models a Claude Code subagent `model:` field may name. */
|
|
18
|
+
export declare const AGENT_MODELS: string[];
|
|
19
|
+
/** Lint ONE subagent file's raw text. Pure. */
|
|
20
|
+
export declare function lintAgentFile(file: string, raw: string): Finding[];
|
|
21
|
+
export interface AgentFile {
|
|
22
|
+
file: string;
|
|
23
|
+
raw: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Lint a set of subagent files: every per-file rule PLUS cross-file duplicate
|
|
27
|
+
* `name` detection (Claude Code loads only one of a colliding pair). Pure.
|
|
28
|
+
*/
|
|
29
|
+
export declare function lintAgents(files: AgentFile[]): Finding[];
|
|
30
|
+
//# sourceMappingURL=agents-lint.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents-lint.ts — static validation of Claude Code subagent definitions
|
|
3
|
+
* (`.claude/agents/*.md`).
|
|
4
|
+
*
|
|
5
|
+
* Each subagent file is YAML frontmatter (`name` + `description` required;
|
|
6
|
+
* `tools`, `model` optional) plus a system-prompt body. Claude Code SILENTLY
|
|
7
|
+
* ignores a subagent whose frontmatter is malformed, whose `name` collides with
|
|
8
|
+
* another file, or whose `model` is invalid — so "why isn't my subagent
|
|
9
|
+
* loading?" is an undebuggable footgun, and users mass-copy these files from
|
|
10
|
+
* large community collections where typos ride along. This catches those
|
|
11
|
+
* before CC does.
|
|
12
|
+
*
|
|
13
|
+
* Pure: parsed text in, findings out — same `Finding` shape + report machinery
|
|
14
|
+
* as the rest of config-lint; the command layer only reads files.
|
|
15
|
+
*/
|
|
16
|
+
import { parse as parseYaml } from 'yaml';
|
|
17
|
+
const f = (file, severity, rule, message) => ({ file, severity, rule, message });
|
|
18
|
+
/** Models a Claude Code subagent `model:` field may name. */
|
|
19
|
+
export const AGENT_MODELS = ['sonnet', 'opus', 'haiku', 'inherit'];
|
|
20
|
+
// Leading (optional BOM) `---` fence, YAML body (non-greedy) up to a closing
|
|
21
|
+
// `---` on its own line, then the optional system-prompt body. CRLF-tolerant.
|
|
22
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
|
|
23
|
+
function parseAgent(raw) {
|
|
24
|
+
const m = raw.replace(/^\uFEFF/, "").match(FRONTMATTER_RE);
|
|
25
|
+
if (!m)
|
|
26
|
+
return { meta: null, body: '', malformed: 'no YAML frontmatter block (a subagent file must start with `---` … `---`)' };
|
|
27
|
+
const body = m[2] ?? '';
|
|
28
|
+
let fm;
|
|
29
|
+
try {
|
|
30
|
+
fm = parseYaml(m[1]);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
return { meta: null, body, malformed: `frontmatter is not valid YAML: ${e.message}` };
|
|
34
|
+
}
|
|
35
|
+
if (fm === null || typeof fm !== 'object' || Array.isArray(fm)) {
|
|
36
|
+
return { meta: null, body, malformed: 'frontmatter must be a YAML mapping of key: value' };
|
|
37
|
+
}
|
|
38
|
+
return { meta: fm, body };
|
|
39
|
+
}
|
|
40
|
+
function lintParsed(file, p) {
|
|
41
|
+
if (p.malformed)
|
|
42
|
+
return [f(file, 'error', 'agent-malformed-frontmatter', p.malformed)];
|
|
43
|
+
const out = [];
|
|
44
|
+
const meta = p.meta;
|
|
45
|
+
if (typeof meta.name !== 'string' || meta.name.trim() === '') {
|
|
46
|
+
out.push(f(file, 'error', 'agent-missing-name', 'frontmatter is missing a non-empty `name`'));
|
|
47
|
+
}
|
|
48
|
+
if (typeof meta.description !== 'string' || meta.description.trim() === '') {
|
|
49
|
+
out.push(f(file, 'error', 'agent-missing-description', 'frontmatter is missing a non-empty `description` (Claude Code routes to a subagent by its description)'));
|
|
50
|
+
}
|
|
51
|
+
if (meta.model !== undefined && !AGENT_MODELS.includes(String(meta.model))) {
|
|
52
|
+
out.push(f(file, 'error', 'agent-bad-model', `model "${String(meta.model)}" is not one of: ${AGENT_MODELS.join(', ')}`));
|
|
53
|
+
}
|
|
54
|
+
if (p.body.trim() === '') {
|
|
55
|
+
out.push(f(file, 'warn', 'agent-empty-body', 'no system prompt below the frontmatter — the subagent has no instructions'));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** Lint ONE subagent file's raw text. Pure. */
|
|
60
|
+
export function lintAgentFile(file, raw) {
|
|
61
|
+
return lintParsed(file, parseAgent(raw));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Lint a set of subagent files: every per-file rule PLUS cross-file duplicate
|
|
65
|
+
* `name` detection (Claude Code loads only one of a colliding pair). Pure.
|
|
66
|
+
*/
|
|
67
|
+
export function lintAgents(files) {
|
|
68
|
+
const out = [];
|
|
69
|
+
const nameToFiles = new Map();
|
|
70
|
+
for (const { file, raw } of files) {
|
|
71
|
+
const p = parseAgent(raw);
|
|
72
|
+
out.push(...lintParsed(file, p));
|
|
73
|
+
const name = p.meta && typeof p.meta.name === 'string' ? p.meta.name.trim() : '';
|
|
74
|
+
if (name) {
|
|
75
|
+
const arr = nameToFiles.get(name) ?? [];
|
|
76
|
+
arr.push(file);
|
|
77
|
+
nameToFiles.set(name, arr);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const [name, group] of nameToFiles) {
|
|
81
|
+
if (group.length > 1) {
|
|
82
|
+
out.push(f(group[0], 'error', 'agent-duplicate-name', `subagent name "${name}" is declared by ${group.length} files (${group.join(', ')}) — Claude Code loads only one; rename the rest`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=agents-lint.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands-lint.ts — static validation of Claude Code custom slash commands
|
|
3
|
+
* (the `.claude/commands` tree) and skills (`.claude/skills/<name>/SKILL.md`).
|
|
4
|
+
*
|
|
5
|
+
* Slash commands / skills are the most-copied Claude Code power-user artifact,
|
|
6
|
+
* and CC swallows their footguns silently: malformed YAML frontmatter makes CC
|
|
7
|
+
* load the body with EMPTY metadata (so `/cmd` "works" typed but Claude can
|
|
8
|
+
* never auto-match it), and two body rules bite in ways manual review misses —
|
|
9
|
+
* an inline `` !`cmd` `` that isn't at a line start / after whitespace is left as
|
|
10
|
+
* literal text and NEVER runs, and one whose command no `allowed-tools` Bash
|
|
11
|
+
* rule covers prompts/blocks instead of running pre-approved. This catches those
|
|
12
|
+
* before CC does. Sibling of agents-lint; same `Finding` shape.
|
|
13
|
+
*
|
|
14
|
+
* Pure: parsed text in, findings out. Reuses the permission-rule matcher
|
|
15
|
+
* (parseRule/covers) so allowed-tools coverage matches Claude Code's own glob
|
|
16
|
+
* semantics. Only truly-bounded fields are enum-checked (NOT `model`, which
|
|
17
|
+
* accepts aliases and full IDs) to stay false-positive-safe.
|
|
18
|
+
*/
|
|
19
|
+
import type { Finding } from './lint.js';
|
|
20
|
+
/** Reasoning-effort levels a command/skill `effort:` field may name. */
|
|
21
|
+
export declare const EFFORT_LEVELS: string[];
|
|
22
|
+
/** Lint ONE command/skill file's raw text. `kind` toggles the skill-only rule
|
|
23
|
+
* that frontmatter (with name + description) is REQUIRED. Pure. */
|
|
24
|
+
export declare function lintCommandFile(file: string, raw: string, kind?: 'command' | 'skill'): Finding[];
|
|
25
|
+
export interface CommandFile {
|
|
26
|
+
file: string;
|
|
27
|
+
raw: string;
|
|
28
|
+
}
|
|
29
|
+
/** Lint a set of command files and skill files. Pure. */
|
|
30
|
+
export declare function lintCommandFiles(commands: CommandFile[], skills?: CommandFile[]): Finding[];
|
|
31
|
+
//# sourceMappingURL=commands-lint.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands-lint.ts — static validation of Claude Code custom slash commands
|
|
3
|
+
* (the `.claude/commands` tree) and skills (`.claude/skills/<name>/SKILL.md`).
|
|
4
|
+
*
|
|
5
|
+
* Slash commands / skills are the most-copied Claude Code power-user artifact,
|
|
6
|
+
* and CC swallows their footguns silently: malformed YAML frontmatter makes CC
|
|
7
|
+
* load the body with EMPTY metadata (so `/cmd` "works" typed but Claude can
|
|
8
|
+
* never auto-match it), and two body rules bite in ways manual review misses —
|
|
9
|
+
* an inline `` !`cmd` `` that isn't at a line start / after whitespace is left as
|
|
10
|
+
* literal text and NEVER runs, and one whose command no `allowed-tools` Bash
|
|
11
|
+
* rule covers prompts/blocks instead of running pre-approved. This catches those
|
|
12
|
+
* before CC does. Sibling of agents-lint; same `Finding` shape.
|
|
13
|
+
*
|
|
14
|
+
* Pure: parsed text in, findings out. Reuses the permission-rule matcher
|
|
15
|
+
* (parseRule/covers) so allowed-tools coverage matches Claude Code's own glob
|
|
16
|
+
* semantics. Only truly-bounded fields are enum-checked (NOT `model`, which
|
|
17
|
+
* accepts aliases and full IDs) to stay false-positive-safe.
|
|
18
|
+
*/
|
|
19
|
+
import { parse as parseYaml } from 'yaml';
|
|
20
|
+
import { parseRule, covers } from '../permissions/audit.js';
|
|
21
|
+
const f = (file, severity, rule, message) => ({ file, severity, rule, message });
|
|
22
|
+
/** Reasoning-effort levels a command/skill `effort:` field may name. */
|
|
23
|
+
export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
24
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
|
|
25
|
+
function parseFile(raw) {
|
|
26
|
+
const text = raw.replace(/^\uFEFF/, '');
|
|
27
|
+
const m = text.match(FRONTMATTER_RE);
|
|
28
|
+
if (!m) {
|
|
29
|
+
// An opening `---` with no closing fence is malformed; anything else is a
|
|
30
|
+
// plain-markdown command (no frontmatter — perfectly valid).
|
|
31
|
+
if (/^---[ \t]*\r?\n/.test(text))
|
|
32
|
+
return { meta: null, body: text, malformed: 'has an opening `---` but no closing `---` fence' };
|
|
33
|
+
return { meta: null, body: text };
|
|
34
|
+
}
|
|
35
|
+
const body = m[2] ?? '';
|
|
36
|
+
let fm;
|
|
37
|
+
try {
|
|
38
|
+
fm = parseYaml(m[1]);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
return { meta: null, body, malformed: `frontmatter is not valid YAML: ${e.message}` };
|
|
42
|
+
}
|
|
43
|
+
if (fm === null || typeof fm !== 'object' || Array.isArray(fm)) {
|
|
44
|
+
return { meta: null, body, malformed: 'frontmatter must be a YAML mapping of key: value' };
|
|
45
|
+
}
|
|
46
|
+
return { meta: fm, body };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Inline bash-injection placeholders `` !`cmd` `` in the body. Claude Code only
|
|
50
|
+
* RUNS one whose `!` is at a line start or immediately after whitespace; if `!`
|
|
51
|
+
* follows another character (e.g. `` KEY=!`cmd` ``) it is left as literal text.
|
|
52
|
+
*/
|
|
53
|
+
function bashInjections(body) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const re = /!`([^`\n]+)`/g;
|
|
56
|
+
let m;
|
|
57
|
+
while ((m = re.exec(body)) !== null) {
|
|
58
|
+
const at = m.index;
|
|
59
|
+
const active = at === 0 || /\s/.test(body[at - 1]);
|
|
60
|
+
out.push({ cmd: m[1].trim(), active });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Parse the `allowed-tools` frontmatter (string list or YAML array) into rules;
|
|
65
|
+
* null = the key is absent (no pre-approval declared, so coverage isn't checked). */
|
|
66
|
+
function allowedToolRules(meta) {
|
|
67
|
+
const at = meta['allowed-tools'] ?? meta['allowedTools'];
|
|
68
|
+
if (at === undefined)
|
|
69
|
+
return null;
|
|
70
|
+
const parts = Array.isArray(at) ? at.map(String) : typeof at === 'string' ? at.split(',') : [];
|
|
71
|
+
const rules = [];
|
|
72
|
+
for (const p of parts) {
|
|
73
|
+
const r = parseRule(p.trim());
|
|
74
|
+
if (r.tool !== undefined)
|
|
75
|
+
rules.push(r);
|
|
76
|
+
}
|
|
77
|
+
return rules;
|
|
78
|
+
}
|
|
79
|
+
/** Lint ONE command/skill file's raw text. `kind` toggles the skill-only rule
|
|
80
|
+
* that frontmatter (with name + description) is REQUIRED. Pure. */
|
|
81
|
+
export function lintCommandFile(file, raw, kind = 'command') {
|
|
82
|
+
const p = parseFile(raw);
|
|
83
|
+
const out = [];
|
|
84
|
+
if (p.malformed) {
|
|
85
|
+
out.push(f(file, 'error', 'command-malformed-frontmatter', p.malformed));
|
|
86
|
+
}
|
|
87
|
+
else if (kind === 'skill' && !p.meta) {
|
|
88
|
+
out.push(f(file, 'error', 'skill-missing-frontmatter', 'a SKILL.md must start with a `---` … `---` frontmatter block declaring `name` and `description`'));
|
|
89
|
+
}
|
|
90
|
+
const meta = p.meta;
|
|
91
|
+
if (meta) {
|
|
92
|
+
if (kind === 'skill' && (typeof meta.name !== 'string' || meta.name.trim() === '')) {
|
|
93
|
+
out.push(f(file, 'error', 'skill-missing-name', 'frontmatter is missing a non-empty `name`'));
|
|
94
|
+
}
|
|
95
|
+
if (typeof meta.description !== 'string' || meta.description.trim() === '') {
|
|
96
|
+
const how = kind === 'skill' ? 'decide when to invoke the skill' : 'list the command and model-invoke it';
|
|
97
|
+
out.push(f(file, kind === 'skill' ? 'error' : 'warn', 'command-missing-description', `frontmatter has no non-empty \`description\` (Claude Code uses it to ${how})`));
|
|
98
|
+
}
|
|
99
|
+
if (meta.effort !== undefined && !EFFORT_LEVELS.includes(String(meta.effort))) {
|
|
100
|
+
out.push(f(file, 'error', 'command-bad-effort', `effort "${String(meta.effort)}" is not one of: ${EFFORT_LEVELS.join(', ')}`));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Body cross-checks (both kinds). Coverage is only checked when allowed-tools
|
|
104
|
+
// is declared — otherwise the author never opted into pre-approval.
|
|
105
|
+
const rules = meta ? allowedToolRules(meta) : null;
|
|
106
|
+
for (const inj of bashInjections(p.body)) {
|
|
107
|
+
const tok = '!`' + inj.cmd + '`';
|
|
108
|
+
if (!inj.active) {
|
|
109
|
+
out.push(f(file, 'warn', 'command-inert-bash-injection', `inline bash ${tok} follows a non-whitespace character — Claude Code leaves it as literal text and never runs it (put \`!\` at a line start or after whitespace)`));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (rules && rules.length > 0 && !rules.some((r) => covers(r, { tool: 'Bash', specifier: inj.cmd }))) {
|
|
113
|
+
out.push(f(file, 'warn', 'command-uncovered-bash-injection', `inline bash ${tok} is not covered by any \`allowed-tools\` Bash rule — it will prompt/block instead of running pre-approved`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Lint a set of command files and skill files. Pure. */
|
|
119
|
+
export function lintCommandFiles(commands, skills = []) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const c of commands)
|
|
122
|
+
out.push(...lintCommandFile(c.file, c.raw, 'command'));
|
|
123
|
+
for (const s of skills)
|
|
124
|
+
out.push(...lintCommandFile(s.file, s.raw, 'skill'));
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=commands-lint.js.map
|
|
@@ -17,6 +17,8 @@ export interface Finding {
|
|
|
17
17
|
}
|
|
18
18
|
export declare const TOPOLOGIES: string[];
|
|
19
19
|
export declare const MEMORY_BACKENDS: string[];
|
|
20
|
+
import { type AgentFile } from './agents-lint.js';
|
|
21
|
+
import { type CommandFile } from './commands-lint.js';
|
|
20
22
|
export declare const KNOWN_CONFIG_KEYS: string[];
|
|
21
23
|
export declare const HOOK_EVENTS: string[];
|
|
22
24
|
/** Parse a JSON file's raw text; a null raw means "file absent" (fine). */
|
|
@@ -49,6 +51,12 @@ export interface LintInput {
|
|
|
49
51
|
/** entries under `.claude/commands/sDo/` — used to identify flat pre-1.4 twins */
|
|
50
52
|
sdoCommands?: string[];
|
|
51
53
|
skills?: string[];
|
|
54
|
+
/** `.claude/agents/*.md` subagent definitions (raw text per file) */
|
|
55
|
+
agentFiles?: AgentFile[];
|
|
56
|
+
/** custom slash commands under the `.claude/commands` tree (raw text per file) */
|
|
57
|
+
commandFiles?: CommandFile[];
|
|
58
|
+
/** skill definitions at `.claude/skills/<name>/SKILL.md` (raw text per file) */
|
|
59
|
+
skillFiles?: CommandFile[];
|
|
52
60
|
}
|
|
53
61
|
export interface LintReport {
|
|
54
62
|
findings: Finding[];
|
|
@@ -12,6 +12,8 @@ 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';
|
|
16
|
+
import { lintCommandFiles } from './commands-lint.js';
|
|
15
17
|
export const KNOWN_CONFIG_KEYS = ['topology', 'maxAgents', 'strategy', 'consensus', 'memory', 'memoryBackend', 'hnsw', 'neural', 'embeddings', 'providers', 'mcp', 'logging', 'daemon', 'hooks', 'version', 'openrouter', '$schema'];
|
|
16
18
|
// Current Claude Code hook events (source: code.claude.com/docs/en/hooks).
|
|
17
19
|
// Kept in sync with the runtime; a stale list here false-warns on valid hooks.
|
|
@@ -194,6 +196,11 @@ export function lintAll(input) {
|
|
|
194
196
|
findings.push(...jf, ...lintMcpConfig(input.mcpConfig.file, obj));
|
|
195
197
|
}
|
|
196
198
|
findings.push(...lintLegacyLayout(input.commandsRoot ?? [], input.skills ?? [], input.sdoCommands ?? []));
|
|
199
|
+
if (input.agentFiles?.length)
|
|
200
|
+
findings.push(...lintAgents(input.agentFiles));
|
|
201
|
+
if (input.commandFiles?.length || input.skillFiles?.length) {
|
|
202
|
+
findings.push(...lintCommandFiles(input.commandFiles ?? [], input.skillFiles ?? []));
|
|
203
|
+
}
|
|
197
204
|
return {
|
|
198
205
|
findings,
|
|
199
206
|
errors: findings.filter((x) => x.severity === 'error').length,
|