@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 +107 -10
- package/dist/commands/doctor.js +203 -2
- package/dist/commands/run.js +70 -15
- package/dist/config.js +53 -61
- package/dist/git.js +43 -29
- package/dist/github.js +136 -21
- package/dist/linear.js +295 -63
- package/dist/orchestrator.js +407 -115
- package/dist/policy.js +76 -0
- package/dist/prompts.js +44 -1
- package/dist/subprocess.js +40 -0
- package/dist/telemetry.js +31 -0
- package/package.json +10 -1
- package/prompts/implement.md +46 -2
- package/templates/Dockerfile.claude-code.base +24 -0
package/dist/policy.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/**
|
|
6
|
+
* VA-352: per-repo + per-run write-path policy for the impl agent.
|
|
7
|
+
*
|
|
8
|
+
* Defaults are conservative — secrets and sandbox-internals are always
|
|
9
|
+
* denied. Repos that need agents to touch CI workflows (the common
|
|
10
|
+
* case) opt in by creating `.runway/policy.yml` with `allowedPaths`,
|
|
11
|
+
* or by passing `--allow-paths=` for a single invocation.
|
|
12
|
+
*
|
|
13
|
+
* The policy is reflected back to the agent in the rendered prompt
|
|
14
|
+
* (`prompts/implement.md`'s "Working style" denylist sentence) so the
|
|
15
|
+
* sentence the agent sees matches what runway will enforce at review
|
|
16
|
+
* time. Enforcement itself (refusing to push a PR that touches a
|
|
17
|
+
* denied path) lives in the reviewer pass — out of scope for this
|
|
18
|
+
* change; the goal here is that the agent gets a correct denylist
|
|
19
|
+
* and surfaces `IMPL: BLOCKED` when an AC requires a denied path.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_FORBIDDEN_PATHS = [
|
|
22
|
+
".github/workflows/**",
|
|
23
|
+
".env*",
|
|
24
|
+
"*.pem",
|
|
25
|
+
"*.key",
|
|
26
|
+
"pnpm-lock.yaml",
|
|
27
|
+
".sandcastle/**",
|
|
28
|
+
];
|
|
29
|
+
const PolicyFileSchema = z.object({
|
|
30
|
+
allowedPaths: z.array(z.string()).optional(),
|
|
31
|
+
forbiddenPaths: z.array(z.string()).optional(),
|
|
32
|
+
});
|
|
33
|
+
const POLICY_RELATIVE_PATH = join(".runway", "policy.yml");
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the effective policy for `cwd`. Reads `.runway/policy.yml`
|
|
36
|
+
* when present, layers it on top of the conservative defaults, then
|
|
37
|
+
* applies any `--allow-paths` CLI override.
|
|
38
|
+
*/
|
|
39
|
+
export function loadPolicy(cwd, opts = {}) {
|
|
40
|
+
const sources = [];
|
|
41
|
+
let forbidden = new Set(DEFAULT_FORBIDDEN_PATHS);
|
|
42
|
+
const policyPath = join(cwd, POLICY_RELATIVE_PATH);
|
|
43
|
+
if (existsSync(policyPath)) {
|
|
44
|
+
sources.push(POLICY_RELATIVE_PATH);
|
|
45
|
+
const raw = readFileSync(policyPath, "utf8");
|
|
46
|
+
const parsed = PolicyFileSchema.parse(parseYaml(raw) ?? {});
|
|
47
|
+
if (parsed.forbiddenPaths) {
|
|
48
|
+
forbidden = new Set(parsed.forbiddenPaths);
|
|
49
|
+
}
|
|
50
|
+
for (const allow of parsed.allowedPaths ?? [])
|
|
51
|
+
forbidden.delete(allow);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
sources.push("defaults");
|
|
55
|
+
}
|
|
56
|
+
if (opts.allowPathsOverride?.length) {
|
|
57
|
+
for (const allow of opts.allowPathsOverride)
|
|
58
|
+
forbidden.delete(allow);
|
|
59
|
+
sources.push("--allow-paths");
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
forbiddenPaths: [...forbidden],
|
|
63
|
+
source: sources.join(" + "),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Render the bullet sentence the impl prompt shows the agent. Stable
|
|
68
|
+
* formatting so a missing path is visible in a diff.
|
|
69
|
+
*/
|
|
70
|
+
export function renderForbiddenPathsBullet(policy) {
|
|
71
|
+
if (policy.forbiddenPaths.length === 0) {
|
|
72
|
+
return "- (No write-path restrictions for this repo. Use judgment.)";
|
|
73
|
+
}
|
|
74
|
+
const quoted = policy.forbiddenPaths.map((p) => `\`${p}\``).join(", ");
|
|
75
|
+
return `- Never modify ${quoted}. If the issue's acceptance criteria require modifying one of these paths, **stop and emit \`IMPL: BLOCKED — issue requires modifying <path>, which working-style policy forbids\`** — do not silently skip the work.`;
|
|
76
|
+
}
|
package/dist/prompts.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { renderForbiddenPathsBullet } from "./policy.js";
|
|
4
5
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
6
|
// Prompts ship with the runway package, NOT in the target repo's
|
|
6
7
|
// .sandcastle/. Runway substitutes {{KEY}} placeholders before passing
|
|
@@ -22,13 +23,55 @@ export async function loadReviewPrompt() {
|
|
|
22
23
|
export function renderPrompt(template, vars) {
|
|
23
24
|
return template.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
|
24
25
|
}
|
|
25
|
-
export function implementVars(issue) {
|
|
26
|
+
export function implementVars(issue, opts = {}) {
|
|
26
27
|
return {
|
|
27
28
|
ISSUE_IDENTIFIER: issue.identifier,
|
|
28
29
|
ISSUE_TITLE: issue.title,
|
|
29
30
|
ISSUE_DESCRIPTION: issue.description || "(no description)",
|
|
31
|
+
// VA-349: empty for iteration 1, a structured summary for 2+.
|
|
32
|
+
PREVIOUS_ITERATIONS: opts.previousIterations ?? "",
|
|
33
|
+
// VA-352: render the working-style denylist from the active policy
|
|
34
|
+
// so the agent never sees a hardcoded list that diverges from what
|
|
35
|
+
// runway actually enforces.
|
|
36
|
+
POLICY_FORBIDDEN_BULLET: opts.policy
|
|
37
|
+
? renderForbiddenPathsBullet(opts.policy)
|
|
38
|
+
: "",
|
|
30
39
|
};
|
|
31
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* VA-349: build the "## Previous iterations" block that gets prepended
|
|
43
|
+
* to iteration N+1's prompt. Carries the agent's commit log and the
|
|
44
|
+
* tail of its final message so the next iteration doesn't re-explore
|
|
45
|
+
* the repo from scratch.
|
|
46
|
+
*/
|
|
47
|
+
export function buildIterationSummary(args) {
|
|
48
|
+
const { iterationsRun, commits, finalMessageTail } = args;
|
|
49
|
+
return [
|
|
50
|
+
"## Previous iterations",
|
|
51
|
+
"",
|
|
52
|
+
`You have already completed ${iterationsRun} iteration(s) on this issue.`,
|
|
53
|
+
"Do **not** re-explore the repository — pick up where the last iteration left off.",
|
|
54
|
+
"",
|
|
55
|
+
"### Commits so far on this branch",
|
|
56
|
+
"",
|
|
57
|
+
"```",
|
|
58
|
+
commits.trim() || "(no commits yet)",
|
|
59
|
+
"```",
|
|
60
|
+
"",
|
|
61
|
+
"### Tail of the last iteration's final message",
|
|
62
|
+
"",
|
|
63
|
+
"```",
|
|
64
|
+
finalMessageTail.trim() || "(no output captured)",
|
|
65
|
+
"```",
|
|
66
|
+
"",
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
/** Keep the tail of an iteration's stdout small enough to fit alongside the prompt. */
|
|
70
|
+
export function tailOfMessage(stdout, maxChars = 2000) {
|
|
71
|
+
if (stdout.length <= maxChars)
|
|
72
|
+
return stdout;
|
|
73
|
+
return `…(earlier output truncated)\n${stdout.slice(-maxChars)}`;
|
|
74
|
+
}
|
|
32
75
|
export function reviewVars(args) {
|
|
33
76
|
return {
|
|
34
77
|
ISSUE_IDENTIFIER: args.issue.identifier,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { execa } from "execa";
|
|
3
|
+
/**
|
|
4
|
+
* VA-358: scoped subprocess runner. Spawns a child via `execa`, awaits
|
|
5
|
+
* its result, and — critically — sends SIGKILL on Effect interruption
|
|
6
|
+
* (Ctrl-C, parent fiber failure, timeout, etc.). Without this, a hung
|
|
7
|
+
* `git push`, stalled `gh pr create`, or in-flight sandcastle agent
|
|
8
|
+
* would survive process exit as an orphan.
|
|
9
|
+
*
|
|
10
|
+
* `classifyError` translates the raw thrown value (an `ExecaError`,
|
|
11
|
+
* usually) into the caller's typed error ADT — this is the same hook
|
|
12
|
+
* we used in VA-356's `Effect.tryPromise({try, catch})`, kept here so
|
|
13
|
+
* the gateway methods can preserve their existing `GhCliMissing` /
|
|
14
|
+
* `PushFailed` / `PrCreateFailed` discrimination.
|
|
15
|
+
*/
|
|
16
|
+
export const runExecaScoped = (bin, args, opts, classifyError) => Effect.acquireUseRelease(Effect.sync(() => execa(bin, args, opts)), (proc) => Effect.tryPromise({
|
|
17
|
+
try: () => proc,
|
|
18
|
+
catch: classifyError,
|
|
19
|
+
}), (proc) => Effect.sync(() => {
|
|
20
|
+
// SIGKILL the child if it's still running when the Effect's
|
|
21
|
+
// fiber is interrupted, errors, or times out. `exitCode` is
|
|
22
|
+
// null until the child has settled.
|
|
23
|
+
//
|
|
24
|
+
// Defensive: execa's `ResultPromise` carries `.kill / .exitCode /
|
|
25
|
+
// .killed`, but vitest mocks frequently return a bare `Promise`
|
|
26
|
+
// (no kill handle). Skip the kill in that case — there's no
|
|
27
|
+
// child to clean up.
|
|
28
|
+
const killable = proc;
|
|
29
|
+
if (typeof killable.kill === "function" &&
|
|
30
|
+
killable.exitCode === null &&
|
|
31
|
+
!killable.killed) {
|
|
32
|
+
try {
|
|
33
|
+
killable.kill("SIGKILL");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Race: child may have exited between the check and the
|
|
37
|
+
// kill. Best-effort cleanup; nothing more to do.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NodeSdk } from "@effect/opentelemetry";
|
|
2
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
3
|
+
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
4
|
+
/**
|
|
5
|
+
* VA-358: OpenTelemetry tracer for the orchestrator.
|
|
6
|
+
*
|
|
7
|
+
* Env-conditional: only wires the OTLP HTTP exporter when
|
|
8
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT` (or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`)
|
|
9
|
+
* is set. Otherwise we provide `NodeSdk.layerEmpty` — `Effect.withSpan`
|
|
10
|
+
* still works in the program but the resulting spans get dropped on the
|
|
11
|
+
* floor rather than spamming the network. Dev-mode default: no traces,
|
|
12
|
+
* no warnings.
|
|
13
|
+
*
|
|
14
|
+
* Standard OpenTelemetry env vars apply (the exporter reads them
|
|
15
|
+
* itself): `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`,
|
|
16
|
+
* `OTEL_SERVICE_NAME`, etc.
|
|
17
|
+
*/
|
|
18
|
+
const isTracingEnabled = () => {
|
|
19
|
+
return Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
|
|
20
|
+
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT);
|
|
21
|
+
};
|
|
22
|
+
const liveLayer = NodeSdk.layer(() => ({
|
|
23
|
+
resource: {
|
|
24
|
+
serviceName: process.env.OTEL_SERVICE_NAME ?? "runway",
|
|
25
|
+
serviceVersion: process.env.RUNWAY_VERSION,
|
|
26
|
+
},
|
|
27
|
+
spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter()),
|
|
28
|
+
}));
|
|
29
|
+
export const TelemetryLive = isTracingEnabled()
|
|
30
|
+
? liveLayer
|
|
31
|
+
: NodeSdk.layerEmpty;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valescoagency/runway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|
|
@@ -40,8 +40,17 @@
|
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@ai-hero/sandcastle": "^0.5.10",
|
|
43
|
+
"@effect/opentelemetry": "^0.63.0",
|
|
43
44
|
"@linear/sdk": "^41.0.0",
|
|
45
|
+
"@opentelemetry/api": "^1.9.1",
|
|
46
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.217.0",
|
|
47
|
+
"@opentelemetry/resources": "^2.7.1",
|
|
48
|
+
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
|
49
|
+
"@opentelemetry/sdk-trace-node": "^2.7.1",
|
|
50
|
+
"@opentelemetry/semantic-conventions": "^1.40.0",
|
|
51
|
+
"effect": "^3.21.2",
|
|
44
52
|
"execa": "^9.5.2",
|
|
53
|
+
"yaml": "^2.9.0",
|
|
45
54
|
"zod": "^3.23.8"
|
|
46
55
|
},
|
|
47
56
|
"devDependencies": {
|
package/prompts/implement.md
CHANGED
|
@@ -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
|
+
{{PREVIOUS_ITERATIONS}}
|
|
10
|
+
|
|
9
11
|
# Repository context
|
|
10
12
|
|
|
11
13
|
You are operating inside a clean checkout of the target repository on a
|
|
@@ -29,9 +31,51 @@ fresh branch named `agent/{{ISSUE_IDENTIFIER}}`. Branch off `main`.
|
|
|
29
31
|
- If the issue is ambiguous and you can't make a reasonable judgment
|
|
30
32
|
call, stop and explain what's missing in your final message — runway
|
|
31
33
|
will route to a human.
|
|
32
|
-
|
|
33
|
-
`pnpm-lock.yaml` (unless the task is a dep bump), or `.sandcastle/**`.
|
|
34
|
+
{{POLICY_FORBIDDEN_BULLET}}
|
|
34
35
|
|
|
35
36
|
# Stop conditions
|
|
36
37
|
|
|
37
38
|
When all five "done" criteria pass, stop. Don't keep polishing.
|
|
39
|
+
|
|
40
|
+
# Termination contract — REQUIRED
|
|
41
|
+
|
|
42
|
+
End **every** response with exactly one of these markers, on its own
|
|
43
|
+
line, as the **last non-empty line** of your message. Nothing after it.
|
|
44
|
+
|
|
45
|
+
- `IMPL: DONE` — all five "done" criteria are met. The reviewer pass
|
|
46
|
+
will run next; no further iterations are needed.
|
|
47
|
+
- `IMPL: BLOCKED — <one-line reason>` — you cannot proceed without
|
|
48
|
+
human input (issue is ambiguous, requires a decision outside the
|
|
49
|
+
agent's purview, conflicts with a working-style constraint, hits a
|
|
50
|
+
permission wall, etc.). Runway will route the issue to a human with
|
|
51
|
+
your reason attached and will not run the reviewer pass.
|
|
52
|
+
- `IMPL: CONTINUE` — you made progress but the work isn't done yet.
|
|
53
|
+
Runway will run another iteration so you can pick up where you left
|
|
54
|
+
off.
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
…all tests pass, typecheck clean, lint clean. Commit pushed.
|
|
60
|
+
|
|
61
|
+
IMPL: DONE
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
…the issue's acceptance criteria require modifying
|
|
66
|
+
`.github/workflows/release.yml`, which the working-style policy
|
|
67
|
+
forbids. Cannot proceed.
|
|
68
|
+
|
|
69
|
+
IMPL: BLOCKED — issue requires CI workflow changes that working-style policy forbids
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
…added the migration and the RLS policy. Tests for the policy
|
|
74
|
+
helper still need to be written next iteration.
|
|
75
|
+
|
|
76
|
+
IMPL: CONTINUE
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The marker is parsed mechanically by runway. A missing or malformed
|
|
80
|
+
marker is treated as `CONTINUE` for back-compat, but **always** emit
|
|
81
|
+
one explicitly — silent completions waste budget on re-exploration.
|
|
@@ -39,6 +39,30 @@ RUN if ! getent group $AGENT_GID >/dev/null; then \
|
|
|
39
39
|
groupmod -g $AGENT_GID node; \
|
|
40
40
|
fi \
|
|
41
41
|
&& usermod -u $AGENT_UID -g $AGENT_GID -d /home/agent -m -l agent node
|
|
42
|
+
|
|
43
|
+
# VA-351: bake the container env up front so agents don't manually
|
|
44
|
+
# work around host-path leaks, missing pnpm, or unset HOME on every
|
|
45
|
+
# iteration. Without these, every agent run repeats the same
|
|
46
|
+
# corepack/TURBO_CACHE_DIR/HOME setup commands — see VA-312's run log
|
|
47
|
+
# for the receipts.
|
|
48
|
+
ENV HOME=/home/agent
|
|
49
|
+
ENV XDG_CACHE_HOME=/home/agent/.cache
|
|
50
|
+
ENV TURBO_CACHE_DIR=/tmp/turbo-cache
|
|
51
|
+
ENV npm_config_cache=/home/agent/.cache/npm
|
|
52
|
+
|
|
53
|
+
# Pre-create cache dirs with agent ownership so the first pnpm/turbo
|
|
54
|
+
# run doesn't have to chown them. Both are inside paths the agent owns
|
|
55
|
+
# anyway; this just makes them exist.
|
|
56
|
+
RUN mkdir -p /home/agent/.cache /home/agent/.cache/npm /tmp/turbo-cache \
|
|
57
|
+
&& chown -R $AGENT_UID:$AGENT_GID /home/agent/.cache /tmp/turbo-cache
|
|
58
|
+
|
|
59
|
+
# Bake pnpm via corepack at build time so `pnpm` is on PATH inside the
|
|
60
|
+
# container before any agent command runs. Pin a default; target repos
|
|
61
|
+
# can override at runtime via `packageManager` in package.json +
|
|
62
|
+
# `corepack use`.
|
|
63
|
+
RUN corepack enable \
|
|
64
|
+
&& corepack prepare pnpm@10.0.0 --activate
|
|
65
|
+
|
|
42
66
|
USER ${AGENT_UID}:${AGENT_GID}
|
|
43
67
|
|
|
44
68
|
# Install Claude Code CLI
|