@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.
- package/dist/agent-env.d.ts +2 -1
- package/dist/agent-env.js +9 -3
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +3 -0
- package/dist/budget.js +5 -1
- package/dist/cli/comment-output.d.ts +4 -2
- package/dist/cli/comment-output.js +16 -8
- package/dist/cli/main.js +42 -26
- package/dist/cli/new.js +3 -0
- package/dist/cli/output.d.ts +3 -2
- package/dist/cli/output.js +39 -37
- package/dist/cli/progress.d.ts +33 -0
- package/dist/cli/progress.js +69 -0
- package/dist/cli/renderer.d.ts +4 -0
- package/dist/cli/renderer.js +20 -13
- package/dist/cli/run-output.d.ts +13 -6
- package/dist/cli/run-output.js +36 -26
- package/dist/cli/runs.js +17 -11
- package/dist/cli/style.d.ts +26 -0
- package/dist/cli/style.js +48 -0
- package/dist/cli/terminal.d.ts +47 -0
- package/dist/cli/terminal.js +123 -0
- package/dist/cli/ticket.d.ts +89 -0
- package/dist/cli/ticket.js +218 -0
- package/dist/cli/try.d.ts +15 -8
- package/dist/cli/try.js +84 -65
- package/dist/executor.d.ts +8 -1
- package/dist/executor.js +21 -2
- package/dist/git-credentials.d.ts +106 -0
- package/dist/git-credentials.js +322 -0
- package/dist/harness/acp.js +3 -0
- package/dist/harness/claude-code.d.ts +7 -0
- package/dist/harness/claude-code.js +44 -3
- package/dist/harness/codex.js +3 -0
- package/dist/harness/pi.js +3 -0
- package/dist/harness/policy.d.ts +5 -0
- package/dist/harness/policy.js +14 -0
- package/dist/harness/sandbox-wrapper.d.ts +9 -2
- package/dist/harness/sandbox-wrapper.js +14 -4
- package/dist/harness/types.d.ts +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mermaid.d.ts +10 -5
- package/dist/mermaid.js +11 -13
- package/dist/safe-outputs.js +5 -0
- package/dist/skills/git-push.d.ts +62 -0
- package/dist/skills/git-push.js +132 -0
- package/dist/skills/github.d.ts +17 -0
- package/dist/skills/github.js +23 -0
- package/dist/testing.d.ts +16 -12
- package/dist/testing.js +45 -20
- package/dist/theme.d.ts +185 -0
- package/dist/theme.js +183 -0
- package/dist/types.d.ts +2 -0
- package/dist/workflows/browser.js +2 -2
- package/dist/workflows/implement.yml +2 -1
- package/dist/workflows/triage.yml +2 -1
- 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
|
-
|
|
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
|
|
20
|
+
/** Execution state. With a non-empty map, nodes missing from it render as `pending`. */
|
|
16
21
|
state?: Record<string, NodeStatus>;
|
|
17
|
-
/** Execution trace
|
|
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
|
|
26
|
-
*
|
|
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
|
|
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
|
|
137
|
-
|
|
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
|
|
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(",")}
|
|
152
|
+
lines.push(` linkStyle ${takenIndices.join(",")} ${MERMAID_EDGE_STYLES.taken}`);
|
|
155
153
|
}
|
|
156
154
|
if (notTakenIndices.length > 0) {
|
|
157
|
-
lines.push(` linkStyle ${notTakenIndices.join(",")}
|
|
155
|
+
lines.push(` linkStyle ${notTakenIndices.join(",")} ${MERMAID_EDGE_STYLES.notTaken}`);
|
|
158
156
|
}
|
|
159
157
|
}
|
|
160
158
|
return lines.join("\n");
|
package/dist/safe-outputs.js
CHANGED
|
@@ -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);
|
package/dist/skills/github.d.ts
CHANGED
|
@@ -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.
|
package/dist/skills/github.js
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
9
|
+
* import { MockHarness } from '@sweny-ai/core/testing'
|
|
10
10
|
*
|
|
11
|
-
* const
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
96
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
9
|
+
* import { MockHarness } from '@sweny-ai/core/testing'
|
|
10
10
|
*
|
|
11
|
-
* const
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
*
|
|
140
|
-
*
|
|
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
|
|
146
|
-
if (
|
|
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) {
|