@valescoagency/runway 0.2.0 → 0.4.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.
@@ -2,8 +2,18 @@ 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
+ const collectAllow = (raw) => {
8
+ const paths = raw
9
+ .split(",")
10
+ .map((s) => s.trim())
11
+ .filter(Boolean);
12
+ if (paths.length === 0) {
13
+ throw new Error("--allow-paths requires at least one glob");
14
+ }
15
+ opts.allowPaths = [...(opts.allowPaths ?? []), ...paths];
16
+ };
7
17
  for (let i = 0; i < argv.length; i += 1) {
8
18
  const a = argv[i];
9
19
  if (a === "--max" || a === "-n") {
@@ -27,6 +37,16 @@ function parseRunArgs(argv) {
27
37
  else if (a?.startsWith("--project=")) {
28
38
  opts.project = a.slice("--project=".length);
29
39
  }
40
+ else if (a === "--allow-paths") {
41
+ const v = argv[i + 1];
42
+ if (!v)
43
+ throw new Error("--allow-paths requires a value");
44
+ collectAllow(v);
45
+ i += 1;
46
+ }
47
+ else if (a?.startsWith("--allow-paths=")) {
48
+ collectAllow(a.slice("--allow-paths=".length));
49
+ }
30
50
  else if (a === "--help" || a === "-h") {
31
51
  printRunUsage();
32
52
  process.exit(0);
@@ -45,20 +65,32 @@ USAGE
45
65
  runway run [--max N]
46
66
 
47
67
  OPTIONS
48
- --max, -n N Process at most N issues then exit. Default: drain queue.
68
+ --max, -n N Attempt at most N issues then exit (counts every
69
+ attempt — success, HITL, or revert-to-Todo).
70
+ Default: drain queue.
49
71
  --project ID Scope the queue to a single Linear project under the
50
72
  team. Accepts project UUID, slug, or name. Overrides
51
73
  RUNWAY_LINEAR_PROJECT. Default: team-wide.
74
+ --allow-paths GLOBS
75
+ Comma-separated globs removed from the impl policy's
76
+ forbidden-paths list for this invocation only.
77
+ Example: --allow-paths='.github/workflows/**' lets
78
+ the agent touch CI for issues whose AC require it.
79
+ Repeatable; pairs with .runway/policy.yml.
52
80
  --help, -h Show this help.
53
81
 
54
82
  ENVIRONMENT
55
83
  LINEAR_API_KEY required
56
84
  RUNWAY_LINEAR_TEAM default "VA"
57
85
  RUNWAY_LINEAR_PROJECT optional — scope to one project
86
+ RUNWAY_BASE_BRANCH optional — override the auto-detected base
87
+ branch (the branch runway diffs against
88
+ and targets with PRs). Detected from
89
+ origin/HEAD when unset.
58
90
  RUNWAY_READY_STATUS default "Todo"
59
91
  RUNWAY_IN_PROGRESS_STATUS default "In Progress"
60
92
  RUNWAY_IN_REVIEW_STATUS default "In Review"
61
- RUNWAY_HITL_LABEL default "needs-human"
93
+ RUNWAY_HITL_LABEL default "ready-for-human"
62
94
  RUNWAY_MAX_ITERATIONS default 5
63
95
  `);
64
96
  }
@@ -76,6 +108,6 @@ export async function runCommand(argv) {
76
108
  ? `team ${config.linearTeam} / project ${config.linearProject}`
77
109
  : `team ${config.linearTeam}`;
78
110
  console.log(`[runway] draining queue from ${scope} (status="${config.readyStatus}") against ${cwd}`);
79
- const result = await drainQueue({ config, linear, github, cwd }, { max: opts.max });
80
- console.log(`[runway] done — processed=${result.processed} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
111
+ const result = await drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths });
112
+ console.log(`[runway] done — attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
81
113
  }
@@ -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,10 +33,26 @@ 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"),
39
- hitlLabel: z.string().default("needs-human"),
49
+ // VA-354: default to the Flightplan canonical state label
50
+ // `ready-for-human`. The previous default (`needs-human`) doesn't
51
+ // exist on Flightplan-aligned Linear workspaces (the common case
52
+ // for Valesco repos), and `linear.applyLabel` failures cascaded
53
+ // into the substantive rejection reason being lost. Workspaces that
54
+ // use a different label override via `RUNWAY_HITL_LABEL`.
55
+ hitlLabel: z.string().default("ready-for-human"),
40
56
  maxIterations: z.coerce.number().int().positive().default(5),
41
57
  });
42
58
  export function loadConfig() {
@@ -45,6 +61,7 @@ export function loadConfig() {
45
61
  opServiceAccountToken: process.env.OP_SERVICE_ACCOUNT_TOKEN,
46
62
  linearTeam: process.env.RUNWAY_LINEAR_TEAM,
47
63
  linearProject: process.env.RUNWAY_LINEAR_PROJECT,
64
+ baseBranch: process.env.RUNWAY_BASE_BRANCH,
48
65
  readyStatus: process.env.RUNWAY_READY_STATUS,
49
66
  inProgressStatus: process.env.RUNWAY_IN_PROGRESS_STATUS,
50
67
  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",
package/dist/linear.js CHANGED
@@ -104,3 +104,44 @@ export function createLinearGateway(config) {
104
104
  },
105
105
  };
106
106
  }
107
+ export async function validateLinearConfig(config) {
108
+ const client = new LinearClient({ apiKey: config.linearApiKey });
109
+ const teams = await client.teams({
110
+ filter: { key: { eq: config.linearTeam } },
111
+ });
112
+ const team = teams.nodes[0];
113
+ if (!team) {
114
+ return {
115
+ team: { kind: "missing", key: config.linearTeam },
116
+ readyStatus: { kind: "skipped", reason: "team missing" },
117
+ inProgressStatus: { kind: "skipped", reason: "team missing" },
118
+ inReviewStatus: { kind: "skipped", reason: "team missing" },
119
+ hitlLabel: { kind: "skipped", reason: "team missing" },
120
+ };
121
+ }
122
+ const states = await client.workflowStates({
123
+ filter: { team: { id: { eq: team.id } } },
124
+ });
125
+ const stateNames = states.nodes.map((s) => s.name);
126
+ const checkState = (want) => stateNames.includes(want)
127
+ ? { kind: "ok", name: want }
128
+ : { kind: "missing", name: want, available: stateNames };
129
+ const labels = await client.issueLabels({
130
+ filter: { team: { id: { eq: team.id } } },
131
+ });
132
+ const labelNames = labels.nodes.map((l) => l.name);
133
+ const hitlLabel = labelNames.includes(config.hitlLabel)
134
+ ? { kind: "ok", name: config.hitlLabel }
135
+ : {
136
+ kind: "missing",
137
+ name: config.hitlLabel,
138
+ available: labelNames.slice(0, 50),
139
+ };
140
+ return {
141
+ team: { kind: "ok", id: team.id },
142
+ readyStatus: checkState(config.readyStatus),
143
+ inProgressStatus: checkState(config.inProgressStatus),
144
+ inReviewStatus: checkState(config.inReviewStatus),
145
+ hitlLabel,
146
+ };
147
+ }