@valescoagency/runway 0.1.1 → 0.2.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
@@ -1,5 +1,7 @@
1
1
  # runway
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@valescoagency/runway?logo=npm)](https://www.npmjs.com/package/@valescoagency/runway) [![License](https://img.shields.io/npm/l/@valescoagency/runway)](https://github.com/ValescoAgency/runway/blob/master/LICENSE) [![CI status](https://img.shields.io/github/actions/workflow/status/ValescoAgency/runway/release.yml?label=release)](https://github.com/ValescoAgency/runway/actions/workflows/release.yml) [![Provenance](https://img.shields.io/badge/provenance-signed-blue?logo=sigstore)](https://www.npmjs.com/package/@valescoagency/runway)
4
+
3
5
  A small CLI for two jobs: **scaffold** a target repo for autonomous
4
6
  coding-agent runs, then **drain** a Linear queue against it. Wraps
5
7
  [Sandcastle](https://github.com/mattpocock/sandcastle) (Claude Code
@@ -75,7 +77,9 @@ runway init \
75
77
  (No `--op-account` — runway uses 1Password service-account auth
76
78
  (`OP_SERVICE_ACCOUNT_TOKEN`) exclusively, and the token already
77
79
  encodes the tenant. `op://` URIs runway writes are
78
- `op://<vault>/<item>`, not `op://<account>/<vault>/<item>`.)
80
+ `op://<vault>/<item>/credential`, not `op://<account>/<vault>/<item>`.
81
+ The `/credential` field selector is required for `API_CREDENTIAL`
82
+ items, which is the canonical 1Password category for API keys.)
79
83
 
80
84
  This runs `npx sandcastle init`, patches the generated `.sandcastle/Dockerfile`
81
85
  to bake in `varlock` + the 1Password CLI + a `claude` shim, scaffolds
@@ -116,6 +120,7 @@ Export runway's own env (in your shell rc, or wherever you keep API keys):
116
120
  export LINEAR_API_KEY=lin_api_...
117
121
  # Optional overrides:
118
122
  # export RUNWAY_LINEAR_TEAM=VA
123
+ # export RUNWAY_LINEAR_PROJECT=<project-id-or-slug> # optional, scopes queue to one project
119
124
  # export RUNWAY_READY_STATUS="Todo"
120
125
  # export RUNWAY_IN_PROGRESS_STATUS="In Progress"
121
126
  # export RUNWAY_IN_REVIEW_STATUS="In Review"
@@ -155,13 +160,18 @@ per-issue comments for what happened.
155
160
  Runway picks up issues that are:
156
161
 
157
162
  - in team `RUNWAY_LINEAR_TEAM` (default `VA`)
163
+ - (optionally) in project `RUNWAY_LINEAR_PROJECT` (override per-run
164
+ with `runway run --project=<id-or-slug-or-name>`; unset = team-wide)
158
165
  - in workflow state `RUNWAY_READY_STATUS` (default `Todo`)
159
166
 
160
167
  It transitions them through:
161
168
 
162
- - `In Progress` while the agent is running
169
+ - `In Progress` while the agent is running (specifically: once the
170
+ agent has committed to its branch — startup failures before any
171
+ commits revert the issue back to `Todo` rather than stranding it)
163
172
  - `In Review` when the PR opens
164
- - (label `needs-human`) if the agent or reviewer can't finish
173
+ - (label `needs-human`) if the agent or reviewer can't finish *after*
174
+ the agent has committed real work
165
175
 
166
176
  These names are configurable per env var; the queries match by name so
167
177
  your Linear workspace's actual state names need to line up with what
@@ -232,6 +232,16 @@ async function checkGhAuth() {
232
232
  function checkEnvironment(tier) {
233
233
  const checks = new Map();
234
234
  checks.set("LINEAR_API_KEY", envSet("LINEAR_API_KEY", "fail"));
235
+ // Informational: which Linear scope a `runway run` would use.
236
+ const team = process.env.RUNWAY_LINEAR_TEAM?.trim() || "VA";
237
+ const project = process.env.RUNWAY_LINEAR_PROJECT?.trim();
238
+ checks.set("linear_scope", {
239
+ status: "ok",
240
+ label: "linear scope",
241
+ detail: project
242
+ ? `team ${team} / project ${project}`
243
+ : `team ${team} (team-wide — RUNWAY_LINEAR_PROJECT unset)`,
244
+ });
235
245
  if (tier === 2) {
236
246
  // Tier 2: needed by varlock to resolve op:// refs in the container.
237
247
  checks.set("OP_SERVICE_ACCOUNT_TOKEN", envSet("OP_SERVICE_ACCOUNT_TOKEN", "fail"));
@@ -35,8 +35,10 @@ NOTE
35
35
  No --op-account flag — runway uses 1Password service-account auth
36
36
  exclusively (OP_SERVICE_ACCOUNT_TOKEN). The token already encodes
37
37
  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>\`.
38
+ account segment: \`op://<vault>/<item>/<field>\` rather than
39
+ \`op://<account>/<vault>/<item>/<field>\`. Runway hard-codes the
40
+ \`credential\` field, which is the canonical field name on
41
+ 1Password API_CREDENTIAL items.
40
42
 
41
43
  WHAT THIS COMMAND DOES
42
44
  1. Preflight: docker, gh, node, (tier 2) varlock + op CLI, git state.
@@ -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,15 @@ 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
44
58
  RUNWAY_READY_STATUS default "Todo"
45
59
  RUNWAY_IN_PROGRESS_STATUS default "In Progress"
46
60
  RUNWAY_IN_REVIEW_STATUS default "In Review"
@@ -52,10 +66,16 @@ export async function runCommand(argv) {
52
66
  const opts = parseRunArgs(argv);
53
67
  const cwd = process.cwd();
54
68
  assertSandcastleInitialised(cwd);
55
- const config = loadConfig();
69
+ const baseConfig = loadConfig();
70
+ const config = opts.project
71
+ ? { ...baseConfig, linearProject: opts.project }
72
+ : baseConfig;
56
73
  const linear = createLinearGateway(config);
57
74
  const github = createGithubGateway();
58
- console.log(`[runway] draining queue from team ${config.linearTeam} (status="${config.readyStatus}") against ${cwd}`);
75
+ const scope = config.linearProject
76
+ ? `team ${config.linearTeam} / project ${config.linearProject}`
77
+ : `team ${config.linearTeam}`;
78
+ console.log(`[runway] draining queue from ${scope} (status="${config.readyStatus}") against ${cwd}`);
59
79
  const result = await drainQueue({ config, linear, github, cwd }, { max: opts.max });
60
80
  console.log(`[runway] done — processed=${result.processed} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
61
81
  }
package/dist/config.js CHANGED
@@ -25,6 +25,14 @@ 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(),
28
36
  readyStatus: z.string().default("Todo"),
29
37
  inProgressStatus: z.string().default("In Progress"),
30
38
  inReviewStatus: z.string().default("In Review"),
@@ -36,6 +44,7 @@ export function loadConfig() {
36
44
  linearApiKey: process.env.LINEAR_API_KEY,
37
45
  opServiceAccountToken: process.env.OP_SERVICE_ACCOUNT_TOKEN,
38
46
  linearTeam: process.env.RUNWAY_LINEAR_TEAM,
47
+ linearProject: process.env.RUNWAY_LINEAR_PROJECT,
39
48
  readyStatus: process.env.RUNWAY_READY_STATUS,
40
49
  inProgressStatus: process.env.RUNWAY_IN_PROGRESS_STATUS,
41
50
  inReviewStatus: process.env.RUNWAY_IN_REVIEW_STATUS,
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",
@@ -43,12 +43,30 @@ export async function drainQueue(deps, opts = {}) {
43
43
  catch (err) {
44
44
  errored += 1;
45
45
  console.error(`[runway] error on ${issue.identifier}:`, err);
46
- await linear
47
- .applyLabel(issue.id, config.hitlLabel)
48
- .catch(() => undefined);
49
- await linear
50
- .comment(issue.id, `Runway hit an unrecoverable error and flagged for human review:\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``)
51
- .catch(() => undefined);
46
+ // If the agent crashed before producing any commits (missing
47
+ // image, varlock validation, container failed to boot, etc.),
48
+ // it's an infrastructure failure — not a HITL. Revert the issue
49
+ // to `Todo` and skip the `needs-human` label so the next run
50
+ // can pick it up cleanly. `In Progress` is reserved for "agent
51
+ // has committed to the branch".
52
+ const branch = `agent/${issue.identifier.toLowerCase()}`;
53
+ const startedRealWork = await hasCommits(deps.cwd, branch);
54
+ if (!startedRealWork) {
55
+ await linear
56
+ .transition(issue.id, config.readyStatus)
57
+ .catch(() => undefined);
58
+ await linear
59
+ .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\`\`\``)
60
+ .catch(() => undefined);
61
+ }
62
+ else {
63
+ await linear
64
+ .applyLabel(issue.id, config.hitlLabel)
65
+ .catch(() => undefined);
66
+ await linear
67
+ .comment(issue.id, `Runway hit an unrecoverable error and flagged for human review:\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``)
68
+ .catch(() => undefined);
69
+ }
52
70
  }
53
71
  }
54
72
  return { processed, opened, hitl, errored };
@@ -113,6 +131,22 @@ async function flagHitl(issue, deps, reason) {
113
131
  await linear.applyLabel(issue.id, config.hitlLabel);
114
132
  await linear.comment(issue.id, `Runway flagged for human review: ${reason}`);
115
133
  }
134
+ /**
135
+ * Whether the agent branch has any commits beyond `main`. Used by the
136
+ * drain loop to distinguish "agent crashed mid-run, after producing
137
+ * real work" (→ HITL) from "agent crashed during startup, no work
138
+ * done" (→ revert to Todo). If the branch doesn't exist or git fails,
139
+ * treat as "no commits" so we revert rather than strand the issue.
140
+ */
141
+ async function hasCommits(repoPath, branch) {
142
+ try {
143
+ const { stdout } = await execa("git", ["rev-list", "--count", `main..${branch}`], { cwd: repoPath, reject: false });
144
+ return Number.parseInt(stdout.trim(), 10) > 0;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
116
150
  async function captureDiff(repoPath, branch) {
117
151
  const { stdout } = await execa("git", ["diff", `main...${branch}`], {
118
152
  cwd: repoPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valescoagency/runway",
3
- "version": "0.1.1",
3
+ "version": "0.2.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": {
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "files": [
35
35
  "dist",
36
+ "prompts",
36
37
  "templates",
37
38
  "LICENSE",
38
39
  "README.md"
@@ -0,0 +1,37 @@
1
+ You are an autonomous coding agent working on a single Linear issue.
2
+
3
+ # Issue
4
+
5
+ **{{ISSUE_IDENTIFIER}} — {{ISSUE_TITLE}}**
6
+
7
+ {{ISSUE_DESCRIPTION}}
8
+
9
+ # Repository context
10
+
11
+ You are operating inside a clean checkout of the target repository on a
12
+ fresh branch named `agent/{{ISSUE_IDENTIFIER}}`. Branch off `main`.
13
+
14
+ # What done looks like
15
+
16
+ 1. Code changes that satisfy the issue body.
17
+ 2. All existing tests still pass. Run them: `!`pnpm test 2>&1 | tail -40``.
18
+ 3. New tests for any new behavior, where it's reasonable to add them.
19
+ 4. Lint / typecheck clean: `!`pnpm typecheck 2>&1 | tail -20`` and
20
+ `!`pnpm lint 2>&1 | tail -20``.
21
+ 5. A clear commit message in conventional-commits style describing the
22
+ change. The commit body should reference the Linear issue ID
23
+ (`Refs {{ISSUE_IDENTIFIER}}`).
24
+
25
+ # Working style
26
+
27
+ - Read before writing. Skim related files. Match existing patterns.
28
+ - Surgical changes. Touch only what the issue requires.
29
+ - If the issue is ambiguous and you can't make a reasonable judgment
30
+ call, stop and explain what's missing in your final message — runway
31
+ will route to a human.
32
+ - Never modify `.github/workflows/**`, `.env*`, `*.pem`, `*.key`,
33
+ `pnpm-lock.yaml` (unless the task is a dep bump), or `.sandcastle/**`.
34
+
35
+ # Stop conditions
36
+
37
+ When all five "done" criteria pass, stop. Don't keep polishing.
@@ -0,0 +1,45 @@
1
+ You are an adversarial code reviewer. You did NOT write this code; your
2
+ job is to find reasons it should NOT ship.
3
+
4
+ # Issue the change claims to address
5
+
6
+ **{{ISSUE_IDENTIFIER}} — {{ISSUE_TITLE}}**
7
+
8
+ {{ISSUE_DESCRIPTION}}
9
+
10
+ # The diff
11
+
12
+ ```
13
+ {{DIFF}}
14
+ ```
15
+
16
+ # Commits
17
+
18
+ ```
19
+ {{COMMITS}}
20
+ ```
21
+
22
+ # Your job
23
+
24
+ Score the change against these axes. For each, give a brief verdict
25
+ (`PASS` / `CONCERN` / `BLOCK`) and one to two sentences of reasoning.
26
+
27
+ 1. **Addresses the issue** — does the diff actually solve what was asked?
28
+ 2. **Surgical** — only touched what was needed; no scope creep, no
29
+ "drive-by" refactors.
30
+ 3. **Tests** — new behavior covered; existing tests still meaningful.
31
+ 4. **Safety** — no secret leakage, no dangerous defaults, no protected
32
+ paths touched (workflows, env files, keys, lockfiles for non-dep work).
33
+ 5. **Clarity** — commit messages and code are readable.
34
+
35
+ # Output format
36
+
37
+ End your response with EXACTLY one of these two lines, alone, no other
38
+ text on the line:
39
+
40
+ REVIEW: APPROVED
41
+ REVIEW: REJECTED — <one-line reason>
42
+
43
+ If you output `REVIEW: REJECTED`, the agent will get one more iteration
44
+ to address your concerns. Be specific about what to fix. Don't reject
45
+ for nits.
@@ -14,19 +14,17 @@
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
- # @sensitive @required
21
- ANTHROPIC_API_KEY=exec('op read "op://{{OP_VAULT}}/{{ANTHROPIC_ITEM}}"')
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
+ # To add another secret, copy one of the two live entries below. Do
22
+ # NOT leave a commented-out example block here: varlock parses any
23
+ # `# @decorator` line as a real decorator, and a decorator with no
24
+ # attached config line fails validation ("detached comment block").
22
25
 
23
26
  # @sensitive @required
24
- GH_TOKEN=exec('op read "op://{{OP_VAULT}}/{{GH_TOKEN_ITEM}}"')
27
+ ANTHROPIC_API_KEY=exec('op read "op://{{OP_VAULT}}/{{ANTHROPIC_ITEM}}/credential"')
25
28
 
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
29
  # @sensitive @required
32
- # DATABASE_URL=exec('op read "op://{{OP_VAULT}}/database-url"')
30
+ GH_TOKEN=exec('op read "op://{{OP_VAULT}}/{{GH_TOKEN_ITEM}}/credential"')