@sweny-ai/core 0.1.208 → 0.1.210

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.
@@ -18,6 +18,12 @@ import { type TryFixture } from "./try.js";
18
18
  export declare const RECORD_COLUMNS = 80;
19
19
  /** Lines longer than this wrap in the window, like a terminal would. */
20
20
  export declare const RECORD_WRAP = 96;
21
+ /**
22
+ * Items of each list the recorded answer shows (the rest is counted in the heading). The README's
23
+ * first screen has a 900px budget; the real answer is longer than that, so the recording shows a
24
+ * summary of it. `sweny try` in a terminal shows all of it.
25
+ */
26
+ export declare const RECORD_ANSWER_ITEMS = 2;
21
27
  /** Width of one terminal column in the window, in px. Every segment is placed at column * CELL_W. */
22
28
  export declare const CELL_W = 7.85;
23
29
  /** Left edge of column 0. */
@@ -22,6 +22,12 @@ import { loadTryFixture, runTry, SUMMARY_PREVIEW_LEAD } from "./try.js";
22
22
  export const RECORD_COLUMNS = 80;
23
23
  /** Lines longer than this wrap in the window, like a terminal would. */
24
24
  export const RECORD_WRAP = 96;
25
+ /**
26
+ * Items of each list the recorded answer shows (the rest is counted in the heading). The README's
27
+ * first screen has a 900px budget; the real answer is longer than that, so the recording shows a
28
+ * summary of it. `sweny try` in a terminal shows all of it.
29
+ */
30
+ export const RECORD_ANSWER_ITEMS = 2;
25
31
  const FONT_SIZE = 13;
26
32
  /** Width of one terminal column in the window, in px. Every segment is placed at column * CELL_W. */
27
33
  export const CELL_W = 7.85;
