@sweny-ai/core 0.1.205 → 0.1.207

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.
@@ -201,18 +201,80 @@ export function recordRun(record, cwd = process.cwd()) {
201
201
  pruneRuns(cwd);
202
202
  return true;
203
203
  }
204
- function isRecord(v) {
205
- const r = v;
206
- return (!!r &&
207
- typeof r === "object" &&
208
- r.schema_version === RUN_HISTORY_SCHEMA_VERSION &&
209
- typeof r.run_id === "string" &&
210
- typeof r.workflow_id === "string" &&
211
- typeof r.started_at === "string" &&
212
- Array.isArray(r.nodes) &&
213
- Array.isArray(r.routes) &&
214
- !!r.totals &&
215
- typeof r.totals === "object");
204
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
205
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
206
+ const numOrNull = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
207
+ const RUNGS = new Set(["expr", "decider", "agent"]);
208
+ /**
209
+ * A typed, allow-listed copy of a stored record, or null when it is not a run record. History is
210
+ * agent-writable, so a record is never passed through: only the documented fields are copied, numbers
211
+ * are made finite, and malformed node, route, harness, degraded and spend entries are dropped. Strings
212
+ * are kept as read (terminal safety is the printer's job, see `printableRun`).
213
+ */
214
+ function parseRecord(v) {
215
+ if (!isObj(v))
216
+ return null;
217
+ if (v.schema_version !== RUN_HISTORY_SCHEMA_VERSION ||
218
+ typeof v.run_id !== "string" ||
219
+ typeof v.workflow_id !== "string" ||
220
+ typeof v.started_at !== "string" ||
221
+ typeof v.status !== "string" ||
222
+ !Array.isArray(v.nodes) ||
223
+ !Array.isArray(v.routes) ||
224
+ !isObj(v.totals)) {
225
+ return null;
226
+ }
227
+ const t = v.totals;
228
+ const out = {
229
+ schema_version: RUN_HISTORY_SCHEMA_VERSION,
230
+ run_id: v.run_id,
231
+ workflow_id: v.workflow_id,
232
+ workflow_hash: typeof v.workflow_hash === "string" ? v.workflow_hash : "",
233
+ started_at: v.started_at,
234
+ duration_ms: num(v.duration_ms),
235
+ status: v.status,
236
+ nodes: v.nodes
237
+ .filter((n) => isObj(n) && typeof n.id === "string" && typeof n.status === "string")
238
+ .map((n) => ({
239
+ id: n.id,
240
+ status: n.status,
241
+ duration_ms: num(n.duration_ms),
242
+ tool_calls: num(n.tool_calls),
243
+ tokens: numOrNull(n.tokens),
244
+ cost_usd: numOrNull(n.cost_usd),
245
+ retries: num(n.retries),
246
+ })),
247
+ routes: v.routes
248
+ .filter((e) => isObj(e) && typeof e.from === "string" && typeof e.to === "string")
249
+ .map((e) => ({
250
+ from: e.from,
251
+ to: e.to,
252
+ ...(typeof e.rung === "string" && RUNGS.has(e.rung) ? { rung: e.rung } : {}),
253
+ })),
254
+ totals: {
255
+ nodes_total: num(t.nodes_total),
256
+ nodes_ok: num(t.nodes_ok),
257
+ nodes_skipped: num(t.nodes_skipped),
258
+ tool_calls: num(t.tool_calls),
259
+ tokens: numOrNull(t.tokens),
260
+ cost_usd: numOrNull(t.cost_usd),
261
+ },
262
+ };
263
+ if (isObj(v.harness) && typeof v.harness.id === "string" && typeof v.harness.version === "string") {
264
+ out.harness = { id: v.harness.id, version: v.harness.version };
265
+ }
266
+ if (Array.isArray(v.degraded))
267
+ out.degraded = v.degraded.filter((d) => typeof d === "string");
268
+ if (isObj(v.spend)) {
269
+ const spend = {};
270
+ for (const k of SPEND_KINDS) {
271
+ const e = Object.hasOwn(v.spend, k) ? v.spend[k] : undefined;
272
+ if (isObj(e))
273
+ spend[k] = { tokens: num(e.tokens), cost_usd: num(e.cost_usd), calls: num(e.calls) };
274
+ }
275
+ out.spend = spend;
276
+ }
277
+ return out;
216
278
  }
217
279
  /**
218
280
  * All readable records, newest first. Corrupt files and unknown schema versions
@@ -238,8 +300,9 @@ export function listRuns(cwd = process.cwd()) {
238
300
  if (!st.isFile() || st.size > RECORD_READ_MAX_BYTES)
239
301
  continue;
240
302
  const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
241
- if (isRecord(parsed) && RUN_ID_RE.test(parsed.run_id) && f === `${parsed.run_id}.json`)
242
- out.push(parsed);
303
+ const rec = parseRecord(parsed);
304
+ if (rec && RUN_ID_RE.test(rec.run_id) && f === `${rec.run_id}.json`)
305
+ out.push(rec);
243
306
  }
244
307
  catch {
245
308
  // corrupt record: skip
@@ -10,12 +10,15 @@ import { type RunRecord } from "./run-history.js";
10
10
  import { type AnswersComparison } from "./run-answers.js";
11
11
  export declare function formatAge(startedAt: string, nowMs?: number): string;
12
12
  /**
13
- * A copy of a history record that is safe to print. `.sweny/runs/` is writable by an agent, so every
14
- * string read from a record goes through {@link sanitizeTerminal} (control sequences stripped, length
15
- * capped) and every number is coerced to a finite number. Only for display: matching and `--json`
16
- * use the record as read.
13
+ * A copy of a history record that is safe to print and to export. `.sweny/runs/` is writable by an
14
+ * agent, so the copy is built from an explicit allow-list of typed fields, never by spreading the
15
+ * record: unknown keys are dropped at every level, every string goes through {@link sanitizeTerminal}
16
+ * (control sequences stripped, length capped), every number is coerced finite, and malformed node and
17
+ * route entries are skipped. The table, the diff and every `--json` output use it.
17
18
  */
18
19
  export declare function printableRun(r: RunRecord): RunRecord;
20
+ /** `sweny runs --json`: the validated records, never the raw ones. */
21
+ export declare function formatRunsJson(records: RunRecord[]): string;
19
22
  export declare function formatRunsTable(records: RunRecord[], color: boolean, nowMs?: number, unicode?: boolean): string;
20
23
  export interface RunDiffOptions {
21
24
  /** Answer comparison for the pair; when set the diff leads with it and ends on its summary line. */
package/dist/cli/runs.js CHANGED
@@ -51,23 +51,31 @@ function pad(s, w, right = false) {
51
51
  // ── History records are untrusted ───────────────────────────────
52
52
  const finite = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
53
53
  const finiteOrNull = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
54
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
55
+ const RUNG = new Set(["expr", "decider", "agent"]);
54
56
  /**
55
- * A copy of a history record that is safe to print. `.sweny/runs/` is writable by an agent, so every
56
- * string read from a record goes through {@link sanitizeTerminal} (control sequences stripped, length
57
- * capped) and every number is coerced to a finite number. Only for display: matching and `--json`
58
- * use the record as read.
57
+ * A copy of a history record that is safe to print and to export. `.sweny/runs/` is writable by an
58
+ * agent, so the copy is built from an explicit allow-list of typed fields, never by spreading the
59
+ * record: unknown keys are dropped at every level, every string goes through {@link sanitizeTerminal}
60
+ * (control sequences stripped, length capped), every number is coerced finite, and malformed node and
61
+ * route entries are skipped. The table, the diff and every `--json` output use it.
59
62
  */
60
63
  export function printableRun(r) {
61
64
  const t = (v, max = 64) => sanitizeTerminal(v, max);
62
- return {
63
- ...r,
64
- run_id: t(r.run_id),
65
- workflow_id: t(r.workflow_id),
66
- workflow_hash: t(r.workflow_hash),
67
- started_at: t(r.started_at),
68
- status: t(r.status, 16),
69
- duration_ms: finite(r.duration_ms),
70
- nodes: r.nodes.map((n) => ({
65
+ const rec = r;
66
+ const list = (v) => (Array.isArray(v) ? v : []);
67
+ const totals = isObj(rec.totals) ? rec.totals : {};
68
+ const out = {
69
+ schema_version: finite(rec.schema_version),
70
+ run_id: t(rec.run_id),
71
+ workflow_id: t(rec.workflow_id),
72
+ workflow_hash: t(rec.workflow_hash),
73
+ started_at: t(rec.started_at),
74
+ duration_ms: finite(rec.duration_ms),
75
+ status: t(rec.status, 16),
76
+ nodes: list(rec.nodes)
77
+ .filter(isObj)
78
+ .map((n) => ({
71
79
  id: t(n.id),
72
80
  status: t(n.status, 16),
73
81
  duration_ms: finite(n.duration_ms),
@@ -76,17 +84,34 @@ export function printableRun(r) {
76
84
  cost_usd: finiteOrNull(n.cost_usd),
77
85
  retries: finite(n.retries),
78
86
  })),
79
- routes: r.routes.map((e) => ({ from: t(e.from), to: t(e.to) })),
87
+ routes: list(rec.routes)
88
+ .filter((e) => isObj(e) && typeof e.from === "string" && typeof e.to === "string")
89
+ .map((e) => ({
90
+ from: t(e.from),
91
+ to: t(e.to),
92
+ ...(typeof e.rung === "string" && RUNG.has(e.rung) ? { rung: e.rung } : {}),
93
+ })),
80
94
  totals: {
81
- nodes_total: finite(r.totals.nodes_total),
82
- nodes_ok: finite(r.totals.nodes_ok),
83
- nodes_skipped: finite(r.totals.nodes_skipped),
84
- tool_calls: finite(r.totals.tool_calls),
85
- tokens: finiteOrNull(r.totals.tokens),
86
- cost_usd: finiteOrNull(r.totals.cost_usd),
95
+ nodes_total: finite(totals.nodes_total),
96
+ nodes_ok: finite(totals.nodes_ok),
97
+ nodes_skipped: finite(totals.nodes_skipped),
98
+ tool_calls: finite(totals.tool_calls),
99
+ tokens: finiteOrNull(totals.tokens),
100
+ cost_usd: finiteOrNull(totals.cost_usd),
87
101
  },
88
- ...(r.spend && typeof r.spend === "object" ? { spend: printableSpend(r.spend) } : {}),
89
102
  };
103
+ if (isObj(rec.harness))
104
+ out.harness = { id: t(rec.harness.id), version: t(rec.harness.version) };
105
+ if (Array.isArray(rec.degraded)) {
106
+ out.degraded = rec.degraded.filter((d) => typeof d === "string").map((d) => t(d));
107
+ }
108
+ if (isObj(rec.spend))
109
+ out.spend = printableSpend(rec.spend);
110
+ return out;
111
+ }
112
+ /** `sweny runs --json`: the validated records, never the raw ones. */
113
+ export function formatRunsJson(records) {
114
+ return JSON.stringify(records.map(printableRun), null, 2) + "\n";
90
115
  }
91
116
  /** The record's split, or its totals as all node spend when it has none. */
92
117
  function splitOf(r) {
@@ -432,7 +457,8 @@ export function runRunsDiff(refs, opts, io) {
432
457
  const { a, b } = picked;
433
458
  const answers = compareAnswers(readRunAnswers(a.run_id, io.cwd), readRunAnswers(b.run_id, io.cwd));
434
459
  if (opts.json) {
435
- io.stdout.write(JSON.stringify({ schema_version: 1, a, b, answers }, null, 2) + "\n");
460
+ const body = { schema_version: 1, a: printableRun(a), b: printableRun(b), answers };
461
+ io.stdout.write(JSON.stringify(body, null, 2) + "\n");
436
462
  return 0;
437
463
  }
438
464
  io.stdout.write("\n" + formatRunDiff(a, b, colorEnabled(io.stdout, env), { answers, unicode: supportsUnicode(env) }) + "\n");
@@ -458,7 +484,7 @@ export function registerRunsCommand(program) {
458
484
  runs = runs.filter((r) => r.workflow_id === opts.workflow);
459
485
  runs = runs.slice(0, limit);
460
486
  if (opts.json) {
461
- process.stdout.write(JSON.stringify(runs, null, 2) + "\n");
487
+ process.stdout.write(formatRunsJson(runs));
462
488
  return;
463
489
  }
464
490
  process.stdout.write("\n" + formatRunsTable(runs, colorEnabled(process.stdout), Date.now(), supportsUnicode()) + "\n");
@@ -528,12 +528,19 @@ export class ClaudeCodeHarness {
528
528
  // `sweny tool-bridge` stdio shim instead, so no in-process server is built.
529
529
  const useBridge = this.toolBridge && tools.length > 0;
530
530
  const sdkTools = useBridge ? [] : tools.map((t) => coreToolToSdkTool(t, this.defaultContext));
531
- // Create in-process MCP server
531
+ // Create in-process MCP server. `alwaysLoad`: Claude Code defers every MCP
532
+ // server's tools behind ToolSearch by default and starts MCP servers
533
+ // without waiting, so a node's skill tools were not on the model's first
534
+ // turn and it could answer without them (dogfood run 42884113267: the
535
+ // pr-risk-review scope node called only StructuredOutput). These are the
536
+ // few tools the node was given to do its job; they load up front, and the
537
+ // query waits for the server before the first turn.
532
538
  const mcpServer = useBridge
533
539
  ? undefined
534
540
  : createSdkMcpServer({
535
541
  name: "sweny-core",
536
542
  tools: sdkTools,
543
+ alwaysLoad: true,
537
544
  });
538
545
  let bridge;
539
546
  // Build prompt. Context (workflow input such as issues/alerts, plus prior
@@ -565,6 +572,14 @@ export class ClaudeCodeHarness {
565
572
  const allMcpServers = mcpWithheld(!!readOnly, agentAccess)
566
573
  ? {}
567
574
  : { ...this.defaultMcpServers, ...opts.mcpServers, ...this.mcpServers };
575
+ // A server the node's own skills declare (stdio or http) carries tools the
576
+ // node's checks require, so it loads up front like sweny-core below
577
+ // (Claude Code otherwise defers them behind ToolSearch). An explicit
578
+ // `alwaysLoad` in the server config wins.
579
+ for (const name of Object.keys(opts.mcpServers ?? {})) {
580
+ if (allMcpServers[name])
581
+ allMcpServers[name] = { alwaysLoad: true, ...allMcpServers[name] };
582
+ }
568
583
  if (useBridge) {
569
584
  // Only `tools` (already allow/deny and dry-run filtered by the
570
585
  // executor) are exposed. Under readOnly it is still the only MCP
@@ -575,7 +590,8 @@ export class ClaudeCodeHarness {
575
590
  logger: this.logger,
576
591
  ...(this.toolBridgeShim ? { shimCommand: this.toolBridgeShim } : {}),
577
592
  });
578
- allMcpServers["sweny-core"] = bridge.mcpServer;
593
+ // Same reason as the in-process server: the node's tools load up front.
594
+ allMcpServers["sweny-core"] = { ...bridge.mcpServer, alwaysLoad: true };
579
595
  }
580
596
  else if (sdkTools.length > 0) {
581
597
  allMcpServers["sweny-core"] = mcpServer;
@@ -618,6 +634,19 @@ export class ClaudeCodeHarness {
618
634
  },
619
635
  });
620
636
  for await (const message of stream) {
637
+ if (message.type === "system" && message.subtype === "init") {
638
+ // What the agent was actually given: the tool list (--verbose) and,
639
+ // loudly, a node tool server that did not come up.
640
+ const init = message;
641
+ if (Array.isArray(init.tools))
642
+ this.logger.debug(` agent tools: ${init.tools.join(", ")}`);
643
+ const core = Array.isArray(init.mcp_servers)
644
+ ? init.mcp_servers.find((s) => s?.name === "sweny-core")
645
+ : undefined;
646
+ if (allMcpServers["sweny-core"] && core && core.status !== "connected") {
647
+ this.logger.warn(` sweny-core tool server is ${core.status} at start; the node's skill tools may be missing`);
648
+ }
649
+ }
621
650
  if (message.type === "tool_progress") {
622
651
  const tp = message;
623
652
  if (tp.tool_name && typeof tp.elapsed_time_seconds === "number") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.205",
3
+ "version": "0.1.207",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"