@valescoagency/runway 0.2.0 → 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 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
- - Anthropic API key (set in the **target repo's** `.sandcastle/.env`,
65
- not in runway's env Sandcastle reads it)
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,7 +75,8 @@ 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
@@ -92,6 +97,37 @@ and no varlock (faster but secrets land on disk).
92
97
 
93
98
  Architecture walkthrough: [`docs/secrets-with-varlock.md`](docs/secrets-with-varlock.md).
94
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
+
95
131
  ## Secrets — recommended: varlock + 1Password
96
132
 
97
133
  If you don't want any secret sitting at rest in any `.env` file,
@@ -121,6 +157,7 @@ export LINEAR_API_KEY=lin_api_...
121
157
  # Optional overrides:
122
158
  # export RUNWAY_LINEAR_TEAM=VA
123
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
124
161
  # export RUNWAY_READY_STATUS="Todo"
125
162
  # export RUNWAY_IN_PROGRESS_STATUS="In Progress"
126
163
  # export RUNWAY_IN_REVIEW_STATUS="In Review"
@@ -140,6 +177,38 @@ pnpm link --global # so `runway` is on your $PATH
140
177
 
141
178
  `pnpm dev -- <args>` runs the TypeScript source via `tsx` without building, useful while iterating on runway itself.
142
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
+
143
212
  ## Usage
144
213
 
145
214
  ```bash
@@ -177,6 +246,21 @@ These names are configurable per env var; the queries match by name so
177
246
  your Linear workspace's actual state names need to line up with what
178
247
  you set.
179
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
+
180
264
  ## Sub-agent review
181
265
 
182
266
  Every implementation run is followed by a fresh Sandcastle run with
@@ -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
- if (/ANTHROPIC_API_KEY\s*=\s*exec\(/.test(schema)) {
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,7 +243,7 @@ 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"));
235
249
  // Informational: which Linear scope a `runway run` would use.
@@ -242,9 +256,70 @@ function checkEnvironment(tier) {
242
256
  ? `team ${team} / project ${project}`
243
257
  : `team ${team} (team-wide — RUNWAY_LINEAR_PROJECT unset)`,
244
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
+ }
245
287
  if (tier === 2) {
246
288
  // Tier 2: needed by varlock to resolve op:// refs in the container.
247
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
+ }
248
323
  }
249
324
  return { title: "Environment", checks, ran: true };
250
325
  }
@@ -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 ANTHROPIC_API_KEY. Required for tier 2.
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,
@@ -63,6 +77,7 @@ function parseInitArgs(argv) {
63
77
  let opVault;
64
78
  let anthropicItem;
65
79
  let ghTokenItem;
80
+ let authMode = "api-key";
66
81
  let allowDirty = false;
67
82
  let force = false;
68
83
  let skipBuild = false;
@@ -95,6 +110,13 @@ function parseInitArgs(argv) {
95
110
  else if (arg.startsWith("--gh-token-item=")) {
96
111
  ghTokenItem = arg.slice("--gh-token-item=".length);
97
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
+ }
98
120
  else {
99
121
  throw new Error(`unknown argument: ${arg}`);
100
122
  }
@@ -116,6 +138,7 @@ function parseInitArgs(argv) {
116
138
  opVault,
117
139
  anthropicItem,
118
140
  ghTokenItem,
141
+ authMode,
119
142
  allowDirty,
120
143
  force,
121
144
  skipBuild,
@@ -277,12 +300,14 @@ export async function applyVarlockLayer(cwd, opts) {
277
300
  writeFileSync(`${schemaPath}.bak`, readFileSync(schemaPath, "utf8"));
278
301
  }
279
302
  const schemaTemplate = readFileSync(join(TEMPLATES_DIR, ".env.schema.target-repo"), "utf8");
303
+ const anthropicEnvVar = AUTH_MODE_ENV_VAR[opts.authMode];
280
304
  const rendered = schemaTemplate
281
305
  .replaceAll("{{OP_VAULT}}", opts.opVault)
282
306
  .replaceAll("{{ANTHROPIC_ITEM}}", opts.anthropicItem)
283
- .replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem);
307
+ .replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem)
308
+ .replaceAll("{{ANTHROPIC_ENV_VAR}}", anthropicEnvVar);
284
309
  writeFileSync(schemaPath, rendered);
285
- console.log(` ✓ wrote .env.schema (op://${opts.opVault}/...)`);
310
+ console.log(` ✓ wrote .env.schema (auth-mode=${opts.authMode}, ${anthropicEnvVar}, op://${opts.opVault}/...)`);
286
311
  // 2. Patch Dockerfile.
287
312
  const dockerfilePath = join(cwd, ".sandcastle", "Dockerfile");
288
313
  if (!existsSync(dockerfilePath)) {
@@ -361,11 +386,12 @@ export async function verify(cwd, opts) {
361
386
  if (!existsSync(schemaPath))
362
387
  fail(".env.schema missing at repo root (tier 2 requires it)");
363
388
  const schema = readFileSync(schemaPath, "utf8");
364
- if (!schema.includes("ANTHROPIC_API_KEY="))
365
- fail(".env.schema missing ANTHROPIC_API_KEY");
389
+ const anthropicEnvVar = AUTH_MODE_ENV_VAR[opts.authMode];
390
+ if (!schema.includes(`${anthropicEnvVar}=`))
391
+ fail(`.env.schema missing ${anthropicEnvVar} (auth-mode=${opts.authMode})`);
366
392
  if (!schema.includes("GH_TOKEN="))
367
393
  fail(".env.schema missing GH_TOKEN");
368
- ok(".env.schema declares ANTHROPIC_API_KEY + GH_TOKEN");
394
+ ok(`.env.schema declares ${anthropicEnvVar} + GH_TOKEN`);
369
395
  // Inline secret shape check.
370
396
  const secretRe = /(sk-ant-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{20,}|lin_api_[A-Za-z0-9]{20,})/;
371
397
  if (secretRe.test(schema)) {
@@ -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];
@@ -55,6 +55,10 @@ ENVIRONMENT
55
55
  LINEAR_API_KEY required
56
56
  RUNWAY_LINEAR_TEAM default "VA"
57
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.
58
62
  RUNWAY_READY_STATUS default "Todo"
59
63
  RUNWAY_IN_PROGRESS_STATUS default "In Progress"
60
64
  RUNWAY_IN_REVIEW_STATUS default "In Review"
@@ -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 ANTHROPIC_API_KEY item name.
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: ANTHROPIC_API_KEY uses the varlock shell-call form.
184
- const tier2Marker = new RegExp(`ANTHROPIC_API_KEY\\s*=\\s*${execName()}\\(`);
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
- const ANTHROPIC_RE = new RegExp(`^\\s*ANTHROPIC_API_KEY\\s*=\\s*${execName()}\\(\\s*['"]op read "op://([^/"]+)/([^"]+)"['"]\\s*\\)\\s*$`, "m");
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?.[1] ?? ghTokenMatch?.[1] ?? null;
209
- const anthropicItem = opts.anthropicItem ?? anthropicMatch?.[2] ?? null;
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[1] !== ghTokenMatch[1]) {
221
- throw new Error(`vault mismatch in .env.schema: ANTHROPIC_API_KEY uses "${anthropicMatch[1]}", GH_TOKEN uses "${ghTokenMatch[1]}". Pass --op-vault to disambiguate.`);
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
- // Preserve user-added `KEY=<call>(...)` lines for keys other than the two
278
- // we own. Match any `KEY = <execName>(` line in the existing schema and
279
- // append the whole line if its key isn't ANTHROPIC_API_KEY or GH_TOKEN.
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
- const ownedKeys = new Set(["ANTHROPIC_API_KEY", "GH_TOKEN"]);
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
@@ -33,6 +33,16 @@ const ConfigSchema = z.object({
33
33
  * `--project` CLI flag on `runway run`.
34
34
  */
35
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(),
36
46
  readyStatus: z.string().default("Todo"),
37
47
  inProgressStatus: z.string().default("In Progress"),
38
48
  inReviewStatus: z.string().default("In Review"),
@@ -45,6 +55,7 @@ export function loadConfig() {
45
55
  opServiceAccountToken: process.env.OP_SERVICE_ACCOUNT_TOKEN,
46
56
  linearTeam: process.env.RUNWAY_LINEAR_TEAM,
47
57
  linearProject: process.env.RUNWAY_LINEAR_PROJECT,
58
+ baseBranch: process.env.RUNWAY_BASE_BRANCH,
48
59
  readyStatus: process.env.RUNWAY_READY_STATUS,
49
60
  inProgressStatus: process.env.RUNWAY_IN_PROGRESS_STATUS,
50
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
- "main",
21
+ base,
22
22
  "--head",
23
23
  branch,
24
24
  "--title",
@@ -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, deps);
43
+ const verdict = await processIssue(issue, runDeps);
37
44
  processed += 1;
38
45
  if (verdict === "opened")
39
46
  opened += 1;
@@ -50,7 +57,7 @@ export async function drainQueue(deps, opts = {}) {
50
57
  // can pick it up cleanly. `In Progress` is reserved for "agent
51
58
  // has committed to the branch".
52
59
  const branch = `agent/${issue.identifier.toLowerCase()}`;
53
- const startedRealWork = await hasCommits(deps.cwd, branch);
60
+ const startedRealWork = await hasCommits(deps.cwd, baseBranch, branch);
54
61
  if (!startedRealWork) {
55
62
  await linear
56
63
  .transition(issue.id, config.readyStatus)
@@ -72,7 +79,7 @@ export async function drainQueue(deps, opts = {}) {
72
79
  return { processed, opened, hitl, errored };
73
80
  }
74
81
  async function processIssue(issue, deps) {
75
- const { config, linear, github, cwd } = deps;
82
+ const { config, linear, github, cwd, baseBranch } = deps;
76
83
  const branch = `agent/${issue.identifier.toLowerCase()}`;
77
84
  await linear.transition(issue.id, config.inProgressStatus);
78
85
  await linear.comment(issue.id, `Runway picked up this issue. Branch: \`${branch}\`.`);
@@ -94,8 +101,8 @@ async function processIssue(issue, deps) {
94
101
  return "hitl";
95
102
  }
96
103
  // 2. Review pass — read-only-ish, just looking at the diff.
97
- const diff = await captureDiff(cwd, branch);
98
- const commitLog = await captureCommitLog(cwd, branch);
104
+ const diff = await captureDiff(cwd, baseBranch, branch);
105
+ const commitLog = await captureCommitLog(cwd, baseBranch, branch);
99
106
  const reviewPrompt = renderPrompt(await loadReviewPrompt(), reviewVars({ issue, diff, commits: commitLog }));
100
107
  const reviewResult = await run({
101
108
  agent: claudeCode("claude-opus-4-6"),
@@ -119,6 +126,7 @@ async function processIssue(issue, deps) {
119
126
  const prUrl = await github.openPullRequest({
120
127
  repoPath: cwd,
121
128
  branch,
129
+ base: baseBranch,
122
130
  issue,
123
131
  body: prBody,
124
132
  });
@@ -132,30 +140,30 @@ async function flagHitl(issue, deps, reason) {
132
140
  await linear.comment(issue.id, `Runway flagged for human review: ${reason}`);
133
141
  }
134
142
  /**
135
- * Whether the agent branch has any commits beyond `main`. Used by the
143
+ * Whether the agent branch has any commits beyond `base`. Used by the
136
144
  * drain loop to distinguish "agent crashed mid-run, after producing
137
145
  * real work" (→ HITL) from "agent crashed during startup, no work
138
146
  * done" (→ revert to Todo). If the branch doesn't exist or git fails,
139
147
  * treat as "no commits" so we revert rather than strand the issue.
140
148
  */
141
- async function hasCommits(repoPath, branch) {
149
+ async function hasCommits(repoPath, base, branch) {
142
150
  try {
143
- const { stdout } = await execa("git", ["rev-list", "--count", `main..${branch}`], { cwd: repoPath, reject: false });
151
+ const { stdout } = await execa("git", ["rev-list", "--count", `${base}..${branch}`], { cwd: repoPath, reject: false });
144
152
  return Number.parseInt(stdout.trim(), 10) > 0;
145
153
  }
146
154
  catch {
147
155
  return false;
148
156
  }
149
157
  }
150
- async function captureDiff(repoPath, branch) {
151
- const { stdout } = await execa("git", ["diff", `main...${branch}`], {
158
+ async function captureDiff(repoPath, base, branch) {
159
+ const { stdout } = await execa("git", ["diff", `${base}...${branch}`], {
152
160
  cwd: repoPath,
153
161
  });
154
162
  // Truncate to keep the review prompt under the model's context budget.
155
163
  return stdout.length > 60_000 ? `${stdout.slice(0, 60_000)}\n…(truncated)` : stdout;
156
164
  }
157
- async function captureCommitLog(repoPath, branch) {
158
- const { stdout } = await execa("git", ["log", "--oneline", `main..${branch}`], { cwd: repoPath });
165
+ async function captureCommitLog(repoPath, base, branch) {
166
+ const { stdout } = await execa("git", ["log", "--oneline", `${base}..${branch}`], { cwd: repoPath });
159
167
  return stdout;
160
168
  }
161
169
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valescoagency/runway",
3
- "version": "0.2.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
  }
@@ -18,13 +18,20 @@
18
18
  # `op://<account>/<vault>/<item>/<field>`. For API_CREDENTIAL items
19
19
  # (the natural category for API keys), the field is `credential`.
20
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
+ #
21
28
  # To add another secret, copy one of the two live entries below. Do
22
29
  # NOT leave a commented-out example block here: varlock parses any
23
30
  # `# @decorator` line as a real decorator, and a decorator with no
24
31
  # attached config line fails validation ("detached comment block").
25
32
 
26
33
  # @sensitive @required
27
- ANTHROPIC_API_KEY=exec('op read "op://{{OP_VAULT}}/{{ANTHROPIC_ITEM}}/credential"')
34
+ {{ANTHROPIC_ENV_VAR}}=exec('op read "op://{{OP_VAULT}}/{{ANTHROPIC_ITEM}}/credential"')
28
35
 
29
36
  # @sensitive @required
30
37
  GH_TOKEN=exec('op read "op://{{OP_VAULT}}/{{GH_TOKEN_ITEM}}/credential"')