@sweny-ai/core 0.1.209 → 0.1.211

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.
@@ -34,11 +34,12 @@ export declare const CLASSIFICATION_DISALLOWED_TOOLS: readonly ["Bash", "Write",
34
34
  /**
35
35
  * Built-in tools disallowed for a node run with `readOnly: true` (dry run,
36
36
  * #380): everything that can edit the workspace, shell out (and so reach
37
- * `gh`, `curl`, `git push`), spawn a subagent that could, or fetch an
38
- * arbitrary URL (a GET can still trigger a webhook or exfiltrate data).
39
- * Read, Grep, Glob and WebSearch stay available for analysis.
37
+ * `gh`, `curl`, `git push`), spawn a subagent that could, fetch an arbitrary
38
+ * URL (a GET can still trigger a webhook or exfiltrate data) or search the web
39
+ * (a query is an egress channel that can carry repo data out). Read, Grep and
40
+ * Glob stay available for analysis.
40
41
  */
41
- export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Agent", "WebFetch"];
42
+ export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Agent", "WebFetch", "WebSearch"];
42
43
  /**
43
44
  * Claude Code built-in tool names per portable tool class. A node's
44
45
  * `tools.deny` classes compile to these names on `disallowedTools`.
@@ -71,6 +72,40 @@ export declare function claudeReadDenyRule(absPath: string): string;
71
72
  export declare function claudeStateDirDenyRules(root?: string): string[];
72
73
  /** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
73
74
  export declare function compileClaudeCodeDeny(policy: NodePolicy, legacy?: readonly string[]): string[];
75
+ /**
76
+ * The only Claude Code built-in tools a node may get, by policy. Every node
77
+ * reads and searches the checkout, and a read-only node gets nothing else: no
78
+ * web search or fetch, since either can carry repo data out. A write node adds
79
+ * the shell, file-edit and web built-ins (its `tools.deny` classes and
80
+ * `disallowed_tools` names then leave the list). No node gets the subagent
81
+ * tools (Task, Agent) by default: nothing shows a subagent is held to its
82
+ * parent's `tools`, so the default must not depend on it.
83
+ *
84
+ * Passed as the SDK's `tools` option, which "Specif[ies] the base set of
85
+ * available built-in tools" (sdk.d.ts); `allowedTools` only auto-approves
86
+ * ("To restrict which tools are available, use the `tools` option instead").
87
+ * Anything not named here never reaches a node: agent-to-agent messaging
88
+ * (SendMessage, ListAgents), scheduling (Cron*, ScheduleWakeup), remote
89
+ * triggers, notifications, worktrees, design sync, Skill, Workflow and
90
+ * ToolSearch (added back only while a server defers its tools, see
91
+ * {@link runQuery}). Dogfood run 42890468912 listed 23 tools for a read-only
92
+ * node that needed two.
93
+ */
94
+ export declare const CLAUDE_READ_ONLY_BUILTIN_TOOLS: readonly ["Read", "Grep", "Glob"];
95
+ export declare const CLAUDE_WRITE_BUILTIN_TOOLS: readonly ["Read", "Grep", "Glob", ...string[]];
96
+ /**
97
+ * The built-in allow-list for one node: the policy's list minus every name the
98
+ * compiled deny list removes. Path-scoped rules (`Read(//dir/**)`) narrow a
99
+ * tool, they do not remove it, so they are not matched here.
100
+ */
101
+ export declare function claudeCodeBuiltinTools(policy: {
102
+ readOnly: boolean;
103
+ }, deny?: readonly string[]): string[];
104
+ /**
105
+ * Env vars the harness itself sets on the agent (not passed through from the
106
+ * parent): `ENABLE_TOOL_SEARCH`, see {@link ClaudeCodeHarness} runQuery.
107
+ */
108
+ export declare const CLAUDE_CODE_HARNESS_VARS: readonly ["ENABLE_TOOL_SEARCH"];
74
109
  /** How sweny resolves which credentials reach the Claude Code subprocess. */
75
110
  export type SwenyAuthMode = "auto" | "api-key" | "oauth";
76
111
  export interface ResolveAuthEnvOpts {
@@ -68,9 +68,10 @@ export const CLASSIFICATION_DISALLOWED_TOOLS = [
68
68
  /**
69
69
  * Built-in tools disallowed for a node run with `readOnly: true` (dry run,
70
70
  * #380): everything that can edit the workspace, shell out (and so reach
71
- * `gh`, `curl`, `git push`), spawn a subagent that could, or fetch an
72
- * arbitrary URL (a GET can still trigger a webhook or exfiltrate data).
73
- * Read, Grep, Glob and WebSearch stay available for analysis.
71
+ * `gh`, `curl`, `git push`), spawn a subagent that could, fetch an arbitrary
72
+ * URL (a GET can still trigger a webhook or exfiltrate data) or search the web
73
+ * (a query is an egress channel that can carry repo data out). Read, Grep and
74
+ * Glob stay available for analysis.
74
75
  */
75
76
  export const READ_ONLY_DISALLOWED_TOOLS = [
76
77
  "Bash",
@@ -81,6 +82,7 @@ export const READ_ONLY_DISALLOWED_TOOLS = [
81
82
  "Task",
82
83
  "Agent",
83
84
  "WebFetch",
85
+ "WebSearch",
84
86
  ];
85
87
  /**
86
88
  * Claude Code built-in tool names per portable tool class. A node's
@@ -146,6 +148,55 @@ export function compileClaudeCodeDeny(policy, legacy = []) {
146
148
  ]),
147
149
  ];
148
150
  }
151
+ /**
152
+ * The only Claude Code built-in tools a node may get, by policy. Every node
153
+ * reads and searches the checkout, and a read-only node gets nothing else: no
154
+ * web search or fetch, since either can carry repo data out. A write node adds
155
+ * the shell, file-edit and web built-ins (its `tools.deny` classes and
156
+ * `disallowed_tools` names then leave the list). No node gets the subagent
157
+ * tools (Task, Agent) by default: nothing shows a subagent is held to its
158
+ * parent's `tools`, so the default must not depend on it.
159
+ *
160
+ * Passed as the SDK's `tools` option, which "Specif[ies] the base set of
161
+ * available built-in tools" (sdk.d.ts); `allowedTools` only auto-approves
162
+ * ("To restrict which tools are available, use the `tools` option instead").
163
+ * Anything not named here never reaches a node: agent-to-agent messaging
164
+ * (SendMessage, ListAgents), scheduling (Cron*, ScheduleWakeup), remote
165
+ * triggers, notifications, worktrees, design sync, Skill, Workflow and
166
+ * ToolSearch (added back only while a server defers its tools, see
167
+ * {@link runQuery}). Dogfood run 42890468912 listed 23 tools for a read-only
168
+ * node that needed two.
169
+ */
170
+ export const CLAUDE_READ_ONLY_BUILTIN_TOOLS = ["Read", "Grep", "Glob"];
171
+ export const CLAUDE_WRITE_BUILTIN_TOOLS = [
172
+ "Read",
173
+ "Grep",
174
+ "Glob",
175
+ ...CLAUDE_TOOLS_BY_CLASS.shell,
176
+ ...CLAUDE_TOOLS_BY_CLASS.write,
177
+ ...CLAUDE_TOOLS_BY_CLASS.edit,
178
+ ...CLAUDE_TOOLS_BY_CLASS.net,
179
+ ];
180
+ /**
181
+ * The built-in allow-list for one node: the policy's list minus every name the
182
+ * compiled deny list removes. Path-scoped rules (`Read(//dir/**)`) narrow a
183
+ * tool, they do not remove it, so they are not matched here.
184
+ */
185
+ export function claudeCodeBuiltinTools(policy, deny = []) {
186
+ const denied = new Set(deny);
187
+ const base = policy.readOnly ? CLAUDE_READ_ONLY_BUILTIN_TOOLS : CLAUDE_WRITE_BUILTIN_TOOLS;
188
+ return base.filter((t) => !denied.has(t));
189
+ }
190
+ /**
191
+ * Env vars the harness itself sets on the agent (not passed through from the
192
+ * parent): `ENABLE_TOOL_SEARCH`, see {@link ClaudeCodeHarness} runQuery.
193
+ */
194
+ export const CLAUDE_CODE_HARNESS_VARS = ["ENABLE_TOOL_SEARCH"];
195
+ /** CI: a truthy `CI` (not "", "0", "false", "no", "off"), or GitHub Actions itself. */
196
+ function runningInCi(env = process.env) {
197
+ const ci = (env.CI ?? "").trim().toLowerCase();
198
+ return env.GITHUB_ACTIONS === "true" || !["", "0", "false", "no", "off"].includes(ci);
199
+ }
149
200
  /**
150
201
  * Decide which auth credentials survive into the Claude Code subprocess env.
151
202
  *
@@ -562,6 +613,8 @@ export class ClaudeCodeHarness {
562
613
  // Fail closed: a stream that ends with no terminal `result` message
563
614
  // (subprocess died, stream truncated) is not a success.
564
615
  let sawResult = false;
616
+ // The tool names the agent was given (init message), for the executor's repair rule.
617
+ let agentTools;
565
618
  // Timeout / abort wiring (back-compat: only armed when requested).
566
619
  // A single AbortController drives both an optional caller signal and an
567
620
  // optional timeout timer. The SDK aborts the subprocess when this fires.
@@ -596,6 +649,20 @@ export class ClaudeCodeHarness {
596
649
  else if (sdkTools.length > 0) {
597
650
  allMcpServers["sweny-core"] = mcpServer;
598
651
  }
652
+ // Deterministic tool loading: when every server loads its tools up front
653
+ // (sweny-core always does; a node's skill servers do unless they opt out),
654
+ // tool search has nothing to defer, so it is off and ToolSearch is not on
655
+ // the list. `ENABLE_TOOL_SEARCH=false` is the bundled CLI's switch (its
656
+ // own docs: "To turn it off ... set `ENABLE_TOOL_SEARCH` to `false`"; CLI
657
+ // 0.3.319 drops ToolSearch from the init tool list with it). A client-level
658
+ // server not marked alwaysLoad keeps tool search, and ToolSearch, so its
659
+ // deferred tools stay reachable.
660
+ const defers = Object.entries(allMcpServers).some(([name, cfg]) => name !== "sweny-core" && cfg?.alwaysLoad !== true);
661
+ if (!defers)
662
+ env.ENABLE_TOOL_SEARCH = "false";
663
+ const builtinTools = opts.nativeNone
664
+ ? []
665
+ : [...claudeCodeBuiltinTools({ readOnly: !!readOnly }, disallowedTools), ...(defers ? ["ToolSearch"] : [])];
599
666
  stream = query({
600
667
  prompt,
601
668
  options: {
@@ -622,10 +689,13 @@ export class ClaudeCodeHarness {
622
689
  strictMcpConfig: true,
623
690
  settingSources: [],
624
691
  ...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
625
- // permissions.native: none. `tools: []` removes every built-in tool whatever its
626
- // name; `settingSources: []` loads no user, project or local settings, CLAUDE.md or skills
627
- // (already the default above since #497; kept so the policy value states its whole contract).
628
- ...(opts.nativeNone ? { tools: [], settingSources: [] } : {}),
692
+ // Least privilege: only the built-ins this node's policy names (see
693
+ // CLAUDE_WRITE_BUILTIN_TOOLS). permissions.native: none passes `tools: []`,
694
+ // which removes every built-in tool whatever its name.
695
+ tools: builtinTools,
696
+ // `settingSources: []` loads no user, project or local settings, CLAUDE.md or skills
697
+ // (already the default above since #497; kept so native: none states its whole contract).
698
+ ...(opts.nativeNone ? { settingSources: [] } : {}),
629
699
  // CC-08: ask the SDK to produce validated structured output when the
630
700
  // node declares an output schema. The SDK then returns the parsed
631
701
  // value on `result.structured_output`, which we prefer over the
@@ -635,11 +705,27 @@ export class ClaudeCodeHarness {
635
705
  });
636
706
  for await (const message of stream) {
637
707
  if (message.type === "system" && message.subtype === "init") {
638
- // What the agent was actually given: the tool list (--verbose) and,
639
- // loudly, a node tool server that did not come up.
708
+ // What the agent was actually given: the tool names (never inputs)
709
+ // and, loudly, a node tool or tool server that did not make it onto
710
+ // the list. In CI the names go out as one warning-level line, the
711
+ // level the default (non-verbose) run log shows, so a failed node is
712
+ // diagnosable without a verbose rerun; locally they stay at info.
640
713
  const init = message;
641
- if (Array.isArray(init.tools))
642
- this.logger.debug(` agent tools: ${init.tools.join(", ")}`);
714
+ if (Array.isArray(init.tools)) {
715
+ agentTools = init.tools.filter((t) => typeof t === "string");
716
+ const line = ` agent tools: ${init.tools.join(", ")}`;
717
+ if (runningInCi())
718
+ this.logger.warn(line);
719
+ else
720
+ this.logger.info(line);
721
+ const listed = new Set(init.tools);
722
+ const absent = allMcpServers["sweny-core"]
723
+ ? tools.map((t) => t.name).filter((n) => !listed.has(`mcp__sweny-core__${n}`))
724
+ : [];
725
+ if (absent.length > 0) {
726
+ this.logger.warn(` node tools missing from the agent's tool list: ${absent.join(", ")}`);
727
+ }
728
+ }
643
729
  const core = Array.isArray(init.mcp_servers)
644
730
  ? init.mcp_servers.find((s) => s?.name === "sweny-core")
645
731
  : undefined;
@@ -833,6 +919,7 @@ export class ClaudeCodeHarness {
833
919
  data: { summary: response, ...parsedData },
834
920
  toolCalls,
835
921
  ...(usage ? { usage } : {}),
922
+ ...(agentTools ? { agentTools } : {}),
836
923
  };
837
924
  }
838
925
  /**
@@ -152,6 +152,14 @@ export interface HarnessRunRequest extends ClaudeRunRequest {
152
152
  export interface HarnessRunResult extends NodeResult {
153
153
  harness: HarnessInfo;
154
154
  degraded: string[];
155
+ /**
156
+ * Names of every tool the agent was given for this run, as the agent itself
157
+ * listed them (Claude Code: the init message; MCP tools as
158
+ * `mcp__<server>__<tool>`). The executor repairs a skipped required tool
159
+ * only when it is on this list. Absent when the harness does not report it,
160
+ * and then no such repair happens. Never journaled.
161
+ */
162
+ agentTools?: string[];
155
163
  }
156
164
  export interface HarnessCompleteRequest {
157
165
  prompt: string;
package/dist/journal.d.ts CHANGED
@@ -123,7 +123,8 @@ export declare function canonicalHash(value: unknown): string;
123
123
  /**
124
124
  * Hash of the workflow a journal is bound to: the canonical workflow plus its
125
125
  * effective `spec_version` (#469), so a file that only changes spec version
126
- * (and therefore migrations) is a different workflow for resume.
126
+ * (and therefore migrations) is a different workflow for resume. Absent means
127
+ * 1, the version such a workflow runs as (#598), not the current one.
127
128
  */
128
129
  export declare function workflowHashOf(workflow: Workflow): string;
129
130
  /** Hash of what the nodes were told: every resolved instruction, rule and context Source. */
package/dist/journal.js CHANGED
@@ -109,7 +109,7 @@ import os from "node:os";
109
109
  import path from "node:path";
110
110
  import { isRefusal, refusalReason } from "./types.js";
111
111
  import { resolveNodePermissions } from "./node-policy.js";
112
- import { CURRENT_SPEC_VERSION } from "./migrations.js";
112
+ import { effectiveSpecVersion } from "./migrations.js";
113
113
  import { spendOf } from "./budget.js";
114
114
  /**
115
115
  * v3: each record's HMAC chains the run id and the previous record's HMAC.
@@ -152,10 +152,11 @@ export function canonicalHash(value) {
152
152
  /**
153
153
  * Hash of the workflow a journal is bound to: the canonical workflow plus its
154
154
  * effective `spec_version` (#469), so a file that only changes spec version
155
- * (and therefore migrations) is a different workflow for resume.
155
+ * (and therefore migrations) is a different workflow for resume. Absent means
156
+ * 1, the version such a workflow runs as (#598), not the current one.
156
157
  */
157
158
  export function workflowHashOf(workflow) {
158
- return canonicalHash({ ...workflow, spec_version: workflow.spec_version ?? String(CURRENT_SPEC_VERSION) });
159
+ return canonicalHash({ ...workflow, spec_version: String(effectiveSpecVersion(workflow)) });
159
160
  }
160
161
  /** Hash of what the nodes were told: every resolved instruction, rule and context Source. */
161
162
  export function instructionHash(sources) {
package/dist/loader.js CHANGED
@@ -14,6 +14,7 @@ import * as path from "node:path";
14
14
  import { parse as parseYaml } from "yaml";
15
15
  import { ZodError } from "zod";
16
16
  import { workflowZ, validateWorkflow, preflightLegacyVerify } from "./schema.js";
17
+ import { approveDirectiveWarnings } from "./node-policy.js";
17
18
  import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow, migrationWarning, } from "./migrations.js";
18
19
  /**
19
20
  * Load a workflow YAML or JSON file, parse with the canonical Zod schema,
@@ -57,6 +58,9 @@ export function validateParsed(raw, options = {}) {
57
58
  }
58
59
  raw = migrated.workflow;
59
60
  const warnings = migrated.applied.length > 0 ? [migrationWarning(migrated.from, migrated.to, options.filePath)] : [];
61
+ for (const note of migrated.notes)
62
+ if (note.warning)
63
+ warnings.push(note.warning);
60
64
  // Detect legacy `verify:` blocks (renamed to `eval:` in v0.2.0) before the
61
65
  // Zod parse. workflowZ is intentionally non-strict at the top level, so a
62
66
  // top-level `verify:` would be silently STRIPPED instead of surfacing the
@@ -93,5 +97,6 @@ export function validateParsed(raw, options = {}) {
93
97
  errors: structural.map((e) => ({ message: e.message, code: e.code, nodeId: e.nodeId })),
94
98
  };
95
99
  }
100
+ warnings.push(...approveDirectiveWarnings(parsed));
96
101
  return warnings.length > 0 ? { ok: true, workflow: parsed, warnings } : { ok: true, workflow: parsed };
97
102
  }
@@ -12,6 +12,11 @@
12
12
  *
13
13
  * Adding a migration: see `__tests__/migrations.test.ts` for the harness
14
14
  * (registry validity check plus a chain test to copy).
15
+ *
16
+ * A migration must keep behavior: a file migrated in memory runs exactly as it
17
+ * did. Where a new version changes a default, the migration writes the old
18
+ * default out explicitly, and reports each spot as a {@link MigrationNote}: the
19
+ * loader turns notes into warnings, `sweny workflow upgrade` into comments.
15
20
  */
16
21
  /** A raw, not-yet-validated workflow object as parsed from YAML or JSON. */
17
22
  export type RawWorkflow = Record<string, unknown>;
@@ -24,11 +29,35 @@ export interface Migration {
24
29
  description: string;
25
30
  /** Pure: must not mutate its input. Returns the next-version object. */
26
31
  migrate: (raw: RawWorkflow) => RawWorkflow;
32
+ /** Optional, pure: what the author should know about this step's changes to `raw` (its input). */
33
+ notes?: (raw: RawWorkflow) => MigrationNote[];
34
+ }
35
+ /** One spot a migration made explicit that the author should review. */
36
+ export interface MigrationNote {
37
+ /** Path of the key the migration set, in the migrated object (e.g. `["nodes", "post", "permissions"]`). */
38
+ path: (string | number)[];
39
+ /** The node it is about, when there is one. */
40
+ nodeId?: string;
41
+ /** One line `sweny workflow upgrade` writes as a comment above `path`. */
42
+ comment: string;
43
+ /** One line the loader emits when it migrates in memory. Absent: nothing to warn about. */
44
+ warning?: string;
27
45
  }
28
46
  /** The spec version this build of sweny reads natively. */
29
- export declare const CURRENT_SPEC_VERSION = 1;
47
+ export declare const CURRENT_SPEC_VERSION = 2;
48
+ /**
49
+ * The spec version a workflow object runs as: its `spec_version`, or 1 when
50
+ * absent (a file written before the field, or a workflow built in code that
51
+ * the loader never migrated). Permission defaults and the journal's workflow
52
+ * hash both read this, so resume binds to the version the policy ran under.
53
+ */
54
+ export declare function effectiveSpecVersion(workflow: {
55
+ spec_version?: string;
56
+ }): number;
57
+ /** The warning for one node that can write only because nothing limits it. */
58
+ export declare function implicitWriteWarning(nodeId: string): string;
30
59
  /**
31
- * Ordered registry, one entry per version step. Empty while v1 is current.
60
+ * Ordered registry, one entry per version step.
32
61
  * Append only; never edit a shipped migration.
33
62
  */
34
63
  export declare const MIGRATIONS: readonly Migration[];
@@ -53,6 +82,8 @@ export type MigrateResult = {
53
82
  declared: boolean;
54
83
  /** Migrations applied, in order. Empty when the file is already current. */
55
84
  applied: Migration[];
85
+ /** Notes from the applied migrations, in order. Empty when there are none. */
86
+ notes: MigrationNote[];
56
87
  } | ({
57
88
  ok: false;
58
89
  } & MigrateError);
@@ -12,14 +12,113 @@
12
12
  *
13
13
  * Adding a migration: see `__tests__/migrations.test.ts` for the harness
14
14
  * (registry validity check plus a chain test to copy).
15
+ *
16
+ * A migration must keep behavior: a file migrated in memory runs exactly as it
17
+ * did. Where a new version changes a default, the migration writes the old
18
+ * default out explicitly, and reports each spot as a {@link MigrationNote}: the
19
+ * loader turns notes into warnings, `sweny workflow upgrade` into comments.
15
20
  */
16
21
  /** The spec version this build of sweny reads natively. */
17
- export const CURRENT_SPEC_VERSION = 1;
22
+ export const CURRENT_SPEC_VERSION = 2;
23
+ /**
24
+ * The spec version a workflow object runs as: its `spec_version`, or 1 when
25
+ * absent (a file written before the field, or a workflow built in code that
26
+ * the loader never migrated). Permission defaults and the journal's workflow
27
+ * hash both read this, so resume binds to the version the policy ran under.
28
+ */
29
+ export function effectiveSpecVersion(workflow) {
30
+ return workflow.spec_version === undefined ? 1 : Number(workflow.spec_version);
31
+ }
32
+ // ─── v1 -> v2: safe by default (#598) ────────────────────────────
33
+ function isRecord(v) {
34
+ return typeof v === "object" && v !== null && !Array.isArray(v);
35
+ }
36
+ /** The `access` a raw `permissions` value declares, if any. */
37
+ function declaredAccess(p) {
38
+ if (typeof p === "string")
39
+ return p;
40
+ return isRecord(p) ? p.access : undefined;
41
+ }
42
+ function declaresNativeNone(p) {
43
+ return isRecord(p) && p.native === "none";
44
+ }
45
+ function skillList(node) {
46
+ return Array.isArray(node.skills) ? node.skills.filter((s) => typeof s === "string") : [];
47
+ }
48
+ /**
49
+ * Nodes whose access spec v1 left to its `write` fallback: no `access` of
50
+ * their own, no `outputs`, and no workflow `access`. Spec v2 reads those as
51
+ * `read`, so the migration declares what each one had.
52
+ *
53
+ * `read` only where it is provably the same: a node with no skills that runs
54
+ * with `native: none` (its own or the workflow's) has no tool at all, so
55
+ * nothing for `write` to unlock. Every other such node keeps `write`, the
56
+ * access it had; the migration never grants more than v1 did.
57
+ */
58
+ function implicitAccessNodes(raw) {
59
+ if (declaredAccess(raw.permissions) !== undefined || !isRecord(raw.nodes))
60
+ return [];
61
+ const wfNone = declaresNativeNone(raw.permissions);
62
+ const found = [];
63
+ for (const [id, node] of Object.entries(raw.nodes)) {
64
+ if (!isRecord(node))
65
+ continue;
66
+ if (declaredAccess(node.permissions) !== undefined)
67
+ continue;
68
+ if (Array.isArray(node.outputs) && node.outputs.length > 0)
69
+ continue;
70
+ const skills = skillList(node);
71
+ const noTools = skills.length === 0 && (wfNone || declaresNativeNone(node.permissions));
72
+ const path = isRecord(node.permissions) ? ["nodes", id, "permissions", "access"] : ["nodes", id, "permissions"];
73
+ found.push({ id, access: noTools ? "read" : "write", path, skills });
74
+ }
75
+ return found;
76
+ }
77
+ /** The warning for one node that can write only because nothing limits it. */
78
+ export function implicitWriteWarning(nodeId) {
79
+ return (`Node "${nodeId}" can write because nothing limits it (spec_version "1" default). ` +
80
+ "Declare permissions or outputs on it, or run `sweny workflow upgrade`.");
81
+ }
82
+ const v1ToV2 = {
83
+ from: 1,
84
+ to: 2,
85
+ description: "safe by default: declare `permissions` on every node that relied on the v1 `write` default",
86
+ migrate: (raw) => {
87
+ const found = implicitAccessNodes(raw);
88
+ if (found.length === 0)
89
+ return { ...raw };
90
+ const nodes = { ...raw.nodes };
91
+ for (const { id, access } of found) {
92
+ const node = nodes[id];
93
+ nodes[id] = {
94
+ ...node,
95
+ permissions: isRecord(node.permissions) ? { ...node.permissions, access } : access,
96
+ };
97
+ }
98
+ return { ...raw, nodes };
99
+ },
100
+ notes: (raw) => implicitAccessNodes(raw).map(({ id, access, path, skills }) => access === "read"
101
+ ? {
102
+ path,
103
+ nodeId: id,
104
+ comment: "sweny workflow upgrade: read, because this node has no skills and no built-in tools.",
105
+ }
106
+ : {
107
+ path,
108
+ nodeId: id,
109
+ comment: `sweny workflow upgrade: write, kept from spec v1 (nothing limited this node; ` +
110
+ (skills.length > 0
111
+ ? `its skills [${skills.join(", ")}] may need their write tools). `
112
+ : "it can use built-in shell and file edit tools). ") +
113
+ "If it only reads, set permissions: read; if it only posts, declare outputs.",
114
+ warning: implicitWriteWarning(id),
115
+ }),
116
+ };
18
117
  /**
19
- * Ordered registry, one entry per version step. Empty while v1 is current.
118
+ * Ordered registry, one entry per version step.
20
119
  * Append only; never edit a shipped migration.
21
120
  */
22
- export const MIGRATIONS = [];
121
+ export const MIGRATIONS = [v1ToV2];
23
122
  export const DEFAULT_MIGRATION_CONFIG = {
24
123
  current: CURRENT_SPEC_VERSION,
25
124
  migrations: MIGRATIONS,
@@ -65,12 +164,15 @@ export function migrateWorkflow(raw, config = DEFAULT_MIGRATION_CONFIG) {
65
164
  }
66
165
  let current = declared ? { ...raw, spec_version: String(from) } : raw;
67
166
  const applied = [];
167
+ const notes = [];
68
168
  for (let v = from; v < config.current; v++) {
69
169
  const step = config.migrations.find((m) => m.from === v);
70
170
  if (!step) {
71
171
  return { ok: false, code: "MIGRATION_FAILED", message: `No migration registered from spec_version "${v}".` };
72
172
  }
73
173
  try {
174
+ if (step.notes)
175
+ notes.push(...step.notes(current));
74
176
  current = { ...step.migrate(current), spec_version: String(step.to) };
75
177
  }
76
178
  catch (err) {
@@ -83,7 +185,7 @@ export function migrateWorkflow(raw, config = DEFAULT_MIGRATION_CONFIG) {
83
185
  }
84
186
  applied.push(step);
85
187
  }
86
- return { ok: true, workflow: current, from, to: config.current, declared, applied };
188
+ return { ok: true, workflow: current, from, to: config.current, declared, applied, notes };
87
189
  }
88
190
  /** The single warning the loader emits when it migrated in memory. */
89
191
  export function migrationWarning(from, to, filePath) {
@@ -12,15 +12,29 @@ export interface ResolvedPermissions {
12
12
  strict: boolean;
13
13
  native?: NodeNative;
14
14
  }
15
+ /**
16
+ * The access a node gets when neither it nor the workflow declares one (#598).
17
+ * Spec v2 and later: `read`, safe by default. Spec v1, or no `spec_version` (a
18
+ * workflow built in code, or one the loader did not migrate): `write`, the v1
19
+ * behavior. The loader migrates v1 files to v2 with that `write` made explicit.
20
+ */
21
+ export declare function defaultNodeAccess(workflow?: Pick<Workflow, "spec_version">): NodeAccess;
22
+ /**
23
+ * True when the node runs under a declared policy: its own or the workflow's
24
+ * `permissions`, its `outputs`, or the spec v2 `read` default. A node that runs
25
+ * under none keeps each harness's own defaults (spec v1 only).
26
+ */
27
+ export declare function hasNodePolicy(node: Pick<Node, "permissions" | "outputs">, workflow?: Pick<Workflow, "permissions" | "spec_version">): boolean;
15
28
  /**
16
29
  * Effective permissions for a node.
17
30
  *
18
31
  * access: the node's own, else `read` when it declares `outputs`, else the
19
- * workflow's, else `write` (today's behavior). A workflow `read` ceiling wins
20
- * over a node `write` (the loader rejects that combination; this is the
21
- * runtime backstop). deny: union. strict: either. native: `none` if either says so.
32
+ * workflow's, else {@link defaultNodeAccess} (`read` from spec v2, `write` in
33
+ * v1). A workflow `read` ceiling wins over a node `write` (the loader rejects
34
+ * that combination; this is the runtime backstop). deny: union. strict:
35
+ * either. native: `none` if either says so.
22
36
  */
23
- export declare function resolveNodePermissions(node: Pick<Node, "permissions" | "outputs">, workflow?: Pick<Workflow, "permissions">): ResolvedPermissions;
37
+ export declare function resolveNodePermissions(node: Pick<Node, "permissions" | "outputs">, workflow?: Pick<Workflow, "permissions" | "spec_version">): ResolvedPermissions;
24
38
  /** True when a node asks for more than the workflow ceiling allows. */
25
39
  export declare function exceedsPermissionCeiling(node: Pick<Node, "permissions">, workflow: Pick<Workflow, "permissions">): boolean;
26
40
  /** The portable policy handed to the harness for one node run. */
@@ -30,3 +44,12 @@ export declare function buildNodePolicy(opts: {
30
44
  disallowedTools?: string[];
31
45
  egress?: string[];
32
46
  }): NodePolicy;
47
+ /**
48
+ * Load warnings for review nodes told to approve (#598). The pr-review
49
+ * template before #598 said "If no issues found, approve with a brief note";
50
+ * files generated from it keep that line through `sweny workflow upgrade`.
51
+ * Precise on purpose: only a node that can write (so it could act on it), in a
52
+ * `pr_review` workflow or whose instruction is about a pull request or review,
53
+ * with an inline instruction line that says "approve", not negated ("never approve").
54
+ */
55
+ export declare function approveDirectiveWarnings(workflow: Workflow): string[];
@@ -4,23 +4,45 @@
4
4
  * Pure and browser-safe. The workflow's `permissions` is both the default and
5
5
  * the ceiling for every node; a node may narrow it, never widen it.
6
6
  */
7
+ import { effectiveSpecVersion } from "./migrations.js";
7
8
  function spec(p) {
8
9
  if (p === undefined)
9
10
  return {};
10
11
  return typeof p === "string" ? { access: p } : p;
11
12
  }
13
+ /**
14
+ * The access a node gets when neither it nor the workflow declares one (#598).
15
+ * Spec v2 and later: `read`, safe by default. Spec v1, or no `spec_version` (a
16
+ * workflow built in code, or one the loader did not migrate): `write`, the v1
17
+ * behavior. The loader migrates v1 files to v2 with that `write` made explicit.
18
+ */
19
+ export function defaultNodeAccess(workflow = {}) {
20
+ return effectiveSpecVersion(workflow) >= 2 ? "read" : "write";
21
+ }
22
+ /**
23
+ * True when the node runs under a declared policy: its own or the workflow's
24
+ * `permissions`, its `outputs`, or the spec v2 `read` default. A node that runs
25
+ * under none keeps each harness's own defaults (spec v1 only).
26
+ */
27
+ export function hasNodePolicy(node, workflow = {}) {
28
+ return (node.permissions !== undefined ||
29
+ workflow.permissions !== undefined ||
30
+ (node.outputs?.length ?? 0) > 0 ||
31
+ defaultNodeAccess(workflow) === "read");
32
+ }
12
33
  /**
13
34
  * Effective permissions for a node.
14
35
  *
15
36
  * access: the node's own, else `read` when it declares `outputs`, else the
16
- * workflow's, else `write` (today's behavior). A workflow `read` ceiling wins
17
- * over a node `write` (the loader rejects that combination; this is the
18
- * runtime backstop). deny: union. strict: either. native: `none` if either says so.
37
+ * workflow's, else {@link defaultNodeAccess} (`read` from spec v2, `write` in
38
+ * v1). A workflow `read` ceiling wins over a node `write` (the loader rejects
39
+ * that combination; this is the runtime backstop). deny: union. strict:
40
+ * either. native: `none` if either says so.
19
41
  */
20
42
  export function resolveNodePermissions(node, workflow = {}) {
21
43
  const wf = spec(workflow.permissions);
22
44
  const own = spec(node.permissions);
23
- let access = own.access ?? (node.outputs && node.outputs.length > 0 ? "read" : (wf.access ?? "write"));
45
+ let access = own.access ?? (node.outputs && node.outputs.length > 0 ? "read" : (wf.access ?? defaultNodeAccess(workflow)));
24
46
  if (wf.access === "read")
25
47
  access = "read";
26
48
  const deny = [...new Set([...(wf.deny ?? []), ...(own.deny ?? [])])];
@@ -48,3 +70,36 @@ export function buildNodePolicy(opts) {
48
70
  ...(opts.permissions.native === "none" ? { nativeNone: true } : {}),
49
71
  };
50
72
  }
73
+ const APPROVE = /\bapprove\b/i;
74
+ /** "never approve", "do not approve", "don't ever approve": the negation governs approve itself. */
75
+ const NEGATED = /(?:\b(?:never|not|no|nor)|n't)\s+(?:\w+\s+)?approve\b/i;
76
+ const REVIEW = /\bpull requests?\b|\bPRs?\b|\breview/i;
77
+ /**
78
+ * Load warnings for review nodes told to approve (#598). The pr-review
79
+ * template before #598 said "If no issues found, approve with a brief note";
80
+ * files generated from it keep that line through `sweny workflow upgrade`.
81
+ * Precise on purpose: only a node that can write (so it could act on it), in a
82
+ * `pr_review` workflow or whose instruction is about a pull request or review,
83
+ * with an inline instruction line that says "approve", not negated ("never approve").
84
+ */
85
+ export function approveDirectiveWarnings(workflow) {
86
+ const warnings = [];
87
+ for (const [id, node] of Object.entries(workflow.nodes)) {
88
+ const text = typeof node.instruction === "string"
89
+ ? node.instruction
90
+ : "inline" in node.instruction
91
+ ? node.instruction.inline
92
+ : undefined;
93
+ if (text === undefined || resolveNodePermissions(node, workflow).access !== "write")
94
+ continue;
95
+ if (workflow.workflow_type !== "pr_review" && !REVIEW.test(text))
96
+ continue;
97
+ const line = text.split("\n").find((l) => APPROVE.test(l) && !NEGATED.test(l));
98
+ if (line === undefined)
99
+ continue;
100
+ warnings.push(`Node "${id}" can write and its instruction tells the agent to approve ("${line.trim()}"). ` +
101
+ "An AI reviewer should never approve: delete that line, and post the review as a comment output " +
102
+ "(see `sweny new --template pr-review`).");
103
+ }
104
+ return warnings;
105
+ }
@@ -13,7 +13,7 @@
13
13
  * labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
14
14
  * over the intents that passed and can only veto them.
15
15
  */
16
- import type { BranchPusher, Logger, SafeOutputDeclaration, SafeOutputPin, SafeOutputReceipt, SafeOutputsPolicy, SafeOutputType, Skill, Tool, ToolContext } from "./types.js";
16
+ import type { BranchPusher, Logger, SafeOutputDeclaration, SafeOutputPin, SafeOutputTarget, SafeOutputReceipt, SafeOutputsPolicy, SafeOutputType, Skill, Tool, ToolContext } from "./types.js";
17
17
  /** Name of the intent-recording tool a node with `outputs` receives. */
18
18
  export declare const EMIT_OUTPUT_TOOL = "emit_output";
19
19
  /** Longest title the write stage accepts (after the prefix). */
@@ -60,6 +60,8 @@ export declare function actorTrusted(policy: SafeOutputsPolicy | undefined, acto
60
60
  * The issue or PR a declaration pins, as a string. `{ input: name }` reads the
61
61
  * run input; an absent or empty value is `undefined`, which refuses the write.
62
62
  */
63
+ /** A declared target: the literal, or the run input it names. Empty or missing: undefined. */
64
+ export declare function resolveTarget(target: SafeOutputTarget | undefined, input?: Record<string, unknown>): string | undefined;
63
65
  export declare function resolvePin(pin: SafeOutputPin, input?: Record<string, unknown>): string | undefined;
64
66
  /** `30m` → 1_800_000. Undefined for anything the schema would reject. */
65
67
  export declare function parseDuration(s: string | undefined): number | undefined;