@sweny-ai/core 0.1.188 → 0.1.190

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.
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { parseEnv } from "node:util";
3
4
  import { parse as parseYaml } from "yaml";
4
5
  // Imported for its side effect too: the startup env snapshot is taken before loadDotenv runs.
5
6
  import { markWorkspaceEnv } from "../startup-env.js";
@@ -32,19 +33,9 @@ export function loadDotenv(cwd = process.cwd()) {
32
33
  catch {
33
34
  return; // no .env — silently skip
34
35
  }
35
- for (const line of content.split("\n")) {
36
- const trimmed = line.trim();
37
- if (!trimmed || trimmed.startsWith("#"))
38
- continue;
39
- const eqIndex = trimmed.indexOf("=");
40
- if (eqIndex === -1)
41
- continue;
42
- const key = trimmed.slice(0, eqIndex).trim();
43
- let value = trimmed.slice(eqIndex + 1).trim();
44
- // Strip surrounding quotes
45
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
46
- value = value.slice(1, -1);
47
- }
36
+ // Node's own dotenv parser (util.parseEnv): inline `# comments`, quoted
37
+ // values, `export KEY=`, escaped and multiline double-quoted values, CRLF.
38
+ for (const [key, value] of Object.entries(parseEnv(content))) {
48
39
  if (process.env[key] !== undefined)
49
40
  continue;
50
41
  if (isDotenvDenied(key)) {
@@ -16,7 +16,7 @@
16
16
  * same `isError` status.
17
17
  */
18
18
  import { z } from "zod";
19
- import type { JSONSchema, Tool, ToolContext } from "../../types.js";
19
+ import { type JSONSchema, type Tool, type ToolContext } from "../../types.js";
20
20
  export declare const BRIDGE_PROTOCOL_VERSION = 1;
21
21
  /** Frames longer than this are refused and the connection is dropped. */
22
22
  export declare const MAX_FRAME_BYTES: number;
@@ -73,7 +73,11 @@ export declare function createFrameReader(onFrame: (value: unknown) => void, onO
73
73
  export declare function tokenMatches(expected: string, given: unknown): boolean;
74
74
  /** Structural check of an incoming request frame (token checked separately). */
75
75
  export declare function parseRequest(value: unknown): BridgeRequest | undefined;
76
- /** A handler's return value as MCP content. Strings pass through; anything else is JSON. */
76
+ /**
77
+ * A handler's return value as MCP content. Strings pass through; anything else
78
+ * is JSON. A {@link ToolRefusal} is a tool error (#544): the agent and the
79
+ * tool-call status (and so evals) see that the write did not happen.
80
+ */
77
81
  export declare function toolOutputToMcpResult(output: unknown): McpCallToolResult;
78
82
  /** A handler's thrown error as an MCP error result. */
79
83
  export declare function toolErrorToMcpResult(err: unknown): McpCallToolResult;
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { timingSafeEqual } from "node:crypto";
19
19
  import { z } from "zod";
20
+ import { isToolRefusal } from "../../types.js";
20
21
  export const BRIDGE_PROTOCOL_VERSION = 1;
21
22
  /** Frames longer than this are refused and the connection is dropped. */
22
23
  export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
@@ -86,8 +87,15 @@ export function parseRequest(value) {
86
87
  return value;
87
88
  }
88
89
  // ─── Tool result shape (shared with the in-process Claude path) ─────
89
- /** A handler's return value as MCP content. Strings pass through; anything else is JSON. */
90
+ /**
91
+ * A handler's return value as MCP content. Strings pass through; anything else
92
+ * is JSON. A {@link ToolRefusal} is a tool error (#544): the agent and the
93
+ * tool-call status (and so evals) see that the write did not happen.
94
+ */
90
95
  export function toolOutputToMcpResult(output) {
96
+ if (isToolRefusal(output)) {
97
+ return { content: [{ type: "text", text: `Refused: ${output.reason}\n${JSON.stringify(output)}` }], isError: true };
98
+ }
91
99
  return {
92
100
  content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
93
101
  };
package/dist/journal.d.ts CHANGED
@@ -47,6 +47,8 @@
47
47
  * write intents, written before the write stage runs
48
48
  * output:intent a write is about to be applied (idempotency key + tool)
49
49
  * output:applied the write returned (key + the ids it produced)
50
+ * output:refused the write's handler returned a refusal (#544): key, its
51
+ * fixed-wording reason and any ids it names; never applied
50
52
  * node:end final result of the visit and the write-stage counters
51
53
  * route the edge taken after the visit (null = the run ended), and the rung that chose it
52
54
  * usage spend of one agent attempt (cumulative; one per model turn that raised it, written
@@ -84,6 +86,15 @@
84
86
  * key with a receipt is not re-applied, and a key with an intent but no receipt
85
87
  * (the process died between the API call and the receipt) is looked up on the
86
88
  * provider by a marker embedded in the body before anything is re-sent.
89
+ *
90
+ * A handler that returns a refusal (`ToolRefusal`) gets `output:refused`
91
+ * instead of `output:applied`. On resume that refusal is replayed as is: the
92
+ * handler is not called and the provider is not searched (a marked write the
93
+ * refusal named would otherwise be taken for an applied one). The one
94
+ * exception is a refusal marked `transient` that names nothing it produced:
95
+ * nothing was written, so it goes through the unconfirmed-intent path above
96
+ * (provider lookup, then a first send). `--allow-repeat-writes` does not
97
+ * override a refusal: its outcome is known, not unconfirmed.
87
98
  */
88
99
  import type { Logger, NodeResult, NodeUsage, RouteRung, Skill, Workflow } from "./types.js";
89
100
  import type { SafeOutputIntent, WriteStageState } from "./safe-outputs.js";
@@ -335,6 +346,8 @@ export interface PlannedVisit {
335
346
  rung?: RouteRung;
336
347
  /** Writes the checkpointed write stage already applied (receipts in the journal). */
337
348
  applied: number;
349
+ /** Writes whose handler refused (#544): the refusal is replayed, not re-sent. */
350
+ refused: number;
338
351
  /** Writes with an intent but no receipt: checked on the provider before anything is re-sent. */
339
352
  unconfirmed: number;
340
353
  }
@@ -356,6 +369,8 @@ export interface ResumePlan {
356
369
  ends: Map<string, EndRecord>;
357
370
  intents: Map<string, OutputRecord>;
358
371
  receipts: Map<string, OutputRecord>;
372
+ /** Writes whose handler refused (`output:refused`, #544): replayed on resume, never taken for applied. */
373
+ refusals: Map<string, OutputRecord>;
359
374
  /** Spend every earlier attempt journaled: the run budget starts here on resume. */
360
375
  priorSpend: Spend;
361
376
  /**
package/dist/journal.js CHANGED
@@ -47,6 +47,8 @@
47
47
  * write intents, written before the write stage runs
48
48
  * output:intent a write is about to be applied (idempotency key + tool)
49
49
  * output:applied the write returned (key + the ids it produced)
50
+ * output:refused the write's handler returned a refusal (#544): key, its
51
+ * fixed-wording reason and any ids it names; never applied
50
52
  * node:end final result of the visit and the write-stage counters
51
53
  * route the edge taken after the visit (null = the run ended), and the rung that chose it
52
54
  * usage spend of one agent attempt (cumulative; one per model turn that raised it, written
@@ -84,11 +86,21 @@
84
86
  * key with a receipt is not re-applied, and a key with an intent but no receipt
85
87
  * (the process died between the API call and the receipt) is looked up on the
86
88
  * provider by a marker embedded in the body before anything is re-sent.
89
+ *
90
+ * A handler that returns a refusal (`ToolRefusal`) gets `output:refused`
91
+ * instead of `output:applied`. On resume that refusal is replayed as is: the
92
+ * handler is not called and the provider is not searched (a marked write the
93
+ * refusal named would otherwise be taken for an applied one). The one
94
+ * exception is a refusal marked `transient` that names nothing it produced:
95
+ * nothing was written, so it goes through the unconfirmed-intent path above
96
+ * (provider lookup, then a first send). `--allow-repeat-writes` does not
97
+ * override a refusal: its outcome is known, not unconfirmed.
87
98
  */
88
99
  import crypto from "node:crypto";
89
100
  import fs from "node:fs";
90
101
  import os from "node:os";
91
102
  import path from "node:path";
103
+ import { isToolRefusal } from "./types.js";
92
104
  import { resolveNodePermissions } from "./node-policy.js";
93
105
  import { CURRENT_SPEC_VERSION } from "./migrations.js";
94
106
  import { spendOf } from "./budget.js";
@@ -840,6 +852,7 @@ const RECORD_TYPES = new Set([
840
852
  "node:checkpoint",
841
853
  "output:intent",
842
854
  "output:applied",
855
+ "output:refused",
843
856
  "node:end",
844
857
  "route",
845
858
  "usage",
@@ -937,6 +950,7 @@ export function checkRecordSequence(records) {
937
950
  }
938
951
  case "output:intent":
939
952
  case "output:applied":
953
+ case "output:refused":
940
954
  inVisit(r);
941
955
  if (!checkpointed.has(visitKey(r.node, r.iteration))) {
942
956
  bad(r, "records a write before the visit's checkpoint");
@@ -1057,6 +1071,7 @@ export function buildResumePlan(records) {
1057
1071
  const byKey = new Map();
1058
1072
  const intents = new Map();
1059
1073
  const receipts = new Map();
1074
+ const refusals = new Map();
1060
1075
  let attempts = 1;
1061
1076
  let lastStatus;
1062
1077
  let deciderCounters;
@@ -1113,6 +1128,9 @@ export function buildResumePlan(records) {
1113
1128
  case "output:applied":
1114
1129
  receipts.set(r.key, r);
1115
1130
  break;
1131
+ case "output:refused":
1132
+ refusals.set(r.key, r);
1133
+ break;
1116
1134
  case "run:end":
1117
1135
  lastStatus = r.status;
1118
1136
  break;
@@ -1129,7 +1147,8 @@ export function buildResumePlan(records) {
1129
1147
  const complete = !!v.end && !(isLast && v.end.result.status === "failed" && typeof v.next !== "string");
1130
1148
  const keys = (rec) => [...rec.values()].filter((o) => o.node === v.node && o.iteration === v.iteration).map((o) => o.key);
1131
1149
  const applied = keys(receipts).length;
1132
- const unconfirmed = keys(intents).filter((k) => !receipts.has(k)).length;
1150
+ const refused = keys(refusals).filter((k) => !receipts.has(k)).length;
1151
+ const unconfirmed = keys(intents).filter((k) => !receipts.has(k) && !refusals.has(k)).length;
1133
1152
  if (complete && !resumed) {
1134
1153
  visits.push({
1135
1154
  node: v.node,
@@ -1139,6 +1158,7 @@ export function buildResumePlan(records) {
1139
1158
  next: v.next,
1140
1159
  ...(v.rung ? { rung: v.rung } : {}),
1141
1160
  applied,
1161
+ refused,
1142
1162
  unconfirmed,
1143
1163
  });
1144
1164
  writeState = v.end.write_state;
@@ -1150,6 +1170,7 @@ export function buildResumePlan(records) {
1150
1170
  iteration: v.iteration,
1151
1171
  action: v.checkpoint ? "write-stage" : "rerun",
1152
1172
  applied,
1173
+ refused,
1153
1174
  unconfirmed,
1154
1175
  });
1155
1176
  break;
@@ -1180,6 +1201,7 @@ export function buildResumePlan(records) {
1180
1201
  ends,
1181
1202
  intents,
1182
1203
  receipts,
1204
+ refusals,
1183
1205
  priorSpend: journaledSpend(records),
1184
1206
  unsandboxed: [...new Set(records.filter((r) => r.type === "agent:unsandboxed").map((r) => String(r.node)))],
1185
1207
  ...(start.decider
@@ -1336,6 +1358,14 @@ function slimOutput(o, depth = 0) {
1336
1358
  if (!o || typeof o !== "object" || Array.isArray(o) || depth > 3)
1337
1359
  return null;
1338
1360
  const out = {};
1361
+ // A refusal keeps what it is and its fixed-wording reason (#544); never `detail`.
1362
+ if (depth === 0 && o.refused === true) {
1363
+ out.refused = true;
1364
+ if (isToolRefusal(o))
1365
+ out.reason = o.reason;
1366
+ if (o.transient === true)
1367
+ out.transient = true;
1368
+ }
1339
1369
  for (const [k, v] of Object.entries(o)) {
1340
1370
  if (["number", "identifier", "id", "html_url", "url", "success", "reused"].includes(k)) {
1341
1371
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
@@ -1805,6 +1835,12 @@ export class RunJournal {
1805
1835
  this.logger.info(` run journal: ${tool.name} already applied before the crash; not re-sent`, { node });
1806
1836
  return receipt.output ?? null;
1807
1837
  }
1838
+ const refusal = plan?.refusals.get(key);
1839
+ if (refusal && !retriesRefusal(refusal.output)) {
1840
+ this.logger.info(` run journal: ${tool.name} was refused in an earlier attempt; not re-sent`, { node });
1841
+ const prior = refusal.output && typeof refusal.output === "object" ? refusal.output : {};
1842
+ return { reason: "the tool refused the write", ...prior, refused: true };
1843
+ }
1808
1844
  const replayingStage = !!plan?.checkpoints.has(visitKey(node, iteration));
1809
1845
  const pending = plan?.intents.has(key) === true;
1810
1846
  if (pending || replayingStage) {
@@ -1826,12 +1862,30 @@ export class RunJournal {
1826
1862
  // A write the journal cannot record is never sent: append throws first.
1827
1863
  this.append("output:intent", { node, iteration, key, tool: tool.name });
1828
1864
  const output = await tool.handler(withMarker(tool.name, args, key), ctx);
1829
- this.append("output:applied", { node, iteration, key, tool: tool.name, output: slimOutput(output) });
1865
+ const refused = !!output && typeof output === "object" && output.refused === true;
1866
+ this.append(refused ? "output:refused" : "output:applied", {
1867
+ node,
1868
+ iteration,
1869
+ key,
1870
+ tool: tool.name,
1871
+ output: slimOutput(output),
1872
+ });
1830
1873
  return output;
1831
1874
  },
1832
1875
  };
1833
1876
  }
1834
1877
  }
1878
+ /**
1879
+ * Whether a journaled refusal is tried again on resume (#544): only a
1880
+ * transient one that names nothing it produced. Anything else stands.
1881
+ */
1882
+ function retriesRefusal(output) {
1883
+ if (!output || typeof output !== "object")
1884
+ return false;
1885
+ const o = output;
1886
+ // Anything besides the refusal itself is something the write produced.
1887
+ return o.transient === true && Object.keys(o).every((k) => k === "refused" || k === "reason" || k === "transient");
1888
+ }
1835
1889
  /** What a node result needs to continue a run. Tool call inputs and outputs are dropped. */
1836
1890
  function journalResult(r) {
1837
1891
  return {
@@ -114,7 +114,7 @@ export interface ApplySafeOutputsOptions {
114
114
  }
115
115
  export interface ApplySafeOutputsResult {
116
116
  receipts: SafeOutputReceipt[];
117
- /** Set when an apply call failed; the node fails with it. */
117
+ /** Set when an apply call failed or its handler refused (a `ToolRefusal`); the node fails with it. */
118
118
  error?: string;
119
119
  }
120
120
  /** Check, then apply (or stage) one node's intents. See the module comment for the order of checks. */
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
17
  import { readFileSync } from "node:fs";
18
- import { SAFE_OUTPUT_APPLIERS } from "./types.js";
18
+ import { SAFE_OUTPUT_APPLIERS, isToolRefusal } from "./types.js";
19
19
  /** Name of the intent-recording tool a node with `outputs` receives. */
20
20
  export const EMIT_OUTPUT_TOOL = "emit_output";
21
21
  /** Longest title the write stage accepts (after the prefix). */
@@ -574,7 +574,22 @@ export async function applySafeOutputs(o) {
574
574
  });
575
575
  const ref = refOf(output);
576
576
  const url = urlOf(output);
577
- receipts[index] = { ...receipts[index], ...(ref !== undefined ? { ref } : {}), ...(url ? { url } : {}) };
577
+ const produced = { ...(ref !== undefined ? { ref } : {}), ...(url ? { url } : {}) };
578
+ const refused = !!output && typeof output === "object" && output.refused === true;
579
+ if (refused) {
580
+ // #544: the handler declined the write, or could not vouch for it. The
581
+ // receipt says refused with the handler's fixed-wording reason (and
582
+ // names anything it did create); the node fails and the rest are
583
+ // skipped, the same as a failed apply. A refusal without a usable
584
+ // reason still counts as one.
585
+ const reason = isToolRefusal(output) ? output.reason.slice(0, 200) : "the tool refused the write";
586
+ receipts[index] = { ...receipts[index], ...produced, status: "refused", reason };
587
+ for (const rest of accepted.slice(n + 1)) {
588
+ receipts[rest.index] = { ...receipts[rest.index], status: "skipped", reason: "an earlier write was refused" };
589
+ }
590
+ return { receipts, error: `safe output ${write.type} via ${write.via} was refused: ${reason}` };
591
+ }
592
+ receipts[index] = { ...receipts[index], ...produced };
578
593
  o.logger.info(` safe output: ${write.type} via ${write.via} applied${ref !== undefined ? ` (${ref})` : ""}`, {
579
594
  node: o.nodeId,
580
595
  });
@@ -110,6 +110,12 @@ export interface PushHeadResult {
110
110
  attempted: boolean;
111
111
  /** Why nothing was pushed, or why the push failed. Never carries the token. */
112
112
  reason?: string;
113
+ /**
114
+ * The local commit the push tried to publish. Set when the push ran and
115
+ * failed: `github_create_pr` then opens the PR only if the remote head is
116
+ * this commit (#544).
117
+ */
118
+ sha?: string;
113
119
  }
114
120
  /** `owner/repo` from a GitHub remote URL (https, ssh or scp form), lowercased, or undefined. */
115
121
  export declare function repoFromRemote(url: string): {
@@ -459,7 +459,7 @@ export async function pushHeadBranch(opts) {
459
459
  ], pushEnv);
460
460
  if (r.code !== 0) {
461
461
  const why = (r.stderr || r.stdout).trim().split("\n").at(-1) ?? "";
462
- return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, token)}` };
462
+ return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, token)}`, sha };
463
463
  }
464
464
  return { pushed: true, attempted: true };
465
465
  }
@@ -19,12 +19,13 @@ export declare function githubApiBase(apiUrl: string | undefined): string;
19
19
  * remotely is pushed with an empty lease (it must still not exist). One that
20
20
  * exists is pushed only when it is unprotected and already has an open PR from
21
21
  * this repo into `base`, leased on the sha the API returned. Any API failure
22
- * means no push.
22
+ * means no push. A skip carries the remote head sha when the API returned one.
23
23
  */
24
24
  export declare function headPushLease(repo: string, head: string, base: string, ctx: ToolContext): Promise<{
25
25
  remoteSha: string;
26
26
  } | {
27
27
  skip: string;
28
+ remoteSha?: string;
28
29
  }>;
29
30
  /**
30
31
  * Encode an LLM-chosen repo file path for safe interpolation into a
@@ -56,7 +56,7 @@ async function gh(path, ctx, init) {
56
56
  * remotely is pushed with an empty lease (it must still not exist). One that
57
57
  * exists is pushed only when it is unprotected and already has an open PR from
58
58
  * this repo into `base`, leased on the sha the API returned. Any API failure
59
- * means no push.
59
+ * means no push. A skip carries the remote head sha when the API returned one.
60
60
  */
61
61
  export async function headPushLease(repo, head, base, ctx) {
62
62
  const branch = head.replace(/^refs\/heads\//, "");
@@ -76,7 +76,7 @@ export async function headPushLease(repo, head, base, ctx) {
76
76
  return { skip: `cannot check head ${branch} on ${repo}: no branch sha in the API answer` };
77
77
  }
78
78
  if (info.protected !== false)
79
- return { skip: `head ${branch} exists on ${repo} and is protected` };
79
+ return { skip: `head ${branch} exists on ${repo} and is protected`, remoteSha: sha };
80
80
  const owner = repo.split("/")[0];
81
81
  let open;
82
82
  try {
@@ -85,13 +85,45 @@ export async function headPushLease(repo, head, base, ctx) {
85
85
  catch (err) {
86
86
  return {
87
87
  skip: `cannot check open PRs for ${branch} on ${repo}: ${err instanceof Error ? err.message : String(err)}`,
88
+ remoteSha: sha,
88
89
  };
89
90
  }
90
91
  if (!Array.isArray(open) || open.length === 0) {
91
- return { skip: `head ${branch} already exists on ${repo} with no open PR into ${target}` };
92
+ return { skip: `head ${branch} already exists on ${repo} with no open PR into ${target}`, remoteSha: sha };
92
93
  }
93
94
  return { remoteSha: sha };
94
95
  }
96
+ const SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
97
+ // Fixed-wording refusal reasons for `github_create_pr` (#544). Receipts and PR
98
+ // comments carry these; shas and upstream errors go in the result's `detail`.
99
+ const REFUSE_NO_LOCAL = "the head push failed and the pusher did not report the local commit";
100
+ const REFUSE_UNREADABLE = "the head push failed and the remote head could not be read";
101
+ const REFUSE_MISMATCH = "the head push failed and the remote head is not the commit this run produced";
102
+ const REFUSE_PR_MISMATCH = "the PR was opened but its head is not the commit this run produced";
103
+ const REFUSE_PR_UNVERIFIED = "the PR was opened but its head commit could not be verified";
104
+ /**
105
+ * The sha the remote `head` branch points at, read through the API (#544), or
106
+ * an error. A missing branch is `{ sha: null }`; anything unreadable is an error,
107
+ * so the caller fails closed.
108
+ */
109
+ async function remoteHeadSha(repo, head, ctx) {
110
+ const branch = head.replace(/^refs\/heads\//, "");
111
+ const seg = branch.split("/").map(encodeURIComponent).join("/");
112
+ let ref;
113
+ try {
114
+ ref = (await gh(`/repos/${repo}/git/ref/heads/${seg}`, ctx));
115
+ }
116
+ catch (err) {
117
+ if (err instanceof GitHubApiError && err.status === 404)
118
+ return { sha: null };
119
+ return { error: err instanceof Error ? err.message : String(err) };
120
+ }
121
+ const sha = ref?.object?.sha;
122
+ if (ref?.ref !== `refs/heads/${branch}` || typeof sha !== "string" || !SHA.test(sha)) {
123
+ return { error: "no matching ref in the API answer" };
124
+ }
125
+ return { sha };
126
+ }
95
127
  function isAlreadyExistsError(err) {
96
128
  if (!(err instanceof GitHubApiError) || err.status !== 422)
97
129
  return false;
@@ -280,6 +312,10 @@ export const github = {
280
312
  handler: async (input, ctx) => {
281
313
  let pr;
282
314
  let reused = false;
315
+ // The remote head this run saw, for the result (#544).
316
+ let observedHead;
317
+ // Set after sweny's own push failed: the PR's head must be this commit (#544).
318
+ let mustHead;
283
319
  // #473: push the head branch from sweny with this skill's token, so the
284
320
  // recommended `persist-credentials: false` checkout still ships the
285
321
  // branch and the agent never holds a write token. Skipped unless the
@@ -307,12 +343,49 @@ export const github = {
307
343
  remoteSha: lease.remoteSha,
308
344
  token: ctx.config.GITHUB_TOKEN,
309
345
  });
346
+ if (lease.remoteSha)
347
+ observedHead = lease.remoteSha;
310
348
  if ("skip" in lease)
311
349
  ctx.logger?.warn?.(` github_create_pr: not pushing: ${lease.skip}`);
312
350
  else if (push.pushed)
313
351
  ctx.logger?.info?.(` github_create_pr: pushed ${input.head} to ${input.repo}`);
314
- else if (push.attempted)
315
- ctx.logger?.warn?.(` github_create_pr: ${push.reason}; requesting the PR anyway`);
352
+ else if (push.attempted) {
353
+ // #544: a push sweny ran and that failed. The PR would publish
354
+ // whatever the remote branch holds, so open it only when that is
355
+ // the commit this run would have pushed. Anything else, or a
356
+ // remote head that cannot be read, refuses.
357
+ const local = typeof push.sha === "string" && SHA.test(push.sha) ? push.sha : null;
358
+ const remote = local ? await remoteHeadSha(input.repo, input.head, ctx) : undefined;
359
+ const remoteSha = remote && "sha" in remote ? remote.sha : null;
360
+ if (!local || !remoteSha || remoteSha !== local) {
361
+ const [reason, why] = !local
362
+ ? [REFUSE_NO_LOCAL, "the pusher did not report the local commit"]
363
+ : remote && "error" in remote
364
+ ? [REFUSE_UNREADABLE, `cannot read the remote head of ${input.head}: ${remote.error}`]
365
+ : remoteSha
366
+ ? [
367
+ REFUSE_MISMATCH,
368
+ `the remote head of ${input.head} is ${remoteSha}, not the local commit ${local}`,
369
+ ]
370
+ : [REFUSE_MISMATCH, `${input.head} does not exist on ${input.repo}; the local commit is ${local}`];
371
+ const detail = `sweny's push failed (${push.reason}) and ${why}`;
372
+ ctx.logger?.warn?.(` github_create_pr: refusing to open the PR: ${detail}`);
373
+ const refusal = {
374
+ refused: true,
375
+ reason,
376
+ detail,
377
+ // A ref read that failed may succeed later; nothing was written.
378
+ ...(reason === REFUSE_UNREADABLE ? { transient: true } : {}),
379
+ head: input.head,
380
+ local_sha: local,
381
+ remote_sha: remoteSha,
382
+ };
383
+ return refusal;
384
+ }
385
+ observedHead = remoteSha;
386
+ mustHead = local;
387
+ ctx.logger?.warn?.(` github_create_pr: ${push.reason}; the remote head is already the local commit ${local}, requesting the PR`);
388
+ }
316
389
  else
317
390
  ctx.logger?.debug?.(` github_create_pr: no sweny-side push (${push.reason})`);
318
391
  }
@@ -347,6 +420,29 @@ export const github = {
347
420
  pr = existing;
348
421
  reused = true;
349
422
  }
423
+ // #544: the remote could move between the ref read and the request. The
424
+ // PR exists now; it is not closed (that would be another write nobody
425
+ // asked for), but the result refuses to vouch for it.
426
+ const prHead = pr.head?.sha;
427
+ const prHeadSha = typeof prHead === "string" && SHA.test(prHead) ? prHead : null;
428
+ if (mustHead && prHeadSha !== mustHead) {
429
+ const detail = prHeadSha
430
+ ? `the PR's head is ${prHeadSha}, not the local commit ${mustHead}`
431
+ : `GitHub did not report the PR's head commit; the local commit is ${mustHead}`;
432
+ ctx.logger?.warn?.(` github_create_pr: ${pr.html_url ?? `#${pr.number}`} is not verified: ${detail}`);
433
+ const refusal = {
434
+ refused: true,
435
+ reason: prHeadSha ? REFUSE_PR_MISMATCH : REFUSE_PR_UNVERIFIED,
436
+ detail,
437
+ ...(pr.number !== undefined ? { number: pr.number } : {}),
438
+ ...(pr.html_url ? { html_url: pr.html_url } : {}),
439
+ ...(reused ? { reused: true } : {}),
440
+ head: input.head,
441
+ local_sha: mustHead,
442
+ remote_sha: prHeadSha,
443
+ };
444
+ return refusal;
445
+ }
350
446
  try {
351
447
  if (pr.number && !reused) {
352
448
  await gh(`/repos/${input.repo}/issues/${pr.number}/labels`, ctx, {
@@ -364,7 +460,10 @@ export const github = {
364
460
  const msg = err instanceof Error ? err.message : String(err);
365
461
  ctx.logger?.warn?.(` github_create_pr: label POST failed for ${pr.html_url ?? `#${pr.number}`} — ${msg}`);
366
462
  }
367
- return reused ? { ...pr, reused: true } : pr;
463
+ // The head the PR was opened from: GitHub's answer, else the remote head this run read.
464
+ const headSha = prHeadSha ?? observedHead;
465
+ const out = headSha ? { ...pr, head_sha: headSha } : pr;
466
+ return reused ? { ...out, reused: true } : out;
368
467
  },
369
468
  },
370
469
  {
package/dist/types.d.ts CHANGED
@@ -48,6 +48,11 @@ export type BranchPusher = (opts: {
48
48
  pushed: boolean;
49
49
  attempted: boolean;
50
50
  reason?: string;
51
+ /**
52
+ * The local commit the push tried to publish. Set when a push was attempted
53
+ * and failed, so the caller can check what the remote head holds (#544).
54
+ */
55
+ sha?: string;
51
56
  }>;
52
57
  /**
53
58
  * Side-effect class of a tool. `"read"` only reads. `"write"` can change
@@ -68,6 +73,32 @@ export interface Tool {
68
73
  access?: ToolAccess;
69
74
  handler: (input: any, ctx: ToolContext) => Promise<unknown>;
70
75
  }
76
+ /**
77
+ * A write handler's structured refusal: it declined the write, or could not
78
+ * vouch for what it wrote, and says why. Returned, not thrown, so the caller
79
+ * keeps the details. `refused: true` is the same flag a refused node result
80
+ * carries (`data: { error, refused: true }`). The write stage records it as
81
+ * refused, never applied (#544).
82
+ *
83
+ * `reason` is fixed wording chosen by the handler, never model, agent or
84
+ * remote text: receipts and PR comments carry it. Specifics (shas, branch
85
+ * names, upstream errors) go in `detail` and other fields, which only the
86
+ * tool result carries. A refusal may still name what exists (`number`,
87
+ * `html_url`) when the write happened but could not be verified.
88
+ */
89
+ export interface ToolRefusal {
90
+ refused: true;
91
+ reason: string;
92
+ detail?: string;
93
+ /**
94
+ * The refusal may not hold on a later try (an upstream read failed) and
95
+ * nothing was written. Only such a refusal is tried again when a run is
96
+ * resumed; any other refusal is final for the run.
97
+ */
98
+ transient?: boolean;
99
+ }
100
+ /** Whether a tool handler's output is a {@link ToolRefusal}. */
101
+ export declare function isToolRefusal(output: unknown): output is ToolRefusal;
71
102
  /** Config field declaration — skills say what they need */
72
103
  export interface ConfigField {
73
104
  description: string;
package/dist/types.js CHANGED
@@ -4,6 +4,13 @@ export { WORKFLOW_INPUT_TYPES } from "./inputs.js";
4
4
  * something outside the run: create, update, delete, post, send, invoke.
5
5
  */
6
6
  export const TOOL_ACCESS = ["read", "write"];
7
+ /** Whether a tool handler's output is a {@link ToolRefusal}. */
8
+ export function isToolRefusal(output) {
9
+ if (!output || typeof output !== "object" || Array.isArray(output))
10
+ return false;
11
+ const o = output;
12
+ return o.refused === true && typeof o.reason === "string" && o.reason.length > 0;
13
+ }
7
14
  /**
8
15
  * Skill categories: used for per-node validation and grouping.
9
16
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.188",
3
+ "version": "0.1.190",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"
@@ -8,7 +8,7 @@
8
8
  "description": "Skill library, DAG workflow orchestration, and E2E browser testing powered by Claude",
9
9
  "license": "MIT",
10
10
  "engines": {
11
- "node": ">=20"
11
+ "node": ">=22"
12
12
  },
13
13
  "exports": {
14
14
  ".": "./dist/index.js",