swarmdo 1.30.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +11 -6
  2. package/package.json +1 -1
  3. package/v3/@swarmdo/cli/dist/src/affected/affected.d.ts +6 -1
  4. package/v3/@swarmdo/cli/dist/src/affected/affected.js +7 -2
  5. package/v3/@swarmdo/cli/dist/src/apply/apply.js +24 -8
  6. package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
  7. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +47 -1
  8. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
  9. package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
  10. package/v3/@swarmdo/cli/dist/src/commands/comms.d.ts +16 -0
  11. package/v3/@swarmdo/cli/dist/src/commands/comms.js +280 -0
  12. package/v3/@swarmdo/cli/dist/src/commands/compact.js +6 -2
  13. package/v3/@swarmdo/cli/dist/src/commands/config.js +7 -1
  14. package/v3/@swarmdo/cli/dist/src/commands/coupling.d.ts +17 -0
  15. package/v3/@swarmdo/cli/dist/src/commands/coupling.js +85 -0
  16. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +176 -1
  17. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +10 -3
  18. package/v3/@swarmdo/cli/dist/src/commands/index.js +12 -3
  19. package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
  20. package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
  21. package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
  22. package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
  23. package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
  24. package/v3/@swarmdo/cli/dist/src/commands/usage.js +34 -1
  25. package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
  26. package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
  27. package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
  28. package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
  29. package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
  30. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
  31. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
  32. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
  33. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +6 -1
  34. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +14 -4
  35. package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
  36. package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
  37. package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
  38. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
  39. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
  40. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +28 -1
  41. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
  42. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
  43. package/v3/@swarmdo/cli/dist/src/index.js +8 -0
  44. package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
  45. package/v3/@swarmdo/cli/dist/src/mcp-client.js +4 -0
  46. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
  47. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
  48. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
  49. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -0
  50. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +2 -0
  51. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +2 -0
  52. package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
  53. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
  54. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
  55. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
  56. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
  57. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
  58. package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
  59. package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
  60. package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
  61. package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
  62. package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
  63. package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
  64. package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
  65. package/v3/@swarmdo/cli/dist/src/redact/redact.js +22 -5
  66. package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
  67. package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
  68. package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
  69. package/v3/@swarmdo/cli/dist/src/release/release.js +29 -1
  70. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
  71. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
  72. package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
  73. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
  74. package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
  75. package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
  76. package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
  77. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
  78. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
  79. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +1 -0
  80. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +35 -1
  81. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +58 -4
  82. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
  83. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
  84. package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
  85. package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
  86. package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
  87. package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
  88. package/v3/@swarmdo/cli/package.json +1 -1
