@sweny-ai/core 0.1.210 → 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.
@@ -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;
@@ -113,8 +113,9 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
113
113
  export function safeOutputsInstruction(declarations, input) {
114
114
  const lines = declarations.map((d) => {
115
115
  const parts = [`at most ${d.max ?? 1}`];
116
- if (d.target)
117
- parts.push(`target ${d.target}`);
116
+ const target = resolveTarget(d.target, input);
117
+ if (target)
118
+ parts.push(`target ${target}`);
118
119
  if (d.number !== undefined) {
119
120
  const pin = resolvePin(d.number, input);
120
121
  parts.push(pin ? `only issue or PR ${pin} (the number may be omitted)` : "pinned issue not set for this run");
@@ -179,6 +180,14 @@ export function actorTrusted(policy, actor) {
179
180
  * The issue or PR a declaration pins, as a string. `{ input: name }` reads the
180
181
  * run input; an absent or empty value is `undefined`, which refuses the write.
181
182
  */
183
+ /** A declared target: the literal, or the run input it names. Empty or missing: undefined. */
184
+ export function resolveTarget(target, input) {
185
+ const raw = typeof target === "object" ? input?.[target.input] : target;
186
+ if (typeof raw !== "string")
187
+ return undefined;
188
+ const v = raw.trim();
189
+ return v.length > 0 ? v : undefined;
190
+ }
182
191
  export function resolvePin(pin, input) {
183
192
  const raw = typeof pin === "object" ? input?.[pin.input] : pin;
184
193
  if (typeof raw === "number" && Number.isInteger(raw) && raw > 0)
@@ -375,12 +384,21 @@ export async function applySafeOutputs(o) {
375
384
  refuse("no skill can apply this type");
376
385
  continue;
377
386
  }
378
- // The run's repo from the operator's env, never one a workspace .env introduced.
379
- const pinned = decl.target ?? (via === "github" ? trustedEnvValue(o.env, "GITHUB_REPOSITORY") || undefined : undefined);
387
+ // The declared target (a literal, or the run input it names), else the run's
388
+ // repo from the operator's env, never one a workspace .env introduced.
389
+ const pinned = resolveTarget(decl.target, o.input) ??
390
+ (via === "github" ? trustedEnvValue(o.env, "GITHUB_REPOSITORY") || undefined : undefined);
380
391
  if (intent.target && pinned && intent.target.toLowerCase() !== pinned.toLowerCase()) {
381
392
  refuse("target outside the declared target", { via });
382
393
  continue;
383
394
  }
395
+ // #598: an output pinned to one issue or PR is pinned to one repository too, and that
396
+ // repository comes only from the declaration, a run input, or GITHUB_REPOSITORY. Never
397
+ // from the agent: PR #N of any repo the token can write to is not the pinned PR.
398
+ if (via === "github" && decl.number !== undefined && !pinned) {
399
+ refuse("pinned output needs a trusted repository", { via });
400
+ continue;
401
+ }
384
402
  const target = intent.target ?? pinned;
385
403
  if (applier.needsTarget && !target) {
386
404
  refuse("no target", { via });
package/dist/schema.d.ts CHANGED
@@ -297,7 +297,9 @@ export declare const safeOutputDeclarationZ: z.ZodObject<{
297
297
  }>;
298
298
  via: z.ZodOptional<z.ZodString>;
299
299
  max: z.ZodOptional<z.ZodNumber>;
300
- target: z.ZodOptional<z.ZodString>;
300
+ target: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
301
+ input: z.ZodString;
302
+ }, z.core.$strict>]>>;
301
303
  title_prefix: z.ZodOptional<z.ZodString>;
302
304
  labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
303
305
  expires: z.ZodOptional<z.ZodString>;
@@ -397,7 +399,9 @@ export declare const nodeZ: z.ZodObject<{
397
399
  }>;
398
400
  via: z.ZodOptional<z.ZodString>;
399
401
  max: z.ZodOptional<z.ZodNumber>;
400
- target: z.ZodOptional<z.ZodString>;
402
+ target: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
403
+ input: z.ZodString;
404
+ }, z.core.$strict>]>>;
401
405
  title_prefix: z.ZodOptional<z.ZodString>;
402
406
  labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
403
407
  expires: z.ZodOptional<z.ZodString>;
@@ -603,7 +607,9 @@ export declare const workflowZ: z.ZodObject<{
603
607
  }>;
604
608
  via: z.ZodOptional<z.ZodString>;
605
609
  max: z.ZodOptional<z.ZodNumber>;
606
- target: z.ZodOptional<z.ZodString>;
610
+ target: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
611
+ input: z.ZodString;
612
+ }, z.core.$strict>]>>;
607
613
  title_prefix: z.ZodOptional<z.ZodString>;
608
614
  labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
609
615
  expires: z.ZodOptional<z.ZodString>;
@@ -871,7 +877,9 @@ export declare function parseWorkflow(raw: unknown): {
871
877
  type: "label" | "comment" | "issue" | "pr" | "issue_state";
872
878
  via?: string | undefined;
873
879
  max?: number | undefined;
874
- target?: string | undefined;
880
+ target?: string | {
881
+ input: string;
882
+ } | undefined;
875
883
  title_prefix?: string | undefined;
876
884
  labels?: string[] | undefined;
877
885
  expires?: string | undefined;
@@ -1360,9 +1368,21 @@ export declare const workflowJsonSchema: {
1360
1368
  readonly description: "Max writes of this type from this node per run. Default 1.";
1361
1369
  };
1362
1370
  readonly target: {
1363
- readonly type: "string";
1364
- readonly minLength: 1;
1365
- readonly description: "Pinned target (GitHub owner/repo, Linear team id). GitHub default: GITHUB_REPOSITORY.";
1371
+ readonly description: "Pinned target (GitHub owner/repo, Linear team id), or { input: <name> } to take it from a run input. GitHub default: GITHUB_REPOSITORY. An output that pins number never takes its GitHub repository from the agent.";
1372
+ readonly oneOf: readonly [{
1373
+ readonly type: "string";
1374
+ readonly minLength: 1;
1375
+ }, {
1376
+ readonly type: "object";
1377
+ readonly required: readonly ["input"];
1378
+ readonly additionalProperties: false;
1379
+ readonly properties: {
1380
+ readonly input: {
1381
+ readonly type: "string";
1382
+ readonly minLength: 1;
1383
+ };
1384
+ };
1385
+ }];
1366
1386
  };
1367
1387
  readonly title_prefix: {
1368
1388
  readonly type: "string";
@@ -1427,7 +1447,7 @@ export declare const workflowJsonSchema: {
1427
1447
  readonly spec_version: {
1428
1448
  readonly type: "string";
1429
1449
  readonly pattern: "^[1-9][0-9]*$";
1430
- readonly description: "Workflow spec version as a positive integer string. Absent means 1. Older versions are migrated in memory on load; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.";
1450
+ readonly description: "Workflow spec version as a positive integer string. Absent means 1. Version 2 is safe by default: a node that declares no permissions and no outputs, in a workflow that declares no permissions, is read-only. Version 1 files are migrated in memory on load, keeping write on such nodes with one warning each; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.";
1431
1451
  };
1432
1452
  readonly entry: {
1433
1453
  readonly type: "string";
@@ -1465,7 +1485,7 @@ export declare const workflowJsonSchema: {
1465
1485
  };
1466
1486
  readonly permissions: {
1467
1487
  readonly $ref: "#/$defs/Permissions";
1468
- readonly description: "Default and ceiling for every node's permissions. Absent: write.";
1488
+ readonly description: "Default and ceiling for every node's permissions. Absent: read (spec_version 2), write (spec_version 1).";
1469
1489
  };
1470
1490
  readonly safe_outputs: {
1471
1491
  readonly type: "object";
@@ -1759,7 +1779,7 @@ export declare const workflowJsonSchema: {
1759
1779
  };
1760
1780
  readonly permissions: {
1761
1781
  readonly $ref: "#/$defs/Permissions";
1762
- readonly description: "What this node's agent may do. Absent: read when the node declares outputs, else the workflow's permissions, else write.";
1782
+ readonly description: "What this node's agent may do. Absent: read when the node declares outputs, else the workflow's permissions, else read (spec_version 2) or write (spec_version 1).";
1763
1783
  };
1764
1784
  readonly outputs: {
1765
1785
  readonly type: "array";
package/dist/schema.js CHANGED
@@ -303,7 +303,7 @@ export const safeOutputDeclarationZ = z
303
303
  type: z.enum(SAFE_OUTPUT_TYPES),
304
304
  via: z.string().min(1).optional(),
305
305
  max: z.number().int().min(1).max(SAFE_OUTPUT_MAX_CEILING).optional(),
306
- target: z.string().min(1).optional(),
306
+ target: z.union([z.string().min(1), z.object({ input: z.string().min(1) }).strict()]).optional(),
307
307
  title_prefix: z.string().min(1).max(64).optional(),
308
308
  labels: z.array(z.string().min(1)).min(1).optional(),
309
309
  expires: z.string().regex(SAFE_OUTPUT_EXPIRES_PATTERN).optional(),
@@ -1073,9 +1073,16 @@ export const workflowJsonSchema = {
1073
1073
  description: "Max writes of this type from this node per run. Default 1.",
1074
1074
  },
1075
1075
  target: {
1076
- type: "string",
1077
- minLength: 1,
1078
- description: "Pinned target (GitHub owner/repo, Linear team id). GitHub default: GITHUB_REPOSITORY.",
1076
+ description: "Pinned target (GitHub owner/repo, Linear team id), or { input: <name> } to take it from a run input. GitHub default: GITHUB_REPOSITORY. An output that pins number never takes its GitHub repository from the agent.",
1077
+ oneOf: [
1078
+ { type: "string", minLength: 1 },
1079
+ {
1080
+ type: "object",
1081
+ required: ["input"],
1082
+ additionalProperties: false,
1083
+ properties: { input: { type: "string", minLength: 1 } },
1084
+ },
1085
+ ],
1079
1086
  },
1080
1087
  title_prefix: {
1081
1088
  type: "string",
@@ -1122,7 +1129,7 @@ export const workflowJsonSchema = {
1122
1129
  spec_version: {
1123
1130
  type: "string",
1124
1131
  pattern: "^[1-9][0-9]*$",
1125
- description: "Workflow spec version as a positive integer string. Absent means 1. Older versions are migrated in memory on load; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.",
1132
+ description: "Workflow spec version as a positive integer string. Absent means 1. Version 2 is safe by default: a node that declares no permissions and no outputs, in a workflow that declares no permissions, is read-only. Version 1 files are migrated in memory on load, keeping write on such nodes with one warning each; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.",
1126
1133
  },
1127
1134
  entry: { type: "string", minLength: 1, description: "ID of the entry node" },
1128
1135
  rules: {
@@ -1155,7 +1162,7 @@ export const workflowJsonSchema = {
1155
1162
  },
1156
1163
  permissions: {
1157
1164
  $ref: "#/$defs/Permissions",
1158
- description: "Default and ceiling for every node's permissions. Absent: write.",
1165
+ description: "Default and ceiling for every node's permissions. Absent: read (spec_version 2), write (spec_version 1).",
1159
1166
  },
1160
1167
  safe_outputs: {
1161
1168
  type: "object",
@@ -1374,7 +1381,7 @@ export const workflowJsonSchema = {
1374
1381
  },
1375
1382
  permissions: {
1376
1383
  $ref: "#/$defs/Permissions",
1377
- description: "What this node's agent may do. Absent: read when the node declares outputs, else the workflow's permissions, else write.",
1384
+ description: "What this node's agent may do. Absent: read when the node declares outputs, else the workflow's permissions, else read (spec_version 2) or write (spec_version 1).",
1378
1385
  },
1379
1386
  outputs: {
1380
1387
  type: "array",
package/dist/types.d.ts CHANGED
@@ -502,7 +502,7 @@ export interface Node {
502
502
  * only `access: "read"` skill tools, no external skill MCP servers, no
503
503
  * shell / file-write / edit / fetch / subagent built-ins. Absent: `read`
504
504
  * when the node declares `outputs`, else the workflow's `permissions`,
505
- * else `write` (today's behavior).
505
+ * else `read` from spec_version 2, `write` in spec_version 1 (#598).
506
506
  */
507
507
  permissions?: NodePermissions;
508
508
  /**
@@ -561,8 +561,12 @@ export interface SafeOutputDeclaration {
561
561
  via?: string;
562
562
  /** Max writes of this type from this node per run. Default 1. */
563
563
  max?: number;
564
- /** Pinned target (GitHub `owner/repo`, Linear team id). GitHub default: `GITHUB_REPOSITORY`. */
565
- target?: string;
564
+ /**
565
+ * Pinned target (GitHub `owner/repo`, Linear team id), or `{ input: <name> }`
566
+ * to take it from a run input. GitHub default: `GITHUB_REPOSITORY`. An output
567
+ * pinned to an issue (`number`) never takes its GitHub repository from the agent (#598).
568
+ */
569
+ target?: SafeOutputTarget;
566
570
  /** Prepended to issue / PR titles that do not already start with it. */
567
571
  title_prefix?: string;
568
572
  /** issue / pr: labels always added. label: the only labels the agent may add. */
@@ -580,6 +584,10 @@ export interface SafeOutputDeclaration {
580
584
  /** issue_state only: the one change this output may make. Default: either. */
581
585
  state?: SafeOutputState;
582
586
  }
587
+ /** A pinned target: a literal (`owner/repo`, team id), or the name of a run input that holds one. */
588
+ export type SafeOutputTarget = string | {
589
+ input: string;
590
+ };
583
591
  /** A pinned issue / PR: a literal number or identifier, or the name of a run input that holds one. */
584
592
  export type SafeOutputPin = string | number | {
585
593
  input: string;
@@ -733,7 +741,10 @@ export interface Workflow {
733
741
  * See `src/inputs.ts` for the field shape and validation rules.
734
742
  */
735
743
  inputs?: import("./inputs.js").WorkflowInputs;
736
- /** Default and ceiling for every node's `permissions` (#365). Absent: `write`. */
744
+ /**
745
+ * Default and ceiling for every node's `permissions` (#365). Absent: `read`
746
+ * from spec_version 2, `write` in spec_version 1 (#598).
747
+ */
737
748
  permissions?: NodePermissions;
738
749
  /** Workflow-level safe-output policy (#365). */
739
750
  safe_outputs?: SafeOutputsPolicy;
@@ -26,7 +26,7 @@ export declare function buildSystemPrompt(skills: Skill[], existingWorkflow?: Wo
26
26
  * adds a `summary` key from the text response. We strip `summary`
27
27
  * and any other non-workflow keys before parsing.
28
28
  */
29
- export declare function extractWorkflow(data: Record<string, unknown>): Workflow;
29
+ export declare function extractWorkflow(data: Record<string, unknown>, keep?: Pick<Workflow, "spec_version">): Workflow;
30
30
  /**
31
31
  * Derive the required environment variables for a workflow from its skills.
32
32
  * Deterministic — doesn't depend on Claude generating a variables block.
@@ -6,6 +6,7 @@
6
6
  * description and available skills, and returns a validated Workflow.
7
7
  */
8
8
  import { workflowZ, validateWorkflow, workflowJsonSchema } from "./schema.js";
9
+ import { CURRENT_SPEC_VERSION } from "./migrations.js";
9
10
  // ─── Instruction quality guidance ────────────────────────────────
10
11
  const INSTRUCTION_GUIDANCE = `Each node's \`instruction\` field is a detailed prompt that Claude will execute autonomously.
11
12
  Write instructions as if briefing a skilled engineer who has access to the node's tools
@@ -60,6 +61,7 @@ export function buildSystemPrompt(skills, existingWorkflow) {
60
61
  "- Use a natural-language `when` only for a fuzzy condition no declared field can express",
61
62
  "- Put safety conditions (anything that gates a write, a PR or an issue) in expressions, never in natural language",
62
63
  "- Every node must be reachable from the entry node",
64
+ "- Nodes are read-only unless they declare otherwise (spec_version 2). A node that files issues, comments or opens PRs declares `outputs` (comment, issue, pr, label, issue_state) and stays read-only; any other node that must change something declares `permissions: write`, narrowed with `deny` to the built-in tool classes it does not need",
63
65
  "- Return ONLY the workflow JSON object — no markdown fences, no explanation",
64
66
  ];
65
67
  if (existingWorkflow) {
@@ -73,9 +75,14 @@ export function buildSystemPrompt(skills, existingWorkflow) {
73
75
  * adds a `summary` key from the text response. We strip `summary`
74
76
  * and any other non-workflow keys before parsing.
75
77
  */
76
- export function extractWorkflow(data) {
77
- // Remove keys added by ClaudeClient that aren't part of the workflow schema
78
- const { summary: _summary, ...rest } = data;
78
+ export function extractWorkflow(data, keep) {
79
+ // Remove keys added by ClaudeClient that aren't part of the workflow schema.
80
+ // The spec version is ours to set, never the model's (#598): a new workflow is
81
+ // current (read-only by default), a refined one keeps the version it had.
82
+ const { summary: _summary, spec_version: _modelVersion, ...body } = data;
83
+ // `keep` (a refine) holds the version the workflow had, possibly none; a build gets the current one.
84
+ const specVersion = keep ? keep.spec_version : String(CURRENT_SPEC_VERSION);
85
+ const rest = specVersion === undefined ? body : { spec_version: specVersion, ...body };
79
86
  // workflowZ.parse throws a ZodError on invalid input
80
87
  const parsed = workflowZ.parse(rest);
81
88
  const errors = validateWorkflow(parsed);
@@ -158,5 +165,5 @@ export async function refineWorkflow(workflow, instruction, options) {
158
165
  if (result.status === "failed") {
159
166
  throw new Error(`Claude failed to refine workflow: ${result.data.error ?? "unknown error"}`);
160
167
  }
161
- return extractWorkflow(result.data);
168
+ return extractWorkflow(result.data, { spec_version: workflow.spec_version });
162
169
  }