@valescoagency/runway 0.3.0 → 0.5.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
@@ -14,7 +14,7 @@ zero-secrets-at-rest, and the `gh` CLI for PR creation.
14
14
  |---|---|
15
15
  | `runway doctor` | Read-only preflight diagnostic: host tooling, env vars, repo state, and the agent docker image. Use when something stopped working and you want a sanity report. `--json` for CI / scripted health checks. |
16
16
  | `runway init` | Scaffold the cwd repo for runway: write `.sandcastle/Dockerfile` + (tier 2) `.env.schema` with op:// references. Run **once per target repo**. |
17
- | `runway run` | Drain a Linear queue. For each `Todo` issue: branch, agent works, sub-agent reviews, PR opens (or `needs-human` label). Run **whenever you want a batch of work done**. |
17
+ | `runway run` | Drain a Linear queue. For each `Todo` issue: branch, agent works, sub-agent reviews, PR opens (or `ready-for-human` label). Run **whenever you want a batch of work done**. |
18
18
  | `runway upgrade` | Update the runway CLI itself: `git pull` the local clone, `pnpm install`, typecheck. `--check` for a dry-run, `--force` to override dirty/branch refusals. |
19
19
  | `runway upgrade-repo` | Re-render the cwd repo's runway scaffold against the current vendored templates. Use after a runway version bump that changed the Dockerfile or template shape — `init` writes them, `upgrade-repo` keeps them current without re-prompting for op:// values. |
20
20
 
@@ -44,14 +44,23 @@ Linear (Todo, team=VA)
44
44
  runway (this CLI, on your Mac, run from inside the target repo)
45
45
  ↓ for each issue
46
46
  │ sandcastle.run({ agent: claudeCode, sandbox: docker, cwd: process.cwd(), ... })
47
+ │ iter 1 → IMPL: DONE | IMPL: BLOCKED — <reason> | IMPL: CONTINUE
48
+ │ iter 2 → same, with previous iteration's summary injected
49
+ │ …
47
50
  │ → branch agent/<issue-id>, commits, tests
48
51
 
49
- sandcastle.run({ ..., prompt: review template })
50
- → REVIEW: APPROVED | REVIEW: REJECTED — <reason>
52
+ if BLOCKED HITL (skip review)
53
+ else:
54
+ │ sandcastle.run({ ..., prompt: review template })
55
+ │ → REVIEW: APPROVED | REVIEW: REJECTED — <reason>
51
56
 
52
57
  ├── approved → git push → gh pr create → Linear "In Review"
53
- └── rejected → Linear label "needs-human", comment with reason
58
+ └── rejected → Linear comment with reason, then `ready-for-human` label
54
59
  ↓ next issue
60
+
61
+ [runway] per-issue outcomes:
62
+ VA-312 APPROVED → PR opened https://github.com/.../pull/42
63
+ VA-313 HITL Sub-agent review rejected: TOCTOU race in …
55
64
  ```
56
65
 
57
66
  ## Prerequisites
@@ -161,10 +170,19 @@ export LINEAR_API_KEY=lin_api_...
161
170
  # export RUNWAY_READY_STATUS="Todo"
162
171
  # export RUNWAY_IN_PROGRESS_STATUS="In Progress"
163
172
  # export RUNWAY_IN_REVIEW_STATUS="In Review"
164
- # export RUNWAY_HITL_LABEL="needs-human"
173
+ # export RUNWAY_HITL_LABEL="ready-for-human"
165
174
  # export RUNWAY_MAX_ITERATIONS=5
166
175
  ```
167
176
 
