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.
Files changed (57) hide show
  1. package/README.md +9 -5
  2. package/package.json +1 -1
  3. package/v3/@swarmdo/cli/dist/src/apply/apply.js +6 -1
  4. package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
  5. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +43 -0
  6. package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
  7. package/v3/@swarmdo/cli/dist/src/commands/config.js +37 -1
  8. package/v3/@swarmdo/cli/dist/src/commands/coupling.d.ts +17 -0
  9. package/v3/@swarmdo/cli/dist/src/commands/coupling.js +85 -0
  10. package/v3/@swarmdo/cli/dist/src/commands/hidden-coupling.d.ts +19 -0
  11. package/v3/@swarmdo/cli/dist/src/commands/hidden-coupling.js +92 -0
  12. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +55 -0
  13. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +2 -1
  14. package/v3/@swarmdo/cli/dist/src/commands/index.js +12 -3
  15. package/v3/@swarmdo/cli/dist/src/commands/ownership.d.ts +18 -0
  16. package/v3/@swarmdo/cli/dist/src/commands/ownership.js +88 -0
  17. package/v3/@swarmdo/cli/dist/src/commands/pack.js +4 -4
  18. package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
  19. package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
  20. package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
  21. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
  22. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
  23. package/v3/@swarmdo/cli/dist/src/config-lint/commands-lint.d.ts +31 -0
  24. package/v3/@swarmdo/cli/dist/src/config-lint/commands-lint.js +127 -0
  25. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +8 -0
  26. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +7 -0
  27. package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
  28. package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
  29. package/v3/@swarmdo/cli/dist/src/coupling/hidden.d.ts +45 -0
  30. package/v3/@swarmdo/cli/dist/src/coupling/hidden.js +66 -0
  31. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
  32. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
  33. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +8 -0
  34. package/v3/@swarmdo/cli/dist/src/mcp-client.js +4 -0
  35. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
  36. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -0
  37. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +2 -0
  38. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +2 -0
  39. package/v3/@swarmdo/cli/dist/src/mcp-tools/ownership-tools.d.ts +14 -0
  40. package/v3/@swarmdo/cli/dist/src/mcp-tools/ownership-tools.js +51 -0
  41. package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +2 -0
  42. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
  43. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
  44. package/v3/@swarmdo/cli/dist/src/ownership/ownership.d.ts +61 -0
  45. package/v3/@swarmdo/cli/dist/src/ownership/ownership.js +151 -0
  46. package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +1 -1
  47. package/v3/@swarmdo/cli/dist/src/pack/pack.js +12 -4
  48. package/v3/@swarmdo/cli/dist/src/redact/redact.js +47 -16
  49. package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
  50. package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
  51. package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
  52. package/v3/@swarmdo/cli/dist/src/release/release.js +28 -0
  53. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +9 -1
  54. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +14 -4
  55. package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
  56. package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
  57. 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
- 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;
@@ -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,