@valescoagency/runway 0.1.2 → 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
@@ -77,7 +77,9 @@ runway init \
77
77
  (No `--op-account` — runway uses 1Password service-account auth
78
78
  (`OP_SERVICE_ACCOUNT_TOKEN`) exclusively, and the token already
79
79
  encodes the tenant. `op://` URIs runway writes are
80
- `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.)
81
83
 
82
84
  This runs `npx sandcastle init`, patches the generated `.sandcastle/Dockerfile`
83
85
  to bake in `varlock` + the 1Password CLI + a `claude` shim, scaffolds
@@ -118,6 +120,7 @@ Export runway's own env (in your shell rc, or wherever you keep API keys):
118
120
  export LINEAR_API_KEY=lin_api_...
119
121
  # Optional overrides:
120
122
  # export RUNWAY_LINEAR_TEAM=VA
123
+ # export RUNWAY_LINEAR_PROJECT=<project-id-or-slug> # optional, scopes queue to one project
121
124
  # export RUNWAY_READY_STATUS="Todo"
122
125
  # export RUNWAY_IN_PROGRESS_STATUS="In Progress"
123
126
  # export RUNWAY_IN_REVIEW_STATUS="In Review"
@@ -157,13 +160,18 @@ per-issue comments for what happened.
157
160
  Runway picks up issues that are:
158
161
 
159
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)
160
165
  - in workflow state `RUNWAY_READY_STATUS` (default `Todo`)
161
166
 
162
167
  It transitions them through:
163
168
 
164
- - `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)
165
172
  - `In Review` when the PR opens
166
- - (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
167
175
 
168
176
  These names are configurable per env var; the queries match by name so
169
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.2",
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": {
@@ -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"')