@sweny-ai/core 0.1.169 → 0.1.171

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 (58) hide show
  1. package/dist/agent-env.d.ts +2 -1
  2. package/dist/agent-env.js +9 -3
  3. package/dist/browser.d.ts +2 -0
  4. package/dist/browser.js +3 -0
  5. package/dist/budget.js +5 -1
  6. package/dist/cli/comment-output.d.ts +4 -2
  7. package/dist/cli/comment-output.js +16 -8
  8. package/dist/cli/main.js +42 -26
  9. package/dist/cli/new.js +3 -0
  10. package/dist/cli/output.d.ts +3 -2
  11. package/dist/cli/output.js +39 -37
  12. package/dist/cli/progress.d.ts +33 -0
  13. package/dist/cli/progress.js +69 -0
  14. package/dist/cli/renderer.d.ts +4 -0
  15. package/dist/cli/renderer.js +20 -13
  16. package/dist/cli/run-output.d.ts +13 -6
  17. package/dist/cli/run-output.js +36 -26
  18. package/dist/cli/runs.js +17 -11
  19. package/dist/cli/style.d.ts +26 -0
  20. package/dist/cli/style.js +48 -0
  21. package/dist/cli/terminal.d.ts +47 -0
  22. package/dist/cli/terminal.js +123 -0
  23. package/dist/cli/ticket.d.ts +89 -0
  24. package/dist/cli/ticket.js +218 -0
  25. package/dist/cli/try.d.ts +15 -8
  26. package/dist/cli/try.js +84 -65
  27. package/dist/executor.d.ts +8 -1
  28. package/dist/executor.js +21 -2
  29. package/dist/git-credentials.d.ts +106 -0
  30. package/dist/git-credentials.js +322 -0
  31. package/dist/harness/acp.js +3 -0
  32. package/dist/harness/claude-code.d.ts +7 -0
  33. package/dist/harness/claude-code.js +44 -3
  34. package/dist/harness/codex.js +3 -0
  35. package/dist/harness/pi.js +3 -0
  36. package/dist/harness/policy.d.ts +5 -0
  37. package/dist/harness/policy.js +14 -0
  38. package/dist/harness/sandbox-wrapper.d.ts +9 -2
  39. package/dist/harness/sandbox-wrapper.js +14 -4
  40. package/dist/harness/types.d.ts +10 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +2 -0
  43. package/dist/mermaid.d.ts +10 -5
  44. package/dist/mermaid.js +11 -13
  45. package/dist/safe-outputs.js +5 -0
  46. package/dist/skills/git-push.d.ts +62 -0
  47. package/dist/skills/git-push.js +132 -0
  48. package/dist/skills/github.d.ts +17 -0
  49. package/dist/skills/github.js +23 -0
  50. package/dist/testing.d.ts +16 -12
  51. package/dist/testing.js +45 -20
  52. package/dist/theme.d.ts +185 -0
  53. package/dist/theme.js +183 -0
  54. package/dist/types.d.ts +2 -0
  55. package/dist/workflows/browser.js +2 -2
  56. package/dist/workflows/implement.yml +2 -1
  57. package/dist/workflows/triage.yml +2 -1
  58. package/package.json +6 -1
package/dist/mermaid.d.ts CHANGED
@@ -10,11 +10,16 @@
10
10
  * to highlight which edges were taken and show loop iterations.
11
11
  */
12
12
  import type { Workflow, ExecutionTrace } from "./types.js";
