@valescoagency/runway 0.6.0 → 0.7.1

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
@@ -172,6 +172,14 @@ export LINEAR_API_KEY=lin_api_...
172
172
  # export RUNWAY_IN_REVIEW_STATUS="In Review"
173
173
  # export RUNWAY_HITL_LABEL="ready-for-human"
174
174
  # export RUNWAY_MAX_ITERATIONS=5
175
+ # export RUNWAY_COMMENT_AUTHOR_ALLOWLIST="Reviewer Bot,Jane Reviewer"
176
+ # optional, comma-separated Linear user names whose comments on a
177
+ # re-queued issue surface as "Review feedback from prior attempts"
178
+ # in the implement prompt. Defaults to the Linear user the API key
179
+ # authenticates as (which is both the reviewer-agent's identity
180
+ # and, in the solo-dev case, the repo owner). Set this only when
181
+ # the reviewer agent and the repo owner are split across distinct
182
+ # Linear identities.
175
183
  ```
176
184
 
177
185
  `RUNWAY_HITL_LABEL` defaults to `ready-for-human`, matching the
@@ -384,7 +392,7 @@ These are tractable, just not v1.
384
392
 
385
393
  ## Status
386
394
 
387
- 0.6.0 — production-shaped and dogfooded against live Linear queues.
395
+ 0.7.1 — production-shaped and dogfooded against live Linear queues.
388
396
  The end-to-end pipeline (init → run → review → PR) is stable; surface
389
397
  may still shift as the orchestrator's policy and iteration mechanics
390
398
  mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
package/dist/config.js CHANGED
@@ -20,6 +20,7 @@ const configEffect = EConfig.all({
20
20
  message: "RUNWAY_MAX_ITERATIONS must be a positive integer",
21
21
  validation: (n) => n > 0,
22
22
  })),
23
+ commentAuthorAllowlist: EConfig.option(EConfig.string("RUNWAY_COMMENT_AUTHOR_ALLOWLIST")),
23
24
  }).pipe(Effect.map((raw) => ({
24
25
  linearApiKey: raw.linearApiKey,
25
26
  opServiceAccountToken: Option.getOrUndefined(raw.opServiceAccountToken),
@@ -31,6 +32,10 @@ const configEffect = EConfig.all({
31
32
  inReviewStatus: raw.inReviewStatus,
32
33
  hitlLabel: raw.hitlLabel,
33
34
  maxIterations: raw.maxIterations,
35
+ commentAuthorAllowlist: Option.getOrUndefined(raw.commentAuthorAllowlist)
36
+ ?.split(",")
37
+ .map((s) => s.trim())
38
+ .filter(Boolean),
34
39
  })));
35
40
  /**
36
41
  * VA-359: Context tag for the resolved RunwayConfig. Provided by
package/dist/implement.js CHANGED
@@ -44,7 +44,7 @@ export function parseImplVerdict(result) {
44
44
  * completionSignal.
45
45
  */
46
46
  export const runImplementLoop = (issue, deps, branch) => Effect.gen(function* () {
47
- const { config, cwd, baseBranch, policy } = deps;
47
+ const { config, cwd, baseBranch, policy, priorFeedback } = deps;
48
48
  const maxIters = Math.max(1, config.maxIterations);
49
49
  let prevSummary = "";
50
50
  let implementResult;
@@ -54,6 +54,12 @@ export const runImplementLoop = (issue, deps, branch) => Effect.gen(function* ()
54
54
  issue,
55
55
  policy,
56
56
  previousIterations: prevSummary,
57
+ // VA-383: surface prior-attempt review feedback on every
58
+ // iteration of this run — the rejection blockers that put
59
+ // the issue back to Todo are equally relevant on iteration 1
60
+ // and iteration N+1, since the implementer keeps drifting
61
+ // toward the same code paths until corrected.
62
+ priorReviewFeedback: priorFeedback,
57
63
  }));
58
64
  implementResult = yield* runSandcastle({
59
65
  agent: claudeCode("claude-opus-4-7"),
package/dist/linear.js CHANGED
@@ -26,11 +26,92 @@ const IssueLabelNodeSchema = Schema.Struct({
26
26
  const ProjectNodeSchema = Schema.Struct({
27
27
  id: Schema.String,
28
28
  });
29
+ // VA-394: minimal shape needed to ask "is this blocker still active?"
30
+ // — we only read the relation's `type` (filtering for `blocks`) and
31
+ // the related issue's workflow state `type`. Schemas stay narrow so
32
+ // the validation cost per candidate stays small.
33
+ const IssueRelationNodeSchema = Schema.Struct({
34
+ id: Schema.String,
35
+ type: Schema.String,
36
+ });
37
+ const WorkflowStateTypeNodeSchema = Schema.Struct({
38
+ type: Schema.String,
39
+ });
40
+ // VA-383: comments fetched for surfacing prior-attempt review feedback
41
+ // to the implementer. `user` is nullable on Linear's side (system /
42
+ // integration comments can lack an author); we map a missing user to
43
+ // the empty string so downstream filters can match by name without a
44
+ // null-guard at every call site.
45
+ const CommentNodeSchema = Schema.Struct({
46
+ id: Schema.String,
47
+ body: Schema.String,
48
+ createdAt: Schema.Union(Schema.String, Schema.DateFromSelf),
49
+ user: Schema.NullOr(Schema.Struct({
50
+ id: Schema.String,
51
+ name: Schema.String,
52
+ })),
53
+ });
54
+ const ViewerSchema = Schema.Struct({
55
+ id: Schema.String,
56
+ name: Schema.String,
57
+ });
29
58
  const decodeIssueNode = Schema.decodeUnknownSync(IssueNodeSchema);
30
59
  const decodeWorkflowStateNode = Schema.decodeUnknownSync(WorkflowStateNodeSchema);
31
60
  const decodeTeamNode = Schema.decodeUnknownSync(TeamNodeSchema);
32
61
  const decodeIssueLabelNode = Schema.decodeUnknownSync(IssueLabelNodeSchema);
33
62
  const decodeProjectNode = Schema.decodeUnknownSync(ProjectNodeSchema);
63
+ const decodeCommentNode = Schema.decodeUnknownSync(CommentNodeSchema);
64
+ const decodeViewer = Schema.decodeUnknownSync(ViewerSchema);
65
+ const decodeIssueRelationNode = Schema.decodeUnknownSync(IssueRelationNodeSchema);
66
+ const decodeWorkflowStateTypeNode = Schema.decodeUnknownSync(WorkflowStateTypeNodeSchema);
67
+ // VA-394: Linear workflow state `type` values that mean "the blocker
68
+ // has resolved." Anything else (`triage`, `backlog`, `unstarted`,
69
+ // `started`) still gates the blocked issue from runway pickup.
70
+ const TERMINAL_STATE_TYPES = new Set(["completed", "canceled"]);
71
+ const isTerminalStateType = (type) => TERMINAL_STATE_TYPES.has(type);
72
+ /**
73
+ * VA-394: returns true when `issue` carries `hitlLabel`. Runway writes
74
+ * this label on HITL escapes, and triage may apply it to flag
75
+ * human-only work — either way the contract is "do not pick up." Uses
76
+ * the SDK's chained `labels()` call rather than asking for labels
77
+ * inline at the candidate-fetch site so the schema for `IssueNode`
78
+ * stays narrow.
79
+ */
80
+ async function hasHitlLabel(issue, hitlLabel) {
81
+ const labels = await issue.labels();
82
+ for (const raw of labels.nodes) {
83
+ if (decodeIssueLabelNode(raw).name === hitlLabel)
84
+ return true;
85
+ }
86
+ return false;
87
+ }
88
+ /**
89
+ * VA-394: returns true when `issue` has at least one `inverseRelations`
90
+ * record of type `blocks` whose blocker is in a non-terminal workflow
91
+ * state. Other relation types (`duplicate`, `related`) do not gate
92
+ * pickup. A blocker with a missing or undecodable state is treated as
93
+ * inactive (best-effort: drift in one relation should not stop the
94
+ * queue), but the decoder still throws `ParseError` on outright
95
+ * malformed relation nodes — caught upstream as `LinearSchemaError`.
96
+ */
97
+ async function hasActiveBlocker(issue) {
98
+ const relations = await issue.inverseRelations();
99
+ for (const rawRel of relations.nodes) {
100
+ const rel = decodeIssueRelationNode(rawRel);
101
+ if (rel.type !== "blocks")
102
+ continue;
103
+ const blocker = await rawRel.issue;
104
+ if (!blocker)
105
+ continue;
106
+ const blockerState = await blocker.state;
107
+ if (!blockerState)
108
+ continue;
109
+ const stateType = decodeWorkflowStateTypeNode(blockerState).type;
110
+ if (!isTerminalStateType(stateType))
111
+ return true;
112
+ }
113
+ return false;
114
+ }
34
115
  export class LinearNotFound extends Data.TaggedError("LinearNotFound") {
35
116
  }
36
117
  export class LinearUnauthorized extends Data.TaggedError("LinearUnauthorized") {
@@ -219,15 +300,30 @@ export function createLinearGateway(config, limiter = null) {
219
300
  // VA-360: validate every issue node through the schema —
220
301
  // a single drifted issue surfaces as `LinearSchemaError`
221
302
  // instead of a downstream `cannot read property X`.
222
- return issues.nodes.map((raw) => {
303
+ //
304
+ // VA-394: per-candidate eligibility — skip issues carrying
305
+ // `config.hitlLabel` (triage's "human-only" marker, also
306
+ // applied by runway itself on prior HITL escapes), and
307
+ // skip issues with at least one active `blocks` relation
308
+ // pointing at them. The candidate set is the Todo queue,
309
+ // typically a handful of issues; the N+1 SDK calls per
310
+ // candidate go through the rate limiter and the retry
311
+ // policy alongside everything else.
312
+ const eligible = [];
313
+ for (const raw of issues.nodes) {
223
314
  const i = decodeIssueNode(raw);
224
- return {
315
+ if (await hasHitlLabel(raw, config.hitlLabel))
316
+ continue;
317
+ if (await hasActiveBlocker(raw))
318
+ continue;
319
+ eligible.push({
225
320
  id: i.id,
226
321
  identifier: i.identifier,
227
322
  title: i.title,
228
323
  description: i.description ?? "",
229
- };
230
- });
324
+ });
325
+ }
326
+ return eligible;
231
327
  },
232
328
  catch: (err) => classifyLinearError(err, "fetchReady"),
233
329
  }), { call: "fetchReady" }));
@@ -293,6 +389,42 @@ export function createLinearGateway(config, limiter = null) {
293
389
  catch: (err) => classifyLinearError(err, "comment"),
294
390
  }), { call: "comment" }));
295
391
  },
392
+ fetchComments(issueId) {
393
+ return gate(applyLinearPolicy(Effect.tryPromise({
394
+ try: async () => {
395
+ // Linear SDK paginates by default — use the issue-scoped
396
+ // accessor and let the SDK's first page suffice for the
397
+ // common case (Runway's HITL + reviewer flow rarely
398
+ // produces more than a handful per attempt). If issues
399
+ // start blowing past the page limit, fold in pagination.
400
+ const issue = await client.issue(issueId);
401
+ const comments = await issue.comments();
402
+ const decoded = comments.nodes.map((raw) => {
403
+ const c = decodeCommentNode(raw);
404
+ const createdAt = c.createdAt instanceof Date ? c.createdAt : new Date(c.createdAt);
405
+ return {
406
+ id: c.id,
407
+ author: c.user?.name ?? "",
408
+ body: c.body,
409
+ createdAt,
410
+ };
411
+ });
412
+ // VA-383: ascending by createdAt so the impl agent reads
413
+ // feedback in the order it was given.
414
+ return [...decoded].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
415
+ },
416
+ catch: (err) => classifyLinearError(err, "fetchComments"),
417
+ }), { call: "fetchComments" }));
418
+ },
419
+ viewer() {
420
+ return gate(applyLinearPolicy(Effect.tryPromise({
421
+ try: async () => {
422
+ const v = await client.viewer;
423
+ return decodeViewer({ id: v.id, name: v.name });
424
+ },
425
+ catch: (err) => classifyLinearError(err, "viewer"),
426
+ }), { call: "viewer" }));
427
+ },
296
428
  };
297
429
  }
298
430
  export async function validateLinearConfig(config) {
@@ -5,6 +5,7 @@ import { detectBaseBranch, pruneStaleAgentBranch } from "./git.js";
5
5
  import { loadPolicy } from "./policy.js";
6
6
  import { flagHitl, handleProcessFailure } from "./hitl.js";
7
7
  import { runImplementLoop } from "./implement.js";
8
+ import { formatPriorFeedback } from "./prompts.js";
8
9
  import { runReviewPass } from "./review.js";
9
10
  import { finalize } from "./finalize.js";
10
11
  // Re-exports so existing callers (commands/run.ts) and tests
@@ -58,10 +59,24 @@ export const drainQueue = (deps, opts = {}) => Effect.gen(function* () {
58
59
  yield* Effect.logInfo("base branch resolved").pipe(Effect.annotateLogs({ baseBranch: baseBranchResolved }));
59
60
  const policy = loadPolicy(deps.cwd, { allowPathsOverride: opts.allowPaths });
60
61
  yield* Effect.logInfo(`policy source: ${policy.source}`);
62
+ // VA-383: resolve the Linear viewer once per drain to seed the
63
+ // default comment-author allowlist. If the operator has provided
64
+ // an explicit allowlist, skip the lookup. If the lookup fails,
65
+ // fall back to an empty allowlist — `formatPriorFeedback` will
66
+ // emit nothing and the run proceeds with description-only
67
+ // context (matching VA-383's "never blocks the run" AC).
68
+ const commentAuthorAllowlist = config
69
+ .commentAuthorAllowlist?.length
70
+ ? config.commentAuthorAllowlist
71
+ : yield* linear.viewer().pipe(Effect.map((v) => [v.name]), Effect.catchAll((err) => Effect.logWarning(`VA-383: viewer() failed; prior-attempt feedback will be empty (${err.message ?? String(err)})`).pipe(Effect.as([]))));
72
+ yield* Effect.logInfo(`comment author allowlist: ${commentAuthorAllowlist.length === 0
73
+ ? "(empty — prior-feedback surfacing disabled)"
74
+ : commentAuthorAllowlist.join(", ")}`);
61
75
  const runDeps = {
62
76
  ...deps,
63
77
  baseBranch: baseBranchResolved,
64
78
  policy,
79
+ commentAuthorAllowlist,
65
80
  };
66
81
  // VA-344: never re-pick an issue in the same invocation, even if
67
82
  // VA-342 reverted it to `Todo`. Without this, a deterministic
@@ -133,8 +148,17 @@ export const drainQueue = (deps, opts = {}) => Effect.gen(function* () {
133
148
  * Linear side-effects so the phases stay independently testable.
134
149
  */
135
150
  const processIssue = (issue, deps) => Effect.gen(function* () {
136
- const { config, linear } = deps;
151
+ const { config, linear, commentAuthorAllowlist } = deps;
137
152
  const branch = `agent/${issue.identifier.toLowerCase()}`;
153
+ // VA-383: fetch prior comments BEFORE posting "Runway picked up
154
+ // this issue" so we don't see our own bookkeeping. The allowlist
155
+ // resolved at drain startup filters to reviewer/repo-owner
156
+ // identities; `formatPriorFeedback` additionally strips known
157
+ // runway bookkeeping prefixes. A fetch failure logs a warning and
158
+ // the run proceeds with empty feedback (never blocks).
159
+ const priorFeedback = yield* (commentAuthorAllowlist.length === 0
160
+ ? Effect.succeed("")
161
+ : linear.fetchComments(issue.id).pipe(Effect.map((cs) => formatPriorFeedback(cs, commentAuthorAllowlist)), Effect.catchAll((err) => Effect.logWarning(`${issue.identifier}: fetchComments failed; proceeding with description-only context (${err.message ?? String(err)})`).pipe(Effect.as("")))));
138
162
  yield* linear.transition(issue.id, config.inProgressStatus);
139
163
  yield* linear.comment(issue.id, `Runway picked up this issue. Branch: \`${branch}\`.`);
