@valescoagency/runway 0.14.3 → 0.16.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
@@ -8,13 +8,14 @@ coding-agent runs, then **drain** a Linear queue against it. Wraps
8
8
  inside Docker), [varlock](https://varlock.dev) + 1Password for
9
9
  zero-secrets-at-rest, and the `gh` CLI for PR creation.
10
10
 
11
- ## Seven commands
11
+ ## Eight commands
12
12
 
13
13
  | | |
14
14
  |---|---|
15
15
  | `runway dash` | Bring up the operations dashboard (`up` / `logs` / `stop`). Wraps the published `ghcr.io/valescoagency/runway-dashboard` image so any runway-using project can run the dashboard without cloning runway. Ports bind to `127.0.0.1` only. |
16
16
  | `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. |
17
17
  | `runway init` | Scaffold the cwd repo for runway: write `.sandcastle/Dockerfile` + (tier 2) `.env.schema` with op:// references. Run **once per target repo**. |
18
+ | `runway planner` | Run the planner daemon — a long-running host process that will own cross-PR shepherding and queue selection per [ADR 0004](docs/adr/0004-planner-daemon-architecture.md). VA-472 epic in flight; VA-473 ships the foreground scaffold (`runway planner run` boots a tick loop, no capabilities wired yet). |
18
19
  | `runway review` | Run an IRA retrospective pass. `runway review run --drain <trace-id> --issue <id>` grades one issue-process post-drain — loads the captured agent + reviewer reports from the dashboard, fetches hindsight (PR merge state, human review-thread comments, Linear follow-ups in the past 48h), pulls rolling norms for the issue's category (last 30 drains, via the VA-399 read-model), asks an Anthropic model for a structured `Run Review` with absolute + relative grading axes, writes it to a `runway-meta` Linear project + the dashboard's `meta_reviews` table. Scheduler-agnostic; usually invoked from cron or GitHub Actions ~18h after a drain. The drain-age delay is enforced by `RUNWAY_REVIEW_DELAY_HOURS` (default 18, constrained to the 12–24h band) — pass `--force` to override for one-off operator bypass. **`runway review drain --id <trace-id>`** (VA-406) grades a whole drain: reads every per-issue Run Review for the drain, asks the model for a structured `Drain Review` covering composition / sequencing / cross-issue patterns, files it in `runway-meta`. When the model marks a finding `severity: critical` (drain-unsafe or captured-data-lost), the IRA also escalates a `Bug` + `runway-meta-promoted` issue into the runway-repo project (set `RUNWAY_REPO_PROJECT_NAME` to scope by project; otherwise team-level). The Drain Review fires automatically in-process the moment every Run Review for a drain has landed — manual invocation is for scheduled / catch-up runs. |
19
20
  | `runway run` | Drain a Linear queue. For each issue carrying the `ready-for-agent` label: branch, agent works, sub-agent reviews, PR opens (or `ready-for-human` label). Run **whenever you want a batch of work done**. |
20
21
  | `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. |
@@ -218,6 +219,18 @@ export LINEAR_API_KEY=lin_api_...
218
219
  # export RUNWAY_READY_LABEL="ready-for-agent"
219
220
  # export RUNWAY_HITL_LABEL="ready-for-human"
220
221
  # export RUNWAY_MAX_ITERATIONS=5
222
+ # VA-482: the four RUNWAY_SHEPHERD_* env vars have been retired.
223
+ # Mergeability / CI / reviewer-feedback work now flows through the
224
+ # `runway planner` daemon (capabilities VA-477..479). Boot the
225
+ # planner separately (`runway planner run`) and configure it with
226
+ # the RUNWAY_PLANNER_* env vars; the planner reads from the
227
+ # dashboard's SQLite file and emits OTLP back to the dashboard.
228
+ # export RUNWAY_PR_REVIEWER_BOT_LOGIN="runway-reviewer-bot"
229
+ # VA-463 / VA-479: GitHub login of the adversarial PR-reviewer
230
+ # agent. Consumed by the planner's reviewer dispatcher to
231
+ # distinguish bot CHANGES-REQUESTED comments from human feedback
232
+ # (the latter routes through the LLM interpretation path).
233
+ # Unset → every comment lands on the human-feedback path.
221
234
  # export RUNWAY_COMMENT_AUTHOR_ALLOWLIST="Reviewer Bot,Jane Reviewer"
222
235
  # optional, comma-separated Linear user names whose comments on a
223
236
  # re-queued issue surface as "Review feedback from prior attempts"
@@ -541,7 +554,7 @@ These are tractable, just not v1.
541
554
 
542
555
  ## Status
543
556
 
544
- 0.14.3 — production-shaped and dogfooded against live Linear queues.
557
+ 0.15.0 — production-shaped and dogfooded against live Linear queues.
545
558
  The end-to-end pipeline (init → run → review → PR) is stable; surface
546
559
  may still shift as the orchestrator's policy and iteration mechanics
547
560
  mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { dashCommand, printDashUsage } from "./commands/dash.js";
3
3
  import { doctorCommand, printDoctorUsage } from "./commands/doctor.js";
4
4
  import { initCommand, printInitUsage } from "./commands/init.js";
5
+ import { plannerCommand, printPlannerUsage } from "./commands/planner.js";
5
6
  import { reviewCommand, printReviewUsage } from "./commands/review.js";
6
7
  import { runCommand, printRunUsage } from "./commands/run.js";
7
8
  import { upgradeCommand, printUpgradeUsage } from "./commands/upgrade.js";
@@ -25,6 +26,12 @@ const SUBCOMMANDS = [
25
26
  run: initCommand,
26
27
  help: printInitUsage,
27
28
  },
29
+ {
30
+ name: "planner",
31
+ summary: "Run the planner daemon (queue selection + PR shepherding).",
32
+ run: plannerCommand,
33
+ help: printPlannerUsage,
34
+ },
28
35
  {
29
36
  name: "review",
30
37
  summary: "Run an IRA retrospective pass (run / drain / weekly).",
@@ -0,0 +1,265 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Deferred, Effect, Layer, Logger, Runtime } from "effect";
5
+ import { ConfigLive, ConfigTag } from "../config.js";
6
+ import { installSignalHandlers, makeHealthReader, makePlannerLoop, makePlannerState, } from "../planner.js";
7
+ import { PLANNER_CAPABILITY_MODE_CHANGED_LOG, PLANNER_RECOMMENDATION_ACCEPTED_LOG, PLANNER_RECOMMENDATION_REJECTED_LOG, } from "../dashboard/projector.js";
8
+ import { createGithubGateway } from "../github.js";
9
+ import { startPlannerHttpServer, } from "../planner-http.js";
10
+ import { makeAnthropicCommentInterpreter } from "../planner-reviewer.js";
11
+ import { createPlannerReadClient } from "../planner-storage.js";
12
+ import { TelemetryLive } from "../telemetry.js";
13
+ export function printPlannerUsage() {
14
+ console.log(`runway planner — long-running daemon for queue selection and PR shepherding
15
+
16
+ Sub-1 scaffold (VA-473): boots the tick loop with no capabilities wired.
17
+ Capabilities arrive in VA-474..VA-482 per ADR 0004.
18
+
19
+ USAGE
20
+ runway planner run
21
+
22
+ VERBS
23
+ run Boot the planner in the foreground. Emits a planner.tick
24
+ heartbeat each RUNWAY_PLANNER_TICK_SECONDS. SIGINT / SIGTERM
25
+ triggers graceful shutdown (OTLP flush, then exit).
26
+
27
+ OPTIONS
28
+ --help, -h Show this help.
29
+
30
+ ENDPOINTS (VA-475 / VA-481)
31
+ GET /planner/health liveness probe
32
+ GET /planner/recommendations[?status=&limit=N] list (status: proposed|accepted|rejected|all)
33
+ GET /planner/recommendations/:id single
34
+ POST /planner/recommendations/:id/accept emits accepted
35
+ POST /planner/recommendations/:id/reject emits rejected (requires { reason })
36
+ GET /planner/actions[?status=] list (status: started|completed|failed|all)
37
+ GET /planner/capabilities per-capability modes (VA-481)
38
+ PUT /planner/capabilities/:capability body { mode: "manual"|"suggest"|"auto" }
39
+
40
+ Auth is network-layer (Tailscale ACLs). Same-origin-only CORS —
41
+ browsers at a different origin are rejected; route through the
42
+ dashboard's backend.
43
+
44
+ ENVIRONMENT
45
+ RUNWAY_PLANNER_TICK_SECONDS default 30 — tick cadence.
46
+ RUNWAY_PLANNER_HOST default 127.0.0.1 — HTTP bind host.
47
+ Set to a tailnet IP to expose the API
48
+ across your Tailscale network.
49
+ RUNWAY_PLANNER_PORT default 3002 — HTTP bind port.
50
+ RUNWAY_PLANNER_SQLITE_PATH optional — path to the dashboard's
51
+ SQLite file (the planner opens it
52
+ read-only). When unset, GET endpoints
53
+ return 503; POST endpoints still emit
54
+ OTLP (the dashboard validates on
55
+ receive).
56
+
57
+ LINEAR_API_KEY required (planner reads the queue).
58
+ OTEL_EXPORTER_OTLP_ENDPOINT optional — when set, planner emissions
59
+ stream to the dashboard's OTLP receiver.
60
+
61
+ SUPERVISION
62
+ Mac mini operators: install the bundled launchd plist at
63
+ ~/Library/LaunchAgents/com.valescoagency.runway-planner.plist
64
+ (template ships at templates/com.valescoagency.runway-planner.plist
65
+ in the runway package) and run \`launchctl load\` once. launchd
66
+ restarts the planner on crash and on login.
67
+
68
+ REFERENCES
69
+ ADR 0004 — Planner daemon architecture.
70
+ Epic VA-472 — Planner daemon (replatform shepherd).
71
+ `);
72
+ }
73
+ export function parsePlannerArgs(argv) {
74
+ if (argv.length === 0) {
75
+ throw new Error("missing verb — expected `run`. Run `runway planner --help`.");
76
+ }
77
+ const [verbRaw, ...rest] = argv;
78
+ if (verbRaw === "--help" || verbRaw === "-h") {
79
+ printPlannerUsage();
80
+ process.exit(0);
81
+ }
82
+ if (verbRaw !== "run") {
83
+ throw new Error(`unknown verb "${verbRaw}" — expected \`run\`. Run \`runway planner --help\`.`);
84
+ }
85
+ for (const arg of rest) {
86
+ if (arg === "--help" || arg === "-h") {
87
+ printPlannerUsage();
88
+ process.exit(0);
89
+ }
90
+ throw new Error(`unknown argument: ${arg}`);
91
+ }
92
+ return { verb: "run" };
93
+ }
94
+ export async function plannerCommand(argv) {
95
+ parsePlannerArgs(argv);
96
+ const LoggerLive = process.env.RUNWAY_JSON_LOGS === "1"
97
+ ? Logger.replace(Logger.defaultLogger, Logger.jsonLogger)
98
+ : Layer.empty;
99
+ const MainLayer = Layer.mergeAll(ConfigLive, TelemetryLive, LoggerLive);
100
+ const program = Effect.gen(function* () {
101
+ const config = yield* ConfigTag;
102
+ yield* Effect.logInfo(`[runway planner] boot host=${config.plannerHost} port=${config.plannerPort} tick=${config.plannerTickSeconds}s sqlite=${config.plannerSqlitePath ?? "(unset — GET endpoints will 503)"}`);
103
+ const shutdownSignal = yield* Deferred.make();
104
+ yield* installSignalHandlers(shutdownSignal);
105
+ const stateRef = yield* makePlannerState(Date.now());
106
+ // Capture the current runtime so the HTTP request handlers (which
107
+ // are non-Effect functions) can fire-and-forget OTLP log emissions
108
+ // through the same TelemetryLive layer the loop uses.
109
+ const runtime = yield* Effect.runtime();
110
+ const runFork = Runtime.runFork(runtime);
111
+ const emitter = makeDecisionEmitter(runFork);
112
+ // Open the read-only SQLite client when configured. Absent path
113
+ // is non-fatal — the daemon stays useful for POST endpoints (the
114
+ // dashboard validates against its own storage on receive); GET
115
+ // endpoints will return 503 with a clear remediation message.
116
+ const readClient = yield* Effect.acquireRelease(Effect.sync(() => config.plannerSqlitePath
117
+ ? createPlannerReadClient({ sqlitePath: config.plannerSqlitePath })
118
+ : null), (client) => Effect.sync(() => client?.close()));
119
+ const httpServer = yield* Effect.acquireRelease(Effect.promise(() => startPlannerHttpServer({
120
+ host: config.plannerHost,
121
+ port: config.plannerPort,
122
+ readClient,
123
+ getHealth: makeHealthReader(stateRef, packageVersion()),
124
+ emitter,
125
+ })), (server) => Effect.promise(() => server.close()));
126
+ yield* Effect.logInfo(`[runway planner] http listening on ${config.plannerHost}:${httpServer.port}`);
127
+ // VA-476: queue-selection capability is wired when we have a
128
+ // SQLite read client. Absent → tick body skips it (the daemon
129
+ // still serves /planner/health and the POST endpoints, just no
130
+ // recommendations get proposed).
131
+ //
132
+ // VA-477: mergeability capability needs both the SQLite read
133
+ // client AND a `baseBranch` configured. Without baseBranch the
134
+ // rebase-target is unknown, so the capability is dormant rather
135
+ // than rebasing against a guessed default.
136
+ const nowNano = () => String(Date.now() * 1_000_000);
137
+ const capabilities = readClient
138
+ ? {
139
+ queueSelection: {
140
+ readClient,
141
+ readyLabel: config.readyLabel,
142
+ now: nowNano,
143
+ },
144
+ // VA-480: drain spawn doesn't need baseBranch — it just
145
+ // shells out to `runway run --target` which resolves its
146
+ // own base branch. Wire it whenever the read client is
147
+ // available so queue-selection accepts can complete the
148
+ // recommendation → action lifecycle regardless of whether
149
+ // shepherd capabilities are configured.
150
+ drain: {
151
+ readClient,
152
+ cwd: process.cwd(),
153
+ now: nowNano,
154
+ },
155
+ ...(config.baseBranch
156
+ ? {
157
+ mergeability: {
158
+ readClient,
159
+ github: createGithubGateway(),
160
+ config,
161
+ cwd: process.cwd(),
162
+ baseBranch: config.baseBranch,
163
+ now: nowNano,
164
+ },
165
+ // VA-478: CI capability shares the same gating
166
+ // (SQLite + baseBranch) so all per-PR shepherd
167
+ // capabilities light up or stay dark together.
168
+ ci: {
169
+ readClient,
170
+ github: createGithubGateway(),
171
+ config,
172
+ cwd: process.cwd(),
173
+ baseBranch: config.baseBranch,
174
+ now: nowNano,
175
+ },
176
+ // VA-479: reviewer capability. LLM interpreter is
177
+ // wired only when ANTHROPIC_API_KEY is in the env;
178
+ // absent → verbatim-comment fallback (operator sees
179
+ // the raw body in evidence).
180
+ reviewer: {
181
+ readClient,
182
+ github: createGithubGateway(),
183
+ config,
184
+ cwd: process.cwd(),
185
+ baseBranch: config.baseBranch,
186
+ interpretComment: process.env.ANTHROPIC_API_KEY
187
+ ? makeAnthropicCommentInterpreter({
188
+ apiKey: process.env.ANTHROPIC_API_KEY,
189
+ })
190
+ : undefined,
191
+ now: nowNano,
192
+ },
193
+ }
194
+ : {}),
195
+ }
196
+ : {};
197
+ yield* makePlannerLoop(shutdownSignal, stateRef, capabilities);
198
+ }).pipe(Effect.scoped, Effect.provide(MainLayer));
199
+ await Effect.runPromise(program);
200
+ console.log("[runway planner] shutdown complete");
201
+ process.exit(0);
202
+ }
203
+ /**
204
+ * VA-475: turn the captured Effect runtime into a synchronous emitter
205
+ * the HTTP handlers can call without re-entering Effect.gen. Each
206
+ * call schedules a single log emission on the runtime; the
207
+ * TelemetryLive layer flushes it through the OTLP exporter to the
208
+ * dashboard's projector.
209
+ */
210
+ export function makeDecisionEmitter(runFork) {
211
+ return {
212
+ emitAccepted: ({ recommendationId, source, reason, timestampUnixNano }) => {
213
+ const attrs = {
214
+ recommendationId,
215
+ source,
216
+ };
217
+ if (reason)
218
+ attrs.reason = reason;
219
+ runFork(Effect.logInfo(PLANNER_RECOMMENDATION_ACCEPTED_LOG).pipe(Effect.annotateLogs(attrs),
220
+ // Force the log timestamp so the dashboard projector sees
221
+ // the operator-perceived decision moment, not the OTLP
222
+ // emit moment (close in practice; explicit is clearer).
223
+ Effect.annotateLogs({ timestampUnixNano })));
224
+ },
225
+ emitRejected: ({ recommendationId, source, reason, timestampUnixNano }) => {
226
+ runFork(Effect.logInfo(PLANNER_RECOMMENDATION_REJECTED_LOG).pipe(Effect.annotateLogs({
227
+ recommendationId,
228
+ source,
229
+ reason,
230
+ timestampUnixNano,
231
+ })));
232
+ },
233
+ emitCapabilityModeChanged: ({ capability, mode, updatedBy, timestampUnixNano, }) => {
234
+ runFork(Effect.logInfo(PLANNER_CAPABILITY_MODE_CHANGED_LOG).pipe(Effect.annotateLogs({
235
+ capability,
236
+ mode,
237
+ updatedBy,
238
+ timestampUnixNano,
239
+ })));
240
+ },
241
+ };
242
+ }
243
+ /**
244
+ * Resolve `package.json#version` for the health endpoint. Reads
245
+ * synchronously at boot — runtime cost is one stat + parse. Cached
246
+ * after first call.
247
+ */
248
+ let cachedVersion = null;
249
+ function packageVersion() {
250
+ if (cachedVersion !== null)
251
+ return cachedVersion;
252
+ try {
253
+ const here = dirname(fileURLToPath(import.meta.url));
254
+ const pkg = JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf8"));
255
+ cachedVersion = String(pkg.version ?? "unknown");
256
+ }
257
+ catch (err) {
258
+ // Surface the read failure to stderr so an operator running the
259
+ // foreground daemon can see what's wrong; the "unknown" fallback
260
+ // keeps the health endpoint usable.
261
+ console.error("[runway planner] could not resolve package version:", err instanceof Error ? err.message : String(err));
262
+ cachedVersion = "unknown";
263
+ }
264
+ return cachedVersion;
265
+ }
@@ -87,6 +87,23 @@ export function parseRunArgs(argv) {
87
87
  }
88
88
  opts.reviewRetries = n;
89
89
  }
