@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.
- package/dist/cli/main.js +4 -0
- package/dist/cli/packs.d.ts +1 -0
- package/dist/cli/packs.js +8 -3
- package/dist/cli/templates.js +148 -21
- package/dist/cli/workflow-upgrade.d.ts +7 -1
- package/dist/cli/workflow-upgrade.js +28 -2
- package/dist/executor.js +3 -2
- package/dist/journal.d.ts +2 -1
- package/dist/journal.js +4 -3
- package/dist/loader.js +5 -0
- package/dist/migrations.d.ts +33 -2
- package/dist/migrations.js +106 -4
- package/dist/node-policy.d.ts +27 -4
- package/dist/node-policy.js +59 -4
- package/dist/safe-outputs.d.ts +3 -1
- package/dist/safe-outputs.js +22 -4
- package/dist/schema.d.ts +30 -10
- package/dist/schema.js +14 -7
- package/dist/types.d.ts +15 -4
- package/dist/workflow-builder.d.ts +1 -1
- package/dist/workflow-builder.js +11 -4
- package/dist/workflows/browser.js +3 -3
- package/dist/workflows/implement.yml +7 -0
- package/dist/workflows/seed-content.yml +17 -0
- package/dist/workflows/triage.yml +7 -0
- package/package.json +1 -1
package/dist/migrations.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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) {
|
package/dist/node-policy.d.ts
CHANGED
|
@@ -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 `
|
|
20
|
-
* over a node `write` (the loader rejects
|
|
21
|
-
* runtime backstop). deny: union. strict:
|
|
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[];
|
package/dist/node-policy.js
CHANGED
|
@@ -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 `
|
|
17
|
-
* over a node `write` (the loader rejects
|
|
18
|
-
* runtime backstop). deny: union. strict:
|
|
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 ??
|
|
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
|
+
}
|
package/dist/safe-outputs.d.ts
CHANGED
|
@@ -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;
|
package/dist/safe-outputs.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
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
|
|
379
|
-
|
|
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 |
|
|
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
|
|
1364
|
-
readonly
|
|
1365
|
-
|
|
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.
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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.
|
|
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` (
|
|
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
|
-
/**
|
|
565
|
-
|
|
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
|
-
/**
|
|
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.
|
package/dist/workflow-builder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|