@valescoagency/runway 0.1.2 → 0.3.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 +98 -6
- package/dist/commands/doctor.js +89 -4
- package/dist/commands/init.js +36 -8
- package/dist/commands/run.js +27 -3
- package/dist/commands/upgrade-repo.js +42 -14
- package/dist/config.js +20 -0
- package/dist/git.js +41 -0
- package/dist/github.js +2 -2
- package/dist/linear.js +25 -0
- package/dist/orchestrator.js +56 -14
- package/package.json +9 -3
- package/templates/.env.schema.target-repo +17 -12
package/README.md
CHANGED
|
@@ -61,8 +61,12 @@ runway (this CLI, on your Mac, run from inside the target repo)
|
|
|
61
61
|
- Node 22+
|
|
62
62
|
- `gh` CLI authenticated against the org that hosts your target repo
|
|
63
63
|
- Linear API key with read+write on the team you're targeting
|
|
64
|
-
-
|
|
65
|
-
|
|
64
|
+
- A Claude Code credential — **either** an Anthropic API key
|
|
65
|
+
(`sk-ant-api03-…`, pay-per-token) **or** a Pro/Max OAuth token
|
|
66
|
+
(`sk-ant-oat01-…`, generated via `claude setup-token`). The two are
|
|
67
|
+
not interchangeable — see "Claude Code auth modes" below. Stored in
|
|
68
|
+
the **target repo's** `.sandcastle/.env` (tier 1) or 1Password
|
|
69
|
+
(tier 2); never in runway's own env.
|
|
66
70
|
|
|
67
71
|
## One-time setup per target repo
|
|
68
72
|
|
|
@@ -71,13 +75,16 @@ cd /path/to/your/repo
|
|
|
71
75
|
runway init \
|
|
72
76
|
--op-vault=runway \
|
|
73
77
|
--anthropic-item=anthropic-api-key \
|
|
74
|
-
--gh-token-item=gh-token
|
|
78
|
+
--gh-token-item=gh-token \
|
|
79
|
+
--auth-mode=api-key # or --auth-mode=oauth for Pro/Max tokens
|
|
75
80
|
```
|
|
76
81
|
|
|
77
82
|
(No `--op-account` — runway uses 1Password service-account auth
|
|
78
83
|
(`OP_SERVICE_ACCOUNT_TOKEN`) exclusively, and the token already
|
|
79
84
|
encodes the tenant. `op://` URIs runway writes are
|
|
80
|
-
`op://<vault>/<item
|
|
85
|
+
`op://<vault>/<item>/credential`, not `op://<account>/<vault>/<item>`.
|
|
86
|
+
The `/credential` field selector is required for `API_CREDENTIAL`
|
|
87
|
+
items, which is the canonical 1Password category for API keys.)
|
|
81
88
|
|
|
82
89
|
This runs `npx sandcastle init`, patches the generated `.sandcastle/Dockerfile`
|
|
83
90
|
to bake in `varlock` + the 1Password CLI + a `claude` shim, scaffolds
|
|
@@ -90,6 +97,37 @@ and no varlock (faster but secrets land on disk).
|
|
|
90
97
|
|
|
91
98
|
Architecture walkthrough: [`docs/secrets-with-varlock.md`](docs/secrets-with-varlock.md).
|
|
92
99
|
|
|
100
|
+
## Claude Code auth modes
|
|
101
|
+
|
|
102
|
+
Claude Code accepts two distinct credentials, and they are **not
|
|
103
|
+
interchangeable** — passing one as the other yields a generic
|
|
104
|
+
`Invalid API key` inside the container with no useful diagnostic.
|
|
105
|
+
|
|
106
|
+
| Mode | Env var | Token shape | Source |
|
|
107
|
+
|---|---|---|---|
|
|
108
|
+
| `api-key` (default) | `ANTHROPIC_API_KEY` | `sk-ant-api03-…` | [Anthropic console](https://console.anthropic.com), pay-per-token |
|
|
109
|
+
| `oauth` | `CLAUDE_CODE_OAUTH_TOKEN` | `sk-ant-oat01-…` | `claude setup-token` on your Pro/Max account |
|
|
110
|
+
|
|
111
|
+
Pick whichever matches what's stored in your 1Password item:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
runway init --tier=2 --op-vault=runway \
|
|
115
|
+
--anthropic-item=claude-pro-oauth-token \
|
|
116
|
+
--gh-token-item=gh-token \
|
|
117
|
+
--auth-mode=oauth
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The `--anthropic-item` flag is the 1Password item name regardless of
|
|
121
|
+
mode; only the env var written into `.env.schema` changes. `runway
|
|
122
|
+
doctor` surfaces the resolved mode under Environment (`claude auth
|
|
123
|
+
mode: oauth (…)`), and fails fast if `.env.schema` ends up with both
|
|
124
|
+
env vars at once.
|
|
125
|
+
|
|
126
|
+
If you switch modes later, run `runway upgrade-repo` — it extracts
|
|
127
|
+
the existing op:// references, re-renders the template with the new
|
|
128
|
+
mode (detected automatically from the schema), and writes back. You
|
|
129
|
+
do not need to re-pass the op:// flags.
|
|
130
|
+
|
|
93
131
|
## Secrets — recommended: varlock + 1Password
|
|
94
132
|
|
|
95
133
|
If you don't want any secret sitting at rest in any `.env` file,
|
|
@@ -118,6 +156,8 @@ Export runway's own env (in your shell rc, or wherever you keep API keys):
|
|
|
118
156
|
export LINEAR_API_KEY=lin_api_...
|
|
119
157
|
# Optional overrides:
|
|
120
158
|
# export RUNWAY_LINEAR_TEAM=VA
|
|
159
|
+
# export RUNWAY_LINEAR_PROJECT=<project-id-or-slug> # optional, scopes queue to one project
|
|
160
|
+
# export RUNWAY_BASE_BRANCH=master # optional, overrides auto-detected default branch
|
|
121
161
|
# export RUNWAY_READY_STATUS="Todo"
|
|
122
162
|
# export RUNWAY_IN_PROGRESS_STATUS="In Progress"
|
|
123
163
|
# export RUNWAY_IN_REVIEW_STATUS="In Review"
|
|
@@ -137,6 +177,38 @@ pnpm link --global # so `runway` is on your $PATH
|
|
|
137
177
|
|
|
138
178
|
`pnpm dev -- <args>` runs the TypeScript source via `tsx` without building, useful while iterating on runway itself.
|
|
139
179
|
|
|
180
|
+
#### Tests
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
pnpm test # one-shot run, used by CI
|
|
184
|
+
pnpm test:watch # watch mode for local iteration
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Vitest is the harness; tests live colocated with the source as
|
|
188
|
+
`*.test.ts` files (e.g. `src/git.test.ts` next to `src/git.ts`). CI
|
|
189
|
+
runs `pnpm typecheck && pnpm test` on every PR via
|
|
190
|
+
`.github/workflows/ci.yml`.
|
|
191
|
+
|
|
192
|
+
When adding logic that has a sharp pass/fail signal, add a test next
|
|
193
|
+
to it. The seed suite covers `parseRunArgs`, `detectBaseBranch`, the
|
|
194
|
+
`parseOpRefs` regex extraction, and the `drainQueue` error-handler
|
|
195
|
+
branches — copy any of those as a shape for new tests.
|
|
196
|
+
|
|
197
|
+
#### Git hooks (lefthook + commitlint)
|
|
198
|
+
|
|
199
|
+
Hooks install automatically on `pnpm install` via the `prepare`
|
|
200
|
+
script. What runs and when:
|
|
201
|
+
|
|
202
|
+
| Hook | Runs | Why |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| `pre-commit` | `pnpm typecheck` | Catch TS errors before they land on a branch. |
|
|
205
|
+
| `commit-msg` | `pnpm exec commitlint --edit` | Reject non-conventional commit messages (CLAUDE.md convention). |
|
|
206
|
+
| `pre-push` | `pnpm test` | Block pushing red. |
|
|
207
|
+
|
|
208
|
+
Skip a single hook invocation with `LEFTHOOK=0 git commit …` (or
|
|
209
|
+
`… git push …`). To re-install after editing `lefthook.yml`, run
|
|
210
|
+
`pnpm exec lefthook install -f`.
|
|
211
|
+
|
|
140
212
|
## Usage
|
|
141
213
|
|
|
142
214
|
```bash
|
|
@@ -157,18 +229,38 @@ per-issue comments for what happened.
|
|
|
157
229
|
Runway picks up issues that are:
|
|
158
230
|
|
|
159
231
|
- in team `RUNWAY_LINEAR_TEAM` (default `VA`)
|
|
232
|
+
- (optionally) in project `RUNWAY_LINEAR_PROJECT` (override per-run
|
|
233
|
+
with `runway run --project=<id-or-slug-or-name>`; unset = team-wide)
|
|
160
234
|
- in workflow state `RUNWAY_READY_STATUS` (default `Todo`)
|
|
161
235
|
|
|
162
236
|
It transitions them through:
|
|
163
237
|
|
|
164
|
-
- `In Progress` while the agent is running
|
|
238
|
+
- `In Progress` while the agent is running (specifically: once the
|
|
239
|
+
agent has committed to its branch — startup failures before any
|
|
240
|
+
commits revert the issue back to `Todo` rather than stranding it)
|
|
165
241
|
- `In Review` when the PR opens
|
|
166
|
-
- (label `needs-human`) if the agent or reviewer can't finish
|
|
242
|
+
- (label `needs-human`) if the agent or reviewer can't finish *after*
|
|
243
|
+
the agent has committed real work
|
|
167
244
|
|
|
168
245
|
These names are configurable per env var; the queries match by name so
|
|
169
246
|
your Linear workspace's actual state names need to line up with what
|
|
170
247
|
you set.
|
|
171
248
|
|
|
249
|
+
## Base branch
|
|
250
|
+
|
|
251
|
+
Runway auto-detects the repo's default branch at the start of every
|
|
252
|
+
`runway run` by reading `origin/HEAD` (with `git remote show origin`
|
|
253
|
+
as a fallback for fresh clones). That branch is used for diffing the
|
|
254
|
+
agent's work, counting commits when deciding whether a startup
|
|
255
|
+
failure should revert to `Todo`, and as the `--base` for the PR.
|
|
256
|
+
|
|
257
|
+
Set `RUNWAY_BASE_BRANCH=<name>` to override detection — useful when
|
|
258
|
+
you want runway to target a release branch instead of the default, or
|
|
259
|
+
when `origin/HEAD` isn't set and you don't want to run
|
|
260
|
+
`git remote set-head origin --auto`. `runway doctor` surfaces the
|
|
261
|
+
resolved base branch (detected or overridden) in its Environment
|
|
262
|
+
section.
|
|
263
|
+
|
|
172
264
|
## Sub-agent review
|
|
173
265
|
|
|
174
266
|
Every implementation run is followed by a fresh Sandcastle run with
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { execa } from "execa";
|
|
4
|
+
import { detectBaseBranch } from "../git.js";
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Usage
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -83,7 +84,7 @@ export async function doctorCommand(argv) {
|
|
|
83
84
|
const sections = [];
|
|
84
85
|
sections.push(await checkHostTooling(tierForToolingChecks));
|
|
85
86
|
if (initialised || opts.tierOverride !== undefined) {
|
|
86
|
-
sections.push(checkEnvironment(tierForToolingChecks));
|
|
87
|
+
sections.push(await checkEnvironment(tierForToolingChecks, cwd, repo));
|
|
87
88
|
sections.push(await checkRepoState(cwd, repo));
|
|
88
89
|
sections.push(await checkDockerImage(cwd));
|
|
89
90
|
}
|
|
@@ -113,11 +114,24 @@ function detectRepoState(cwd) {
|
|
|
113
114
|
const hasDockerfile = existsSync(join(cwd, ".sandcastle", "Dockerfile"));
|
|
114
115
|
const hasSchema = existsSync(join(cwd, ".env.schema"));
|
|
115
116
|
let tier = null;
|
|
117
|
+
let authMode = null;
|
|
118
|
+
let hasConflictingAuthVars = false;
|
|
116
119
|
if (hasSchema) {
|
|
117
120
|
try {
|
|
118
121
|
const schema = readFileSync(join(cwd, ".env.schema"), "utf8");
|
|
119
|
-
|
|
122
|
+
const hasApiKey = /ANTHROPIC_API_KEY\s*=\s*exec\(/.test(schema);
|
|
123
|
+
const hasOauth = /CLAUDE_CODE_OAUTH_TOKEN\s*=\s*exec\(/.test(schema);
|
|
124
|
+
if (hasApiKey && hasOauth) {
|
|
120
125
|
tier = 2;
|
|
126
|
+
hasConflictingAuthVars = true;
|
|
127
|
+
}
|
|
128
|
+
else if (hasApiKey) {
|
|
129
|
+
tier = 2;
|
|
130
|
+
authMode = "api-key";
|
|
131
|
+
}
|
|
132
|
+
else if (hasOauth) {
|
|
133
|
+
tier = 2;
|
|
134
|
+
authMode = "oauth";
|
|
121
135
|
}
|
|
122
136
|
else if (hasDockerfile) {
|
|
123
137
|
tier = 1;
|
|
@@ -130,7 +144,7 @@ function detectRepoState(cwd) {
|
|
|
130
144
|
else if (hasDockerfile) {
|
|
131
145
|
tier = 1;
|
|
132
146
|
}
|
|
133
|
-
return { tier, hasDockerfile, hasSchema };
|
|
147
|
+
return { tier, hasDockerfile, hasSchema, authMode, hasConflictingAuthVars };
|
|
134
148
|
}
|
|
135
149
|
// ---------------------------------------------------------------------------
|
|
136
150
|
// Section: Host tooling
|
|
@@ -229,12 +243,83 @@ async function checkGhAuth() {
|
|
|
229
243
|
// ---------------------------------------------------------------------------
|
|
230
244
|
// Section: Environment
|
|
231
245
|
// ---------------------------------------------------------------------------
|
|
232
|
-
function checkEnvironment(tier) {
|
|
246
|
+
async function checkEnvironment(tier, cwd, repo) {
|
|
233
247
|
const checks = new Map();
|
|
234
248
|
checks.set("LINEAR_API_KEY", envSet("LINEAR_API_KEY", "fail"));
|
|
249
|
+
// Informational: which Linear scope a `runway run` would use.
|
|
250
|
+
const team = process.env.RUNWAY_LINEAR_TEAM?.trim() || "VA";
|
|
251
|
+
const project = process.env.RUNWAY_LINEAR_PROJECT?.trim();
|
|
252
|
+
checks.set("linear_scope", {
|
|
253
|
+
status: "ok",
|
|
254
|
+
label: "linear scope",
|
|
255
|
+
detail: project
|
|
256
|
+
? `team ${team} / project ${project}`
|
|
257
|
+
: `team ${team} (team-wide — RUNWAY_LINEAR_PROJECT unset)`,
|
|
258
|
+
});
|
|
259
|
+
// Informational: which base branch a `runway run` would diff against
|
|
260
|
+
// and target with PRs. Detection failure here is a real problem —
|
|
261
|
+
// surface it as a fail so the user knows up front.
|
|
262
|
+
const override = process.env.RUNWAY_BASE_BRANCH?.trim();
|
|
263
|
+
if (override) {
|
|
264
|
+
checks.set("base_branch", {
|
|
265
|
+
status: "ok",
|
|
266
|
+
label: "base branch",
|
|
267
|
+
detail: `${override} (RUNWAY_BASE_BRANCH override)`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
try {
|
|
272
|
+
const detected = await detectBaseBranch(cwd);
|
|
273
|
+
checks.set("base_branch", {
|
|
274
|
+
status: "ok",
|
|
275
|
+
label: "base branch",
|
|
276
|
+
detail: `${detected} (detected from origin/HEAD)`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
checks.set("base_branch", {
|
|
281
|
+
status: "fail",
|
|
282
|
+
label: "base branch",
|
|
283
|
+
detail: errMsg(err),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
235
287
|
if (tier === 2) {
|
|
236
288
|
// Tier 2: needed by varlock to resolve op:// refs in the container.
|
|
237
289
|
checks.set("OP_SERVICE_ACCOUNT_TOKEN", envSet("OP_SERVICE_ACCOUNT_TOKEN", "fail"));
|
|
290
|
+
// Surface which Claude Code auth env var the .env.schema declares.
|
|
291
|
+
// ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN aren't
|
|
292
|
+
// interchangeable; a mismatch between this and what's stored in
|
|
293
|
+
// 1Password yields a generic "Invalid API key" inside the
|
|
294
|
+
// container with no useful diagnostic.
|
|
295
|
+
if (repo.hasConflictingAuthVars) {
|
|
296
|
+
checks.set("auth_mode", {
|
|
297
|
+
status: "fail",
|
|
298
|
+
label: "claude auth mode",
|
|
299
|
+
detail: ".env.schema declares both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN — pick one (they are not interchangeable)",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
else if (repo.authMode === "oauth") {
|
|
303
|
+
checks.set("auth_mode", {
|
|
304
|
+
status: "ok",
|
|
305
|
+
label: "claude auth mode",
|
|
306
|
+
detail: "oauth (CLAUDE_CODE_OAUTH_TOKEN — Pro/Max subscription)",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
else if (repo.authMode === "api-key") {
|
|
310
|
+
checks.set("auth_mode", {
|
|
311
|
+
status: "ok",
|
|
312
|
+
label: "claude auth mode",
|
|
313
|
+
detail: "api-key (ANTHROPIC_API_KEY — pay-per-token)",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
checks.set("auth_mode", {
|
|
318
|
+
status: "fail",
|
|
319
|
+
label: "claude auth mode",
|
|
320
|
+
detail: ".env.schema declares neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
238
323
|
}
|
|
239
324
|
return { title: "Environment", checks, ran: true };
|
|
240
325
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -5,6 +5,10 @@ import { execa } from "execa";
|
|
|
5
5
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
// runway/src/commands/init.ts → runway/templates/
|
|
7
7
|
const TEMPLATES_DIR = join(__dirname, "..", "..", "templates");
|
|
8
|
+
const AUTH_MODE_ENV_VAR = {
|
|
9
|
+
"api-key": "ANTHROPIC_API_KEY",
|
|
10
|
+
oauth: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
11
|
+
};
|
|
8
12
|
export function printInitUsage() {
|
|
9
13
|
console.log(`runway init — scaffold a target repo for runway consumption
|
|
10
14
|
|
|
@@ -23,8 +27,18 @@ OPTIONS
|
|
|
23
27
|
--tier=2 DEFAULT. Adds varlock + 1Password CLI inside the
|
|
24
28
|
container. Zero secrets at rest.
|
|
25
29
|
--op-vault=NAME 1Password vault name (e.g. "runway"). Required for tier 2.
|
|
26
|
-
--anthropic-item=N Item name in the vault that holds
|
|
30
|
+
--anthropic-item=N Item name in the vault that holds the Claude Code
|
|
31
|
+
credential (ANTHROPIC_API_KEY or
|
|
32
|
+
CLAUDE_CODE_OAUTH_TOKEN — see --auth-mode).
|
|
33
|
+
Required for tier 2.
|
|
27
34
|
--gh-token-item=N Item name in the vault that holds GH_TOKEN. Required for tier 2.
|
|
35
|
+
--auth-mode=MODE How Claude Code authenticates inside the
|
|
36
|
+
container. \`api-key\` (default) writes the
|
|
37
|
+
ANTHROPIC_API_KEY env var for pay-per-token API
|
|
38
|
+
keys (sk-ant-api03-…). \`oauth\` writes
|
|
39
|
+
CLAUDE_CODE_OAUTH_TOKEN for Pro/Max
|
|
40
|
+
subscription tokens from \`claude setup-token\`
|
|
41
|
+
(sk-ant-oat01-…). They are NOT interchangeable.
|
|
28
42
|
--allow-dirty Skip the "working tree clean" preflight check.
|
|
29
43
|
--force Overwrite an existing .sandcastle/Dockerfile.
|
|
30
44
|
--skip-build Don't \`docker build\` the agent image. Faster init,
|
|
@@ -35,8 +49,10 @@ NOTE
|
|
|
35
49
|
No --op-account flag — runway uses 1Password service-account auth
|
|
36
50
|
exclusively (OP_SERVICE_ACCOUNT_TOKEN). The token already encodes
|
|
37
51
|
which 1Password tenant to talk to, so the op:// URI omits the
|
|
38
|
-
account segment: \`op://<vault>/<item>\` rather than
|
|
39
|
-
\`op://<account>/<vault>/<item>\`.
|
|
52
|
+
account segment: \`op://<vault>/<item>/<field>\` rather than
|
|
53
|
+
\`op://<account>/<vault>/<item>/<field>\`. Runway hard-codes the
|
|
54
|
+
\`credential\` field, which is the canonical field name on
|
|
55
|
+
1Password API_CREDENTIAL items.
|
|
40
56
|
|
|
41
57
|
WHAT THIS COMMAND DOES
|
|
42
58
|
1. Preflight: docker, gh, node, (tier 2) varlock + op CLI, git state.
|
|
@@ -61,6 +77,7 @@ function parseInitArgs(argv) {
|
|
|
61
77
|
let opVault;
|
|
62
78
|
let anthropicItem;
|
|
63
79
|
let ghTokenItem;
|
|
80
|
+
let authMode = "api-key";
|
|
64
81
|
let allowDirty = false;
|
|
65
82
|
let force = false;
|
|
66
83
|
let skipBuild = false;
|
|
@@ -93,6 +110,13 @@ function parseInitArgs(argv) {
|
|
|
93
110
|
else if (arg.startsWith("--gh-token-item=")) {
|
|
94
111
|
ghTokenItem = arg.slice("--gh-token-item=".length);
|
|
95
112
|
}
|
|
113
|
+
else if (arg.startsWith("--auth-mode=")) {
|
|
114
|
+
const v = arg.slice("--auth-mode=".length);
|
|
115
|
+
if (v !== "api-key" && v !== "oauth") {
|
|
116
|
+
throw new Error(`--auth-mode must be "api-key" or "oauth", got "${v}"`);
|
|
117
|
+
}
|
|
118
|
+
authMode = v;
|
|
119
|
+
}
|
|
96
120
|
else {
|
|
97
121
|
throw new Error(`unknown argument: ${arg}`);
|
|
98
122
|
}
|
|
@@ -114,6 +138,7 @@ function parseInitArgs(argv) {
|
|
|
114
138
|
opVault,
|
|
115
139
|
anthropicItem,
|
|
116
140
|
ghTokenItem,
|
|
141
|
+
authMode,
|
|
117
142
|
allowDirty,
|
|
118
143
|
force,
|
|
119
144
|
skipBuild,
|
|
@@ -275,12 +300,14 @@ export async function applyVarlockLayer(cwd, opts) {
|
|
|
275
300
|
writeFileSync(`${schemaPath}.bak`, readFileSync(schemaPath, "utf8"));
|
|
276
301
|
}
|
|
277
302
|
const schemaTemplate = readFileSync(join(TEMPLATES_DIR, ".env.schema.target-repo"), "utf8");
|
|
303
|
+
const anthropicEnvVar = AUTH_MODE_ENV_VAR[opts.authMode];
|
|
278
304
|
const rendered = schemaTemplate
|
|
279
305
|
.replaceAll("{{OP_VAULT}}", opts.opVault)
|
|
280
306
|
.replaceAll("{{ANTHROPIC_ITEM}}", opts.anthropicItem)
|
|
281
|
-
.replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem)
|
|
307
|
+
.replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem)
|
|
308
|
+
.replaceAll("{{ANTHROPIC_ENV_VAR}}", anthropicEnvVar);
|
|
282
309
|
writeFileSync(schemaPath, rendered);
|
|
283
|
-
console.log(` ✓ wrote .env.schema (op://${opts.opVault}/...)`);
|
|
310
|
+
console.log(` ✓ wrote .env.schema (auth-mode=${opts.authMode}, ${anthropicEnvVar}, op://${opts.opVault}/...)`);
|
|
284
311
|
// 2. Patch Dockerfile.
|
|
285
312
|
const dockerfilePath = join(cwd, ".sandcastle", "Dockerfile");
|
|
286
313
|
if (!existsSync(dockerfilePath)) {
|
|
@@ -359,11 +386,12 @@ export async function verify(cwd, opts) {
|
|
|
359
386
|
if (!existsSync(schemaPath))
|
|
360
387
|
fail(".env.schema missing at repo root (tier 2 requires it)");
|
|
361
388
|
const schema = readFileSync(schemaPath, "utf8");
|
|
362
|
-
|
|
363
|
-
|
|
389
|
+
const anthropicEnvVar = AUTH_MODE_ENV_VAR[opts.authMode];
|
|
390
|
+
if (!schema.includes(`${anthropicEnvVar}=`))
|
|
391
|
+
fail(`.env.schema missing ${anthropicEnvVar} (auth-mode=${opts.authMode})`);
|
|
364
392
|
if (!schema.includes("GH_TOKEN="))
|
|
365
393
|
fail(".env.schema missing GH_TOKEN");
|
|
366
|
-
ok(
|
|
394
|
+
ok(`.env.schema declares ${anthropicEnvVar} + GH_TOKEN`);
|
|
367
395
|
// Inline secret shape check.
|
|
368
396
|
const secretRe = /(sk-ant-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{20,}|lin_api_[A-Za-z0-9]{20,})/;
|
|
369
397
|
if (secretRe.test(schema)) {
|
package/dist/commands/run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { loadConfig } from "../config.js";
|
|
|
2
2
|
import { createLinearGateway } from "../linear.js";
|
|
3
3
|
import { createGithubGateway } from "../github.js";
|
|
4
4
|
import { assertSandcastleInitialised, drainQueue, } from "../orchestrator.js";
|
|
5
|
-
function parseRunArgs(argv) {
|
|
5
|
+
export function parseRunArgs(argv) {
|
|
6
6
|
const opts = {};
|
|
7
7
|
for (let i = 0; i < argv.length; i += 1) {
|
|
8
8
|
const a = argv[i];
|
|
@@ -17,6 +17,16 @@ function parseRunArgs(argv) {
|
|
|
17
17
|
opts.max = n;
|
|
18
18
|
i += 1;
|
|
19
19
|
}
|
|
20
|
+
else if (a === "--project") {
|
|
21
|
+
const v = argv[i + 1];
|
|
22
|
+
if (!v)
|
|
23
|
+
throw new Error("--project requires a value");
|
|
24
|
+
opts.project = v;
|
|
25
|
+
i += 1;
|
|
26
|
+
}
|
|
27
|
+
else if (a?.startsWith("--project=")) {
|
|
28
|
+
opts.project = a.slice("--project=".length);
|
|
29
|
+
}
|
|
20
30
|
else if (a === "--help" || a === "-h") {
|
|
21
31
|
printRunUsage();
|
|
22
32
|
process.exit(0);
|
|
@@ -36,11 +46,19 @@ USAGE
|
|
|
36
46
|
|
|
37
47
|
OPTIONS
|
|
38
48
|
--max, -n N Process at most N issues then exit. Default: drain queue.
|
|
49
|
+
--project ID Scope the queue to a single Linear project under the
|
|
50
|
+
team. Accepts project UUID, slug, or name. Overrides
|
|
51
|
+
RUNWAY_LINEAR_PROJECT. Default: team-wide.
|
|
39
52
|
--help, -h Show this help.
|
|
40
53
|
|
|
41
54
|
ENVIRONMENT
|
|
42
55
|
LINEAR_API_KEY required
|
|
43
56
|
RUNWAY_LINEAR_TEAM default "VA"
|
|
57
|
+
RUNWAY_LINEAR_PROJECT optional — scope to one project
|
|
58
|
+
RUNWAY_BASE_BRANCH optional — override the auto-detected base
|
|
59
|
+
branch (the branch runway diffs against
|
|
60
|
+
and targets with PRs). Detected from
|
|
61
|
+
origin/HEAD when unset.
|
|
44
62
|
RUNWAY_READY_STATUS default "Todo"
|
|
45
63
|
RUNWAY_IN_PROGRESS_STATUS default "In Progress"
|
|
46
64
|
RUNWAY_IN_REVIEW_STATUS default "In Review"
|
|
@@ -52,10 +70,16 @@ export async function runCommand(argv) {
|
|
|
52
70
|
const opts = parseRunArgs(argv);
|
|
53
71
|
const cwd = process.cwd();
|
|
54
72
|
assertSandcastleInitialised(cwd);
|
|
55
|
-
const
|
|
73
|
+
const baseConfig = loadConfig();
|
|
74
|
+
const config = opts.project
|
|
75
|
+
? { ...baseConfig, linearProject: opts.project }
|
|
76
|
+
: baseConfig;
|
|
56
77
|
const linear = createLinearGateway(config);
|
|
57
78
|
const github = createGithubGateway();
|
|
58
|
-
|
|
79
|
+
const scope = config.linearProject
|
|
80
|
+
? `team ${config.linearTeam} / project ${config.linearProject}`
|
|
81
|
+
: `team ${config.linearTeam}`;
|
|
82
|
+
console.log(`[runway] draining queue from ${scope} (status="${config.readyStatus}") against ${cwd}`);
|
|
59
83
|
const result = await drainQueue({ config, linear, github, cwd }, { max: opts.max });
|
|
60
84
|
console.log(`[runway] done — processed=${result.processed} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
|
|
61
85
|
}
|
|
@@ -27,7 +27,9 @@ OPTIONS
|
|
|
27
27
|
lines outside the runway templates (manual edits).
|
|
28
28
|
--op-vault=NAME Override the 1Password vault. By default, upgrade-repo
|
|
29
29
|
extracts this from the existing .env.schema.
|
|
30
|
-
--anthropic-item=N Override the
|
|
30
|
+
--anthropic-item=N Override the Claude Code credential item name
|
|
31
|
+
(i.e. the ANTHROPIC_API_KEY or
|
|
32
|
+
CLAUDE_CODE_OAUTH_TOKEN 1Password item).
|
|
31
33
|
--gh-token-item=N Override the GH_TOKEN item name.
|
|
32
34
|
--help, -h Show this help.
|
|
33
35
|
|
|
@@ -118,6 +120,7 @@ export async function upgradeRepoCommand(argv) {
|
|
|
118
120
|
opVault: "placeholder",
|
|
119
121
|
anthropicItem: "placeholder",
|
|
120
122
|
ghTokenItem: "placeholder",
|
|
123
|
+
authMode: "api-key",
|
|
121
124
|
};
|
|
122
125
|
await preflight(cwd, preflightOpts);
|
|
123
126
|
// Render new file contents in memory.
|
|
@@ -165,6 +168,7 @@ export async function upgradeRepoCommand(argv) {
|
|
|
165
168
|
opVault: resolved?.opVault ?? "placeholder",
|
|
166
169
|
anthropicItem: resolved?.anthropicItem ?? "placeholder",
|
|
167
170
|
ghTokenItem: resolved?.ghTokenItem ?? "placeholder",
|
|
171
|
+
authMode: resolved?.authMode ?? "api-key",
|
|
168
172
|
};
|
|
169
173
|
await verify(cwd, verifyOpts);
|
|
170
174
|
console.log(`[runway upgrade-repo] done — tier ${tier} scaffold refreshed`);
|
|
@@ -180,8 +184,10 @@ function detectTier(cwd) {
|
|
|
180
184
|
}
|
|
181
185
|
if (existsSync(schemaPath)) {
|
|
182
186
|
const schema = readFileSync(schemaPath, "utf8");
|
|
183
|
-
// Tier-2 marker:
|
|
184
|
-
|
|
187
|
+
// Tier-2 marker: the Claude Code credential (whichever env var
|
|
188
|
+
// name the user picked at init time) uses the varlock shell-call
|
|
189
|
+
// form.
|
|
190
|
+
const tier2Marker = new RegExp(`(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)\\s*=\\s*${execName()}\\(`);
|
|
185
191
|
if (tier2Marker.test(schema)) {
|
|
186
192
|
return 2;
|
|
187
193
|
}
|
|
@@ -197,17 +203,29 @@ function execName() {
|
|
|
197
203
|
// ---------------------------------------------------------------------------
|
|
198
204
|
// op:// extraction
|
|
199
205
|
// ---------------------------------------------------------------------------
|
|
200
|
-
|
|
206
|
+
// Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN — capture which
|
|
207
|
+
// it is so upgrade-repo can re-render in the same auth mode.
|
|
208
|
+
const ANTHROPIC_RE = new RegExp(`^\\s*(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)\\s*=\\s*${execName()}\\(\\s*['"]op read "op://([^/"]+)/([^"]+)"['"]\\s*\\)\\s*$`, "m");
|
|
201
209
|
const GH_TOKEN_RE = new RegExp(`^\\s*GH_TOKEN\\s*=\\s*${execName()}\\(\\s*['"]op read "op://([^/"]+)/([^"]+)"['"]\\s*\\)\\s*$`, "m");
|
|
202
210
|
function resolveOpRefs(cwd, opts) {
|
|
203
211
|
const schemaPath = join(cwd, ".env.schema");
|
|
204
212
|
const schema = readFileSync(schemaPath, "utf8");
|
|
213
|
+
return parseOpRefs(schema, opts);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Pure schema-string → ResolvedOpRefs parser. Split out from
|
|
217
|
+
* `resolveOpRefs` so it can be unit-tested without touching the
|
|
218
|
+
* filesystem. The regex captures + override / vault-mismatch logic
|
|
219
|
+
* live here; the disk read lives in `resolveOpRefs`.
|
|
220
|
+
*/
|
|
221
|
+
export function parseOpRefs(schema, opts) {
|
|
205
222
|
const anthropicMatch = schema.match(ANTHROPIC_RE);
|
|
206
223
|
const ghTokenMatch = schema.match(GH_TOKEN_RE);
|
|
207
224
|
// Per-field override > extracted > error.
|
|
208
|
-
const opVault = opts.opVault ?? anthropicMatch?.[
|
|
209
|
-
const anthropicItem = opts.anthropicItem ?? anthropicMatch?.[
|
|
225
|
+
const opVault = opts.opVault ?? anthropicMatch?.[2] ?? ghTokenMatch?.[1] ?? null;
|
|
226
|
+
const anthropicItem = opts.anthropicItem ?? anthropicMatch?.[3] ?? null;
|
|
210
227
|
const ghTokenItem = opts.ghTokenItem ?? ghTokenMatch?.[2] ?? null;
|
|
228
|
+
const authMode = anthropicMatch?.[1] === "CLAUDE_CODE_OAUTH_TOKEN" ? "oauth" : "api-key";
|
|
211
229
|
if (!opVault || !anthropicItem || !ghTokenItem) {
|
|
212
230
|
throw new Error("could not parse existing .env.schema; pass --op-vault, --anthropic-item, --gh-token-item explicitly to override.");
|
|
213
231
|
}
|
|
@@ -217,10 +235,10 @@ function resolveOpRefs(cwd, opts) {
|
|
|
217
235
|
if (!opts.opVault &&
|
|
218
236
|
anthropicMatch &&
|
|
219
237
|
ghTokenMatch &&
|
|
220
|
-
anthropicMatch[
|
|
221
|
-
throw new Error(`vault mismatch in .env.schema:
|
|
238
|
+
anthropicMatch[2] !== ghTokenMatch[1]) {
|
|
239
|
+
throw new Error(`vault mismatch in .env.schema: ${anthropicMatch[1]} uses "${anthropicMatch[2]}", GH_TOKEN uses "${ghTokenMatch[1]}". Pass --op-vault to disambiguate.`);
|
|
222
240
|
}
|
|
223
|
-
return { opVault, anthropicItem, ghTokenItem };
|
|
241
|
+
return { opVault, anthropicItem, ghTokenItem, authMode };
|
|
224
242
|
}
|
|
225
243
|
// ---------------------------------------------------------------------------
|
|
226
244
|
// Render: Dockerfile
|
|
@@ -270,15 +288,25 @@ function renderEnvSchema(cwd, resolved) {
|
|
|
270
288
|
const schemaPath = join(cwd, ".env.schema");
|
|
271
289
|
const before = readFileSync(schemaPath, "utf8");
|
|
272
290
|
const tmpl = readFileSync(join(TEMPLATES_DIR, ".env.schema.target-repo"), "utf8");
|
|
291
|
+
const anthropicEnvVar = resolved.authMode === "oauth" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY";
|
|
273
292
|
let body = tmpl
|
|
274
293
|
.replaceAll("{{OP_VAULT}}", resolved.opVault)
|
|
275
294
|
.replaceAll("{{ANTHROPIC_ITEM}}", resolved.anthropicItem)
|
|
276
|
-
.replaceAll("{{GH_TOKEN_ITEM}}", resolved.ghTokenItem)
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
//
|
|
295
|
+
.replaceAll("{{GH_TOKEN_ITEM}}", resolved.ghTokenItem)
|
|
296
|
+
.replaceAll("{{ANTHROPIC_ENV_VAR}}", anthropicEnvVar);
|
|
297
|
+
// Preserve user-added `KEY=<call>(...)` lines for keys other than the
|
|
298
|
+
// ones we own. Match any `KEY = <execName>(` line in the existing
|
|
299
|
+
// schema and append the whole line if its key isn't the active
|
|
300
|
+
// Claude Code auth var or GH_TOKEN.
|
|
280
301
|
const userExecRe = new RegExp(`^([A-Z_][A-Z0-9_]*)\\s*=\\s*${execName()}\\(`, "gm");
|
|
281
|
-
|
|
302
|
+
// Both possible auth-mode vars are reserved so a user who switches
|
|
303
|
+
// modes doesn't end up with the dead one duplicated as a "preserved"
|
|
304
|
+
// line.
|
|
305
|
+
const ownedKeys = new Set([
|
|
306
|
+
"ANTHROPIC_API_KEY",
|
|
307
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
308
|
+
"GH_TOKEN",
|
|
309
|
+
]);
|
|
282
310
|
const preservedLines = [];
|
|
283
311
|
for (const match of before.matchAll(userExecRe)) {
|
|
284
312
|
const key = match[1];
|
package/dist/config.js
CHANGED
|
@@ -25,6 +25,24 @@ const ConfigSchema = z.object({
|
|
|
25
25
|
*/
|
|
26
26
|
opServiceAccountToken: z.string().optional(),
|
|
27
27
|
linearTeam: z.string().default("VA"),
|
|
28
|
+
/**
|
|
29
|
+
* Optional. Scopes the `runway run` queue to a single project under
|
|
30
|
+
* `linearTeam`. Resolved by Linear project ID, slug, or name. When
|
|
31
|
+
* unset, runway drains every `Todo` issue on the team (legacy
|
|
32
|
+
* behavior). Source: `RUNWAY_LINEAR_PROJECT` env var or
|
|
33
|
+
* `--project` CLI flag on `runway run`.
|
|
34
|
+
*/
|
|
35
|
+
linearProject: z.string().optional(),
|
|
36
|
+
/**
|
|
37
|
+
* Optional. Override the auto-detected base branch — the branch
|
|
38
|
+
* runway diffs against, opens PRs against, and uses to count
|
|
39
|
+
* agent-branch commits. Source: `RUNWAY_BASE_BRANCH` env var. When
|
|
40
|
+
* unset, runway resolves the default branch from `origin/HEAD` at
|
|
41
|
+
* orchestrator startup. Set this when the repo's default branch is
|
|
42
|
+
* not on the origin (rare) or when you want to target a release
|
|
43
|
+
* branch instead.
|
|
44
|
+
*/
|
|
45
|
+
baseBranch: z.string().optional(),
|
|
28
46
|
readyStatus: z.string().default("Todo"),
|
|
29
47
|
inProgressStatus: z.string().default("In Progress"),
|
|
30
48
|
inReviewStatus: z.string().default("In Review"),
|
|
@@ -36,6 +54,8 @@ export function loadConfig() {
|
|
|
36
54
|
linearApiKey: process.env.LINEAR_API_KEY,
|
|
37
55
|
opServiceAccountToken: process.env.OP_SERVICE_ACCOUNT_TOKEN,
|
|
38
56
|
linearTeam: process.env.RUNWAY_LINEAR_TEAM,
|
|
57
|
+
linearProject: process.env.RUNWAY_LINEAR_PROJECT,
|
|
58
|
+
baseBranch: process.env.RUNWAY_BASE_BRANCH,
|
|
39
59
|
readyStatus: process.env.RUNWAY_READY_STATUS,
|
|
40
60
|
inProgressStatus: process.env.RUNWAY_IN_PROGRESS_STATUS,
|
|
41
61
|
inReviewStatus: process.env.RUNWAY_IN_REVIEW_STATUS,
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the default branch name of the cwd repo. Tries
|
|
4
|
+
* `git symbolic-ref` against `origin/HEAD` first (fast, works on any
|
|
5
|
+
* clone where the symbolic ref has been set), then falls back to
|
|
6
|
+
* `git remote show origin` (slower, hits the network but works on
|
|
7
|
+
* fresh clones that never had `origin/HEAD` set locally).
|
|
8
|
+
*
|
|
9
|
+
* Throws if neither path resolves a branch name — better to fail
|
|
10
|
+
* fast at orchestrator startup than to crash mid-diff with a stale
|
|
11
|
+
* "ambiguous argument" git error.
|
|
12
|
+
*/
|
|
13
|
+
export async function detectBaseBranch(repoPath) {
|
|
14
|
+
// Fast path: local symbolic ref. Returns e.g. `origin/main` or `origin/master`.
|
|
15
|
+
try {
|
|
16
|
+
const { stdout, exitCode } = await execa("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repoPath, reject: false });
|
|
17
|
+
if (exitCode === 0) {
|
|
18
|
+
const name = stdout.trim().replace(/^origin\//, "");
|
|
19
|
+
if (name)
|
|
20
|
+
return name;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// fall through to remote-show fallback
|
|
25
|
+
}
|
|
26
|
+
// Slow path: ask the remote. Output line looks like ` HEAD branch: master`.
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execa("git", ["remote", "show", "origin"], {
|
|
29
|
+
cwd: repoPath,
|
|
30
|
+
});
|
|
31
|
+
const match = stdout.match(/^\s*HEAD branch:\s*(\S+)\s*$/m);
|
|
32
|
+
if (match?.[1])
|
|
33
|
+
return match[1];
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// fall through to error
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Could not detect the default branch of ${repoPath}. ` +
|
|
39
|
+
`Set RUNWAY_BASE_BRANCH explicitly, or run ` +
|
|
40
|
+
`\`git remote set-head origin --auto\` to populate origin/HEAD.`);
|
|
41
|
+
}
|
package/dist/github.js
CHANGED
|
@@ -12,13 +12,13 @@ export function createGithubGateway() {
|
|
|
12
12
|
stdio: "inherit",
|
|
13
13
|
});
|
|
14
14
|
},
|
|
15
|
-
async openPullRequest({ repoPath, branch, issue, body }) {
|
|
15
|
+
async openPullRequest({ repoPath, branch, base, issue, body }) {
|
|
16
16
|
const title = `${issue.identifier}: ${issue.title}`;
|
|
17
17
|
const { stdout } = await execa("gh", [
|
|
18
18
|
"pr",
|
|
19
19
|
"create",
|
|
20
20
|
"--base",
|
|
21
|
-
|
|
21
|
+
base,
|
|
22
22
|
"--head",
|
|
23
23
|
branch,
|
|
24
24
|
"--title",
|
package/dist/linear.js
CHANGED
|
@@ -25,14 +25,39 @@ export function createLinearGateway(config) {
|
|
|
25
25
|
}
|
|
26
26
|
return team.id;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a project identifier (UUID, slug, or name) to its Linear
|
|
30
|
+
* project ID. Tries each shape in order so user-facing flags like
|
|
31
|
+
* `--project=bedrock` work without forcing users to copy the UUID.
|
|
32
|
+
*/
|
|
33
|
+
async function findProjectId(identifier) {
|
|
34
|
+
const projects = await client.projects({
|
|
35
|
+
filter: {
|
|
36
|
+
or: [
|
|
37
|
+
{ id: { eq: identifier } },
|
|
38
|
+
{ slugId: { eq: identifier } },
|
|
39
|
+
{ name: { eq: identifier } },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const project = projects.nodes[0];
|
|
44
|
+
if (!project) {
|
|
45
|
+
throw new Error(`Linear project "${identifier}" not found`);
|
|
46
|
+
}
|
|
47
|
+
return project.id;
|
|
48
|
+
}
|
|
28
49
|
return {
|
|
29
50
|
async fetchReady() {
|
|
30
51
|
const teamId = await findTeamId();
|
|
31
52
|
const readyStateId = await findStateId(teamId, config.readyStatus);
|
|
53
|
+
const projectId = config.linearProject
|
|
54
|
+
? await findProjectId(config.linearProject)
|
|
55
|
+
: null;
|
|
32
56
|
const issues = await client.issues({
|
|
33
57
|
filter: {
|
|
34
58
|
team: { id: { eq: teamId } },
|
|
35
59
|
state: { id: { eq: readyStateId } },
|
|
60
|
+
...(projectId ? { project: { id: { eq: projectId } } } : {}),
|
|
36
61
|
},
|
|
37
62
|
// Stable order: oldest first so the queue drains FIFO.
|
|
38
63
|
orderBy: "createdAt",
|
package/dist/orchestrator.js
CHANGED
|
@@ -4,6 +4,7 @@ import { run, claudeCode } from "@ai-hero/sandcastle";
|
|
|
4
4
|
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
5
5
|
import { execa } from "execa";
|
|
6
6
|
import { implementVars, loadImplementPrompt, loadReviewPrompt, renderPrompt, reviewVars, } from "./prompts.js";
|
|
7
|
+
import { detectBaseBranch } from "./git.js";
|
|
7
8
|
const REVIEW_VERDICT_RE = /^REVIEW:\s*(APPROVED|REJECTED)(?:\s+—\s+(.*))?$/m;
|
|
8
9
|
/**
|
|
9
10
|
* Confirms the cwd looks like a sandcastle-initialised repo. If not,
|
|
@@ -27,13 +28,19 @@ export async function drainQueue(deps, opts = {}) {
|
|
|
27
28
|
let opened = 0;
|
|
28
29
|
let hitl = 0;
|
|
29
30
|
let errored = 0;
|
|
31
|
+
// Resolve the base branch once at startup so every issue in the
|
|
32
|
+
// drain sees the same answer (and so a misconfigured repo fails
|
|
33
|
+
// fast, before we touch any Linear state).
|
|
34
|
+
const baseBranch = config.baseBranch ?? (await detectBaseBranch(deps.cwd));
|
|
35
|
+
console.log(`[runway] base branch resolved to "${baseBranch}"`);
|
|
36
|
+
const runDeps = { ...deps, baseBranch };
|
|
30
37
|
while (processed < max) {
|
|
31
38
|
const queue = await linear.fetchReady();
|
|
32
39
|
if (queue.length === 0)
|
|
33
40
|
break;
|
|
34
41
|
const issue = queue[0];
|
|
35
42
|
try {
|
|
36
|
-
const verdict = await processIssue(issue,
|
|
43
|
+
const verdict = await processIssue(issue, runDeps);
|
|
37
44
|
processed += 1;
|
|
38
45
|
if (verdict === "opened")
|
|
39
46
|
opened += 1;
|
|
@@ -43,18 +50,36 @@ export async function drainQueue(deps, opts = {}) {
|
|
|
43
50
|
catch (err) {
|
|
44
51
|
errored += 1;
|
|
45
52
|
console.error(`[runway] error on ${issue.identifier}:`, err);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
// If the agent crashed before producing any commits (missing
|
|
54
|
+
// image, varlock validation, container failed to boot, etc.),
|
|
55
|
+
// it's an infrastructure failure — not a HITL. Revert the issue
|
|
56
|
+
// to `Todo` and skip the `needs-human` label so the next run
|
|
57
|
+
// can pick it up cleanly. `In Progress` is reserved for "agent
|
|
58
|
+
// has committed to the branch".
|
|
59
|
+
const branch = `agent/${issue.identifier.toLowerCase()}`;
|
|
60
|
+
const startedRealWork = await hasCommits(deps.cwd, baseBranch, branch);
|
|
61
|
+
if (!startedRealWork) {
|
|
62
|
+
await linear
|
|
63
|
+
.transition(issue.id, config.readyStatus)
|
|
64
|
+
.catch(() => undefined);
|
|
65
|
+
await linear
|
|
66
|
+
.comment(issue.id, `Runway hit a startup failure before the agent produced any commits — reverting to \`${config.readyStatus}\` for retry:\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``)
|
|
67
|
+
.catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
await linear
|
|
71
|
+
.applyLabel(issue.id, config.hitlLabel)
|
|
72
|
+
.catch(() => undefined);
|
|
73
|
+
await linear
|
|
74
|
+
.comment(issue.id, `Runway hit an unrecoverable error and flagged for human review:\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``)
|
|
75
|
+
.catch(() => undefined);
|
|
76
|
+
}
|
|
52
77
|
}
|
|
53
78
|
}
|
|
54
79
|
return { processed, opened, hitl, errored };
|
|
55
80
|
}
|
|
56
81
|
async function processIssue(issue, deps) {
|
|
57
|
-
const { config, linear, github, cwd } = deps;
|
|
82
|
+
const { config, linear, github, cwd, baseBranch } = deps;
|
|
58
83
|
const branch = `agent/${issue.identifier.toLowerCase()}`;
|
|
59
84
|
await linear.transition(issue.id, config.inProgressStatus);
|
|
60
85
|
await linear.comment(issue.id, `Runway picked up this issue. Branch: \`${branch}\`.`);
|
|
@@ -76,8 +101,8 @@ async function processIssue(issue, deps) {
|
|
|
76
101
|
return "hitl";
|
|
77
102
|
}
|
|
78
103
|
// 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);
|
|
104
|
+
const diff = await captureDiff(cwd, baseBranch, branch);
|
|
105
|
+
const commitLog = await captureCommitLog(cwd, baseBranch, branch);
|
|
81
106
|
const reviewPrompt = renderPrompt(await loadReviewPrompt(), reviewVars({ issue, diff, commits: commitLog }));
|
|
82
107
|
const reviewResult = await run({
|
|
83
108
|
agent: claudeCode("claude-opus-4-6"),
|
|
@@ -101,6 +126,7 @@ async function processIssue(issue, deps) {
|
|
|
101
126
|
const prUrl = await github.openPullRequest({
|
|
102
127
|
repoPath: cwd,
|
|
103
128
|
branch,
|
|
129
|
+
base: baseBranch,
|
|
104
130
|
issue,
|
|
105
131
|
body: prBody,
|
|
106
132
|
});
|
|
@@ -113,15 +139,31 @@ async function flagHitl(issue, deps, reason) {
|
|
|
113
139
|
await linear.applyLabel(issue.id, config.hitlLabel);
|
|
114
140
|
await linear.comment(issue.id, `Runway flagged for human review: ${reason}`);
|
|
115
141
|
}
|
|
116
|
-
|
|
117
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Whether the agent branch has any commits beyond `base`. Used by the
|
|
144
|
+
* drain loop to distinguish "agent crashed mid-run, after producing
|
|
145
|
+
* real work" (→ HITL) from "agent crashed during startup, no work
|
|
146
|
+
* done" (→ revert to Todo). If the branch doesn't exist or git fails,
|
|
147
|
+
* treat as "no commits" so we revert rather than strand the issue.
|
|
148
|
+
*/
|
|
149
|
+
async function hasCommits(repoPath, base, branch) {
|
|
150
|
+
try {
|
|
151
|
+
const { stdout } = await execa("git", ["rev-list", "--count", `${base}..${branch}`], { cwd: repoPath, reject: false });
|
|
152
|
+
return Number.parseInt(stdout.trim(), 10) > 0;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function captureDiff(repoPath, base, branch) {
|
|
159
|
+
const { stdout } = await execa("git", ["diff", `${base}...${branch}`], {
|
|
118
160
|
cwd: repoPath,
|
|
119
161
|
});
|
|
120
162
|
// Truncate to keep the review prompt under the model's context budget.
|
|
121
163
|
return stdout.length > 60_000 ? `${stdout.slice(0, 60_000)}\n…(truncated)` : stdout;
|
|
122
164
|
}
|
|
123
|
-
async function captureCommitLog(repoPath, branch) {
|
|
124
|
-
const { stdout } = await execa("git", ["log", "--oneline",
|
|
165
|
+
async function captureCommitLog(repoPath, base, branch) {
|
|
166
|
+
const { stdout } = await execa("git", ["log", "--oneline", `${base}..${branch}`], { cwd: repoPath });
|
|
125
167
|
return stdout;
|
|
126
168
|
}
|
|
127
169
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valescoagency/runway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -45,9 +45,13 @@
|
|
|
45
45
|
"zod": "^3.23.8"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
+
"@commitlint/cli": "^21.0.0",
|
|
49
|
+
"@commitlint/config-conventional": "^21.0.0",
|
|
48
50
|
"@types/node": "^22.10.0",
|
|
51
|
+
"lefthook": "^2.1.6",
|
|
49
52
|
"tsx": "^4.19.2",
|
|
50
|
-
"typescript": "^5.7.2"
|
|
53
|
+
"typescript": "^5.7.2",
|
|
54
|
+
"vitest": "^4.1.5"
|
|
51
55
|
},
|
|
52
56
|
"engines": {
|
|
53
57
|
"node": ">=22"
|
|
@@ -56,9 +60,11 @@
|
|
|
56
60
|
"access": "public"
|
|
57
61
|
},
|
|
58
62
|
"scripts": {
|
|
59
|
-
"build": "tsc && chmod +x dist/cli.js",
|
|
63
|
+
"build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
60
64
|
"typecheck": "tsc --noEmit",
|
|
61
65
|
"dev": "tsx src/cli.ts",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"test:watch": "vitest",
|
|
62
68
|
"lint": "echo 'lint not configured yet'"
|
|
63
69
|
}
|
|
64
70
|
}
|
|
@@ -14,19 +14,24 @@
|
|
|
14
14
|
#
|
|
15
15
|
# Note on the op:// shape: with service-account auth (the only mode
|
|
16
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
|
-
#
|
|
21
|
-
ANTHROPIC_API_KEY
|
|
17
|
+
# URI omits the account segment — `op://<vault>/<item>/<field>`, not
|
|
18
|
+
# `op://<account>/<vault>/<item>/<field>`. For API_CREDENTIAL items
|
|
19
|
+
# (the natural category for API keys), the field is `credential`.
|
|
20
|
+
#
|
|
21
|
+
# Note on Claude Code auth: ANTHROPIC_API_KEY is a pay-per-token API
|
|
22
|
+
# key (sk-ant-api03-…). CLAUDE_CODE_OAUTH_TOKEN is a Pro/Max
|
|
23
|
+
# subscription token from `claude setup-token` (sk-ant-oat01-…). They
|
|
24
|
+
# are NOT interchangeable. Runway init writes whichever the user
|
|
25
|
+
# selected with --auth-mode; see runway's README "Claude Code auth"
|
|
26
|
+
# section for details.
|
|
27
|
+
#
|
|
28
|
+
# To add another secret, copy one of the two live entries below. Do
|
|
29
|
+
# NOT leave a commented-out example block here: varlock parses any
|
|
30
|
+
# `# @decorator` line as a real decorator, and a decorator with no
|
|
31
|
+
# attached config line fails validation ("detached comment block").
|
|
22
32
|
|
|
23
33
|
# @sensitive @required
|
|
24
|
-
|
|
34
|
+
{{ANTHROPIC_ENV_VAR}}=exec('op read "op://{{OP_VAULT}}/{{ANTHROPIC_ITEM}}/credential"')
|
|
25
35
|
|
|
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
36
|
# @sensitive @required
|
|
32
|
-
|
|
37
|
+
GH_TOKEN=exec('op read "op://{{OP_VAULT}}/{{GH_TOKEN_ITEM}}/credential"')
|