90
+ else if (a === "--target") {
91
+ const v = argv[i + 1];
92
+ if (!v)
93
+ throw new Error("--target requires a Linear issue identifier");
94
+ if (!/^[A-Z]+-\d+$/.test(v)) {
95
+ throw new Error(`--target must be a Linear issue identifier like VA-123, got "${v}"`);
96
+ }
97
+ opts.target = v;
98
+ i += 1;
99
+ }
100
+ else if (a?.startsWith("--target=")) {
101
+ const v = a.slice("--target=".length);
102
+ if (!/^[A-Z]+-\d+$/.test(v)) {
103
+ throw new Error(`--target must be a Linear issue identifier like VA-123, got "${v}"`);
104
+ }
105
+ opts.target = v;
106
+ }
90
107
  else if (a === "--help" || a === "-h") {
91
108
  printRunUsage();
92
109
  process.exit(0);
@@ -129,6 +146,15 @@ OPTIONS
129
146
  and re-runs review. N caps the extra impl+review
130
147
  pairs per drain pickup. 0 disables retries entirely.
131
148
  Overrides RUNWAY_REVIEW_RETRIES. Default: 1.
149
+ --target VA-NNN
150
+ Restrict the drain to one Linear issue. Skips the
151
+ FIFO pick and claims this identifier instead. Used
152
+ by the planner daemon (VA-480) when accepting a
153
+ queue-selection recommendation; manual operators
154
+ can also use it to re-attempt a specific issue
155
+ without picking up everything else in the queue.
156
+ Fails fast if the issue isn't currently in the
157
+ ready queue.
132
158
  --help, -h Show this help.
133
159
 
134
160
  ENVIRONMENT
@@ -212,20 +238,26 @@ export async function runCommand(argv) {
212
238
  });