@@ -317,6 +323,7 @@ export async function recordTrySvg(fixture = loadTryFixture()) {
317
323
  rich: true,
318
324
  unicode: true,
319
325
  columns: RECORD_COLUMNS,
326
+ compactAnswer: RECORD_ANSWER_ITEMS,
320
327
  fixture,
321
328
  write: (s) => {
322
329
  out += s;
@@ -1,33 +1,73 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "illustrative": true,
4
- "notice": "ILLUSTRATIVE SAMPLE DATA. This is not output from a real run and not about your code. The repo (acme-notes-api) is fictional; timings, tool-call counts, token usage and cost are plausible numbers for a small repo, written by hand. Replace this file with a recorded real run (metadata plus the sample answer only) and set illustrative to false.",
3
+ "illustrative": false,
4
+ "captured_at": "2026-11-10T20:09:47.380Z",
5
+ "notice": "A replay of a real run of explain-repo on swenyai/sweny, started 2026-11-10T20:09:47Z (2026-11-10). Durations, tool-call counts, token usage and cost are as recorded; the answer is redacted. Made by `sweny try --capture`; do not edit by hand.",
5
6
  "workflow": "explain-repo",
6
- "sample_repo": "acme-notes-api",
7
- "harness": { "id": "claude-code", "version": "sample" },
8
- "policy": { "envScope": true, "sandbox": "auto", "sandboxStarted": true },
7
+ "sample_repo": "swenyai/sweny",
8
+ "harness": {
9
+ "id": "claude-code",
10
+ "version": "0.3.322"
11
+ },
12
+ "policy": {
13
+ "envScope": true,
14
+ "sandbox": "strict",
15
+ "sandboxStarted": true
16
+ },
9
17
  "nodes": {
10
18
  "survey": {
11
- "duration_ms": 18400,
12
- "tool_calls": { "Glob": 3, "Read": 7, "Grep": 2 },
13
- "usage": { "inputTokens": 21300, "outputTokens": 1150, "costUsd": 0.09, "numTurns": 8 }
19
+ "duration_ms": 15711,
20
+ "tool_calls": {
21
+ "Bash": 2
22
+ },
23
+ "usage": {
24
+ "inputTokens": 6,
25
+ "outputTokens": 2006,
26
+ "costUsd": 0.1197956,
27
+ "numTurns": 3
28
+ }
14
29
  },
15
30
  "explain": {
16
- "duration_ms": 9100,
17
- "tool_calls": {},
18
- "usage": { "inputTokens": 6800, "outputTokens": 640, "costUsd": 0.04, "numTurns": 1 },
31
+ "duration_ms": 7211,
32
+ "tool_calls": {
33
+ "StructuredOutput": 1
34
+ },
35
+ "usage": {
36
+ "inputTokens": 2,
37
+ "outputTokens": 877,
38
+ "costUsd": 0.0871939,
39
+ "numTurns": 2
40
+ },
19
41
  "output": {
20
- "purpose": "A small REST API for storing and searching notes, built with Express and TypeScript on top of SQLite. Meant as the backend for a notes app.",
21
- "how_to_run": ["npm install", "npm run build", "npm test", "npm start (serves on http://localhost:3000)"],
42
+ "purpose": "SWEny runs DAGs of coding-agent steps (Claude Code, Codex, pi, ACP agents) defined in YAML workflows, locally or in CI. It scopes each step's env and tools, validates output schemas, and records a receipt per run. For teams automating coding-agent work.",
43
+ "how_to_run": [
44
+ "Use Node >=22, then run: npm install (installs all workspaces)",
45
+ "npm run build",
46
+ "npm run typecheck",
47
+ "npm test (Vitest)",
48
+ "npm run lint",
49
+ "Run the CLI: node packages/core/dist/cli/main.js workflow run <file.yml> (the `sweny` bin)",
50
+ "Docs site: npm run dev:web",
51
+ "In CI: use the GitHub Action swenyai/sweny@v5 (action.yml)"
52
+ ],
22
53
  "key_files": [
23
- "src/server.ts: the entry point, wires routes and the database",
24
- "src/routes/notes.ts: the CRUD and search endpoints",
25
- "src/db/schema.sql: the tables, applied on first start",
26
- "test/notes.test.ts: the API tests, a good map of expected behavior"
54
+ "README.md, ARCHITECTURE.md, CONTRIBUTING.md, CLAUDE.md: start here for the concepts and the contributor rules",
55
+ "packages/core/src/executor.ts: runs the DAG",
56
+ "packages/core/src/cli/: CLI entry point (main.js is built from here)",
57
+ "packages/core/src/schema.ts, types.ts, loader.ts: workflow definition and validation",
58
+ "packages/core/src/node-policy.ts, agent-env.ts, safe-outputs.ts, untrusted.ts: security scoping and output checks",
59
+ "packages/core/src/journal.ts: run receipts",
60
+ "packages/core/src/workflows/, .sweny/workflows/, examples/: built-in and sample workflows",
61
+ "packages/mcp/src/index.ts: MCP server",
62
+ "packages/studio: visual workflow editor",
63
+ "action.yml and .github/workflows/ci.yml, release.yml: Action and CI/release"
27
64
  ],
28
65
  "risks": [
29
- "No auth on any route: fine locally, not safe to expose as is.",
30
- "The README does not mention the PORT or DB_PATH environment variables that src/server.ts reads."
66
+ "Only Claude Code is fully supported. Codex, pi and ACP agents are experimental.",
67
+ "Every push to main triggers release.yml, which publishes to npm, so merges ship straight away. There are no changesets.",
68
+ "The build, test and run commands above come from a survey of the root scripts, not from running them. Check them against package.json.",
69
+ "The repo has many moving parts: 5 packages, 2 Astro sites, the plugin and the Action. Run builds from the repo root so workspaces resolve.",
70
+ "Security-sensitive code (env scoping, input fencing, output checks) needs careful review. Read SECURITY.md first."
31
71
  ]
32
72
  }
33
73
  }
package/dist/cli/try.d.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  import type { Command } from "commander";
17
17
  import type { AgentHarness } from "../harness/types.js";
18
18
  import type { Workflow } from "../types.js";
19
+ import { type FinalOutput } from "./final-output.js";
19
20
  import { type Paint } from "./style.js";
20
21
  export interface TryFixtureNode {
21
22
  /** Recorded wall-clock time of the node. */
@@ -109,6 +110,12 @@ export interface TryOptions {
109
110
  rich?: boolean;
110
111
  /** Terminal width. Default: stdout's. */
111
112
  columns?: number;
113
+ /**
114
+ * Show at most this many items of each list in the answer, with the count in its heading
115
+ * ("How to run it (2 of 8):"). The recording uses it to stay README-sized; a real terminal run
116
+ * leaves it unset and shows the whole answer.
117
+ */
118
+ compactAnswer?: number;
112
119
  fixture?: TryFixture;
113
120
  }
114
121
  /** A default replay takes about this long in total, however long the real run took. */
@@ -117,6 +124,11 @@ export declare const REPLAY_TARGET_MS = 8000;
117
124
  export declare const REPLAY_NODE_MIN_MS = 250;
118
125
  /** Per-node replay time: the recorded durations scaled to {@link REPLAY_TARGET_MS} in total (never slowed down). */
119
126
  export declare function scaledPace(fixture: TryFixture, targetMs?: number): (nodeId: string) => number;
127
+ /**
128
+ * The answer with each list cut to its first `maxItems` items; the heading then says
129
+ * "<title> (<maxItems> of <total>):". Scalars, short lists and errors pass through. Pure.
130
+ */
131
+ export declare function compactAnswer(out: FinalOutput, maxItems: number): FinalOutput;
120
132
  /** The line that opens the job summary preview (record.ts cuts the preview from here). */
121
133
  export declare const SUMMARY_PREVIEW_LEAD = "In CI, the job summary gets this heading, this DAG and the receipt below:";
122
134
  /** Replay the recording. Returns the process exit code. */
package/dist/cli/try.js CHANGED
@@ -211,6 +211,41 @@ export function scaledPace(fixture, targetMs = REPLAY_TARGET_MS) {
211
211
  const scale = total > targetMs ? targetMs / total : 1;
212
212
  return (id) => Math.max(REPLAY_NODE_MIN_MS, Math.round((fixture.nodes[id]?.duration_ms ?? 0) * scale));
213
213
  }
214
+ /**
215
+ * The answer with each list cut to its first `maxItems` items; the heading then says
216
+ * "<title> (<maxItems> of <total>):". Scalars, short lists and errors pass through. Pure.
217
+ */
218
+ export function compactAnswer(out, maxItems) {
219
+ if (out.kind !== "result" || !Number.isInteger(maxItems) || maxItems < 1)
220
+ return out;
221
+ const src = out.lines;
222
+ const lines = [];
223
+ let i = 0;
224
+ while (i < src.length) {
225
+ const head = src[i];
226
+ // A list is a heading with no value ("Key files:") followed by "- " items and their indented continuations.
227
+ if (!/^[^\s-].*:$/.test(head) || !src[i + 1]?.startsWith("- ")) {
228
+ lines.push(head);
229
+ i++;
230
+ continue;
231
+ }
232
+ const items = [];
233
+ let j = i + 1;
234
+ while (j < src.length && (src[j].startsWith("- ") || src[j].startsWith(" "))) {
235
+ if (src[j].startsWith("- "))
236
+ items.push([src[j]]);
237
+ else
238
+ items[items.length - 1].push(src[j]);
239
+ j++;
240
+ }
241
+ if (items.length <= maxItems)
242
+ lines.push(head, ...items.flat());
243
+ else
244
+ lines.push(`${head.slice(0, -1)} (${maxItems} of ${items.length}):`, ...items.slice(0, maxItems).flat());
245
+ i = j;
246
+ }
247
+ return { ...out, lines };
248
+ }
214
249
  const silentLogger = { info() { }, warn() { }, error() { }, debug() { } };
215
250
  /** The line that opens the job summary preview (record.ts cuts the preview from here). */
216
251
  export const SUMMARY_PREVIEW_LEAD = "In CI, the job summary gets this heading, this DAG and the receipt below:";
@@ -291,8 +326,10 @@ export async function runTry(opts = {}) {
291
326
  const totalMs = Object.values(durationsMs).reduce((a, b) => a + b, 0);
292
327
  const receipt = summarizeRun(results, totalMs, false, trace);
293
328
  const answer = resolveFinalOutput(workflow, results);
294
- if (answer)
295
- write(`${formatFinalOutput(answer)}\n\n`);
329
+ if (answer) {
330
+ const shown = opts.compactAnswer ? compactAnswer(answer, opts.compactAnswer) : answer;
331
+ write(`${formatFinalOutput(shown)}\n\n`);
332
+ }
296
333
  // Compact preview of CI's job summary: heading and DAG here, receipt below.
297
334
  const comment = formatRunComment(workflow, results, receipt, { trace, durationsMs });
298
335
  write(` ${paint.muted(SUMMARY_PREVIEW_LEAD)}\n\n`);
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * This replaces ~8k lines of engine + recipe step code.
14
14
  */
15
- import type { Workflow, Node, Skill, Tool, Claude, Observer, NodeResult, Logger, ExecutionResult, JSONSchema } from "./types.js";
15
+ import type { Workflow, Node, Skill, Tool, Claude, Observer, NodeResult, Logger, ExecutionResult, JSONSchema, EvalResult, Evaluator, ToolCall } from "./types.js";
16
+ import type { ToolAliases } from "./eval/index.js";
16
17
  import type { HarnessPolicyMode } from "./harness/policy.js";
17
18
  import type { AgentHarness } from "./harness/types.js";
18
19
  import type { DeciderOperatorConfig } from "./decider.js";
@@ -190,6 +191,20 @@ export declare const COUNT_OF_KEYWORD = "x-sweny-count-of";
190
191
  * nested shapes and additionalProperties stay the author's concern.
191
192
  */
192
193
  export declare function outputContractProblems(data: unknown, output: JSONSchema | undefined): ContractProblem[];
194
+ /**
195
+ * Required tools the agent never called, when that is the only reason a node
196
+ * failed its evals and another turn could call them: every failure is a
197
+ * `function` evaluator, none saw a forbidden call, every required tool it names
198
+ * is absent from the trace (not called and failed: that is the retry path's
199
+ * job), and every one is reachable, i.e. on `agentTools`, the tool list the
200
+ * harness reported for the run, by its own name or an alias (an MCP tool
201
+ * counts by its bare name). Empty otherwise, and empty when the harness
202
+ * reported no list. An `any_tool_called` set needs one reachable member and
203
+ * reads "one of a, b" over its reachable members.
204
+ */
205
+ export declare function uncalledRequiredTools(evaluators: Evaluator[] | undefined, failures: EvalResult[], toolCalls: ToolCall[], agentTools: readonly string[] | undefined, aliases?: ToolAliases): string[];
206
+ /** The repair turn for {@link uncalledRequiredTools}: names the tools, then the step again. */
207
+ export declare function requiredToolsRepairInstruction(uncalled: string[], instruction: string): string;
193
208
  /**
194
209
  * Dry-run tool gate (#380). Only an explicit `access: "read"` passes; a tool
195
210
  * with no `access` is treated as a write so new tools fail safe.
package/dist/executor.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { consoleLogger } from "./types.js";
16
16
  import { resolveSources } from "./source-resolver.js";
17
17
  import { evaluateAll, aggregateEval } from "./eval/index.js";
18
+ import { checkAllToolsCalled, checkAnyToolCalled, checkNoToolCalled } from "./eval/function.js";
18
19
  import { evaluateRequires } from "./requires.js";
19
20
  import { evaluateExpression, isWhenExpression, parseExpression, whenLabel } from "./when.js";
20
21
  import { buildRetryPreamble } from "./retry.js";
@@ -602,6 +603,8 @@ async function executeRun(workflow, input, options, results, trace, at) {
602
603
  let currentInstruction = instruction;
603
604
  // One structured-output repair per visit (#357), before any eval retry.
604
605
  let repaired = false;
606
+ // One required-tool repair per visit, before any eval retry.
607
+ let toolRepaired = false;
605
608
  const retry = node.retry;
606
609
  // Per-node execution model: node.model ?? workflow.model. When undefined,
607
610
  // claude.run falls back to its own client default (then Claude Code's).
@@ -723,8 +726,8 @@ async function executeRun(workflow, input, options, results, trace, at) {
723
726
  // Resume (#363): spend is journaled as it is reported, so a crash mid-node
724
727
  // does not hand the resumed run a fresh budget.
725
728
  const usageNode = currentId;
726
- // The repair call (at most one) shifts later attempts by one, so every agent call has its own key.
727
- const usageAttempt = attempt + (repaired ? 1 : 0);
729
+ // Each repair call (at most one of each) shifts later attempts by one, so every agent call has its own key.
730
+ const usageAttempt = attempt + (repaired ? 1 : 0) + (toolRepaired ? 1 : 0);
728
731
  let liveUsage;
729
732
  // A live usage record the journal cannot write stops the agent at once:
730
733
  // its spend would be invisible to a resume.
@@ -957,6 +960,30 @@ async function executeRun(workflow, input, options, results, trace, at) {
957
960
  }
958
961
  if (outcome.pass)
959
962
  break;
963
+ // Required tools: a node whose only failures are `function` evals naming
964
+ // tools its agent never called, all of them on the tool list the harness
965
+ // says the agent had, gets one repair turn that names them, before any
966
+ // eval retry. Same footing as the output contract repair: part
967
+ // of this visit (no extra max_steps slot), its spend counted. Agents
968
+ // sometimes answer on turn 1 without the tools they were given (dogfood
969
+ // run 42890245104: `called: [StructuredOutput]`).
970
+ const uncalled = toolRepaired
971
+ ? []
972
+ : uncalledRequiredTools(node.eval, outcome.failures, result.toolCalls, result.agentTools, toolAliases);
973
+ if (uncalled.length > 0) {
974
+ toolRepaired = true;
975
+ logger.warn(` required tools not called: ${uncalled.join(", ")}; asking the agent once to call them`, {
976
+ node: currentId,
977
+ });
978
+ safeObserve(observer, {
979
+ type: "node:warning",
980
+ node: currentId,
981
+ reason: "required tools not called; one repair attempt",
982
+ fields: uncalled,
983
+ }, logger);
984
+ currentInstruction = requiredToolsRepairInstruction(uncalled, currentInstruction);
985
+ continue;
986
+ }
960
987
  // Apply the failure to the result.
961
988
  result.status = "failed";
962
989
  result.data = { ...result.data, error: outcome.error };
@@ -1599,6 +1626,62 @@ export function outputContractProblems(data, output) {
1599
1626
  }
1600
1627
  return problems;
1601
1628
  }
1629
+ /**
1630
+ * Required tools the agent never called, when that is the only reason a node
1631
+ * failed its evals and another turn could call them: every failure is a
1632
+ * `function` evaluator, none saw a forbidden call, every required tool it names
1633
+ * is absent from the trace (not called and failed: that is the retry path's
1634
+ * job), and every one is reachable, i.e. on `agentTools`, the tool list the
1635
+ * harness reported for the run, by its own name or an alias (an MCP tool
1636
+ * counts by its bare name). Empty otherwise, and empty when the harness
1637
+ * reported no list. An `any_tool_called` set needs one reachable member and
1638
+ * reads "one of a, b" over its reachable members.
1639
+ */
1640
+ export function uncalledRequiredTools(evaluators, failures, toolCalls, agentTools, aliases) {
1641
+ if (!agentTools)
1642
+ return [];
1643
+ const listed = new Set(agentTools.map(bareToolName));
1644
+ const reachable = (name) => listed.has(name) || [...(aliases?.get(name) ?? [])].some((a) => listed.has(a));
1645
+ const uncalled = [];
1646
+ // A call to any of these (by name or alias), successful or not.
1647
+ const invoked = (names) => checkNoToolCalled(names, toolCalls, aliases) !== null;
1648
+ for (const failure of failures) {
1649
+ const rule = failure.kind === "function"
1650
+ ? evaluators?.find((e) => e.kind === "function" && e.name === failure.name)?.rule
1651
+ : undefined;
1652
+ if (!rule)
1653
+ return [];
1654
+ if (rule.no_tool_called && rule.no_tool_called.length > 0 && invoked(rule.no_tool_called))
1655
+ return [];
1656
+ for (const name of rule.all_tools_called ?? []) {
1657
+ if (checkAllToolsCalled([name], toolCalls, aliases) === null)
1658
+ continue;
1659
+ if (invoked([name]) || !reachable(name))
1660
+ return [];
1661
+ uncalled.push(name);
1662
+ }
1663
+ const any = rule.any_tool_called ?? [];
1664
+ if (any.length > 0 && checkAnyToolCalled(any, toolCalls, aliases) !== null) {
1665
+ const open = any.filter(reachable);
1666
+ if (invoked(any) || open.length === 0)
1667
+ return [];
1668
+ uncalled.push(open.length === 1 ? open[0] : `one of ${open.join(", ")}`);
1669
+ }
1670
+ }
1671
+ return [...new Set(uncalled)];
1672
+ }
1673
+ /** `mcp__<server>__<tool>` → `<tool>`; any other name unchanged. Tool calls are recorded the same way. */
1674
+ function bareToolName(name) {
1675
+ const parts = name.split("__");
1676
+ return parts.length >= 3 && parts[0] === "mcp" ? parts.slice(2).join("__") : name;
1677
+ }
1678
+ /** The repair turn for {@link uncalledRequiredTools}: names the tools, then the step again. */
1679
+ export function requiredToolsRepairInstruction(uncalled, instruction) {
1680
+ const listed = uncalled.map((t) => `- ${t}`).join("\n");
1681
+ return (`This step requires these tools, and your previous attempt finished without calling them:\n${listed}\n\n` +
1682
+ `Do the step again: call each listed tool, use what it returns, then return the complete output.` +
1683
+ `\n\n---\n\n${instruction}`);
1684
+ }
1602
1685
  /** The output fields of `nodeId` that some `{ expr }` in the workflow reads. */
1603
1686
  function routingFieldsOf(workflow, nodeId) {
1604
1687
  const out = new Set();
@@ -34,11 +34,12 @@ export declare const CLASSIFICATION_DISALLOWED_TOOLS: readonly ["Bash", "Write",
34
34
  /**
35
35
  * Built-in tools disallowed for a node run with `readOnly: true` (dry run,
36
36
  * #380): everything that can edit the workspace, shell out (and so reach
37
- * `gh`, `curl`, `git push`), spawn a subagent that could, or fetch an
38
- * arbitrary URL (a GET can still trigger a webhook or exfiltrate data).
39
- * Read, Grep, Glob and WebSearch stay available for analysis.
37
+ * `gh`, `curl`, `git push`), spawn a subagent that could, fetch an arbitrary
38
+ * URL (a GET can still trigger a webhook or exfiltrate data) or search the web
39
+ * (a query is an egress channel that can carry repo data out). Read, Grep and
40
+ * Glob stay available for analysis.
40
41
  */
41
- export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Agent", "WebFetch"];
42
+ export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Agent", "WebFetch", "WebSearch"];
42
43
  /**
43
44
  * Claude Code built-in tool names per portable tool class. A node's
44
45
  * `tools.deny` classes compile to these names on `disallowedTools`.
@@ -71,6 +72,40 @@ export declare function claudeReadDenyRule(absPath: string): string;
71
72
  export declare function claudeStateDirDenyRules(root?: string): string[];
72
73
  /** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
73
74
  export declare function compileClaudeCodeDeny(policy: NodePolicy, legacy?: readonly string[]): string[];
75
+ /**
76
+ * The only Claude Code built-in tools a node may get, by policy. Every node
77
+ * reads and searches the checkout, and a read-only node gets nothing else: no
78
+ * web search or fetch, since either can carry repo data out. A write node adds
79
+ * the shell, file-edit and web built-ins (its `tools.deny` classes and
80
+ * `disallowed_tools` names then leave the list). No node gets the subagent
81
+ * tools (Task, Agent) by default: nothing shows a subagent is held to its
82
+ * parent's `tools`, so the default must not depend on it.
83
+ *
84
+ * Passed as the SDK's `tools` option, which "Specif[ies] the base set of
85
+ * available built-in tools" (sdk.d.ts); `allowedTools` only auto-approves
86
+ * ("To restrict which tools are available, use the `tools` option instead").
87
+ * Anything not named here never reaches a node: agent-to-agent messaging
88
+ * (SendMessage, ListAgents), scheduling (Cron*, ScheduleWakeup), remote
89
+ * triggers, notifications, worktrees, design sync, Skill, Workflow and
90
+ * ToolSearch (added back only while a server defers its tools, see
91
+ * {@link runQuery}). Dogfood run 42890468912 listed 23 tools for a read-only
92
+ * node that needed two.
93
+ */
94
+ export declare const CLAUDE_READ_ONLY_BUILTIN_TOOLS: readonly ["Read", "Grep", "Glob"];
95
+ export declare const CLAUDE_WRITE_BUILTIN_TOOLS: readonly ["Read", "Grep", "Glob", ...string[]];
96
+ /**
97
+ * The built-in allow-list for one node: the policy's list minus every name the
98
+ * compiled deny list removes. Path-scoped rules (`Read(//dir/**)`) narrow a
99
+ * tool, they do not remove it, so they are not matched here.
100
+ */
101
+ export declare function claudeCodeBuiltinTools(policy: {
102
+ readOnly: boolean;
103
+ }, deny?: readonly string[]): string[];
104
+ /**
105
+ * Env vars the harness itself sets on the agent (not passed through from the
106
+ * parent): `ENABLE_TOOL_SEARCH`, see {@link ClaudeCodeHarness} runQuery.
107
+ */
108
+ export declare const CLAUDE_CODE_HARNESS_VARS: readonly ["ENABLE_TOOL_SEARCH"];
74
109
  /** How sweny resolves which credentials reach the Claude Code subprocess. */
75
110
  export type SwenyAuthMode = "auto" | "api-key" | "oauth";
76
111
  export interface ResolveAuthEnvOpts {
@@ -68,9 +68,10 @@ export const CLASSIFICATION_DISALLOWED_TOOLS = [
68
68
  /**
69
69
  * Built-in tools disallowed for a node run with `readOnly: true` (dry run,
70
70
  * #380): everything that can edit the workspace, shell out (and so reach
71
- * `gh`, `curl`, `git push`), spawn a subagent that could, or fetch an
72
- * arbitrary URL (a GET can still trigger a webhook or exfiltrate data).
73
- * Read, Grep, Glob and WebSearch stay available for analysis.
71
+ * `gh`, `curl`, `git push`), spawn a subagent that could, fetch an arbitrary
72
+ * URL (a GET can still trigger a webhook or exfiltrate data) or search the web
73
+ * (a query is an egress channel that can carry repo data out). Read, Grep and
74
+ * Glob stay available for analysis.
74
75
  */
75
76
  export const READ_ONLY_DISALLOWED_TOOLS = [
76
77
  "Bash",
@@ -81,6 +82,7 @@ export const READ_ONLY_DISALLOWED_TOOLS = [
81
82
  "Task",
82
83
  "Agent",
83
84
  "WebFetch",
85
+ "WebSearch",
84
86
  ];
85
87
  /**
86
88
  * Claude Code built-in tool names per portable tool class. A node's
@@ -146,6 +148,55 @@ export function compileClaudeCodeDeny(policy, legacy = []) {
146
148
  ]),
147
149
  ];
148
150
  }
151
+ /**
152
+ * The only Claude Code built-in tools a node may get, by policy. Every node
153
+ * reads and searches the checkout, and a read-only node gets nothing else: no
154
+ * web search or fetch, since either can carry repo data out. A write node adds
155
+ * the shell, file-edit and web built-ins (its `tools.deny` classes and
156
+ * `disallowed_tools` names then leave the list). No node gets the subagent
157
+ * tools (Task, Agent) by default: nothing shows a subagent is held to its
158
+ * parent's `tools`, so the default must not depend on it.
159
+ *
160
+ * Passed as the SDK's `tools` option, which "Specif[ies] the base set of
161
+ * available built-in tools" (sdk.d.ts); `allowedTools` only auto-approves
162
+ * ("To restrict which tools are available, use the `tools` option instead").
163
+ * Anything not named here never reaches a node: agent-to-agent messaging
164
+ * (SendMessage, ListAgents), scheduling (Cron*, ScheduleWakeup), remote
165
+ * triggers, notifications, worktrees, design sync, Skill, Workflow and
166
+ * ToolSearch (added back only while a server defers its tools, see
167
+ * {@link runQuery}). Dogfood run 42890468912 listed 23 tools for a read-only
168
+ * node that needed two.
169
+ */
170
+ export const CLAUDE_READ_ONLY_BUILTIN_TOOLS = ["Read", "Grep", "Glob"];
171
+ export const CLAUDE_WRITE_BUILTIN_TOOLS = [
172
+ "Read",
173
+ "Grep",
174
+ "Glob",
175
+ ...CLAUDE_TOOLS_BY_CLASS.shell,
176
+ ...CLAUDE_TOOLS_BY_CLASS.write,
177
+ ...CLAUDE_TOOLS_BY_CLASS.edit,
178
+ ...CLAUDE_TOOLS_BY_CLASS.net,
179
+ ];
180
+ /**
181
+ * The built-in allow-list for one node: the policy's list minus every name the
182
+ * compiled deny list removes. Path-scoped rules (`Read(//dir/**)`) narrow a
183
+ * tool, they do not remove it, so they are not matched here.
184
+ */
185
+ export function claudeCodeBuiltinTools(policy, deny = []) {
186
+ const denied = new Set(deny);
187
+ const base = policy.readOnly ? CLAUDE_READ_ONLY_BUILTIN_TOOLS : CLAUDE_WRITE_BUILTIN_TOOLS;
188
+ return base.filter((t) => !denied.has(t));
189
+ }
190
+ /**
191
+ * Env vars the harness itself sets on the agent (not passed through from the
192
+ * parent): `ENABLE_TOOL_SEARCH`, see {@link ClaudeCodeHarness} runQuery.
193
+ */
194
+ export const CLAUDE_CODE_HARNESS_VARS = ["ENABLE_TOOL_SEARCH"];
195
+ /** CI: a truthy `CI` (not "", "0", "false", "no", "off"), or GitHub Actions itself. */
196
+ function runningInCi(env = process.env) {
197
+ const ci = (env.CI ?? "").trim().toLowerCase();
198
+ return env.GITHUB_ACTIONS === "true" || !["", "0", "false", "no", "off"].includes(ci);
199
+ }
149
200
  /**
150
201
  * Decide which auth credentials survive into the Claude Code subprocess env.
151
202
  *
@@ -562,6 +613,8 @@ export class ClaudeCodeHarness {
562
613
  // Fail closed: a stream that ends with no terminal `result` message
563
614
  // (subprocess died, stream truncated) is not a success.
564
615
  let sawResult = false;
616
+ // The tool names the agent was given (init message), for the executor's repair rule.
617
+ let agentTools;
565
618
  // Timeout / abort wiring (back-compat: only armed when requested).
566
619
  // A single AbortController drives both an optional caller signal and an
567
620
  // optional timeout timer. The SDK aborts the subprocess when this fires.
@@ -596,6 +649,20 @@ export class ClaudeCodeHarness {
596
649
  else if (sdkTools.length > 0) {
597
650
  allMcpServers["sweny-core"] = mcpServer;
598
651
  }
652
+ // Deterministic tool loading: when every server loads its tools up front
653
+ // (sweny-core always does; a node's skill servers do unless they opt out),
654
+ // tool search has nothing to defer, so it is off and ToolSearch is not on
655
+ // the list. `ENABLE_TOOL_SEARCH=false` is the bundled CLI's switch (its
656
+ // own docs: "To turn it off ... set `ENABLE_TOOL_SEARCH` to `false`"; CLI
657
+ // 0.3.319 drops ToolSearch from the init tool list with it). A client-level
658
+ // server not marked alwaysLoad keeps tool search, and ToolSearch, so its
659
+ // deferred tools stay reachable.
660
+ const defers = Object.entries(allMcpServers).some(([name, cfg]) => name !== "sweny-core" && cfg?.alwaysLoad !== true);
661
+ if (!defers)
662
+ env.ENABLE_TOOL_SEARCH = "false";
663
+ const builtinTools = opts.nativeNone
664
+ ? []
665
+ : [...claudeCodeBuiltinTools({ readOnly: !!readOnly }, disallowedTools), ...(defers ? ["ToolSearch"] : [])];
599
666
  stream = query({
600
667
  prompt,
601
668
  options: {
@@ -622,10 +689,13 @@ export class ClaudeCodeHarness {
622
689
  strictMcpConfig: true,
623
690
  settingSources: [],
624
691
  ...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
625
- // permissions.native: none. `tools: []` removes every built-in tool whatever its
626
- // name; `settingSources: []` loads no user, project or local settings, CLAUDE.md or skills
627
- // (already the default above since #497; kept so the policy value states its whole contract).
628
- ...(opts.nativeNone ? { tools: [], settingSources: [] } : {}),
692
+ // Least privilege: only the built-ins this node's policy names (see
693
+ // CLAUDE_WRITE_BUILTIN_TOOLS). permissions.native: none passes `tools: []`,
694
+ // which removes every built-in tool whatever its name.
695
+ tools: builtinTools,
696
+ // `settingSources: []` loads no user, project or local settings, CLAUDE.md or skills
697
+ // (already the default above since #497; kept so native: none states its whole contract).
698
+ ...(opts.nativeNone ? { settingSources: [] } : {}),
629
699
  // CC-08: ask the SDK to produce validated structured output when the
630
700
  // node declares an output schema. The SDK then returns the parsed
631
701
  // value on `result.structured_output`, which we prefer over the
@@ -635,11 +705,27 @@ export class ClaudeCodeHarness {
635
705
  });
636
706
  for await (const message of stream) {
637
707
  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.
708
+ // What the agent was actually given: the tool names (never inputs)
709
+ // and, loudly, a node tool or tool server that did not make it onto
710
+ // the list. In CI the names go out as one warning-level line, the
711
+ // level the default (non-verbose) run log shows, so a failed node is
712
+ // diagnosable without a verbose rerun; locally they stay at info.
640
713
  const init = message;
641
- if (Array.isArray(init.tools))
642
- this.logger.debug(` agent tools: ${init.tools.join(", ")}`);
714
+ if (Array.isArray(init.tools)) {
715
+ agentTools = init.tools.filter((t) => typeof t === "string");
716
+ const line = ` agent tools: ${init.tools.join(", ")}`;
717
+ if (runningInCi())
718
+ this.logger.warn(line);
719
+ else
720
+ this.logger.info(line);
721
+ const listed = new Set(init.tools);
722
+ const absent = allMcpServers["sweny-core"]
723
+ ? tools.map((t) => t.name).filter((n) => !listed.has(`mcp__sweny-core__${n}`))
724
+ : [];
725
+ if (absent.length > 0) {
726
+ this.logger.warn(` node tools missing from the agent's tool list: ${absent.join(", ")}`);
727
+ }
728
+ }
643
729
  const core = Array.isArray(init.mcp_servers)
644
730
  ? init.mcp_servers.find((s) => s?.name === "sweny-core")
645
731
  : undefined;
@@ -833,6 +919,7 @@ export class ClaudeCodeHarness {
833
919
  data: { summary: response, ...parsedData },
834
920
  toolCalls,
835
921
  ...(usage ? { usage } : {}),
922
+ ...(agentTools ? { agentTools } : {}),
836
923
  };
837
924
  }
838
925
  /**
@@ -152,6 +152,14 @@ export interface HarnessRunRequest extends ClaudeRunRequest {
152
152
  export interface HarnessRunResult extends NodeResult {
153
153
  harness: HarnessInfo;
154
154
  degraded: string[];
155
+ /**
156
+ * Names of every tool the agent was given for this run, as the agent itself
157
+ * listed them (Claude Code: the init message; MCP tools as
158
+ * `mcp__<server>__<tool>`). The executor repairs a skipped required tool
159
+ * only when it is on this list. Absent when the harness does not report it,
160
+ * and then no such repair happens. Never journaled.
161
+ */
162
+ agentTools?: string[];
155
163
  }
156
164
  export interface HarnessCompleteRequest {
157
165
  prompt: string;
package/dist/schema.d.ts CHANGED
@@ -1035,7 +1035,7 @@ export declare function parseWorkflow(raw: unknown): {
1035
1035
  } | undefined;
1036
1036
  };
1037
1037
  export interface WorkflowError {
1038
- code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE" | "AMBIGUOUS_EDGES" | "UNSUPPORTED_EVAL_POLICY" | "INVALID_INLINE_SKILL" | "EDGE_ITERATIONS_EXCEEDED" | "RETRY_MAX_EXCEEDED" | "PERMISSION_CEILING" | "AGENT_ENV_READ_ONLY" | "BUDGET_CEILING" | "OUTPUT_NOT_ALLOWED" | "DUPLICATE_OUTPUT" | "UNSUPPORTED_OUTPUT" | "INVALID_WHEN_EXPRESSION" | "MIXED_EDGE_CONDITIONS" | "DUPLICATE_EDGE";
1038
+ code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE" | "AMBIGUOUS_EDGES" | "UNSUPPORTED_EVAL_POLICY" | "DUPLICATE_EVALUATOR" | "INVALID_INLINE_SKILL" | "EDGE_ITERATIONS_EXCEEDED" | "RETRY_MAX_EXCEEDED" | "PERMISSION_CEILING" | "AGENT_ENV_READ_ONLY" | "BUDGET_CEILING" | "OUTPUT_NOT_ALLOWED" | "DUPLICATE_OUTPUT" | "UNSUPPORTED_OUTPUT" | "INVALID_WHEN_EXPRESSION" | "MIXED_EDGE_CONDITIONS" | "DUPLICATE_EDGE";
1039
1039
  message: string;
1040
1040
  nodeId?: string;
1041
1041
  }
package/dist/schema.js CHANGED
@@ -710,6 +710,22 @@ export function validateWorkflow(workflow, knownSkills) {
710
710
  });
711
711
  }
712
712
  }
713
+ // Evaluator names are a node's eval keys: results, retry preambles, routes
714
+ // (`node.evals.<name>.pass`) and the required-tool repair look a verdict up
715
+ // by name, so two evaluators with one name would make that lookup ambiguous.
716
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
717
+ const seen = new Set();
718
+ for (const e of node.eval ?? []) {
719
+ if (seen.has(e.name)) {
720
+ errors.push({
721
+ code: "DUPLICATE_EVALUATOR",
722
+ message: `Node "${nodeId}" declares two evaluators named "${e.name}"; evaluator names must be unique within a node`,
723
+ nodeId,
724
+ });
725
+ }
726
+ seen.add(e.name);
727
+ }
728
+ }
713
729
  // Inline skills need usage instructions; the engine server name is reserved.
714
730
  for (const [skillId, def] of Object.entries(workflow.skills ?? {})) {
715
731
  if (!def.instruction?.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.208",
3
+ "version": "0.1.210",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"