@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.
- package/README.md +109 -9
- package/dist/commands/doctor.js +282 -6
- package/dist/commands/init.js +32 -6
- package/dist/commands/run.js +37 -5
- package/dist/commands/upgrade-repo.js +42 -14
- package/dist/config.js +18 -1
- package/dist/git.js +41 -0
- package/dist/github.js +2 -2
- package/dist/linear.js +41 -0
- package/dist/orchestrator.js +262 -57
- package/dist/policy.js +76 -0
- package/dist/prompts.js +44 -1
- package/package.json +10 -3
- package/prompts/implement.md +46 -2
- package/templates/.env.schema.target-repo +8 -1
- package/templates/Dockerfile.claude-code.base +24 -0
package/dist/commands/run.js
CHANGED
|
@@ -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
|
|
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 "
|
|
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 —
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|