213
239
  const linear = createLinearGateway(config, linearLimiter);
214
240
  const github = createGithubGateway();
215
- return yield* drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths });
241
+ return yield* drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths, target: opts.target });
216
242
  }).pipe(Effect.scoped, Effect.provide(MainLayer));
217
243
  const result = await Effect.runPromise(program);
218
244
  console.log(`[runway] done — attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored} skipped=${result.skipped}`);
245
+ // VA-480: when `--target` was specified but no issue was attempted,
246
+ // the target wasn't in the ready queue. Surface as a non-zero
247
+ // exit so the planner's drain-spawn dispatcher routes the action
248
+ // to `action.failed` instead of `action.completed`. The drainQueue
249
+ // Effect already logged the warning; this is the exit-code half.
250
+ const targetMissed = opts.target !== undefined && result.attempts === 0;
219
251
  // Single-line, parser-friendly completion marker. Background
220
252
  // watchers (Claude Code's `run_in_background` bash task, CI,
221
253
  // scripts) can grep for `[runway:exit]` instead of guessing
222
254
  // whether the drain is still in flight.
223
- console.log(`[runway:exit] status=success attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored} skipped=${result.skipped}`);
255
+ console.log(`[runway:exit] status=${targetMissed ? "target_not_in_queue" : "success"} attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored} skipped=${result.skipped}`);
224
256
  // Hard exit so any lingering handle (OTel BatchSpanProcessor's
