swarmdo 1.29.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 (76) hide show
  1. package/.claude/helpers/auto-memory-hook.mjs +66 -33
  2. package/README.md +6 -3
  3. package/package.json +1 -1
  4. package/v3/@swarmdo/cli/.claude/helpers/auto-memory-hook.mjs +65 -32
  5. package/v3/@swarmdo/cli/dist/src/affected/affected.d.ts +6 -1
  6. package/v3/@swarmdo/cli/dist/src/affected/affected.js +7 -2
  7. package/v3/@swarmdo/cli/dist/src/apply/apply.js +18 -7
  8. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +4 -1
  9. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
  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 +1 -0
  14. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +121 -1
  15. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +8 -2
  16. package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -0
  17. package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
  18. package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
  19. package/v3/@swarmdo/cli/dist/src/commands/usage.js +208 -3
  20. package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
  21. package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
  22. package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
  23. package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
  24. package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
  25. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
  26. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +3 -1
  27. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +11 -4
  28. package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
  29. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +20 -1
  30. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
  31. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
  32. package/v3/@swarmdo/cli/dist/src/index.js +8 -0
  33. package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
  34. package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
  35. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
  36. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
  37. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
  38. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
  39. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
  40. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
  41. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
  42. package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
  43. package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
  44. package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
  45. package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
  46. package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
  47. package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
  48. package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
  49. package/v3/@swarmdo/cli/dist/src/redact/redact.js +7 -2
  50. package/v3/@swarmdo/cli/dist/src/release/release.js +1 -1
  51. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
  52. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
  53. package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
  54. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
  55. package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
  56. package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
  57. package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
  58. package/v3/@swarmdo/cli/dist/src/usage/limits.d.ts +77 -0
  59. package/v3/@swarmdo/cli/dist/src/usage/limits.js +134 -0
  60. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
  61. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
  62. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.d.ts +23 -0
  63. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +95 -0
  64. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +131 -0
  65. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +209 -0
  66. package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.d.ts +28 -0
  67. package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.js +33 -0
  68. package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.d.ts +14 -0
  69. package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.js +10 -0
  70. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
  71. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
  72. package/v3/@swarmdo/cli/dist/src/util/csv.d.ts +14 -0
  73. package/v3/@swarmdo/cli/dist/src/util/csv.js +20 -0
  74. package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
  75. package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
  76. 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,77 @@
