@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/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.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": {
@@ -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
- - Never modify `.github/workflows/**`, `.env*`, `*.pem`, `*.key`,
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