@sweny-ai/core 0.1.151 → 0.1.153

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.
@@ -150,10 +150,63 @@ export declare function scopeAgentEnv(source: Record<string, string | undefined>
150
150
  * values are always dropped. Pure: never mutates `source`.
151
151
  */
152
152
  export declare function buildAgentEnv(source: Record<string, string | undefined>, opts?: BuildAgentEnvOpts): Record<string, string>;
153
+ /**
154
+ * Write tokens withheld from the agent when pushes are blocked. `gh`, the
155
+ * GitHub/GitLab REST APIs and git's HTTPS auth all read these; skill tools
156
+ * run in the sweny process, not the agent, so reads through skills still work.
157
+ * The harness's own model credentials are never in this list.
158
+ */
159
+ export declare const PUSH_TOKEN_VARS: readonly string[];
160
+ /** The remote name every push is pointed at under {@link withPushBlocked}. It never exists. */
161
+ export declare const NO_PUSH_REMOTE = "sweny-no-push";
162
+ /**
163
+ * A sweny-owned directory holding a `pre-push` hook and a `GIT_ASKPASS`
164
+ * program that both refuse, an ssh wrapper that refuses pushes, and an empty
165
+ * `gh` config dir. Created once per process under the OS temp dir.
166
+ */
167
+ export declare function noPushDir(): string;
168
+ /**
169
+ * Git config entries (command scope, via `GIT_CONFIG_COUNT`) that make every
170
+ * push fail while leaving fetch, commit and diff alone:
171
+ *
172
+ * - `credential.helper` set to empty resets the helper list, so no stored
173
+ * credential (keychain, `gh auth git-credential`, store) reaches git;
174
+ * - `remote.pushDefault` names a remote that does not exist and
175
+ * `push.default=nothing` refuses a bare `git push`;
176
+ * - `url.<unusable>.pushInsteadOf` rewrites every https/ssh/git/file/absolute
177
+ * path push URL to a scheme git has no helper for;
178
+ * - `core.hooksPath` points at a sweny `pre-push` hook that exits 1, which
179
+ * also covers a remote with an explicit `pushurl` (git ignores
180
+ * pushInsteadOf there). Repo hooks do not run in these nodes.
181
+ */
182
+ export declare function noPushGitConfig(dir: string): Array<[string, string]>;
183
+ /**
184
+ * The agent env with pushes blocked (#442). Applied to every node's agent
185
+ * env when a run is staged or a dry run, on every harness, whether or not env
186
+ * scoping is on. Withholds {@link PUSH_TOKEN_VARS}, appends
187
+ * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present,
188
+ * sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
189
+ * routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
190
+ * ssh push fails even to a remote with an explicit `pushurl` under
191
+ * `--no-verify`; ssh fetches still run the operator's own ssh command), and
192
+ * gives `gh` an empty config dir so a stored `gh auth login` is not used.
193
+ *
194
+ * Not a sandbox: a process that rewrites its own env or git config and
195
+ * finds a credential on disk (an ssh key without a passphrase, a keychain
196
+ * entry) is outside what env can stop. The sandbox wrapper hides credential
197
+ * files such as `~/.ssh`. `enabled` false returns `env` unchanged. Pure apart from
198
+ * creating {@link noPushDir} once.
199
+ */
200
+ export declare function withPushBlocked(env: Record<string, string>, enabled: boolean | undefined): Record<string, string>;
153
201
  /** What one node's agent call may see: env var names and network hosts. */
154
202
  export interface AgentAccess {
155
203
  envVars: string[];
156
204
  domains: string[];
205
+ /**
206
+ * The run is staged or a dry run (#442): the harness applies
207
+ * {@link withPushBlocked} to the agent env, so no push can leave the node.
208
+ */
209
+ noPush?: boolean;
157
210
  }
158
211
  /** Hosts reachable by sandboxed commands in every node: source hosting + package registries. */
159
212
  export declare const DEFAULT_SANDBOX_DOMAINS: readonly string[];
package/dist/agent-env.js CHANGED
@@ -22,8 +22,8 @@
22
22
  * SWENY_SANDBOX_ALLOWED_DOMAINS / sandbox-allowed-domains extra hosts for sandboxed commands
23
23
  */
24
24
  import { execFileSync } from "node:child_process";
25
- import { existsSync } from "node:fs";
26
- import { homedir } from "node:os";
25
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
26
+ import { homedir, tmpdir } from "node:os";
27
27
  import * as path from "node:path";
28
28
  // ─── Scoped env ──────────────────────────────────────────────────
29
29
  /**
@@ -429,6 +429,132 @@ export function buildAgentEnv(source, opts = {}) {
429
429
  }
430
430
  return out;
431
431
  }
432
+ // ─── No push under --stage / --dry-run (#442) ────────────────────
433
+ /**
434
+ * Write tokens withheld from the agent when pushes are blocked. `gh`, the
435
+ * GitHub/GitLab REST APIs and git's HTTPS auth all read these; skill tools
436
+ * run in the sweny process, not the agent, so reads through skills still work.
437
+ * The harness's own model credentials are never in this list.
438
+ */
439
+ export const PUSH_TOKEN_VARS = [
440
+ "GITHUB_TOKEN",
441
+ "GH_TOKEN",
442
+ "GH_ENTERPRISE_TOKEN",
443
+ "GITHUB_ENTERPRISE_TOKEN",
444
+ "GITHUB_PAT",
445
+ "GITLAB_TOKEN",
446
+ "GL_TOKEN",
447
+ "CI_JOB_TOKEN",
448
+ "BITBUCKET_TOKEN",
449
+ // git transport credentials: the ssh agent socket and any askpass program
450
+ "SSH_AUTH_SOCK",
451
+ "SSH_ASKPASS",
452
+ "GIT_ASKPASS",
453
+ ];
454
+ /** The remote name every push is pointed at under {@link withPushBlocked}. It never exists. */
455
+ export const NO_PUSH_REMOTE = "sweny-no-push";
456
+ /** URL prefixes rewritten to an unusable scheme for pushes only (fetches are untouched). */
457
+ const PUSH_URL_PREFIXES = ["https://", "http://", "ssh://", "git://", "git@", "file://", "/"];
458
+ const NO_PUSH_MESSAGE = "sweny: git push is blocked under --stage and --dry-run (#442)";
459
+ let noPushDirCache;
460
+ /** Single-quote for sh: git runs `GIT_SSH_COMMAND` through the shell. */
461
+ function shQuote(s) {
462
+ return `'${s.replace(/'/g, `'\\''`)}'`;
463
+ }
464
+ /**
465
+ * A sweny-owned directory holding a `pre-push` hook and a `GIT_ASKPASS`
466
+ * program that both refuse, an ssh wrapper that refuses pushes, and an empty
467
+ * `gh` config dir. Created once per process under the OS temp dir.
468
+ */
469
+ export function noPushDir() {
470
+ if (noPushDirCache && existsSync(path.join(noPushDirCache, "hooks", "pre-push")))
471
+ return noPushDirCache;
472
+ const dir = mkdtempSync(path.join(tmpdir(), "sweny-no-push-"));
473
+ mkdirSync(path.join(dir, "hooks"));
474
+ mkdirSync(path.join(dir, "gh"));
475
+ const refuse = `#!/bin/sh\necho "${NO_PUSH_MESSAGE}" >&2\nexit 1\n`;
476
+ writeFileSync(path.join(dir, "hooks", "pre-push"), refuse, { mode: 0o755 });
477
+ writeFileSync(path.join(dir, "askpass"), refuse, { mode: 0o755 });
478
+ // git runs `<ssh> host "git-receive-pack '<path>'"` to push and
479
+ // git-upload-pack to fetch: refuse the first, hand everything else to the
480
+ // operator's own ssh command (or plain ssh).
481
+ const ssh = `#!/bin/sh\n` +
482
+ `for a in "$@"; do case "$a" in *receive-pack*) echo "${NO_PUSH_MESSAGE}" >&2; exit 1;; esac; done\n` +
483
+ `if [ -n "$SWENY_NO_PUSH_SSH" ]; then exec sh -c "$SWENY_NO_PUSH_SSH \\"\\$@\\"" ssh "$@"; fi\n` +
484
+ `exec ssh "$@"\n`;
485
+ writeFileSync(path.join(dir, "ssh"), ssh, { mode: 0o755 });
486
+ noPushDirCache = dir;
487
+ return dir;
488
+ }
489
+ /**
490
+ * Git config entries (command scope, via `GIT_CONFIG_COUNT`) that make every
491
+ * push fail while leaving fetch, commit and diff alone:
492
+ *
493
+ * - `credential.helper` set to empty resets the helper list, so no stored
494
+ * credential (keychain, `gh auth git-credential`, store) reaches git;
495
+ * - `remote.pushDefault` names a remote that does not exist and
496
+ * `push.default=nothing` refuses a bare `git push`;
497
+ * - `url.<unusable>.pushInsteadOf` rewrites every https/ssh/git/file/absolute
498
+ * path push URL to a scheme git has no helper for;
499
+ * - `core.hooksPath` points at a sweny `pre-push` hook that exits 1, which
500
+ * also covers a remote with an explicit `pushurl` (git ignores
501
+ * pushInsteadOf there). Repo hooks do not run in these nodes.
502
+ */
503
+ export function noPushGitConfig(dir) {
504
+ return [
505
+ ["credential.helper", ""],
506
+ ["remote.pushDefault", NO_PUSH_REMOTE],
507
+ ["push.default", "nothing"],
508
+ ["core.hooksPath", path.join(dir, "hooks")],
509
+ ...PUSH_URL_PREFIXES.map((p) => [`url.${NO_PUSH_REMOTE}://blocked/.pushInsteadOf`, p]),
510
+ ];
511
+ }
512
+ /**
513
+ * The agent env with pushes blocked (#442). Applied to every node's agent
514
+ * env when a run is staged or a dry run, on every harness, whether or not env
515
+ * scoping is on. Withholds {@link PUSH_TOKEN_VARS}, appends
516
+ * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present,
517
+ * sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
518
+ * routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
519
+ * ssh push fails even to a remote with an explicit `pushurl` under
520
+ * `--no-verify`; ssh fetches still run the operator's own ssh command), and
521
+ * gives `gh` an empty config dir so a stored `gh auth login` is not used.
522
+ *
523
+ * Not a sandbox: a process that rewrites its own env or git config and
524
+ * finds a credential on disk (an ssh key without a passphrase, a keychain
525
+ * entry) is outside what env can stop. The sandbox wrapper hides credential
526
+ * files such as `~/.ssh`. `enabled` false returns `env` unchanged. Pure apart from
527
+ * creating {@link noPushDir} once.
528
+ */
529
+ export function withPushBlocked(env, enabled) {
530
+ if (!enabled)
531
+ return env;
532
+ const dir = noPushDir();
533
+ const out = {};
534
+ for (const [k, v] of Object.entries(env)) {
535
+ if (PUSH_TOKEN_VARS.includes(k))
536
+ continue;
537
+ out[k] = v;
538
+ }
539
+ const base = Number.parseInt(out.GIT_CONFIG_COUNT ?? "0", 10);
540
+ let n = Number.isFinite(base) && base > 0 ? base : 0;
541
+ for (const [key, value] of noPushGitConfig(dir)) {
542
+ out[`GIT_CONFIG_KEY_${n}`] = key;
543
+ out[`GIT_CONFIG_VALUE_${n}`] = value;
544
+ n++;
545
+ }
546
+ out.GIT_CONFIG_COUNT = String(n);
547
+ const ownSsh = env.GIT_SSH_COMMAND ?? (env.GIT_SSH ? shQuote(env.GIT_SSH) : undefined);
548
+ if (ownSsh)
549
+ out.SWENY_NO_PUSH_SSH = ownSsh;
550
+ out.GIT_SSH_COMMAND = shQuote(path.join(dir, "ssh"));
551
+ out.GIT_SSH_VARIANT = "ssh";
552
+ out.GIT_ASKPASS = path.join(dir, "askpass");
553
+ out.GIT_TERMINAL_PROMPT = "0";
554
+ out.GH_CONFIG_DIR = path.join(dir, "gh");
555
+ out.GH_PROMPT_DISABLED = "1";
556
+ return out;
557
+ }
432
558
  /** Hosts reachable by sandboxed commands in every node: source hosting + package registries. */
433
559
  export const DEFAULT_SANDBOX_DOMAINS = [
434
560
  "github.com",
@@ -33,6 +33,7 @@ const LEGACY_WRITE_TOOLS = {
33
33
  comment: ["github_add_comment", "linear_add_comment", "linear_update_issue"],
34
34
  pr: ["github_create_pr"],
35
35
  label: ["github_add_labels"],
36
+ issue_state: ["linear_update_issue", "linear_set_issue_state", "github_set_issue_state"],
36
37
  };
37
38
  const writeToolsBySkill = new Map(builtinSkills.map((s) => [s.id, s.tools.filter((t) => t.access !== "read").map((t) => t.name)]));
38
39
  function skillWriteTools(node) {
@@ -59,7 +60,7 @@ export function readGaps(p) {
59
60
  }
60
61
  /** The skill that applies this output type for these providers, or undefined when none can. */
61
62
  export function outputSkill(type, p) {
62
- if (type === "issue" || type === "comment")
63
+ if (type === "issue" || type === "comment" || type === "issue_state")
63
64
  return TRACKER_SKILL[p.issueTracker];
64
65
  return p.sourceControl === "github" ? "github" : undefined;
65
66
  }
package/dist/executor.js CHANGED
@@ -281,7 +281,12 @@ export async function execute(workflow, input, options) {
281
281
  // compiles them or reports them in `degraded`.
282
282
  const denyClasses = (node.tools?.deny ?? []).filter(isToolClass);
283
283
  let degradedLogged = false;
284
- const agentAccess = resolveAgentAccess(node.skills, skills);
284
+ // #442: a staged or dry run cannot push. The harness blocks git push and
285
+ // withholds write tokens in this node's agent env (see withPushBlocked).
286
+ const agentAccess = {
287
+ ...resolveAgentAccess(node.skills, skills),
288
+ ...(stageOutputs ? { noPush: true } : {}),
289
+ };
285
290
  // #365: a node or workflow that declares `permissions` or `outputs` gets
286
291
  // one portable policy, compiled by each adapter. The gate also runs here so
287
292
  // a strict refusal holds on every harness, before any spend. Nodes that
@@ -48,7 +48,7 @@ import { spawn } from "node:child_process";
48
48
  import * as fs from "node:fs";
49
49
  import * as path from "node:path";
50
50
  import { consoleLogger } from "../types.js";
51
- import { parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, } from "../agent-env.js";
51
+ import { parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
52
52
  import { ACP_CAPABILITIES } from "./capabilities.js";
53
53
  import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
54
54
  import { buildNodePrompt } from "./prompts.js";
@@ -714,7 +714,7 @@ export class AcpHarness {
714
714
  const readOnly = policy.readOnly;
715
715
  // Permission answers: the node's deny list, plus legacy `disallowed_tools` names that map to a class.
716
716
  const deny = new Set([...policy.deny, ...translateDenyNames(policy.nativeDeny).classes]);
717
- const env = this.buildEnv(req.agentAccess?.envVars);
717
+ const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
718
718
  let bridge;
719
719
  try {
720
720
  // Dry run: external MCP servers cannot be classified per tool, so only
@@ -11,7 +11,7 @@
11
11
  import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
12
12
  import { createRequire } from "node:module";
13
13
  import { consoleLogger } from "../types.js";
14
- import { reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, resolveAgentSandbox, } from "../agent-env.js";
14
+ import { reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, resolveAgentSandbox, } from "../agent-env.js";
15
15
  import { policyGate } from "./policy.js";
16
16
  import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
17
17
  import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt, buildNodePrompt } from "./prompts.js";
@@ -310,7 +310,7 @@ export class ClaudeCodeHarness {
310
310
  disallowedTools: disallowedTools.length > 0 ? disallowedTools : undefined,
311
311
  strictMcp: readOnly || policy.strict || policy.exclusiveMcp === true,
312
312
  sandboxMode: policy.sandbox,
313
- agentAccess: { envVars: req.agentAccess?.envVars ?? [], domains: policy.egress },
313
+ agentAccess: { envVars: req.agentAccess?.envVars ?? [], domains: policy.egress, noPush: req.agentAccess?.noPush },
314
314
  });
315
315
  return { ...result, harness: this.info(), degraded: gate.degraded };
316
316
  }
@@ -380,7 +380,7 @@ export class ClaudeCodeHarness {
380
380
  // Build prompt. Context (workflow input such as issues/alerts, plus prior
381
381
  // node outputs) is fenced as untrusted data (#360).
382
382
  const prompt = buildNodePrompt(instruction, context, outputSchema);
383
- const env = this.buildEnv(agentAccess?.envVars);
383
+ const env = withPushBlocked(this.buildEnv(agentAccess?.envVars), agentAccess?.noPush);
384
384
  let response = "";
385
385
  // CC-08: when the SDK populates typed structured output (because we passed
386
386
  // an `outputFormat`), prefer it over re-parsing JSON out of the free-text
@@ -28,7 +28,7 @@ import * as fs from "node:fs";
28
28
  import * as os from "node:os";
29
29
  import * as path from "node:path";
30
30
  import { consoleLogger } from "../types.js";
31
- import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, } from "../agent-env.js";
31
+ import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
32
32
  import { CODEX_CAPABILITIES } from "./capabilities.js";
33
33
  import { policyGate, resolveHarnessPolicy } from "./policy.js";
34
34
  import { buildNodePrompt } from "./prompts.js";
@@ -763,7 +763,7 @@ export class CodexHarness {
763
763
  sandbox = s.mode;
764
764
  }
765
765
  }
766
- const env = this.buildEnv(req.agentAccess?.envVars);
766
+ const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
767
767
  if (wrapper && !env.CODEX_API_KEY && !env.CODEX_ACCESS_TOKEN && !this.loginWarned) {
768
768
  this.loginWarned = true;
769
769
  this.logger.warn("Codex runs inside the sandbox wrapper, where a stored `codex login` is not readable; set CODEX_API_KEY or OPENAI_API_KEY.");
@@ -34,7 +34,7 @@ import * as fs from "node:fs";
34
34
  import * as os from "node:os";
35
35
  import * as path from "node:path";
36
36
  import { consoleLogger } from "../types.js";
37
- import { PI_AUTH_VARS, PI_ENV_PREFIXES, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, } from "../agent-env.js";
37
+ import { PI_AUTH_VARS, PI_ENV_PREFIXES, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
38
38
  import { PI_CAPABILITIES } from "./capabilities.js";
39
39
  import { policyGate, resolveHarnessPolicy } from "./policy.js";
40
40
  import { buildNodePrompt } from "./prompts.js";
@@ -746,7 +746,7 @@ export class PiHarness {
746
746
  if (readOnly)
747
747
  for (const c of ["shell", "write", "edit"])
748
748
  deny.add(c);
749
- const env = this.buildEnv(req.agentAccess?.envVars);
749
+ const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
750
750
  const model = req.model ?? this.model;
751
751
  let bridge;
752
752
  let agentDir;
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * Browser-safe: types only, no runtime imports.
10
10
  */
11
+ import type { AgentAccess } from "../agent-env.js";
11
12
  import type { Claude, Logger, NodeResult } from "../types.js";
12
13
  export type HarnessId = "claude-code" | "mock" | "codex" | "pi" | "hermes" | "gemini" | `acp:${string}`;
13
14
  /** Portable tool vocabulary for node policy. */
@@ -100,6 +101,12 @@ export interface HarnessRunRequest extends ClaudeRunRequest {
100
101
  * `readOnly` / `disallowedTools` / `agentAccess` fields.
101
102
  */
102
103
  policy?: NodePolicy;
104
+ /**
105
+ * {@link ClaudeRunRequest.agentAccess} plus `noPush` (#442): the executor
106
+ * sets it for a staged or dry run, and every adapter applies
107
+ * `withPushBlocked` to the agent env.
108
+ */
109
+ agentAccess?: AgentAccess;
103
110
  }
104
111
  export interface HarnessRunResult extends NodeResult {
105
112
  harness: HarnessInfo;
@@ -9,7 +9,7 @@
9
9
  * the write, so this holds on every harness.
10
10
  *
11
11
  * Order of checks per intent (all deterministic): declared type, workflow
12
- * ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
12
+ * ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
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
  */
@@ -31,6 +31,8 @@ export interface SafeOutputIntent {
31
31
  labels?: string[];
32
32
  head?: string;
33
33
  base?: string;
34
+ /** issue_state: `reopen` or `close`. */
35
+ state?: string;
34
36
  dedupe_key?: string;
35
37
  /** ms since epoch when the intent was recorded. */
36
38
  recordedAt: number;
@@ -9,7 +9,7 @@
9
9
  * the write, so this holds on every harness.
10
10
  *
11
11
  * Order of checks per intent (all deterministic): declared type, workflow
12
- * ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
12
+ * ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
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
  */
@@ -49,7 +49,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
49
49
  return {
50
50
  name: EMIT_OUTPUT_TOOL,
51
51
  access: "read",
52
- description: "Request a write (comment, issue, pr or label). This does NOT write anything now: sweny checks the request " +
52
+ description: "Request a write (comment, issue, pr, label or issue_state). This does NOT write anything now: sweny checks the request " +
53
53
  "against this step's declared outputs and limits, and applies it after the step finishes. Call once per write. " +
54
54
  `Allowed types here: ${types.join(", ")}.`,
55
55
  input_schema: {
@@ -64,8 +64,9 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
64
64
  },
65
65
  number: {
66
66
  type: "string",
67
- description: "Issue or PR number (GitHub) or issue id (Linear), for comment and label",
67
+ description: "Issue or PR number (GitHub) or issue id (Linear), for comment, label and issue_state",
68
68
  },
69
+ state: { type: "string", enum: ["reopen", "close"], description: "issue_state only: reopen or close" },
69
70
  labels: { type: "array", items: { type: "string" }, description: "Labels to add" },
70
71
  head: { type: "string", description: "pr only: branch with the changes" },
71
72
  base: { type: "string", description: "pr only: target branch (default main)" },
@@ -88,7 +89,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
88
89
  return { recorded: false, error: `limit reached: at most ${max} ${type} write(s) from this step` };
89
90
  }
90
91
  const intent = { type, recordedAt: now() };
91
- const fields = ["title", "body", "target", "number", "head", "base", "dedupe_key"];
92
+ const fields = ["title", "body", "target", "number", "head", "base", "state", "dedupe_key"];
92
93
  for (const f of fields) {
93
94
  const v = str(input?.[f]);
94
95
  if (v !== undefined)
@@ -117,6 +118,8 @@ export function safeOutputsInstruction(declarations, input) {
117
118
  const pin = resolvePin(d.number, input);
118
119
  parts.push(pin ? `only issue or PR ${pin} (the number may be omitted)` : "pinned issue not set for this run");
119
120
  }
121
+ if (d.type === "issue_state")
122
+ parts.push(d.state ? `only to ${d.state}` : "reopen or close");
120
123
  if (d.title_prefix)
121
124
  parts.push(`title prefix "${d.title_prefix}"`);
122
125
  if (d.type === "label" && d.labels?.length)
@@ -247,6 +250,11 @@ const APPLIERS = {
247
250
  needsTarget: true,
248
251
  build: (i) => ({ repo: i.target, issue_number: Number(i.number), labels: i.labels }),
249
252
  },
253
+ issue_state: {
254
+ tool: "github_set_issue_state",
255
+ needsTarget: true,
256
+ build: (i) => ({ repo: i.target, issue_number: Number(i.number), state: i.state }),
257
+ },
250
258
  },
251
259
  linear: {
252
260
  comment: {
@@ -264,6 +272,11 @@ const APPLIERS = {
264
272
  ...(i.labels ? { labelIds: i.labels } : {}),
265
273
  }),
266
274
  },
275
+ issue_state: {
276
+ tool: "linear_set_issue_state",
277
+ needsTarget: false,
278
+ build: (i) => ({ issueId: i.number, state: i.state }),
279
+ },
267
280
  },
268
281
  };
269
282
  function dedupeKey(w, explicit) {
@@ -279,6 +292,7 @@ function dedupeKey(w, explicit) {
279
292
  [...(w.labels ?? [])].sort().join(","),
280
293
  w.head ?? "",
281
294
  w.base ?? "",
295
+ ...(w.state ? [w.state] : []),
282
296
  ];
283
297
  return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
284
298
  }
@@ -377,9 +391,11 @@ export async function applySafeOutputs(o) {
377
391
  refuse(type === "comment" && !body.trim() ? "missing body" : "body too long", at);
378
392
  continue;
379
393
  }
380
- // Issue pin: a pinned comment or label may only land on that issue or PR.
394
+ // Issue pin: a pinned comment, label or state change may only land on that issue or PR.
395
+ // issue_state never guesses an issue: it needs the pin or a number on the intent.
396
+ const onIssue = type === "comment" || type === "label" || type === "issue_state";
381
397
  let issueRef = intent.number?.trim().replace(/^#/, "") || undefined;
382
- if ((type === "comment" || type === "label") && decl.number !== undefined) {
398
+ if (onIssue && decl.number !== undefined) {
383
399
  const pin = resolvePin(decl.number, o.input);
384
400
  if (!pin) {
385
401
  refuse("pinned issue is not set for this run", at);
@@ -391,10 +407,29 @@ export async function applySafeOutputs(o) {
391
407
  }
392
408
  issueRef = pin;
393
409
  }
394
- if ((type === "comment" || type === "label") && !issueRef) {
410
+ if (onIssue && !issueRef) {
395
411
  refuse("missing number", at);
396
412
  continue;
397
413
  }
414
+ // State change: reopen or close, inside the declared direction.
415
+ let state;
416
+ if (type === "issue_state") {
417
+ const asked = intent.state?.trim().toLowerCase() || decl.state;
418
+ if (asked !== "reopen" && asked !== "close") {
419
+ refuse("state must be reopen or close", at);
420
+ continue;
421
+ }
422
+ if (decl.state && asked !== decl.state) {
423
+ refuse("state outside the declared state", at);
424
+ continue;
425
+ }
426
+ // Closing needs the declaration to pin the issue (a workflow that skipped validation cannot bypass it).
427
+ if (asked === "close" && decl.number === undefined) {
428
+ refuse("close needs a pinned issue", at);
429
+ continue;
430
+ }
431
+ state = asked;
432
+ }
398
433
  if (via === "github" && issueRef !== undefined && !/^[1-9][0-9]*$/.test(issueRef)) {
399
434
  refuse("number must be an issue or PR number", at);
400
435
  continue;
@@ -432,11 +467,11 @@ export async function applySafeOutputs(o) {
432
467
  refuse("title too long", at);
433
468
  continue;
434
469
  }
435
- // A Linear comment's issue ID does not encode or enforce its declared team.
470
+ // A Linear comment's (or state change's) issue ID does not encode or enforce its declared team.
436
471
  // Resolve it through the configured read tool and carry its immutable ID to
437
472
  // the write. Missing lookup support or unverifiable membership fails closed.
438
473
  let number = issueRef;
439
- if (via === "linear" && type === "comment" && target) {
474
+ if (via === "linear" && (type === "comment" || type === "issue_state") && target) {
440
475
  const lookup = o.skills.get(via)?.tools.find((t) => t.name === "linear_get_issue" && t.access === "read");
441
476
  let issue;
442
477
  try {
@@ -464,7 +499,8 @@ export async function applySafeOutputs(o) {
464
499
  ...(target ? { target } : {}),
465
500
  ...(number !== undefined ? { number } : {}),
466
501
  ...(title ? { title } : {}),
467
- ...(type !== "label" ? { body } : {}),
502
+ ...(type === "comment" || type === "issue" || type === "pr" ? { body } : {}),
503
+ ...(state ? { state } : {}),
468
504
  ...(labels ? { labels } : {}),
469
505
  ...(type === "pr" ? { head: intent.head, ...(intent.base ? { base: intent.base } : {}) } : {}),
470
506
  };
@@ -546,6 +582,8 @@ export async function applySafeOutputs(o) {
546
582
  function printPreview(w, logger) {
547
583
  const where = `${w.target ?? ""}${w.number ? `#${w.number}` : ""}`;
548
584
  logger.info(` [staged] ${w.type} via ${w.via}${where ? ` -> ${where}` : ""}${w.title ? `: ${w.title}` : ""}`);
585
+ if (w.state)
586
+ logger.info(` state: ${w.state}`);
549
587
  if (w.labels?.length)
550
588
  logger.info(` labels: ${w.labels.join(", ")}`);
551
589
  if (w.type === "pr")