swarmdo 1.18.0 → 1.27.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 (41) hide show
  1. package/.claude/helpers/statusline.cjs +1 -1
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/v3/@swarmdo/cli/dist/src/apply/apply.d.ts +2 -0
  5. package/v3/@swarmdo/cli/dist/src/apply/apply.js +29 -2
  6. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +26 -3
  7. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.d.ts +26 -7
  8. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +97 -13
  9. package/v3/@swarmdo/cli/dist/src/codegraph/store.d.ts +8 -1
  10. package/v3/@swarmdo/cli/dist/src/codegraph/store.js +78 -1
  11. package/v3/@swarmdo/cli/dist/src/commands/cycles.js +4 -1
  12. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +5 -2
  13. package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -3
  14. package/v3/@swarmdo/cli/dist/src/commands/release.js +5 -1
  15. package/v3/@swarmdo/cli/dist/src/commands/testreport.d.ts +17 -0
  16. package/v3/@swarmdo/cli/dist/src/commands/testreport.js +120 -0
  17. package/v3/@swarmdo/cli/dist/src/cycles/cycles.d.ts +12 -2
  18. package/v3/@swarmdo/cli/dist/src/cycles/cycles.js +5 -3
  19. package/v3/@swarmdo/cli/dist/src/env/env.d.ts +5 -2
  20. package/v3/@swarmdo/cli/dist/src/env/env.js +24 -3
  21. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +15 -0
  22. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +33 -1
  23. package/v3/@swarmdo/cli/dist/src/license/license.d.ts +31 -2
  24. package/v3/@swarmdo/cli/dist/src/license/license.js +133 -11
  25. package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
  26. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
  27. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
  28. package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
  29. package/v3/@swarmdo/cli/dist/src/mcp-tools/testreport-tools.d.ts +11 -0
  30. package/v3/@swarmdo/cli/dist/src/mcp-tools/testreport-tools.js +33 -0
  31. package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +2 -3
  32. package/v3/@swarmdo/cli/dist/src/pack/pack.js +68 -12
  33. package/v3/@swarmdo/cli/dist/src/redact/redact.js +14 -2
  34. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +16 -0
  35. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +28 -1
  36. package/v3/@swarmdo/cli/dist/src/testreport/testreport.d.ts +65 -0
  37. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +246 -0
  38. package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.d.ts +6 -0
  39. package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.js +12 -9
  40. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +4 -1
  41. package/v3/@swarmdo/cli/package.json +1 -1
@@ -80,7 +80,15 @@ function renderMarkdown(files, opts) {
80
80
  return parts.join('\n');
81
81
  }
