@valescoagency/runway 0.1.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.
@@ -0,0 +1,191 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { run, claudeCode } from "@ai-hero/sandcastle";
4
+ import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
5
+ import { execa } from "execa";
6
+ import { implementVars, loadImplementPrompt, loadReviewPrompt, renderPrompt, reviewVars, } from "./prompts.js";
7
+ const REVIEW_VERDICT_RE = /^REVIEW:\s*(APPROVED|REJECTED)(?:\s+—\s+(.*))?$/m;
8
+ /**
9
+ * Confirms the cwd looks like a sandcastle-initialised repo. If not,
10
+ * we error early with a clear message rather than letting Sandcastle
11
+ * fail deep inside Docker setup.
12
+ */
13
+ export function assertSandcastleInitialised(cwd) {
14
+ const sandcastleDir = join(cwd, ".sandcastle");
15
+ if (!existsSync(sandcastleDir)) {
16
+ throw new Error(`No .sandcastle/ directory in ${cwd}. Run \`npx sandcastle init\` here first.`);
17
+ }
18
+ }
19
+ /**
20
+ * Drains the Linear queue until empty (or until --max is hit). One
21
+ * issue at a time in v1; parallel runs are a follow-up.
22
+ */
23
+ export async function drainQueue(deps, opts = {}) {
24
+ const { config, linear } = deps;
25
+ const max = opts.max ?? Number.POSITIVE_INFINITY;
26
+ let processed = 0;
27
+ let opened = 0;
28
+ let hitl = 0;
29
+ let errored = 0;
30
+ while (processed < max) {
31
+ const queue = await linear.fetchReady();
32
+ if (queue.length === 0)
33
+ break;
34
+ const issue = queue[0];
35
+ try {
36
+ const verdict = await processIssue(issue, deps);
37
+ processed += 1;
38
+ if (verdict === "opened")
39
+ opened += 1;
40
+ if (verdict === "hitl")
41
+ hitl += 1;
42
+ }
43
+ catch (err) {
44
+ errored += 1;
45
+ console.error(`[runway] error on ${issue.identifier}:`, err);
46
+ await linear
47
+ .applyLabel(issue.id, config.hitlLabel)
48
+ .catch(() => undefined);
49
+ await linear
50
+ .comment(issue.id, `Runway hit an unrecoverable error and flagged for human review:\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``)
51
+ .catch(() => undefined);
52
+ }
53
+ }
54
+ return { processed, opened, hitl, errored };
55
+ }
56
+ async function processIssue(issue, deps) {
57
+ const { config, linear, github, cwd } = deps;
58
+ const branch = `agent/${issue.identifier.toLowerCase()}`;
59
+ await linear.transition(issue.id, config.inProgressStatus);
60
+ await linear.comment(issue.id, `Runway picked up this issue. Branch: \`${branch}\`.`);
61
+ // 1. Implementation pass.
62
+ const implementPrompt = renderPrompt(await loadImplementPrompt(), implementVars(issue));
63
+ const implementResult = await run({
64
+ agent: claudeCode("claude-opus-4-6"),
65
+ sandbox: docker({
66
+ env: dockerEnv(config),
67
+ }),
68
+ cwd,
69
+ prompt: implementPrompt,
70
+ branchStrategy: { type: "branch", branch },
71
+ maxIterations: config.maxIterations,
72
+ name: `impl-${issue.identifier}`,
73
+ });
74
+ if (implementResult.commits.length === 0) {
75
+ await flagHitl(issue, deps, "Agent produced no commits — the issue may need clarification or human input.");
76
+ return "hitl";
77
+ }
78
+ // 2. Review pass — read-only-ish, just looking at the diff.
79
+ const diff = await captureDiff(cwd, branch);
80
+ const commitLog = await captureCommitLog(cwd, branch);
81
+ const reviewPrompt = renderPrompt(await loadReviewPrompt(), reviewVars({ issue, diff, commits: commitLog }));
82
+ const reviewResult = await run({
83
+ agent: claudeCode("claude-opus-4-6"),
84
+ sandbox: docker({
85
+ env: dockerEnv(config),
86
+ }),
87
+ cwd,
88
+ prompt: reviewPrompt,
89
+ branchStrategy: { type: "head" },
90
+ maxIterations: 1,
91
+ name: `review-${issue.identifier}`,
92
+ });
93
+ const verdict = parseReviewVerdict(reviewResult);
94
+ if (verdict.kind === "rejected") {
95
+ await flagHitl(issue, deps, `Sub-agent review rejected: ${verdict.reason}`);
96
+ return "hitl";
97
+ }
98
+ // 3. Push + PR.
99
+ await github.pushBranch(cwd, branch);
100
+ const prBody = buildPrBody(issue);
101
+ const prUrl = await github.openPullRequest({
102
+ repoPath: cwd,
103
+ branch,
104
+ issue,
105
+ body: prBody,
106
+ });
107
+ await linear.transition(issue.id, config.inReviewStatus);
108
+ await linear.comment(issue.id, `Runway opened a PR for review: ${prUrl}`);
109
+ return "opened";
110
+ }
111
+ async function flagHitl(issue, deps, reason) {
112
+ const { config, linear } = deps;
113
+ await linear.applyLabel(issue.id, config.hitlLabel);
114
+ await linear.comment(issue.id, `Runway flagged for human review: ${reason}`);
115
+ }
116
+ async function captureDiff(repoPath, branch) {
117
+ const { stdout } = await execa("git", ["diff", `main...${branch}`], {
118
+ cwd: repoPath,
119
+ });
120
+ // Truncate to keep the review prompt under the model's context budget.
121
+ return stdout.length > 60_000 ? `${stdout.slice(0, 60_000)}\n…(truncated)` : stdout;
122
+ }
123
+ async function captureCommitLog(repoPath, branch) {
124
+ const { stdout } = await execa("git", ["log", "--oneline", `main..${branch}`], { cwd: repoPath });
125
+ return stdout;
126
+ }
127
+ /**
128
+ * Sandcastle's `RunResult` shape varies by version; defensively dig out
129
+ * the last assistant message text. We only need to match the
130
+ * `REVIEW: APPROVED` / `REVIEW: REJECTED — …` line at the tail.
131
+ */
132
+ function parseReviewVerdict(result) {
133
+ const text = stringifyResult(result);
134
+ const match = text.match(REVIEW_VERDICT_RE);
135
+ if (!match) {
136
+ return {
137
+ kind: "rejected",
138
+ reason: "review output did not contain a REVIEW: verdict line",
139
+ };
140
+ }
141
+ if (match[1] === "APPROVED")
142
+ return { kind: "approved", reason: "" };
143
+ return {
144
+ kind: "rejected",
145
+ reason: match[2]?.trim() || "no reason given",
146
+ };
147
+ }
148
+ function stringifyResult(result) {
149
+ if (typeof result === "string")
150
+ return result;
151
+ if (result && typeof result === "object") {
152
+ const r = result;
153
+ if (r.iterations?.length) {
154
+ return r.iterations
155
+ .map((i) => i.output ?? i.text ?? "")
156
+ .filter(Boolean)
157
+ .join("\n");
158
+ }
159
+ if (typeof r.output === "string")
160
+ return r.output;
161
+ return JSON.stringify(result);
162
+ }
163
+ return String(result);
164
+ }
165
+ /**
166
+ * Env vars to inject into every sandcastle container. Today this is
167
+ * just OP_SERVICE_ACCOUNT_TOKEN (when present) so the in-container
168
+ * varlock shim can authenticate with 1Password and resolve
169
+ * ANTHROPIC_API_KEY / GH_TOKEN at agent run time. See
170
+ * docs/secrets-with-varlock.md for the full flow.
171
+ */
172
+ function dockerEnv(config) {
173
+ const env = {};
174
+ if (config.opServiceAccountToken) {
175
+ env.OP_SERVICE_ACCOUNT_TOKEN = config.opServiceAccountToken;
176
+ }
177
+ return env;
178
+ }
179
+ function buildPrBody(issue) {
180
+ return [
181
+ `Runway-generated PR for **${issue.identifier} — ${issue.title}**.`,
182
+ "",
183
+ "Sub-agent review pass: APPROVED.",
184
+ "",
185
+ "## Linear issue",
186
+ "",
187
+ issue.description || "(no description)",
188
+ "",
189
+ `Refs ${issue.identifier}`,
190
+ ].join("\n");
191
+ }
@@ -0,0 +1,40 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ // Prompts ship with the runway package, NOT in the target repo's
6
+ // .sandcastle/. Runway substitutes {{KEY}} placeholders before passing
7
+ // the rendered string inline to sandcastle.run({ prompt }).
8
+ // runway/src/prompts.ts → runway/prompts/
9
+ const PROMPT_DIR = join(__dirname, "..", "prompts");
10
+ export async function loadImplementPrompt() {
11
+ return readFile(join(PROMPT_DIR, "implement.md"), "utf8");
12
+ }
13
+ export async function loadReviewPrompt() {
14
+ return readFile(join(PROMPT_DIR, "review.md"), "utf8");
15
+ }
16
+ /**
17
+ * Render a prompt by replacing all `{{KEY}}` placeholders with values
18
+ * from `vars`. We do the substitution here (instead of relying on
19
+ * sandcastle's promptArgs) because we pass the prompt inline and want
20
+ * one canonical place to template.
21
+ */
22
+ export function renderPrompt(template, vars) {
23
+ return template.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
24
+ }
25
+ export function implementVars(issue) {
26
+ return {
27
+ ISSUE_IDENTIFIER: issue.identifier,
28
+ ISSUE_TITLE: issue.title,
29
+ ISSUE_DESCRIPTION: issue.description || "(no description)",
30
+ };
31
+ }
32
+ export function reviewVars(args) {
33
+ return {
34
+ ISSUE_IDENTIFIER: args.issue.identifier,
35
+ ISSUE_TITLE: args.issue.title,
36
+ ISSUE_DESCRIPTION: args.issue.description || "(no description)",
37
+ DIFF: args.diff || "(empty diff)",
38
+ COMMITS: args.commits || "(no commits)",
39
+ };
40
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@valescoagency/runway",
3
+ "version": "0.1.0",
4
+ "description": "Linear-driven orchestrator + scaffolder for coding agents on Sandcastle. `runway init` scaffolds a target repo (sandcastle + varlock + 1Password); `runway run` drains a Linear queue against it; `runway doctor`, `runway upgrade`, `runway upgrade-repo` round out the lifecycle.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Valesco Agency",
8
+ "email": "jason@valescoagency.com",
9
+ "url": "https://valescoagency.com"
10
+ },
11
+ "homepage": "https://github.com/ValescoAgency/runway#readme",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/ValescoAgency/runway.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/ValescoAgency/runway/issues"
18
+ },
19
+ "keywords": [
20
+ "claude-code",
21
+ "sandcastle",
22
+ "linear",
23
+ "orchestrator",
24
+ "agent",
25
+ "varlock",
26
+ "1password",
27
+ "valesco",
28
+ "cli"
29
+ ],
30
+ "type": "module",
31
+ "bin": {
32
+ "runway": "./dist/cli.js"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "templates",
37
+ "LICENSE",
38
+ "README.md"
39
+ ],
40
+ "dependencies": {
41
+ "@ai-hero/sandcastle": "^0.5.10",
42
+ "@linear/sdk": "^41.0.0",
43
+ "execa": "^9.5.2",
44
+ "zod": "^3.23.8"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.10.0",
48
+ "tsx": "^4.19.2",
49
+ "typescript": "^5.7.2"
50
+ },
51
+ "engines": {
52
+ "node": ">=22"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc && chmod +x dist/cli.js",
59
+ "typecheck": "tsc --noEmit",
60
+ "dev": "tsx src/cli.ts",
61
+ "lint": "echo 'lint not configured yet'"
62
+ }
63
+ }
@@ -0,0 +1,32 @@
1
+ # Per-target-repo secrets manifest. Lives at the target repo root
2
+ # (NOT in .sandcastle/, since that directory is sandcastle's territory).
3
+ #
4
+ # This file is committed. Values resolve at runtime via varlock + the
5
+ # 1Password CLI inside the sandcastle container — see
6
+ # .sandcastle/Dockerfile for the wiring.
7
+ #
8
+ # When the agent runs, the `claude` binary inside the container is a
9
+ # wrapper that invokes `varlock run -- claude.real`. varlock reads
10
+ # THIS file, fetches the op:// references using the OP_SERVICE_ACCOUNT_TOKEN
11
+ # that runway passed in at container start, and exposes the resolved
12
+ # values to the real claude process for the duration of that one
13
+ # invocation. After it exits, secrets are gone from container memory.
14
+ #
15
+ # Note on the op:// shape: with service-account auth (the only mode
16
+ # runway uses), the token already encodes the 1Password tenant, so the
17
+ # URI omits the account segment — `op://<vault>/<item>`, not
18
+ # `op://<account>/<vault>/<item>`.
19
+
20
+ # @sensitive @required
21
+ ANTHROPIC_API_KEY=exec('op read "op://{{OP_VAULT}}/{{ANTHROPIC_ITEM}}"')
22
+
23
+ # @sensitive @required
24
+ GH_TOKEN=exec('op read "op://{{OP_VAULT}}/{{GH_TOKEN_ITEM}}"')
25
+
26
+ # Add other secrets the agent needs at runtime here. Examples:
27
+ #
28
+ # @sensitive @required
29
+ # OPENAI_API_KEY=exec('op read "op://{{OP_VAULT}}/openai-api-key"')
30
+ #
31
+ # @sensitive @required
32
+ # DATABASE_URL=exec('op read "op://{{OP_VAULT}}/database-url"')
@@ -0,0 +1,55 @@
1
+ # Canonical claude-code Dockerfile — vendored from
2
+ # @ai-hero/sandcastle's InitService.ts (CLAUDE_CODE_DOCKERFILE constant).
3
+ # Kept here so `runway init` can write it directly, without invoking
4
+ # `sandcastle init` (which has interactive prompts that hang in
5
+ # non-TTY environments like CI / Mac Mini cron).
6
+ #
7
+ # Drift policy: when sandcastle bumps its claude-code Dockerfile,
8
+ # refresh this file. The diff should be tiny — runway's tier 2 layer
9
+ # patches AFTER this base, so adopters re-run `runway init --force`
10
+ # to roll forward.
11
+
12
+ FROM node:22-bookworm
13
+
14
+ # Install system dependencies
15
+ RUN apt-get update && apt-get install -y \
16
+ git \
17
+ curl \
18
+ jq \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ # Build-args for UID/GID alignment: defaults match the host user's
22
+ # UID/GID at build time so image-built files and bind-mounted files
23
+ # share an owner without runtime chown.
24
+ ARG AGENT_UID=1000
25
+ ARG AGENT_GID=1000
26
+
27
+ # Rename the base image's "node" user to "agent" and align UID/GID.
28
+ #
29
+ # Divergence from sandcastle's stock Dockerfile: stock runs
30
+ # `groupmod -g $AGENT_GID node` unconditionally, which fails on macOS
31
+ # hosts where the host GID is 20 (`staff`) — Debian's `dialout` group
32
+ # already has GID 20, and `groupmod` refuses to assign a duplicate
33
+ # GID. We guard with `getent group` so groupmod only runs if the
34
+ # target GID is unused; if it's already taken, we point the agent
35
+ # user at the pre-existing group via `usermod -g <gid>` and the
36
+ # image still works (the in-image group name is irrelevant — only the
37
+ # numeric GID matters for bind-mount permissions).
38
+ RUN if ! getent group $AGENT_GID >/dev/null; then \
39
+ groupmod -g $AGENT_GID node; \
40
+ fi \
41
+ && usermod -u $AGENT_UID -g $AGENT_GID -d /home/agent -m -l agent node
42
+ USER ${AGENT_UID}:${AGENT_GID}
43
+
44
+ # Install Claude Code CLI
45
+ RUN curl -fsSL https://claude.ai/install.sh | bash
46
+
47
+ # Add Claude to PATH
48
+ ENV PATH="/home/agent/.local/bin:$PATH"
49
+
50
+ WORKDIR /home/agent
51
+
52
+ # In worktree sandbox mode, Sandcastle bind-mounts the git worktree at
53
+ # the sandbox repo dir and overrides the working directory to that dir
54
+ # at container start.
55
+ ENTRYPOINT ["sleep", "infinity"]
@@ -0,0 +1,43 @@
1
+ # --- runway tier-2 layer (varlock + 1Password) ---
2
+ # Spliced in by `runway init --tier=2` immediately before the
3
+ # final `ENTRYPOINT ["sleep", "infinity"]` line.
4
+
5
+ # Install varlock via npm. The official `ghcr.io/dmno-dev/varlock`
6
+ # image is musl/Alpine — copying its binary into a glibc base
7
+ # (node:22-bookworm) produces an ELF that the loader can't resolve
8
+ # ("not found" on exec). npm install gets the right binary for the
9
+ # image's libc.
10
+ USER root
11
+ RUN npm install -g varlock
12
+
13
+ # Install the 1Password CLI so varlock can resolve `op://` references.
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ ca-certificates curl gnupg \
16
+ && curl -sS https://downloads.1password.com/linux/keys/1password.asc \
17
+ | gpg --dearmor -o /usr/share/keyrings/1password-archive-keyring.gpg \
18
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/$(dpkg --print-architecture) stable main" \
19
+ > /etc/apt/sources.list.d/1password.list \
20
+ && apt-get update && apt-get install -y 1password-cli \
21
+ && rm -rf /var/lib/apt/lists/*
22
+
23
+ # Shim the `claude` binary so every invocation runs through varlock.
24
+ # The real binary moves to claude.real; the shim resolves secrets from
25
+ # 1Password (using OP_SERVICE_ACCOUNT_TOKEN passed in by runway) and
26
+ # execs the real claude with secrets in its process env. Secrets exist
27
+ # only inside the lifetime of one claude invocation — never in any
28
+ # image layer, never on the container filesystem.
29
+ RUN mv /home/agent/.local/bin/claude /home/agent/.local/bin/claude.real \
30
+ && printf '%s\n' \
31
+ '#!/usr/bin/env bash' \
32
+ 'set -euo pipefail' \
33
+ 'exec varlock run --env-file /home/agent/workspace/.env.schema -- /home/agent/.local/bin/claude.real "$@"' \
34
+ > /home/agent/.local/bin/claude \
35
+ && chmod +x /home/agent/.local/bin/claude \
36
+ && chown ${AGENT_UID}:${AGENT_GID} /home/agent/.local/bin/claude
37
+
38
+ USER ${AGENT_UID}:${AGENT_GID}
39
+
40
+ # Final ENTRYPOINT remains:
41
+ # ENTRYPOINT ["sleep", "infinity"]
42
+ # Sandcastle will `docker exec <container> claude ...` and our shim
43
+ # transparently wraps each invocation.