swarmdo 1.30.0 → 1.37.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 (65) hide show
  1. package/README.md +6 -3
  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 +18 -7
  6. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +4 -1
  7. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
  8. package/v3/@swarmdo/cli/dist/src/commands/comms.d.ts +16 -0
  9. package/v3/@swarmdo/cli/dist/src/commands/comms.js +280 -0
  10. package/v3/@swarmdo/cli/dist/src/commands/compact.js +6 -2
  11. package/v3/@swarmdo/cli/dist/src/commands/config.js +1 -0
  12. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +121 -1
  13. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +8 -2
  14. package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -0
  15. package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
  16. package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
  17. package/v3/@swarmdo/cli/dist/src/commands/usage.js +34 -1
  18. package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
  19. package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
  20. package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
  21. package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
  22. package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
  23. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
  24. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +3 -1
  25. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +11 -4
  26. package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
  27. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +20 -1
  28. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
  29. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
  30. package/v3/@swarmdo/cli/dist/src/index.js +8 -0
  31. package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
  32. package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
  33. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
  34. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
  35. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
  36. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
  37. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
  38. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
  39. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
  40. package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
  41. package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
  42. package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
  43. package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
  44. package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
  45. package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
  46. package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
  47. package/v3/@swarmdo/cli/dist/src/redact/redact.js +7 -2
  48. package/v3/@swarmdo/cli/dist/src/release/release.js +1 -1
  49. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
  50. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
  51. package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
  52. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
  53. package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
  54. package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
  55. package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
  56. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
  57. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
  58. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +1 -0
  59. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +26 -0
  60. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +44 -0
  61. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
  62. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
  63. package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
  64. package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
  65. package/v3/@swarmdo/cli/package.json +1 -1