82
82
  function escapeXml(s) {
83
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
83
+ // All five predefined XML entities — the quote escapes matter because this
84
+ // value is also interpolated into a double-quoted `path="…"` attribute, where
85
+ // an unescaped `"` in a file path would break out of the attribute.
86
+ return s
87
+ .replace(/&/g, '&amp;')
88
+ .replace(/</g, '&lt;')
89
+ .replace(/>/g, '&gt;')
90
+ .replace(/"/g, '&quot;')
91
+ .replace(/'/g, '&#39;');
84
92
  }
85
93
  function renderXml(files, opts) {
86
94
  const parts = ['<repository>'];
@@ -138,11 +146,42 @@ export function packFiles(input, opts = {}) {
138
146
  },
139
147
  };
140
148
  }
149
+ /**
150
+ * Convert a .gitignore glob body to a regex source (no anchors). Handles a
151
+ * double-star with correct gitignore(5) depth semantics: a double-star path
152
+ * segment matches zero or more directories (so `a/[star][star]/b` matches
153
+ * `a/b`, `a/x/b`, `a/x/y/b`); a leading one matches in any directory; a
154
+ * trailing one matches everything below. Single `*`/`?` stay within one path
155
+ * segment; other consecutive asterisks degrade to regular stars, per the spec.
156
+ * Pure.
157
+ */
158
+ function globToRegExp(body) {
159
+ const esc = (s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]');
160
+ const parts = body.split('/');
161
+ let re = '';
162
+ for (let i = 0; i < parts.length; i++) {
163
+ const last = i === parts.length - 1;
164
+ if (parts[i] === '**') {
165
+ if (last) {
166
+ re += '.*';
167
+ } // trailing /** — everything below
168
+ else {
169
+ re += '(?:[^/]*/)*';
170
+ continue;
171
+ } // absorbs the following slash → zero-or-more dirs
172
+ }
173
+ else {
174
+ re += esc(parts[i]);
175
+ }
176
+ if (!last)
177
+ re += '/';
178
+ }
179
+ return re;
180
+ }
141
181
  /**
142
182
  * Minimal .gitignore-style matcher. Handles the common cases — plain names,
143
- * `dir/`, `*.ext` globs, leading `/` anchors, and `!` negation — not the full
144
- * spec (no `**` depth semantics beyond substring, no nested ignore files).
145
- * Good enough to keep obvious noise out; full semantics is a follow-up.
183
+ * `dir/`, `*.ext` globs, `**` depth globs (gitignore(5) semantics), leading `/`
184
+ * anchors, and `!` negation. Not the full spec (no nested ignore files).
146
185
  */
147
186
  export function makeIgnoreMatcher(patterns) {
148
187
  const rules = patterns
@@ -157,22 +196,39 @@ export function makeIgnoreMatcher(patterns) {
157
196
  const anchored = body.startsWith('/');
158
197
  if (anchored)
159
198
  body = body.slice(1);
160
- const re = new RegExp('^' +
161
- body
162
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
163
- .replace(/\*/g, '[^/]*')
164
- .replace(/\?/g, '[^/]') +
165
- (dirOnly ? '(/|$)' : '($|/)'));
199
+ const re = new RegExp('^' + globToRegExp(body) + (dirOnly ? '(/|$)' : '($|/)'));
166
200
  return { negate, anchored, re };
167
201
  });
168
- return (relPath) => {
202
+ // Ignored status of a single path (last-match-wins over the ordered rules).
203
+ const ruleVerdict = (path) => {
169
204
  let ignored = false;
170
205
  for (const r of rules) {
171
- const candidates = r.anchored ? [relPath] : [relPath, ...relPath.split('/').map((_, i, a) => a.slice(i).join('/'))];
206
+ const candidates = r.anchored ? [path] : [path, ...path.split('/').map((_, i, a) => a.slice(i).join('/'))];
172
207
  if (candidates.some((c) => r.re.test(c)))
173
208
  ignored = !r.negate;
174
209
  }
175
210
  return ignored;
176
211
  };
212
+ return (relPath) => {
213
+ // Walk ancestor dirs top-down: once a parent directory is excluded, the file
214
+ // stays excluded — a negation cannot re-include under an excluded parent
215
+ // (gitignore(5): "not possible to re-include a file if a parent directory of
216
+ // that file is excluded").
217
+ const parts = relPath.split('/').filter(Boolean);
218
+ let parentIgnored = false;
219
+ for (let i = 0; i < parts.length; i++) {
220
+ const isLast = i === parts.length - 1;
221
+ if (parentIgnored) {
222
+ if (isLast)
223
+ return true;
224
+ continue; // excluded ancestor carries down
225
+ }
226
+ const verdict = ruleVerdict(parts.slice(0, i + 1).join('/'));
227
+ if (isLast)
228
+ return verdict;
229
+ parentIgnored = verdict; // this directory prefix carries forward
230
+ }
231
+ return false;
232
+ };
177
233
  }
178
234
  //# sourceMappingURL=pack.js.map
@@ -26,6 +26,7 @@ export const RULES = [
26
26
  { id: 'anthropic-key', description: 'Anthropic API key', regex: /\bsk-ant-[0-9A-Za-z-]{16,}\b/g },
27
27
  { id: 'openai-key', description: 'OpenAI API key', regex: /\bsk-(?:proj-)?[0-9A-Za-z]{20,}\b/g },
28
28
  { id: 'google-api-key', description: 'Google API key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
29
+ { id: 'google-oauth-token', description: 'Google OAuth access token', regex: /\bya29\.[0-9A-Za-z_-]{20,}/g },
29
30
  { id: 'slack-token', description: 'Slack token', regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
30
31
  { id: 'slack-webhook', description: 'Slack incoming webhook', regex: /https:\/\/hooks\.slack\.com\/services\/T[0-9A-Za-z_]+\/B[0-9A-Za-z_]+\/[0-9A-Za-z_]+/g },
31
32
  { id: 'stripe-key', description: 'Stripe secret/restricted key', regex: /\b(?:sk|rk)_live_[0-9A-Za-z]{24,}\b/g },
@@ -33,9 +34,20 @@ export const RULES = [
33
34
  { id: 'twilio-key', description: 'Twilio API key SID', regex: /\bSK[0-9a-fA-F]{32}\b/g },
34
35
  { id: 'npm-token', description: 'npm access token', regex: /\bnpm_[0-9A-Za-z]{36}\b/g },
35
36
  { id: 'jwt', description: 'JSON Web Token', regex: /\beyJ[0-9A-Za-z_-]{10,}\.eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}\b/g },
37
+ // RFC 6750 Bearer credential. Last (most generic): specific token rules above
38
+ // claim their range first. 16+ b64token chars keeps prose ("Bearer document")
39
+ // from matching; only the token (group 1) is masked, not the `Bearer ` prefix.
40
+ { id: 'bearer-token', description: 'HTTP Bearer token (RFC 6750)', regex: /\bBearer\s+([A-Za-z0-9\-._~+/]{16,}=*)/g, group: 1 },
36
41
  ];
37
- /** Keyword-prefixed assignment, e.g. `api_key = "..."` — the value is group 1. */
38
- const ASSIGNMENT_RE = /(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
42
+ /**
43
+ * Keyword-prefixed assignment, e.g. `api_key = "..."` — the value is group 1.
44
+ * Keyword set mirrors gitleaks' reference `generic-api-key` rule (adds bare
45
+ * `key`, `credential`, `creds` so `PRIVATE_KEY=`/`ENCRYPTION_KEY=`/`DB_CREDENTIAL=`
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.
49
+ */
50
+ const ASSIGNMENT_RE = /(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
39
51
  /** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
40
52
  export function shannonEntropy(s) {
41
53
  if (!s)
@@ -21,6 +21,7 @@ export interface Component {
21
21
  content: string;
22
22
  };
23
23
  dev?: boolean;
24
+ optional?: boolean;
24
25
  }
25
26
  interface NpmLockEntry {
26
27
  version?: string;
@@ -53,6 +54,21 @@ export interface BomMeta {
53
54
  name: string;
54
55
  version: string;
55
56
  }
57
+ /**
58
+ * Is this npm `license` string an SPDX *expression* (compound), rather than a
59
+ * single license identifier? npm permits expressions like `(MIT OR Apache-2.0)`
60
+ * or `Apache-2.0 WITH LLVM-exception` for dual/multi-licensed packages. SPDX
61
+ * operators are uppercase and space-delimited, so a single ID (`MIT`,
62
+ * `BSD-3-Clause`, `CC-BY-SA-4.0`) never matches. Pure.
63
+ */
64
+ export declare function isSpdxExpression(license: string): boolean;
65
+ /**
66
+ * A CycloneDX `licenses[]` entry for a license string. CycloneDX requires
67
+ * `license.id` to be a single valid SPDX identifier, so an expression must use
68
+ * the sibling `expression` field instead — otherwise strict validators and
69
+ * scanners (Dependency-Track, Grype) reject or mis-parse the component. Pure.
70
+ */
71
+ export declare function cdxLicenseEntry(license: string): Record<string, unknown>;
56
72
  /** Build a CycloneDX 1.5 BOM object (no timestamp → deterministic). Pure. */
57
73
  export declare function buildCycloneDX(components: Component[], meta: BomMeta): Record<string, unknown>;
58
74
  /** Build a minimal SPDX 2.3 JSON document (no created timestamp → deterministic). Pure. */
@@ -77,11 +77,32 @@ export function componentsFromNpmLock(lock, opts = {}) {
77
77
  }
78
78
  if (entry.dev)
79
79
  comp.dev = true;
80
+ if (entry.optional)
81
+ comp.optional = true;
80
82
  out.push(comp);
81
83
  }
82
84
  out.sort((a, b) => (a.name === b.name ? a.version.localeCompare(b.version) : a.name.localeCompare(b.name)));
83
85
  return out;
84
86
  }
87
+ /**
88
+ * Is this npm `license` string an SPDX *expression* (compound), rather than a
89
+ * single license identifier? npm permits expressions like `(MIT OR Apache-2.0)`
90
+ * or `Apache-2.0 WITH LLVM-exception` for dual/multi-licensed packages. SPDX
91
+ * operators are uppercase and space-delimited, so a single ID (`MIT`,
92
+ * `BSD-3-Clause`, `CC-BY-SA-4.0`) never matches. Pure.
93
+ */
94
+ export function isSpdxExpression(license) {
95
+ return / (OR|AND|WITH) /.test(license) || license.includes('(');
96
+ }
97
+ /**
98
+ * A CycloneDX `licenses[]` entry for a license string. CycloneDX requires
99
+ * `license.id` to be a single valid SPDX identifier, so an expression must use
100
+ * the sibling `expression` field instead — otherwise strict validators and
101
+ * scanners (Dependency-Track, Grype) reject or mis-parse the component. Pure.
102
+ */
103
+ export function cdxLicenseEntry(license) {
104
+ return isSpdxExpression(license) ? { expression: license } : { license: { id: license } };
105
+ }
85
106
  /** Build a CycloneDX 1.5 BOM object (no timestamp → deterministic). Pure. */
86
107
  export function buildCycloneDX(components, meta) {
87
108
  return {
@@ -93,10 +114,16 @@ export function buildCycloneDX(components, meta) {
93
114
  },
94
115
  components: components.map((c) => {
95
116
  const comp = { type: 'library', name: c.name, version: c.version, purl: c.purl };
117
+ // CycloneDX 1.5 `scope`: dev-only deps are `excluded` (not shipped),
118
+ // optional deps are `optional`; a required runtime dep omits it (implicit
119
+ // `required`), keeping the common case clean. dev wins over optional.
120
+ const scope = c.dev ? 'excluded' : c.optional ? 'optional' : undefined;
121
+ if (scope)
122
+ comp.scope = scope;
96
123
  if (c.hash)
97
124
  comp.hashes = [{ alg: c.hash.alg, content: c.hash.content }];
98
125
  if (c.license)
99
- comp.licenses = [{ license: { id: c.license } }];
126
+ comp.licenses = [cdxLicenseEntry(c.license)];
100
127
  return comp;
101
128
  }),
102
129
  };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * testreport.ts — parse JUnit-XML / TAP test results into a compact failure
3
+ * digest. The missing front-half of the test→fix loop: an agent runs the suite,
4
+ * points this at the results, and gets exactly the failing test names + file:line
5
+ * + assertion message — instead of re-reading hundreds of log lines — then feeds
6
+ * that into `repair`. Inspired by dorny/test-reporter, action-junit-report,
7
+ * gotestsum.
8
+ *
9
+ * Pure + deterministic: a results string in, a normalized summary out. The file
10
+ * read / glob lives in ../commands/testreport.ts, so this is fixture-testable.
11
+ * No XML dependency — well-formed JUnit escapes <>& in attribute values, so a
12
+ * focused tokenizer over <testcase>/<failure> is safe.
13
+ */
14
+ export type TestStatus = 'passed' | 'failed' | 'skipped';
15
+ export type TestFormat = 'junit' | 'tap';
16
+ export interface TestFailure {
17
+ suite: string;
18
+ name: string;
19
+ file?: string;
20
+ line?: number;
21
+ message?: string;
22
+ type?: string;
23
+ }
24
+ export interface TestSummary {
25
+ passed: number;
26
+ failed: number;
27
+ skipped: number;
28
+ total: number;
29
+ durationMs: number;
30
+ failures: TestFailure[];
31
+ /**
32
+ * TAP `# TODO` tests — "not expected to succeed" per the TAP spec, so a
33
+ * `not ok … # TODO` is a KNOWN-incomplete stub, counted here as a success,
34
+ * never a failure. Absent (0) for JUnit and TODO-free TAP.
35
+ */
36
+ todo?: number;
37
+ /**
38
+ * True if a TAP `Bail out!` line aborted the run. The counts are then
39
+ * INCOMPLETE — the suite stopped early, so a "0 failed" here does NOT mean
40
+ * the suite passed. Callers/CI must treat this as a failure.
41
+ */
42
+ bailedOut?: boolean;
43
+ /** Optional reason text following `Bail out!`. */
44
+ bailReason?: string;
45
+ }
46
+ /** Pull a `file:line` out of a stack-trace / assertion message. Pure. */
47
+ export declare function extractFileLine(text: string): {
48
+ file?: string;
49
+ line?: number;
50
+ };
51
+ /** Parse JUnit XML (testsuites/testsuite/testcase). Pure. */
52
+ export declare function parseJUnit(xml: string): TestSummary;
53
+ /** Parse TAP (Test Anything Protocol) output. Pure. */
54
+ export declare function parseTAP(text: string): TestSummary;
55
+ /** Sniff the format from a path extension, then content. Pure. */
56
+ export declare function detectFormat(content: string, path?: string): TestFormat;
57
+ /** Parse by explicit or detected format. Pure. */
58
+ export declare function parseTestReport(content: string, format: TestFormat): TestSummary;
59
+ /** Merge several summaries (multi-file globs). Pure. */
60
+ export declare function mergeSummaries(list: TestSummary[]): TestSummary;
61
+ /** Human-readable digest. Pure. */
62
+ export declare function formatSummary(s: TestSummary, opts?: {
63
+ top?: number;
64
+ }): string;
65
+ //# sourceMappingURL=testreport.d.ts.map
@@ -0,0 +1,246 @@
1
+ /**
2
+ * testreport.ts — parse JUnit-XML / TAP test results into a compact failure
3
+ * digest. The missing front-half of the test→fix loop: an agent runs the suite,
4
+ * points this at the results, and gets exactly the failing test names + file:line
5
+ * + assertion message — instead of re-reading hundreds of log lines — then feeds
6
+ * that into `repair`. Inspired by dorny/test-reporter, action-junit-report,
7
+ * gotestsum.
8
+ *
9
+ * Pure + deterministic: a results string in, a normalized summary out. The file
10
+ * read / glob lives in ../commands/testreport.ts, so this is fixture-testable.
11
+ * No XML dependency — well-formed JUnit escapes <>& in attribute values, so a
12
+ * focused tokenizer over <testcase>/<failure> is safe.
13
+ */
14
+ /** Decode the five predefined XML entities. Pure. */
15
+ function decodeXml(s) {
16
+ return s
17
+ .replace(/&lt;/g, '<')
18
+ .replace(/&gt;/g, '>')
19
+ .replace(/&quot;/g, '"')
20
+ .replace(/&apos;/g, "'")
21
+ .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(parseInt(d, 10)))
22
+ .replace(/&amp;/g, '&'); // last, so &amp;lt; → &lt; not <
23
+ }
24
+ /**
25
+ * Decode an element's text content, honoring CDATA sections. Text OUTSIDE
26
+ * `<![CDATA[ … ]]>` is entity-decoded; text INSIDE is taken literally (per the
27
+ * XML spec, CDATA content is never entity-expanded). Maven Surefire/Failsafe
28
+ * wrap `<failure>`/`<error>` stack traces in CDATA, so without this the raw
29
+ * `<![CDATA[`/`]]>` markers leak into the failure message. Pure.
30
+ */
31
+ function decodeXmlContent(s) {
32
+ const OPEN = '<![CDATA[';
33
+ const CLOSE = ']]>';
34
+ if (!s.includes(OPEN))
35
+ return decodeXml(s);
36
+ let out = '';
37
+ let i = 0;
38
+ while (i < s.length) {
39
+ const start = s.indexOf(OPEN, i);
40
+ if (start < 0) {
41
+ out += decodeXml(s.slice(i));
42
+ break;
43
+ }
44
+ out += decodeXml(s.slice(i, start));
45
+ const end = s.indexOf(CLOSE, start + OPEN.length);
46
+ if (end < 0) {
47
+ out += s.slice(start + OPEN.length);
48
+ break;
49
+ } // unterminated → rest is literal
50
+ out += s.slice(start + OPEN.length, end);
51
+ i = end + CLOSE.length;
52
+ }
53
+ return out;
54
+ }
55
+ /** Extract key="value" / key='value' attributes from a tag's attribute text. Pure. */
56
+ function parseAttrs(attrText) {
57
+ const out = {};
58
+ const re = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
59
+ let m;
60
+ while ((m = re.exec(attrText)) !== null) {
61
+ out[m[1]] = decodeXml(m[3] ?? m[4] ?? '');
62
+ }
63
+ return out;
64
+ }
65
+ /** Pull a `file:line` out of a stack-trace / assertion message. Pure. */
66
+ export function extractFileLine(text) {
67
+ if (!text)
68
+ return {};
69
+ // Prefer a parenthesised frame `(path:line:col)`, else a bare `path:line`.
70
+ const paren = text.match(/\(([^()\s]+?):(\d+):(?:\d+)\)/);
71
+ if (paren)
72
+ return { file: paren[1], line: parseInt(paren[2], 10) };
73
+ const bare = text.match(/([\w./\\-]+\.[a-zA-Z]{1,5}):(\d+)(?::\d+)?/);
74
+ if (bare)
75
+ return { file: bare[1], line: parseInt(bare[2], 10) };
76
+ return {};
77
+ }
78
+ /** Parse JUnit XML (testsuites/testsuite/testcase). Pure. */
79
+ export function parseJUnit(xml) {
80
+ const failures = [];
81
+ let passed = 0, failed = 0, skipped = 0, durationMs = 0;
82
+ const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
83
+ let c;
84
+ while ((c = caseRe.exec(xml)) !== null) {
85
+ const attrs = parseAttrs(c[1]);
86
+ const body = c[2] ?? '';
87
+ const name = attrs.name ?? '(unnamed)';
88
+ const suite = attrs.classname ?? attrs.suite ?? '';
89
+ if (attrs.time)
90
+ durationMs += Math.round(parseFloat(attrs.time) * 1000) || 0;
91
+ const fail = body.match(/<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/);
92
+ const isSkipped = /<skipped\b[^>]*\/?>/.test(body);
93
+ if (fail) {
94
+ failed++;
95
+ const fAttrs = parseAttrs(fail[2]);
96
+ const inner = decodeXmlContent(fail[3] ?? '').trim();
97
+ const message = fAttrs.message || inner.split('\n')[0] || undefined;
98
+ // Prefer explicit file/line attrs (pytest, some emitters), else sniff the trace.
99
+ const loc = attrs.file
100
+ ? { file: attrs.file, line: attrs.line ? parseInt(attrs.line, 10) : undefined }
101
+ : extractFileLine(inner || fAttrs.message || '');
102
+ failures.push({ suite, name, message, type: fAttrs.type, ...loc });
103
+ }
104
+ else if (isSkipped) {
105
+ skipped++;
106
+ }
107
+ else {
108
+ passed++;
109
+ }
110
+ }
111
+ return { passed, failed, skipped, total: passed + failed + skipped, durationMs, failures };
112
+ }
113
+ /** Parse TAP (Test Anything Protocol) output. Pure. */
114
+ export function parseTAP(text) {
115
+ const failures = [];
116
+ let passed = 0, failed = 0, skipped = 0, todo = 0;
117
+ let bailedOut = false;
118
+ let bailReason;
119
+ const lines = text.split('\n');
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const line = lines[i];
122
+ // `Bail out!` (column 1, per the TAP spec) aborts the run — stop parsing;
123
+ // anything after it is not a valid result.
124
+ const bail = line.match(/^Bail out!(?:\s+(.*))?$/);
125
+ if (bail) {
126
+ bailedOut = true;
127
+ bailReason = bail[1]?.trim() || undefined;
128
+ break;
129
+ }
130
+ const m = line.match(/^(ok|not ok)\b\s*(\d+)?\s*-?\s*(.*)$/);
131
+ if (!m)
132
+ continue;
133
+ const ok = m[1] === 'ok';
134
+ let desc = m[3].trim();
135
+ const directive = desc.match(/#\s*(SKIP|TODO)\b(.*)$/i);
136
+ if (directive)
137
+ desc = desc.slice(0, directive.index).trim();
138
+ if (directive && /skip/i.test(directive[1])) {
139
+ skipped++;
140
+ continue;
141
+ }
142
+ // TODO tests are "not expected to succeed" — a `not ok … # TODO` is a known
143
+ // stub, not a real failure (TAP spec). Count it as todo, never a failure.
144
+ if (directive && /todo/i.test(directive[1])) {
145
+ todo++;
146
+ continue;
147
+ }
148
+ if (ok) {
149
+ passed++;
150
+ }
151
+ else {
152
+ failed++;
153
+ // Look ahead for an indented YAML diagnostic block for message/file/line.
154
+ let message;
155
+ let file;
156
+ let lineNo;
157
+ for (let j = i + 1; j < lines.length; j++) {
158
+ const d = lines[j];
159
+ if (!/^\s/.test(d) && d.trim() !== '')
160
+ break; // dedented → block ended
161
+ const mm = d.match(/^\s*message:\s*(.+)$/);
162
+ if (mm)
163
+ message = mm[1].replace(/^["']|["']$/g, '').trim();
164
+ const fm = d.match(/^\s*(?:file|at):\s*(.+)$/);
165
+ if (fm) {
166
+ const fl = extractFileLine(fm[1]);
167
+ if (fl.file) {
168
+ file = fl.file;
169
+ lineNo = fl.line;
170
+ }
171
+ }
172
+ const lm = d.match(/^\s*line:\s*(\d+)/);
173
+ if (lm)
174
+ lineNo = parseInt(lm[1], 10);
175
+ if (/^\s*\.\.\.\s*$/.test(d))
176
+ break; // end of YAML block
177
+ }
178
+ failures.push({ suite: '', name: desc || '(unnamed)', message, file, line: lineNo });
179
+ }
180
+ }
181
+ return { passed, failed, skipped, total: passed + failed + skipped + todo, durationMs: 0, failures, ...(todo && { todo }), ...(bailedOut && { bailedOut, bailReason }) };
182
+ }
183
+ /** Sniff the format from a path extension, then content. Pure. */
184
+ export function detectFormat(content, path) {
185
+ if (path) {
186
+ if (/\.tap$/i.test(path))
187
+ return 'tap';
188
+ if (/\.xml$/i.test(path))
189
+ return 'junit';
190
+ }
191
+ if (/<testsuite|<testcase/i.test(content))
192
+ return 'junit';
193
+ if (/^\s*(TAP version|1\.\.\d|ok\b|not ok\b)/m.test(content))
194
+ return 'tap';
195
+ return 'junit';
196
+ }
197
+ /** Parse by explicit or detected format. Pure. */
198
+ export function parseTestReport(content, format) {
199
+ return format === 'tap' ? parseTAP(content) : parseJUnit(content);
200
+ }
201
+ /** Merge several summaries (multi-file globs). Pure. */
202
+ export function mergeSummaries(list) {
203
+ const merged = list.reduce((acc, s) => ({
204
+ passed: acc.passed + s.passed,
205
+ failed: acc.failed + s.failed,
206
+ skipped: acc.skipped + s.skipped,
207
+ total: acc.total + s.total,
208
+ durationMs: acc.durationMs + s.durationMs,
209
+ failures: acc.failures.concat(s.failures),
210
+ }), { passed: 0, failed: 0, skipped: 0, total: 0, durationMs: 0, failures: [] });
211
+ const todoSum = list.reduce((n, s) => n + (s.todo ?? 0), 0);
212
+ if (todoSum > 0)
213
+ merged.todo = todoSum;
214
+ // Any bailed file taints the whole run; keep the first reason seen.
215
+ const bailed = list.find((s) => s.bailedOut);
216
+ if (bailed) {
217
+ merged.bailedOut = true;
218
+ merged.bailReason = bailed.bailReason;
219
+ }
220
+ return merged;
221
+ }
222
+ /** Human-readable digest. Pure. */
223
+ export function formatSummary(s, opts = {}) {
224
+ const todoSeg = s.todo ? ` · ${s.todo} todo` : '';
225
+ const head = `${s.passed} passed · ${s.failed} failed · ${s.skipped} skipped${todoSeg} (${s.total} total, ${s.durationMs}ms)`;
226
+ const bailLine = s.bailedOut
227
+ ? `⚠ suite ABORTED (Bail out!${s.bailReason ? `: ${s.bailReason}` : ''}) — results incomplete`
228
+ : '';
229
+ if (s.failures.length === 0) {
230
+ if (s.bailedOut)
231
+ return `${bailLine}\n${head}`;
232
+ return head + (s.failed === 0 ? ' ✓' : '');
233
+ }
234
+ const shown = opts.top && opts.top > 0 ? s.failures.slice(0, opts.top) : s.failures;
235
+ const lines = s.bailedOut ? [bailLine, head, ''] : [head, ''];
236
+ for (const f of shown) {
237
+ const where = f.file ? `${f.file}${f.line ? `:${f.line}` : ''}` : '';
238
+ lines.push(`✗ ${f.suite ? f.suite + ' › ' : ''}${f.name}${where ? ` (${where})` : ''}`);
239
+ if (f.message)
240
+ lines.push(` ${f.message.split('\n')[0]}`);
241
+ }
242
+ if (shown.length < s.failures.length)
243
+ lines.push(`… and ${s.failures.length - shown.length} more`);
244
+ return lines.join('\n');
245
+ }
246
+ //# sourceMappingURL=testreport.js.map
@@ -16,7 +16,10 @@
16
16
  export interface TranscriptModelPrice {
17
17
  in: number;
18
18
  out: number;
19
+ /** 5-minute-TTL cache write (1.25× base input) */
19
20
  cacheWrite: number;
21
+ /** 1-hour-TTL cache write (2× base input) */
22
+ cacheWrite1h: number;
20
23
  cacheRead: number;
21
24
  }
22
25
  /**
@@ -32,7 +35,10 @@ export declare function resolveTranscriptPrice(rawModelId: string): TranscriptMo
32
35
  export interface TokenBundle {
33
36
  inputTokens: number;
34
37
  outputTokens: number;
38
+ /** TOTAL cache-write tokens (5-min + 1-hour). */
35
39
  cacheWriteTokens: number;
40
+ /** The 1-hour-TTL SUBSET of cacheWriteTokens (≤ cacheWriteTokens). Default 0 → all writes priced at the 5-min rate. */
41
+ cacheWrite1hTokens?: number;
36
42
  cacheReadTokens: number;
37
43
  }
38
44
  /** USD for one response at the given rates. */
@@ -20,14 +20,14 @@
20
20
  * publishes rates — absent means "unpriced", never "guessed".
21
21
  */
22
22
  const PRICE_FAMILIES = {
23
- 'claude-opus-4': { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 },
24
- 'claude-sonnet-4': { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 },
25
- 'claude-haiku-4': { in: 1, out: 5, cacheWrite: 1.25, cacheRead: 0.1 },
26
- 'claude-3-7-sonnet': { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 },
27
- 'claude-3-5-sonnet': { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 },
28
- 'claude-3-5-haiku': { in: 0.8, out: 4, cacheWrite: 1, cacheRead: 0.08 },
29
- 'claude-3-opus': { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 },
30
- 'claude-3-haiku': { in: 0.25, out: 1.25, cacheWrite: 0.3, cacheRead: 0.03 },
23
+ 'claude-opus-4': { in: 15, out: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
24
+ 'claude-sonnet-4': { in: 3, out: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
25
+ 'claude-haiku-4': { in: 1, out: 5, cacheWrite: 1.25, cacheWrite1h: 2, cacheRead: 0.1 },
26
+ 'claude-3-7-sonnet': { in: 3, out: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
27
+ 'claude-3-5-sonnet': { in: 3, out: 15, cacheWrite: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
28
+ 'claude-3-5-haiku': { in: 0.8, out: 4, cacheWrite: 1, cacheWrite1h: 1.6, cacheRead: 0.08 },
29
+ 'claude-3-opus': { in: 15, out: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
30
+ 'claude-3-haiku': { in: 0.25, out: 1.25, cacheWrite: 0.3, cacheWrite1h: 0.5, cacheRead: 0.03 },
31
31
  };
32
32
  /**
33
33
  * Reduce a transcript/gateway model id to the bare Anthropic id:
@@ -62,9 +62,12 @@ export function resolveTranscriptPrice(rawModelId) {
62
62
  }
63
63
  /** USD for one response at the given rates. */
64
64
  export function transcriptCostUsd(price, t) {
65
+ const cacheWrite1h = t.cacheWrite1hTokens ?? 0;
66
+ const cacheWrite5m = Math.max(0, t.cacheWriteTokens - cacheWrite1h);
65
67
  return ((t.inputTokens * price.in +
66
68
  t.outputTokens * price.out +
67
- t.cacheWriteTokens * price.cacheWrite +
69
+ cacheWrite5m * price.cacheWrite +
70
+ cacheWrite1h * price.cacheWrite1h +
68
71
  t.cacheReadTokens * price.cacheRead) / 1_000_000);
69
72
  }
70
73
  //# sourceMappingURL=claude-pricing.js.map
@@ -177,6 +177,9 @@ function toUsageEvent(line, project, seen, unpriced) {
177
177
  cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
178
178
  cacheReadTokens: usage.cache_read_input_tokens ?? 0,
179
179
  };
180
+ // 1-hour-TTL cache writes cost 2× base input, not the 1.25× 5-min rate. Recent
181
+ // transcripts split them in `cache_creation`; older ones don't (→ all 5-min).
182
+ const cacheWrite1hTokens = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
180
183
  let costUsd;
181
184
  let costSource;
182
185
  const price = resolveTranscriptPrice(model);
@@ -185,7 +188,7 @@ function toUsageEvent(line, project, seen, unpriced) {
185
188
  costSource = 'transcript';
186
189
  }
187
190
  else if (price) {
188
- costUsd = transcriptCostUsd(price, tokens);
191
+ costUsd = transcriptCostUsd(price, { ...tokens, cacheWrite1hTokens });
189
192
  costSource = 'computed';
190
193
  }
191
194
  else {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmdo/cli",
3
- "version": "1.18.0",
3
+ "version": "1.27.0",
4
4
  "type": "module",
5
5
  "description": "Swarmdo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",