@sweny-ai/core 0.1.149 → 0.1.150

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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Bind a built-in workflow (triage, implement) to the providers `sweny triage`
3
+ * and `sweny implement` run with (#365).
4
+ *
5
+ * The bundled YAML is written for the exemplary case: analysis nodes are
6
+ * `read`, and tracker and PR writes are safe outputs that the github or linear
7
+ * skill applies after the node. Here the CLI pins each output to the tracker
8
+ * the user configured (`via`) and to the repository (`target`), so a Linear key
9
+ * in the environment never redirects a GitHub-issues user's issues, and back.
10
+ *
11
+ * Two setups cannot run that way. Each affected node keeps its pre-#365 shape,
12
+ * and the CLI prints one notice naming the nodes and the reason:
13
+ *
14
+ * - Reads that need the shell or an MCP server (a Jira or GitLab integration,
15
+ * an observability provider with no built-in skill, user MCP servers or
16
+ * workspace tools). A `read` node has neither, so the analysis nodes run
17
+ * write-capable with file edits and every skill write tool denied, as before.
18
+ * - Writes no safe-output skill can apply (a Jira or file tracker, GitLab or
19
+ * file source control). Those nodes use the tracker's own tools, as before.
20
+ *
21
+ * Pure: returns a new workflow, never mutates the input.
22
+ */
23
+ import type { SafeOutputType, Workflow } from "../types.js";
24
+ export interface BuiltinProviders {
25
+ /** `github-issues` | `linear` | `jira` | `file` */
26
+ issueTracker: string;
27
+ /** `github` | `gitlab` | `file` */
28
+ sourceControl: string;
29
+ observability: string[];
30
+ /** `owner/repo`; pins GitHub outputs. */
31
+ repository?: string;
32
+ /** User-configured MCP servers (`SWENY_MCP_SERVERS`). */
33
+ userMcpServers?: string[];
34
+ workspaceTools?: string[];
35
+ }
36
+ export interface BoundWorkflow {
37
+ workflow: Workflow;
38
+ /** One line per fallback, for the CLI to print. Empty when every node runs as written. */
39
+ notes: string[];
40
+ }
41
+ /** What a read-only node could not reach for these providers. Empty when it reaches everything. */
42
+ export declare function readGaps(p: BuiltinProviders): string[];
43
+ /** The skill that applies this output type for these providers, or undefined when none can. */
44
+ export declare function outputSkill(type: SafeOutputType, p: BuiltinProviders): string | undefined;
45
+ export declare function bindBuiltinWorkflow(workflow: Workflow, p: BuiltinProviders): BoundWorkflow;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Bind a built-in workflow (triage, implement) to the providers `sweny triage`
3
+ * and `sweny implement` run with (#365).
4
+ *
5
+ * The bundled YAML is written for the exemplary case: analysis nodes are
6
+ * `read`, and tracker and PR writes are safe outputs that the github or linear
7
+ * skill applies after the node. Here the CLI pins each output to the tracker
8
+ * the user configured (`via`) and to the repository (`target`), so a Linear key
9
+ * in the environment never redirects a GitHub-issues user's issues, and back.
10
+ *
11
+ * Two setups cannot run that way. Each affected node keeps its pre-#365 shape,
12
+ * and the CLI prints one notice naming the nodes and the reason:
13
+ *
14
+ * - Reads that need the shell or an MCP server (a Jira or GitLab integration,
15
+ * an observability provider with no built-in skill, user MCP servers or
16
+ * workspace tools). A `read` node has neither, so the analysis nodes run
17
+ * write-capable with file edits and every skill write tool denied, as before.
18
+ * - Writes no safe-output skill can apply (a Jira or file tracker, GitLab or
19
+ * file source control). Those nodes use the tracker's own tools, as before.
20
+ *
21
+ * Pure: returns a new workflow, never mutates the input.
22
+ */
23
+ import { builtinSkills } from "../skills/index.js";
24
+ import { resolveNodePermissions } from "../node-policy.js";
25
+ /** Observability providers a read-only node can reach: a built-in skill, or a local log file (Read). */
26
+ const SKILL_OBSERVABILITY = new Set(["sentry", "datadog", "betterstack", "file"]);
27
+ /** Trackers whose issues and comments a safe-output skill applies. */
28
+ const TRACKER_SKILL = { "github-issues": "github", linear: "linear" };
29
+ /** Skill write tools a legacy node keeps for each output type it declared. */
30
+ const LEGACY_WRITE_TOOLS = {
31
+ issue: ["github_create_issue", "linear_create_issue"],
32
+ // Reopening a closed duplicate goes with the +1 comment.
33
+ comment: ["github_add_comment", "linear_add_comment", "linear_update_issue"],
34
+ pr: ["github_create_pr"],
35
+ label: ["github_add_labels"],
36
+ };
37
+ const writeToolsBySkill = new Map(builtinSkills.map((s) => [s.id, s.tools.filter((t) => t.access !== "read").map((t) => t.name)]));
38
+ function skillWriteTools(node) {
39
+ return node.skills.flatMap((id) => writeToolsBySkill.get(id) ?? []);
40
+ }
41
+ function union(...lists) {
42
+ return [...new Set(lists.flatMap((l) => l ?? []))];
43
+ }
44
+ /** What a read-only node could not reach for these providers. Empty when it reaches everything. */
45
+ export function readGaps(p) {
46
+ const gaps = [];
47
+ for (const o of p.observability)
48
+ if (!SKILL_OBSERVABILITY.has(o))
49
+ gaps.push(o);
50
+ if (!(p.issueTracker in TRACKER_SKILL) && p.issueTracker !== "file")
51
+ gaps.push(p.issueTracker);
52
+ if (p.sourceControl !== "github")
53
+ gaps.push(`${p.sourceControl} source control`);
54
+ if ((p.userMcpServers ?? []).length > 0)
55
+ gaps.push("user MCP servers");
56
+ if ((p.workspaceTools ?? []).length > 0)
57
+ gaps.push("workspace tools");
58
+ return [...new Set(gaps)];
59
+ }
60
+ /** The skill that applies this output type for these providers, or undefined when none can. */
61
+ export function outputSkill(type, p) {
62
+ if (type === "issue" || type === "comment")
63
+ return TRACKER_SKILL[p.issueTracker];
64
+ return p.sourceControl === "github" ? "github" : undefined;
65
+ }
66
+ /** A read node that needs the shell or MCP: write-capable, but no file edits and no skill write tool. */
67
+ function legacyRead(node) {
68
+ return {
69
+ ...node,
70
+ permissions: { access: "write", deny: ["write", "edit"] },
71
+ tools: { ...(node.tools ?? {}), deny: union(node.tools?.deny, skillWriteTools(node)) },
72
+ };
73
+ }
74
+ /** An output node whose writes no safe-output skill can apply: the tracker's own tools, as before. */
75
+ function legacyWrite(node) {
76
+ const { outputs, ...rest } = node;
77
+ const keep = new Set((outputs ?? []).flatMap((o) => LEGACY_WRITE_TOOLS[o.type]));
78
+ const deny = union(node.tools?.deny, skillWriteTools(node)).filter((t) => !keep.has(t));
79
+ const tools = node.tools?.allow
80
+ ? { allow: node.tools.allow, ...(deny.length > 0 ? { deny } : {}) }
81
+ : deny.length > 0
82
+ ? { deny }
83
+ : undefined;
84
+ const { tools: _old, ...base } = rest;
85
+ return { ...base, permissions: "write", ...(tools ? { tools } : {}) };
86
+ }
87
+ function bindOutput(o, p) {
88
+ const via = o.via ?? outputSkill(o.type, p);
89
+ const target = o.target ?? (via === "github" && p.repository ? p.repository : undefined);
90
+ return { ...o, ...(via ? { via } : {}), ...(target ? { target } : {}) };
91
+ }
92
+ export function bindBuiltinWorkflow(workflow, p) {
93
+ const gaps = readGaps(p);
94
+ const legacyReads = [];
95
+ const legacyWrites = [];
96
+ const unsupported = new Set();
97
+ const nodes = {};
98
+ for (const [id, node] of Object.entries(workflow.nodes)) {
99
+ const outputs = node.outputs ?? [];
100
+ const access = resolveNodePermissions(node, workflow).access;
101
+ const missing = outputs.filter((o) => !outputSkill(o.type, p));
102
+ if (missing.length > 0) {
103
+ for (const o of missing)
104
+ unsupported.add(o.type === "pr" || o.type === "label" ? p.sourceControl : p.issueTracker);
105
+ legacyWrites.push(id);
106
+ nodes[id] = legacyWrite(node);
107
+ }
108
+ else if (outputs.length > 0) {
109
+ nodes[id] = { ...node, outputs: outputs.map((o) => bindOutput(o, p)) };
110
+ }
111
+ else if (access === "read" && gaps.length > 0) {
112
+ legacyReads.push(id);
113
+ nodes[id] = legacyRead(node);
114
+ }
115
+ else {
116
+ nodes[id] = node;
117
+ }
118
+ }
119
+ const notes = [];
120
+ if (legacyReads.length > 0) {
121
+ notes.push(`${legacyReads.join(", ")} run write-capable (file edits and write tools still denied): ` +
122
+ `a read-only step cannot reach ${gaps.join(", ")}`);
123
+ }
124
+ if (legacyWrites.length > 0) {
125
+ notes.push(`${legacyWrites.join(", ")} write through the tracker's own tools: ` +
126
+ `no safe-output skill writes to ${[...unsupported].join(", ")}`);
127
+ }
128
+ return { workflow: { ...workflow, nodes }, notes };
129
+ }
@@ -29,15 +29,9 @@ export declare function checkDatadog(creds: Record<string, string>): Promise<Che
29
29
  /**
30
30
  * Detect a local Claude Code login the way `sweny workflow run` benefits from
31
31
  * it: the spawned Claude Code agent finds its own login, so no env credential
32
- * is needed. Checks `~/.claude/.credentials.json` (or CLAUDE_CONFIG_DIR) and,
33
- * on macOS, the Keychain entry (attributes only, the secret is never read).
32
+ * is needed. Lives with the harness login probes (#339); re-exported here.
34
33
  */
35
- export declare function detectClaudeCodeLogin(opts?: {
36
- env?: Record<string, string | undefined>;
37
- home?: string;
38
- platform?: NodeJS.Platform;
39
- keychainHasLogin?: () => boolean;
40
- }): boolean;
34
+ export { detectClaudeCodeLogin } from "../harness/auth.js";
41
35
  /**
42
36
  * Skill ids used by the workflows in `.sweny/workflows/`. `found` is false when
43
37
  * there are no workflow files, in which case callers keep the legacy checks.
package/dist/cli/check.js CHANGED
@@ -1,7 +1,5 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
4
- import { spawnSync } from "node:child_process";
5
3
  import { parse as parseYaml } from "yaml";
6
4
  import { validateInputs } from "./config.js";
7
5
  import { findSkillEnvGaps } from "./skill-env.js";
@@ -349,39 +347,9 @@ function networkErrorMessage(err) {
349
347
  /**
350
348
  * Detect a local Claude Code login the way `sweny workflow run` benefits from
351
349
  * it: the spawned Claude Code agent finds its own login, so no env credential
352
- * is needed. Checks `~/.claude/.credentials.json` (or CLAUDE_CONFIG_DIR) and,
353
- * on macOS, the Keychain entry (attributes only, the secret is never read).
350
+ * is needed. Lives with the harness login probes (#339); re-exported here.
354
351
  */
355
- export function detectClaudeCodeLogin(opts = {}) {
356
- const env = opts.env ?? process.env;
357
- const home = opts.home ?? os.homedir();
358
- const platform = opts.platform ?? process.platform;
359
- const configDir = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
360
- try {
361
- if (fs.existsSync(path.join(configDir, ".credentials.json")))
362
- return true;
363
- }
364
- catch {
365
- // fall through
366
- }
367
- if (platform === "darwin") {
368
- const probe = opts.keychainHasLogin ??
369
- (() => {
370
- const r = spawnSync("security", ["find-generic-password", "-s", "Claude Code-credentials"], {
371
- stdio: "ignore",
372
- timeout: 3000,
373
- });
374
- return r.status === 0;
375
- });
376
- try {
377
- return probe();
378
- }
379
- catch {
380
- return false;
381
- }
382
- }
383
- return false;
384
- }
352
+ export { detectClaudeCodeLogin } from "../harness/auth.js";
385
353
  /**
386
354
  * Skill ids used by the workflows in `.sweny/workflows/`. `found` is false when
387
355
  * there are no workflow files, in which case callers keep the legacy checks.
@@ -23,6 +23,7 @@ export function registerTriageCommand(program) {
23
23
  .option("--base-branch <branch>", "Base branch for PRs (default: main)")
24
24
  .option("--pr-labels <labels>", "Comma-separated PR labels (default: agent,triage,needs-review)")
25
25
  .option("--dry-run", "Analyze only, do not create issues or PRs", false)
26
+ .option("--stage", "Run normally, but preview the issues, comments and PR instead of filing them; stops before any code is pushed", false)
26
27
  .option("--review-mode <mode>", "PR merge behavior: auto (merge when CI passes) | review (human approval, default)", "review")
27
28
  .option("--no-novelty-mode", "Disable novelty mode (allow +1 on existing issues)")
28
29
  .option("--issue-override <issue>", "Work on a specific existing issue")
@@ -670,6 +671,7 @@ export function registerImplementCommand(program) {
670
671
  .option("--issue-tracker-provider <provider>", "Issue tracker (linear|jira|github-issues|file)")
671
672
  .option("--source-control-provider <provider>", "Source control (github|gitlab|file)")
672
673
  .option("--dry-run", "Skip creating PR — report only", false)
674
+ .option("--stage", "Run normally, but preview the PR and issue comment instead of writing them", false)
673
675
  .option("--max-implement-turns <n>", "Max coding agent turns (default: 40)")
674
676
  .option("--base-branch <branch>", "Base branch for PRs (default: main)")
675
677
  .option("--repository <owner/repo>", "Repository (auto-detected from git remote)")
package/dist/cli/main.js CHANGED
@@ -10,6 +10,7 @@ import { execute } from "../executor.js";
10
10
  import { triageWorkflow, implementWorkflow, seedContentWorkflow } from "../workflows/index.js";
11
11
  import { consoleLogger } from "../types.js";
12
12
  import { createHarness } from "../harness/index.js";
13
+ import { bindBuiltinWorkflow } from "./builtin-workflows.js";
13
14
  import { isSupportedAgent, unsupportedAgentError } from "../harness/agents.js";
14
15
  import { resolveHarnessPolicy } from "../harness/policy.js";
15
16
  import { builtinSkills, createSkillMap, validateWorkflowSkills } from "../skills/index.js";
@@ -94,6 +95,8 @@ program
94
95
  await runNew({ marketplaceId: id, template: options.template, yes: options.yes });
95
96
  });
96
97
  // ── sweny check ───────────────────────────────────────────────────────
98
+ /** validateInputs' generic agent-auth lines, replaced by the harness preflight reason (#339). */
99
+ const AGENT_AUTH_ERROR_PREFIXES = ["Missing: ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN", "Missing: CODEX_API_KEY"];
97
100
  program
98
101
  .command("check")
99
102
  .description("Verify provider credentials and connectivity")
@@ -103,13 +106,22 @@ program
103
106
  const workflows = discoverWorkflowSkillIds(process.cwd());
104
107
  const scope = workflows.found ? workflows.skillIds : undefined;
105
108
  const claudeCodeLogin = detectClaudeCodeLogin();
106
- const errors = validateCheckInputs(config, { scope, claudeCodeLogin });
109
+ let errors = validateCheckInputs(config, { scope, claudeCodeLogin });
110
+ // #339: the agent's own preflight (install and login), named by --agent.
111
+ // Its reason replaces the generic agent-auth line from validateInputs.
112
+ const agent = config.codingAgentProvider;
113
+ const pre = isSupportedAgent(agent) ? await createHarness(agent, { logger: consoleLogger }).preflight() : undefined;
114
+ if (pre && !pre.ok) {
115
+ errors = [pre.reason, ...errors.filter((e) => !AGENT_AUTH_ERROR_PREFIXES.some((p) => e.startsWith(p)))];
116
+ }
107
117
  if (errors.length > 0) {
108
118
  console.error(formatValidationErrors(errors));
109
119
  process.exit(1);
110
120
  }
111
121
  console.log(chalk.dim("\n Checking provider connectivity…\n"));
112
122
  const results = await checkProviderConnectivity(config, { scope, claudeCodeLogin });
123
+ if (pre?.ok)
124
+ results.unshift({ name: `Agent (${agent})`, status: "ok", detail: `ready (${pre.version})` });
113
125
  console.log(formatCheckResults(results));
114
126
  const hasFailure = results.some((r) => r.status === "fail");
115
127
  process.exit(hasFailure ? 1 : 0);
@@ -178,8 +190,9 @@ async function resolveRulesAndContext(config) {
178
190
  };
179
191
  }
180
192
  /**
181
- * Build the harness for `--agent` (#331). A non-Claude agent is checked before
182
- * any node runs, so a missing or too-old CLI fails fast with the fix.
193
+ * Build the harness for `--agent` (#331) and preflight it before any node
194
+ * runs (#339): the adapter checks its own install and login, so a missing
195
+ * CLI or a missing login fails fast with the fix, naming the agent.
183
196
  */
184
197
  async function harnessFor(agent, opts, harnessPolicy) {
185
198
  if (!isSupportedAgent(agent)) {
@@ -190,15 +203,31 @@ async function harnessFor(agent, opts, harnessPolicy) {
190
203
  ...opts,
191
204
  policy: resolveHarnessPolicy(process.env, harnessPolicy, opts.logger),
192
205
  });
193
- if (agent !== "claude") {
194
- const pre = await harness.preflight();
195
- if (!pre.ok) {
196
- console.error(chalk.red(`\n ${pre.reason}\n`));
197
- process.exit(1);
198
- }
206
+ const pre = await harness.preflight();
207
+ if (!pre.ok) {
208
+ console.error(chalk.red(`\n ${pre.reason}\n`));
209
+ process.exit(1);
199
210
  }
200
211
  return harness;
201
212
  }
213
+ /**
214
+ * Bind a built-in workflow to this run's providers (#365) and say which nodes
215
+ * fall back to their pre-safe-outputs shape, and why.
216
+ */
217
+ function bindForCli(workflow, config) {
218
+ const bound = bindBuiltinWorkflow(workflow, {
219
+ issueTracker: config.issueTrackerProvider,
220
+ sourceControl: config.sourceControlProvider,
221
+ observability: config.observabilityProviders,
222
+ repository: config.repository || undefined,
223
+ userMcpServers: Object.keys(config.mcpServers),
224
+ workspaceTools: config.workspaceTools,
225
+ });
226
+ if (!config.json)
227
+ for (const note of bound.notes)
228
+ console.error(c.subtle(` ${workflow.id}: ${note}`));
229
+ return bound.workflow;
230
+ }
202
231
  // ── sweny triage ──────────────────────────────────────────────────────
203
232
  const triageCmd = registerTriageCommand(program);
204
233
  triageCmd.action(async (options) => {
@@ -401,8 +430,11 @@ triageCmd.action(async (options) => {
401
430
  console.log(c.subtle(` cloud: ${cloudHandle.dashboardUrl}`));
402
431
  }
403
432
  const observer = composeObservers(progressObserver, config.verbose ? createVerboseToolObserver() : undefined, config.stream ? createStreamObserver() : undefined, createCloudStreamObserver(config, cloudHandle));
433
+ // #365: pin safe outputs to the configured tracker and repo; nodes these
434
+ // providers cannot run read-only or through safe outputs keep their old shape.
435
+ const triageRun = bindForCli(triageWorkflow, config);
404
436
  try {
405
- const { results, trace } = await execute(triageWorkflow, workflowInput, {
437
+ const { results, trace } = await execute(triageRun, workflowInput, {
406
438
  skills,
407
439
  harness: claude,
408
440
  observer,
@@ -412,6 +444,7 @@ triageCmd.action(async (options) => {
412
444
  fetchAuth: config.fetchAuth,
413
445
  offline: config.offline,
414
446
  fileRoot: config.fileRoot || undefined,
447
+ stageOutputs: options.stage === true,
415
448
  });
416
449
  const durationMs = Date.now() - runStart;
417
450
  // Output
@@ -605,8 +638,9 @@ implementCmd.action(async (issueId, options) => {
605
638
  console.log(c.subtle(` cloud: ${implCloudHandle.dashboardUrl}`));
606
639
  }
607
640
  const observer = composeObservers(implProgressObserver, config.verbose ? createVerboseToolObserver() : undefined, Boolean(options.stream) ? createStreamObserver() : undefined, createCloudStreamObserver(config, implCloudHandle));
641
+ const implementRun = bindForCli(implementWorkflow, config);
608
642
  try {
609
- const { results } = await execute(implementWorkflow, workflowInput, {
643
+ const { results } = await execute(implementRun, workflowInput, {
610
644
  skills,
611
645
  harness: claude,
612
646
  observer,
@@ -616,6 +650,7 @@ implementCmd.action(async (issueId, options) => {
616
650
  fetchAuth: config.fetchAuth,
617
651
  offline: config.offline,
618
652
  fileRoot: config.fileRoot || undefined,
653
+ stageOutputs: options.stage === true,
619
654
  });
620
655
  const hasFailed = [...results.values()].some((r) => r.status === "failed");
621
656
  const implDurationMs = Date.now() - implRunStart;
@@ -10,11 +10,15 @@
10
10
  * - validates with no credentials
11
11
  * - every node declares an `output` schema and at least one `eval` gate
12
12
  * - value and function gates first; a judge only where a rule cannot decide
13
- * - every node is read-only except the delivery node, and each node's write
14
- * tools are named in `pack.writes`; the delivery node also restricts its
15
- * tool list with `tools.allow`
16
- * - harness-agnostic: instructions name SWEny skill tools and plain
17
- * read-only shell commands, never a specific agent's built-in tools
13
+ * - safe outputs (#365): every GitHub write (issue, comment) is a declared
14
+ * output with a cap, and a pin or title prefix where one applies; sweny
15
+ * applies it after the node. No node holds a GitHub write tool.
16
+ * - least privilege: a workflow-level `permissions` ceiling, and every node
17
+ * is `read` except a delivery node that needs a write no safe output
18
+ * covers (Slack); its write tools are named in `pack.writes` and its tool
19
+ * list is restricted with `tools.allow`
20
+ * - harness-agnostic: instructions name SWEny skill tools, never a specific
21
+ * agent's built-in tools
18
22
  */
19
23
  import type { WorkflowTemplate } from "./templates.js";
20
24
  export declare const PACK_TEMPLATES: WorkflowTemplate[];