@sweny-ai/core 0.1.189 → 0.1.191

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.
@@ -41,6 +41,11 @@ export interface BuildRunAnswersInput {
41
41
  results: Map<string, NodeResult>;
42
42
  /** Secret values to redact (the same set the journal and output.md use). */
43
43
  secrets?: string[];
44
+ /**
45
+ * The key for redacted key names, asked for only when a key needs one. Default: a random key that
46
+ * lives only in this process (names then differ from run to run, which a diff shows as a change).
47
+ */
48
+ macKey?: () => Buffer;
44
49
  }
45
50
  /**
46
51
  * Declared fields of each successful node, deep, redacted (values and keys). A failed or skipped
@@ -21,7 +21,7 @@
21
21
  import crypto from "node:crypto";
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
- import { redact } from "../journal.js";
24
+ import { redact, workspaceSecretKey } from "../journal.js";
25
25
  import { writeFileNoFollow } from "../safe-file.js";
26
26
  import { RUN_HISTORY_DIR, RUN_ID_RE, listRuns } from "./run-history.js";
27
27
  export const RUN_ANSWERS_SCHEMA_VERSION = 1;
@@ -32,13 +32,56 @@ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
32
32
  // ── Build, write, read ──────────────────────────────────────────
33
33
  const has = (o, k) => Object.hasOwn(o, k);
34
34
  const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
35
+ /** The JSON Schema types a schema declares: `type`, else implied by `properties` / `items`. */
36
+ function declaredTypes(sch) {
37
+ if (typeof sch.type === "string")
38
+ return [sch.type];
39
+ if (Array.isArray(sch.type))
40
+ return sch.type.filter((t) => typeof t === "string");
41
+ if (isObj(sch.properties))
42
+ return ["object"];
43
+ if (isObj(sch.items))
44
+ return ["array"];
45
+ return undefined;
46
+ }
47
+ function typeMatches(value, types) {
48
+ return types.some((t) => {
49
+ switch (t) {
50
+ case "string":
51
+ return typeof value === "string";
52
+ case "number":
53
+ return typeof value === "number" && Number.isFinite(value);
54
+ case "integer":
55
+ return typeof value === "number" && Number.isInteger(value);
56
+ case "boolean":
57
+ return typeof value === "boolean";
58
+ case "null":
59
+ return value === null;
60
+ case "object":
61
+ return isObj(value);
62
+ case "array":
63
+ return Array.isArray(value);
64
+ default:
65
+ return false;
66
+ }
67
+ });
68
+ }
35
69
  /**
36
- * Keep only what the schema declares, deep: `findings[].title` keeps `title` and nothing else. An
37
- * object with no declared properties is dropped (nothing in it was declared); scalars are kept.
38
- * Undefined means "drop".
70
+ * Keep only what the schema declares, deep, and only values whose shape matches it: `findings[].title`
71
+ * keeps `title` and nothing else, a string where an object is declared is dropped, as is a value of
72
+ * the wrong `type` or outside an `enum`. An object with no declared properties is dropped (nothing in
73
+ * it was declared). Undefined means "drop".
39
74
  */
