@vekexasia/fucina 0.0.0

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/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # Fucina
2
+
3
+ Issue dentro, PR fuori.
4
+
5
+ Fucina is a thin layer over Sandcastle for label-driven AI workflows on GitHub.com repositories. Fucina owns labels, GitHub workflow conventions, prompts, configuration loading, and CLI setup; Sandcastle owns agent execution, sandboxing, branch strategy, sessions, and provider integration.
6
+
7
+ ## MVP labels
8
+
9
+ Operational trigger labels are one-shot buttons: Fucina removes only the label that triggered the run when that run starts.
10
+
11
+ - `fucina:explore` - analyze an open issue and comment with findings
12
+ - `fucina:implement` - implement an open issue and create a draft PR
13
+ - `fucina:review` - publish a read-only GitHub PR Review
14
+ - `fucina:address-feedback` - address feedback on an internal PR branch
15
+ - `fucina:in-progress` - visible running state
16
+ - `fucina:blocked` - last run failed or was refused; removed on retry
17
+
18
+ There is no MVP readiness label, `fucina:queued`, or `fucina:update-branch`. Runs share repository-wide GitHub Actions concurrency with `cancel-in-progress: false`, so GitHub Actions is the queue.
19
+
20
+ ## Install in a repo
21
+
22
+ ```bash
23
+ npx @vekexasia/fucina@<version> install
24
+ ```
25
+
26
+ `fucina install` writes workflows and `.fucina/config.json`, creates or updates labels, never commits, and does not overwrite existing files without confirmation or `--force`. Use `install-labels` only for label maintenance.
27
+
28
+ Target repositories pin Fucina in the generated workflows with `npx @vekexasia/fucina@<version>`; Fucina is not a repository dependency.
29
+
30
+ ## Configuration
31
+
32
+ Fucina reads environment variables first, then optional `.fucina/config.json`. Required values must come from one of those sources.
33
+
34
+ Minimum config keys:
35
+
36
+ - `agent` - one of Sandcastle providers `claudeCode`, `codex`, or `pi`
37
+ - `model`
38
+ - `agentCliVersion` - pinned CLI version installed by Fucina
39
+ - `maxIterations` - optional, defaults to `1`
40
+
41
+ Optional repository variable:
42
+
43
+ - `FUCINA_ALLOWED_ACTORS` - comma-separated GitHub usernames allowed to run Fucina. When omitted, Fucina allows repository collaborators with write, maintain, or admin permission.
44
+
45
+ Optional secret:
46
+
47
+ - `AGENT_PAT` - Fucina uses `AGENT_PAT || GITHUB_TOKEN` and fails clearly when a PAT is required.
48
+
49
+ ## Sensitive instruction path protection
50
+
51
+ Fucina protects files that can influence agent instructions from prompt injection by untrusted PR authors.
52
+
53
+ The `sensitiveInstructionPaths` config key defaults to:
54
+ - `.fucina/**`
55
+ - `.github/workflows/**`
56
+ - `AGENTS.md`
57
+ - `CLAUDE.md`
58
+ - `.claude/**`
59
+ - `.codex/**`
60
+ - `.pi/**`
61
+
62
+ This list can be completely overridden in `.fucina/config.json`, but not via environment variables.
63
+
64
+ When `fucina:review` runs on a PR:
65
+ 1. If the PR modifies files matching sensitive paths AND the PR author is not an Authorized Actor, the review blocks with `fucina:blocked`
66
+ 2. An error comment is posted with the exact command to approve: `/fucina trust-instructions <full-sha>`
67
+ 3. An Authorized Actor can post that slash command as a PR comment to explicitly trust the changes
68
+ 4. The approval is SHA-specific; if the PR is updated, a new approval is required
69
+
70
+ This prevents untrusted contributors from injecting malicious instructions into agent workflows while allowing Authorized Actors to explicitly review and approve instruction changes.
71
+
72
+ ## Local development
73
+
74
+ ```bash
75
+ npm install
76
+ npm run typecheck
77
+ npm run build
78
+ ```
package/dist/agent.js ADDED
@@ -0,0 +1,54 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { claudeCode, codex, pi, run as sandcastleRun } from "@ai-hero/sandcastle";
3
+ import { noSandbox } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
4
+ import { loadConfig } from "./config.js";
5
+ export async function runAgent(prompt, cwd = process.cwd()) {
6
+ if (process.env.FUCINA_SIMULATE_AGENT_OUTPUT)
7
+ return { stdout: process.env.FUCINA_SIMULATE_AGENT_OUTPUT, commits: [], branch: "simulated" };
8
+ const config = loadConfig(cwd);
9
+ installAgentCli(config.agent, config.agentCliVersion);
10
+ const agent = config.agent === "claudeCode" ? claudeCode(config.model) : config.agent === "codex" ? codex(config.model) : pi(config.model);
11
+ return sandcastleRun({ agent, sandbox: noSandbox(), cwd, prompt, maxIterations: config.maxIterations, logging: { type: "stdout" } });
12
+ }
13
+ export function agentCliPackage(agent) {
14
+ return agent === "claudeCode" ? "@anthropic-ai/claude-code" : undefined;
15
+ }
16
+ export function installAgentCli(agent, version) {
17
+ const pkg = agentCliPackage(agent);
18
+ if (pkg)
19
+ execFileSync("npm", ["install", "-g", `${pkg}@${version}`], { stdio: "inherit" });
20
+ }
21
+ export function parseFucinaJson(stdout) {
22
+ const match = stdout.match(/<fucina>([\s\S]*?)<\/fucina>/);
23
+ if (!match)
24
+ throw new Error("Agent output did not include <fucina> JSON");
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(match[1]);
28
+ }
29
+ catch (error) {
30
+ const summary = match[1].match(/"summary"\s*:\s*"([\s\S]*)"\s*}/)?.[1];
31
+ if (summary !== undefined)
32
+ parsed = { summary };
33
+ else
34
+ throw new Error(`Agent output <fucina> JSON was malformed: ${error instanceof Error ? error.message : String(error)}`);
35
+ }
36
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
37
+ throw new Error("Agent output must be a JSON object");
38
+ const output = parsed;
39
+ if (typeof output.summary !== "string")
40
+ throw new Error("Agent output summary must be a string");
41
+ if (output.prUrl !== undefined && typeof output.prUrl !== "string")
42
+ throw new Error("Agent output prUrl must be a string");
43
+ return output;
44
+ }
45
+ export async function runFucinaAgent(agent, prompt) {
46
+ const result = await agent(prompt);
47
+ try {
48
+ return { result, parsed: parseFucinaJson(result.stdout) };
49
+ }
50
+ catch (error) {
51
+ const retry = await agent(`${prompt}\n\nYour previous output was invalid: ${error instanceof Error ? error.message : String(error)}. Return only <fucina>{"summary":"..."}</fucina>.`);
52
+ return { result: retry, parsed: parseFucinaJson(retry.stdout) };
53
+ }
54
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,33 @@
1
+ import { gh } from "./gh.js";
2
+ const writePermissions = new Set(["admin", "maintain", "write"]);
3
+ export function assertAuthorized(event) {
4
+ return assertAuthorizedWithGh(event, gh);
5
+ }
6
+ export function assertAuthorizedWithGh(event, runGh) {
7
+ const allowed = process.env.FUCINA_ALLOWED_ACTORS?.split(",").map((name) => name.trim()).filter(Boolean);
8
+ if (allowed?.length) {
9
+ if (!allowed.includes(event.actor))
10
+ throw new Error(`${event.actor} is not allowed to run Fucina`);
11
+ return;
12
+ }
13
+ const repo = process.env.GITHUB_REPOSITORY ?? "OWNER/REPO";
14
+ const output = runGh(["api", `repos/${repo}/collaborators/${event.actor}/permission`, "--jq", ".permission"]).trim();
15
+ const value = output.startsWith("{") ? JSON.parse(output).permission : output;
16
+ if (!writePermissions.has(value))
17
+ throw new Error(`${event.actor} does not have write access to ${repo}`);
18
+ }
19
+ export function isAuthorizedActor(actor, runGh) {
20
+ try {
21
+ const allowed = process.env.FUCINA_ALLOWED_ACTORS?.split(",").map((name) => name.trim()).filter(Boolean);
22
+ if (allowed?.length) {
23
+ return allowed.includes(actor);
24
+ }
25
+ const repo = process.env.GITHUB_REPOSITORY ?? "OWNER/REPO";
26
+ const output = runGh(["api", `repos/${repo}/collaborators/${actor}/permission`, "--jq", ".permission"]).trim();
27
+ const value = output.startsWith("{") ? JSON.parse(output).permission : output;
28
+ return writePermissions.has(value);
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { installLabels } from "./install-labels.js";
3
+ import { install, upgrade } from "./install.js";
4
+ import { run } from "./run.js";
5
+ const command = process.argv[2];
6
+ const force = process.argv.includes("--force");
7
+ try {
8
+ if (command === "install")
9
+ install({ force });
10
+ else if (command === "upgrade")
11
+ upgrade({ force: true });
12
+ else if (command === "install-labels")
13
+ installLabels();
14
+ else if (command === "run")
15
+ await run();
16
+ else {
17
+ console.error("Usage: fucina <install|upgrade|install-labels|run> [--force]");
18
+ process.exit(1);
19
+ }
20
+ }
21
+ catch (error) {
22
+ console.error(error instanceof Error ? error.message : error);
23
+ process.exit(1);
24
+ }
@@ -0,0 +1,3 @@
1
+ export function withRunLink(body, runUrl) {
2
+ return runUrl ? `${body}\n\nWorkflow run: ${runUrl}` : body;
3
+ }
package/dist/config.js ADDED
@@ -0,0 +1,22 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const defaultSensitiveInstructionPaths = [".fucina/**", ".github/workflows/**", "AGENTS.md", "CLAUDE.md", ".claude/**", ".codex/**", ".pi/**"];
4
+ export function loadConfig(cwd = process.cwd()) {
5
+ const path = join(cwd, ".fucina/config.json");
6
+ const file = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
7
+ const env = (name) => process.env[name]?.trim() || undefined;
8
+ const config = {
9
+ agent: env("FUCINA_AGENT") ?? file.agent,
10
+ model: env("FUCINA_MODEL") ?? file.model,
11
+ agentCliVersion: env("FUCINA_AGENT_CLI_VERSION") ?? file.agentCliVersion,
12
+ maxIterations: Number(env("FUCINA_MAX_ITERATIONS") ?? file.maxIterations ?? 1),
13
+ sensitiveInstructionPaths: Array.isArray(file.sensitiveInstructionPaths) ? file.sensitiveInstructionPaths : defaultSensitiveInstructionPaths,
14
+ };
15
+ if (!["claudeCode", "codex", "pi"].includes(config.agent))
16
+ throw new Error("FUCINA_AGENT or .fucina/config.json agent must be claudeCode, codex, or pi");
17
+ if (!config.model)
18
+ throw new Error("FUCINA_MODEL or .fucina/config.json model is required");
19
+ if (!config.agentCliVersion)
20
+ throw new Error("FUCINA_AGENT_CLI_VERSION or .fucina/config.json agentCliVersion is required");
21
+ return config;
22
+ }
package/dist/event.js ADDED
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from "node:fs";
2
+ export function readEvent() {
3
+ const path = process.env.GITHUB_EVENT_PATH;
4
+ if (!path)
5
+ throw new Error("GITHUB_EVENT_PATH is missing");
6
+ const event = JSON.parse(readFileSync(path, "utf8"));
7
+ const actor = event.sender?.login;
8
+ if (typeof actor !== "string")
9
+ throw new Error("Event has no sender login");
10
+ if (event.comment) {
11
+ const body = event.comment.body;
12
+ if (typeof body === "string" && body.trim().startsWith("/fucina ")) {
13
+ const label = body.trim().split("\n")[0];
14
+ if (event.issue) {
15
+ const isPR = !!event.issue.pull_request;
16
+ return { label, actor, kind: isPR ? "pull_request" : "issue", number: event.issue.number, title: event.issue.title, body: event.issue.body };
17
+ }
18
+ }
19
+ }
20
+ const label = event.label?.name;
21
+ if (typeof label !== "string")
22
+ throw new Error("Event has no label name");
23
+ if (event.issue) {
24
+ return { label, actor, kind: "issue", number: event.issue.number, title: event.issue.title, body: event.issue.body };
25
+ }
26
+ if (event.pull_request) {
27
+ return { label, actor, kind: "pull_request", number: event.pull_request.number, title: event.pull_request.title, body: event.pull_request.body };
28
+ }
29
+ throw new Error("Unsupported GitHub event");
30
+ }
package/dist/gh.js ADDED
@@ -0,0 +1,13 @@
1
+ import { execFileSync } from "node:child_process";
2
+ export function gh(args) {
3
+ return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4
+ }
5
+ export function ghOk(args) {
6
+ try {
7
+ gh(args);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
@@ -0,0 +1,11 @@
1
+ import { labels } from "./labels.js";
2
+ import { ghOk } from "./gh.js";
3
+ export function installLabels() {
4
+ for (const [name, description, color] of labels) {
5
+ const updated = ghOk(["label", "edit", name, "--description", description, "--color", color]);
6
+ if (!updated) {
7
+ ghOk(["label", "create", name, "--description", description, "--color", color]);
8
+ }
9
+ console.log(name);
10
+ }
11
+ }
@@ -0,0 +1,108 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { defaultSensitiveInstructionPaths } from "./config.js";
5
+ import { installLabels } from "./install-labels.js";
6
+ const require = createRequire(import.meta.url);
7
+ const version = require("../package.json").version;
8
+ export function install(options = {}) {
9
+ const cwd = options.cwd ?? process.cwd();
10
+ writeNew(join(cwd, ".fucina/config.json"), JSON.stringify({ agent: "", model: "", agentCliVersion: "", maxIterations: 1, sensitiveInstructionPaths: defaultSensitiveInstructionPaths }, null, 2) + "\n", options.force);
11
+ writeNew(join(cwd, ".github/workflows/fucina-issue.yml"), workflow("issues", "issues: write\n pull-requests: write\n contents: write", ["fucina:explore", "fucina:implement"]), options.force);
12
+ writeNew(join(cwd, ".github/workflows/fucina-review.yml"), workflow("pull_request_target", "contents: read\n pull-requests: write\n issues: write", ["fucina:review"], false), options.force);
13
+ writeNew(join(cwd, ".github/workflows/fucina-mutate.yml"), workflow("pull_request_target", "contents: write\n pull-requests: write\n issues: write", ["fucina:address-feedback"], false), options.force);
14
+ writeNew(join(cwd, ".github/workflows/fucina-slash.yml"), slashWorkflow(), options.force);
15
+ if (!options.skipLabels)
16
+ installLabels();
17
+ }
18
+ export function upgrade(options = {}) {
19
+ const cwd = options.cwd ?? process.cwd();
20
+ const configPath = join(cwd, ".fucina/config.json");
21
+ const current = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {};
22
+ const next = { ...{ agent: "", model: "", agentCliVersion: "", maxIterations: 1, sensitiveInstructionPaths: defaultSensitiveInstructionPaths }, ...current };
23
+ writeNew(configPath, JSON.stringify(next, null, 2) + "\n", true);
24
+ writeNew(join(cwd, ".github/workflows/fucina-issue.yml"), workflow("issues", "issues: write\n pull-requests: write\n contents: write", ["fucina:explore", "fucina:implement"]), true);
25
+ writeNew(join(cwd, ".github/workflows/fucina-review.yml"), workflow("pull_request_target", "contents: read\n pull-requests: write\n issues: write", ["fucina:review"], false), true);
26
+ writeNew(join(cwd, ".github/workflows/fucina-mutate.yml"), workflow("pull_request_target", "contents: write\n pull-requests: write\n issues: write", ["fucina:address-feedback"], false), true);
27
+ writeNew(join(cwd, ".github/workflows/fucina-slash.yml"), slashWorkflow(), true);
28
+ if (!options.skipLabels)
29
+ installLabels();
30
+ }
31
+ function writeNew(path, content, force = false) {
32
+ if (existsSync(path) && !force)
33
+ throw new Error(`Refusing to overwrite ${path}; rerun with --force`);
34
+ mkdirSync(dirname(path), { recursive: true });
35
+ writeFileSync(path, content);
36
+ }
37
+ function workflow(event, permissions, labels, checkout = true) {
38
+ return `name: Fucina ${event === "issues" ? "Issue" : labels[0] === "fucina:review" ? "Review" : "Mutation"}
39
+
40
+ on:
41
+ ${event}:
42
+ types: [labeled]
43
+
44
+ concurrency:
45
+ group: fucina-\${{ github.repository }}
46
+ cancel-in-progress: false
47
+
48
+ permissions:
49
+ ${permissions}
50
+
51
+ jobs:
52
+ fucina:
53
+ if: \${{ ${labels.map((label) => `github.event.label.name == '${label}'`).join(" || ")} }}
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ ${checkout ? ` - uses: actions/checkout@v4
57
+ with:
58
+ fetch-depth: 0
59
+ ` : ""} - uses: actions/setup-node@v4
60
+ with:
61
+ node-version: 22
62
+ - name: Run Fucina
63
+ env:
64
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
65
+ GH_TOKEN: \${{ secrets.AGENT_PAT || secrets.GITHUB_TOKEN }}
66
+ AGENT_PAT: \${{ secrets.AGENT_PAT }}
67
+ CLAUDE_CODE_OAUTH_TOKEN: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
68
+ FUCINA_ALLOWED_ACTORS: \${{ vars.FUCINA_ALLOWED_ACTORS }}
69
+ FUCINA_AGENT: \${{ vars.FUCINA_AGENT }}
70
+ FUCINA_MODEL: \${{ vars.FUCINA_MODEL }}
71
+ FUCINA_AGENT_CLI_VERSION: \${{ vars.FUCINA_AGENT_CLI_VERSION }}
72
+ FUCINA_MAX_ITERATIONS: \${{ vars.FUCINA_MAX_ITERATIONS }}
73
+ run: npx @vekexasia/fucina@${version} run
74
+ `;
75
+ }
76
+ function slashWorkflow() {
77
+ return `name: Fucina Slash Command
78
+
79
+ on:
80
+ issue_comment:
81
+ types: [created]
82
+
83
+ concurrency:
84
+ group: fucina-\${{ github.repository }}
85
+ cancel-in-progress: false
86
+
87
+ permissions:
88
+ contents: read
89
+ pull-requests: write
90
+ issues: write
91
+
92
+ jobs:
93
+ fucina:
94
+ if: \${{ startsWith(github.event.comment.body, '/fucina ') }}
95
+ runs-on: ubuntu-latest
96
+ steps:
97
+ - uses: actions/setup-node@v4
98
+ with:
99
+ node-version: 22
100
+ - name: Run Fucina Slash Command
101
+ env:
102
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
103
+ GH_TOKEN: \${{ secrets.AGENT_PAT || secrets.GITHUB_TOKEN }}
104
+ AGENT_PAT: \${{ secrets.AGENT_PAT }}
105
+ FUCINA_ALLOWED_ACTORS: \${{ vars.FUCINA_ALLOWED_ACTORS }}
106
+ run: npx @vekexasia/fucina@${version} run
107
+ `;
108
+ }
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const modePrompts = {
4
+ explore: "Explore this issue read-only. Report difficulty, relevant files, verified claims, open questions, and a possible approach. Do not edit files.",
5
+ implement: "Implement the issue with the smallest correct change. Run the project checks. Commit with a conventional commit message. Do not push.",
6
+ review: "Review the PR read-only. Publish comments and a summary. Do not edit files, commit, or push. If changes are needed, explain them so the owner can add `fucina:address-feedback`.",
7
+ };
8
+ const maxInstructionBytes = 64 * 1024;
9
+ export function composeAgentPrompt(mode, taskPrompt, cwd = process.cwd()) {
10
+ return [modePrompts[mode], loadInstructions(mode, cwd), taskPrompt].filter(Boolean).join("\n\n");
11
+ }
12
+ function loadInstructions(mode, cwd) {
13
+ const dir = join(cwd, ".fucina/instructions");
14
+ return ["safety", "global", mode].map((name) => readInstruction(join(dir, `${name}.md`))).filter(Boolean).join("\n\n");
15
+ }
16
+ function readInstruction(path) {
17
+ if (!existsSync(path))
18
+ return "";
19
+ try {
20
+ const stat = statSync(path);
21
+ if (!stat.isFile())
22
+ throw new Error("is not a file");
23
+ if (stat.size > maxInstructionBytes)
24
+ throw new Error(`is too large (${stat.size} bytes, max ${maxInstructionBytes})`);
25
+ const text = readFileSync(path, "utf8");
26
+ if (text.includes("\0"))
27
+ throw new Error("must be a markdown text file");
28
+ return text.trim();
29
+ }
30
+ catch (error) {
31
+ throw new Error(`Cannot load Fucina instruction ${path}: ${error instanceof Error ? error.message : String(error)}`);
32
+ }
33
+ }
package/dist/labels.js ADDED
@@ -0,0 +1,8 @@
1
+ export const labels = [
2
+ ["fucina:explore", "Run read-only exploration", "0E8A16"],
3
+ ["fucina:implement", "Run implementation", "0E8A16"],
4
+ ["fucina:review", "Run automated PR review", "0E8A16"],
5
+ ["fucina:address-feedback", "Address review feedback on a PR", "0E8A16"],
6
+ ["fucina:in-progress", "Fucina workflow is running", "FBCA04"],
7
+ ["fucina:blocked", "Fucina workflow is blocked or failed", "D73A4A"],
8
+ ];
@@ -0,0 +1,23 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { runAgent, runFucinaAgent } from "../agent.js";
3
+ import { withRunLink } from "../comment.js";
4
+ export async function addressFeedback(event, deps) {
5
+ if (event.kind !== "pull_request")
6
+ throw new Error("fucina:address-feedback only runs on PRs");
7
+ const pr = JSON.parse(deps.gh(["pr", "view", String(event.number), "--json", "headRepositoryOwner,baseRepositoryOwner,headRefName,headRefOid,isCrossRepository"]));
8
+ if (pr.isCrossRepository)
9
+ throw new Error("Fucina mutation refuses PRs from forks");
10
+ const comments = deps.gh(["pr", "view", String(event.number), "--comments"]);
11
+ const { result, parsed } = await runFucinaAgent(deps.agent, `Address unresolved feedback on internal PR #${event.number}: ${event.title}\n\n${comments}\n\nReturn <fucina>{"summary":"..."}</fucina>.`);
12
+ if (!result.commits.length && !parsed.summary.trim())
13
+ throw new Error("Fucina address-feedback produced no useful commit or comment");
14
+ if (result.commits.length) {
15
+ const sh = deps.sh ?? ((args) => execFileSync(args[0], args.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }));
16
+ sh(["git", "push", `--force-with-lease=${pr.headRefName}:${pr.headRefOid}`, "origin", `HEAD:${pr.headRefName}`]);
17
+ }
18
+ if (parsed.summary.trim())
19
+ deps.gh(["pr", "comment", String(event.number), "--body", withRunLink(parsed.summary, deps.runUrl)]);
20
+ }
21
+ export async function addressFeedbackDefault(event) {
22
+ return addressFeedback(event, { gh: () => "", agent: (prompt) => runAgent(prompt) });
23
+ }
@@ -0,0 +1,9 @@
1
+ import { runAgent, runFucinaAgent } from "../agent.js";
2
+ import { withRunLink } from "../comment.js";
3
+ export async function explore(event, deps) {
4
+ const { parsed } = await runFucinaAgent(deps.agent, `Explore issue #${event.number}: ${event.title}\n\n${event.body ?? ""}\n\nReturn <fucina>{"summary":"..."}</fucina>.`);
5
+ deps.gh(["issue", "comment", String(event.number), "--body", withRunLink(parsed.summary, deps.runUrl)]);
6
+ }
7
+ export async function exploreDefault(event) {
8
+ return explore(event, { gh: () => "", agent: (prompt) => runAgent(prompt) });
9
+ }
@@ -0,0 +1,31 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { runAgent, runFucinaAgent } from "../agent.js";
3
+ import { withRunLink } from "../comment.js";
4
+ export async function implement(event, deps) {
5
+ if (event.kind !== "issue")
6
+ throw new Error("fucina:implement only runs on issues");
7
+ const state = deps.gh(["issue", "view", String(event.number), "--json", "state", "--jq", ".state"]).trim().toUpperCase();
8
+ if (state && state !== "OPEN")
9
+ throw new Error(`Fucina implement issue #${event.number} is not open`);
10
+ const comments = deps.gh(["issue", "view", String(event.number), "--comments"]);
11
+ const { result, parsed } = await runFucinaAgent(deps.agent, `Implement issue #${event.number}: ${event.title}\n\nIssue body:\n${event.body ?? ""}\n\nIssue comments:\n${comments}\n\nIf the requested work is GitHub-side bookkeeping, do it with gh and return a summary. Do not close issue #${event.number}; Fucina relies on PR merge/Closes for closure. Return <fucina>{"summary":"..."}</fucina>.`);
12
+ try {
13
+ deps.gh(["issue", "reopen", String(event.number)]);
14
+ }
15
+ catch { }
16
+ const sh = deps.sh ?? ((args) => execFileSync(args[0], args.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }));
17
+ const commitsAhead = Number(sh(["git", "rev-list", "--count", "origin/main..HEAD"]).trim());
18
+ if (!result.commits.length || !Number.isFinite(commitsAhead) || commitsAhead === 0) {
19
+ deps.gh(["issue", "comment", String(event.number), "--body", withRunLink(parsed.summary, deps.runUrl)]);
20
+ return;
21
+ }
22
+ const slug = event.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "work";
23
+ const branch = `fucina/issue-${event.number}-${slug}`;
24
+ sh(["git", "checkout", "-B", branch]);
25
+ sh(["git", "push", "--force-with-lease", "origin", branch]);
26
+ deps.gh(["pr", "create", "--draft", "--head", branch, "--title", `Implement #${event.number}: ${event.title}`, "--body", `Closes #${event.number}`]);
27
+ deps.gh(["issue", "comment", String(event.number), "--body", withRunLink(parsed.prUrl ?? parsed.summary, deps.runUrl)]);
28
+ }
29
+ export async function implementDefault(event) {
30
+ return implement(event, { gh: () => "", agent: (prompt) => runAgent(prompt) });
31
+ }
@@ -0,0 +1,29 @@
1
+ import { runAgent, runFucinaAgent } from "../agent.js";
2
+ import { withRunLink } from "../comment.js";
3
+ import { loadConfig } from "../config.js";
4
+ import { isAuthorizedActor } from "../auth.js";
5
+ import { getSensitiveFiles } from "../sensitive-paths.js";
6
+ export async function review(event, deps) {
7
+ if (event.kind !== "pull_request")
8
+ throw new Error("fucina:review only runs on PRs");
9
+ const pr = JSON.parse(deps.gh(["api", `repos/${process.env.GITHUB_REPOSITORY}/pulls/${event.number}`, "--jq", "."]));
10
+ const prAuthor = pr.user.login;
11
+ const headSha = pr.head.sha;
12
+ const config = loadConfig(deps.cwd);
13
+ const filesData = JSON.parse(deps.gh(["api", `repos/${process.env.GITHUB_REPOSITORY}/pulls/${event.number}/files`, "--jq", "."]));
14
+ const changedFiles = filesData.map((f) => f.filename);
15
+ const sensitiveFiles = getSensitiveFiles(changedFiles, config.sensitiveInstructionPaths);
16
+ if (sensitiveFiles.length > 0 && !isAuthorizedActor(prAuthor, deps.gh)) {
17
+ const fileList = sensitiveFiles.map((f) => ` - ${f}`).join("\n");
18
+ throw new Error(`PR author @${prAuthor} is not an Authorized Actor and modified sensitive instruction paths:\n${fileList}\n\nAn Authorized Actor must explicitly trust these changes:\n\`/fucina trust-instructions ${headSha}\``);
19
+ }
20
+ const diff = deps.gh(["api", `repos/${process.env.GITHUB_REPOSITORY}/pulls/${event.number}`, "--header", "Accept: application/vnd.github.v3.diff"]);
21
+ const { result, parsed } = await runFucinaAgent(deps.agent, `Review PR #${event.number}: ${event.title}\n\nDiff:\n${diff}\n\nReturn <fucina>{"summary":"..."}</fucina>.`);
22
+ if (result.commits.length)
23
+ throw new Error("Fucina review must not mutate files, commit, or push");
24
+ deps.gh(["pr", "review", String(event.number), "--comment", "--body", withRunLink(parsed.summary, deps.runUrl)]);
25
+ }
26
+ export async function reviewDefault(event) {
27
+ const { gh } = await import("../gh.js");
28
+ return review(event, { gh, agent: (prompt) => runAgent(prompt), cwd: process.cwd() });
29
+ }
@@ -0,0 +1,23 @@
1
+ import { withRunLink } from "../comment.js";
2
+ export async function trustInstructions(event, sha, deps) {
3
+ if (event.kind !== "pull_request")
4
+ throw new Error("/fucina trust-instructions only works on PRs");
5
+ const pr = JSON.parse(deps.gh(["api", `repos/${process.env.GITHUB_REPOSITORY}/pulls/${event.number}`, "--jq", "."]));
6
+ const currentSha = pr.head.sha;
7
+ if (currentSha !== sha) {
8
+ throw new Error(`SHA mismatch: PR head is ${currentSha.substring(0, 7)} but you provided ${sha.substring(0, 7)}. The PR has been updated since you copied the command. Re-run the review to get the current SHA.`);
9
+ }
10
+ deps.gh(["pr", "edit", String(event.number), "--remove-label", "fucina:review"]);
11
+ deps.gh(["pr", "edit", String(event.number), "--remove-label", "fucina:blocked"]);
12
+ deps.gh(["pr", "edit", String(event.number), "--add-label", "fucina:review"]);
13
+ deps.gh(["pr", "comment", String(event.number), "--body", withRunLink(`Sensitive instruction changes at ${sha.substring(0, 7)} explicitly trusted. Review will proceed.`, deps.runUrl)]);
14
+ }
15
+ export async function trustInstructionsDefault(event, sha) {
16
+ const { gh } = await import("../gh.js");
17
+ return trustInstructions(event, sha, {
18
+ gh,
19
+ runUrl: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
20
+ ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
21
+ : undefined
22
+ });
23
+ }
package/dist/run.js ADDED
@@ -0,0 +1,66 @@
1
+ import { assertAuthorizedWithGh } from "./auth.js";
2
+ import { readEvent } from "./event.js";
3
+ import { gh } from "./gh.js";
4
+ import { runAgent } from "./agent.js";
5
+ import { composeAgentPrompt } from "./instructions.js";
6
+ import { explore } from "./modes/explore.js";
7
+ import { addressFeedback } from "./modes/address-feedback.js";
8
+ import { implement } from "./modes/implement.js";
9
+ import { review } from "./modes/review.js";
10
+ export async function run() {
11
+ return runEvent(readEvent(), { gh, agent: (prompt) => runAgent(prompt), cwd: process.cwd(), runUrl: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` : undefined });
12
+ }
13
+ export async function runEvent(event, deps) {
14
+ if (event.label.startsWith("/fucina ")) {
15
+ return await runSlashCommand(event, deps);
16
+ }
17
+ const target = event.kind === "issue" ? "issue" : "pr";
18
+ deps.gh([target, "edit", String(event.number), "--remove-label", event.label]);
19
+ try {
20
+ assertAuthorizedWithGh(event, deps.gh);
21
+ deps.gh([target, "edit", String(event.number), "--remove-label", "fucina:blocked"]);
22
+ deps.gh([target, "edit", String(event.number), "--add-label", "fucina:in-progress"]);
23
+ const mode = event.label.replace("fucina:", "");
24
+ const modeDeps = { ...deps, agent: (prompt) => deps.agent(composeAgentPrompt(mode, prompt, deps.cwd)) };
25
+ if (event.label === "fucina:explore" && event.kind === "issue")
26
+ return await explore(event, modeDeps);
27
+ if (event.label === "fucina:implement" && event.kind === "issue")
28
+ return await implement(event, modeDeps);
29
+ if (event.label === "fucina:address-feedback" && event.kind === "pull_request")
30
+ return await addressFeedback(event, modeDeps);
31
+ if (event.label === "fucina:review" && event.kind === "pull_request")
32
+ return await review(event, modeDeps);
33
+ throw new Error(`${event.label} is not valid for ${event.kind}`);
34
+ }
35
+ catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ deps.gh([target, "edit", String(event.number), "--remove-label", event.label]);
38
+ deps.gh([target, "edit", String(event.number), "--add-label", "fucina:blocked"]);
39
+ deps.gh([target, "comment", String(event.number), "--body", `Fucina ${event.label} failed.\n\nReason: ${message}\n${deps.runUrl ? `\nRetry by re-adding ${event.label}. Run: ${deps.runUrl}` : `\nRetry by re-adding ${event.label}.`}`]);
40
+ throw error;
41
+ }
42
+ finally {
43
+ deps.gh([target, "edit", String(event.number), "--remove-label", "fucina:in-progress"]);
44
+ }
45
+ }
46
+ async function runSlashCommand(event, deps) {
47
+ const target = event.kind === "issue" ? "issue" : "pr";
48
+ try {
49
+ assertAuthorizedWithGh(event, deps.gh);
50
+ const parts = event.label.split(/\s+/);
51
+ const command = parts[1];
52
+ if (command === "trust-instructions") {
53
+ const sha = parts[2];
54
+ if (!sha || sha.length !== 40)
55
+ throw new Error("trust-instructions requires a full 40-character SHA");
56
+ const { trustInstructions } = await import("./modes/trust-instructions.js");
57
+ return await trustInstructions(event, sha, deps);
58
+ }
59
+ throw new Error(`Unknown slash command: ${command}`);
60
+ }
61
+ catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ deps.gh([target, "comment", String(event.number), "--body", `Fucina slash command failed.\n\nReason: ${message}\n${deps.runUrl ? `\nRun: ${deps.runUrl}` : ""}`]);
64
+ throw error;
65
+ }
66
+ }
@@ -0,0 +1,11 @@
1
+ export function matchesSensitivePaths(files, patterns) {
2
+ return files.some((file) => patterns.some((pattern) => matchesPattern(file, pattern)));
3
+ }
4
+ export function getSensitiveFiles(files, patterns) {
5
+ return files.filter((file) => patterns.some((pattern) => matchesPattern(file, pattern)));
6
+ }
7
+ function matchesPattern(file, pattern) {
8
+ if (pattern.endsWith("/**"))
9
+ return file === pattern.slice(0, -3) || file.startsWith(pattern.slice(0, -2));
10
+ return file === pattern;
11
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@vekexasia/fucina",
3
+ "version": "0.0.0",
4
+ "description": "Label-driven AI workflows for GitHub repositories.",
5
+ "type": "module",
6
+ "bin": {
7
+ "fucina": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "templates",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "typecheck": "tsc --noEmit",
17
+ "dev": "tsx src/cli.ts",
18
+ "test": "node --import tsx --test test/*.test.ts"
19
+ },
20
+ "dependencies": {
21
+ "@ai-hero/sandcastle": "^0.10.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.0.0",
25
+ "tsx": "^4.20.0",
26
+ "typescript": "^5.9.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ }
31
+ }
@@ -0,0 +1,2 @@
1
+ # Generated by `fucina install`.
2
+ # See src/install.ts for the current template.
@@ -0,0 +1,2 @@
1
+ # Generated by `fucina install`.
2
+ # See src/install.ts for the current template.
@@ -0,0 +1,2 @@
1
+ # Generated by `fucina install`.
2
+ # See src/install.ts for the current template.