@@ -193,9 +193,13 @@ export function makeIgnoreMatcher(patterns) {
193
193
  const dirOnly = body.endsWith('/');
194
194
  if (dirOnly)
195
195
  body = body.slice(0, -1);
196
- const anchored = body.startsWith('/');
197
- if (anchored)
196
+ const leadingSlash = body.startsWith('/');
197
+ if (leadingSlash)
198
198
  body = body.slice(1);
199
+ // gitignore(5): a slash ANYWHERE (not just leading) anchors the pattern to
200
+ // the root — `src/fixtures` matches `src/fixtures` but NOT `a/src/fixtures`.
201
+ // Only a slash-free pattern (`dir`, `*.ext`) matches at any depth.
202
+ const anchored = leadingSlash || body.includes('/');
199
203
  const re = new RegExp('^' + globToRegExp(body) + (dirOnly ? '(/|$)' : '($|/)'));
200
204
  return { negate, anchored, re };
201
205
  });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * permissions/audit.ts — static analyzer for Claude Code permission rules (#80).
3
+ *
4
+ * Claude Code reads `permissions.allow` / `deny` / `ask` arrays from
5
+ * `.claude/settings.json` (+ `settings.local.json` + `~/.claude/settings.json`),
6
+ * each entry a `Tool` or `Tool(specifier)` rule, with precedence deny > ask >
7
+ * allow. Users hand-edit these and get them wrong: a rule in both allow and deny
8
+ * (silently dead), a specific allow already covered by a broader one, or an
9
+ * over-broad `Bash(*)` that re-grants what deny was fencing off.
10
+ *
11
+ * This is the PURE rule layer — parse + set logic, no fs/network — so the whole
12
+ * analysis is unit-testable; the command reads the settings files.
13
+ */
14
+ export type PermSeverity = 'error' | 'warn' | 'info';
15
+ export interface PermFinding {
16
+ severity: PermSeverity;
17
+ /** kebab-case finding type */
18
+ rule: string;
19
+ message: string;
20
+ /** the rule string(s) the finding is about */
21
+ subjects: string[];
22
+ }
23
+ export interface ParsedRule {
24
+ tool: string;
25
+ /** undefined = a bare tool rule (grants the whole tool) */
26
+ specifier?: string;
27
+ }
28
+ export type ParseResult = ParsedRule | {
29
+ error: string;
30
+ };
31
+ export interface PermissionSets {
32
+ allow?: string[];
33
+ deny?: string[];
34
+ ask?: string[];
35
+ }
36
+ /** Parse one `Tool` or `Tool(specifier)` rule. */
37
+ export declare function parseRule(raw: string): ParseResult;
38
+ /**
39
+ * Does `broad` grant everything `specific` grants (same tool)? A bare tool or a
40
+ * `*` specifier covers anything on that tool; otherwise the broad specifier is
41
+ * glob-matched against the specific one (exact equality included).
42
+ */
43
+ export declare function covers(broad: ParsedRule, specific: ParsedRule): boolean;
44
+ /**
45
+ * Analyze a merged permission ruleset. Reports: allow↔deny conflicts (dead allow
46
+ * rules), allow rules shadowed by a broader allow, over-broad whole-tool grants,
47
+ * exact duplicates, and malformed rules. Order: errors, then warns, then infos.
48
+ */
49
+ export declare function auditPermissions(perms: PermissionSets): PermFinding[];
50
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1,127 @@
1
+ /**
2
+ * permissions/audit.ts — static analyzer for Claude Code permission rules (#80).
3
+ *
4
+ * Claude Code reads `permissions.allow` / `deny` / `ask` arrays from
5
+ * `.claude/settings.json` (+ `settings.local.json` + `~/.claude/settings.json`),
6
+ * each entry a `Tool` or `Tool(specifier)` rule, with precedence deny > ask >
7
+ * allow. Users hand-edit these and get them wrong: a rule in both allow and deny
8
+ * (silently dead), a specific allow already covered by a broader one, or an
9
+ * over-broad `Bash(*)` that re-grants what deny was fencing off.
10
+ *
11
+ * This is the PURE rule layer — parse + set logic, no fs/network — so the whole
12
+ * analysis is unit-testable; the command reads the settings files.
13
+ */
14
+ /** Parse one `Tool` or `Tool(specifier)` rule. */
15
+ export function parseRule(raw) {
16
+ const s = (raw ?? '').trim();
17
+ if (!s)
18
+ return { error: 'empty rule' };
19
+ const open = s.indexOf('(');
20
+ if (open === -1) {
21
+ // Bare tool, e.g. `Bash` or `WebFetch`.
22
+ if (!/^[A-Za-z_][\w-]*$/.test(s))
23
+ return { error: 'invalid tool name' };
24
+ return { tool: s };
25
+ }
26
+ if (!s.endsWith(')'))
27
+ return { error: 'unbalanced parentheses' };
28
+ const tool = s.slice(0, open);
29
+ if (!tool)
30
+ return { error: 'missing tool name' };
31
+ if (!/^[A-Za-z_][\w-]*$/.test(tool))
32
+ return { error: 'invalid tool name' };
33
+ const specifier = s.slice(open + 1, s.length - 1);
34
+ if (specifier.trim() === '')
35
+ return { error: 'empty specifier' };
36
+ return { tool, specifier };
37
+ }
38
+ function isParsed(r) {
39
+ return r.tool !== undefined;
40
+ }
41
+ function escapeRegex(s) {
42
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
+ }
44
+ /** Turn a specifier glob (`*` = any run) into an anchored matcher. */
45
+ function specifierMatches(broadSpec, target) {
46
+ const re = new RegExp('^' + broadSpec.split('*').map(escapeRegex).join('.*') + '$');
47
+ return re.test(target);
48
+ }
49
+ /**
50
+ * Does `broad` grant everything `specific` grants (same tool)? A bare tool or a
51
+ * `*` specifier covers anything on that tool; otherwise the broad specifier is
52
+ * glob-matched against the specific one (exact equality included).
53
+ */
54
+ export function covers(broad, specific) {
55
+ if (broad.tool !== specific.tool)
56
+ return false;
57
+ if (broad.specifier === undefined)
58
+ return true; // bare tool grants all
59
+ if (broad.specifier === '*')
60
+ return true;
61
+ if (specific.specifier === undefined)
62
+ return false; // broad is scoped, specific is the whole tool
63
+ return specifierMatches(broad.specifier, specific.specifier);
64
+ }
65
+ /** True for a rule that grants an entire tool (`Bash` or `Bash(*)`). */
66
+ function isWholeTool(r) {
67
+ return r.specifier === undefined || r.specifier === '*';
68
+ }
69
+ /**
70
+ * Analyze a merged permission ruleset. Reports: allow↔deny conflicts (dead allow
71
+ * rules), allow rules shadowed by a broader allow, over-broad whole-tool grants,
72
+ * exact duplicates, and malformed rules. Order: errors, then warns, then infos.
73
+ */
74
+ export function auditPermissions(perms) {
75
+ const findings = [];
76
+ const parsed = { allow: [], deny: [], ask: [] };
77
+ for (const list of ['allow', 'deny', 'ask']) {
78
+ for (const raw of perms[list] ?? []) {
79
+ const r = parseRule(raw);
80
+ if (!isParsed(r)) {
81
+ findings.push({ severity: 'error', rule: 'malformed-rule', message: `${list} rule "${raw}" is malformed: ${r.error}`, subjects: [raw] });
82
+ }
83
+ else {
84
+ parsed[list].push({ raw, parsed: r });
85
+ }
86
+ }
87
+ }
88
+ // conflict: an allow rule that some deny rule covers is dead (deny wins).
89
+ for (const a of parsed.allow) {
90
+ const killer = parsed.deny.find((d) => covers(d.parsed, a.parsed));
91
+ if (killer) {
92
+ findings.push({ severity: 'error', rule: 'conflict', message: `allow "${a.raw}" is dead — deny "${killer.raw}" overrides it (deny wins)`, subjects: [a.raw, killer.raw] });
93
+ }
94
+ }
95
+ // over-broad: an allow rule granting a whole tool defeats scoped denies.
96
+ for (const a of parsed.allow) {
97
+ if (isWholeTool(a.parsed)) {
98
+ findings.push({ severity: 'warn', rule: 'over-broad', message: `allow "${a.raw}" grants every ${a.parsed.tool} call — narrow it or a scoped deny can't fence it off`, subjects: [a.raw] });
99
+ }
100
+ }
101
+ // exact duplicates within a list.
102
+ for (const list of ['allow', 'deny', 'ask']) {
103
+ const seen = new Set();
104
+ const dupped = new Set();
105
+ for (const e of parsed[list]) {
106
+ if (seen.has(e.raw))
107
+ dupped.add(e.raw);
108
+ else
109
+ seen.add(e.raw);
110
+ }
111
+ for (const raw of dupped) {
112
+ findings.push({ severity: 'info', rule: 'duplicate', message: `${list} rule "${raw}" is listed more than once`, subjects: [raw] });
113
+ }
114
+ }
115
+ // shadowed-allow: a scoped allow rule already covered by a broader, different allow.
116
+ for (const a of parsed.allow) {
117
+ if (isWholeTool(a.parsed))
118
+ continue; // whole-tool grants are flagged as over-broad, not shadowed
119
+ const broader = parsed.allow.find((b) => b.raw !== a.raw && covers(b.parsed, a.parsed));
120
+ if (broader) {
121
+ findings.push({ severity: 'info', rule: 'shadowed-allow', message: `allow "${a.raw}" is redundant — already covered by "${broader.raw}"`, subjects: [a.raw, broader.raw] });
122
+ }
123
+ }
124
+ const order = { error: 0, warn: 1, info: 2 };
125
+ return findings.sort((x, y) => order[x.severity] - order[y.severity]);
126
+ }
127
+ //# sourceMappingURL=audit.js.map
@@ -60,7 +60,7 @@ export function searchPlugins(registry, options = {}) {
60
60
  }
61
61
  // Security audit filter
62
62
  if (options.hasSecurityAudit !== undefined) {
63
- plugins = plugins.filter(p => options.hasSecurityAudit ? p.securityAudit !== undefined : true);
63
+ plugins = plugins.filter(p => options.hasSecurityAudit ? p.securityAudit !== undefined : p.securityAudit === undefined);
64
64
  }
65
65
  // Sort
66
66
  const sortBy = options.sortBy || 'downloads';
@@ -44,20 +44,37 @@ export const RULES = [
44
44
  * Keyword set mirrors gitleaks' reference `generic-api-key` rule (adds bare
45
45
  * `key`, `credential`, `creds` so `PRIVATE_KEY=`/`ENCRYPTION_KEY=`/`DB_CREDENTIAL=`
46
46
  * secrets reach the entropy check). The `[:=]` immediately after the keyword
47
- * anchors it, so `keyboard=`/`monkey_val=` don't match; the entropy + length
48
- * gates keep low-entropy values (paths, short flags) from being flagged.
47
+ * anchors it, so `keyboard=`/`monkey_val=` don't match. A `(?<![A-Za-z])` guard
48
+ * requires the keyword NOT be glued to a preceding letter, so a common word that
49
+ * merely ENDS in a keyword — `monkey=`/`donkey=`/`whiskey=`/`turkey=` (end in
50
+ * `key`), `compass=`/`bypass=` (end in `pass`) — is no longer flagged as a
51
+ * secret, while a `_`/`-`-separated name (`PRIVATE_KEY`, `x-api-key`) still
52
+ * matches (`_`/`-` aren't letters). The entropy + length gates keep low-entropy
53
+ * values (paths, short flags) out. The value class also stops at the URL/query
54
+ * delimiters `&`, `?`, `#` (absent from base64/base64url/hex token alphabets):
55
+ * otherwise a query-string secret like `client_secret=…&api_key=…` over-captures
56
+ * past the `&` into the next secret, and that inflated span — overlapping a range
57
+ * the high-confidence RULES pass already claimed — gets dropped entirely, leaving
58
+ * the first secret unredacted.
49
59
  */
50
- const ASSIGNMENT_RE = /(?:pass(?:word|phrase|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
60
+ const ASSIGNMENT_RE = /(?<![A-Za-z])(?:pass(?:word|phrase|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;&?#]{8,})/gi;
51
61
  /** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
52
62
  export function shannonEntropy(s) {
53
63
  if (!s)
54
64
  return 0;
65
+ // Iterate AND normalize by code points, not UTF-16 code units. `for..of` /
66
+ // spread yield one symbol per code point, but `s.length` counts an astral
67
+ // char (emoji, CJK Ext-B, math alphanumerics) as 2 units — so dividing the
68
+ // per-symbol counts by s.length made the probabilities sum to <1 and
69
+ // under-counted entropy up to ~2×, letting a high-entropy secret with astral
70
+ // chars fall under the threshold and pass through UN-redacted (#95).
71
+ const cps = [...s];
55
72
  const freq = new Map();
56
- for (const ch of s)
73
+ for (const ch of cps)
57
74
  freq.set(ch, (freq.get(ch) ?? 0) + 1);
58
75
  let bits = 0;
59
76
  for (const n of freq.values()) {
60
- const p = n / s.length;
77
+ const p = n / cps.length;
61
78
  bits -= p * Math.log2(p);
62
79
  }
63
80
  return bits;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * sarif.ts — render redact secret-scan findings as a SARIF 2.1.0 document.
3
+ *
4
+ * SARIF (Static Analysis Results Interchange Format) is a ratified OASIS
5
+ * standard that GitHub code-scanning ingests via `github/codeql-action/
6
+ * upload-sarif`. Emit it from `redact --scan` in CI and leaked-secret findings
7
+ * surface as alerts in the Security tab + PR annotations instead of dying in
8
+ * the build log — the same thing gitleaks/trufflehog/ggshield do with their
9
+ * `--report-format sarif`.
10
+ *
11
+ * Pure + deterministic: findings in, JSON string out. No fs/network.
12
+ */
13
+ import type { Finding } from './redact.js';
14
+ export interface SarifOptions {
15
+ /** SARIF tool.driver.name (default 'swarmdo-redact') */
16
+ toolName?: string;
17
+ /** SARIF tool.driver.version — omitted from the document when not given */
18
+ toolVersion?: string;
19
+ /**
20
+ * URI for the artifact each finding lives in. `redact --scan` reads a STREAM
21
+ * (stdin / wrapped output), so there is no committed file by default; pass
22
+ * this (e.g. from `--source <path>`) to anchor the alerts onto a repo path.
23
+ */
24
+ artifactUri?: string;
25
+ }
26
+ /** Render `findings` as a SARIF 2.1.0 document string. Pure + deterministic. */
27
+ export declare function toSarif(findings: Finding[], opts?: SarifOptions): string;
28
+ //# sourceMappingURL=sarif.d.ts.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * sarif.ts — render redact secret-scan findings as a SARIF 2.1.0 document.
3
+ *
4
+ * SARIF (Static Analysis Results Interchange Format) is a ratified OASIS
5
+ * standard that GitHub code-scanning ingests via `github/codeql-action/
6
+ * upload-sarif`. Emit it from `redact --scan` in CI and leaked-secret findings
7
+ * surface as alerts in the Security tab + PR annotations instead of dying in
8
+ * the build log — the same thing gitleaks/trufflehog/ggshield do with their
9
+ * `--report-format sarif`.
10
+ *
11
+ * Pure + deterministic: findings in, JSON string out. No fs/network.
12
+ */
13
+ const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
14
+ const INFO_URI = 'the upstream project (see NOTICE)';
15
+ /** Render `findings` as a SARIF 2.1.0 document string. Pure + deterministic. */
16
+ export function toSarif(findings, opts = {}) {
17
+ const toolName = opts.toolName ?? 'swarmdo-redact';
18
+ // One rule descriptor per distinct ruleId, in first-seen order. Findings
19
+ // carry their own description, so the entropy fallback (not in RULES) is
20
+ // covered too — every result.ruleId is guaranteed to appear in rules[].
21
+ const ruleIndex = new Map();
22
+ const rules = [];
23
+ for (const f of findings) {
24
+ if (ruleIndex.has(f.ruleId))
25
+ continue;
26
+ ruleIndex.set(f.ruleId, rules.length);
27
+ rules.push({ id: f.ruleId, name: f.ruleId, shortDescription: { text: f.description } });
28
+ }
29
+ const results = findings.map((f) => {
30
+ const physicalLocation = {
31
+ region: { startLine: f.line, startColumn: f.column },
32
+ };
33
+ if (opts.artifactUri)
34
+ physicalLocation.artifactLocation = { uri: opts.artifactUri };
35
+ return {
36
+ ruleId: f.ruleId,
37
+ ruleIndex: ruleIndex.get(f.ruleId),
38
+ level: 'error',
39
+ message: { text: `Possible secret: ${f.description}` },
40
+ locations: [{ physicalLocation }],
41
+ };
42
+ });
43
+ const driver = { name: toolName, informationUri: INFO_URI, rules };
44
+ if (opts.toolVersion)
45
+ driver.version = opts.toolVersion;
46
+ const doc = {
47
+ $schema: SARIF_SCHEMA,
48
+ version: '2.1.0',
49
+ runs: [{ tool: { driver }, results }],
50
+ };
51
+ return JSON.stringify(doc, null, 2);
52
+ }
53
+ //# sourceMappingURL=sarif.js.map
@@ -69,4 +69,23 @@ export declare const DOC_FILES: string[];
69
69
  export declare function planRelease(input: ReleaseInput): ReleasePlan;
70
70
  /** One-line human rendering of a step (for the dry-run plan). */
71
71
  export declare function renderStep(s: ReleaseStep): string;
72
+ export interface SiteIdentity {
73
+ name: string;
74
+ email: string;
75
+ }
76
+ /**
77
+ * Resolve the committer identity for the throwaway swarmdo.com clone. That
78
+ * clone inherits no identity, and a machine may have only a *local* (per-repo)
79
+ * git user — so `git commit` there dies with "Author identity unknown" unless
80
+ * we inject one. Prefer the operator's configured identity; fall back to the
81
+ * Swarmdo bot so the site deploy never fails on a missing global git config.
82
+ * `readConfig` is injected (real: `git -C <root> config <key>`) so this stays
83
+ * pure and unit-testable.
84
+ */
85
+ export declare function resolveSiteIdentity(readConfig: (key: string) => string | undefined): SiteIdentity;
86
+ /**
87
+ * `git commit` argv for the site sync — injects the identity via `-c` so the
88
+ * commit succeeds without a global git user, and keeps the bot co-author trailer.
89
+ */
90
+ export declare function siteCommitArgs(version: string, id: SiteIdentity): string[];
72
91
  //# sourceMappingURL=release.d.ts.map
@@ -49,7 +49,7 @@ export function planRelease(input) {
49
49
  for (const f of TRIO_FILES)
50
50
  steps.push({ kind: 'bump-json', file: f, version: next });
51
51
  steps.push({ kind: 'bump-lock', file: LOCK_FILE, version: next });
52
- steps.push({ kind: 'sync-docs', version: next, files: DOC_FILES });
52
+ steps.push({ kind: 'sync-docs', version: next, files: [...DOC_FILES] }); // copy — a pure plan must not alias the module constant
53
53
  steps.push({ kind: 'exec', title: 'commit release', cmd: 'git', args: ['commit', '-am', `release: ${tag}`], cwd: root });
54
54
  steps.push({ kind: 'exec', title: 'push main', cmd: 'git', args: ['push', 'origin', 'main'], cwd: root });
55
55
  if (!input.skipPublish) {
@@ -83,4 +83,32 @@ export function renderStep(s) {
83
83
  case 'deploy-site': return `deploy website → SwarmDo/swarmdo.com (copy working copy, push, curl-verify swarmdo.com serves ${s.version})`;
84
84
  }
85
85
  }
86
+ /**
87
+ * Resolve the committer identity for the throwaway swarmdo.com clone. That
88
+ * clone inherits no identity, and a machine may have only a *local* (per-repo)
89
+ * git user — so `git commit` there dies with "Author identity unknown" unless
90
+ * we inject one. Prefer the operator's configured identity; fall back to the
91
+ * Swarmdo bot so the site deploy never fails on a missing global git config.
92
+ * `readConfig` is injected (real: `git -C <root> config <key>`) so this stays
93
+ * pure and unit-testable.
94
+ */
95
+ export function resolveSiteIdentity(readConfig) {
96
+ const name = (readConfig('user.name') || '').trim();
97
+ const email = (readConfig('user.email') || '').trim();
98
+ return {
99
+ name: name || 'Swarmdo',
100
+ email: email || 'maintainers@swarmdo.com',
101
+ };
102
+ }
103
+ /**
104
+ * `git commit` argv for the site sync — injects the identity via `-c` so the
105
+ * commit succeeds without a global git user, and keeps the bot co-author trailer.
106
+ */
107
+ export function siteCommitArgs(version, id) {
108
+ return [
109
+ '-c', `user.name=${id.name}`,
110
+ '-c', `user.email=${id.email}`,
111
+ 'commit', '-m', `release: sync site for v${version}\n\nCo-Authored-By: Swarmdo <maintainers@swarmdo.com>`,
112
+ ];
113
+ }
86
114
  //# sourceMappingURL=release.js.map
@@ -30,6 +30,8 @@ interface NpmLockEntry {
30
30
  license?: unknown;
31
31
  dev?: boolean;
32
32
  optional?: boolean;
33
+ /** npm workspace symlink — a local monorepo package, not a registry dependency. */
34
+ link?: boolean;
33
35
  }
34
36
  interface NpmLock {
35
37
  name?: string;
@@ -58,6 +58,11 @@ export function componentsFromNpmLock(lock, opts = {}) {
58
58
  continue; // the root project entry
59
59
  if (!key.includes('node_modules/'))
60
60
  continue;
61
+ // Skip workspace symlinks (`link:true`, no version) — they're the project's
62
+ // own local packages, not third-party components; emitting them fabricates a
63
+ // phantom `@name@0.0.0` purl that pollutes vuln scanners / regulators.
64
+ if (entry.link)
65
+ continue;
61
66
  if (opts.productionOnly && entry.dev)
62
67
  continue;
63
68
  const name = nameFromKey(key);
@@ -31,8 +31,13 @@ export class SemanticRouter {
31
31
  throw new Error(`Embedding must be Float32Array of length ${this.dimension}`);
32
32
  }
33
33
  }
34
- // Normalize embeddings for cosine similarity
35
- const normalizedEmbeddings = embeddings.map(emb => this.normalize(emb));
34
+ // Normalize ONLY for cosine (cosine = dot product of unit vectors). The
35
+ // dotProduct and euclidean metrics are magnitude-sensitive, so they must
36
+ // keep the raw vectors — normalizing them unconditionally silently turned
37
+ // both into cosine and could flip the ranking.
38
+ const normalizedEmbeddings = this.metric === 'cosine'
39
+ ? embeddings.map(emb => this.normalize(emb))
40
+ : embeddings.map(emb => Float32Array.from(emb));
36
41
  this.intents.set(name, {
37
42
  name,
38
43
  embeddings: normalizedEmbeddings,
@@ -47,7 +52,7 @@ export class SemanticRouter {
47
52
  if (!(embedding instanceof Float32Array) || embedding.length !== this.dimension) {
48
53
  throw new Error(`Embedding must be Float32Array of length ${this.dimension}`);
49
54
  }
50
- const normalizedQuery = this.normalize(embedding);
55
+ const normalizedQuery = this.metric === 'cosine' ? this.normalize(embedding) : embedding;
51
56
  const scores = [];
52
57
  // Calculate best score for each intent
53
58
  for (const [intentName, intent] of this.intents) {
@@ -97,8 +97,15 @@ export function parseJUnit(xml) {
97
97
  const suite = attrs.classname ?? attrs.suite ?? '';
98
98
  if (attrs.time)
99
99
  durationMs += Math.round(parseFloat(attrs.time) * 1000) || 0;
100
- const fail = body.match(/<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/);
101
- const isSkipped = /<skipped\b[^>]*\/?>/.test(body);
100
+ // Strip captured stdout/stderr before scanning for failure/skip markers: their
101
+ // text (pytest/jest-junit/gotestsum embed logs, often in CDATA) can contain
102
+ // literal <failure…>/<error…>-looking content that must NOT be read as a real
103
+ // failure element on an otherwise-passing test.
104
+ const scanBody = body
105
+ .replace(/<system-(out|err)\b[^>]*>[\s\S]*?<\/system-\1>/g, '')
106
+ .replace(/<system-(out|err)\b[^>]*\/>/g, '');
107
+ const fail = scanBody.match(/<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/);
108
+ const isSkipped = /<skipped\b[^>]*\/?>/.test(scanBody);
102
109
  if (fail) {
103
110
  failed++;
104
111
  const fAttrs = parseAttrs(fail[2]);
@@ -141,6 +141,7 @@ export function summarizeFile(fs_stat) {
141
141
  let firstPrompt = '';
142
142
  try {
143
143
  const content = fs.readFileSync(fs_stat.file, 'utf8');
144
+ const lines = [];
144
145
  for (const raw of content.split('\n')) {
145
146
  if (!raw.trim())
146
147
  continue;
@@ -151,17 +152,20 @@ export function summarizeFile(fs_stat) {
151
152
  catch {
152
153
  continue;
153
154
  }
155
+ lines.push(line);
154
156
  if (line.type && !RENDERABLE.has(line.type))
155
157
  continue;
156
- const role = line.message?.role;
157
- if (role === 'user' || role === 'assistant')
158
- turns++;
159
- if (!firstPrompt && role === 'user') {
158
+ if (!firstPrompt && line.message?.role === 'user') {
160
159
  const txt = cleanUserText(contentToText(line.message?.content) || (typeof line.message?.content === 'string' ? line.message.content : ''));
161
160
  if (txt)
162
161
  firstPrompt = txt.replace(/\s+/g, ' ').slice(0, 100);
163
162
  }
164
163
  }
164
+ // Count turns EXACTLY as exportSession does — render, then count role
165
+ // headings — so `transcript list` and `transcript export` can never disagree
166
+ // (#70). A pure tool-result carrier line renders no heading, so it's not a
167
+ // turn; the old raw per-line count inflated `list` for any session with tools.
168
+ turns = countRenderedTurns(renderTranscriptMarkdown(lines));
165
169
  }
166
170
  catch { /* unreadable */ }
167
171
  return { sessionId: sessionIdFromFile(fs_stat.file), file: fs_stat.file, project: fs_stat.project, turns, firstPrompt, mtimeMs: fs_stat.mtimeMs, sizeBytes: fs_stat.sizeBytes };
@@ -23,6 +23,7 @@ export interface UpdateConfig {
23
23
  exclude: string[];
24
24
  }
25
25
  declare const DEFAULT_CONFIG: UpdateConfig;
26
+ export declare function getUpdateType(current: string, latest: string): 'major' | 'minor' | 'patch' | 'none';
26
27
  export declare function getInstalledVersion(packageName: string): string | null;
27
28
  export declare function checkForUpdates(config?: UpdateConfig): Promise<{
28
29
  results: UpdateCheckResult[];
@@ -45,11 +45,16 @@ async function fetchPackageInfo(packageName) {
45
45
  return null;
46
46
  }
47
47
  }
48
- function getUpdateType(current, latest) {
48
+ export function getUpdateType(current, latest) {
49
49
  if (!semver.valid(current) || !semver.valid(latest)) {
50
50
  return 'none';
51
51
  }
52
- if (semver.eq(current, latest)) {
52
+ // Only a strictly-NEWER latest is an update. Without this guard the
53
+ // per-field comparisons below misclassify a same-or-OLDER latest: e.g.
54
+ // current 1.30.2 vs latest 1.29.5 has patch 5 > 2, so it would report
55
+ // 'patch' and auto-update could DOWNGRADE. `gt` also subsumes the equality
56
+ // case (eq → not gt → 'none'). (#79)
57
+ if (!semver.gt(latest, current)) {
53
58
  return 'none';
54
59
  }
55
60
  if (semver.major(latest) > semver.major(current)) {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * model-efficiency.ts — rank models by their EFFECTIVE cost per unit of useful
3
+ * output in your actual usage.
4
+ *
5
+ * A model's list output price is fixed, but what you really pay per output token
6
+ * also carries the input + cache cost of every request amortized over the output
7
+ * it produced. A model driven with huge contexts can effectively cost far more
8
+ * per output token than its sticker price — this surfaces that, per model, from
9
+ * the same aggregation the `usage models` view already computes.
10
+ *
11
+ * Pure: it folds already-summed per-model totals, no transcripts, no clock.
12
+ */
13
+ import type { UsageTotals } from './transcript-usage.js';
14
+ export interface ModelEfficiency {
15
+ model: string;
16
+ costUsd: number;
17
+ outputTokens: number;
18
+ /** effective $ per 1,000,000 output tokens (cost / output × 1e6) */
19
+ costPerMOutput: number;
20
+ }
21
+ /**
22
+ * Effective cost per 1M output tokens per model, cheapest first. Models with no
23
+ * cost or no output are dropped (no meaningful ratio). Pure.
24
+ */
25
+ export declare function computeModelEfficiency(models: Array<{
26
+ key: string;
27
+ totals: UsageTotals;
28
+ }>): ModelEfficiency[];
29
+ //# sourceMappingURL=model-efficiency.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * model-efficiency.ts — rank models by their EFFECTIVE cost per unit of useful
3
+ * output in your actual usage.
4
+ *
5
+ * A model's list output price is fixed, but what you really pay per output token
6
+ * also carries the input + cache cost of every request amortized over the output
7
+ * it produced. A model driven with huge contexts can effectively cost far more
8
+ * per output token than its sticker price — this surfaces that, per model, from
9
+ * the same aggregation the `usage models` view already computes.
10
+ *
11
+ * Pure: it folds already-summed per-model totals, no transcripts, no clock.
12
+ */
13
+ /**
14
+ * Effective cost per 1M output tokens per model, cheapest first. Models with no
15
+ * cost or no output are dropped (no meaningful ratio). Pure.
16
+ */
17
+ export function computeModelEfficiency(models) {
18
+ return models
19
+ .filter((m) => m.totals.costUsd > 0 && m.totals.outputTokens > 0)
20
+ .map((m) => ({
21
+ model: m.key,
22
+ costUsd: m.totals.costUsd,
23
+ outputTokens: m.totals.outputTokens,
24
+ costPerMOutput: (m.totals.costUsd / m.totals.outputTokens) * 1_000_000,
25
+ }))
26
+ .sort((a, b) => a.costPerMOutput - b.costPerMOutput || (a.model < b.model ? -1 : 1));
27
+ }
28
+ //# sourceMappingURL=model-efficiency.js.map
@@ -81,6 +81,7 @@ ${stat('Avg / active day', usd(r.avgCostPerActiveDay))}
81
81
  ${stat('Longest streak', `${r.longestStreak} day${r.longestStreak === 1 ? '' : 's'}`)}
82
82
  ${stat('Peak hour', peak)}
83
83
  ${stat('Cache read', pct(r.cacheReadPct))}
84
+ ${r.cacheSavingsUsd > 0 ? stat('Cache saved', usd(r.cacheSavingsUsd)) : ''}
84
85
  ${stat('Busiest day', busiest)}
85
86
  ${stat('Trend', `${arrow} ${pct(r.trend.firstHalfCost > 0 ? (r.trend.secondHalfCost - r.trend.firstHalfCost) / r.trend.firstHalfCost : 0)}`)}
86
87
  ${opts.delegation && opts.delegation.toolCalls > 0 ? stat('Delegation', pct(opts.delegation.ratio)) : ''}