13
- export type NodeStatus = "current" | "success" | "failed" | "skipped";
13
+ import { type DiagramNodeStatus } from "./theme.js";
14
+ /**
15
+ * A node's state in a diagram. `pending` is applied automatically to every
16
+ * node missing from a non-empty `state` map (the run never reached it).
17
+ */
18
+ export type NodeStatus = DiagramNodeStatus;
14
19
  export interface MermaidOptions {
15
- /** Execution state — nodes not in this map render as default (pending) */
20
+ /** Execution state. With a non-empty map, nodes missing from it render as `pending`. */
16
21
  state?: Record<string, NodeStatus>;
17
- /** Execution trace — highlights taken/not-taken edges and loop counts */
22
+ /** Execution trace: highlights taken and not-taken edges and loop counts */
18
23
  trace?: ExecutionTrace;
19
24
  /** Graph direction: TB (top-bottom) or LR (left-right). Default: TB */
20
25
  direction?: "TB" | "LR";
@@ -22,8 +27,8 @@ export interface MermaidOptions {
22
27
  title?: string;
23
28
  /**
24
29
  * Override the `classDef` style body (the part after the class name) for
25
- * specific statuses, e.g. `{ success: "fill:#2563eb,stroke:#1d4ed8,color:#fff" }`.
26
- * Unset statuses keep the defaults.
30
+ * specific statuses. Unset statuses keep the brand defaults
31
+ * (`MERMAID_CLASS_DEFS` in theme.ts). SWEny's own surfaces pass none.
27
32
  */
28
33
  classDefs?: Partial<Record<NodeStatus, string>>;
29
34
  }
package/dist/mermaid.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * to highlight which edges were taken and show loop iterations.
11
11
  */
12
12
  import { whenLabel } from "./when.js";
13
+ import { MERMAID_CLASS_DEFS, MERMAID_EDGE_STYLES } from "./theme.js";
13
14
  /**
14
15
  * Convert a Workflow to a Mermaid flowchart diagram string.
15
16
  *
@@ -91,7 +92,7 @@ export function toMermaid(workflow, options = {}) {
91
92
  edgeTakenCounts.set(key, (edgeTakenCounts.get(key) ?? 0) + 1);
92
93
  }
93
94
  }
94
- // Edges — track index for linkStyle directives
95
+ // Edges: track index for linkStyle directives
95
96
  const takenIndices = [];
96
97
  const notTakenIndices = [];
97
98
  let edgeIndex = 0;
@@ -129,32 +130,29 @@ export function toMermaid(workflow, options = {}) {
129
130
  }
130
131
  edgeIndex++;
131
132
  }
132
- // Node status styling
133
+ // Node status styling (brand classDefs from theme.ts)
133
134
  const statusNodes = groupByStatus(state);
134
135
  if (statusNodes.size > 0) {
136
+ const unreached = Object.keys(workflow.nodes).filter((id) => !(id in state));
137
+ if (unreached.length > 0)
138
+ statusNodes.set("pending", [...(statusNodes.get("pending") ?? []), ...unreached]);
135
139
  lines.push("");
136
- const defaults = {
137
- current: "fill:#3b82f6,stroke:#2563eb,color:#fff,stroke-width:2px",
138
- success: "fill:#22c55e,stroke:#16a34a,color:#fff,stroke-width:2px",
139
- failed: "fill:#ef4444,stroke:#dc2626,color:#fff,stroke-width:2px",
140
- skipped: "fill:#6b7280,stroke:#4b5563,color:#fff,stroke-dasharray:5 5",
141
- };
142
- for (const status of ["current", "success", "failed", "skipped"]) {
143
- lines.push(` classDef ${status} ${classDefs[status] ?? defaults[status]}`);
140
+ for (const status of ["current", "success", "failed", "skipped", "pending"]) {
141
+ lines.push(` classDef ${status} ${classDefs[status] ?? MERMAID_CLASS_DEFS[status]}`);
144
142
  }
145
143
  for (const [status, ids] of statusNodes) {
146
144
  const sanitized = ids.map(aliasFor).join(",");
147
145
  lines.push(` class ${sanitized} ${status}`);
148
146
  }
149
147
  }
150
- // Edge styling — taken edges bold green, not-taken edges dashed gray
148
+ // Edge styling: taken edges bold brand blue, not-taken edges dashed slate
151
149
  if (trace) {
152
150
  lines.push("");
153
151
  if (takenIndices.length > 0) {
154
- lines.push(` linkStyle ${takenIndices.join(",")} stroke:#22c55e,stroke-width:3px`);
152
+ lines.push(` linkStyle ${takenIndices.join(",")} ${MERMAID_EDGE_STYLES.taken}`);
155
153
  }
156
154
  if (notTakenIndices.length > 0) {
157
- lines.push(` linkStyle ${notTakenIndices.join(",")} stroke:#6b7280,stroke-width:1px,stroke-dasharray:5 5`);
155
+ lines.push(` linkStyle ${notTakenIndices.join(",")} ${MERMAID_EDGE_STYLES.notTaken}`);
158
156
  }
159
157
  }
160
158
  return lines.join("\n");
@@ -428,6 +428,11 @@ export async function applySafeOutputs(o) {
428
428
  refuse("close needs a pinned issue", at);
429
429
  continue;
430
430
  }
431
+ // ...and the repository (or Linear team) must come from the declaration or the run, never from the agent's intent.
432
+ if (asked === "close" && !pinned) {
433
+ refuse("close needs a pinned repository", at);
434
+ continue;
435
+ }
431
436
  state = asked;
432
437
  }
433
438
  if (via === "github" && issueRef !== undefined && !/^[1-9][0-9]*$/.test(issueRef)) {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * sweny-side branch push for `github_create_pr` (#473).
3
+ *
4
+ * With `actions/checkout`'s recommended `persist-credentials: false` the
5
+ * checkout holds no token, so the agent's own `git push` in the create_pr node
6
+ * has nothing to push with. sweny pushes the PR's head branch itself, from
7
+ * the sweny process, with the github skill's token (`GITHUB_TOKEN`), right
8
+ * before it opens the PR. The agent never holds the token.
9
+ *
10
+ * Guard rails: only a local branch that exists, only to `origin` when it is
11
+ * the PR's own repo, never force, never the base branch or the remote's
12
+ * default branch. The token goes to git through command-scope config in the
13
+ * child env (`GIT_CONFIG_*`), never argv, as an `http.<server>/.extraheader`
14
+ * that first resets any persisted one (so the job token is never sent twice).
15
+ * A push that fails is logged and the PR is still requested: the agent may
16
+ * already have pushed (persisted credentials, an `agent_env` grant).
17
+ */
18
+ export interface GitRunResult {
19
+ code: number;
20
+ stdout: string;
21
+ stderr: string;
22
+ }
23
+ export type GitRunner = (args: string[], opts: {
24
+ cwd: string;
25
+ env: NodeJS.ProcessEnv;
26
+ }) => Promise<GitRunResult>;
27
+ export interface PushHeadOptions {
28
+ /** `owner/repo` the PR is for. */
29
+ repo: string;
30
+ /** The PR's head branch. */
31
+ head: string;
32
+ /** The PR's base branch. */
33
+ base: string;
34
+ /** The github skill's token. Without it, git uses whatever credential the checkout has. */
35
+ token?: string;
36
+ /** The checkout. Default: `process.cwd()`. */
37
+ cwd?: string;
38
+ env?: NodeJS.ProcessEnv;
39
+ /** Test seam. */
40
+ git?: GitRunner;
41
+ }
42
+ export interface PushHeadResult {
43
+ pushed: boolean;
44
+ /** A push ran (and `pushed` says whether it worked); false when a guard skipped it. */
45
+ attempted: boolean;
46
+ /** Why nothing was pushed, or why the push failed. Never carries the token. */
47
+ reason?: string;
48
+ }
49
+ /** `owner/repo` from a GitHub remote URL (https, ssh or scp form), lowercased, or undefined. */
50
+ export declare function repoFromRemote(url: string): {
51
+ host: string;
52
+ repo: string;
53
+ https: boolean;
54
+ } | undefined;
55
+ /** A branch name sweny will pass to git: plain ref characters, no option or traversal forms. */
56
+ export declare function isSafeBranch(name: string): boolean;
57
+ /** The command-scope git config that authenticates one push to `server` with `token`. */
58
+ export declare function pushAuthConfig(server: string, token: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
59
+ /**
60
+ * Push the PR's head branch to `origin` from the sweny process. Never throws.
61
+ */
62
+ export declare function pushHeadBranch(opts: PushHeadOptions): Promise<PushHeadResult>;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * sweny-side branch push for `github_create_pr` (#473).
3
+ *
4
+ * With `actions/checkout`'s recommended `persist-credentials: false` the
5
+ * checkout holds no token, so the agent's own `git push` in the create_pr node
6
+ * has nothing to push with. sweny pushes the PR's head branch itself, from
7
+ * the sweny process, with the github skill's token (`GITHUB_TOKEN`), right
8
+ * before it opens the PR. The agent never holds the token.
9
+ *
10
+ * Guard rails: only a local branch that exists, only to `origin` when it is
11
+ * the PR's own repo, never force, never the base branch or the remote's
12
+ * default branch. The token goes to git through command-scope config in the
13
+ * child env (`GIT_CONFIG_*`), never argv, as an `http.<server>/.extraheader`
14
+ * that first resets any persisted one (so the job token is never sent twice).
15
+ * A push that fails is logged and the PR is still requested: the agent may
16
+ * already have pushed (persisted credentials, an `agent_env` grant).
17
+ */
18
+ import { execFile } from "node:child_process";
19
+ import { setBranchPusher } from "./github.js";
20
+ const defaultGit = (args, opts) => new Promise((resolve) => {
21
+ execFile("git", args, { cwd: opts.cwd, env: opts.env, timeout: 120_000 }, (err, stdout, stderr) => {
22
+ const code = err
23
+ ? typeof err.code === "number"
24
+ ? err.code
25
+ : 1
26
+ : 0;
27
+ resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
28
+ });
29
+ });
30
+ /** `owner/repo` from a GitHub remote URL (https, ssh or scp form), lowercased, or undefined. */
31
+ export function repoFromRemote(url) {
32
+ const u = url.trim();
33
+ const https = /^https?:\/\/(?:[^@/]*@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/i.exec(u);
34
+ if (https)
35
+ return { host: https[1].toLowerCase(), repo: https[2].toLowerCase(), https: true };
36
+ const ssh = /^ssh:\/\/(?:[^@/]*@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/i.exec(u);
37
+ if (ssh)
38
+ return { host: ssh[1].toLowerCase(), repo: ssh[2].toLowerCase(), https: false };
39
+ const scp = /^(?:[^@/]+@)?([^/:]+):(.+?)(?:\.git)?\/?$/.exec(u);
40
+ if (scp)
41
+ return { host: scp[1].toLowerCase(), repo: scp[2].toLowerCase(), https: false };
42
+ return undefined;
43
+ }
44
+ /** A branch name sweny will pass to git: plain ref characters, no option or traversal forms. */
45
+ export function isSafeBranch(name) {
46
+ return (/^[A-Za-z0-9._/-]+$/.test(name) &&
47
+ !name.startsWith("-") &&
48
+ !name.startsWith("/") &&
49
+ !name.endsWith("/") &&
50
+ !name.endsWith(".lock") &&
51
+ !name.includes("..") &&
52
+ !name.includes("//"));
53
+ }
54
+ /** The command-scope git config that authenticates one push to `server` with `token`. */
55
+ export function pushAuthConfig(server, token, env) {
56
+ const out = { ...env };
57
+ const base = Number.parseInt(out.GIT_CONFIG_COUNT ?? "0", 10);
58
+ let n = Number.isFinite(base) && base > 0 ? base : 0;
59
+ const key = `http.${server.replace(/\/+$/, "")}/.extraheader`;
60
+ const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
61
+ // An empty value resets the list, so a header the checkout persisted is not sent too.
62
+ for (const value of ["", `AUTHORIZATION: basic ${basic}`]) {
63
+ out[`GIT_CONFIG_KEY_${n}`] = key;
64
+ out[`GIT_CONFIG_VALUE_${n}`] = value;
65
+ n++;
66
+ }
67
+ out.GIT_CONFIG_COUNT = String(n);
68
+ return out;
69
+ }
70
+ /**
71
+ * Push the PR's head branch to `origin` from the sweny process. Never throws.
72
+ */
73
+ export async function pushHeadBranch(opts) {
74
+ const git = opts.git ?? defaultGit;
75
+ const cwd = opts.cwd ?? process.cwd();
76
+ const env = { ...(opts.env ?? process.env), GIT_TERMINAL_PROMPT: "0" };
77
+ const { head, base } = opts;
78
+ if (!isSafeBranch(head))
79
+ return { pushed: false, attempted: false, reason: `head "${head}" is not a plain branch name` };
80
+ if (head === base)
81
+ return { pushed: false, attempted: false, reason: "head is the base branch" };
82
+ try {
83
+ const local = await git(["rev-parse", "--verify", "--quiet", `refs/heads/${head}^{commit}`], { cwd, env });
84
+ if (local.code !== 0)
85
+ return { pushed: false, attempted: false, reason: `no local branch ${head}` };
86
+ const remote = await git(["remote", "get-url", "origin"], { cwd, env });
87
+ if (remote.code !== 0)
88
+ return { pushed: false, attempted: false, reason: "no origin remote" };
89
+ const parsed = repoFromRemote(remote.stdout);
90
+ if (!parsed || parsed.repo !== opts.repo.toLowerCase()) {
91
+ return { pushed: false, attempted: false, reason: `origin is not ${opts.repo}` };
92
+ }
93
+ const originHead = await git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], { cwd, env });
94
+ const defaultBranch = originHead.code === 0 ? originHead.stdout.trim().replace(/^origin\//, "") : undefined;
95
+ if (head === defaultBranch || head === "main" || head === "master") {
96
+ return { pushed: false, attempted: false, reason: `head ${head} is the default branch` };
97
+ }
98
+ const server = (env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "");
99
+ let serverHost;
100
+ try {
101
+ serverHost = new URL(server).host.toLowerCase();
102
+ }
103
+ catch {
104
+ serverHost = "github.com";
105
+ }
106
+ const pushEnv = opts.token && parsed.https && parsed.host === serverHost ? pushAuthConfig(server, opts.token, env) : env;
107
+ const r = await git(["push", "--porcelain", "origin", `refs/heads/${head}:refs/heads/${head}`], {
108
+ cwd,
109
+ env: pushEnv,
110
+ });
111
+ if (r.code !== 0) {
112
+ const why = (r.stderr || r.stdout).trim().split("\n").at(-1) ?? "";
113
+ return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, opts.token)}` };
114
+ }
115
+ return { pushed: true, attempted: true };
116
+ }
117
+ catch (err) {
118
+ return {
119
+ pushed: false,
120
+ attempted: false,
121
+ reason: redact(err instanceof Error ? err.message : String(err), opts.token),
122
+ };
123
+ }
124
+ }
125
+ function redact(text, token) {
126
+ if (!token)
127
+ return text;
128
+ const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
129
+ return text.split(token).join("***").split(basic).join("***");
130
+ }
131
+ // The Node entry imports this module (executor.ts), which arms the push in `github_create_pr`.
132
+ setBranchPusher(pushHeadBranch);
@@ -5,6 +5,23 @@
5
5
  * ~800 lines → ~120 lines
6
6
  */
7
7
  import type { Skill } from "../types.js";
8
+ /**
9
+ * Pushes a PR's head branch from the sweny process (#473). The Node entry
10
+ * registers it (skills/git-push.ts, imported by the executor); this module
11
+ * stays browser-safe, and without a pusher `github_create_pr` only calls the API.
12
+ */
13
+ export type BranchPusher = (opts: {
14
+ repo: string;
15
+ head: string;
16
+ base: string;
17
+ token?: string;
18
+ }) => Promise<{
19
+ pushed: boolean;
20
+ attempted: boolean;
21
+ reason?: string;
22
+ }>;
23
+ /** Register (or clear, with `undefined`) the sweny-side branch pusher. */
24
+ export declare function setBranchPusher(fn: BranchPusher | undefined): void;
8
25
  /**
9
26
  * Encode an LLM-chosen repo file path for safe interpolation into a
10
27
  * GitHub API request path.
@@ -4,6 +4,11 @@
4
4
  * Replaces: source-control/github.ts + issue-tracking/github-issues.ts
5
5
  * ~800 lines → ~120 lines
6
6
  */
7
+ let branchPusher;
8
+ /** Register (or clear, with `undefined`) the sweny-side branch pusher. */
9
+ export function setBranchPusher(fn) {
10
+ branchPusher = fn;
11
+ }
7
12
  class GitHubApiError extends Error {
8
13
  status;
9
14
  body;
@@ -178,6 +183,24 @@ export const github = {
178
183
  handler: async (input, ctx) => {
179
184
  let pr;
180
185
  let reused = false;
186
+ // #473: push the head branch from sweny with this skill's token, so the
187
+ // recommended `persist-credentials: false` checkout still ships the
188
+ // branch and the agent never holds a write token. Skipped unless the
189
+ // branch exists locally and origin is this repo; never forced.
190
+ if (branchPusher) {
191
+ const push = await branchPusher({
192
+ repo: input.repo,
193
+ head: input.head,
194
+ base: input.base ?? "main",
195
+ token: ctx.config.GITHUB_TOKEN,
196
+ });
197
+ if (push.pushed)
198
+ ctx.logger?.info?.(` github_create_pr: pushed ${input.head} to origin`);
199
+ else if (push.attempted)
200
+ ctx.logger?.warn?.(` github_create_pr: ${push.reason}; requesting the PR anyway`);
201
+ else
202
+ ctx.logger?.debug?.(` github_create_pr: no sweny-side push (${push.reason})`);
203
+ }
181
204
  try {
182
205
  pr = (await gh(`/repos/${input.repo}/pulls`, ctx, {
183
206
  method: "POST",
package/dist/testing.d.ts CHANGED
@@ -1,20 +1,18 @@
1
1
  /**
2
2
  * Test Helpers
3
3
  *
4
- * MockClaude for running workflows without an API key.
4
+ * MockHarness for running workflows without an API key.
5
5
  * File-based skill for local testing.
6
6
  *
7
7
  * @example
8
8
  * ```ts
9
- * import { MockClaude, fileSkill } from '@sweny-ai/core/testing'
9
+ * import { MockHarness } from '@sweny-ai/core/testing'
10
10
  *
11
- * const claude = new MockClaude({
12
- * gather: {
13
- * toolCalls: [{ tool: 'fs_read_json', input: { path: 'logs.json' } }],
14
- * data: { logs: [...] },
15
- * },
16
- * investigate: {
17
- * data: { root_cause: 'NPE in handler', severity: 'high' },
11
+ * const harness = new MockHarness({
12
+ * strict: true,
13
+ * responses: {
14
+ * gather: { data: { logs: [] } },
15
+ * investigate: { data: { root_cause: 'NPE in handler', severity: 'high' } },
18
16
  * },
19
17
  * })
20
18
  * ```
@@ -33,11 +31,13 @@ export interface MockNodeResponse {
33
31
  status?: "success" | "skipped" | "failed";
34
32
  }
35
33
  export interface MockHarnessOptions {
34
+ /** Refuse incomplete or ambiguous fixtures. Does not isolate supplied tool handlers. */
35
+ strict?: boolean;
36
36
  /** Node ID → scripted response */
37
37
  responses: Record<string, MockNodeResponse>;
38
38
  /** Route decisions: "fromNode" → chosen target node ID */
39
39
  routes?: Record<string, string>;
40
- /** Workflow definition — enables instruction-based node matching (required for branching workflows) */
40
+ /** Enables instruction matching for manual calls without nodeId; execute() supplies node IDs. */
41
41
  workflow?: Workflow;
42
42
  /** Scripted handler for `ask()` calls. */
43
43
  ask?: (instruction: string, context: Record<string, unknown>) => string;
@@ -57,6 +57,7 @@ export declare class MockHarness implements Claude, AgentHarness {
57
57
  private responses;
58
58
  private routes;
59
59
  private instructionMap;
60
+ private strict;
60
61
  private askFn?;
61
62
  constructor(opts: MockHarnessOptions);
62
63
  /** Returns the order in which nodes were executed */
@@ -70,6 +71,7 @@ export declare class MockHarness implements Claude, AgentHarness {
70
71
  prompt: string;
71
72
  }): Promise<string | null>;
72
73
  run(opts: {
74
+ nodeId?: string;
73
75
  instruction: string;
74
76
  context: Record<string, unknown>;
75
77
  tools: Tool[];
@@ -92,9 +94,11 @@ export declare class MockHarness implements Claude, AgentHarness {
92
94
  * Identify which node is being executed.
93
95
  *
94
96
  * Strategy:
95
- * 1. If a workflow was provided, match by instruction text (accurate for branching)
96
- * 2. Otherwise, fall back to sequential key matching (works for linear DAGs)
97
+ * Manual calls without nodeId can match a unique workflow instruction.
98
+ * Legacy mode also permits heuristic/sequential matching for compatibility.
99
+ * Strict mode never guesses from response order or instruction substrings.
97
100
  */
101
+ private fixtureFailure;
98
102
  private identifyNode;
99
103
  }
100
104
  /** @deprecated Use {@link MockHarnessOptions}. */
package/dist/testing.js CHANGED
@@ -1,20 +1,18 @@
1
1
  /**
2
2
  * Test Helpers
3
3
  *
4
- * MockClaude for running workflows without an API key.
4
+ * MockHarness for running workflows without an API key.
5
5
  * File-based skill for local testing.
6
6
  *
7
7
  * @example
8
8
  * ```ts
9
- * import { MockClaude, fileSkill } from '@sweny-ai/core/testing'
9
+ * import { MockHarness } from '@sweny-ai/core/testing'
10
10
  *
11
- * const claude = new MockClaude({
12
- * gather: {
13
- * toolCalls: [{ tool: 'fs_read_json', input: { path: 'logs.json' } }],
14
- * data: { logs: [...] },
15
- * },
16
- * investigate: {
17
- * data: { root_cause: 'NPE in handler', severity: 'high' },
11
+ * const harness = new MockHarness({
12
+ * strict: true,
13
+ * responses: {
14
+ * gather: { data: { logs: [] } },
15
+ * investigate: { data: { root_cause: 'NPE in handler', severity: 'high' } },
18
16
  * },
19
17
  * })
20
18
  * ```
@@ -47,9 +45,11 @@ export class MockHarness {
47
45
  callOrder = [];
48
46
  responses;
49
47
  routes;
50
- instructionMap; // instruction text → node ID
48
+ instructionMap; // instruction text → matching node IDs
49
+ strict;
51
50
  askFn;
52
51
  constructor(opts) {
52
+ this.strict = opts.strict ?? false;
53
53
  this.responses = opts.responses;
54
54
  this.routes = opts.routes ?? {};
55
55
  this.askFn = opts.ask;
@@ -59,7 +59,9 @@ export class MockHarness {
59
59
  for (const [id, node] of Object.entries(opts.workflow.nodes)) {
60
60
  // Source can be a string or an object — only index string instructions
61
61
  if (typeof node.instruction === "string") {
62
- this.instructionMap.set(node.instruction, id);
62
+ const ids = this.instructionMap.get(node.instruction) ?? [];
63
+ ids.push(id);
64
+ this.instructionMap.set(node.instruction, ids);
63
65
  }
64
66
  }
65
67
  }
@@ -80,19 +82,29 @@ export class MockHarness {
80
82
  return { ...result, harness: { id: "mock", version: "mock" }, degraded: [] };
81
83
  }
82
84
  async runScripted(opts) {
83
- // Identify which node this is by matching instruction text against responses
84
- // The executor wraps tools with event tracking, so we can find the node
85
- // by checking which response key's instruction appears
86
- const nodeId = this.identifyNode(opts.instruction);
85
+ // Executor identity is authoritative. Text matching is for older manual callers.
86
+ const nodeId = opts.nodeId ?? this.identifyNode(opts.instruction);
87
+ if (nodeId === null) {
88
+ return this.fixtureFailure("ambiguous or unknown instruction; pass nodeId or provide a workflow with unique instructions");
89
+ }
87
90
  this.callOrder.push(nodeId);
88
- const response = this.responses[nodeId];
91
+ const response = Object.hasOwn(this.responses, nodeId) ? this.responses[nodeId] : undefined;
89
92
  if (!response) {
93
+ if (this.strict)
94
+ return this.fixtureFailure(`node "${nodeId}" has no scripted response`);
90
95
  return {
91
96
  status: "success",
92
97
  data: { summary: `Mock: no scripted response for "${nodeId}"` },
93
98
  toolCalls: [],
94
99
  };
95
100
  }
101
+ // Check the entire script before executing any handler, so an incomplete
102
+ // fixture cannot perform an earlier write before discovering a missing tool.
103
+ if (this.strict) {
104
+ const missing = response.toolCalls?.find((tc) => !opts.tools.some((tool) => tool.name === tc.tool));
105
+ if (missing)
106
+ return this.fixtureFailure(`node "${nodeId}" requests unavailable tool "${missing.tool}"`);
107
+ }
96
108
  // Execute scripted tool calls
97
109
  const toolCalls = [];
98
110
  if (response.toolCalls) {
@@ -136,16 +148,29 @@ export class MockHarness {
136
148
  * Identify which node is being executed.
137
149
  *
138
150
  * Strategy:
139
- * 1. If a workflow was provided, match by instruction text (accurate for branching)
140
- * 2. Otherwise, fall back to sequential key matching (works for linear DAGs)
151
+ * Manual calls without nodeId can match a unique workflow instruction.
152
+ * Legacy mode also permits heuristic/sequential matching for compatibility.
153
+ * Strict mode never guesses from response order or instruction substrings.
141
154
  */
155
+ fixtureFailure(reason) {
156
+ return {
157
+ status: "failed",
158
+ data: { error: `MockHarness fixture error: ${reason}`, refused: true },
159
+ toolCalls: [],
160
+ };
161
+ }
142
162
  identifyNode(instruction) {
143
163
  // 1. Instruction-based matching (accurate for branching workflows)
144
164
  if (this.instructionMap.size > 0) {
145
- const nodeId = this.instructionMap.get(instruction);
146
- if (nodeId && nodeId in this.responses)
165
+ const matches = this.instructionMap.get(instruction);
166
+ if (this.strict)
167
+ return matches?.length === 1 ? matches[0] : null;
168
+ const nodeId = matches?.at(-1);
169
+ if (nodeId && Object.hasOwn(this.responses, nodeId))
147
170
  return nodeId;
148
171
  }
172
+ if (this.strict)
173
+ return null;
149
174
  // 2. Check if any response key appears literally in the instruction
150
175
  const keys = Object.keys(this.responses);
151
176
  for (const key of keys) {