@@ -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';
@@ -45,9 +45,14 @@ export const RULES = [
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
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.
48
+ * gates keep low-entropy values (paths, short flags) from being flagged. The
49
+ * value class also stops at the URL/query delimiters `&`, `?`, `#` (absent from
50
+ * base64/base64url/hex token alphabets): otherwise a query-string secret like
51
+ * `client_secret=…&api_key=…` over-captures past the `&` into the next secret,
52
+ * and that inflated span — overlapping a range the high-confidence RULES pass
53
+ * already claimed — gets dropped entirely, leaving the first secret unredacted.
49
54
  */
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;
55
+ 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;
51
56
  /** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
52
57
  export function shannonEntropy(s) {
53
58
  if (!s)
@@ -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) {
@@ -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)) : ''}
@@ -14,6 +14,7 @@
14
14
  * rows the parser already produces. See #47.
15
15
  */
16
16
  import type { DayRow, ModelRow } from './diff.js';
17
+ import { type TranscriptModelPrice } from './claude-pricing.js';
17
18
  export interface ModelShare {
18
19
  model: string;
19
20
  costUsd: number;
@@ -45,6 +46,8 @@ export interface Reflection {
45
46
  costUsd: number;
46
47
  totalTokens: number;
47
48
  } | null;
49
+ /** active days whose cost is an outlier vs the median (possible runaways) */
50
+ spikeDays: SpikeDay[];
48
51
  /** models ranked by cost desc (capped to opts.topModels) */
49
52
  topModels: ModelShare[];
50
53
  /** projects ranked by cost desc (ModelShare.model holds the project path) */
@@ -68,12 +71,16 @@ export interface Reflection {
68
71
  secondHalfCost: number;
69
72
  direction: 'up' | 'down' | 'flat';
70
73
  };
74
+ /** $ prompt-caching saved this period (vs paying full input rate); 0 if no priced models */
75
+ cacheSavingsUsd: number;
71
76
  }
72
77
  export interface ReflectOptions {
73
78
  /** how many models to keep in topModels (default 5) */
74
79
  topModels?: number;
75
80
  /** relative change below which the trend reads 'flat' (default 0.05 = 5%) */
76
81
  flatThreshold?: number;
82
+ /** price resolver for cache-savings (injected for tests; default = the real table) */
83
+ resolvePrice?: (model: string) => TranscriptModelPrice | undefined;
77
84
  }
78
85
  /** The calendar day after an ISO date (handles month/year rollover). Pure. */
79
86
  export declare function nextDay(iso: string): string;
@@ -87,6 +94,25 @@ export declare function monthsBefore(iso: string, n: number): string;
87
94
  export declare function spanDays(from: string, to: string): number;
88
95
  /** Longest run of consecutive calendar days present in the set. Pure. */
89
96
  export declare function longestStreakOf(days: string[]): number;
97
+ export interface SpikeDay {
98
+ day: string;
99
+ costUsd: number;
100
+ /** costUsd / median active-day cost */
101
+ ratioToMedian: number;
102
+ }
103
+ /**
104
+ * Days whose cost is an outlier vs the median active-day cost — a heads-up for
105
+ * runaway sessions. Median (not mean) so the spikes don't inflate the baseline.
106
+ * Needs ≥3 active days for a meaningful median; a floor keeps trivially-cheap
107
+ * days from tripping the ratio. Sorted by cost desc. Pure.
108
+ */
109
+ export declare function detectSpikeDays(days: Array<{
110
+ day: string;
111
+ costUsd: number;
112
+ }>, opts?: {
113
+ minRatio?: number;
114
+ minCostUsd?: number;
115
+ }): SpikeDay[];
90
116
  /** Render a numeric series as a Unicode block sparkline (one char per bucket).
91
117
  * Zero buckets are blank; the rest scale ▁..█ against the max. Pure — used for
92
118
  * the terminal cost-by-hour view (the HTML dashboard draws the same data). */
@@ -13,6 +13,8 @@
13
13
  * without touching disk. The command layer in ../commands/usage.ts feeds it the
14
14
  * rows the parser already produces. See #47.
15
15
  */
16
+ import { modelCacheSavings } from './cache-stats.js';
17
+ import { resolveTranscriptPrice } from './claude-pricing.js';
16
18
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
17
19
  /** The calendar day after an ISO date (handles month/year rollover). Pure. */
18
20
  export function nextDay(iso) {
@@ -58,6 +60,27 @@ export function longestStreakOf(days) {
58
60
  }
59
61
  return best;
60
62
  }
63
+ /**
64
+ * Days whose cost is an outlier vs the median active-day cost — a heads-up for
65
+ * runaway sessions. Median (not mean) so the spikes don't inflate the baseline.
66
+ * Needs ≥3 active days for a meaningful median; a floor keeps trivially-cheap
67
+ * days from tripping the ratio. Sorted by cost desc. Pure.
68
+ */
69
+ export function detectSpikeDays(days, opts = {}) {
70
+ const minRatio = opts.minRatio ?? 2.5;
71
+ const minCostUsd = opts.minCostUsd ?? 0.5;
72
+ const costs = days.map((d) => d.costUsd).filter((c) => c > 0).sort((a, b) => a - b);
73
+ if (costs.length < 3)
74
+ return [];
75
+ const mid = costs.length >> 1;
76
+ const median = costs.length % 2 ? costs[mid] : (costs[mid - 1] + costs[mid]) / 2;
77
+ if (median <= 0)
78
+ return [];
79
+ return days
80
+ .filter((d) => d.costUsd >= minCostUsd && d.costUsd >= minRatio * median)
81
+ .map((d) => ({ day: d.day, costUsd: d.costUsd, ratioToMedian: d.costUsd / median }))
82
+ .sort((a, b) => b.costUsd - a.costUsd);
83
+ }
61
84
  const within = (key, from, to) => key >= from && key <= to;
62
85
  const SPARK_BLOCKS = ' ▁▂▃▄▅▆▇█'; // index 0 = empty (zero), 1..8 = ▁..█ scaled to max
63
86
  /** Render a numeric series as a Unicode block sparkline (one char per bucket).
@@ -145,6 +168,25 @@ export function computeReflection(dayRows, modelRows, from, to, opts = {}, proje
145
168
  // Cost-ranked shares over the same window, for both models and projects.
146
169
  const topModels = rankShares(modelRows, from, to, totals.costUsd, topN);
147
170
  const topProjects = rankShares(projectRows, from, to, totals.costUsd, topN);
171
+ // $ saved by prompt caching this period: fold windowed per-model token counts,
172
+ // then price each via the (injected) resolver. Unpriced models contribute 0.
173
+ const resolvePrice = opts.resolvePrice ?? resolveTranscriptPrice;
174
+ const cacheTokens = new Map();
175
+ for (const r of modelRows) {
176
+ if (!within(r.day, from, to))
177
+ continue;
178
+ const s = cacheTokens.get(r.key) ?? { inputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
179
+ s.inputTokens += r.totals.inputTokens;
180
+ s.cacheWriteTokens += r.totals.cacheWriteTokens;
181
+ s.cacheReadTokens += r.totals.cacheReadTokens;
182
+ cacheTokens.set(r.key, s);
183
+ }
184
+ let cacheSavingsUsd = 0;
185
+ for (const [model, tok] of cacheTokens) {
186
+ const sv = modelCacheSavings(tok, resolvePrice(model));
187
+ if (sv !== null)
188
+ cacheSavingsUsd += sv;
189
+ }
148
190
  const inputSide = totals.inputTokens + totals.cacheWriteTokens + totals.cacheReadTokens;
149
191
  const relChange = firstHalfCost > 0 ? (secondHalfCost - firstHalfCost) / firstHalfCost : (secondHalfCost > 0 ? 1 : 0);
150
192
  const direction = Math.abs(relChange) < flat ? 'flat' : relChange > 0 ? 'up' : 'down';
@@ -152,6 +194,7 @@ export function computeReflection(dayRows, modelRows, from, to, opts = {}, proje
152
194
  period: { from, to, spanDays: spanDays(from, to) },
153
195
  totals,
154
196
  busiestDay,
197
+ spikeDays: detectSpikeDays(days.map((r) => ({ day: r.key, costUsd: r.totals.costUsd }))),
155
198
  topModels,
156
199
  topProjects,
157
200
  peakHour: peakHourOf(hourHistogram),
@@ -160,6 +203,7 @@ export function computeReflection(dayRows, modelRows, from, to, opts = {}, proje
160
203
  cacheReadPct: inputSide > 0 ? totals.cacheReadTokens / inputSide : 0,
161
204
  avgCostPerActiveDay: totals.activeDays > 0 ? totals.costUsd / totals.activeDays : 0,
162
205
  trend: { firstHalfCost, secondHalfCost, direction },
206
+ cacheSavingsUsd,
163
207
  };
164
208
  }
165
209
  //# sourceMappingURL=reflect.js.map
@@ -77,7 +77,14 @@ export declare function normalizeDateBound(raw: string | undefined): string | un
77
77
  * report must not fail because one session file is mid-write.
78
78
  */
79
79
  export declare function collectUsage(opts?: CollectOptions): UsageCollection;
80
- export type UsageDimension = 'day' | 'month' | 'model' | 'project' | 'session';
80
+ export type UsageDimension = 'day' | 'week' | 'month' | 'model' | 'project' | 'session';
81
+ /**
82
+ * ISO-8601 week key for a `YYYY-MM-DD` date, e.g. '2026-W28'. The week belongs to
83
+ * the year of its Thursday, so a late-December / early-January week can carry the
84
+ * neighbouring ISO year (e.g. 2025-12-31 → 2026-W01, 2027-01-01 → 2026-W53).
85
+ * Keys sort chronologically as strings. Pure.
86
+ */
87
+ export declare function isoWeekKey(iso: string): string;
81
88
  /**
82
89
  * Group events along one dimension. Time dimensions sort chronologically;
83
90
  * the rest sort by descending cost (what a "top spenders" view wants).
@@ -209,8 +209,28 @@ function toUsageEvent(line, project, seen, unpriced) {
209
209
  costSource,
210
210
  };
211
211
  }
212
+ /**
213
+ * ISO-8601 week key for a `YYYY-MM-DD` date, e.g. '2026-W28'. The week belongs to
214
+ * the year of its Thursday, so a late-December / early-January week can carry the
215
+ * neighbouring ISO year (e.g. 2025-12-31 → 2026-W01, 2027-01-01 → 2026-W53).
216
+ * Keys sort chronologically as strings. Pure.
217
+ */
218
+ export function isoWeekKey(iso) {
219
+ const [y, m, d] = iso.split('-').map(Number);
220
+ const date = new Date(Date.UTC(y, m - 1, d));
221
+ const dayNum = (date.getUTCDay() + 6) % 7; // Mon=0 … Sun=6
222
+ date.setUTCDate(date.getUTCDate() - dayNum + 3); // Thursday of this week
223
+ const isoYear = date.getUTCFullYear();
224
+ const jan4 = new Date(Date.UTC(isoYear, 0, 4)); // Jan 4 is always in week 1
225
+ const jan4Day = (jan4.getUTCDay() + 6) % 7;
226
+ const week1Monday = new Date(jan4);
227
+ week1Monday.setUTCDate(jan4.getUTCDate() - jan4Day);
228
+ const week = 1 + Math.round((date.getTime() - week1Monday.getTime()) / (7 * 86_400_000));
229
+ return `${isoYear}-W${String(week).padStart(2, '0')}`;
230
+ }
212
231
  const DIMENSION_KEY = {
213
232
  day: (e) => e.dateKey,
233
+ week: (e) => isoWeekKey(e.dateKey),
214
234
  month: (e) => e.monthKey,
215
235
  model: (e) => e.model,
216
236
  project: (e) => e.project,
@@ -253,7 +273,7 @@ export function aggregateUsage(events, dimension) {
253
273
  addEvent(t, e);
254
274
  }
255
275
  const rows = Array.from(groups.entries()).map(([key, totals]) => ({ key, totals }));
256
- if (dimension === 'day' || dimension === 'month') {
276
+ if (dimension === 'day' || dimension === 'week' || dimension === 'month') {
257
277
  rows.sort((a, b) => a.key.localeCompare(b.key));
258
278
  }
259
279
  else {
@@ -7,6 +7,28 @@
7
7
  * (`swarmdo sbom | …`, `pack`, `compact`, `redact`) truncates at the ~64 KiB
8
8
  * pipe buffer. Awaiting the write callback guarantees the data reached the OS
9
9
  * before we hand control back. For a TTY (synchronous) this is a no-op await.
10
+ *
11
+ * Broken-pipe (EPIPE) handling: when the reader closes the pipe early (`… |
12
+ * head`), Node emits an `'error'` event on the stdout Socket — often
13
+ * ASYNCHRONOUSLY, after our write callback has already fired — and an unhandled
14
+ * `'error'` event crashes the process with a raw stack trace. A per-write
15
+ * listener therefore doesn't cover it (it's gone by the time the async error
16
+ * lands). So we install ONE persistent EPIPE guard per stream that swallows the
17
+ * broken pipe (normal producer termination) while still rethrowing any other
18
+ * stream error, and additionally resolve the write promise on an EPIPE callback.
19
+ * The `stream` param is injectable so this is unit-testable without a subprocess.
20
+ */
21
+ /** The subset of a writable stream writeStdout needs (process.stdout satisfies it). */
22
+ export interface WritableLike {
23
+ write(data: string, cb: (err?: Error | null) => void): boolean;
24
+ on(event: 'error', listener: (err: Error) => void): unknown;
25
+ }
26
+ /**
27
+ * Install (once per stream) a persistent `'error'` handler that swallows EPIPE —
28
+ * a closed reader is normal termination for a Unix producer, not a crash — and
29
+ * rethrows everything else so genuine stream errors still surface. Exported for
30
+ * the CLI entry point to arm stdout/stderr before any write happens.
10
31
  */
11
- export declare function writeStdout(data: string): Promise<void>;
32
+ export declare function guardStreamEpipe(stream: WritableLike): void;
33
+ export declare function writeStdout(data: string, stream?: WritableLike): Promise<void>;
12
34
  //# sourceMappingURL=stdout.d.ts.map