177
+ `RUNWAY_HITL_LABEL` defaults to `ready-for-human`, matching the
178
+ [Flightplan](https://github.com/valescoagency/flightplan) canonical
179
+ state-label vocabulary (`needs-triage`, `needs-info`,
180
+ `ready-for-agent`, `ready-for-human`, `wontfix`) that Bedrock and
181
+ other Valesco repos use. Override the env var if your workspace uses
182
+ a different label. `runway doctor` validates that the configured
183
+ team, workflow states, and HITL label all exist before any agent run
184
+ — misconfiguration surfaces immediately instead of mid-drain.
185
+
168
186
  ### From source (development)
169
187
 
170
188
  ```bash
@@ -214,15 +232,25 @@ Skip a single hook invocation with `LEFTHOOK=0 git commit …` (or
214
232
  ```bash
215
233
  cd /path/to/the/repo/you/want/agents/working/on
216
234
  runway run # drain the entire ready queue
217
- runway run --max 3 # process at most 3 issues then exit
235
+ runway run --max 3 # attempt at most 3 issues then exit
218
236
  runway --help
219
237
  ```
220
238
 
221
239
  `runway` (no subcommand) is an alias for `runway run` for back-compat.
222
240
 
241
+ `--max N` bounds **attempts**, not successes. Every issue picked up
242
+ counts as one attempt, whether it ends in a PR, a `needs-human` label,
243
+ or a revert-to-`Todo` after an infrastructure failure. An issue
244
+ reverted in this invocation will not be re-picked in the same
245
+ invocation — re-run runway after fixing the underlying config to retry
246
+ it.
247
+
223
248
  The CLI exits with 0 even if some issues hit HITL or errored — those
224
- are normal outcomes. Check Linear for the `needs-human` label and the
225
- per-issue comments for what happened.
249
+ are normal outcomes. Every run prints a per-issue verdict trail on
250
+ exit (`APPROVED PR opened <url>` / `HITL <reason>` /
251
+ `REVERTED → Todo <reason>` / `INFRA_ERROR <reason>`) so you can scan
252
+ results without opening Linear; the same content also lives on the
253
+ issue as a Linear comment.
226
254
 
227
255
  ## Linear conventions
228
256
 
@@ -239,13 +267,55 @@ It transitions them through:
239
267
  agent has committed to its branch — startup failures before any
240
268
  commits revert the issue back to `Todo` rather than stranding it)
241
269
  - `In Review` when the PR opens
242
- - (label `needs-human`) if the agent or reviewer can't finish *after*
270
+ - (label `ready-for-human`) if the agent or reviewer can't finish *after*
243
271
  the agent has committed real work
244
272
 
245
273
  These names are configurable per env var; the queries match by name so
246
274
  your Linear workspace's actual state names need to line up with what
247
275
  you set.
248
276
 
277
+ ## Write-path policy
278
+
279
+ Runway tells the impl agent which paths it must **not** write to. By
280
+ default the denylist is:
281
+
282
+ ```
283
+ .github/workflows/** .env* *.pem *.key pnpm-lock.yaml .sandcastle/**
284
+ ```
285
+
286
+ When an issue's acceptance criteria require modifying a forbidden path,
287
+ the agent is instructed to emit `IMPL: BLOCKED — issue requires
288
+ modifying <path>, which working-style policy forbids` rather than
289
+ silently skipping the work. Runway routes those to HITL with the
290
+ reason attached.
291
+
292
+ Two layers of override:
293
+
294
+ **Per repo** — drop a `.runway/policy.yml` in the target repo root:
295
+
296
+ ```yaml
297
+ # Grants write access to specific paths from the default denylist.
298
+ allowedPaths:
299
+ - .github/workflows/**
300
+
301
+ # Or replace the denylist entirely (use with care).
302
+ # forbiddenPaths:
303
+ # - .env*
304
+ # - "*.pem"
305
+ ```
306
+
307
+ **Per invocation** — comma-separated globs, removed from the effective
308
+ denylist for one `runway run`:
309
+
310
+ ```bash
311
+ runway run --allow-paths='.github/workflows/**'
312
+ runway run --allow-paths='.github/workflows/**,scripts/ci/*.sh'
313
+ ```
314
+
315
+ `runway doctor` surfaces the active policy under Environment so you
316
+ can see what an agent run can and can't touch (e.g. `impl policy:
317
+ .runway/policy.yml + --allow-paths (5 forbidden paths)`).
318
+
249
319
  ## Base branch
250
320
 
251
321
  Runway auto-detects the repo's default branch at the start of every
@@ -261,6 +331,30 @@ when `origin/HEAD` isn't set and you don't want to run
261
331
  resolved base branch (detected or overridden) in its Environment
262
332
  section.
263
333
 
334
+ ## Implementation pass
335
+
336
+ The impl agent runs in a Sandcastle container with
337
+ [`prompts/implement.md`](prompts/implement.md). It iterates up to
338
+ `RUNWAY_MAX_ITERATIONS` times (default 5) and must end every iteration
339
+ with one of:
340
+
341
+ ```
342
+ IMPL: DONE
343
+ IMPL: BLOCKED — <one-line reason>
344
+ IMPL: CONTINUE
345
+ ```
346
+
347
+ - `DONE` → runway stops the loop and runs the sub-agent reviewer.
348
+ - `BLOCKED — <reason>` → runway routes the issue to HITL with the
349
+ reason attached; the reviewer pass does **not** run.
350
+ - `CONTINUE` → runway runs another iteration (up to `maxIterations`).
351
+
352
+ Between iterations, runway prepends a `## Previous iterations` block
353
+ to the next prompt — running commit log + tail of the last iteration's
354
+ final message — so the agent doesn't re-explore the repository from
355
+ scratch every time. Converged issues typically exit after 1–2
356
+ iterations.
357
+
264
358
  ## Sub-agent review
265
359
 
266
360
  Every implementation run is followed by a fresh Sandcastle run with
@@ -290,4 +384,7 @@ These are tractable, just not v1.
290
384
 
291
385
  ## Status
292
386
 
293
- v0.1scaffold complete, untested against a live queue.
387
+ 0.5.0production-shaped and dogfooded against live Linear queues.
388
+ The end-to-end pipeline (init → run → review → PR) is stable; surface
389
+ may still shift as the orchestrator's policy and iteration mechanics
390
+ mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
@@ -2,6 +2,9 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { execa } from "execa";
4
4
  import { detectBaseBranch } from "../git.js";
5
+ import { loadPolicy } from "../policy.js";
6
+ import { loadConfig } from "../config.js";
7
+ import { validateLinearConfig } from "../linear.js";
5
8
  // ---------------------------------------------------------------------------
6
9
  // Usage
7
10
  // ---------------------------------------------------------------------------
@@ -87,12 +90,14 @@ export async function doctorCommand(argv) {
87
90
  sections.push(await checkEnvironment(tierForToolingChecks, cwd, repo));
88
91
  sections.push(await checkRepoState(cwd, repo));
89
92
  sections.push(await checkDockerImage(cwd));
93
+ sections.push(await checkLinearConfig());
90
94
  }
91
95
  else {
92
96
  // Push placeholder skipped sections so JSON output stays well-shaped.
93
97
  sections.push(skippedSection("Environment"));
94
98
  sections.push(skippedSection("Repo state"));
95
99
  sections.push(skippedSection("Docker image"));
100
+ sections.push(skippedSection("Linear configuration"));
96
101
  }
97
102
  // Render
98
103
  if (opts.json) {
@@ -102,8 +107,14 @@ export async function doctorCommand(argv) {
102
107
  renderText(sections, tier, initialised, opts.detailed);
103
108
  }
104
109
  // Exit code: required-check failures = 1.
105
- // Sections 1, 2, 4 are "required"; section 3 (repo state) is informational.
106
- const requiredSections = [sections[0], sections[1], sections[3]];
110
+ // Required: 0 host tooling, 1 environment, 3 docker image, 4 Linear
111
+ // config. Section 2 (repo state) is informational.
112
+ const requiredSections = [
113
+ sections[0],
114
+ sections[1],
115
+ sections[3],
116
+ sections[4],
117
+ ];
107
118
  const failed = requiredSections.some((s) => s?.ran && [...s.checks.values()].some((c) => c.status === "fail"));
108
119
  process.exit(failed ? 1 : 0);
109
120
  }
@@ -321,6 +332,23 @@ async function checkEnvironment(tier, cwd, repo) {
321
332
  });
322
333
  }
323
334
  }
335
+ // VA-352: surface the active impl-pass write-path policy so the
336
+ // operator can see whether an agent run can touch CI workflows, etc.
337
+ try {
338
+ const policy = loadPolicy(cwd);
339
+ checks.set("policy", {
340
+ status: "ok",
341
+ label: "impl policy",
342
+ detail: `${policy.source} (${policy.forbiddenPaths.length} forbidden path${policy.forbiddenPaths.length === 1 ? "" : "s"})`,
343
+ });
344
+ }
345
+ catch (err) {
346
+ checks.set("policy", {
347
+ status: "fail",
348
+ label: "impl policy",
349
+ detail: errMsg(err),
350
+ });
351
+ }
324
352
  return { title: "Environment", checks, ran: true };
325
353
  }
326
354
  function envSet(name, missingStatus) {
@@ -445,6 +473,51 @@ async function checkDockerImage(cwd) {
445
473
  detail: imageUser ? `User=${imageUser}` : `User unset (root); host=${expected}`,
446
474
  });
447
475
  }
476
+ // VA-351: container readiness — pnpm on PATH + HOME/cache env
477
+ // baked in. Cheap one-shot run; fails fast if the image is stale.
478
+ try {
479
+ const probe = await execa("docker", [
480
+ "run",
481
+ "--rm",
482
+ imageName,
483
+ "bash",
484
+ "-lc",
485
+ 'set -e; which pnpm >/dev/null && printf "HOME=%s\\nXDG_CACHE_HOME=%s\\nTURBO_CACHE_DIR=%s\\n" "$HOME" "$XDG_CACHE_HOME" "$TURBO_CACHE_DIR"',
486
+ ], { reject: false });
487
+ const out = probe.stdout ?? "";
488
+ const missing = [];
489
+ if (probe.exitCode !== 0)
490
+ missing.push("pnpm");
491
+ if (!/^HOME=\/home\/agent\s*$/m.test(out))
492
+ missing.push("HOME");
493
+ if (!/^XDG_CACHE_HOME=\/home\/agent\/.cache\s*$/m.test(out)) {
494
+ missing.push("XDG_CACHE_HOME");
495
+ }
496
+ if (!/^TURBO_CACHE_DIR=\/tmp\/turbo-cache\s*$/m.test(out)) {
497
+ missing.push("TURBO_CACHE_DIR");
498
+ }
499
+ if (missing.length === 0) {
500
+ checks.set("container_ready", {
501
+ status: "ok",
502
+ label: "container readiness",
503
+ detail: "pnpm on PATH; HOME, XDG_CACHE_HOME, TURBO_CACHE_DIR set",
504
+ });
505
+ }
506
+ else {
507
+ checks.set("container_ready", {
508
+ status: "warn",
509
+ label: "container readiness",
510
+ detail: `missing or wrong inside container: ${missing.join(", ")} — rebuild via \`runway upgrade-repo && docker build .sandcastle -t ${imageName}\``,
511
+ });
512
+ }
513
+ }
514
+ catch (err) {
515
+ checks.set("container_ready", {
516
+ status: "warn",
517
+ label: "container readiness",
518
+ detail: `probe failed: ${errMsg(err)}`,
519
+ });
520
+ }
448
521
  }
449
522
  catch (err) {
450
523
  checks.set("image_present", {
@@ -455,6 +528,134 @@ async function checkDockerImage(cwd) {
455
528
  }
456
529
  return { title: "Docker image", checks, ran: true };
457
530
  }
531
+ // ---------------------------------------------------------------------------
532
+ // Section: Linear configuration (VA-354)
533
+ // ---------------------------------------------------------------------------
534
+ /**
535
+ * Validate that the team, workflow states, and HITL label `runway run`
536
+ * would use actually exist on the Linear workspace. Without this,
537
+ * misconfiguration only surfaces deep inside a long agent run — too
538
+ * late to fix without losing the work.
539
+ */
540
+ async function checkLinearConfig() {
541
+ const checks = new Map();
542
+ // The config loader's only hard requirement is LINEAR_API_KEY; the
543
+ // rest defaults. If the key is missing, the Environment section
544
+ // already fails — surface a skip here rather than re-failing.
545
+ if (!process.env.LINEAR_API_KEY) {
546
+ checks.set("linear_config", {
547
+ status: "skip",
548
+ label: "Linear config",
549
+ detail: "LINEAR_API_KEY unset — skipped",
550
+ });
551
+ return { title: "Linear configuration", checks, ran: true };
552
+ }
553
+ let config;
554
+ try {
555
+ config = loadConfig();
556
+ }
557
+ catch (err) {
558
+ checks.set("linear_config", {
559
+ status: "fail",
560
+ label: "Linear config",
561
+ detail: `failed to load runway config: ${errMsg(err)}`,
562
+ });
563
+ return { title: "Linear configuration", checks, ran: true };
564
+ }
565
+ let result;
566
+ try {
567
+ result = await validateLinearConfig(config);
568
+ }
569
+ catch (err) {
570
+ checks.set("linear_api", {
571
+ status: "fail",
572
+ label: "Linear API",
573
+ detail: `validation request failed: ${errMsg(err)}`,
574
+ });
575
+ return { title: "Linear configuration", checks, ran: true };
576
+ }
577
+ if (result.team.kind === "missing") {
578
+ checks.set("team", {
579
+ status: "fail",
580
+ label: `team ${config.linearTeam}`,
581
+ detail: `Linear team key "${result.team.key}" not found — set RUNWAY_LINEAR_TEAM`,
582
+ });
583
+ // States/labels are skipped when the team missing; surface
584
+ // explicitly so the user knows they weren't checked.
585
+ checks.set("states", {
586
+ status: "skip",
587
+ label: "workflow states",
588
+ detail: "skipped (team missing)",
589
+ });
590
+ checks.set("hitl_label", {
591
+ status: "skip",
592
+ label: "HITL label",
593
+ detail: "skipped (team missing)",
594
+ });
595
+ return { title: "Linear configuration", checks, ran: true };
596
+ }
597
+ checks.set("team", {
598
+ status: "ok",
599
+ label: `team ${config.linearTeam}`,
600
+ detail: `id=${result.team.id}`,
601
+ });
602
+ for (const [key, configured, state] of [
603
+ ["ready_state", config.readyStatus, result.readyStatus],
604
+ ["in_progress_state", config.inProgressStatus, result.inProgressStatus],
605
+ ["in_review_state", config.inReviewStatus, result.inReviewStatus],
606
+ ]) {
607
+ if (state.kind === "ok") {
608
+ checks.set(key, {
609
+ status: "ok",
610
+ label: `workflow state "${configured}"`,
611
+ detail: "present",
612
+ });
613
+ }
614
+ else if (state.kind === "skipped") {
615
+ checks.set(key, {
616
+ status: "skip",
617
+ label: `workflow state "${configured}"`,
618
+ detail: state.reason,
619
+ });
620
+ }
621
+ else {
622
+ checks.set(key, {
623
+ status: "fail",
624
+ label: `workflow state "${configured}"`,
625
+ detail: `not found on team; available: ${formatList(state.available)}`,
626
+ });
627
+ }
628
+ }
629
+ if (result.hitlLabel.kind === "ok") {
630
+ checks.set("hitl_label", {
631
+ status: "ok",
632
+ label: `HITL label "${config.hitlLabel}"`,
633
+ detail: "present",
634
+ });
635
+ }
636
+ else if (result.hitlLabel.kind === "skipped") {
637
+ checks.set("hitl_label", {
638
+ status: "skip",
639
+ label: `HITL label "${config.hitlLabel}"`,
640
+ detail: result.hitlLabel.reason,
641
+ });
642
+ }
643
+ else {
644
+ checks.set("hitl_label", {
645
+ status: "fail",
646
+ label: `HITL label "${config.hitlLabel}"`,
647
+ detail: `not found on team — set RUNWAY_HITL_LABEL or create the label. Available: ${formatList(result.hitlLabel.available)}`,
648
+ });
649
+ }
650
+ return { title: "Linear configuration", checks, ran: true };
651
+ }
652
+ function formatList(items) {
653
+ if (items.length === 0)
654
+ return "(none)";
655
+ if (items.length <= 8)
656
+ return items.join(", ");
657
+ return `${items.slice(0, 8).join(", ")}, …(+${items.length - 8} more)`;
658
+ }
458
659
  /**
459
660
  * Sanitize the cwd's basename the same way sandcastle's `defaultImageName`
460
661
  * does: lowercase, replace any char outside `[a-z0-9_.-]` with `-`, fall
@@ -1,9 +1,21 @@
1
- import { loadConfig } from "../config.js";
1
+ import { Effect, Layer, Logger, RateLimiter } from "effect";
2
+ import { ConfigLive, ConfigTag } from "../config.js";
2
3
  import { createLinearGateway } from "../linear.js";
3
4
  import { createGithubGateway } from "../github.js";
4
5
  import { assertSandcastleInitialised, drainQueue, } from "../orchestrator.js";
6
+ import { TelemetryLive } from "../telemetry.js";
5
7
  export function parseRunArgs(argv) {
6
8
  const opts = {};
9
+ const collectAllow = (raw) => {
10
+ const paths = raw
11
+ .split(",")
12
+ .map((s) => s.trim())
13
+ .filter(Boolean);
14
+ if (paths.length === 0) {
15
+ throw new Error("--allow-paths requires at least one glob");
16
+ }
17
+ opts.allowPaths = [...(opts.allowPaths ?? []), ...paths];
18
+ };
7
19
  for (let i = 0; i < argv.length; i += 1) {
8
20
  const a = argv[i];
9
21
  if (a === "--max" || a === "-n") {
@@ -27,6 +39,16 @@ export function parseRunArgs(argv) {
27
39
  else if (a?.startsWith("--project=")) {
28
40
  opts.project = a.slice("--project=".length);
29
41
  }
42
+ else if (a === "--allow-paths") {
43
+ const v = argv[i + 1];
44
+ if (!v)
45
+ throw new Error("--allow-paths requires a value");
46
+ collectAllow(v);
47
+ i += 1;
48
+ }
49
+ else if (a?.startsWith("--allow-paths=")) {
50
+ collectAllow(a.slice("--allow-paths=".length));
51
+ }
30
52
  else if (a === "--help" || a === "-h") {
31
53
  printRunUsage();
32
54
  process.exit(0);
@@ -45,10 +67,18 @@ USAGE
45
67
  runway run [--max N]
46
68
 
47
69
  OPTIONS
48
- --max, -n N Process at most N issues then exit. Default: drain queue.
70
+ --max, -n N Attempt at most N issues then exit (counts every
71
+ attempt — success, HITL, or revert-to-Todo).
72
+ Default: drain queue.
49
73
  --project ID Scope the queue to a single Linear project under the
50
74
  team. Accepts project UUID, slug, or name. Overrides
51
75
  RUNWAY_LINEAR_PROJECT. Default: team-wide.
76
+ --allow-paths GLOBS
77
+ Comma-separated globs removed from the impl policy's
78
+ forbidden-paths list for this invocation only.
79
+ Example: --allow-paths='.github/workflows/**' lets
80
+ the agent touch CI for issues whose AC require it.
81
+ Repeatable; pairs with .runway/policy.yml.
52
82
  --help, -h Show this help.
53
83
 
54
84
  ENVIRONMENT
@@ -62,7 +92,7 @@ ENVIRONMENT
62
92
  RUNWAY_READY_STATUS default "Todo"
63
93
  RUNWAY_IN_PROGRESS_STATUS default "In Progress"
64
94
  RUNWAY_IN_REVIEW_STATUS default "In Review"
65
- RUNWAY_HITL_LABEL default "needs-human"
95
+ RUNWAY_HITL_LABEL default "ready-for-human"
66
96
  RUNWAY_MAX_ITERATIONS default 5
67
97
  `);
68
98
  }
@@ -70,16 +100,41 @@ export async function runCommand(argv) {
70
100
  const opts = parseRunArgs(argv);
71
101
  const cwd = process.cwd();
72
102
  assertSandcastleInitialised(cwd);
73
- const baseConfig = loadConfig();
74
- const config = opts.project
75
- ? { ...baseConfig, linearProject: opts.project }
76
- : baseConfig;
77
- const linear = createLinearGateway(config);
78
- const github = createGithubGateway();
79
- const scope = config.linearProject
80
- ? `team ${config.linearTeam} / project ${config.linearProject}`
81
- : `team ${config.linearTeam}`;
82
- console.log(`[runway] draining queue from ${scope} (status="${config.readyStatus}") against ${cwd}`);
83
- const result = await drainQueue({ config, linear, github, cwd }, { max: opts.max });
84
- console.log(`[runway] done processed=${result.processed} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
103
+ // VA-358 / VA-359: a single `Effect.runPromise` at the CLI
104
+ // boundary. Composed layers:
105
+ //
106
+ // - `ConfigLive` (VA-359) resolves every env var via Effect's
107
+ // `Config` module. Secrets (LINEAR_API_KEY,
108
+ // OP_SERVICE_ACCOUNT_TOKEN) are `Redacted<string>` and won't
109
+ // appear in `Effect.log` output or stringified errors.
110
+ // - `Logger.jsonLogger` is wired when `RUNWAY_JSON_LOGS=1` so the
111
+ // operator can pipe runway's output to a log aggregator.
112
+ // - `TelemetryLive` (VA-358) is env-conditional only wires the
113
+ // OTLP exporter when `OTEL_EXPORTER_OTLP_ENDPOINT` is set.
114
+ // - Linear `RateLimiter` (folded in from VA-357): conservative
115
+ // 30/minute, built inside `Effect.scoped` so its internals are
116
+ // torn down on program exit.
117
+ const LoggerLive = process.env.RUNWAY_JSON_LOGS === "1"
118
+ ? Logger.replace(Logger.defaultLogger, Logger.jsonLogger)
119
+ : Layer.empty;
120
+ const MainLayer = Layer.mergeAll(ConfigLive, TelemetryLive, LoggerLive);
121
+ const program = Effect.gen(function* () {
122
+ const baseConfig = yield* ConfigTag;
123
+ const config = opts.project
124
+ ? { ...baseConfig, linearProject: opts.project }
125
+ : baseConfig;
126
+ const scope = config.linearProject
127
+ ? `team ${config.linearTeam} / project ${config.linearProject}`
128
+ : `team ${config.linearTeam}`;
129
+ yield* Effect.logInfo(`draining queue from ${scope} (status="${config.readyStatus}") against ${cwd}`);
130
+ const linearLimiter = yield* RateLimiter.make({
131
+ limit: 30,
132
+ interval: "1 minute",
133
+ });
134
+ const linear = createLinearGateway(config, linearLimiter);
135
+ const github = createGithubGateway();
136
+ return yield* drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths });
137
+ }).pipe(Effect.scoped, Effect.provide(MainLayer));
138
+ const result = await Effect.runPromise(program);
139
+ console.log(`[runway] done — attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
85
140
  }
package/dist/config.js CHANGED
@@ -1,65 +1,57 @@
1
- import { z } from "zod";
1
+ import { Config as EConfig, Context, Effect, Layer, Option, } from "effect";
2
2
  /**
3
- * Runway runtime config. Loaded from process.env at startup. We fail
4
- * fast if a required value is missing — no point starting the loop and
5
- * blowing up halfway through an issue.
6
- *
7
- * Notable absences vs. typical agent runners:
8
- * - No ANTHROPIC_API_KEY here. Sandcastle reads it from the target
9
- * repo's `.sandcastle/.env` per its own conventions.
10
- * - No GH_TOKEN here. We use the `gh` CLI for PR creation; if the
11
- * user is logged in (`gh auth status`), it Just Works. If they
12
- * aren't, `gh pr create` errors out with a clear message — no need
13
- * for runway to second-guess.
14
- * - No RUNWAY_TARGET_REPO. Runway runs from inside the target repo
15
- * (`process.cwd()`), the same way `sandcastle run` does.
3
+ * VA-359: Effect.Config program that reads every var from
4
+ * `process.env`, applies defaults, and yields a typed `RunwayConfig`.
5
+ * The `Option<T>` returns from `Config.option(...)` are flattened to
6
+ * `T | undefined` in the final shape so consumers don't have to
7
+ * import `Option` everywhere.
8
+ */
9
+ const configEffect = EConfig.all({
10
+ linearApiKey: EConfig.redacted("LINEAR_API_KEY"),
11
+ opServiceAccountToken: EConfig.option(EConfig.redacted("OP_SERVICE_ACCOUNT_TOKEN")),
12
+ linearTeam: EConfig.string("RUNWAY_LINEAR_TEAM").pipe(EConfig.withDefault("VA")),
13
+ linearProject: EConfig.option(EConfig.string("RUNWAY_LINEAR_PROJECT")),
14
+ baseBranch: EConfig.option(EConfig.string("RUNWAY_BASE_BRANCH")),
15
+ readyStatus: EConfig.string("RUNWAY_READY_STATUS").pipe(EConfig.withDefault("Todo")),
16
+ inProgressStatus: EConfig.string("RUNWAY_IN_PROGRESS_STATUS").pipe(EConfig.withDefault("In Progress")),
17
+ inReviewStatus: EConfig.string("RUNWAY_IN_REVIEW_STATUS").pipe(EConfig.withDefault("In Review")),
18
+ hitlLabel: EConfig.string("RUNWAY_HITL_LABEL").pipe(EConfig.withDefault("ready-for-human")),
19
+ maxIterations: EConfig.integer("RUNWAY_MAX_ITERATIONS").pipe(EConfig.withDefault(5), EConfig.validate({
20
+ message: "RUNWAY_MAX_ITERATIONS must be a positive integer",
21
+ validation: (n) => n > 0,
22
+ })),
23
+ }).pipe(Effect.map((raw) => ({
24
+ linearApiKey: raw.linearApiKey,
25
+ opServiceAccountToken: Option.getOrUndefined(raw.opServiceAccountToken),
26
+ linearTeam: raw.linearTeam,
27
+ linearProject: Option.getOrUndefined(raw.linearProject),
28
+ baseBranch: Option.getOrUndefined(raw.baseBranch),
29
+ readyStatus: raw.readyStatus,
30
+ inProgressStatus: raw.inProgressStatus,
31
+ inReviewStatus: raw.inReviewStatus,
32
+ hitlLabel: raw.hitlLabel,
33
+ maxIterations: raw.maxIterations,
34
+ })));
35
+ /**
36
+ * VA-359: Context tag for the resolved RunwayConfig. Provided by
37
+ * `ConfigLive` at the top of every CLI entry point; consumed by
38
+ * `yield* ConfigTag` inside Effect.gen.
39
+ */
40
+ export class ConfigTag extends Context.Tag("RunwayConfig")() {
41
+ }
42
+ /**
43
+ * Layer that resolves env vars once and makes the result available
44
+ * via `ConfigTag` for the rest of the program.
45
+ */
46
+ export const ConfigLive = Layer.effect(ConfigTag, configEffect);
47
+ /**
48
+ * Sync helper for non-Effect callers (`runway doctor` early
49
+ * validation, the `runway run` CLI bootstrap before it enters
50
+ * Effect-land). `Effect.runSync` throws a `FiberFailure` carrying
51
+ * the `ConfigError` on a missing/invalid env var — the caller's
52
+ * existing `catch (err) { … errMsg(err) … }` shape rendered the
53
+ * Zod issue the same way it'll now render the Effect ConfigError.
16
54
  */
17
- const ConfigSchema = z.object({
18
- linearApiKey: z.string().min(1, "LINEAR_API_KEY required"),
19
- /**
20
- * Optional. If present, forwarded into the sandcastle container so
21
- * the in-container varlock + 1Password-CLI shim can resolve agent
22
- * secrets at run time. If absent, the container falls back to
23
- * sandcastle's normal `.sandcastle/.env` flow. See
24
- * docs/secrets-with-varlock.md.
25
- */
26
- opServiceAccountToken: z.string().optional(),
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(),
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(),
46
- readyStatus: z.string().default("Todo"),
47
- inProgressStatus: z.string().default("In Progress"),
48
- inReviewStatus: z.string().default("In Review"),
49
- hitlLabel: z.string().default("needs-human"),
50
- maxIterations: z.coerce.number().int().positive().default(5),
51
- });
52
55
  export function loadConfig() {
53
- return ConfigSchema.parse({
54
- linearApiKey: process.env.LINEAR_API_KEY,
55
- opServiceAccountToken: process.env.OP_SERVICE_ACCOUNT_TOKEN,
56
- linearTeam: process.env.RUNWAY_LINEAR_TEAM,
57
- linearProject: process.env.RUNWAY_LINEAR_PROJECT,
58
- baseBranch: process.env.RUNWAY_BASE_BRANCH,
59
- readyStatus: process.env.RUNWAY_READY_STATUS,
60
- inProgressStatus: process.env.RUNWAY_IN_PROGRESS_STATUS,
61
- inReviewStatus: process.env.RUNWAY_IN_REVIEW_STATUS,
62
- hitlLabel: process.env.RUNWAY_HITL_LABEL,
63
- maxIterations: process.env.RUNWAY_MAX_ITERATIONS,
64
- });
56
+ return Effect.runSync(configEffect);
65
57
  }