140
164
  // VA-366: if a prior attempt left an `agent/<id>` branch behind
@@ -143,7 +167,7 @@ const processIssue = (issue, deps) => Effect.gen(function* () {
143
167
  // current `baseBranch` HEAD. Preserves the branch when it carries
144
168
  // real work. Best-effort — failures here don't abort the issue.
145
169
  yield* pruneStaleAgentBranch(deps.cwd, deps.baseBranch, branch).pipe(Effect.catchAll((err) => Effect.logWarning(`${issue.identifier}: pruneStaleAgentBranch failed (continuing): ${err.message}`)));
146
- const impl = yield* runImplementLoop(issue, deps, branch);
170
+ const impl = yield* runImplementLoop(issue, { ...deps, priorFeedback }, branch);
147
171
  if (impl.kind === "hitl") {
148
172
  yield* flagHitl(issue, deps, impl.reason);
149
173
  return { kind: "hitl", detail: impl.reason };
package/dist/prompts.js CHANGED
@@ -54,6 +54,7 @@ function implementVars(args) {
54
54
  ISSUE_TITLE: args.issue.title,
55
55
  ISSUE_DESCRIPTION: args.issue.description || "(no description)",
56
56
  PREVIOUS_ITERATIONS: args.previousIterations,
57
+ PRIOR_REVIEW_FEEDBACK: args.priorReviewFeedback,
57
58
  POLICY_FORBIDDEN_BULLET: renderForbiddenPathsBullet(args.policy),
58
59
  };
59
60
  }
@@ -66,6 +67,67 @@ function reviewVars(args) {
66
67
  COMMITS: args.commits || "(no commits)",
67
68
  };
68
69
  }
70
+ /**
71
+ * VA-383: known orchestrator-emitted comment prefixes that are
72
+ * bookkeeping noise, not feedback the impl agent should learn from.
73
+ * Filtering by author alone is insufficient because runway and the
74
+ * reviewer both post under the API-key owner's identity — so
75
+ * "Runway picked up this issue. Branch: …" would otherwise leak into
76
+ * the prior-feedback block on every retry.
77
+ *
78
+ * Intentionally does NOT include `Runway flagged for human review:` —
79
+ * that comment carries the reviewer's rejection reason, which is the
80
+ * load-bearing signal VA-383 exists to surface.
81
+ */
82
+ const RUNWAY_BOOKKEEPING_PREFIXES = [
83
+ "Runway picked up this issue",
84
+ "Runway opened a PR for review:",
85
+ "Runway hit a startup failure",
86
+ "Note: could not apply",
87
+ ];
88
+ const isRunwayBookkeeping = (body) => RUNWAY_BOOKKEEPING_PREFIXES.some((p) => body.startsWith(p));
89
+ /**
90
+ * VA-383: filter + format the comment set fetched off an issue into
91
+ * the "Review feedback from prior attempts" block the implement
92
+ * prompt renders. Pure, side-effect-free, no Linear deps — the
93
+ * orchestrator owns I/O, this owns the shape.
94
+ *
95
+ * Comments survive the filter when **all** of:
96
+ * 1. author name is in `allowlist` (case-sensitive exact match)
97
+ * 2. body does NOT begin with a known runway bookkeeping prefix
98
+ * (orchestrator transitions, PR-opened announcements, etc.)
99
+ *
100
+ * Returns the empty string when nothing makes it through — the
101
+ * `{{PRIOR_REVIEW_FEEDBACK}}` slot expands to empty and the section
102
+ * disappears from the rendered prompt entirely.
103
+ */
104
+ export function formatPriorFeedback(comments, allowlist) {
105
+ const allowed = new Set(allowlist);
106
+ const surviving = comments.filter((c) => allowed.has(c.author) && !isRunwayBookkeeping(c.body));
107
+ if (surviving.length === 0)
108
+ return "";
109
+ // Already sorted ascending by createdAt at the gateway boundary —
110
+ // re-sort here defensively in case a caller hands us an unsorted
111
+ // array (the test fixture, for instance, asserts ordering explicitly).
112
+ const ordered = [...surviving].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
113
+ const entries = ordered.map((c) => {
114
+ const when = c.createdAt.toISOString();
115
+ return `### ${when} — ${c.author}\n\n${c.body.trim()}`;
116
+ });
117
+ return [
118
+ "# Review feedback from prior attempts",
119
+ "",
120
+ "Prior runway attempts on this issue were rejected. Each block below is a",
121
+ "review comment to internalize **before** redoing the work — these are the",
122
+ "specific blockers that caused the previous attempt to be rejected. The",
123
+ "issue description above is the spec; the comments here are augmenting",
124
+ "guidance. If the two contradict, prefer the spec and call out the conflict",
125
+ "in your final message.",
126
+ "",
127
+ entries.join("\n\n"),
128
+ "",
129
+ ].join("\n");
130
+ }
69
131
  /**
70
132
  * VA-352 (absorbed from policy.ts in VA-361): render the bullet
71
133
  * sentence the impl prompt shows the agent. Stable formatting so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valescoagency/runway",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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": {
@@ -41,26 +41,26 @@
41
41
  "dependencies": {
42
42
  "@ai-hero/sandcastle": "^0.5.10",
43
43
  "@effect/opentelemetry": "^0.63.0",
44
- "@linear/sdk": "^41.0.0",
44
+ "@linear/sdk": "^84.0.0",
45
45
  "@opentelemetry/api": "^1.9.1",
46
46
  "@opentelemetry/exporter-trace-otlp-http": "^0.217.0",
47
47
  "@opentelemetry/resources": "^2.7.1",
48
48
  "@opentelemetry/sdk-trace-base": "^2.7.1",
49
49
  "@opentelemetry/sdk-trace-node": "^2.7.1",
50
- "@opentelemetry/semantic-conventions": "^1.40.0",
50
+ "@opentelemetry/semantic-conventions": "^1.41.1",
51
51
  "effect": "^3.21.2",
52
- "execa": "^9.5.2",
52
+ "execa": "^9.6.1",
53
53
  "yaml": "^2.9.0",
54
- "zod": "^3.23.8"
54
+ "zod": "^4.4.3"
55
55
  },
56
56
  "devDependencies": {
57
- "@commitlint/cli": "^21.0.0",
58
- "@commitlint/config-conventional": "^21.0.0",
59
- "@types/node": "^22.10.0",
57
+ "@commitlint/cli": "^21.0.1",
58
+ "@commitlint/config-conventional": "^21.0.1",
59
+ "@types/node": "^25.7.0",
60
60
  "lefthook": "^2.1.6",
61
- "tsx": "^4.19.2",
62
- "typescript": "^5.7.2",
63
- "vitest": "^4.1.5"
61
+ "tsx": "^4.21.0",
62
+ "typescript": "^6.0.3",
63
+ "vitest": "^4.1.6"
64
64
  },
65
65
  "engines": {
66
66
  "node": ">=22"
@@ -6,6 +6,8 @@ You are an autonomous coding agent working on a single Linear issue.
6
6
 
7
7
  {{ISSUE_DESCRIPTION}}
8
8
 
9
+ {{PRIOR_REVIEW_FEEDBACK}}
10
+
9
11
  {{PREVIOUS_ITERATIONS}}
10
12
 
11
13
  # Repository context