40
75
  function pick(value, schema) {
41
76
  const sch = isObj(schema) ? schema : undefined;
77
+ if (sch) {
78
+ const types = declaredTypes(sch);
79
+ if (types && !typeMatches(value, types))
80
+ return undefined;
81
+ if (Array.isArray(sch.enum) && !isObj(value) && !Array.isArray(value) && !sch.enum.includes(value)) {
82
+ return undefined;
83
+ }
84
+ }
42
85
  if (Array.isArray(value)) {
43
86
  const items = sch && isObj(sch.items) ? sch.items : undefined;
44
87
  return value.map((v) => pick(v, items)).filter((v) => v !== undefined);
@@ -54,20 +97,24 @@ function pick(value, schema) {
54
97
  }
55
98
  return value;
56
99
  }
57
- /** Secrets in keys too (`redact` scrubs values and keeps keys). */
58
- function redactKeys(value, secrets) {
100
+ /**
101
+ * Secrets in keys too (`redact` scrubs values and keeps keys). A key that redaction changes, or that
102
+ * already looks like a redacted name, becomes `[redacted-<first 16 hex of HMAC-SHA256(key)>]`, keyed
103
+ * with `macKey()`: injective (no two keys share a name, and no plain key can equal a generated one),
104
+ * stable (the same key gets the same name in every run, whatever else is present, so a diff lines
105
+ * them up) and not a guessing oracle for a low-entropy secret (a plain hash would be).
106
+ */
107
+ function redactKeys(value, secrets, macKey) {
59
108
  if (Array.isArray(value))
60
- return value.map((v) => redactKeys(v, secrets));
109
+ return value.map((v) => redactKeys(v, secrets, macKey));
61
110
  if (!isObj(value))
62
111
  return value;
63
- const seen = new Map();
64
112
  return Object.fromEntries(Object.entries(value).map(([k, v]) => {
65
- let name = redact(k, secrets).value;
66
- const n = (seen.get(name) ?? 0) + 1;
67
- seen.set(name, n);
68
- if (n > 1)
69
- name = `${name}#${n}`;
70
- return [name, redactKeys(v, secrets)];
113
+ const scrubbed = redact(k, secrets).value;
114
+ const name = scrubbed !== k || k.startsWith("[redacted")
115
+ ? `[redacted-${crypto.createHmac("sha256", macKey()).update(k).digest("hex").slice(0, 16)}]`
116
+ : k;
117
+ return [name, redactKeys(v, secrets, macKey)];
71
118
  }));
72
119
  }
73
120
  /** [display value, digest tree]: strings over the cap are cut, and their full-text sha256 goes in the tree. */
@@ -97,6 +144,8 @@ function clipTree(value) {
97
144
  */
98
145
  export function buildRunAnswers(i) {
99
146
  const secrets = i.secrets ?? [];
147
+ let ephemeral;
148
+ const macKey = i.macKey ?? (() => (ephemeral ??= crypto.randomBytes(32)));
100
149
  const nodes = [];
101
150
  const digests = [];
102
151
  for (const [id, r] of i.results) {
@@ -109,12 +158,15 @@ export function buildRunAnswers(i) {
109
158
  if (isObj(props) && Object.keys(props).length > 0) {
110
159
  fields = pick(data, schema);
111
160
  }
112
- else if (typeof data.summary === "string" && data.summary.trim() !== "") {
161
+ else if (typeof data.summary === "string" &&
162
+ data.summary.trim() !== "" &&
163
+ !(isObj(schema) && schema.additionalProperties === false)) {
164
+ // The free-form summary, unless the node's schema closes the object to its declared fields.
113
165
  fields = { summary: data.summary };
114
166
  }
115
167
  if (!isObj(fields) || Object.keys(fields).length === 0)
116
168
  continue;
117
- const clean = redactKeys(redact(fields, secrets).value, secrets);
169
+ const clean = redactKeys(redact(fields, secrets).value, secrets, macKey);
118
170
  const [shown, digs] = clipTree(clean);
119
171
  nodes.push([id, shown]);
120
172
  if (digs !== undefined)
@@ -414,7 +466,10 @@ export function sinceLastRun(runId, workflowId, cwd = process.cwd()) {
414
466
  /** Save this run's answers, then compare with the previous run. Never throws. */
415
467
  export function recordRunAnswers(i, cwd = process.cwd()) {
416
468
  try {
417
- if (!writeRunAnswers(buildRunAnswers(i), cwd))
469
+ // The key is per user and workspace and lives in the state dir; none usable means a process-only key.
470
+ let key;
471
+ const macKey = i.macKey ?? (() => (key ??= workspaceSecretKey(cwd, "answers") ?? crypto.randomBytes(32)));
472
+ if (!writeRunAnswers(buildRunAnswers({ ...i, macKey }), cwd))
418
473
  return null;
419
474
  return sinceLastRun(i.runId, i.workflow.id, cwd);
420
475
  }
@@ -19,6 +19,7 @@ import { openNoFollow } from "../safe-file.js";
19
19
  export const RUN_HISTORY_SCHEMA_VERSION = 1;
20
20
  export const RUN_HISTORY_DIR = path.join(".sweny", "runs");
21
21
  export const RUN_HISTORY_KEEP = 200;
22
+ const RECORD_READ_MAX_BYTES = 1024 * 1024;
22
23
  // ── Hash + ids ──────────────────────────────────────────────────
23
24
  function canonicalize(value) {
24
25
  if (Array.isArray(value))
@@ -215,7 +216,12 @@ export function listRuns(cwd = process.cwd()) {
215
216
  const out = [];
216
217
  for (const f of files) {
217
218
  try {
218
- const parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
219
+ // A link is not history: the target may be anywhere, and an agent can plant it.
220
+ const file = path.join(dir, f);
221
+ const st = fs.lstatSync(file);
222
+ if (!st.isFile() || st.size > RECORD_READ_MAX_BYTES)
223
+ continue;
224
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
219
225
  if (isRecord(parsed) && RUN_ID_RE.test(parsed.run_id) && f === `${parsed.run_id}.json`)
220
226
  out.push(parsed);
221
227
  }
package/dist/cli/runs.js CHANGED
@@ -222,12 +222,14 @@ function formatAnswersSection(cmp, color, g) {
222
222
  out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_ITEMS_SHOWN} more ${word}`));
223
223
  }
224
224
  };
225
+ // Node, field and nested field names are keys of agent-writable files: terminal text, not trusted.
226
+ const key = (s) => sanitizeTerminal(s, 64);
225
227
  const fields = (list) => {
226
228
  if (list.length === 0)
227
229
  return;
228
- const w = Math.max(...list.map((f) => f.field.length));
230
+ const w = Math.max(...list.map((f) => key(f.field).length));
229
231
  for (const f of list.slice(0, MAX_FIELDS_SHOWN)) {
230
- out.push(` ${pad(f.field, w)} ${valueChange(f.before, f.after, g)}`);
232
+ out.push(` ${pad(key(f.field), w)} ${valueChange(f.before, f.after, g)}`);
231
233
  }
232
234
  if (list.length > MAX_FIELDS_SHOWN)
233
235
  out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_FIELDS_SHOWN} more fields`));
@@ -236,14 +238,14 @@ function formatAnswersSection(cmp, color, g) {
236
238
  for (const node of nodes) {
237
239
  const changed = cmp.entries.filter((e) => e.node === node && isChangedEntry(e));
238
240
  if (changed.length === 0) {
239
- out.push(` ${node} ${p.muted("unchanged")}`);
241
+ out.push(` ${key(node)} ${p.muted("unchanged")}`);
240
242
  continue;
241
243
  }
242
- out.push(` ${node}`);
243
- const w = Math.max(...changed.map((e) => e.field.length));
244
+ out.push(` ${key(node)}`);
245
+ const w = Math.max(...changed.map((e) => key(e.field).length));
244
246
  for (const e of changed) {
245
247
  if (e.kind === "collection") {
246
- out.push(` ${pad(e.field, w)} ${p.muted(countsHead(e, g))}`);
248
+ out.push(` ${pad(key(e.field), w)} ${p.muted(countsHead(e, g))}`);
247
249
  items("+", p.warning, e.added, "added");
248
250
  items("-", p.success, e.resolved, "resolved");
249
251
  for (const c of e.changed.slice(0, MAX_ITEMS_SHOWN)) {
@@ -255,18 +257,18 @@ function formatAnswersSection(cmp, color, g) {
255
257
  }
256
258
  }
257
259
  else if (e.status === "added") {
258
- out.push(` ${pad(e.field, w)} ${p.warning("added")} ${showValue(e.after, g)}`);
260
+ out.push(` ${pad(key(e.field), w)} ${p.warning("added")} ${showValue(e.after, g)}`);
259
261
  }
260
262
  else if (e.status === "resolved") {
261
- out.push(` ${pad(e.field, w)} ${p.success("resolved")} ${p.muted(`(was ${showValue(e.before, g)})`)}`);
263
+ out.push(` ${pad(key(e.field), w)} ${p.success("resolved")} ${p.muted(`(was ${showValue(e.before, g)})`)}`);
262
264
  }
263
265
  else {
264
- out.push(` ${pad(e.field, w)} ${valueChange(e.before, e.after, g)}`);
266
+ out.push(` ${pad(key(e.field), w)} ${valueChange(e.before, e.after, g)}`);
265
267
  }
266
268
  }
267
269
  }
268
270
  for (const n of cmp.not_compared) {
269
- out.push(` ${n.node} ${p.muted(`not compared (answer in ${n.only_in} only)`)}`);
271
+ out.push(` ${key(n.node)} ${p.muted(`not compared (answer in ${n.only_in} only)`)}`);
270
272
  }
271
273
  if (nodes.length === 0 && cmp.not_compared.length === 0)
272
274
  out.push(p.muted(" no declared outputs to compare"));
@@ -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";
@@ -143,6 +154,14 @@ export declare class JournalLocationError extends Error {
143
154
  * it points. Throws {@link JournalLocationError}.
144
155
  */
145
156
  export declare function assertStateOutsideWorkspace(dir: string, cwd: string): void;
157
+ /**
158
+ * A 32-byte random key private to this user and workspace, for MACs that must stay the same from run to
159
+ * run (a run's journal key is per run, so it cannot serve). Stored hex, mode 0600, at
160
+ * `<state dir>/keys/<workspace-hash>.<purpose>.key`, read and created without following a link. Null
161
+ * when the state dir cannot be trusted (inside the workspace, where an agent can read it) or used;
162
+ * callers then fall back to a key that lives only in the process. Never written anywhere an agent reads.
163
+ */
164
+ export declare function workspaceSecretKey(cwd: string, purpose: string, env?: Record<string, string | undefined>): Buffer | null;
146
165
  /** Thrown when a journal's key is missing or unreadable: its records cannot be checked. */
147
166
  export declare class JournalKeyError extends Error {
148
167
  constructor(file: string, why: string);
@@ -335,6 +354,8 @@ export interface PlannedVisit {
335
354
  rung?: RouteRung;
336
355
  /** Writes the checkpointed write stage already applied (receipts in the journal). */
337
356
  applied: number;
357
+ /** Writes whose handler refused (#544): the refusal is replayed, not re-sent. */
358
+ refused: number;
338
359
  /** Writes with an intent but no receipt: checked on the provider before anything is re-sent. */
339
360
  unconfirmed: number;
340
361
  }
@@ -356,6 +377,8 @@ export interface ResumePlan {
356
377
  ends: Map<string, EndRecord>;
357
378
  intents: Map<string, OutputRecord>;
358
379
  receipts: Map<string, OutputRecord>;
380
+ /** Writes whose handler refused (`output:refused`, #544): replayed on resume, never taken for applied. */
381
+ refusals: Map<string, OutputRecord>;
359
382
  /** Spend every earlier attempt journaled: the run budget starts here on resume. */
360
383
  priorSpend: Spend;
361
384
  /**
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";
@@ -301,6 +313,64 @@ export function assertStateOutsideWorkspace(dir, cwd) {
301
313
  throw new JournalLocationError(real, ws);
302
314
  }
303
315
  }
316
+ /** True when `dir` (which may not exist yet) would resolve inside `cwd`: its nearest existing ancestor's real path decides. */
317
+ function resolvesInside(dir, cwd) {
318
+ const ws = realOr(cwd);
319
+ let existing = path.resolve(dir);
320
+ const rest = [];
321
+ while (!fs.existsSync(existing) && path.dirname(existing) !== existing) {
322
+ rest.unshift(path.basename(existing));
323
+ existing = path.dirname(existing);
324
+ }
325
+ const real = path.join(realOr(existing), ...rest);
326
+ return real === ws || real.startsWith(ws.endsWith(path.sep) ? ws : ws + path.sep);
327
+ }
328
+ /**
329
+ * A 32-byte random key private to this user and workspace, for MACs that must stay the same from run to
330
+ * run (a run's journal key is per run, so it cannot serve). Stored hex, mode 0600, at
331
+ * `<state dir>/keys/<workspace-hash>.<purpose>.key`, read and created without following a link. Null
332
+ * when the state dir cannot be trusted (inside the workspace, where an agent can read it) or used;
333
+ * callers then fall back to a key that lives only in the process. Never written anywhere an agent reads.
334
+ */
335
+ export function workspaceSecretKey(cwd, purpose, env = process.env) {
336
+ try {
337
+ const dir = path.join(swenyStateDir(env), "keys");
338
+ if (resolvesInside(dir, cwd))
339
+ return null;
340
+ makePrivateDir(dir);
341
+ if (resolvesInside(dir, cwd))
342
+ return null;
343
+ const file = path.join(dir, `${workspaceScope(cwd)}.${purpose.replace(/[^A-Za-z0-9_-]/g, "_")}.key`);
344
+ const read = () => {
345
+ const text = readNoFollow(file).toString("utf8").trim();
346
+ if (!/^[0-9a-f]{64}$/.test(text))
347
+ return null;
348
+ if (process.platform !== "win32" && (fs.lstatSync(file).mode & 0o077) !== 0)
349
+ return null;
350
+ return Buffer.from(text, "hex");
351
+ };
352
+ try {
353
+ return read();
354
+ }
355
+ catch (err) {
356
+ if (err.code !== "ENOENT")
357
+ return null;
358
+ }
359
+ const key = crypto.randomBytes(32);
360
+ try {
361
+ writeNew(file, key.toString("hex") + "\n");
362
+ return key;
363
+ }
364
+ catch (err) {
365
+ if (err.code !== "EEXIST")
366
+ return null;
367
+ return read(); // another run created it first
368
+ }
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ }
304
374
  /** Thrown when a journal's key is missing or unreadable: its records cannot be checked. */
305
375
  export class JournalKeyError extends Error {
306
376
  constructor(file, why) {
@@ -840,6 +910,7 @@ const RECORD_TYPES = new Set([
840
910
  "node:checkpoint",
841
911
  "output:intent",
842
912
  "output:applied",
913
+ "output:refused",
843
914
  "node:end",
844
915
  "route",
845
916
  "usage",
@@ -937,6 +1008,7 @@ export function checkRecordSequence(records) {
937
1008
  }
938
1009
  case "output:intent":
939
1010
  case "output:applied":
1011
+ case "output:refused":
940
1012
  inVisit(r);
941
1013
  if (!checkpointed.has(visitKey(r.node, r.iteration))) {
942
1014
  bad(r, "records a write before the visit's checkpoint");
@@ -1057,6 +1129,7 @@ export function buildResumePlan(records) {
1057
1129
  const byKey = new Map();
1058
1130
  const intents = new Map();
1059
1131
  const receipts = new Map();
1132
+ const refusals = new Map();
1060
1133
  let attempts = 1;
1061
1134
  let lastStatus;
1062
1135
  let deciderCounters;
@@ -1113,6 +1186,9 @@ export function buildResumePlan(records) {
1113
1186
  case "output:applied":
1114
1187
  receipts.set(r.key, r);
1115
1188
  break;
1189
+ case "output:refused":
1190
+ refusals.set(r.key, r);
1191
+ break;
1116
1192
  case "run:end":
1117
1193
  lastStatus = r.status;
1118
1194
  break;
@@ -1129,7 +1205,8 @@ export function buildResumePlan(records) {
1129
1205
  const complete = !!v.end && !(isLast && v.end.result.status === "failed" && typeof v.next !== "string");
1130
1206
  const keys = (rec) => [...rec.values()].filter((o) => o.node === v.node && o.iteration === v.iteration).map((o) => o.key);
1131
1207
  const applied = keys(receipts).length;
1132
- const unconfirmed = keys(intents).filter((k) => !receipts.has(k)).length;
1208
+ const refused = keys(refusals).filter((k) => !receipts.has(k)).length;
1209
+ const unconfirmed = keys(intents).filter((k) => !receipts.has(k) && !refusals.has(k)).length;
1133
1210
  if (complete && !resumed) {
1134
1211
  visits.push({
1135
1212
  node: v.node,
@@ -1139,6 +1216,7 @@ export function buildResumePlan(records) {
1139
1216
  next: v.next,
1140
1217
  ...(v.rung ? { rung: v.rung } : {}),
1141
1218
  applied,
1219
+ refused,
1142
1220
  unconfirmed,
1143
1221
  });
1144
1222
  writeState = v.end.write_state;
@@ -1150,6 +1228,7 @@ export function buildResumePlan(records) {
1150
1228
  iteration: v.iteration,
1151
1229
  action: v.checkpoint ? "write-stage" : "rerun",
1152
1230
  applied,
1231
+ refused,
1153
1232
  unconfirmed,
1154
1233
  });
1155
1234
  break;
@@ -1180,6 +1259,7 @@ export function buildResumePlan(records) {
1180
1259
  ends,
1181
1260
  intents,
1182
1261
  receipts,
1262
+ refusals,
1183
1263
  priorSpend: journaledSpend(records),
1184
1264
  unsandboxed: [...new Set(records.filter((r) => r.type === "agent:unsandboxed").map((r) => String(r.node)))],
1185
1265
  ...(start.decider
@@ -1336,6 +1416,14 @@ function slimOutput(o, depth = 0) {
1336
1416
  if (!o || typeof o !== "object" || Array.isArray(o) || depth > 3)
1337
1417
  return null;
1338
1418
  const out = {};
1419
+ // A refusal keeps what it is and its fixed-wording reason (#544); never `detail`.
1420
+ if (depth === 0 && o.refused === true) {
1421
+ out.refused = true;
1422
+ if (isToolRefusal(o))
1423
+ out.reason = o.reason;
1424
+ if (o.transient === true)
1425
+ out.transient = true;
1426
+ }
1339
1427
  for (const [k, v] of Object.entries(o)) {
1340
1428
  if (["number", "identifier", "id", "html_url", "url", "success", "reused"].includes(k)) {
1341
1429
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
@@ -1805,6 +1893,12 @@ export class RunJournal {
1805
1893
  this.logger.info(` run journal: ${tool.name} already applied before the crash; not re-sent`, { node });
1806
1894
  return receipt.output ?? null;
1807
1895
  }
1896
+ const refusal = plan?.refusals.get(key);
1897
+ if (refusal && !retriesRefusal(refusal.output)) {
1898
+ this.logger.info(` run journal: ${tool.name} was refused in an earlier attempt; not re-sent`, { node });
1899
+ const prior = refusal.output && typeof refusal.output === "object" ? refusal.output : {};
1900
+ return { reason: "the tool refused the write", ...prior, refused: true };
1901
+ }
1808
1902
  const replayingStage = !!plan?.checkpoints.has(visitKey(node, iteration));
1809
1903
  const pending = plan?.intents.has(key) === true;
1810
1904
  if (pending || replayingStage) {
@@ -1826,12 +1920,30 @@ export class RunJournal {
1826
1920
  // A write the journal cannot record is never sent: append throws first.
1827
1921
  this.append("output:intent", { node, iteration, key, tool: tool.name });
1828
1922
  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) });
1923
+ const refused = !!output && typeof output === "object" && output.refused === true;
1924
+ this.append(refused ? "output:refused" : "output:applied", {
1925
+ node,
1926
+ iteration,
1927
+ key,
1928
+ tool: tool.name,
1929
+ output: slimOutput(output),
1930
+ });
1830
1931
  return output;
1831
1932
  },
1832
1933
  };
1833
1934
  }
1834
1935
  }
1936
+ /**
1937
+ * Whether a journaled refusal is tried again on resume (#544): only a
1938
+ * transient one that names nothing it produced. Anything else stands.
1939
+ */
1940
+ function retriesRefusal(output) {
1941
+ if (!output || typeof output !== "object")
1942
+ return false;
1943
+ const o = output;
1944
+ // Anything besides the refusal itself is something the write produced.
1945
+ return o.transient === true && Object.keys(o).every((k) => k === "refused" || k === "reason" || k === "transient");
1946
+ }
1835
1947
  /** What a node result needs to continue a run. Tool call inputs and outputs are dropped. */
1836
1948
  function journalResult(r) {
1837
1949
  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.189",
3
+ "version": "0.1.191",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"