1
+ /**
2
+ * limits.ts — forecast when a Claude Code usage window will hit its cap.
3
+ *
4
+ * Claude Code passes an official `rate_limits` payload to statusline scripts:
5
+ * for the rolling 5-hour and 7-day windows it reports `used_percentage` and
6
+ * `resets_at`. This is the projection layer on top — at the current burn rate,
7
+ * when will the window hit 100%, and does that land before the window resets?
8
+ * ("at this pace you hit the weekly cap Thu 14:00, 6h before it resets").
9
+ *
10
+ * Pure + deterministic: it takes a normalized window state + an injected `now`
11
+ * (the statusline shim maps the raw payload to WindowState — see #46), so the
12
+ * projection math is unit-tested with zero clock or wire-format coupling.
13
+ */
14
+ export type LimitStatus = 'ok' | 'warn' | 'over';
15
+ /** Rolling-window durations Claude Code reports (5-hour + 7-day caps). */
16
+ export declare const FIVE_HOUR_MS: number;
17
+ export declare const SEVEN_DAY_MS: number;
18
+ export interface NamedWindow {
19
+ /** short label, '5h' | '7d' */
20
+ label: string;
21
+ state: WindowState;
22
+ }
23
+ /**
24
+ * Map Claude Code's statusline `rate_limits` payload to normalized windows.
25
+ * Tolerant of shape drift: accepts the payload bare or wrapped in `rate_limits`,
26
+ * snake_case or camelCase keys, and epoch-seconds / epoch-ms / ISO `resets_at`.
27
+ * Windows missing usage or a reset time are dropped. Pure.
28
+ */
29
+ export declare function parseRateLimits(payload: unknown): NamedWindow[];
30
+ export interface WindowState {
31
+ /** percent of the window's quota consumed, 0..100 */
32
+ usedPercentage: number;
33
+ /** window duration in ms (5h = 18_000_000, 7d = 604_800_000) */
34
+ windowMs: number;
35
+ /** epoch ms when the window resets to 0% */
36
+ resetsAtMs: number;
37
+ }
38
+ export interface LimitForecast {
39
+ usedPercentage: number;
40
+ resetsAtMs: number;
41
+ /** ms from now until the window resets (never negative) */
42
+ msToReset: number;
43
+ /** projected epoch ms when usage reaches 100% at the current burn rate, or
44
+ * null when it can't be projected (no burn yet, already at/over 100%, or the
45
+ * window hasn't started per the clock) */
46
+ exhaustionMs: number | null;
47
+ /** true when exhaustion is projected at or before the window resets */
48
+ willExhaust: boolean;
49
+ status: LimitStatus;
50
+ }
51
+ /**
52
+ * Forecast a single window. Burn rate is `usedPercentage / elapsed`, where
53
+ * elapsed = now − windowStart and windowStart = resetsAt − windowMs. Pure.
54
+ */
55
+ export declare function forecastWindow(w: WindowState, nowMs: number, opts?: {
56
+ warnPct?: number;
57
+ }): LimitForecast;
58
+ /** Worst status across windows (over > warn > ok). Pure. */
59
+ export declare function worstStatus(forecasts: LimitForecast[]): LimitStatus;
60
+ /**
61
+ * The binding window — the one that constrains you first. A window projected to
62
+ * exhaust before reset outranks one that won't; among those that will, the
63
+ * earlier exhaustion wins; otherwise the higher used_percentage. null for [].
64
+ */
65
+ export declare function bindingWindow(forecasts: LimitForecast[]): LimitForecast | null;
66
+ /** Compact human duration for a ms span, e.g. 9000000 → "2h30m", 90000 → "1m". */
67
+ export declare function humanizeMs(ms: number): string;
68
+ /** Ultra-compact one-line indicator for a statusline, e.g. "5h 72%⚠ · 7d 12%".
69
+ * `!` marks an over-cap window, `⚠` an at-risk one. Pure. */
70
+ export declare function formatLimitSegment(forecasts: Array<{
71
+ label: string;
72
+ f: LimitForecast;
73
+ }>): string;
74
+ /** One-line summary for a window, e.g. "5h window: 42% used, resets in 2h14m —
75
+ * on pace to hit the cap in ~1h20m (before reset)". `label` names the window. */
76
+ export declare function formatForecast(label: string, f: LimitForecast, nowMs: number): string;
77
+ //# sourceMappingURL=limits.d.ts.map
@@ -0,0 +1,134 @@
1
+ /**
2
+ * limits.ts — forecast when a Claude Code usage window will hit its cap.
3
+ *
4
+ * Claude Code passes an official `rate_limits` payload to statusline scripts:
5
+ * for the rolling 5-hour and 7-day windows it reports `used_percentage` and
6
+ * `resets_at`. This is the projection layer on top — at the current burn rate,
7
+ * when will the window hit 100%, and does that land before the window resets?
8
+ * ("at this pace you hit the weekly cap Thu 14:00, 6h before it resets").
9
+ *
10
+ * Pure + deterministic: it takes a normalized window state + an injected `now`
11
+ * (the statusline shim maps the raw payload to WindowState — see #46), so the
12
+ * projection math is unit-tested with zero clock or wire-format coupling.
13
+ */
14
+ /** Rolling-window durations Claude Code reports (5-hour + 7-day caps). */
15
+ export const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
16
+ export const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
17
+ /** Coerce an epoch-seconds, epoch-ms, or ISO-string timestamp to epoch ms. */
18
+ function toEpochMs(v) {
19
+ if (typeof v === 'number' && Number.isFinite(v))
20
+ return v < 1e12 ? v * 1000 : v; // secs vs ms
21
+ if (typeof v === 'string') {
22
+ const t = Date.parse(v);
23
+ return Number.isNaN(t) ? null : t;
24
+ }
25
+ return null;
26
+ }
27
+ function pickWindow(obj, windowMs, label) {
28
+ if (!obj || typeof obj !== 'object')
29
+ return null;
30
+ const o = obj;
31
+ const used = (o.used_percentage ?? o.usedPercentage);
32
+ const resetsAtMs = toEpochMs(o.resets_at ?? o.resetsAt);
33
+ if (typeof used !== 'number' || !Number.isFinite(used) || resetsAtMs === null)
34
+ return null;
35
+ return { label, state: { usedPercentage: Math.max(0, Math.min(100, used)), windowMs, resetsAtMs } };
36
+ }
37
+ /**
38
+ * Map Claude Code's statusline `rate_limits` payload to normalized windows.
39
+ * Tolerant of shape drift: accepts the payload bare or wrapped in `rate_limits`,
40
+ * snake_case or camelCase keys, and epoch-seconds / epoch-ms / ISO `resets_at`.
41
+ * Windows missing usage or a reset time are dropped. Pure.
42
+ */
43
+ export function parseRateLimits(payload) {
44
+ const p = payload && typeof payload === 'object' ? payload : {};
45
+ const rl = (p.rate_limits ?? p.rateLimits ?? p);
46
+ const out = [];
47
+ const w5 = pickWindow(rl.five_hour ?? rl.fiveHour, FIVE_HOUR_MS, '5h');
48
+ const w7 = pickWindow(rl.seven_day ?? rl.sevenDay, SEVEN_DAY_MS, '7d');
49
+ if (w5)
50
+ out.push(w5);
51
+ if (w7)
52
+ out.push(w7);
53
+ return out;
54
+ }
55
+ /**
56
+ * Forecast a single window. Burn rate is `usedPercentage / elapsed`, where
57
+ * elapsed = now − windowStart and windowStart = resetsAt − windowMs. Pure.
58
+ */
59
+ export function forecastWindow(w, nowMs, opts = {}) {
60
+ const warnPct = opts.warnPct ?? 80;
61
+ const used = w.usedPercentage;
62
+ const msToReset = Math.max(0, w.resetsAtMs - nowMs);
63
+ const windowStartMs = w.resetsAtMs - w.windowMs;
64
+ const elapsed = nowMs - windowStartMs;
65
+ let exhaustionMs = null;
66
+ if (used >= 100) {
67
+ // already exhausted — mark exhaustion as "now"
68
+ exhaustionMs = nowMs;
69
+ }
70
+ else if (used > 0 && elapsed > 0) {
71
+ const msTo100 = ((100 - used) * elapsed) / used; // (remaining%) / (used%/elapsed)
72
+ exhaustionMs = nowMs + msTo100;
73
+ }
74
+ const willExhaust = exhaustionMs !== null && exhaustionMs <= w.resetsAtMs;
75
+ const status = used >= 100 ? 'over' : (willExhaust || used >= warnPct) ? 'warn' : 'ok';
76
+ return { usedPercentage: used, resetsAtMs: w.resetsAtMs, msToReset, exhaustionMs, willExhaust, status };
77
+ }
78
+ /** Worst status across windows (over > warn > ok). Pure. */
79
+ export function worstStatus(forecasts) {
80
+ const rank = { ok: 0, warn: 1, over: 2 };
81
+ return forecasts.reduce((acc, f) => (rank[f.status] > rank[acc] ? f.status : acc), 'ok');
82
+ }
83
+ /**
84
+ * The binding window — the one that constrains you first. A window projected to
85
+ * exhaust before reset outranks one that won't; among those that will, the
86
+ * earlier exhaustion wins; otherwise the higher used_percentage. null for [].
87
+ */
88
+ export function bindingWindow(forecasts) {
89
+ if (forecasts.length === 0)
90
+ return null;
91
+ return [...forecasts].sort((a, b) => {
92
+ if (a.willExhaust !== b.willExhaust)
93
+ return a.willExhaust ? -1 : 1;
94
+ if (a.willExhaust && b.willExhaust)
95
+ return a.exhaustionMs - b.exhaustionMs;
96
+ return b.usedPercentage - a.usedPercentage;
97
+ })[0];
98
+ }
99
+ /** Compact human duration for a ms span, e.g. 9000000 → "2h30m", 90000 → "1m". */
100
+ export function humanizeMs(ms) {
101
+ if (ms <= 0)
102
+ return '0m';
103
+ const totalMin = Math.floor(ms / 60_000);
104
+ const d = Math.floor(totalMin / 1440);
105
+ const h = Math.floor((totalMin % 1440) / 60);
106
+ const m = totalMin % 60;
107
+ if (d > 0)
108
+ return `${d}d${h > 0 ? `${h}h` : ''}`;
109
+ if (h > 0)
110
+ return `${h}h${m > 0 ? `${m}m` : ''}`;
111
+ return `${m}m`;
112
+ }
113
+ /** Ultra-compact one-line indicator for a statusline, e.g. "5h 72%⚠ · 7d 12%".
114
+ * `!` marks an over-cap window, `⚠` an at-risk one. Pure. */
115
+ export function formatLimitSegment(forecasts) {
116
+ if (forecasts.length === 0)
117
+ return 'limits: n/a';
118
+ const mark = (s) => (s === 'over' ? '!' : s === 'warn' ? '⚠' : '');
119
+ return forecasts.map(({ label, f }) => `${label} ${Math.round(f.usedPercentage)}%${mark(f.status)}`).join(' · ');
120
+ }
121
+ /** One-line summary for a window, e.g. "5h window: 42% used, resets in 2h14m —
122
+ * on pace to hit the cap in ~1h20m (before reset)". `label` names the window. */
123
+ export function formatForecast(label, f, nowMs) {
124
+ const head = `${label}: ${Math.round(f.usedPercentage)}% used, resets in ${humanizeMs(f.msToReset)}`;
125
+ if (f.status === 'over')
126
+ return `${head} — CAP REACHED`;
127
+ if (f.willExhaust && f.exhaustionMs !== null) {
128
+ const inMs = Math.max(0, f.exhaustionMs - nowMs);
129
+ const before = humanizeMs(f.resetsAtMs - f.exhaustionMs);
130
+ return `${head} — on pace to hit the cap in ~${humanizeMs(inMs)} (${before} before reset)`;
131
+ }
132
+ return `${head} — on pace to stay under the cap`;
133
+ }
134
+ //# sourceMappingURL=limits.js.map
@@ -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
@@ -0,0 +1,23 @@
1
+ /**
2
+ * reflect-html.ts — render a Reflection (see reflect.ts) into a self-contained,
3
+ * shareable HTML dashboard for `swarmdo usage reflect --html`.
4
+ *
5
+ * Pure + deterministic: it takes an already-computed Reflection and returns a
6
+ * complete HTML document string with all CSS inlined (no external requests, no
7
+ * clock — any timestamp is passed in), so it unit-tests without a browser or a
8
+ * network. Every user-controlled string (model ids, project paths) is HTML-
9
+ * escaped. See #47.
10
+ */
11
+ import type { Reflection } from './reflect.js';
12
+ /** HTML-escape a string for safe interpolation into text or attributes. */
13
+ export declare function escapeHtml(s: string): string;
14
+ /** Render the retrospective to a complete self-contained HTML document. Pure. */
15
+ export declare function renderReflectionHtml(r: Reflection, opts?: {
16
+ generatedAt?: string;
17
+ delegation?: {
18
+ taskCalls: number;
19
+ toolCalls: number;
20
+ ratio: number;
21
+ };
22
+ }): string;
23
+ //# sourceMappingURL=reflect-html.d.ts.map