@valescoagency/runway 0.6.0 → 0.7.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 +8 -0
- package/dist/config.js +5 -0
- package/dist/implement.js +7 -1
- package/dist/linear.js +56 -0
- package/dist/orchestrator.js +26 -2
- package/dist/prompts.js +62 -0
- package/package.json +11 -11
- package/prompts/implement.md +2 -0
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
|
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,31 @@ const IssueLabelNodeSchema = Schema.Struct({
|
|
|
26
26
|
const ProjectNodeSchema = Schema.Struct({
|
|
27
27
|
id: Schema.String,
|
|
28
28
|
});
|
|
29
|
+
// VA-383: comments fetched for surfacing prior-attempt review feedback
|
|
30
|
+
// to the implementer. `user` is nullable on Linear's side (system /
|
|
31
|
+
// integration comments can lack an author); we map a missing user to
|
|
32
|
+
// the empty string so downstream filters can match by name without a
|
|
33
|
+
// null-guard at every call site.
|
|
34
|
+
const CommentNodeSchema = Schema.Struct({
|
|
35
|
+
id: Schema.String,
|
|
36
|
+
body: Schema.String,
|
|
37
|
+
createdAt: Schema.Union(Schema.String, Schema.DateFromSelf),
|
|
38
|
+
user: Schema.NullOr(Schema.Struct({
|
|
39
|
+
id: Schema.String,
|
|
40
|
+
name: Schema.String,
|
|
41
|
+
})),
|
|
42
|
+
});
|
|
43
|
+
const ViewerSchema = Schema.Struct({
|
|
44
|
+
id: Schema.String,
|
|
45
|
+
name: Schema.String,
|
|
46
|
+
});
|
|
29
47
|
const decodeIssueNode = Schema.decodeUnknownSync(IssueNodeSchema);
|
|
30
48
|
const decodeWorkflowStateNode = Schema.decodeUnknownSync(WorkflowStateNodeSchema);
|
|
31
49
|
const decodeTeamNode = Schema.decodeUnknownSync(TeamNodeSchema);
|
|
32
50
|
const decodeIssueLabelNode = Schema.decodeUnknownSync(IssueLabelNodeSchema);
|
|
33
51
|
const decodeProjectNode = Schema.decodeUnknownSync(ProjectNodeSchema);
|
|
52
|
+
const decodeCommentNode = Schema.decodeUnknownSync(CommentNodeSchema);
|
|
53
|
+
const decodeViewer = Schema.decodeUnknownSync(ViewerSchema);
|
|
34
54
|
export class LinearNotFound extends Data.TaggedError("LinearNotFound") {
|
|
35
55
|
}
|
|
36
56
|
export class LinearUnauthorized extends Data.TaggedError("LinearUnauthorized") {
|
|
@@ -293,6 +313,42 @@ export function createLinearGateway(config, limiter = null) {
|
|
|
293
313
|
catch: (err) => classifyLinearError(err, "comment"),
|
|
294
314
|
}), { call: "comment" }));
|
|
295
315
|
},
|
|
316
|
+
fetchComments(issueId) {
|
|
317
|
+
return gate(applyLinearPolicy(Effect.tryPromise({
|
|
318
|
+
try: async () => {
|
|
319
|
+
// Linear SDK paginates by default — use the issue-scoped
|
|
320
|
+
// accessor and let the SDK's first page suffice for the
|
|
321
|
+
// common case (Runway's HITL + reviewer flow rarely
|
|
322
|
+
// produces more than a handful per attempt). If issues
|
|
323
|
+
// start blowing past the page limit, fold in pagination.
|
|
324
|
+
const issue = await client.issue(issueId);
|
|
325
|
+
const comments = await issue.comments();
|
|
326
|
+
const decoded = comments.nodes.map((raw) => {
|
|
327
|
+
const c = decodeCommentNode(raw);
|
|
328
|
+
const createdAt = c.createdAt instanceof Date ? c.createdAt : new Date(c.createdAt);
|
|
329
|
+
return {
|
|
330
|
+
id: c.id,
|
|
331
|
+
author: c.user?.name ?? "",
|
|
332
|
+
body: c.body,
|
|
333
|
+
createdAt,
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
// VA-383: ascending by createdAt so the impl agent reads
|
|
337
|
+
// feedback in the order it was given.
|
|
338
|
+
return [...decoded].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
339
|
+
},
|
|
340
|
+
catch: (err) => classifyLinearError(err, "fetchComments"),
|
|
341
|
+
}), { call: "fetchComments" }));
|
|
342
|
+
},
|
|
343
|
+
viewer() {
|
|
344
|
+
return gate(applyLinearPolicy(Effect.tryPromise({
|
|
345
|
+
try: async () => {
|
|
346
|
+
const v = await client.viewer;
|
|
347
|
+
return decodeViewer({ id: v.id, name: v.name });
|
|
348
|
+
},
|
|
349
|
+
catch: (err) => classifyLinearError(err, "viewer"),
|
|
350
|
+
}), { call: "viewer" }));
|
|
351
|
+
},
|
|
296
352
|
};
|
|
297
353
|
}
|
|
298
354
|
export async function validateLinearConfig(config) {
|
package/dist/orchestrator.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.7.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": {
|
|
@@ -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": "^
|
|
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.
|
|
50
|
+
"@opentelemetry/semantic-conventions": "^1.41.1",
|
|
51
51
|
"effect": "^3.21.2",
|
|
52
|
-
"execa": "^9.
|
|
52
|
+
"execa": "^9.6.1",
|
|
53
53
|
"yaml": "^2.9.0",
|
|
54
|
-
"zod": "^
|
|
54
|
+
"zod": "^4.4.3"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@commitlint/cli": "^21.0.
|
|
58
|
-
"@commitlint/config-conventional": "^21.0.
|
|
59
|
-
"@types/node": "^
|
|
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.
|
|
62
|
-
"typescript": "^
|
|
63
|
-
"vitest": "^4.1.
|
|
61
|
+
"tsx": "^4.21.0",
|
|
62
|
+
"typescript": "^6.0.3",
|
|
63
|
+
"vitest": "^4.1.6"
|
|
64
64
|
},
|
|
65
65
|
"engines": {
|
|
66
66
|
"node": ">=22"
|