225
257
  // interval when OTEL_EXPORTER_OTLP_ENDPOINT is set, a Docker
226
258
  // stream Sandcastle left open, etc.) can't keep the process — and
227
259
  // the background task that launched it — alive after the drain is
228
260
  // logically done. By this point `Effect.scoped` has already torn
229
261
  // down its finalizers.
230
- process.exit(0);
262
+ process.exit(targetMissed ? 1 : 0);
231
263
  }
package/dist/config.js CHANGED
@@ -45,6 +45,28 @@ const configEffect = EConfig.all({
45
45
  metaFilterThresholdsJson: EConfig.option(EConfig.string("RUNWAY_META_FILTER_THRESHOLDS")),
46
46
  runwayRepoProjectName: EConfig.option(EConfig.string("RUNWAY_REPO_PROJECT_NAME")),
47
47
  metaWeeklyModel: EConfig.option(EConfig.string("RUNWAY_META_WEEKLY_MODEL")),
48
+ // VA-482: inline shepherd is retired — the four `RUNWAY_SHEPHERD_*`
49
+ // env vars (ENABLED / POLL_INTERVAL_SECONDS / MAX_ITERATIONS /
50
+ // MAX_WALL_SECONDS) are intentionally removed. Their work moved to
51
+ // the long-running `runway planner` daemon (capabilities VA-477..479)
52
+ // which has its own tick + budget knobs (`RUNWAY_PLANNER_*`).
53
+ // `RUNWAY_PR_REVIEWER_BOT_LOGIN` is preserved because the planner's
54
+ // reviewer dispatcher still consumes it.
55
+ prReviewerBotLogin: EConfig.option(EConfig.string("RUNWAY_PR_REVIEWER_BOT_LOGIN")),
56
+ // VA-473: planner daemon scaffold. Tick cadence + bind address for
57
+ // the long-running process. No capabilities wired yet — these
58
+ // values are consumed by `runway planner run` (heartbeat loop) and
59
+ // by the HTTP listener wired in VA-475.
60
+ plannerTickSeconds: EConfig.integer("RUNWAY_PLANNER_TICK_SECONDS").pipe(EConfig.withDefault(30), EConfig.validate({
61
+ message: "RUNWAY_PLANNER_TICK_SECONDS must be a positive integer",
62
+ validation: (n) => n > 0,
63
+ })),
64
+ plannerHost: EConfig.string("RUNWAY_PLANNER_HOST").pipe(EConfig.withDefault("127.0.0.1")),
65
+ plannerPort: EConfig.integer("RUNWAY_PLANNER_PORT").pipe(EConfig.withDefault(3002), EConfig.validate({
66
+ message: "RUNWAY_PLANNER_PORT must be a positive integer",
67
+ validation: (n) => n > 0 && n < 65536,
68
+ })),
69
+ plannerSqlitePath: EConfig.option(EConfig.string("RUNWAY_PLANNER_SQLITE_PATH")),
48
70
  }).pipe(Effect.map((raw) => ({
49
71
  linearApiKey: raw.linearApiKey,
50
72
  opServiceAccountToken: Option.getOrUndefined(raw.opServiceAccountToken),
@@ -66,6 +88,11 @@ const configEffect = EConfig.all({
66
88
  metaFilterThresholds: parseMetaFilterThresholdsJson(Option.getOrUndefined(raw.metaFilterThresholdsJson)),
67
89
  runwayRepoProjectName: Option.getOrUndefined(raw.runwayRepoProjectName),
68
90
  metaWeeklyModel: Option.getOrUndefined(raw.metaWeeklyModel),
91
+ prReviewerBotLogin: Option.getOrUndefined(raw.prReviewerBotLogin),
92
+ plannerTickSeconds: raw.plannerTickSeconds,
93
+ plannerHost: raw.plannerHost,
94
+ plannerPort: raw.plannerPort,
95
+ plannerSqlitePath: Option.getOrUndefined(raw.plannerSqlitePath),
69
96
  })));
70
97
  /**
71
98
  * VA-359: Context tag for the resolved RunwayConfig. Provided by
@@ -148,6 +148,183 @@ function strArrayAttr(v) {
148
148
  * in lock-step with `orchestrator.ts`.
149
149
  */
150
150
  export const DRAIN_STARTED_LOG = "drain.started";
151
+ /**
152
+ * VA-474: log body strings the planner daemon emits for its six
153
+ * event types (ADR 0004 § Writer model). Kept in lock-step with the
154
+ * planner's `Effect.logInfo` call sites — drift between projector
155
+ * and planner means events stop projecting into the read-model.
156
+ */
157
+ export const PLANNER_RECOMMENDATION_PROPOSED_LOG = "planner.recommendation.proposed";
158
+ export const PLANNER_RECOMMENDATION_ACCEPTED_LOG = "planner.recommendation.accepted";
159
+ export const PLANNER_RECOMMENDATION_REJECTED_LOG = "planner.recommendation.rejected";
160
+ export const PLANNER_ACTION_STARTED_LOG = "planner.action.started";
161
+ export const PLANNER_ACTION_COMPLETED_LOG = "planner.action.completed";
162
+ export const PLANNER_ACTION_FAILED_LOG = "planner.action.failed";
163
+ /**
164
+ * VA-481: emitted by the planner's HTTP PUT /planner/capabilities/:capability
165
+ * handler when an operator (or the planner's auto-promotion logic)
166
+ * changes a capability's mode (manual / suggest / auto). The dashboard
167
+ * projects each event into the `planner_capability_modes` table; the
168
+ * planner reads the table on every tick so toggles take effect on
169
+ * the next iteration without a daemon restart.
170
+ */
171
+ export const PLANNER_CAPABILITY_MODE_CHANGED_LOG = "planner.capability.mode_changed";
172
+ export const PLANNER_CAPABILITY_MODES = new Set([
173
+ "manual",
174
+ "suggest",
175
+ "auto",
176
+ ]);
177
+ const PLANNER_CAPABILITIES = new Set([
178
+ "queue-selection",
179
+ "shepherd-mergeability",
180
+ "shepherd-ci",
181
+ "shepherd-reviewer",
182
+ ]);
183
+ /**
184
+ * VA-474: scan an OTLP logs payload for the six planner-event
185
+ * markers. Records that fail validation (missing required
186
+ * attribute, unknown capability, unparseable `evidence`) are
187
+ * dropped — the projector never crashes on a bad payload, but it
188
+ * also never fabricates fields the emitter didn't send.
189
+ */
190
+ export function extractPlannerMarkers(payload) {
191
+ const out = [];
192
+ for (const rl of payload.resourceLogs ?? []) {
193
+ for (const sl of rl.scopeLogs ?? []) {
194
+ for (const rec of sl.logRecords ?? []) {
195
+ const body = rec.body?.stringValue;
196
+ if (!body || !body.startsWith("planner."))
197
+ continue;
198
+ const ts = rec.timeUnixNano ?? rec.observedTimeUnixNano;
199
+ if (!ts)
200
+ continue;
201
+ const attrs = attributesToStringMap(rec.attributes);
202
+ if (body === PLANNER_RECOMMENDATION_PROPOSED_LOG) {
203
+ const recommendationId = attrs.recommendationId;
204
+ const capability = attrs.capability;
205
+ const target = attrs.target;
206
+ const summary = attrs.summary;
207
+ if (!recommendationId ||
208
+ !capability ||
209
+ !PLANNER_CAPABILITIES.has(capability) ||
210
+ !target ||
211
+ !summary) {
212
+ continue;
213
+ }
214
+ let evidence = {};
215
+ const rawEvidence = attrs.evidence;
216
+ if (rawEvidence) {
217
+ try {
218
+ const parsed = JSON.parse(rawEvidence);
219
+ if (parsed &&
220
+ typeof parsed === "object" &&
221
+ !Array.isArray(parsed)) {
222
+ evidence = parsed;
223
+ }
224
+ }
225
+ catch {
226
+ // Malformed evidence JSON is non-fatal — emit with {}
227
+ // so the recommendation still lands.
228
+ }
229
+ }
230
+ out.push({
231
+ kind: "recommendation.proposed",
232
+ recommendationId,
233
+ capability: capability,
234
+ target,
235
+ summary,
236
+ evidence,
237
+ autoApproveEligible: attrs.autoApproveEligible === "true",
238
+ timestampUnixNano: ts,
239
+ });
240
+ }
241
+ else if (body === PLANNER_RECOMMENDATION_ACCEPTED_LOG ||
242
+ body === PLANNER_RECOMMENDATION_REJECTED_LOG) {
243
+ const recommendationId = attrs.recommendationId;
244
+ const source = attrs.source;
245
+ if (!recommendationId ||
246
+ (source !== "operator" && source !== "auto")) {
247
+ continue;
248
+ }
249
+ const status = body === PLANNER_RECOMMENDATION_ACCEPTED_LOG
250
+ ? "accepted"
251
+ : "rejected";
252
+ out.push({
253
+ kind: status === "accepted"
254
+ ? "recommendation.accepted"
255
+ : "recommendation.rejected",
256
+ recommendationId,
257
+ status,
258
+ source,
259
+ reason: attrs.reason ?? null,
260
+ timestampUnixNano: ts,
261
+ });
262
+ }
263
+ else if (body === PLANNER_ACTION_STARTED_LOG) {
264
+ const actionId = attrs.actionId;
265
+ const capability = attrs.capability;
266
+ const target = attrs.target;
267
+ if (!actionId ||
268
+ !capability ||
269
+ !PLANNER_CAPABILITIES.has(capability) ||
270
+ !target) {
271
+ continue;
272
+ }
273
+ out.push({
274
+ kind: "action.started",
275
+ actionId,
276
+ recommendationId: attrs.recommendationId ?? null,
277
+ capability: capability,
278
+ target,
279
+ timestampUnixNano: ts,
280
+ });
281
+ }
282
+ else if (body === PLANNER_ACTION_COMPLETED_LOG) {
283
+ const actionId = attrs.actionId;
284
+ const outcome = attrs.outcome;
285
+ if (!actionId || !outcome)
286
+ continue;
287
+ out.push({
288
+ kind: "action.completed",
289
+ actionId,
290
+ outcome,
291
+ timestampUnixNano: ts,
292
+ });
293
+ }
294
+ else if (body === PLANNER_ACTION_FAILED_LOG) {
295
+ const actionId = attrs.actionId;
296
+ const error = attrs.error;
297
+ if (!actionId || !error)
298
+ continue;
299
+ out.push({
300
+ kind: "action.failed",
301
+ actionId,
302
+ error,
303
+ timestampUnixNano: ts,
304
+ });
305
+ }
306
+ else if (body === PLANNER_CAPABILITY_MODE_CHANGED_LOG) {
307
+ const capability = attrs.capability;
308
+ const mode = attrs.mode;
309
+ if (!capability ||
310
+ !PLANNER_CAPABILITIES.has(capability) ||
311
+ !mode ||
312
+ !PLANNER_CAPABILITY_MODES.has(mode)) {
313
+ continue;
314
+ }
315
+ out.push({
316
+ kind: "capability.mode_changed",
317
+ capability: capability,
318
+ mode: mode,
319
+ updatedBy: attrs.updatedBy ?? "operator",
320
+ timestampUnixNano: ts,
321
+ });
322
+ }
323
+ }
324
+ }
325
+ }
326
+ return out;
327
+ }
151
328
  /**
152
329
  * VA-455: scan an OTLP logs payload for `drain.started` markers.
153
330
  * Each match becomes an `ActiveDrainMarker` carrying the drain's