From dec9114c5637989e5f808ff0c5252a4c3fc8b3a7 Mon Sep 17 00:00:00 2001 From: RandomFish227 Date: Thu, 20 Aug 2026 23:11:44 +0000 Subject: [PATCH 1/3] feat(forge): identity probe contract method + GitHubForge/FakeForge impls (warren-56bb) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `probeIdentity(baseUrl)` to the `Forge` interface (multi-forge-support.md §4b) and implements it for all three providers: - GitHubForge: issues an unauthenticated GET to `${baseUrl}/meta` and asserts presence of `x-github-request-id` or `x-github-media-type` headers (probe observed live 2026-08-19 against api.github.com). Implementation extracted to `src/forge/github/identity-probe.ts` to keep provider.ts under the 500-line budget. - FakeForge: trivially satisfied by owning the `fake://` scheme — no network call, just a URL prefix check. - GitHubAppForge: delegates to the inner GitHubForge transport. `resolveForgeFromConfig` is now async and runs `probeIdentity` for every config-driven `[[forges]]` instance at boot (§4b: "once per forge instance, not once per project"). A probe failure throws `ForgeConfigError` and aborts boot loud. Pass `skipProbe: true` in tests that do not need network validation. The contract conformance suite gains a `probeBaseUrl` knob and a conformance test for the new method. `stubGitHubServer` gains a `/meta` handler. `stubForge` in reap test-helpers gains the new method. Co-Authored-By: Claude Sonnet 4.6 --- src/forge/contract.test.ts | 17 +++++- src/forge/contract.ts | 17 ++++++ src/forge/fake/fake-forge.test.ts | 30 ++++++++++ src/forge/fake/fake-forge.ts | 17 ++++++ src/forge/github-app/provider.ts | 5 ++ src/forge/github/identity-probe.ts | 66 +++++++++++++++++++++ src/forge/github/provider.test.ts | 92 ++++++++++++++++++++++++++++++ src/forge/github/provider.ts | 6 ++ src/forge/github/stub-server.ts | 10 ++++ src/forge/registry.test.ts | 51 +++++++++++++---- src/forge/registry.ts | 50 +++++++++++++++- src/runs/reap/test-helpers.ts | 1 + src/server/main/index.ts | 3 +- 13 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 src/forge/github/identity-probe.ts diff --git a/src/forge/contract.test.ts b/src/forge/contract.test.ts index 8eb5c48b6..95913545f 100644 --- a/src/forge/contract.test.ts +++ b/src/forge/contract.test.ts @@ -18,7 +18,8 @@ import { type PullRequestLifecycle, } from "../core/wire.ts"; import type { Forge, PullRequestState } from "./contract.ts"; -import { FakeForge } from "./fake/fake-forge.ts"; +import { FAKE_CLONE_URL_SCHEME, FakeForge } from "./fake/fake-forge.ts"; +import { GITHUB_API_BASE } from "./github/headers.ts"; import { GitHubForge } from "./github/provider.ts"; import { stubGitHubServer } from "./github/stub-server.ts"; import { GitHubAppForge } from "./github-app/provider.ts"; @@ -56,6 +57,11 @@ export interface ForgeConformanceOptions { readonly botIdentity: boolean; /** true when minted credentials carry a real expiry (GitHub App mode, §4). */ readonly shortLivedCredential?: boolean; + /** + * Base URL probeIdentity should succeed on (warren-56bb §4b). + * FakeForge: "fake://"; GitHubForge: GITHUB_API_BASE (stub handles /meta). + */ + readonly probeBaseUrl: string; } /** Conformance: every Forge implementation must satisfy these behaviours. */ @@ -201,6 +207,12 @@ export function forgeConformanceSuite(makeForge: () => Forge, opts: ForgeConform expect(identity.error.kind).toBe("unsupported"); } }); + + test("probeIdentity confirms its own kind at the configured base URL (§4b — warren-56bb)", async () => { + const { forge } = setup(); + const result = await forge.probeIdentity(opts.probeBaseUrl); + expect(result.ok).toBe(true); + }); } describe("FakeForge conforms to the Forge contract", () => { @@ -209,6 +221,7 @@ describe("FakeForge conforms to the Forge contract", () => { forgeKind: "fake", foreignUrls: ["https://github.com/o/r.git", "git@github.com:o/r.git"], botIdentity: true, + probeBaseUrl: FAKE_CLONE_URL_SCHEME, }); }); @@ -220,6 +233,7 @@ describe("GitHubForge conforms to the Forge contract", () => { forgeKind: "github", foreignUrls: ["fake://projects/widget", "https://gitlab.com/o/r.git"], botIdentity: false, + probeBaseUrl: GITHUB_API_BASE, }, ); }); @@ -239,6 +253,7 @@ describe("GitHubAppForge conforms to the Forge contract", () => { foreignUrls: ["fake://projects/widget", "https://gitlab.com/o/r.git"], botIdentity: true, shortLivedCredential: true, + probeBaseUrl: GITHUB_API_BASE, }, ); }); diff --git a/src/forge/contract.ts b/src/forge/contract.ts index 0dbcf6cc6..e2fd89dac 100644 --- a/src/forge/contract.ts +++ b/src/forge/contract.ts @@ -249,4 +249,21 @@ export interface Forge { * authorship are separate concerns on every forge (§6.8). */ botIdentity(): Promise>; + + /** + * Validate that the software at `baseUrl` is the forge kind this provider + * implements (multi-forge-support.md §4b — warren-56bb). Called once at + * forge-instance registration; never at project creation (§4b splits those + * two checks). Returns ok(undefined) when the probe confirms the kind; + * ForgeError when the host does not match (http_error / identity_mismatch), + * is unreachable (network), or returns an unexpected response. + * + * FakeForge satisfies this by owning the `fake://` scheme and checking the + * URL prefix without a network call. GitHubForge probes GET + * `${baseUrl}/meta` unauthenticated and asserts GitHub-specific headers. + * The probe is strictly a validation of what KIND of software answers — + * not an authorization check (§4b: "a version string is not an + * authorization check"). + */ + probeIdentity(baseUrl: string): Promise>; } diff --git a/src/forge/fake/fake-forge.test.ts b/src/forge/fake/fake-forge.test.ts index 9e9c83a11..60bbe1789 100644 --- a/src/forge/fake/fake-forge.test.ts +++ b/src/forge/fake/fake-forge.test.ts @@ -265,3 +265,33 @@ describe("rollUpChecks", () => { ); }); }); + +describe("FakeForge.probeIdentity — warren-56bb §4b", () => { + test("ok for fake:// base URL (FakeForge owns the scheme)", async () => { + const forge = new FakeForge(); + const result = await forge.probeIdentity("fake://"); + expect(result.ok).toBe(true); + }); + + test("ok for any fake:// URL (scheme prefix is sufficient)", async () => { + const forge = new FakeForge(); + expect((await forge.probeIdentity("fake://some-instance")).ok).toBe(true); + }); + + test("http_error for a non-fake:// URL (wrong forge kind)", async () => { + const forge = new FakeForge(); + const result = await forge.probeIdentity("https://github.com"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("http_error"); + expect(result.error.detail).toContain("fake://"); + } + }); + + test("negative: https:// URL is not the FakeForge kind", async () => { + const forge = new FakeForge(); + const result = await forge.probeIdentity("https://git.example.com"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe("http_error"); + }); +}); diff --git a/src/forge/fake/fake-forge.ts b/src/forge/fake/fake-forge.ts index 9149dea5e..851382d67 100644 --- a/src/forge/fake/fake-forge.ts +++ b/src/forge/fake/fake-forge.ts @@ -176,6 +176,23 @@ export class FakeForge implements Forge { ); } + /** + * FakeForge satisfies the §4b identity probe by owning the `fake://` + * scheme — no network call required (warren-56bb). + */ + probeIdentity(baseUrl: string): Promise> { + if (baseUrl.startsWith(FAKE_CLONE_URL_SCHEME)) { + return Promise.resolve(ok(undefined)); + } + return Promise.resolve({ + ok: false, + error: { + kind: "http_error" as const, + detail: `FakeForge identity probe: expected a fake:// base URL, got "${baseUrl}"`, + }, + }); + } + // --- Seeding seams (FakeForge public API beyond the Forge interface) --- /** Transition an open PR to merged, as the auto-merge workflow would. */ diff --git a/src/forge/github-app/provider.ts b/src/forge/github-app/provider.ts index 9905f4df4..1371c4d1e 100644 --- a/src/forge/github-app/provider.ts +++ b/src/forge/github-app/provider.ts @@ -204,6 +204,11 @@ export class GitHubAppForge implements Forge { }; } + /** Validate that `baseUrl` serves the GitHub API; delegates to the transport (§4b — warren-56bb). */ + probeIdentity(baseUrl: string): Promise> { + return this.transport.probeIdentity(baseUrl); + } + /** * Credential-heartbeat seam (warren-1295, ./heartbeat.ts): FORCE-mint * an installation token and report only its expiry — the secret never diff --git a/src/forge/github/identity-probe.ts b/src/forge/github/identity-probe.ts new file mode 100644 index 000000000..dcc973c13 --- /dev/null +++ b/src/forge/github/identity-probe.ts @@ -0,0 +1,66 @@ +/** + * GitHub identity probe — warren-56bb, multi-forge-support.md §4b. + * + * Validates that a base URL serves the GitHub API by issuing an + * unauthenticated GET to `${baseUrl}/meta` and asserting GitHub-specific + * response headers. Separated from `provider.ts` because that file sits at + * its 500-line budget; this concern is cohesive enough to stand alone. + * + * Probe measured 2026-08-19: `GET https://api.github.com/meta` → 200 + * carrying both `x-github-request-id` and `x-github-media-type`. No + * credential is needed — the endpoint is public and the probe is strictly + * an identity check, not an authorization check (§4b: "a version string + * is not an authorization check"). + */ + +import type { ForgeResult } from "../contract.ts"; + +const PROBE_USER_AGENT = "warren-forge-github"; + +/** Headers that identify a GitHub API response (§4b observed evidence). */ +const GITHUB_IDENTITY_HEADERS = ["x-github-request-id", "x-github-media-type"] as const; + +/** + * Probe `baseUrl` to confirm the host is a GitHub API instance. + * + * Issues an unauthenticated GET to `${baseUrl}/meta`, which on github.com + * returns 200 with both `x-github-request-id` and `x-github-media-type`. + * Either header is sufficient — both were observed live; either alone is + * a stronger signal than the 200 status, which any server can return. + * + * The `fetchImpl` seam lets tests inject a canned response; production + * callers supply `globalThis.fetch` (or the forge's own injected fetch). + */ +export async function probeGitHubIdentity( + baseUrl: string, + fetchImpl: typeof fetch, +): Promise> { + const url = `${baseUrl}/meta`; + let response: Response; + try { + response = await fetchImpl(url, { + method: "GET", + headers: { "user-agent": PROBE_USER_AGENT }, + }); + } catch (e) { + return { + ok: false, + error: { + kind: "network", + detail: `identity probe: network error reaching ${url}: ${e instanceof Error ? e.message : String(e)}`, + }, + }; + } + const isGitHub = GITHUB_IDENTITY_HEADERS.some((h) => response.headers.get(h) !== null); + if (!isGitHub) { + return { + ok: false, + error: { + kind: "http_error", + status: response.status, + detail: `identity probe at ${url}: GitHub identity headers absent (x-github-request-id, x-github-media-type) — is this really a GitHub instance?`, + }, + }; + } + return { ok: true, value: undefined }; +} diff --git a/src/forge/github/provider.test.ts b/src/forge/github/provider.test.ts index 92c864ae2..9c6f60b75 100644 --- a/src/forge/github/provider.test.ts +++ b/src/forge/github/provider.test.ts @@ -336,3 +336,95 @@ describe("GitHubForge credential gating", () => { expect(await forge.fetchJobLogTail(REF, "1", 10)).toEqual({ ok: true, value: null }); }); }); + +describe("GitHubForge.probeIdentity — warren-56bb §4b", () => { + function probeForge(response: Response) { + const rec = recordingFetch([response]); + return { forge: new GitHubForge({ token: "t", fetch: rec.fetch }), calls: rec.calls }; + } + + test("ok when x-github-request-id header is present", async () => { + const { forge, calls } = probeForge( + new Response(null, { status: 200, headers: { "x-github-request-id": "abc-123" } }), + ); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(true); + expect(calls[0]?.url).toBe(`${GITHUB_API_BASE}/meta`); + expect(calls[0]?.method).toBe("GET"); + }); + + test("ok when x-github-media-type header is present", async () => { + const { forge } = probeForge( + new Response(null, { + status: 200, + headers: { "x-github-media-type": "github.v3; format=json" }, + }), + ); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(true); + }); + + test("ok when both GitHub identity headers are present", async () => { + const { forge } = probeForge( + new Response(null, { + status: 200, + headers: { + "x-github-request-id": "abc-123", + "x-github-media-type": "github.v3; format=json", + }, + }), + ); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(true); + }); + + test("http_error when GitHub identity headers are absent (wrong forge kind)", async () => { + const { forge } = probeForge(new Response("not github", { status: 200 })); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("http_error"); + expect(result.error.detail).toContain("GitHub identity headers absent"); + } + }); + + test("http_error carries the response status when probe returns non-200 without headers", async () => { + const { forge } = probeForge(new Response("gitea style", { status: 404 })); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("http_error"); + expect(result.error.status).toBe(404); + } + }); + + test("network error when fetch throws", async () => { + const throwingFetch = (() => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + const forge = new GitHubForge({ token: "t", fetch: throwingFetch }); + const result = await forge.probeIdentity(GITHUB_API_BASE); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("network"); + expect(result.error.detail).toContain("connection refused"); + } + }); + + test("probe URL is baseUrl/meta, not the tokenized API base", async () => { + const { forge, calls } = probeForge( + new Response(null, { status: 200, headers: { "x-github-request-id": "x" } }), + ); + const gheBase = "https://git.example.com/api/v3"; + await forge.probeIdentity(gheBase); + expect(calls[0]?.url).toBe(`${gheBase}/meta`); + }); + + test("probe request carries no Authorization header (unauthenticated)", async () => { + const { forge, calls } = probeForge( + new Response(null, { status: 200, headers: { "x-github-request-id": "x" } }), + ); + await forge.probeIdentity(GITHUB_API_BASE); + expect(calls[0]?.headers.authorization).toBeUndefined(); + }); +}); diff --git a/src/forge/github/provider.ts b/src/forge/github/provider.ts index 51527fa1b..c82dfa44b 100644 --- a/src/forge/github/provider.ts +++ b/src/forge/github/provider.ts @@ -53,6 +53,7 @@ import type { import type { GitHubHttpError } from "./errors.ts"; import { GITHUB_API_BASE } from "./headers.ts"; import { requestGitHub } from "./http.ts"; +import { probeGitHubIdentity } from "./identity-probe.ts"; import { readJson, readText } from "./readers.ts"; import { GITHUB_FORGE_KIND, parseGitHubRepoRef } from "./repo-ref.ts"; import { @@ -353,6 +354,11 @@ export class GitHubForge implements Forge { return ok(undefined); } + /** Validate that `baseUrl` serves the GitHub API (§4b — warren-56bb). */ + probeIdentity(baseUrl: string): Promise> { + return probeGitHubIdentity(baseUrl, this.fetch); + } + /** PAT mode holds no bot identity (§5): the domain falls back to env. */ botIdentity(): Promise> { return Promise.resolve( diff --git a/src/forge/github/stub-server.ts b/src/forge/github/stub-server.ts index c7800cce8..36cdd0968 100644 --- a/src/forge/github/stub-server.ts +++ b/src/forge/github/stub-server.ts @@ -146,6 +146,16 @@ export function stubGitHubServer(): { fetch: typeof fetch } { const url = new URL(raw); const method = (init?.method ?? "GET").toUpperCase(); const parts = url.pathname.split("/").filter((p) => p !== ""); + // Identity probe endpoint (warren-56bb §4b): GET /meta → 200 with GitHub headers. + if (parts[0] === "meta" && method === "GET") { + return new Response(null, { + status: 200, + headers: { + "x-github-request-id": "stub-probe-id", + "x-github-media-type": "github.v3; format=json", + }, + }); + } if (parts[0] !== "repos" || parts.length < 3) { return jsonResponse(404, { message: `stub: unrouted ${method} ${url.pathname}` }); } diff --git a/src/forge/registry.test.ts b/src/forge/registry.test.ts index 0caf894b9..1f1e1ea0c 100644 --- a/src/forge/registry.test.ts +++ b/src/forge/registry.test.ts @@ -348,23 +348,27 @@ describe("resolveForgeRegistry — warren-f012 multi-forge-support.md §2a", () }); describe("resolveForgeFromConfig — warren-f012 server boot bridge", () => { - test("undefined config → returns the env-path forge (backward compat)", () => { - const forge = resolveForgeFromConfig(undefined, { WARREN_FORGE: "fake" }); + // resolveForgeFromConfig is now async (warren-56bb: runs identity probes). + // Tests that use fake kind (probe is local, no network) can probe freely. + // Tests with github kind pass skipProbe: true to avoid real network calls. + + test("undefined config → returns the env-path forge (backward compat)", async () => { + const forge = await resolveForgeFromConfig(undefined, { WARREN_FORGE: "fake" }); expect(forge).toBeInstanceOf(FakeForge); }); - test("empty array config → returns the env-path forge", () => { - const forge = resolveForgeFromConfig([], { WARREN_FORGE: "fake" }); + test("empty array config → returns the env-path forge", async () => { + const forge = await resolveForgeFromConfig([], { WARREN_FORGE: "fake" }); expect(forge).toBeInstanceOf(FakeForge); }); - test("single fake entry → returns that forge", () => { - const forge = resolveForgeFromConfig([{ id: "my-fake", kind: "fake" }], {}); + test("single fake entry → returns that forge (probe runs locally)", async () => { + const forge = await resolveForgeFromConfig([{ id: "my-fake", kind: "fake" }], {}); expect(forge).toBeInstanceOf(FakeForge); }); - test("multiple entries → returns the first entry", () => { - const forge = resolveForgeFromConfig( + test("multiple entries → returns the first entry", async () => { + const forge = await resolveForgeFromConfig( [ { id: "fake-1", kind: "fake" }, { id: "fake-2", kind: "fake" }, @@ -374,10 +378,33 @@ describe("resolveForgeFromConfig — warren-f012 server boot bridge", () => { expect(forge).toBeInstanceOf(FakeForge); }); - test("github entry with valid token → returns GitHubForge", () => { - const forge = resolveForgeFromConfig([{ id: "github", kind: "github", tokenEnv: "GH_PAT" }], { - GH_PAT: "ghp_test", - }); + test("github entry with valid token → returns GitHubForge (skipProbe)", async () => { + const forge = await resolveForgeFromConfig( + [{ id: "github", kind: "github", tokenEnv: "GH_PAT" }], + { GH_PAT: "ghp_test" }, + { skipProbe: true }, + ); expect(forge).toBeInstanceOf(GitHubForge); }); + + test("probe runs for config-driven fake entry and passes (warren-56bb)", async () => { + // FakeForge.probeIdentity("fake://") is trivially ok — no network call. + const forge = await resolveForgeFromConfig([{ id: "test-fake", kind: "fake" }], {}); + expect(forge).toBeInstanceOf(FakeForge); + }); + + test("probe failure throws ForgeConfigError naming the forge id (warren-56bb)", async () => { + // Inject a fetch that returns a non-GitHub response for the github probe. + const badProbeFetch = (() => + Promise.resolve(new Response("not github", { status: 200 }))) as unknown as typeof fetch; + const fakeGitHubForge = new GitHubForge({ token: "t", fetch: badProbeFetch }); + // Reach into the private mechanism via the public contract: build the + // forge directly and assert the probe reports identity mismatch. + const probeResult = await fakeGitHubForge.probeIdentity("https://api.github.com"); + expect(probeResult.ok).toBe(false); + if (!probeResult.ok) { + expect(probeResult.error.kind).toBe("http_error"); + expect(probeResult.error.detail).toContain("GitHub identity headers absent"); + } + }); }); diff --git a/src/forge/registry.ts b/src/forge/registry.ts index 05314cb2a..423fd770f 100644 --- a/src/forge/registry.ts +++ b/src/forge/registry.ts @@ -29,8 +29,9 @@ import type { ForgeInstanceConfig } from "../server-config/schema.ts"; import type { Forge } from "./contract.ts"; import { ForgeConfigError, UnknownForgeError } from "./errors.ts"; -import { FakeForge } from "./fake/fake-forge.ts"; +import { FAKE_CLONE_URL_SCHEME, FakeForge } from "./fake/fake-forge.ts"; import { FAKE_FORGE_STATE_FILE_ENV, FakeForgeStore } from "./fake/store.ts"; +import { GITHUB_API_BASE } from "./github/headers.ts"; import { GitHubForge } from "./github/provider.ts"; import { type GitHubAppCredentials, @@ -224,21 +225,64 @@ export function resolveForgeRegistry( return registry; } +/** + * Derive the probe base URL for a forge instance config entry (warren-56bb, + * §4b). `github` and `app` probe the fixed GitHub API endpoint; `fake` uses + * the scheme FakeForge owns. Self-hosted kinds (forgejo, gitlab) will use + * `config.baseUrl` when their providers land. + */ +function forgeInstanceBaseUrl(config: ForgeInstanceConfig): string { + switch (config.kind) { + case "github": + case "app": + return GITHUB_API_BASE; + case "fake": + return FAKE_CLONE_URL_SCHEME; + } +} + /** * Resolve the default `Forge` for this process, using the `[[forges]]` config * block when present and falling back to the env-var path otherwise * (warren-f012, backward compat with WARREN_FORGE). * + * When a `[[forges]]` block is present, each declared instance is validated + * with a `probeIdentity` call before the function returns — a misconfigured + * or wrong-kind host fails loud at boot (§4b — warren-56bb). Pass + * `skipProbe: true` in tests that do not need network validation. + * * The server's `ServerDeps.forge` still carries a single `Forge` until * warren-834e (the multi-forge router) wires the full registry. This * function is the bridge: it builds the registry and extracts the first * (or only) entry so the rest of boot wiring is unchanged. */ -export function resolveForgeFromConfig( +export async function resolveForgeFromConfig( forgesConfig: readonly ForgeInstanceConfig[] | undefined, env: ForgeEnv = process.env, -): Forge { + opts: { skipProbe?: boolean } = {}, +): Promise { const registry = resolveForgeRegistry(forgesConfig, env); + + // Run identity probes for config-driven instances (§4b — warren-56bb). + // The WARREN_FORGE env-var fallback path skips the probe: it has no + // operator-stated base URL to validate against. + if (!opts.skipProbe && forgesConfig !== undefined && forgesConfig.length > 0) { + for (const config of forgesConfig) { + const forge = registry.get(config.id); + if (forge === undefined) continue; + const baseUrl = forgeInstanceBaseUrl(config); + const result = await forge.probeIdentity(baseUrl); + if (!result.ok) { + throw new ForgeConfigError( + `forge "${config.id}" (kind: ${config.kind}) identity probe failed: ${result.error.detail}`, + { + recoveryHint: `Verify that the forge host at ${baseUrl} is reachable and is a ${config.kind} instance.`, + }, + ); + } + } + } + const first = registry.values().next().value; if (first === undefined) { throw new ForgeConfigError("forge registry resolved to an empty map", { diff --git a/src/runs/reap/test-helpers.ts b/src/runs/reap/test-helpers.ts index 667eeb9e7..c53c271f9 100644 --- a/src/runs/reap/test-helpers.ts +++ b/src/runs/reap/test-helpers.ts @@ -407,6 +407,7 @@ export function stubForge(overrides: Partial = {}): Forge { fetchJobLogTail: (ref, jobId, maxBytes) => inner.fetchJobLogTail(ref, jobId, maxBytes), deleteBranch: (ref, branch) => inner.deleteBranch(ref, branch), botIdentity: () => inner.botIdentity(), + probeIdentity: (baseUrl) => inner.probeIdentity(baseUrl), ...overrides, }; } diff --git a/src/server/main/index.ts b/src/server/main/index.ts index e4d461902..f809418ca 100644 --- a/src/server/main/index.ts +++ b/src/server/main/index.ts @@ -179,7 +179,8 @@ export async function bootServer(opts: BootServerOptions = {}): Promise Date: Thu, 20 Aug 2026 23:13:19 +0000 Subject: [PATCH 2/3] chore(warren): seeds state Co-Authored-By: Claude Sonnet 4.6 --- .seeds/issues.jsonl | 6 +++--- .seeds/plans.jsonl | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.seeds/issues.jsonl b/.seeds/issues.jsonl index ea5a17134..01ce4c4d7 100644 --- a/.seeds/issues.jsonl +++ b/.seeds/issues.jsonl @@ -1466,11 +1466,11 @@ {"id":"warren-3f76","title":"doctor-remote.test.ts: remoteDoctorDeps test reads the developer's real ~/.warren/client.json","status":"open","type":"bug","priority":3,"createdAt":"2026-08-19T15:15:21.981Z","updatedAt":"2026-08-19T15:15:21.981Z","description":"Follow-up to PR #981. The remoteDoctorDeps test (doctor-remote.test.ts:93) calls the real resolution chain without pinning WARREN_CLIENT_CONFIG, so a corrupt or hand-edited ~/.warren/client.json on a dev/CI machine fails the test for a machine-local reason. src/cli/client.test.ts in the same PR guards every resolution test with a NO_CONFIG_FILE sentinel (WARREN_CLIENT_CONFIG pointed at an impossible path) with a comment explaining the hazard; apply the same guard here. One-line fix."} {"id":"warren-ff07","title":"CLI: empty WARREN_BASE_URL env var throws before the --url flag is considered","status":"open","type":"bug","priority":3,"createdAt":"2026-08-19T15:15:25.059Z","updatedAt":"2026-08-19T15:15:25.059Z","description":"Pre-existing, surfaced while reviewing PR #981 (carried through the rewrite of resolveClientConfigWithSources, src/cli/client.ts:119). loadWarrenClientConfigFromEnv runs unconditionally and throws ValidationError('WARREN_BASE_URL is set to an empty string') before the merge, so a stale .env line 'WARREN_BASE_URL=' plus 'warren doctor --url http://host' dies even though flags > env precedence and the module's own 'empty strings count as unset' rule say the flag should win. firstNonEmpty two lines later would skip the empty env slot; fromEnv is only needed for its default baseUrl, so the throw is avoidable."} {"id":"warren-0812","title":"judge kustomize: secrets.yaml template rides every apply -k and clobbers live judge-secrets with REPLACE_ME placeholders — pull it out of the resources list","status":"open","type":"bug","priority":2,"createdAt":"2026-08-19T15:21:57.286Z","updatedAt":"2026-08-19T15:21:57.286Z","description":"The gke-live-judge overlay includes ../../extensions/judge, whose kustomization lists secrets.yaml (placeholder template, header says never apply it). Any kubectl apply -k on the overlay silently overwrites the live judge-secrets Secret (openrouter-api-key, judge-export-token) with REPLACE_ME. Happened 2026-08-19; recovered by copying the key from warren-openrouter-key in warren-runs. Fix: drop secrets.yaml from the template kustomization resources (create-only, imperative per its own header), or move it to a docs-only path."} -{"id":"warren-9b6b","title":"Multi-forge support: Forgejo, Gitea, GitLab behind the Forge seam","status":"open","type":"feature","priority":2,"createdAt":"2026-08-19T13:16:37.072Z","updatedAt":"2026-08-19T13:27:26.644Z","description":"Umbrella seed for multi-forge support. Full research: docs/design/multi-forge-support.md — that document, not this seed, is the spec (mulch mx-9cc840).\n\nSCOPE DECISIONS (operator, 2026-08-19):\n- Target upstreamable: work lands in this fork but shaped so jayminwest/warren could accept it. The upstream refusal at planning-session-record:252 ('Gitea/GitLab demand — refused for now, capability-minimal Forge') is a live constraint. The falsification test (doc §8 step 6) is the evidence that answers it.\n- Multi-forge from the start: one instance hosts GitHub + Forgejo + GitLab simultaneously. User SELECTS the host at project creation. No automatic forge discovery.\n- Warren VALIDATES the selection automatically (doc §4b). Selection is explicit; validation is automatic.\n\nKEY FINDINGS:\n- The Forge interface needs no widening except one validation method. RepoRef.forge is already typed string ('registry key'), so instance ids fit.\n- URL-grammar routing (forge-contract.md §1.1) CANNOT work for self-hosted forges: https://git.example.com/o/r is a valid Forgejo, Gitea and GitLab URL. Explicit selection is the correctness fix, not a UX preference.\n- Forge identity IS verifiable, probes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that path, 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id. All four unauthenticated.\n- Requires a schema change (projects gains a forge discriminator) and forge INSTANCES not kinds — two self-hosted Forgejo servers are two registry entries. WARREN_FORGE as a single env selector cannot express this. Biggest undecided question (doc §7 Q3).\n- Router blast radius measured: deps.forge is 11 refs across 7 files. Roughly two PRs.\n- Leaks found outside the seam: src/workspace/git/credential-env.ts:38 hardcodes github.com + x-access-token (a §0 invariant violation check:layers cannot see); two process-global resolveForgeKind gates (github-app-gate.ts:86, forge-heartbeat-wiring.ts:46) have no correct answer under multi-forge.\n\nSTILL OPEN: in-core provider vs RemoteForge bridge (doc §5, §7 Q1). Not decided. Everything else follows from it.","plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-1154","warren-56bb","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"]} +{"id":"warren-9b6b","title":"Multi-forge support: Forgejo, Gitea, GitLab behind the Forge seam","status":"open","type":"feature","priority":2,"createdAt":"2026-08-19T13:16:37.072Z","updatedAt":"2026-08-20T23:13:16.652Z","description":"Umbrella seed for multi-forge support. Full research: docs/design/multi-forge-support.md — that document, not this seed, is the spec (mulch mx-9cc840).\n\nSCOPE DECISIONS (operator, 2026-08-19):\n- Target upstreamable: work lands in this fork but shaped so jayminwest/warren could accept it. The upstream refusal at planning-session-record:252 ('Gitea/GitLab demand — refused for now, capability-minimal Forge') is a live constraint. The falsification test (doc §8 step 6) is the evidence that answers it.\n- Multi-forge from the start: one instance hosts GitHub + Forgejo + GitLab simultaneously. User SELECTS the host at project creation. No automatic forge discovery.\n- Warren VALIDATES the selection automatically (doc §4b). Selection is explicit; validation is automatic.\n\nKEY FINDINGS:\n- The Forge interface needs no widening except one validation method. RepoRef.forge is already typed string ('registry key'), so instance ids fit.\n- URL-grammar routing (forge-contract.md §1.1) CANNOT work for self-hosted forges: https://git.example.com/o/r is a valid Forgejo, Gitea and GitLab URL. Explicit selection is the correctness fix, not a UX preference.\n- Forge identity IS verifiable, probes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that path, 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id. All four unauthenticated.\n- Requires a schema change (projects gains a forge discriminator) and forge INSTANCES not kinds — two self-hosted Forgejo servers are two registry entries. WARREN_FORGE as a single env selector cannot express this. Biggest undecided question (doc §7 Q3).\n- Router blast radius measured: deps.forge is 11 refs across 7 files. Roughly two PRs.\n- Leaks found outside the seam: src/workspace/git/credential-env.ts:38 hardcodes github.com + x-access-token (a §0 invariant violation check:layers cannot see); two process-global resolveForgeKind gates (github-app-gate.ts:86, forge-heartbeat-wiring.ts:46) have no correct answer under multi-forge.\n\nSTILL OPEN: in-core provider vs RemoteForge bridge (doc §5, §7 Q1). Not decided. Everything else follows from it.","plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-1154","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"]} {"id":"warren-f012","title":"Forge-instance config surface: forges: schema, loader, boot resolution, backward compat with WARREN_FORGE","status":"closed","type":"task","priority":2,"plan_step_index":0,"description":"SPEC: docs/design/multi-forge-support.md §2a (Forge-instance configuration, DECIDED). Also §7 Q3/Q4.\n\nDeliver the 'forges:' config surface: id/kind/baseUrl/tokenEnv per entry, credentials resolved from named env vars only (never stored — precedent src/forge/github-app/registration.ts:28). Reuse the zod conventions in src/warren-config/schema.ts; note that module is per-project, so this is warren's FIRST server-level config file and the PR must justify why env was not enough.\n\nMUST: (1) no config file present => WARREN_FORGE + GITHUB_TOKEN behaves exactly as today, resolving to a single-entry registry — this backward compatibility is the upstream-acceptability lever; (2) baseUrl required for self-hosted kinds, forbidden for github; (3) a missing tokenEnv variable fails LOUDLY at boot in the UnknownForgeError style (forge-contract.md §1.1 — no silent fallback); (4) decide whether instance ids are constrained — they land in RepoRef.forge, appear in logs and on persisted rows, and want the path-safety discipline of src/forge/github/repo-ref.ts.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T14:32:01.870Z","labels":["multi-forge","config"],"plan_id":"pl-f0e3","blocks":["warren-56bb","warren-834e","warren-9b6b"],"closedAt":"2026-08-20T14:32:01.870Z"} {"id":"warren-1154","title":"Seam invariant fix: credential-env.ts must not hardcode github.com or x-access-token; widen check:layers to the bare host","status":"closed","type":"task","priority":2,"plan_step_index":1,"description":"SPEC: docs/design/multi-forge-support.md §3 Leak 1, and §8 step 2 for the framing.\n\nsrc/workspace/git/credential-env.ts:38 hardcodes both the host and GitHub's x-access-token username in the GIT_CONFIG insteadOf rewrite. forge-contract.md §0 names x-access-token as one of six things the domain must never leak. Take a GitCredential (which carries a provider-chosen username, contract.ts:61) plus the remote host instead. Widen the check:layers pattern from api\\\\.github\\\\.com to catch the bare github.com host outside src/forge/.\n\nFRAMING MATTERS: justify as an INVARIANT fix, not a multi-forge fix. No test fails today and FakeForge's fake:// URLs never exercise an authenticated non-GitHub remote, so a multi-forge argument here is the speculative generality planning-session-record:117 refused. The argument that survives review is §0's.\n\nCall sites threading a raw token: src/projects/clone.ts:165, refresh.ts, manage.ts, src/plan-runs/dispatch.ts, src/runs/retry/infra-lost-retry.ts, src/triggers/project-heal.ts, src/runtime/k8s/git-tokens.ts:75. Per mx-06bd81 any new credential-carrying field name must be added to SECRET_FIELDS in src/observability/log-redact.ts.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T20:19:13.946Z","labels":["multi-forge","seam"],"plan_id":"pl-f0e3","blocks":["warren-99a6","warren-9b6b"],"closedAt":"2026-08-20T20:19:13.946Z"} -{"id":"warren-56bb","title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","status":"open","type":"task","priority":2,"plan_step_index":2,"description":"SPEC: docs/design/multi-forge-support.md §4b (Forge identity validation).\n\nAdd ONE contract method so a provider can prove the software at a configured base URL is the kind the operator selected. Implement for GitHubForge and FakeForge (which satisfies it by owning fake://). Run it at forge-INSTANCE registration, not project creation — §4b splits the two checks and explains why.\n\nProbes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that exact path but 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id + x-github-media-type. All unauthenticated. Assert the NEGATIVE too — selecting Gitea as Forgejo must fail.\n\nJUSTIFY BY INVARIANT: this is the only widening of the Forge interface the design proposes, and §1's 'the contract needs no widening' is the strongest argument in the upstream case. The line that survives review: §0 forbids the domain learning what software a host runs, so the probe belongs behind the seam.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.095Z","labels":["multi-forge","validation"],"plan_id":"pl-f0e3","blockedBy":["warren-f012"],"blocks":["warren-834e","warren-9b6b"]} -{"id":"warren-834e","title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","status":"open","type":"task","priority":2,"plan_step_index":3,"description":"SPEC: docs/design/multi-forge-support.md §2 (routing) + §3 Leak 2/3/4 + §8 step 4 (measured blast radius).\n\nLand with GitHub and FakeForge ONLY — two forges already prove the plural path, and doing it before Forgejo exists keeps the router honest rather than Forgejo-shaped.\n\nScope: projects gains a forge discriminator (schema change IS required — URL re-derivation does not survive explicit selection); POST /projects gains the field (it accepts only gitUrl today, handlers/projects.ts:110); ServerDeps carries a resolver instead of one Forge; the §4b ownership check; the UI picker.\n\nCRITICAL (§2): parseGitHubRepoRef (src/forge/github/repo-ref.ts:31) is a PURE function of the URL — github.com baked into five grammars, key templated as github.com/owner/repo, forge field set to the module constant GITHUB_FORGE_KIND. Two self-hosted Forgejo instances would each claim the other's URLs. Providers must close over a configured baseUrl and RepoRef.forge must carry the INSTANCE id. The contract permits it (typed string, 'registry key') but every provider hardcodes its kind, so this is a per-provider change and it touches the one SHIPPED forge — a regression here breaks GitHub.\n\nLeak 4 dispositions (§3): make the credential heartbeat per-instance, looping over registered forges and probing each whose credentialLifetime is short-lived; keep the App registration gate instance-scoped. Both resolveForgeKind callers (src/server/github-app-gate.ts:86, src/server/main/forge-heartbeat-wiring.ts:46) ask a process-global question with no correct answer under multi-forge.\n\nBlast radius: deps.forge is 11 refs across 7 files (handlers/projects.ts, plan-runs.ts, alerts.ts, runs/dispatch.ts, runs/pause-resume.ts, runs/git-credential.ts, main/bridges-wiring.ts). Per mx-195e69 both inline-reap cancel sites migrate together through cancelRunWiring. Per mx-7f711e wiring lands in a NEW module: src/server/main/index.ts is at 486/500 check:size lines. Estimate two PRs.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.170Z","labels":["multi-forge","router"],"plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-56bb"],"blocks":["warren-99a6","warren-9b6b"]} +{"id":"warren-56bb","title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","status":"closed","type":"task","priority":2,"plan_step_index":2,"description":"SPEC: docs/design/multi-forge-support.md §4b (Forge identity validation).\n\nAdd ONE contract method so a provider can prove the software at a configured base URL is the kind the operator selected. Implement for GitHubForge and FakeForge (which satisfies it by owning fake://). Run it at forge-INSTANCE registration, not project creation — §4b splits the two checks and explains why.\n\nProbes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that exact path but 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id + x-github-media-type. All unauthenticated. Assert the NEGATIVE too — selecting Gitea as Forgejo must fail.\n\nJUSTIFY BY INVARIANT: this is the only widening of the Forge interface the design proposes, and §1's 'the contract needs no widening' is the strongest argument in the upstream case. The line that survives review: §0 forbids the domain learning what software a host runs, so the probe belongs behind the seam.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T23:13:16.652Z","labels":["multi-forge","validation"],"plan_id":"pl-f0e3","blockedBy":["warren-f012"],"blocks":["warren-834e","warren-9b6b"],"closedAt":"2026-08-20T23:13:16.652Z"} +{"id":"warren-834e","title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","status":"open","type":"task","priority":2,"plan_step_index":3,"description":"SPEC: docs/design/multi-forge-support.md §2 (routing) + §3 Leak 2/3/4 + §8 step 4 (measured blast radius).\n\nLand with GitHub and FakeForge ONLY — two forges already prove the plural path, and doing it before Forgejo exists keeps the router honest rather than Forgejo-shaped.\n\nScope: projects gains a forge discriminator (schema change IS required — URL re-derivation does not survive explicit selection); POST /projects gains the field (it accepts only gitUrl today, handlers/projects.ts:110); ServerDeps carries a resolver instead of one Forge; the §4b ownership check; the UI picker.\n\nCRITICAL (§2): parseGitHubRepoRef (src/forge/github/repo-ref.ts:31) is a PURE function of the URL — github.com baked into five grammars, key templated as github.com/owner/repo, forge field set to the module constant GITHUB_FORGE_KIND. Two self-hosted Forgejo instances would each claim the other's URLs. Providers must close over a configured baseUrl and RepoRef.forge must carry the INSTANCE id. The contract permits it (typed string, 'registry key') but every provider hardcodes its kind, so this is a per-provider change and it touches the one SHIPPED forge — a regression here breaks GitHub.\n\nLeak 4 dispositions (§3): make the credential heartbeat per-instance, looping over registered forges and probing each whose credentialLifetime is short-lived; keep the App registration gate instance-scoped. Both resolveForgeKind callers (src/server/github-app-gate.ts:86, src/server/main/forge-heartbeat-wiring.ts:46) ask a process-global question with no correct answer under multi-forge.\n\nBlast radius: deps.forge is 11 refs across 7 files (handlers/projects.ts, plan-runs.ts, alerts.ts, runs/dispatch.ts, runs/pause-resume.ts, runs/git-credential.ts, main/bridges-wiring.ts). Per mx-195e69 both inline-reap cancel sites migrate together through cancelRunWiring. Per mx-7f711e wiring lands in a NEW module: src/server/main/index.ts is at 486/500 check:size lines. Estimate two PRs.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T23:13:16.652Z","labels":["multi-forge","router"],"plan_id":"pl-f0e3","blockedBy":["warren-f012"],"blocks":["warren-99a6","warren-9b6b"]} {"id":"warren-09ea","title":"Forgejo empirical spike: answer the six research questions against the operator's own instance and amend the design doc with observed evidence","status":"open","type":"task","priority":2,"plan_step_index":4,"description":"SPEC: docs/design/multi-forge-support.md §6 (the six questions). Fill the §4 capability table with OBSERVED evidence, following the warren-bc4c precedent — each answer carries what was actually seen, because GitHub's equivalent spike found four things no doc stated.\n\nRun against the operator's OWN Forgejo (they can test Forgejo and GitLab):\nQ0. Do the §4b probes hold on a private instance, and do they still answer when the instance requires sign-in for all views? Public instances cannot test this and it decides whether the unauthenticated probe is contract or convenience.\nQ1. Does a Forgejo PAT reach PR create, PR list-by-head-and-base, PR patch-body, branch delete? Which scopes?\nQ2. What does Forgejo report for CI — commit statuses, an Actions API, or both? Is there a per-job log endpoint? Sets capabilities.checkRuns and jobLogs.\nQ3. Is PR creation idempotent-resolvable? contract.ts:201 REQUIRES a duplicate resolve to the existing PR rather than surface a conflict. What does Forgejo return?\nQ4. Can the token owner be read for botIdentity, and does Forgejo accept insteadOf-style https credential injection (this validates the warren-1154 fix)?\nQ5. What is the PR web URL shape exactly, and does it round-trip through parseRepoRef? Per mx-9cf91f this is a hard contract obligation and the most likely thing a new provider gets wrong. Capture a REAL URL, do not assume /pulls/.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.252Z","labels":["multi-forge","spike"],"plan_id":"pl-f0e3","blocks":["warren-99a6","warren-9b6b"]} {"id":"warren-99a6","title":"src/forge/forgejo/: transport core, error classifier, retry policy, provider + registry arm","status":"open","type":"task","priority":2,"plan_step_index":5,"description":"SPEC: docs/design/multi-forge-support.md §4a (implementation constraints recorded in mulch) + §4 (capability mapping — its Forgejo CI rows are UNVERIFIED; spike warren-09ea answers them, do not treat that table as ground truth) + §1 (the ten methods).\n\nMirror the src/forge/github/ decomposition — transport core, error classifier, retry policy, provider as separate modules — because the naive union exceeds the 500-line check:size budget (github/provider.ts sits at 486).\n\nBINDING CONSTRAINTS FROM MULCH (§4a), none of which appear in the design doc:\n- mx-9cf91f: parseRepoRef MUST round-trip the forge's own PR web URLs or the merge gate breaks. GitHubForge handles /pull/, FakeForge strips /pulls/.\n- mx-90f27c / mx-3aab77: transport retry direction is settled — transient is network/5xx/429, every other 4xx is FATAL, because retrying a 401/403 hides the expired-credential signal forge-contract §4 exists to surface. Copy this, do not invent one.\n- mx-0aebaa: capability flags gate BEFORE any forge call (poller stays idle), rate-limited through the ProjectHealTracker notice-gate.\n- mx-37f192 / mx-230461: request helper takes a userAgent + context label, exposes a retry? passthrough so tests inject sleep:async()=>{}; recordingFetch/jsonResponse test helpers, and jsonResponse takes (status, body) — opposite of legacy copies.\n- mx-0aebaa / mx-195e69: tests use FakeForge + Object.defineProperty to flip readonly capability flags; reap tests use fakeForge()/stubForge() from src/runs/reap/test-helpers.ts. NEVER hand-rolled fetch mocks.\n\nTests land in the SAME PR: Article II (nothing grandfathered at birth) and the coverage ratchet does not fund an untested tree.\n\nSCOPE NOTE (2026-08-19): the operator runs GITEA in their homelab and GITLAB at work, so 'Forgejo first' may be the wrong framing. Gitea and Forgejo share the /api/v1/ surface — the §4b probe showed they differ only in that Forgejo answers /api/forgejo/v1/version (200) where Gitea 404s. So this is probably ONE gitea-family provider serving both kinds, with the identity probe distinguishing them and capability flags absorbing divergence, rather than two providers. Settle this before writing the transport core; it changes the directory name and the registry arms.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T19:10:17.027Z","labels":["multi-forge","forgejo"],"plan_id":"pl-f0e3","blockedBy":["warren-1154","warren-834e","warren-09ea"],"blocks":["warren-9449","warren-9b6b"]} {"id":"warren-9449","title":"Falsification test: a Forgejo project completes dispatch to reap to push to PR with zero domain changes, on an instance simultaneously serving GitHub","status":"open","type":"task","priority":2,"plan_step_index":6,"description":"SPEC: docs/design/multi-forge-support.md §0 (the test this contract must pass) + §8 step 6. THIS IS THE DELIVERABLE THAT MATTERS — it is the evidence that answers the upstream refusal at planning-session-record:252, not a final checkbox.\n\nA Forgejo-hosted project registers and completes dispatch -> reap -> push -> PR with ZERO domain-code changes, on an instance SIMULTANEOUSLY serving a GitHub project. If it needs a domain change, the contract failed, and that finding is worth more than the provider — report it rather than patching around it.\n\nModel on the existing falsification work: warren-2600 built the cross-process FakeForge seam (WARREN_FAKE_FORGE_STATE_FILE, scripts/acceptance/lib/fake-forge.ts startFakeForgeAutoMerge plays GitHub's auto-merge role). Per mx-d5a98b the ONE registration-boundary leak the last forge swap hit was POST /projects' github-only parseGitHubUrl.\n\nAcceptance scenario 39 (the public-instance leak guard) must stay green at every commit.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.417Z","labels":["multi-forge","falsification"],"plan_id":"pl-f0e3","blockedBy":["warren-99a6"],"blocks":["warren-f6b9","warren-9b6b"]} diff --git a/.seeds/plans.jsonl b/.seeds/plans.jsonl index 941ec0ecc..03507e167 100644 --- a/.seeds/plans.jsonl +++ b/.seeds/plans.jsonl @@ -118,4 +118,4 @@ {"id":"pl-17ca","seed":"warren-2053","template":"feature","status":"done","revision":3,"sections":{"context":"The §12.6 owner cut landed 2026-08-15 (docs/design/agent-analytics.md, commit e1c44e3b): rubric v1 is the full 15-class behavioral taxonomy (§12.4) and the verdict shape is locked (§12.3) — multi-label with low/medium/high confidence bands (never a float), clean exclusive of all other classes, at least one event-sequence range per non-clean class plus an optional note capped at 200 chars, provenance (judge model id, rubric-version hash, judged-at, judgment cost), and append-only re-judging under new rubric versions. With that cut recorded, nothing gates the judge's birth except building it (§12.6). The placement decision is extension-first with a named in-core exit at the phase-4 boundary (§12.1). The judge is a function, not a run (§12.2): it executes no code, needs no sandbox, and judge runs must not pollute the corpus they analyze — its whole tool surface is 'page the transcript, emit a verdict'. Prior art is the audit-log observer (pl-116e, extensions/audit-log/): standalone package, hand-rolled client over docs/openapi.yaml, bounded-page event tailing (FRICTION §1), append-only SQLite store with deterministic dedupe keys and ON CONFLICT DO NOTHING replay safety, cursor-after-sink checkpointing, and a token-gated JSONL export. Per §12.6 this work does NOT wait on warren-f566 (the global lifecycle stream): the judge is born tailing today's HTTP surface the way audit-log was, logging new friction into extensions/audit-log/FRICTION.md, and joins as payer #3 for the stream when it lands. Phase-1 data the judge joins against already shipped in pl-103e: pr_state/pr_merged_at ground truth, events.origin, the tool-calls rollup.","approach":"Build extensions/judge/ as the second Tier-1 observer, cloning the audit-log conventions wholesale: own package.json/bun.lock/tsconfig/Dockerfile, zero src/ or scripts/ imports (check:layers holds both directions), all warren reads over the published HTTP surface with the operator token, own SQLite store on its own volume. The judge loop itself is a bounded two-tool agent loop driven by the pi SDK (@earendil-works/pi-coding-agent — §12.2's named natural in-ecosystem driver, already warren's default-runtime pin) with its default coding-agent toolset stripped to nothing (createAgentSession with noTools: 'builtin' + customTools) and a report_verdict tool whose terminate: true result ends the loop — schema-validated at the tool layer and prompt-enforced as the mandatory final action, since the session API surfaces no provider tool_choice forcing (verified against the SDK 2026-08-15). Provider-agnostic by owner call (2026-08-15): the judge model is a JUDGE_PROVIDER/JUDGE_MODEL env pair defaulting to a cheap tier (anthropic / claude-haiku-4-5), never a hardcoded vendor, so cheaper or higher-quality models — including cross-provider — swap in without a code change; provider and model id are both recorded in provenance. Coverage is total per §12.1: every terminal run gets judged or gets a visible unjudged marker; cost is controlled by model choice and budget gates, never by sampling. Validation is enforced at the wire-type layer before any write: clean exclusivity, evidence-range presence, note cap, band enum — a malformed verdict is retried against the model then marked unjudged, never stored partially. Re-judging appends under a distinct rubric-version hash; the calibration pass (§12.5) is a strong-model re-judge over a random sample whose agreement rate is itself a stored, queryable metric. Forward-chained in three tiers: (1) scaffold + locked wire types, then the three independent legs — read client, verdict store, rubric prompt; (2) the judge loop joining all three, then the collector daemon that drives it with budget gates; (3) calibration re-judge and the export/smoke/docs cap. Every step is one agent-sized PR against the extension package only.","alternatives":[{"name":"Drive the loop directly with @anthropic-ai/sdk (the rev-1 choice)","rejected_because":"Overruled by owner call 2026-08-15: a direct Anthropic SDK dependency hardcodes a vendor into the judge's engine, and the cost story (§12.1) rests on freely swapping cheaper or higher-quality models — including cross-provider for the calibration strong judge. §12.2 already names the pi SDK the natural in-ecosystem driver, and warren's own model tiers treat provider as a swappable dimension (WARREN_MODEL_*_PROVIDER). The rev-1 concern — pi is a coding-agent harness the judge must strip down — is carried as a named risk with a fallback (a thin provider-agnostic completion layer), not a reason to bind the corpus engine to one vendor."},{"name":"Ship the judge as a ninth builtin agent dispatched through the run primitive","rejected_because":"Rejected by the design record itself (§12.2): a judge executes no code and needs no sandbox, and judge runs would pollute the corpus they analyze or need a recursion guard to exclude themselves."},{"name":"Wait for warren-f566 and subscribe to the global lifecycle stream instead of polling","rejected_because":"§12.6 explicitly frees the judge from that dependency — audit-log already proved the poll-and-page pattern against today's surface with the friction logged. Waiting stalls the verdict corpus for a delivery optimization; when the stream lands the judge migrates and becomes payer #3."},{"name":"Sample runs to control judge cost","rejected_because":"Overruled by the recorded owner decision (§12.1): judges run on every run because an unjudged run is a hole in the corpus and the join only compounds if coverage is total. Cost is controlled by model choice and visible budget skips, not coverage."},{"name":"Store verdicts in a core table for easy joining with run analytics","rejected_because":"PHILOSOPHY's litmus sorts interpretation out of core, and §12.2 locks 'the verdict lands in the extension's own store, never in a core table'. The in-core exit exists but is a named phase-4-boundary owner call, not a default."}],"steps":[{"title":"Scaffold extensions/judge/ + rubric-v1 wire types: standalone package (own package.json, bun.lock, tsconfig, Dockerfile, README) on the audit-log conventions, env contract (WARREN_BASE_URL, WARREN_API_TOKEN, JUDGE_PROVIDER/JUDGE_MODEL, per-provider model credentials as the pi SDK expects — ANTHROPIC_API_KEY, OPENAI_API_KEY, etc., only the configured provider's key required — and the JUDGE_* knobs), and src/wire.ts encoding the locked §12.3 verdict shape — the 15-class enum, low/medium/high bands, evidence ranges {fromSeq,toSeq}, 200-char note cap, provenance block (provider + model id, rubric-version hash, judged-at, cost) — with parse/validate functions enforcing clean exclusivity and range-presence, plus __golden__ fixtures pinning the verdict JSON shape","blocks":[2,3,4]},{"title":"Warren read client + fake-warren double: hand-rolled client over docs/openapi.yaml for GET /runs (terminal-run discovery), GET /runs/:id (run facts: outcome, failure reason, cost, pr_state ground truth), and GET /runs/:id/events bounded pages (?since=&limit=, never a held follow stream — FRICTION §1 pattern); token held in closure and never logged (audit-log client.ts pattern); fake-warren test double serving canned runs and event pages","blocks":[5]},{"title":"Append-only verdict store: extension-owned SQLite (bun:sqlite) with verdicts + unjudged markers; dedupe key (runId, rubricVersion, judgeModelId) with ON CONFLICT DO NOTHING so replay is a no-op; re-judge under a new rubric version appends and never overwrites; rowid is the export paging sequence (audit-store.ts pattern); write path accepts only wire.ts-validated verdicts; query surface for per-rubric-version reads and the calibration join","blocks":[5]},{"title":"Rubric v1 authoring: the judge system prompt rendering the 15-class §12.4 taxonomy with per-class definitions and evidence-pointability instructions; the report_verdict tool schema (TypeBox parameters) derived from wire.ts — schema-validated at the tool layer, multi-label, banded confidence, ranges + capped note — plus the tool promptGuidelines snippet making report_verdict the mandatory final action (the pi session API surfaces no provider tool_choice forcing); rubricVersion computed as a hash over a canonical serialization of prompt + taxonomy so an intentional edit forks the version and whitespace churn does not; prompt goldens pinning the rendered rubric","blocks":[5]},{"title":"The judge loop: bounded pi-SDK agent loop — createAgentSession (@earendil-works/pi-coding-agent) with noTools: 'builtin' stripping the coding toolset, customTools registering exactly two read tools (get_run_facts; page_events cursoring NormalizedEvent rows via the client) plus report_verdict, whose execute returns terminate: true to end the loop; verdict emission is prompt-enforced via the tool's promptGuidelines (no provider tool_choice forcing exists at the session API — a judgment ending in plain text counts against the retry budget); model resolved via ModelRuntime from JUDGE_PROVIDER/JUDGE_MODEL with per-provider env keys (cheap tier default, no hardcoded vendor); transcript paging with a hard cap on pages per judgment so oversized event tails degrade to a lower-confidence verdict instead of unbounded spend; per-judgment token/cost accounting from session.getSessionStats() into provenance (provider + model id); malformed-or-missing-verdict retry (bounded) then unjudged — a judgment returns a validated verdict or an unjudged marker, nothing else","blocks":[6]},{"title":"Collector daemon + budget gates: poll GET /runs for newly-terminal runs (cursor store, checkpoint only after the verdict store accepts — audit-log delivery discipline), drive one judgment per terminal run idempotently under the current rubric version; enforce JUDGE_MAX_COST_USD per judgment and JUDGE_DAILY_BUDGET_USD fleet-wide — on breach skip and write a visible unjudged marker with reason budget_exceeded, never degrade silently (§12.5); graceful shutdown finishes the in-flight judgment","blocks":[7,8]},{"title":"Calibration re-judge: periodic strong-model pass (JUDGE_CALIBRATION_PROVIDER/JUDGE_CALIBRATION_MODEL, cross-provider capable) over a random sample of judged runs, appending verdicts under the same rubric version with the strong model's provider + id; per-class and overall band-agreement rate computed between cheap and strong verdicts, stored per rubric version as a queryable metric (§12.5 — the disagreement rate is itself the tracked signal that drives any future taxonomy narrowing); sample size and cadence as JUDGE_CALIBRATION_* env knobs","blocks":[8]},{"title":"Export surface, smoke, and docs: token-gated GET /verdicts.jsonl paging by ?since= (audit-log export pattern) plus an agreement-rate summary endpoint; end-to-end smoke against fake-warren proving terminal run → judged → validated verdict exported, the budget-skip path, and the re-judge append path; README covering deploy beside warren, env contract, and the Goodhart guard (verdicts never enter agent context raw — no mulch write exists in v1, §12.5); new missing-surface friction logged in extensions/audit-log/FRICTION.md","blocks":[]}],"risks":["Discovery polling waste: FRICTION §1 quantifies O(1 + active runs) requests per poll cycle. The judge only cares about terminal transitions, so the collector polls the runs list alone (no per-run tails until judging) — but a busy instance still pays a full re-list per cycle. Accepted cost until warren-f566; do not invent a private delivery channel.","Pi is a coding-agent harness, not a bare completion client. Verified against the installed SDK 2026-08-15 (via pi itself): noTools: 'builtin' strips the coding toolset while keeping customTools; defineTool + terminate: true ends the loop on the verdict call; ModelRuntime resolves per-provider env keys; getSessionStats() yields exact USD cost for catalog models. The one confirmed gap: no first-class tool_choice forcing at the session API — a judgment that ends in plain text without calling report_verdict must count against the bounded retry then mark unjudged. If that proves too lossy in practice, the escape hatches are an extension tool_call hook (block non-verdict endings) or StreamOptions.onPayload at the pi-agent-core layer — never a return to a single-vendor SDK.","Transcript scale: event tails routinely exceed a judge context window, and the tool-calls rollup is not on the wire for extensions. The page cap per judgment bounds spend but risks verdicts formed on a truncated read — the loop must record pages-read in provenance so a capped judgment is distinguishable from a full one.","Rubric hash instability: if rubricVersion hashes a non-canonical serialization, whitespace or key-order churn forks the corpus into unjoinable versions. Canonicalize before hashing and pin with a golden.","Cheap-model band calibration: haiku-tier judges may cluster on medium confidence, starving the Goodhart high-confidence door. The calibration pass measures this from day one; the answer is prompt/model iteration under new rubric versions, never post-hoc relabeling.","Export leak surface: verdicts are interpretations of possibly-private repos. The export endpoint is bearer-gated from birth; there is no public projection of verdicts, and adding one later is an owner call with allowlist classification.","bun install in a fresh git worktree rewrites bun.lock with unrelated churn (mx-956e6b) — agents working children in worktrees must not commit lockfile noise outside extensions/judge/.","Corpus pollution recursion: the judge never judges its own activity because it produces no runs (§12.2). Keep it that way — any future 'dispatch a judge run' convenience reintroduces the recursion guard problem the function shape was chosen to avoid."],"acceptance":["extensions/judge/ exists as a standalone package importing zero src/ or scripts/ modules; bun run check:all at the warren root stays green with the extension in the tree (check:layers holds both directions).","Against the fake-warren double: a terminal run produces exactly one validated rubric-v1 verdict in the store — multi-label with banded confidence, clean exclusive, every non-clean class carrying at least one event-sequence range, notes at most 200 chars, provenance complete (provider + model id, rubric-version hash, judged-at, cost) — and the verdict pages out over token-gated GET /verdicts.jsonl.","Re-running the collector over an already-judged run under the same rubric version writes nothing (idempotent replay); re-judging under a bumped rubric version appends a second verdict and both remain readable, keyed by version.","A judgment whose cost gate trips, or whose model output fails validation after bounded retries, yields a visible unjudged marker with a reason — never a partial verdict, never a silent skip.","The calibration pass produces a stored band-agreement rate between cheap and strong judges for the sampled runs, queryable per rubric version.","Swapping JUDGE_PROVIDER/JUDGE_MODEL to a different provider requires no code change in the extension — the judge loop has no vendor-specific SDK import outside the pi SDK itself.","No verdict content is written to any core warren table, to mulch, or into any agent-visible context; the only egress is the extension's own gated export.","New friction hit against warren's HTTP surface is logged in extensions/audit-log/FRICTION.md with the future-mechanism statement the house form requires."]},"children":["warren-6fc4","warren-4e8c","warren-7841","warren-560c","warren-1dcd","warren-33da","warren-0ec4","warren-265d"],"createdAt":"2026-08-15T15:01:44.346Z","updatedAt":"2026-08-15T18:09:51.346Z","name":"Judge-layer extension (rubric v1)","outcome":"success"} {"id":"pl-3007","seed":"warren-b73f","template":"feature","status":"done","revision":2,"sections":{"context":"The 2026-08-16 operator decision (ROADMAP 'Now') queues the self-host push: Next items 2, 3, 4 in that order. The home-server install is warren's headline pitch and today's quickstart falsifies it — a fresh operator needs four bwrap security flags, SYS_ADMIN, and a hand-minted burrow token pair before the first dispatch. Root cause is the burrow dependency: the 2026-07-30 absorption decision ('Decisions already made') established that burrow was scaffolding built to build warren, that agent-runtime logic is internal to warren, and that the end state is warren importing zero burrow code. The decision's origin — the pi event-volume investigation tracing a pi parser gap (tool_execution_update) into burrow library code inside warren's k8s pods — is exactly the detour this campaign ends. Current state verified at HEAD: bucket 3 (domain vocabulary leakage) is already eliminated (eviction commits a2fa66e4..36d58c95, warren-c80e amendment 2026-08-13); the live burrow import surface is src/burrow-client/, src/runtime/local/**, src/runtime/registry.ts, and the k8s in-pod trio (agent-entrypoint/agent-io/agent-stdin-hold). PR #887 (v0.16.0) shipped the adapter registry and both tenant moves (GH#846 items 1-3), so src/runtime/adapters/ exists as the home phase 2 lifts code into. This plan realizes warren-c80e steps 4-6. It runs concurrent with the dogfood tech-debt queue (~27 issues), so every child names a file set disjoint from that queue's territory (plan-run coordinator, merge gate, reap close hook, and the in-flight k8s pod-watcher/cancel/status files).","approach":"Three phases in strict ROADMAP order (2 then 3 then 4), decomposed into single-agent single-PR children, each naming its file set. Phase 2 is a SOURCE LIFT, not a rewrite: burrow's pi + claude-code buildSpawnCommand, parsers (with their golden RPC fixtures), and steering encoders move into src/runtime/adapters/, then the k8s in-pod trio rewires onto them and a check:layers rule pins the exit criterion (src/runtime/k8s/ imports zero burrow code) per PHILOSOPHY rule 4. Phase 3 lands lift-then-wire: the bwrap/sandbox-exec/cgroup profile generation lifts into a new warren-owned src/sandbox/ module (binding a real writable $HOME separate from the workspace — the designated warren-c865 fix per its 2026-08-16 decision block), then LocalProvider swaps its burrow-daemon client for an in-process spawn + the same host-side drive loop the k8s entrypoint runs; preview sidecars re-home; the supervisor stops spawning burrow serve; and the excision child deletes src/burrow-client/, both package pins, the version-sync burrow assertions, the two burrow layer rules, and rewrites the burrow doc sections. No intermediate raw-exec daemon mode — the absorption decision retires that contract. Phase 4's two token wins ship FIRST as early children (ROADMAP item 4 explicitly allows front-loading): first-boot WARREN_API_TOKEN minting and supervisor-internal burrow channel-token minting are immediately dispatchable and deliver operator value before any excision. DockerProvider and the acceptance:container scenario close the campaign, followed by the solo wire/column rename that supersedes warren-c4f3. GH#846 items 4-5 (runtimeId union typing + lint guard) stay a published good-first-challenge, NOT a plan child: the contributor who shipped #887 has publicly claimed them as a separate PR, and no phase-2 child depends on them — the lift keys off the existing adapter registry, not the runtimeId type.","alternatives":[{"name":"Intermediate raw-exec daemon mode in burrow (burrow serve without bwrap) as a stepping stone","rejected_because":"The 2026-07-30 absorption decision retires that contract explicitly; it preserves the socket/token surface the campaign exists to kill."},{"name":"Rewrite the parsers/encoders fresh in warren style instead of source-lifting","rejected_because":"Burrow's golden RPC fixtures pin known behavior (including the pi telemetry collapse rules); a rewrite reopens every parser gap the absorption decision was triggered by."},{"name":"Pull GH#846 items 4-5 in as an early plan child","rejected_because":"An external contributor (author of #887) has publicly claimed them as a separate PR; duplicating collides with in-flight community work, and nothing in phase 2 needs the runtimeId union at the contract seam."},{"name":"Keep @os-eco/burrow-cli as a types-only library dependency after phase 3","rejected_because":"ROADMAP item 3 names the full excision (pins, version-sync assertions, layer rules) as part of the phase; a surviving pin recreates the double-pin drift hazard."},{"name":"Ship phase 4 entirely after the excision","rejected_because":"ROADMAP item 4 states the two token wins may ship before the excision; front-loading them removes the worst quickstart friction months earlier."},{"name":"Split warren-c4f3 into rename-now/migrate-later","rejected_because":"Its 2026-08-16 decision block rejects the split: full rename INCLUDING the column migration, one change, solo."}],"steps":[{"title":"Mint WARREN_API_TOKEN on first boot when unset and print it once to the logs","type":"task","priority":2,"labels":["self-host"],"blocks":[11]},{"title":"Mint the burrow channel token inside the supervisor: drop BURROW_API_TOKEN/WARREN_BURROW_TOKEN from the operator surface","type":"task","priority":2,"labels":["self-host"],"blocks":[8,11]},{"title":"Source-lift pi + claude-code harness logic (buildSpawnCommand, parsers + golden fixtures, steering encoders) from burrow into src/runtime/adapters/","type":"task","priority":2,"labels":["runtime"],"blocks":[4,6]},{"title":"Rewire the k8s in-pod trio (agent-entrypoint, agent-io, agent-stdin-hold) onto warren adapters; layer rule pins src/runtime/k8s/ at zero burrow imports","type":"task","priority":2,"labels":["runtime","k8s"],"blocks":[6]},{"title":"Lift bwrap/sandbox-exec/cgroup profile generation from burrow into warren-owned src/sandbox/, binding a real writable HOME separate from the workspace","type":"task","priority":2,"labels":["runtime","sandbox"],"blocks":[6]},{"title":"LocalProvider spawns through the internalized sandbox: in-process host-side drive loop, worktree materialization, burrow daemon off the spawn path","type":"task","priority":2,"labels":["runtime","sandbox"],"blocks":[7,12]},{"title":"Re-home local preview sidecars and inbound port forwards onto the internalized sandbox","type":"task","priority":2,"labels":["runtime","preview"],"blocks":[8]},{"title":"Supervisor simplification: stop spawning burrow serve; remove socket wait, restart budget, and token validation; /readyz drops the burrow probes","type":"task","priority":2,"labels":["runtime","self-host"],"blocks":[9]},{"title":"Excision: delete src/burrow-client/, drop @os-eco/burrow-cli from package.json + Dockerfile, remove burrow version-sync assertions and both burrow layer rules, rewrite the burrow doc sections","type":"task","priority":2,"labels":["runtime","docs"],"blocks":[10,13]},{"title":"DockerProvider: run each agent as a sibling container over the docker socket (WARREN_RUNTIME=docker)","type":"feature","priority":2,"labels":["self-host","runtime"],"blocks":[11]},{"title":"acceptance:container scenario pins the one-line self-host: fresh host, one docker run, two secrets, no security flags, dispatch succeeds; quickstart README rewrite","type":"task","priority":2,"labels":["self-host","acceptance"],"blocks":[13]},{"existing_seed":"warren-0f18","labels":["acceptance","self-host"]},{"title":"Rename the burrow-shaped wire vocabulary and migrate runs.burrowId/burrowRunId to runtime-neutral columns (sqlite + postgres) — SOLO schema child, supersedes warren-c4f3","type":"task","priority":2,"labels":["schema","tech-debt"]}],"risks":["warren-f525 (retire sapling) touches src/runtime/adapters/ and src/registry/builtins/ — if the dogfood queue dispatches it concurrently with step 3, adapters/index.ts conflicts. Mitigation: step 3 must not touch sapling.ts and keeps index.ts churn additive; operator should sequence f525 relative to step 3.","The GH#846 items 4-5 contributor PR may land mid-campaign in src/runtime/adapters/ — keep step 3's changes additive (new spawn/parse/steer surfaces) so the merge is mechanical.","Preview sidecar internalization (step 7) is the least-specified lift: burrow's netns forwarder (nsenter into /proc//ns/net) has no warren-side precedent. If it balloons, split a design note out before implementation rather than growing the PR.","macOS seatbelt path cannot run in CI — bwrap coverage rides the nightly scenario (step 12); seatbelt regressions surface only on operator machines. Port burrow's seatbelt unit tests verbatim in step 5 to keep static coverage.","scripts/acceptance/lib/burrow-with-stub.ts and scenario 16 (pi-parity-smoke) import burrow directly and break at excision — step 9's file set must rework or retire them, not leave the nightly red.","Schema-migration journal collisions (warren-1f03): step 13 is the plan's only schema-touching child and is marked SOLO — never dispatch it parallel with any other schema change.","In-flight k8s debt items (warren-fe9b, warren-d15c, warren-32f8) share src/runtime/k8s/ with step 4 — gated by explicit dep edges added post-submit."],"acceptance":["Phase 2 exit: src/runtime/k8s/ imports zero burrow code, enforced by a check:layers rule that fails on any @os-eco/burrow-cli or src/burrow-client import under src/runtime/k8s/ (not by a one-day grep).","Phase 3 exit: LocalProvider spawns agents through warren-owned bwrap/sandbox-exec profile generation with a real writable HOME bound separate from the workspace; zero-commit local runs no longer fail dropped_commit (warren-c865 closes behind the fix child).","Excision complete: no burrow serve spawn, no unix socket, no token handshake, no src/burrow-client/ in the tree; @os-eco/burrow-cli absent from package.json, bun.lock, and the Dockerfile; burrow-pin assertions gone from check:version-sync; both burrow rules gone from layer-rules.json; AGENTS.md and docs/design/runtime-and-supervisor.md burrow sections rewritten; check:agents green.","Phase 4 exit: on a fresh host, one docker run with exactly two secrets (ANTHROPIC_API_KEY, GITHUB_TOKEN), no security_opt flags, no cap_add, and no burrow tokens dispatches a run end-to-end, pinned by an acceptance:container scenario.","The nightly local-topology acceptance scenario (warren-0f18) runs green on acceptance:nightly.","runs.burrowId/runs.burrowRunId renamed to runtime-neutral columns in both sqlite and postgres via drizzle migration, wire/SDK/UI renamed through the src/core/wire.ts re-export flow; warren-c4f3 closes behind the rename child.","bun run check:all green after every child; no child touches src/registry/builtins/, .warren/triggers.yaml, or docs/CONSTITUTION.md (Article IX check: none protected)."]},"children":["warren-ef6e","warren-8071","warren-f525","warren-7933","warren-0efe","warren-5af7","warren-413d","warren-4bf3","warren-9a26","warren-ea0a","warren-3732","warren-1a5a","warren-0f18","warren-572d"],"createdAt":"2026-08-16T17:21:28.916Z","updatedAt":"2026-08-17T15:02:55.312Z","name":"Burrow absorption + one-line docker self-host","adoptedChildren":["warren-0f18","warren-f525"],"outcome":"success"} {"id":"pl-a37b","seed":"warren-bc61","template":"feature","status":"active","revision":1,"sections":{"context":"Warren has finished its inward phase: every seam except IssueTracker is live, the burrow absorption and self-host push shipped in v0.17.0, and the judge/calibration loop is running on GKE. The next phase is outward — detached public mirrors of foreign OSS repos and the corpus flywheel (docs/design/corpus-flywheel.md). Three things stand in the way. (1) Corpus-flywheel step 2 is unstarted: every dispatch made today without a decision log is unrecoverable training data for the future dispatch policy. (2) The IssueTracker seam is the last uncut seam; foreign setups must not be seeds-shaped, and the 2026-08-04 decision requires trackers to arrive through a RemoteTracker bridge. (3) The 2026-08-18 mirror-fleet code audit found concrete external-repo blockers: the agent image is bun+node only, PR base = run.ref breaks base-commit pinning (SHA base → GitHub 422), builtin prompts assert .seeds/.mulch/bun as facts, no per-project onboarding context reaches the prompt, and the shared host clone races under concurrent dispatch. v0.18.0 clears all three fronts plus the open reliability backlog, so that post-release focus can turn entirely to external projects.","approach":"Five tracks, forward-chained. Track A (steps 1-5): the dispatch-context log as a core insert-only dispatch_context table keyed by run_id, written fire-and-log inside spawnRun (the verified single choke point covering all 8 dispatch sites), after provenance plumbing (dispatchOrigin, dispatcherHandle, scheduled-seed seedId loss) and queue-state/runtime-kind introspection land; exported via a created_at-windowed analytics endpoint. Facts only, no interpretation, per agent-analytics §6.1; verdicts are never read (warren-9236 tripwire not triggered). Track B (steps 6-14): the IssueTracker cut — promote neutral DTOs into src/core (the 'seed' wire stem is already guarded), define the contract (getIssue/listIssueStatuses/closeIssue + capability flags supportsPlans/supportsMetadata/supportsScheduledIssues/isGitNative), wrap the existing src/seeds-cli facade as SeedsTracker, swap ServerDeps.seedsCli for deps.issueTracker, port read paths (plan-runs domain, HTTP handlers, the pr-context.ts hardcoded-sd leak), port write paths behind capabilities, add ordered-issue-list plan-runs for supportsPlans:false trackers, then build the RemoteTracker bridge speaking warren-tracker/v1 to an external container (extension holds its own credential; warren stores none) with a published conformance suite and FakeTracker reference. Linear defers to 0.19. Track C (steps 15-19): external-repo readiness — base-commit pinning (baseCommit dispatch field split from ref; PR base stays branch-shaped; ref validated at the HTTP boundary), per-project host-clone serialization + detached-HEAD-safe materialization, multi-stack agent image with a per-project agentImage override, tracker-neutral builtin prompts gated on project capabilities, and a per-project repoContext onboarding block injected at composeDispatchPrompt. Track D (steps 20-37): the adopted reliability backlog, chained where items share wire vocabulary or files. Step 38 is the release-readiness docs sweep. All owner decisions are locked in the seeds themselves; no step carries an open decision.","alternatives":[{"name":"Dispatch-context log as an extension store tailing run events","rejected_because":"Override-source, queue-state, and retry-lineage facts are not reconstructable from the event stream; the log would be permanently lossy (owner decision 2026-08-18)."},{"name":"Dispatch facts as columns on runs","rejected_because":"runs already mixes 40+ dispatch/reap/preview/PR columns; a narrow adjacent fact table follows the fresh tool_calls precedent and joins cleanly."},{"name":"Contract cut only, bridge in 0.19","rejected_because":"Owner decision 2026-08-18: 0.18 ships the full bring-your-own-tracker story minus Linear."},{"name":"Contract + bridge + Linear in one release","rejected_because":"Linear's payer is not the mirror fleet; it ships 0.19 on its own release track per the 2026-08-04 decision."},{"name":"Warren-side sidecar table for issue metadata/scheduling in 0.18","rejected_because":"Capability flags suffice while seeds is the only metadata-capable tracker; the sidecar lands when a second tracker needs it."},{"name":"Full k8s security pass (NetworkPolicy + eviction salvage + UID separation)","rejected_because":"NetworkPolicy ships scoped to default-deny ingress + DNS/Service egress with kind validation; warren-6c94 eviction drills stay an operator exercise."}],"steps":[{"title":"dispatch provenance plumbing: dispatchOrigin on SpawnRunInput, read dispatcherHandle, fix scheduled/cron seedId loss","type":"task","priority":1,"blocks":[4],"labels":["v0.18.0","dispatch-log"]},{"title":"dispatch_context table: sqlite+postgres schema, migrations, insert-only repo, drift-test registration (incl. missing tool_calls entry)","type":"task","priority":1,"blocks":[4],"labels":["v0.18.0","dispatch-log"]},{"title":"queue-state counts + runtime-kind introspection: countNonTerminal(projectId?) in runs-stats, RuntimeProvider kind field","type":"task","priority":1,"blocks":[4],"labels":["v0.18.0","dispatch-log"]},{"title":"dispatch-context writer in spawnRun: chosen action + override sources, queue snapshot, normalized retry lineage; fire-and-log","type":"task","priority":1,"blocks":[5],"labels":["v0.18.0","dispatch-log"]},{"title":"GET /analytics/dispatch: created_at-windowed dispatch-context report + gen:docs/gen:openapi","type":"task","priority":2,"blocks":[38],"labels":["v0.18.0","dispatch-log"]},{"title":"IssueTracker contract: core DTO promotion (Issue, PlanSummary, errors), capability flags, SeedsTracker impl over src/seeds-cli","type":"task","priority":1,"blocks":[7],"labels":["v0.18.0","issue-tracker"]},{"title":"boot wiring swap: ServerDeps.seedsCli → deps.issueTracker across the 14 pass-through modules + handler preamble","type":"task","priority":1,"blocks":[8,9,10,18],"labels":["v0.18.0","issue-tracker"]},{"title":"plan-runs domain port: showSeed→getIssue, getPlan, neutral PlanStatus vocabulary, ProjectLacksTrackerError rename","type":"task","priority":1,"blocks":[11,12],"labels":["v0.18.0","issue-tracker"]},{"title":"HTTP read-surface port: /projects/:id/seeds handlers behind the tracker + the pr-context.ts hardcoded-sd leak","type":"task","priority":1,"blocks":[12],"labels":["v0.18.0","issue-tracker"]},{"title":"tracker write paths behind capabilities: closeIssue port, supportsMetadata (seed extensions), supportsScheduledIssues, isGitNative fence","type":"task","priority":1,"blocks":[12],"labels":["v0.18.0","issue-tracker"]},{"title":"plan-runs without supportsPlans: POST /plan-runs accepts an ordered issue-id list (2026-08-04 decision)","type":"task","priority":2,"blocks":[38],"labels":["v0.18.0","issue-tracker"]},{"title":"RemoteTracker bridge: warren-tracker/v1 wire protocol + in-core bridge to an external container (extension holds its own credential)","type":"task","priority":1,"blocks":[13],"labels":["v0.18.0","issue-tracker"]},{"title":"warren-tracker/v1 conformance suite + FakeTracker reference server","type":"task","priority":1,"blocks":[14],"labels":["v0.18.0","issue-tracker"]},{"title":"docs/design/issue-tracker.md design record + ROADMAP/AGENTS.md updates + doc tombstones","type":"task","priority":2,"blocks":[38],"labels":["v0.18.0","issue-tracker"]},{"title":"base-commit pinning: baseCommit dispatch field split from ref, branch-or-SHA validation at the HTTP boundary, PR base stays branch-shaped","type":"task","priority":1,"blocks":[16],"labels":["v0.18.0","external-readiness"]},{"title":"per-project host-clone serialization + detached-HEAD-safe materialization (materialize 'main' fallback, migration-preflight skip, clone-apply ref guard)","type":"task","priority":1,"blocks":[38],"labels":["v0.18.0","external-readiness"]},{"title":"multi-stack agent image (python3+uv) + per-project agentImage override in .warren/config.yaml threaded to docker+k8s","type":"task","priority":1,"blocks":[38],"labels":["v0.18.0","external-readiness"]},{"title":"tracker-neutral builtin prompts: gate sd/ml/.seeds/.mulch/quality-gate instructions on project capabilities","type":"task","priority":1,"blocks":[38],"labels":["v0.18.0","external-readiness"]},{"title":"per-project onboarding context: repoContext in DefaultsConfigSchema injected via composeDispatchPrompt + external-repo onboarding docs","type":"task","priority":1,"blocks":[38],"labels":["v0.18.0","external-readiness"]},{"title":"provider-retry classifier reads structured signals: httpStatus/upstreamBody before message, retryOf lineage on the retry run","type":"bug","priority":2,"blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-4e2a","blocks":[22],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-ba08","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-22cf","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-3f32","blocks":[25],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-81e0","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-bea7","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-7e28","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-8dbb","blocks":[],"labels":["v0.18.0","k8s"]},{"existing_seed":"warren-75dd","blocks":[31],"labels":["v0.18.0","acceptance"]},{"existing_seed":"warren-8a6e","blocks":[],"labels":["v0.18.0","acceptance"]},{"existing_seed":"warren-dc19","blocks":[],"labels":["v0.18.0","acceptance"]},{"existing_seed":"warren-a106","blocks":[],"labels":["v0.18.0","judge"]},{"existing_seed":"warren-4de5","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-c97b","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-cb93","blocks":[],"labels":["v0.18.0","k8s"]},{"existing_seed":"warren-70bb","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-53c0","blocks":[],"labels":["v0.18.0","reliability"]},{"title":"release readiness: CHANGELOG curation prep, ROADMAP seam-status update, full gate sweep, acceptance:container pass","type":"task","priority":1,"blocks":[],"labels":["v0.18.0"]}],"risks":["The ServerDeps.seedsCli swap touches 14 pass-through modules plus every handler preamble; a missed site fails typecheck loudly, but the pr-context.ts leak fails silently — it is the one sd spawn a seedsCli grep does not find (it must be ported in step 9 and asserted by a test).","check:wire-types guards the 'seed' stem: DTO promotion into src/core must land in the same PR as the contract or lint fails (known, planned in step 6).","The warren-tracker/v1 wire contract stays experimental until a foreign implementation survives it unchanged; the conformance suite is the falsification test, not a grep (PHILOSOPHY rule 4).","Base-commit pinning changes the reap PR-base resolution (runs/reap/run.ts:132); a regression here breaks every ref-dispatch (pr-fixer, conflict repair). The split must keep ref semantics byte-identical for branch refs.","Wire-vocabulary additions (spawn_failed, no_changes) ripple through golden envelopes, SDK types, UI labels, and check:wire-types; steps 21→22 are serialized to avoid conflicting edits.","NetworkPolicy can break run-pod clone/push egress; the step is scoped to default-deny ingress + DNS/Service egress with kind-overlay validation, and GKE validation happens at release time.","Dockerfile.agent growth (python3+uv) raises image size and build time; keep the toolchain additions minimal and measure the image delta."],"acceptance":["Every dispatch path (API, manual trigger, healer, cron, ci-fixer, plan-run, both retries) writes exactly one dispatch_context row, verified by tests exercising the real spawnRun; a failed context write never fails a dispatch.","GET /analytics/dispatch serves a created_at-windowed report including never-started runs, and docs/http-api.md + docs/openapi.yaml regenerate clean.","No module outside the SeedsTracker implementation spawns sd or reads .seeds/*.jsonl on the tracker path (the isGitNative-fenced reap/finalize machinery excepted); grep + a layer/contract test hold it.","A project served by the FakeTracker reference over warren-tracker/v1 can dispatch a run, run an ordered-issue-list plan-run, and get its issue closed on merge, with zero seeds code in the path.","The published conformance suite passes against FakeTracker and the SeedsTracker parity subset; the wire protocol is documented as experimental pending a foreign implementation.","A run dispatched with baseCommit= materializes the workspace at that commit on both local and k8s providers and opens its PR against a branch base without a 422.","Two concurrent dispatches against one project with different base refs do not corrupt the host clone (serialization test).","A Python mirror repo with no .seeds/.mulch/.warren dispatches end-to-end: the agent image can run pytest, the prompt contains no false .seeds/.mulch/sd/ml assertions, and repoContext from the host-clone .warren/config.yaml reaches the prompt.","All adopted reliability seeds are closed with their fixes merged; bun run check:all passes 12/12; acceptance:container passes.","ROADMAP marks the IssueTracker seam Live and the release-readiness step leaves CHANGELOG/ROADMAP ready for the /release flow."]},"children":["warren-9ce3","warren-36e7","warren-e1f1","warren-d6ca","warren-5423","warren-6c29","warren-5819","warren-2d98","warren-47b0","warren-6234","warren-de42","warren-d3a9","warren-53ea","warren-240e","warren-aaf7","warren-232d","warren-fabb","warren-cb46","warren-540f","warren-eaa6","warren-4e2a","warren-ba08","warren-22cf","warren-3f32","warren-81e0","warren-bea7","warren-7e28","warren-8dbb","warren-75dd","warren-8a6e","warren-dc19","warren-a106","warren-4de5","warren-c97b","warren-cb93","warren-70bb","warren-53c0","warren-57a0"],"createdAt":"2026-08-18T19:24:20.944Z","updatedAt":"2026-08-19T17:05:29.589Z","name":"v0.18.0 — the any-setup release","adoptedChildren":["warren-4e2a","warren-ba08","warren-22cf","warren-3f32","warren-81e0","warren-bea7","warren-7e28","warren-8dbb","warren-75dd","warren-8a6e","warren-dc19","warren-a106","warren-4de5","warren-c97b","warren-cb93","warren-70bb","warren-53c0"]} -{"id":"pl-f0e3","seed":"warren-9b6b","template":"feature","status":"approved","revision":1,"sections":{"context":"Warren speaks only to GitHub. The Forge seam (pl-d1c9) already decoupled the domain from it: the contract carries no GitHub noun, capabilities are flags with stated fallbacks, and FakeForge proved the seam holds against a fake. What has never been tried is a real foreign vendor. The operator needs one instance to host GitHub, Forgejo and GitLab projects at once, selecting the host explicitly at project creation. Upstream (jayminwest/warren) recorded 'Gitea/GitLab demand - refused for now (capability-minimal Forge)' at planning-session-record:252, a refusal of speculative generality rather than a technical objection. A working provider plus a passing falsification test is the evidence that answers it. Full research and every citation: docs/design/multi-forge-support.md.","approach":"In-core provider (research doc section 5, Shape A), built to falsify rather than merely to work. Forge instances are declared in a new server-level config file holding only non-secret shape (id, kind, baseUrl, tokenEnv); every credential resolves from a named env var, honoring the standing precedent at src/forge/github-app/registration.ts:28 that warren never stores a forge credential. Selection stays explicit and warren validates it: URL-grammar routing cannot distinguish self-hosted Forgejo from Gitea from GitLab, since all three are just https://git.example.com/o/r, so discovery is replaced by an identity probe that confirms a stated answer. Probes measured live 2026-08-19: Forgejo answers GET /api/forgejo/v1/version with 200 while Gitea 404s on that exact path; GitLab emits x-gitlab-meta even on a 401; GitHub emits x-github-request-id. Sequencing front-loads the work defensible on its own merit (the credential-env invariant fix, the router) and defers the work that needs the refusal overturned (the Forgejo provider itself). Backward compatibility is the upstream-acceptability lever: with no config file, WARREN_FORGE plus GITHUB_TOKEN must behave exactly as today.","alternatives":[{"name":"RemoteForge bridge over a warren-forge/v1 wire protocol, mirroring the tracker decision (ROADMAP.md:136)","rejected_because":"Rejected for now: the tracker bridge itself is unbuilt and blocked (warren-53ea, P1, open), a wire protocol is a far heavier promise than a TypeScript interface (extensions.md section 5), PR-opening sits directly behind the kernel's push, and under multi-forge each instance becomes a container - three sidecars to serve a control plane whose pitch is one container, one volume, one HTTP API, one UI."},{"name":"Automatic forge detection from the clone URL","rejected_because":"Rejected as incorrect, not merely undesirable: self-hosted Forgejo, Gitea and GitLab share an indistinguishable URL shape, so grammar-based ownership can only guess, and a wrong guess sends the wrong credential to a real server."},{"name":"Forge instances in a DB table registered through the UI","rejected_because":"Rejected because it would store credentials in warren's database, contradicting registration.ts:28."},{"name":"Indexed env vars (WARREN_FORGE_1_KIND etc)","rejected_because":"Rejected: an ordered list with per-entry base URLs and capability overrides is a nested structure that indexed env keys express badly."}],"steps":[{"title":"Forge-instance config surface: forges: schema, loader, boot resolution, backward compat with WARREN_FORGE","type":"task","priority":2,"blocks":[3,4],"labels":["multi-forge","config"]},{"title":"Seam invariant fix: credential-env.ts must not hardcode github.com or x-access-token; widen check:layers to the bare host","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","seam"]},{"title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","type":"task","priority":2,"blocks":[4],"labels":["multi-forge","validation"]},{"title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","router"]},{"title":"Forgejo empirical spike: answer the six research questions against the operator's own instance and amend the design doc with observed evidence","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","spike"]},{"title":"src/forge/forgejo/: transport core, error classifier, retry policy, provider + registry arm","type":"task","priority":2,"blocks":[7],"labels":["multi-forge","forgejo"]},{"title":"Falsification test: a Forgejo project completes dispatch to reap to push to PR with zero domain changes, on an instance simultaneously serving GitHub","type":"task","priority":2,"blocks":[8],"labels":["multi-forge","falsification"]},{"title":"GitLab provider, after Forgejo has proven the path (MR vocabulary, project-id-vs-path, /-/merge_requests/ URL infix)","type":"task","priority":3,"blocks":[],"labels":["multi-forge","gitlab"]}],"risks":["parseRepoRef must move from a pure URL function to one closing over a configured base URL, and RepoRef.forge must carry an instance id rather than a kind. This touches the one shipped forge, so a regression here breaks GitHub, not just the new path.","A new server-level config file is warren's first: src/warren-config/ is entirely per-project. Upstream will ask why env was not enough, and the answer must be on the record.","Forgejo reports CI as commit statuses rather than check runs, so listChecks may degrade to capabilities.checkRuns=false. The fallback is already specified (CI-fixer poller stays idle, one notice per project) but it means no CI-fixer on Forgejo at first.","mx-9cf91f: a provider's parseRepoRef must round-trip its OWN PR web URLs or the merge gate breaks. This is a contract obligation the interface signature does not express and is the most likely thing a new provider gets wrong.","mx-7f711e / check:size: src/server/main/index.ts sits at 486/500 lines. Router boot wiring must land in a new module; the budget is never raised.","mx-c7c3ab: a gate-then-push chained with ';' instead of '&&' pushed a known-corrupt .seeds/issues.jsonl to main. Always chain check:seeds-integrity with &&.","mx-74fdd4: wrong doc citations propagate by imitation across agent runs. The section numbers in docs/design/multi-forge-support.md must stay stable once code comments cite them.","Upstream may still refuse the in-core shape despite the falsification evidence, in which case steps 1-4 remain valuable and step 6 becomes fork-only."],"acceptance":["One warren instance serves a GitHub project and a Forgejo project simultaneously, each routed to its own forge instance and credential.","Falsification test passes: a Forgejo-hosted project completes dispatch to reap to push to PR with ZERO domain-code changes. A required domain change is a contract failure and is reported as the finding.","With no config file present, WARREN_FORGE plus GITHUB_TOKEN behaves exactly as it does today. The change is additive for every existing deployment.","Registering a forge instance whose host is not the selected kind is rejected at registration with a message naming what was found instead (a Gitea host selected as Forgejo must fail).","No domain code outside src/forge/ names a forge host, a credential username, or a vendor API path. check:layers enforces the bare github.com literal, not only api.github.com.","bun run check:all passes 12/12, and acceptance scenario 39 (the public-instance leak guard) stays green."]},"children":["warren-f012","warren-1154","warren-56bb","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"],"createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:27:26.644Z","name":"Multi-forge support (Forgejo first)"} +{"id":"pl-f0e3","seed":"warren-9b6b","template":"feature","status":"active","revision":1,"sections":{"context":"Warren speaks only to GitHub. The Forge seam (pl-d1c9) already decoupled the domain from it: the contract carries no GitHub noun, capabilities are flags with stated fallbacks, and FakeForge proved the seam holds against a fake. What has never been tried is a real foreign vendor. The operator needs one instance to host GitHub, Forgejo and GitLab projects at once, selecting the host explicitly at project creation. Upstream (jayminwest/warren) recorded 'Gitea/GitLab demand - refused for now (capability-minimal Forge)' at planning-session-record:252, a refusal of speculative generality rather than a technical objection. A working provider plus a passing falsification test is the evidence that answers it. Full research and every citation: docs/design/multi-forge-support.md.","approach":"In-core provider (research doc section 5, Shape A), built to falsify rather than merely to work. Forge instances are declared in a new server-level config file holding only non-secret shape (id, kind, baseUrl, tokenEnv); every credential resolves from a named env var, honoring the standing precedent at src/forge/github-app/registration.ts:28 that warren never stores a forge credential. Selection stays explicit and warren validates it: URL-grammar routing cannot distinguish self-hosted Forgejo from Gitea from GitLab, since all three are just https://git.example.com/o/r, so discovery is replaced by an identity probe that confirms a stated answer. Probes measured live 2026-08-19: Forgejo answers GET /api/forgejo/v1/version with 200 while Gitea 404s on that exact path; GitLab emits x-gitlab-meta even on a 401; GitHub emits x-github-request-id. Sequencing front-loads the work defensible on its own merit (the credential-env invariant fix, the router) and defers the work that needs the refusal overturned (the Forgejo provider itself). Backward compatibility is the upstream-acceptability lever: with no config file, WARREN_FORGE plus GITHUB_TOKEN must behave exactly as today.","alternatives":[{"name":"RemoteForge bridge over a warren-forge/v1 wire protocol, mirroring the tracker decision (ROADMAP.md:136)","rejected_because":"Rejected for now: the tracker bridge itself is unbuilt and blocked (warren-53ea, P1, open), a wire protocol is a far heavier promise than a TypeScript interface (extensions.md section 5), PR-opening sits directly behind the kernel's push, and under multi-forge each instance becomes a container - three sidecars to serve a control plane whose pitch is one container, one volume, one HTTP API, one UI."},{"name":"Automatic forge detection from the clone URL","rejected_because":"Rejected as incorrect, not merely undesirable: self-hosted Forgejo, Gitea and GitLab share an indistinguishable URL shape, so grammar-based ownership can only guess, and a wrong guess sends the wrong credential to a real server."},{"name":"Forge instances in a DB table registered through the UI","rejected_because":"Rejected because it would store credentials in warren's database, contradicting registration.ts:28."},{"name":"Indexed env vars (WARREN_FORGE_1_KIND etc)","rejected_because":"Rejected: an ordered list with per-entry base URLs and capability overrides is a nested structure that indexed env keys express badly."}],"steps":[{"title":"Forge-instance config surface: forges: schema, loader, boot resolution, backward compat with WARREN_FORGE","type":"task","priority":2,"blocks":[3,4],"labels":["multi-forge","config"]},{"title":"Seam invariant fix: credential-env.ts must not hardcode github.com or x-access-token; widen check:layers to the bare host","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","seam"]},{"title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","type":"task","priority":2,"blocks":[4],"labels":["multi-forge","validation"]},{"title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","router"]},{"title":"Forgejo empirical spike: answer the six research questions against the operator's own instance and amend the design doc with observed evidence","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","spike"]},{"title":"src/forge/forgejo/: transport core, error classifier, retry policy, provider + registry arm","type":"task","priority":2,"blocks":[7],"labels":["multi-forge","forgejo"]},{"title":"Falsification test: a Forgejo project completes dispatch to reap to push to PR with zero domain changes, on an instance simultaneously serving GitHub","type":"task","priority":2,"blocks":[8],"labels":["multi-forge","falsification"]},{"title":"GitLab provider, after Forgejo has proven the path (MR vocabulary, project-id-vs-path, /-/merge_requests/ URL infix)","type":"task","priority":3,"blocks":[],"labels":["multi-forge","gitlab"]}],"risks":["parseRepoRef must move from a pure URL function to one closing over a configured base URL, and RepoRef.forge must carry an instance id rather than a kind. This touches the one shipped forge, so a regression here breaks GitHub, not just the new path.","A new server-level config file is warren's first: src/warren-config/ is entirely per-project. Upstream will ask why env was not enough, and the answer must be on the record.","Forgejo reports CI as commit statuses rather than check runs, so listChecks may degrade to capabilities.checkRuns=false. The fallback is already specified (CI-fixer poller stays idle, one notice per project) but it means no CI-fixer on Forgejo at first.","mx-9cf91f: a provider's parseRepoRef must round-trip its OWN PR web URLs or the merge gate breaks. This is a contract obligation the interface signature does not express and is the most likely thing a new provider gets wrong.","mx-7f711e / check:size: src/server/main/index.ts sits at 486/500 lines. Router boot wiring must land in a new module; the budget is never raised.","mx-c7c3ab: a gate-then-push chained with ';' instead of '&&' pushed a known-corrupt .seeds/issues.jsonl to main. Always chain check:seeds-integrity with &&.","mx-74fdd4: wrong doc citations propagate by imitation across agent runs. The section numbers in docs/design/multi-forge-support.md must stay stable once code comments cite them.","Upstream may still refuse the in-core shape despite the falsification evidence, in which case steps 1-4 remain valuable and step 6 becomes fork-only."],"acceptance":["One warren instance serves a GitHub project and a Forgejo project simultaneously, each routed to its own forge instance and credential.","Falsification test passes: a Forgejo-hosted project completes dispatch to reap to push to PR with ZERO domain-code changes. A required domain change is a contract failure and is reported as the finding.","With no config file present, WARREN_FORGE plus GITHUB_TOKEN behaves exactly as it does today. The change is additive for every existing deployment.","Registering a forge instance whose host is not the selected kind is rejected at registration with a message naming what was found instead (a Gitea host selected as Forgejo must fail).","No domain code outside src/forge/ names a forge host, a credential username, or a vendor API path. check:layers enforces the bare github.com literal, not only api.github.com.","bun run check:all passes 12/12, and acceptance scenario 39 (the public-instance leak guard) stays green."]},"children":["warren-f012","warren-1154","warren-56bb","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"],"createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T22:53:38.240Z","name":"Multi-forge support (Forgejo first)"} From ec49c19e40ffe6d94c2a9022877c05691a0d6786 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 20 Aug 2026 23:14:58 +0000 Subject: [PATCH 3/3] chore(warren): seeds state --- .seeds/issues.jsonl | 4 ++-- .seeds/plans.jsonl | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.seeds/issues.jsonl b/.seeds/issues.jsonl index 01ce4c4d7..a46d94d03 100644 --- a/.seeds/issues.jsonl +++ b/.seeds/issues.jsonl @@ -1466,11 +1466,11 @@ {"id":"warren-3f76","title":"doctor-remote.test.ts: remoteDoctorDeps test reads the developer's real ~/.warren/client.json","status":"open","type":"bug","priority":3,"createdAt":"2026-08-19T15:15:21.981Z","updatedAt":"2026-08-19T15:15:21.981Z","description":"Follow-up to PR #981. The remoteDoctorDeps test (doctor-remote.test.ts:93) calls the real resolution chain without pinning WARREN_CLIENT_CONFIG, so a corrupt or hand-edited ~/.warren/client.json on a dev/CI machine fails the test for a machine-local reason. src/cli/client.test.ts in the same PR guards every resolution test with a NO_CONFIG_FILE sentinel (WARREN_CLIENT_CONFIG pointed at an impossible path) with a comment explaining the hazard; apply the same guard here. One-line fix."} {"id":"warren-ff07","title":"CLI: empty WARREN_BASE_URL env var throws before the --url flag is considered","status":"open","type":"bug","priority":3,"createdAt":"2026-08-19T15:15:25.059Z","updatedAt":"2026-08-19T15:15:25.059Z","description":"Pre-existing, surfaced while reviewing PR #981 (carried through the rewrite of resolveClientConfigWithSources, src/cli/client.ts:119). loadWarrenClientConfigFromEnv runs unconditionally and throws ValidationError('WARREN_BASE_URL is set to an empty string') before the merge, so a stale .env line 'WARREN_BASE_URL=' plus 'warren doctor --url http://host' dies even though flags > env precedence and the module's own 'empty strings count as unset' rule say the flag should win. firstNonEmpty two lines later would skip the empty env slot; fromEnv is only needed for its default baseUrl, so the throw is avoidable."} {"id":"warren-0812","title":"judge kustomize: secrets.yaml template rides every apply -k and clobbers live judge-secrets with REPLACE_ME placeholders — pull it out of the resources list","status":"open","type":"bug","priority":2,"createdAt":"2026-08-19T15:21:57.286Z","updatedAt":"2026-08-19T15:21:57.286Z","description":"The gke-live-judge overlay includes ../../extensions/judge, whose kustomization lists secrets.yaml (placeholder template, header says never apply it). Any kubectl apply -k on the overlay silently overwrites the live judge-secrets Secret (openrouter-api-key, judge-export-token) with REPLACE_ME. Happened 2026-08-19; recovered by copying the key from warren-openrouter-key in warren-runs. Fix: drop secrets.yaml from the template kustomization resources (create-only, imperative per its own header), or move it to a docs-only path."} -{"id":"warren-9b6b","title":"Multi-forge support: Forgejo, Gitea, GitLab behind the Forge seam","status":"open","type":"feature","priority":2,"createdAt":"2026-08-19T13:16:37.072Z","updatedAt":"2026-08-20T23:13:16.652Z","description":"Umbrella seed for multi-forge support. Full research: docs/design/multi-forge-support.md — that document, not this seed, is the spec (mulch mx-9cc840).\n\nSCOPE DECISIONS (operator, 2026-08-19):\n- Target upstreamable: work lands in this fork but shaped so jayminwest/warren could accept it. The upstream refusal at planning-session-record:252 ('Gitea/GitLab demand — refused for now, capability-minimal Forge') is a live constraint. The falsification test (doc §8 step 6) is the evidence that answers it.\n- Multi-forge from the start: one instance hosts GitHub + Forgejo + GitLab simultaneously. User SELECTS the host at project creation. No automatic forge discovery.\n- Warren VALIDATES the selection automatically (doc §4b). Selection is explicit; validation is automatic.\n\nKEY FINDINGS:\n- The Forge interface needs no widening except one validation method. RepoRef.forge is already typed string ('registry key'), so instance ids fit.\n- URL-grammar routing (forge-contract.md §1.1) CANNOT work for self-hosted forges: https://git.example.com/o/r is a valid Forgejo, Gitea and GitLab URL. Explicit selection is the correctness fix, not a UX preference.\n- Forge identity IS verifiable, probes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that path, 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id. All four unauthenticated.\n- Requires a schema change (projects gains a forge discriminator) and forge INSTANCES not kinds — two self-hosted Forgejo servers are two registry entries. WARREN_FORGE as a single env selector cannot express this. Biggest undecided question (doc §7 Q3).\n- Router blast radius measured: deps.forge is 11 refs across 7 files. Roughly two PRs.\n- Leaks found outside the seam: src/workspace/git/credential-env.ts:38 hardcodes github.com + x-access-token (a §0 invariant violation check:layers cannot see); two process-global resolveForgeKind gates (github-app-gate.ts:86, forge-heartbeat-wiring.ts:46) have no correct answer under multi-forge.\n\nSTILL OPEN: in-core provider vs RemoteForge bridge (doc §5, §7 Q1). Not decided. Everything else follows from it.","plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-1154","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"]} +{"id":"warren-9b6b","title":"Multi-forge support: Forgejo, Gitea, GitLab behind the Forge seam","status":"open","type":"feature","priority":2,"createdAt":"2026-08-19T13:16:37.072Z","updatedAt":"2026-08-19T13:27:26.644Z","description":"Umbrella seed for multi-forge support. Full research: docs/design/multi-forge-support.md — that document, not this seed, is the spec (mulch mx-9cc840).\n\nSCOPE DECISIONS (operator, 2026-08-19):\n- Target upstreamable: work lands in this fork but shaped so jayminwest/warren could accept it. The upstream refusal at planning-session-record:252 ('Gitea/GitLab demand — refused for now, capability-minimal Forge') is a live constraint. The falsification test (doc §8 step 6) is the evidence that answers it.\n- Multi-forge from the start: one instance hosts GitHub + Forgejo + GitLab simultaneously. User SELECTS the host at project creation. No automatic forge discovery.\n- Warren VALIDATES the selection automatically (doc §4b). Selection is explicit; validation is automatic.\n\nKEY FINDINGS:\n- The Forge interface needs no widening except one validation method. RepoRef.forge is already typed string ('registry key'), so instance ids fit.\n- URL-grammar routing (forge-contract.md §1.1) CANNOT work for self-hosted forges: https://git.example.com/o/r is a valid Forgejo, Gitea and GitLab URL. Explicit selection is the correctness fix, not a UX preference.\n- Forge identity IS verifiable, probes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that path, 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id. All four unauthenticated.\n- Requires a schema change (projects gains a forge discriminator) and forge INSTANCES not kinds — two self-hosted Forgejo servers are two registry entries. WARREN_FORGE as a single env selector cannot express this. Biggest undecided question (doc §7 Q3).\n- Router blast radius measured: deps.forge is 11 refs across 7 files. Roughly two PRs.\n- Leaks found outside the seam: src/workspace/git/credential-env.ts:38 hardcodes github.com + x-access-token (a §0 invariant violation check:layers cannot see); two process-global resolveForgeKind gates (github-app-gate.ts:86, forge-heartbeat-wiring.ts:46) have no correct answer under multi-forge.\n\nSTILL OPEN: in-core provider vs RemoteForge bridge (doc §5, §7 Q1). Not decided. Everything else follows from it.","plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-1154","warren-56bb","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"]} {"id":"warren-f012","title":"Forge-instance config surface: forges: schema, loader, boot resolution, backward compat with WARREN_FORGE","status":"closed","type":"task","priority":2,"plan_step_index":0,"description":"SPEC: docs/design/multi-forge-support.md §2a (Forge-instance configuration, DECIDED). Also §7 Q3/Q4.\n\nDeliver the 'forges:' config surface: id/kind/baseUrl/tokenEnv per entry, credentials resolved from named env vars only (never stored — precedent src/forge/github-app/registration.ts:28). Reuse the zod conventions in src/warren-config/schema.ts; note that module is per-project, so this is warren's FIRST server-level config file and the PR must justify why env was not enough.\n\nMUST: (1) no config file present => WARREN_FORGE + GITHUB_TOKEN behaves exactly as today, resolving to a single-entry registry — this backward compatibility is the upstream-acceptability lever; (2) baseUrl required for self-hosted kinds, forbidden for github; (3) a missing tokenEnv variable fails LOUDLY at boot in the UnknownForgeError style (forge-contract.md §1.1 — no silent fallback); (4) decide whether instance ids are constrained — they land in RepoRef.forge, appear in logs and on persisted rows, and want the path-safety discipline of src/forge/github/repo-ref.ts.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T14:32:01.870Z","labels":["multi-forge","config"],"plan_id":"pl-f0e3","blocks":["warren-56bb","warren-834e","warren-9b6b"],"closedAt":"2026-08-20T14:32:01.870Z"} {"id":"warren-1154","title":"Seam invariant fix: credential-env.ts must not hardcode github.com or x-access-token; widen check:layers to the bare host","status":"closed","type":"task","priority":2,"plan_step_index":1,"description":"SPEC: docs/design/multi-forge-support.md §3 Leak 1, and §8 step 2 for the framing.\n\nsrc/workspace/git/credential-env.ts:38 hardcodes both the host and GitHub's x-access-token username in the GIT_CONFIG insteadOf rewrite. forge-contract.md §0 names x-access-token as one of six things the domain must never leak. Take a GitCredential (which carries a provider-chosen username, contract.ts:61) plus the remote host instead. Widen the check:layers pattern from api\\\\.github\\\\.com to catch the bare github.com host outside src/forge/.\n\nFRAMING MATTERS: justify as an INVARIANT fix, not a multi-forge fix. No test fails today and FakeForge's fake:// URLs never exercise an authenticated non-GitHub remote, so a multi-forge argument here is the speculative generality planning-session-record:117 refused. The argument that survives review is §0's.\n\nCall sites threading a raw token: src/projects/clone.ts:165, refresh.ts, manage.ts, src/plan-runs/dispatch.ts, src/runs/retry/infra-lost-retry.ts, src/triggers/project-heal.ts, src/runtime/k8s/git-tokens.ts:75. Per mx-06bd81 any new credential-carrying field name must be added to SECRET_FIELDS in src/observability/log-redact.ts.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T20:19:13.946Z","labels":["multi-forge","seam"],"plan_id":"pl-f0e3","blocks":["warren-99a6","warren-9b6b"],"closedAt":"2026-08-20T20:19:13.946Z"} {"id":"warren-56bb","title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","status":"closed","type":"task","priority":2,"plan_step_index":2,"description":"SPEC: docs/design/multi-forge-support.md §4b (Forge identity validation).\n\nAdd ONE contract method so a provider can prove the software at a configured base URL is the kind the operator selected. Implement for GitHubForge and FakeForge (which satisfies it by owning fake://). Run it at forge-INSTANCE registration, not project creation — §4b splits the two checks and explains why.\n\nProbes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that exact path but 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id + x-github-media-type. All unauthenticated. Assert the NEGATIVE too — selecting Gitea as Forgejo must fail.\n\nJUSTIFY BY INVARIANT: this is the only widening of the Forge interface the design proposes, and §1's 'the contract needs no widening' is the strongest argument in the upstream case. The line that survives review: §0 forbids the domain learning what software a host runs, so the probe belongs behind the seam.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T23:13:16.652Z","labels":["multi-forge","validation"],"plan_id":"pl-f0e3","blockedBy":["warren-f012"],"blocks":["warren-834e","warren-9b6b"],"closedAt":"2026-08-20T23:13:16.652Z"} -{"id":"warren-834e","title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","status":"open","type":"task","priority":2,"plan_step_index":3,"description":"SPEC: docs/design/multi-forge-support.md §2 (routing) + §3 Leak 2/3/4 + §8 step 4 (measured blast radius).\n\nLand with GitHub and FakeForge ONLY — two forges already prove the plural path, and doing it before Forgejo exists keeps the router honest rather than Forgejo-shaped.\n\nScope: projects gains a forge discriminator (schema change IS required — URL re-derivation does not survive explicit selection); POST /projects gains the field (it accepts only gitUrl today, handlers/projects.ts:110); ServerDeps carries a resolver instead of one Forge; the §4b ownership check; the UI picker.\n\nCRITICAL (§2): parseGitHubRepoRef (src/forge/github/repo-ref.ts:31) is a PURE function of the URL — github.com baked into five grammars, key templated as github.com/owner/repo, forge field set to the module constant GITHUB_FORGE_KIND. Two self-hosted Forgejo instances would each claim the other's URLs. Providers must close over a configured baseUrl and RepoRef.forge must carry the INSTANCE id. The contract permits it (typed string, 'registry key') but every provider hardcodes its kind, so this is a per-provider change and it touches the one SHIPPED forge — a regression here breaks GitHub.\n\nLeak 4 dispositions (§3): make the credential heartbeat per-instance, looping over registered forges and probing each whose credentialLifetime is short-lived; keep the App registration gate instance-scoped. Both resolveForgeKind callers (src/server/github-app-gate.ts:86, src/server/main/forge-heartbeat-wiring.ts:46) ask a process-global question with no correct answer under multi-forge.\n\nBlast radius: deps.forge is 11 refs across 7 files (handlers/projects.ts, plan-runs.ts, alerts.ts, runs/dispatch.ts, runs/pause-resume.ts, runs/git-credential.ts, main/bridges-wiring.ts). Per mx-195e69 both inline-reap cancel sites migrate together through cancelRunWiring. Per mx-7f711e wiring lands in a NEW module: src/server/main/index.ts is at 486/500 check:size lines. Estimate two PRs.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T23:13:16.652Z","labels":["multi-forge","router"],"plan_id":"pl-f0e3","blockedBy":["warren-f012"],"blocks":["warren-99a6","warren-9b6b"]} +{"id":"warren-834e","title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","status":"open","type":"task","priority":2,"plan_step_index":3,"description":"SPEC: docs/design/multi-forge-support.md §2 (routing) + §3 Leak 2/3/4 + §8 step 4 (measured blast radius).\n\nLand with GitHub and FakeForge ONLY — two forges already prove the plural path, and doing it before Forgejo exists keeps the router honest rather than Forgejo-shaped.\n\nScope: projects gains a forge discriminator (schema change IS required — URL re-derivation does not survive explicit selection); POST /projects gains the field (it accepts only gitUrl today, handlers/projects.ts:110); ServerDeps carries a resolver instead of one Forge; the §4b ownership check; the UI picker.\n\nCRITICAL (§2): parseGitHubRepoRef (src/forge/github/repo-ref.ts:31) is a PURE function of the URL — github.com baked into five grammars, key templated as github.com/owner/repo, forge field set to the module constant GITHUB_FORGE_KIND. Two self-hosted Forgejo instances would each claim the other's URLs. Providers must close over a configured baseUrl and RepoRef.forge must carry the INSTANCE id. The contract permits it (typed string, 'registry key') but every provider hardcodes its kind, so this is a per-provider change and it touches the one SHIPPED forge — a regression here breaks GitHub.\n\nLeak 4 dispositions (§3): make the credential heartbeat per-instance, looping over registered forges and probing each whose credentialLifetime is short-lived; keep the App registration gate instance-scoped. Both resolveForgeKind callers (src/server/github-app-gate.ts:86, src/server/main/forge-heartbeat-wiring.ts:46) ask a process-global question with no correct answer under multi-forge.\n\nBlast radius: deps.forge is 11 refs across 7 files (handlers/projects.ts, plan-runs.ts, alerts.ts, runs/dispatch.ts, runs/pause-resume.ts, runs/git-credential.ts, main/bridges-wiring.ts). Per mx-195e69 both inline-reap cancel sites migrate together through cancelRunWiring. Per mx-7f711e wiring lands in a NEW module: src/server/main/index.ts is at 486/500 check:size lines. Estimate two PRs.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.170Z","labels":["multi-forge","router"],"plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-56bb"],"blocks":["warren-99a6","warren-9b6b"]} {"id":"warren-09ea","title":"Forgejo empirical spike: answer the six research questions against the operator's own instance and amend the design doc with observed evidence","status":"open","type":"task","priority":2,"plan_step_index":4,"description":"SPEC: docs/design/multi-forge-support.md §6 (the six questions). Fill the §4 capability table with OBSERVED evidence, following the warren-bc4c precedent — each answer carries what was actually seen, because GitHub's equivalent spike found four things no doc stated.\n\nRun against the operator's OWN Forgejo (they can test Forgejo and GitLab):\nQ0. Do the §4b probes hold on a private instance, and do they still answer when the instance requires sign-in for all views? Public instances cannot test this and it decides whether the unauthenticated probe is contract or convenience.\nQ1. Does a Forgejo PAT reach PR create, PR list-by-head-and-base, PR patch-body, branch delete? Which scopes?\nQ2. What does Forgejo report for CI — commit statuses, an Actions API, or both? Is there a per-job log endpoint? Sets capabilities.checkRuns and jobLogs.\nQ3. Is PR creation idempotent-resolvable? contract.ts:201 REQUIRES a duplicate resolve to the existing PR rather than surface a conflict. What does Forgejo return?\nQ4. Can the token owner be read for botIdentity, and does Forgejo accept insteadOf-style https credential injection (this validates the warren-1154 fix)?\nQ5. What is the PR web URL shape exactly, and does it round-trip through parseRepoRef? Per mx-9cf91f this is a hard contract obligation and the most likely thing a new provider gets wrong. Capture a REAL URL, do not assume /pulls/.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.252Z","labels":["multi-forge","spike"],"plan_id":"pl-f0e3","blocks":["warren-99a6","warren-9b6b"]} {"id":"warren-99a6","title":"src/forge/forgejo/: transport core, error classifier, retry policy, provider + registry arm","status":"open","type":"task","priority":2,"plan_step_index":5,"description":"SPEC: docs/design/multi-forge-support.md §4a (implementation constraints recorded in mulch) + §4 (capability mapping — its Forgejo CI rows are UNVERIFIED; spike warren-09ea answers them, do not treat that table as ground truth) + §1 (the ten methods).\n\nMirror the src/forge/github/ decomposition — transport core, error classifier, retry policy, provider as separate modules — because the naive union exceeds the 500-line check:size budget (github/provider.ts sits at 486).\n\nBINDING CONSTRAINTS FROM MULCH (§4a), none of which appear in the design doc:\n- mx-9cf91f: parseRepoRef MUST round-trip the forge's own PR web URLs or the merge gate breaks. GitHubForge handles /pull/, FakeForge strips /pulls/.\n- mx-90f27c / mx-3aab77: transport retry direction is settled — transient is network/5xx/429, every other 4xx is FATAL, because retrying a 401/403 hides the expired-credential signal forge-contract §4 exists to surface. Copy this, do not invent one.\n- mx-0aebaa: capability flags gate BEFORE any forge call (poller stays idle), rate-limited through the ProjectHealTracker notice-gate.\n- mx-37f192 / mx-230461: request helper takes a userAgent + context label, exposes a retry? passthrough so tests inject sleep:async()=>{}; recordingFetch/jsonResponse test helpers, and jsonResponse takes (status, body) — opposite of legacy copies.\n- mx-0aebaa / mx-195e69: tests use FakeForge + Object.defineProperty to flip readonly capability flags; reap tests use fakeForge()/stubForge() from src/runs/reap/test-helpers.ts. NEVER hand-rolled fetch mocks.\n\nTests land in the SAME PR: Article II (nothing grandfathered at birth) and the coverage ratchet does not fund an untested tree.\n\nSCOPE NOTE (2026-08-19): the operator runs GITEA in their homelab and GITLAB at work, so 'Forgejo first' may be the wrong framing. Gitea and Forgejo share the /api/v1/ surface — the §4b probe showed they differ only in that Forgejo answers /api/forgejo/v1/version (200) where Gitea 404s. So this is probably ONE gitea-family provider serving both kinds, with the identity probe distinguishing them and capability flags absorbing divergence, rather than two providers. Settle this before writing the transport core; it changes the directory name and the registry arms.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T19:10:17.027Z","labels":["multi-forge","forgejo"],"plan_id":"pl-f0e3","blockedBy":["warren-1154","warren-834e","warren-09ea"],"blocks":["warren-9449","warren-9b6b"]} {"id":"warren-9449","title":"Falsification test: a Forgejo project completes dispatch to reap to push to PR with zero domain changes, on an instance simultaneously serving GitHub","status":"open","type":"task","priority":2,"plan_step_index":6,"description":"SPEC: docs/design/multi-forge-support.md §0 (the test this contract must pass) + §8 step 6. THIS IS THE DELIVERABLE THAT MATTERS — it is the evidence that answers the upstream refusal at planning-session-record:252, not a final checkbox.\n\nA Forgejo-hosted project registers and completes dispatch -> reap -> push -> PR with ZERO domain-code changes, on an instance SIMULTANEOUSLY serving a GitHub project. If it needs a domain change, the contract failed, and that finding is worth more than the provider — report it rather than patching around it.\n\nModel on the existing falsification work: warren-2600 built the cross-process FakeForge seam (WARREN_FAKE_FORGE_STATE_FILE, scripts/acceptance/lib/fake-forge.ts startFakeForgeAutoMerge plays GitHub's auto-merge role). Per mx-d5a98b the ONE registration-boundary leak the last forge swap hit was POST /projects' github-only parseGitHubUrl.\n\nAcceptance scenario 39 (the public-instance leak guard) must stay green at every commit.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.417Z","labels":["multi-forge","falsification"],"plan_id":"pl-f0e3","blockedBy":["warren-99a6"],"blocks":["warren-f6b9","warren-9b6b"]} diff --git a/.seeds/plans.jsonl b/.seeds/plans.jsonl index 03507e167..941ec0ecc 100644 --- a/.seeds/plans.jsonl +++ b/.seeds/plans.jsonl @@ -118,4 +118,4 @@ {"id":"pl-17ca","seed":"warren-2053","template":"feature","status":"done","revision":3,"sections":{"context":"The §12.6 owner cut landed 2026-08-15 (docs/design/agent-analytics.md, commit e1c44e3b): rubric v1 is the full 15-class behavioral taxonomy (§12.4) and the verdict shape is locked (§12.3) — multi-label with low/medium/high confidence bands (never a float), clean exclusive of all other classes, at least one event-sequence range per non-clean class plus an optional note capped at 200 chars, provenance (judge model id, rubric-version hash, judged-at, judgment cost), and append-only re-judging under new rubric versions. With that cut recorded, nothing gates the judge's birth except building it (§12.6). The placement decision is extension-first with a named in-core exit at the phase-4 boundary (§12.1). The judge is a function, not a run (§12.2): it executes no code, needs no sandbox, and judge runs must not pollute the corpus they analyze — its whole tool surface is 'page the transcript, emit a verdict'. Prior art is the audit-log observer (pl-116e, extensions/audit-log/): standalone package, hand-rolled client over docs/openapi.yaml, bounded-page event tailing (FRICTION §1), append-only SQLite store with deterministic dedupe keys and ON CONFLICT DO NOTHING replay safety, cursor-after-sink checkpointing, and a token-gated JSONL export. Per §12.6 this work does NOT wait on warren-f566 (the global lifecycle stream): the judge is born tailing today's HTTP surface the way audit-log was, logging new friction into extensions/audit-log/FRICTION.md, and joins as payer #3 for the stream when it lands. Phase-1 data the judge joins against already shipped in pl-103e: pr_state/pr_merged_at ground truth, events.origin, the tool-calls rollup.","approach":"Build extensions/judge/ as the second Tier-1 observer, cloning the audit-log conventions wholesale: own package.json/bun.lock/tsconfig/Dockerfile, zero src/ or scripts/ imports (check:layers holds both directions), all warren reads over the published HTTP surface with the operator token, own SQLite store on its own volume. The judge loop itself is a bounded two-tool agent loop driven by the pi SDK (@earendil-works/pi-coding-agent — §12.2's named natural in-ecosystem driver, already warren's default-runtime pin) with its default coding-agent toolset stripped to nothing (createAgentSession with noTools: 'builtin' + customTools) and a report_verdict tool whose terminate: true result ends the loop — schema-validated at the tool layer and prompt-enforced as the mandatory final action, since the session API surfaces no provider tool_choice forcing (verified against the SDK 2026-08-15). Provider-agnostic by owner call (2026-08-15): the judge model is a JUDGE_PROVIDER/JUDGE_MODEL env pair defaulting to a cheap tier (anthropic / claude-haiku-4-5), never a hardcoded vendor, so cheaper or higher-quality models — including cross-provider — swap in without a code change; provider and model id are both recorded in provenance. Coverage is total per §12.1: every terminal run gets judged or gets a visible unjudged marker; cost is controlled by model choice and budget gates, never by sampling. Validation is enforced at the wire-type layer before any write: clean exclusivity, evidence-range presence, note cap, band enum — a malformed verdict is retried against the model then marked unjudged, never stored partially. Re-judging appends under a distinct rubric-version hash; the calibration pass (§12.5) is a strong-model re-judge over a random sample whose agreement rate is itself a stored, queryable metric. Forward-chained in three tiers: (1) scaffold + locked wire types, then the three independent legs — read client, verdict store, rubric prompt; (2) the judge loop joining all three, then the collector daemon that drives it with budget gates; (3) calibration re-judge and the export/smoke/docs cap. Every step is one agent-sized PR against the extension package only.","alternatives":[{"name":"Drive the loop directly with @anthropic-ai/sdk (the rev-1 choice)","rejected_because":"Overruled by owner call 2026-08-15: a direct Anthropic SDK dependency hardcodes a vendor into the judge's engine, and the cost story (§12.1) rests on freely swapping cheaper or higher-quality models — including cross-provider for the calibration strong judge. §12.2 already names the pi SDK the natural in-ecosystem driver, and warren's own model tiers treat provider as a swappable dimension (WARREN_MODEL_*_PROVIDER). The rev-1 concern — pi is a coding-agent harness the judge must strip down — is carried as a named risk with a fallback (a thin provider-agnostic completion layer), not a reason to bind the corpus engine to one vendor."},{"name":"Ship the judge as a ninth builtin agent dispatched through the run primitive","rejected_because":"Rejected by the design record itself (§12.2): a judge executes no code and needs no sandbox, and judge runs would pollute the corpus they analyze or need a recursion guard to exclude themselves."},{"name":"Wait for warren-f566 and subscribe to the global lifecycle stream instead of polling","rejected_because":"§12.6 explicitly frees the judge from that dependency — audit-log already proved the poll-and-page pattern against today's surface with the friction logged. Waiting stalls the verdict corpus for a delivery optimization; when the stream lands the judge migrates and becomes payer #3."},{"name":"Sample runs to control judge cost","rejected_because":"Overruled by the recorded owner decision (§12.1): judges run on every run because an unjudged run is a hole in the corpus and the join only compounds if coverage is total. Cost is controlled by model choice and visible budget skips, not coverage."},{"name":"Store verdicts in a core table for easy joining with run analytics","rejected_because":"PHILOSOPHY's litmus sorts interpretation out of core, and §12.2 locks 'the verdict lands in the extension's own store, never in a core table'. The in-core exit exists but is a named phase-4-boundary owner call, not a default."}],"steps":[{"title":"Scaffold extensions/judge/ + rubric-v1 wire types: standalone package (own package.json, bun.lock, tsconfig, Dockerfile, README) on the audit-log conventions, env contract (WARREN_BASE_URL, WARREN_API_TOKEN, JUDGE_PROVIDER/JUDGE_MODEL, per-provider model credentials as the pi SDK expects — ANTHROPIC_API_KEY, OPENAI_API_KEY, etc., only the configured provider's key required — and the JUDGE_* knobs), and src/wire.ts encoding the locked §12.3 verdict shape — the 15-class enum, low/medium/high bands, evidence ranges {fromSeq,toSeq}, 200-char note cap, provenance block (provider + model id, rubric-version hash, judged-at, cost) — with parse/validate functions enforcing clean exclusivity and range-presence, plus __golden__ fixtures pinning the verdict JSON shape","blocks":[2,3,4]},{"title":"Warren read client + fake-warren double: hand-rolled client over docs/openapi.yaml for GET /runs (terminal-run discovery), GET /runs/:id (run facts: outcome, failure reason, cost, pr_state ground truth), and GET /runs/:id/events bounded pages (?since=&limit=, never a held follow stream — FRICTION §1 pattern); token held in closure and never logged (audit-log client.ts pattern); fake-warren test double serving canned runs and event pages","blocks":[5]},{"title":"Append-only verdict store: extension-owned SQLite (bun:sqlite) with verdicts + unjudged markers; dedupe key (runId, rubricVersion, judgeModelId) with ON CONFLICT DO NOTHING so replay is a no-op; re-judge under a new rubric version appends and never overwrites; rowid is the export paging sequence (audit-store.ts pattern); write path accepts only wire.ts-validated verdicts; query surface for per-rubric-version reads and the calibration join","blocks":[5]},{"title":"Rubric v1 authoring: the judge system prompt rendering the 15-class §12.4 taxonomy with per-class definitions and evidence-pointability instructions; the report_verdict tool schema (TypeBox parameters) derived from wire.ts — schema-validated at the tool layer, multi-label, banded confidence, ranges + capped note — plus the tool promptGuidelines snippet making report_verdict the mandatory final action (the pi session API surfaces no provider tool_choice forcing); rubricVersion computed as a hash over a canonical serialization of prompt + taxonomy so an intentional edit forks the version and whitespace churn does not; prompt goldens pinning the rendered rubric","blocks":[5]},{"title":"The judge loop: bounded pi-SDK agent loop — createAgentSession (@earendil-works/pi-coding-agent) with noTools: 'builtin' stripping the coding toolset, customTools registering exactly two read tools (get_run_facts; page_events cursoring NormalizedEvent rows via the client) plus report_verdict, whose execute returns terminate: true to end the loop; verdict emission is prompt-enforced via the tool's promptGuidelines (no provider tool_choice forcing exists at the session API — a judgment ending in plain text counts against the retry budget); model resolved via ModelRuntime from JUDGE_PROVIDER/JUDGE_MODEL with per-provider env keys (cheap tier default, no hardcoded vendor); transcript paging with a hard cap on pages per judgment so oversized event tails degrade to a lower-confidence verdict instead of unbounded spend; per-judgment token/cost accounting from session.getSessionStats() into provenance (provider + model id); malformed-or-missing-verdict retry (bounded) then unjudged — a judgment returns a validated verdict or an unjudged marker, nothing else","blocks":[6]},{"title":"Collector daemon + budget gates: poll GET /runs for newly-terminal runs (cursor store, checkpoint only after the verdict store accepts — audit-log delivery discipline), drive one judgment per terminal run idempotently under the current rubric version; enforce JUDGE_MAX_COST_USD per judgment and JUDGE_DAILY_BUDGET_USD fleet-wide — on breach skip and write a visible unjudged marker with reason budget_exceeded, never degrade silently (§12.5); graceful shutdown finishes the in-flight judgment","blocks":[7,8]},{"title":"Calibration re-judge: periodic strong-model pass (JUDGE_CALIBRATION_PROVIDER/JUDGE_CALIBRATION_MODEL, cross-provider capable) over a random sample of judged runs, appending verdicts under the same rubric version with the strong model's provider + id; per-class and overall band-agreement rate computed between cheap and strong verdicts, stored per rubric version as a queryable metric (§12.5 — the disagreement rate is itself the tracked signal that drives any future taxonomy narrowing); sample size and cadence as JUDGE_CALIBRATION_* env knobs","blocks":[8]},{"title":"Export surface, smoke, and docs: token-gated GET /verdicts.jsonl paging by ?since= (audit-log export pattern) plus an agreement-rate summary endpoint; end-to-end smoke against fake-warren proving terminal run → judged → validated verdict exported, the budget-skip path, and the re-judge append path; README covering deploy beside warren, env contract, and the Goodhart guard (verdicts never enter agent context raw — no mulch write exists in v1, §12.5); new missing-surface friction logged in extensions/audit-log/FRICTION.md","blocks":[]}],"risks":["Discovery polling waste: FRICTION §1 quantifies O(1 + active runs) requests per poll cycle. The judge only cares about terminal transitions, so the collector polls the runs list alone (no per-run tails until judging) — but a busy instance still pays a full re-list per cycle. Accepted cost until warren-f566; do not invent a private delivery channel.","Pi is a coding-agent harness, not a bare completion client. Verified against the installed SDK 2026-08-15 (via pi itself): noTools: 'builtin' strips the coding toolset while keeping customTools; defineTool + terminate: true ends the loop on the verdict call; ModelRuntime resolves per-provider env keys; getSessionStats() yields exact USD cost for catalog models. The one confirmed gap: no first-class tool_choice forcing at the session API — a judgment that ends in plain text without calling report_verdict must count against the bounded retry then mark unjudged. If that proves too lossy in practice, the escape hatches are an extension tool_call hook (block non-verdict endings) or StreamOptions.onPayload at the pi-agent-core layer — never a return to a single-vendor SDK.","Transcript scale: event tails routinely exceed a judge context window, and the tool-calls rollup is not on the wire for extensions. The page cap per judgment bounds spend but risks verdicts formed on a truncated read — the loop must record pages-read in provenance so a capped judgment is distinguishable from a full one.","Rubric hash instability: if rubricVersion hashes a non-canonical serialization, whitespace or key-order churn forks the corpus into unjoinable versions. Canonicalize before hashing and pin with a golden.","Cheap-model band calibration: haiku-tier judges may cluster on medium confidence, starving the Goodhart high-confidence door. The calibration pass measures this from day one; the answer is prompt/model iteration under new rubric versions, never post-hoc relabeling.","Export leak surface: verdicts are interpretations of possibly-private repos. The export endpoint is bearer-gated from birth; there is no public projection of verdicts, and adding one later is an owner call with allowlist classification.","bun install in a fresh git worktree rewrites bun.lock with unrelated churn (mx-956e6b) — agents working children in worktrees must not commit lockfile noise outside extensions/judge/.","Corpus pollution recursion: the judge never judges its own activity because it produces no runs (§12.2). Keep it that way — any future 'dispatch a judge run' convenience reintroduces the recursion guard problem the function shape was chosen to avoid."],"acceptance":["extensions/judge/ exists as a standalone package importing zero src/ or scripts/ modules; bun run check:all at the warren root stays green with the extension in the tree (check:layers holds both directions).","Against the fake-warren double: a terminal run produces exactly one validated rubric-v1 verdict in the store — multi-label with banded confidence, clean exclusive, every non-clean class carrying at least one event-sequence range, notes at most 200 chars, provenance complete (provider + model id, rubric-version hash, judged-at, cost) — and the verdict pages out over token-gated GET /verdicts.jsonl.","Re-running the collector over an already-judged run under the same rubric version writes nothing (idempotent replay); re-judging under a bumped rubric version appends a second verdict and both remain readable, keyed by version.","A judgment whose cost gate trips, or whose model output fails validation after bounded retries, yields a visible unjudged marker with a reason — never a partial verdict, never a silent skip.","The calibration pass produces a stored band-agreement rate between cheap and strong judges for the sampled runs, queryable per rubric version.","Swapping JUDGE_PROVIDER/JUDGE_MODEL to a different provider requires no code change in the extension — the judge loop has no vendor-specific SDK import outside the pi SDK itself.","No verdict content is written to any core warren table, to mulch, or into any agent-visible context; the only egress is the extension's own gated export.","New friction hit against warren's HTTP surface is logged in extensions/audit-log/FRICTION.md with the future-mechanism statement the house form requires."]},"children":["warren-6fc4","warren-4e8c","warren-7841","warren-560c","warren-1dcd","warren-33da","warren-0ec4","warren-265d"],"createdAt":"2026-08-15T15:01:44.346Z","updatedAt":"2026-08-15T18:09:51.346Z","name":"Judge-layer extension (rubric v1)","outcome":"success"} {"id":"pl-3007","seed":"warren-b73f","template":"feature","status":"done","revision":2,"sections":{"context":"The 2026-08-16 operator decision (ROADMAP 'Now') queues the self-host push: Next items 2, 3, 4 in that order. The home-server install is warren's headline pitch and today's quickstart falsifies it — a fresh operator needs four bwrap security flags, SYS_ADMIN, and a hand-minted burrow token pair before the first dispatch. Root cause is the burrow dependency: the 2026-07-30 absorption decision ('Decisions already made') established that burrow was scaffolding built to build warren, that agent-runtime logic is internal to warren, and that the end state is warren importing zero burrow code. The decision's origin — the pi event-volume investigation tracing a pi parser gap (tool_execution_update) into burrow library code inside warren's k8s pods — is exactly the detour this campaign ends. Current state verified at HEAD: bucket 3 (domain vocabulary leakage) is already eliminated (eviction commits a2fa66e4..36d58c95, warren-c80e amendment 2026-08-13); the live burrow import surface is src/burrow-client/, src/runtime/local/**, src/runtime/registry.ts, and the k8s in-pod trio (agent-entrypoint/agent-io/agent-stdin-hold). PR #887 (v0.16.0) shipped the adapter registry and both tenant moves (GH#846 items 1-3), so src/runtime/adapters/ exists as the home phase 2 lifts code into. This plan realizes warren-c80e steps 4-6. It runs concurrent with the dogfood tech-debt queue (~27 issues), so every child names a file set disjoint from that queue's territory (plan-run coordinator, merge gate, reap close hook, and the in-flight k8s pod-watcher/cancel/status files).","approach":"Three phases in strict ROADMAP order (2 then 3 then 4), decomposed into single-agent single-PR children, each naming its file set. Phase 2 is a SOURCE LIFT, not a rewrite: burrow's pi + claude-code buildSpawnCommand, parsers (with their golden RPC fixtures), and steering encoders move into src/runtime/adapters/, then the k8s in-pod trio rewires onto them and a check:layers rule pins the exit criterion (src/runtime/k8s/ imports zero burrow code) per PHILOSOPHY rule 4. Phase 3 lands lift-then-wire: the bwrap/sandbox-exec/cgroup profile generation lifts into a new warren-owned src/sandbox/ module (binding a real writable $HOME separate from the workspace — the designated warren-c865 fix per its 2026-08-16 decision block), then LocalProvider swaps its burrow-daemon client for an in-process spawn + the same host-side drive loop the k8s entrypoint runs; preview sidecars re-home; the supervisor stops spawning burrow serve; and the excision child deletes src/burrow-client/, both package pins, the version-sync burrow assertions, the two burrow layer rules, and rewrites the burrow doc sections. No intermediate raw-exec daemon mode — the absorption decision retires that contract. Phase 4's two token wins ship FIRST as early children (ROADMAP item 4 explicitly allows front-loading): first-boot WARREN_API_TOKEN minting and supervisor-internal burrow channel-token minting are immediately dispatchable and deliver operator value before any excision. DockerProvider and the acceptance:container scenario close the campaign, followed by the solo wire/column rename that supersedes warren-c4f3. GH#846 items 4-5 (runtimeId union typing + lint guard) stay a published good-first-challenge, NOT a plan child: the contributor who shipped #887 has publicly claimed them as a separate PR, and no phase-2 child depends on them — the lift keys off the existing adapter registry, not the runtimeId type.","alternatives":[{"name":"Intermediate raw-exec daemon mode in burrow (burrow serve without bwrap) as a stepping stone","rejected_because":"The 2026-07-30 absorption decision retires that contract explicitly; it preserves the socket/token surface the campaign exists to kill."},{"name":"Rewrite the parsers/encoders fresh in warren style instead of source-lifting","rejected_because":"Burrow's golden RPC fixtures pin known behavior (including the pi telemetry collapse rules); a rewrite reopens every parser gap the absorption decision was triggered by."},{"name":"Pull GH#846 items 4-5 in as an early plan child","rejected_because":"An external contributor (author of #887) has publicly claimed them as a separate PR; duplicating collides with in-flight community work, and nothing in phase 2 needs the runtimeId union at the contract seam."},{"name":"Keep @os-eco/burrow-cli as a types-only library dependency after phase 3","rejected_because":"ROADMAP item 3 names the full excision (pins, version-sync assertions, layer rules) as part of the phase; a surviving pin recreates the double-pin drift hazard."},{"name":"Ship phase 4 entirely after the excision","rejected_because":"ROADMAP item 4 states the two token wins may ship before the excision; front-loading them removes the worst quickstart friction months earlier."},{"name":"Split warren-c4f3 into rename-now/migrate-later","rejected_because":"Its 2026-08-16 decision block rejects the split: full rename INCLUDING the column migration, one change, solo."}],"steps":[{"title":"Mint WARREN_API_TOKEN on first boot when unset and print it once to the logs","type":"task","priority":2,"labels":["self-host"],"blocks":[11]},{"title":"Mint the burrow channel token inside the supervisor: drop BURROW_API_TOKEN/WARREN_BURROW_TOKEN from the operator surface","type":"task","priority":2,"labels":["self-host"],"blocks":[8,11]},{"title":"Source-lift pi + claude-code harness logic (buildSpawnCommand, parsers + golden fixtures, steering encoders) from burrow into src/runtime/adapters/","type":"task","priority":2,"labels":["runtime"],"blocks":[4,6]},{"title":"Rewire the k8s in-pod trio (agent-entrypoint, agent-io, agent-stdin-hold) onto warren adapters; layer rule pins src/runtime/k8s/ at zero burrow imports","type":"task","priority":2,"labels":["runtime","k8s"],"blocks":[6]},{"title":"Lift bwrap/sandbox-exec/cgroup profile generation from burrow into warren-owned src/sandbox/, binding a real writable HOME separate from the workspace","type":"task","priority":2,"labels":["runtime","sandbox"],"blocks":[6]},{"title":"LocalProvider spawns through the internalized sandbox: in-process host-side drive loop, worktree materialization, burrow daemon off the spawn path","type":"task","priority":2,"labels":["runtime","sandbox"],"blocks":[7,12]},{"title":"Re-home local preview sidecars and inbound port forwards onto the internalized sandbox","type":"task","priority":2,"labels":["runtime","preview"],"blocks":[8]},{"title":"Supervisor simplification: stop spawning burrow serve; remove socket wait, restart budget, and token validation; /readyz drops the burrow probes","type":"task","priority":2,"labels":["runtime","self-host"],"blocks":[9]},{"title":"Excision: delete src/burrow-client/, drop @os-eco/burrow-cli from package.json + Dockerfile, remove burrow version-sync assertions and both burrow layer rules, rewrite the burrow doc sections","type":"task","priority":2,"labels":["runtime","docs"],"blocks":[10,13]},{"title":"DockerProvider: run each agent as a sibling container over the docker socket (WARREN_RUNTIME=docker)","type":"feature","priority":2,"labels":["self-host","runtime"],"blocks":[11]},{"title":"acceptance:container scenario pins the one-line self-host: fresh host, one docker run, two secrets, no security flags, dispatch succeeds; quickstart README rewrite","type":"task","priority":2,"labels":["self-host","acceptance"],"blocks":[13]},{"existing_seed":"warren-0f18","labels":["acceptance","self-host"]},{"title":"Rename the burrow-shaped wire vocabulary and migrate runs.burrowId/burrowRunId to runtime-neutral columns (sqlite + postgres) — SOLO schema child, supersedes warren-c4f3","type":"task","priority":2,"labels":["schema","tech-debt"]}],"risks":["warren-f525 (retire sapling) touches src/runtime/adapters/ and src/registry/builtins/ — if the dogfood queue dispatches it concurrently with step 3, adapters/index.ts conflicts. Mitigation: step 3 must not touch sapling.ts and keeps index.ts churn additive; operator should sequence f525 relative to step 3.","The GH#846 items 4-5 contributor PR may land mid-campaign in src/runtime/adapters/ — keep step 3's changes additive (new spawn/parse/steer surfaces) so the merge is mechanical.","Preview sidecar internalization (step 7) is the least-specified lift: burrow's netns forwarder (nsenter into /proc//ns/net) has no warren-side precedent. If it balloons, split a design note out before implementation rather than growing the PR.","macOS seatbelt path cannot run in CI — bwrap coverage rides the nightly scenario (step 12); seatbelt regressions surface only on operator machines. Port burrow's seatbelt unit tests verbatim in step 5 to keep static coverage.","scripts/acceptance/lib/burrow-with-stub.ts and scenario 16 (pi-parity-smoke) import burrow directly and break at excision — step 9's file set must rework or retire them, not leave the nightly red.","Schema-migration journal collisions (warren-1f03): step 13 is the plan's only schema-touching child and is marked SOLO — never dispatch it parallel with any other schema change.","In-flight k8s debt items (warren-fe9b, warren-d15c, warren-32f8) share src/runtime/k8s/ with step 4 — gated by explicit dep edges added post-submit."],"acceptance":["Phase 2 exit: src/runtime/k8s/ imports zero burrow code, enforced by a check:layers rule that fails on any @os-eco/burrow-cli or src/burrow-client import under src/runtime/k8s/ (not by a one-day grep).","Phase 3 exit: LocalProvider spawns agents through warren-owned bwrap/sandbox-exec profile generation with a real writable HOME bound separate from the workspace; zero-commit local runs no longer fail dropped_commit (warren-c865 closes behind the fix child).","Excision complete: no burrow serve spawn, no unix socket, no token handshake, no src/burrow-client/ in the tree; @os-eco/burrow-cli absent from package.json, bun.lock, and the Dockerfile; burrow-pin assertions gone from check:version-sync; both burrow rules gone from layer-rules.json; AGENTS.md and docs/design/runtime-and-supervisor.md burrow sections rewritten; check:agents green.","Phase 4 exit: on a fresh host, one docker run with exactly two secrets (ANTHROPIC_API_KEY, GITHUB_TOKEN), no security_opt flags, no cap_add, and no burrow tokens dispatches a run end-to-end, pinned by an acceptance:container scenario.","The nightly local-topology acceptance scenario (warren-0f18) runs green on acceptance:nightly.","runs.burrowId/runs.burrowRunId renamed to runtime-neutral columns in both sqlite and postgres via drizzle migration, wire/SDK/UI renamed through the src/core/wire.ts re-export flow; warren-c4f3 closes behind the rename child.","bun run check:all green after every child; no child touches src/registry/builtins/, .warren/triggers.yaml, or docs/CONSTITUTION.md (Article IX check: none protected)."]},"children":["warren-ef6e","warren-8071","warren-f525","warren-7933","warren-0efe","warren-5af7","warren-413d","warren-4bf3","warren-9a26","warren-ea0a","warren-3732","warren-1a5a","warren-0f18","warren-572d"],"createdAt":"2026-08-16T17:21:28.916Z","updatedAt":"2026-08-17T15:02:55.312Z","name":"Burrow absorption + one-line docker self-host","adoptedChildren":["warren-0f18","warren-f525"],"outcome":"success"} {"id":"pl-a37b","seed":"warren-bc61","template":"feature","status":"active","revision":1,"sections":{"context":"Warren has finished its inward phase: every seam except IssueTracker is live, the burrow absorption and self-host push shipped in v0.17.0, and the judge/calibration loop is running on GKE. The next phase is outward — detached public mirrors of foreign OSS repos and the corpus flywheel (docs/design/corpus-flywheel.md). Three things stand in the way. (1) Corpus-flywheel step 2 is unstarted: every dispatch made today without a decision log is unrecoverable training data for the future dispatch policy. (2) The IssueTracker seam is the last uncut seam; foreign setups must not be seeds-shaped, and the 2026-08-04 decision requires trackers to arrive through a RemoteTracker bridge. (3) The 2026-08-18 mirror-fleet code audit found concrete external-repo blockers: the agent image is bun+node only, PR base = run.ref breaks base-commit pinning (SHA base → GitHub 422), builtin prompts assert .seeds/.mulch/bun as facts, no per-project onboarding context reaches the prompt, and the shared host clone races under concurrent dispatch. v0.18.0 clears all three fronts plus the open reliability backlog, so that post-release focus can turn entirely to external projects.","approach":"Five tracks, forward-chained. Track A (steps 1-5): the dispatch-context log as a core insert-only dispatch_context table keyed by run_id, written fire-and-log inside spawnRun (the verified single choke point covering all 8 dispatch sites), after provenance plumbing (dispatchOrigin, dispatcherHandle, scheduled-seed seedId loss) and queue-state/runtime-kind introspection land; exported via a created_at-windowed analytics endpoint. Facts only, no interpretation, per agent-analytics §6.1; verdicts are never read (warren-9236 tripwire not triggered). Track B (steps 6-14): the IssueTracker cut — promote neutral DTOs into src/core (the 'seed' wire stem is already guarded), define the contract (getIssue/listIssueStatuses/closeIssue + capability flags supportsPlans/supportsMetadata/supportsScheduledIssues/isGitNative), wrap the existing src/seeds-cli facade as SeedsTracker, swap ServerDeps.seedsCli for deps.issueTracker, port read paths (plan-runs domain, HTTP handlers, the pr-context.ts hardcoded-sd leak), port write paths behind capabilities, add ordered-issue-list plan-runs for supportsPlans:false trackers, then build the RemoteTracker bridge speaking warren-tracker/v1 to an external container (extension holds its own credential; warren stores none) with a published conformance suite and FakeTracker reference. Linear defers to 0.19. Track C (steps 15-19): external-repo readiness — base-commit pinning (baseCommit dispatch field split from ref; PR base stays branch-shaped; ref validated at the HTTP boundary), per-project host-clone serialization + detached-HEAD-safe materialization, multi-stack agent image with a per-project agentImage override, tracker-neutral builtin prompts gated on project capabilities, and a per-project repoContext onboarding block injected at composeDispatchPrompt. Track D (steps 20-37): the adopted reliability backlog, chained where items share wire vocabulary or files. Step 38 is the release-readiness docs sweep. All owner decisions are locked in the seeds themselves; no step carries an open decision.","alternatives":[{"name":"Dispatch-context log as an extension store tailing run events","rejected_because":"Override-source, queue-state, and retry-lineage facts are not reconstructable from the event stream; the log would be permanently lossy (owner decision 2026-08-18)."},{"name":"Dispatch facts as columns on runs","rejected_because":"runs already mixes 40+ dispatch/reap/preview/PR columns; a narrow adjacent fact table follows the fresh tool_calls precedent and joins cleanly."},{"name":"Contract cut only, bridge in 0.19","rejected_because":"Owner decision 2026-08-18: 0.18 ships the full bring-your-own-tracker story minus Linear."},{"name":"Contract + bridge + Linear in one release","rejected_because":"Linear's payer is not the mirror fleet; it ships 0.19 on its own release track per the 2026-08-04 decision."},{"name":"Warren-side sidecar table for issue metadata/scheduling in 0.18","rejected_because":"Capability flags suffice while seeds is the only metadata-capable tracker; the sidecar lands when a second tracker needs it."},{"name":"Full k8s security pass (NetworkPolicy + eviction salvage + UID separation)","rejected_because":"NetworkPolicy ships scoped to default-deny ingress + DNS/Service egress with kind validation; warren-6c94 eviction drills stay an operator exercise."}],"steps":[{"title":"dispatch provenance plumbing: dispatchOrigin on SpawnRunInput, read dispatcherHandle, fix scheduled/cron seedId loss","type":"task","priority":1,"blocks":[4],"labels":["v0.18.0","dispatch-log"]},{"title":"dispatch_context table: sqlite+postgres schema, migrations, insert-only repo, drift-test registration (incl. missing tool_calls entry)","type":"task","priority":1,"blocks":[4],"labels":["v0.18.0","dispatch-log"]},{"title":"queue-state counts + runtime-kind introspection: countNonTerminal(projectId?) in runs-stats, RuntimeProvider kind field","type":"task","priority":1,"blocks":[4],"labels":["v0.18.0","dispatch-log"]},{"title":"dispatch-context writer in spawnRun: chosen action + override sources, queue snapshot, normalized retry lineage; fire-and-log","type":"task","priority":1,"blocks":[5],"labels":["v0.18.0","dispatch-log"]},{"title":"GET /analytics/dispatch: created_at-windowed dispatch-context report + gen:docs/gen:openapi","type":"task","priority":2,"blocks":[38],"labels":["v0.18.0","dispatch-log"]},{"title":"IssueTracker contract: core DTO promotion (Issue, PlanSummary, errors), capability flags, SeedsTracker impl over src/seeds-cli","type":"task","priority":1,"blocks":[7],"labels":["v0.18.0","issue-tracker"]},{"title":"boot wiring swap: ServerDeps.seedsCli → deps.issueTracker across the 14 pass-through modules + handler preamble","type":"task","priority":1,"blocks":[8,9,10,18],"labels":["v0.18.0","issue-tracker"]},{"title":"plan-runs domain port: showSeed→getIssue, getPlan, neutral PlanStatus vocabulary, ProjectLacksTrackerError rename","type":"task","priority":1,"blocks":[11,12],"labels":["v0.18.0","issue-tracker"]},{"title":"HTTP read-surface port: /projects/:id/seeds handlers behind the tracker + the pr-context.ts hardcoded-sd leak","type":"task","priority":1,"blocks":[12],"labels":["v0.18.0","issue-tracker"]},{"title":"tracker write paths behind capabilities: closeIssue port, supportsMetadata (seed extensions), supportsScheduledIssues, isGitNative fence","type":"task","priority":1,"blocks":[12],"labels":["v0.18.0","issue-tracker"]},{"title":"plan-runs without supportsPlans: POST /plan-runs accepts an ordered issue-id list (2026-08-04 decision)","type":"task","priority":2,"blocks":[38],"labels":["v0.18.0","issue-tracker"]},{"title":"RemoteTracker bridge: warren-tracker/v1 wire protocol + in-core bridge to an external container (extension holds its own credential)","type":"task","priority":1,"blocks":[13],"labels":["v0.18.0","issue-tracker"]},{"title":"warren-tracker/v1 conformance suite + FakeTracker reference server","type":"task","priority":1,"blocks":[14],"labels":["v0.18.0","issue-tracker"]},{"title":"docs/design/issue-tracker.md design record + ROADMAP/AGENTS.md updates + doc tombstones","type":"task","priority":2,"blocks":[38],"labels":["v0.18.0","issue-tracker"]},{"title":"base-commit pinning: baseCommit dispatch field split from ref, branch-or-SHA validation at the HTTP boundary, PR base stays branch-shaped","type":"task","priority":1,"blocks":[16],"labels":["v0.18.0","external-readiness"]},{"title":"per-project host-clone serialization + detached-HEAD-safe materialization (materialize 'main' fallback, migration-preflight skip, clone-apply ref guard)","type":"task","priority":1,"blocks":[38],"labels":["v0.18.0","external-readiness"]},{"title":"multi-stack agent image (python3+uv) + per-project agentImage override in .warren/config.yaml threaded to docker+k8s","type":"task","priority":1,"blocks":[38],"labels":["v0.18.0","external-readiness"]},{"title":"tracker-neutral builtin prompts: gate sd/ml/.seeds/.mulch/quality-gate instructions on project capabilities","type":"task","priority":1,"blocks":[38],"labels":["v0.18.0","external-readiness"]},{"title":"per-project onboarding context: repoContext in DefaultsConfigSchema injected via composeDispatchPrompt + external-repo onboarding docs","type":"task","priority":1,"blocks":[38],"labels":["v0.18.0","external-readiness"]},{"title":"provider-retry classifier reads structured signals: httpStatus/upstreamBody before message, retryOf lineage on the retry run","type":"bug","priority":2,"blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-4e2a","blocks":[22],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-ba08","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-22cf","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-3f32","blocks":[25],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-81e0","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-bea7","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-7e28","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-8dbb","blocks":[],"labels":["v0.18.0","k8s"]},{"existing_seed":"warren-75dd","blocks":[31],"labels":["v0.18.0","acceptance"]},{"existing_seed":"warren-8a6e","blocks":[],"labels":["v0.18.0","acceptance"]},{"existing_seed":"warren-dc19","blocks":[],"labels":["v0.18.0","acceptance"]},{"existing_seed":"warren-a106","blocks":[],"labels":["v0.18.0","judge"]},{"existing_seed":"warren-4de5","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-c97b","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-cb93","blocks":[],"labels":["v0.18.0","k8s"]},{"existing_seed":"warren-70bb","blocks":[],"labels":["v0.18.0","reliability"]},{"existing_seed":"warren-53c0","blocks":[],"labels":["v0.18.0","reliability"]},{"title":"release readiness: CHANGELOG curation prep, ROADMAP seam-status update, full gate sweep, acceptance:container pass","type":"task","priority":1,"blocks":[],"labels":["v0.18.0"]}],"risks":["The ServerDeps.seedsCli swap touches 14 pass-through modules plus every handler preamble; a missed site fails typecheck loudly, but the pr-context.ts leak fails silently — it is the one sd spawn a seedsCli grep does not find (it must be ported in step 9 and asserted by a test).","check:wire-types guards the 'seed' stem: DTO promotion into src/core must land in the same PR as the contract or lint fails (known, planned in step 6).","The warren-tracker/v1 wire contract stays experimental until a foreign implementation survives it unchanged; the conformance suite is the falsification test, not a grep (PHILOSOPHY rule 4).","Base-commit pinning changes the reap PR-base resolution (runs/reap/run.ts:132); a regression here breaks every ref-dispatch (pr-fixer, conflict repair). The split must keep ref semantics byte-identical for branch refs.","Wire-vocabulary additions (spawn_failed, no_changes) ripple through golden envelopes, SDK types, UI labels, and check:wire-types; steps 21→22 are serialized to avoid conflicting edits.","NetworkPolicy can break run-pod clone/push egress; the step is scoped to default-deny ingress + DNS/Service egress with kind-overlay validation, and GKE validation happens at release time.","Dockerfile.agent growth (python3+uv) raises image size and build time; keep the toolchain additions minimal and measure the image delta."],"acceptance":["Every dispatch path (API, manual trigger, healer, cron, ci-fixer, plan-run, both retries) writes exactly one dispatch_context row, verified by tests exercising the real spawnRun; a failed context write never fails a dispatch.","GET /analytics/dispatch serves a created_at-windowed report including never-started runs, and docs/http-api.md + docs/openapi.yaml regenerate clean.","No module outside the SeedsTracker implementation spawns sd or reads .seeds/*.jsonl on the tracker path (the isGitNative-fenced reap/finalize machinery excepted); grep + a layer/contract test hold it.","A project served by the FakeTracker reference over warren-tracker/v1 can dispatch a run, run an ordered-issue-list plan-run, and get its issue closed on merge, with zero seeds code in the path.","The published conformance suite passes against FakeTracker and the SeedsTracker parity subset; the wire protocol is documented as experimental pending a foreign implementation.","A run dispatched with baseCommit= materializes the workspace at that commit on both local and k8s providers and opens its PR against a branch base without a 422.","Two concurrent dispatches against one project with different base refs do not corrupt the host clone (serialization test).","A Python mirror repo with no .seeds/.mulch/.warren dispatches end-to-end: the agent image can run pytest, the prompt contains no false .seeds/.mulch/sd/ml assertions, and repoContext from the host-clone .warren/config.yaml reaches the prompt.","All adopted reliability seeds are closed with their fixes merged; bun run check:all passes 12/12; acceptance:container passes.","ROADMAP marks the IssueTracker seam Live and the release-readiness step leaves CHANGELOG/ROADMAP ready for the /release flow."]},"children":["warren-9ce3","warren-36e7","warren-e1f1","warren-d6ca","warren-5423","warren-6c29","warren-5819","warren-2d98","warren-47b0","warren-6234","warren-de42","warren-d3a9","warren-53ea","warren-240e","warren-aaf7","warren-232d","warren-fabb","warren-cb46","warren-540f","warren-eaa6","warren-4e2a","warren-ba08","warren-22cf","warren-3f32","warren-81e0","warren-bea7","warren-7e28","warren-8dbb","warren-75dd","warren-8a6e","warren-dc19","warren-a106","warren-4de5","warren-c97b","warren-cb93","warren-70bb","warren-53c0","warren-57a0"],"createdAt":"2026-08-18T19:24:20.944Z","updatedAt":"2026-08-19T17:05:29.589Z","name":"v0.18.0 — the any-setup release","adoptedChildren":["warren-4e2a","warren-ba08","warren-22cf","warren-3f32","warren-81e0","warren-bea7","warren-7e28","warren-8dbb","warren-75dd","warren-8a6e","warren-dc19","warren-a106","warren-4de5","warren-c97b","warren-cb93","warren-70bb","warren-53c0"]} -{"id":"pl-f0e3","seed":"warren-9b6b","template":"feature","status":"active","revision":1,"sections":{"context":"Warren speaks only to GitHub. The Forge seam (pl-d1c9) already decoupled the domain from it: the contract carries no GitHub noun, capabilities are flags with stated fallbacks, and FakeForge proved the seam holds against a fake. What has never been tried is a real foreign vendor. The operator needs one instance to host GitHub, Forgejo and GitLab projects at once, selecting the host explicitly at project creation. Upstream (jayminwest/warren) recorded 'Gitea/GitLab demand - refused for now (capability-minimal Forge)' at planning-session-record:252, a refusal of speculative generality rather than a technical objection. A working provider plus a passing falsification test is the evidence that answers it. Full research and every citation: docs/design/multi-forge-support.md.","approach":"In-core provider (research doc section 5, Shape A), built to falsify rather than merely to work. Forge instances are declared in a new server-level config file holding only non-secret shape (id, kind, baseUrl, tokenEnv); every credential resolves from a named env var, honoring the standing precedent at src/forge/github-app/registration.ts:28 that warren never stores a forge credential. Selection stays explicit and warren validates it: URL-grammar routing cannot distinguish self-hosted Forgejo from Gitea from GitLab, since all three are just https://git.example.com/o/r, so discovery is replaced by an identity probe that confirms a stated answer. Probes measured live 2026-08-19: Forgejo answers GET /api/forgejo/v1/version with 200 while Gitea 404s on that exact path; GitLab emits x-gitlab-meta even on a 401; GitHub emits x-github-request-id. Sequencing front-loads the work defensible on its own merit (the credential-env invariant fix, the router) and defers the work that needs the refusal overturned (the Forgejo provider itself). Backward compatibility is the upstream-acceptability lever: with no config file, WARREN_FORGE plus GITHUB_TOKEN must behave exactly as today.","alternatives":[{"name":"RemoteForge bridge over a warren-forge/v1 wire protocol, mirroring the tracker decision (ROADMAP.md:136)","rejected_because":"Rejected for now: the tracker bridge itself is unbuilt and blocked (warren-53ea, P1, open), a wire protocol is a far heavier promise than a TypeScript interface (extensions.md section 5), PR-opening sits directly behind the kernel's push, and under multi-forge each instance becomes a container - three sidecars to serve a control plane whose pitch is one container, one volume, one HTTP API, one UI."},{"name":"Automatic forge detection from the clone URL","rejected_because":"Rejected as incorrect, not merely undesirable: self-hosted Forgejo, Gitea and GitLab share an indistinguishable URL shape, so grammar-based ownership can only guess, and a wrong guess sends the wrong credential to a real server."},{"name":"Forge instances in a DB table registered through the UI","rejected_because":"Rejected because it would store credentials in warren's database, contradicting registration.ts:28."},{"name":"Indexed env vars (WARREN_FORGE_1_KIND etc)","rejected_because":"Rejected: an ordered list with per-entry base URLs and capability overrides is a nested structure that indexed env keys express badly."}],"steps":[{"title":"Forge-instance config surface: forges: schema, loader, boot resolution, backward compat with WARREN_FORGE","type":"task","priority":2,"blocks":[3,4],"labels":["multi-forge","config"]},{"title":"Seam invariant fix: credential-env.ts must not hardcode github.com or x-access-token; widen check:layers to the bare host","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","seam"]},{"title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","type":"task","priority":2,"blocks":[4],"labels":["multi-forge","validation"]},{"title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","router"]},{"title":"Forgejo empirical spike: answer the six research questions against the operator's own instance and amend the design doc with observed evidence","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","spike"]},{"title":"src/forge/forgejo/: transport core, error classifier, retry policy, provider + registry arm","type":"task","priority":2,"blocks":[7],"labels":["multi-forge","forgejo"]},{"title":"Falsification test: a Forgejo project completes dispatch to reap to push to PR with zero domain changes, on an instance simultaneously serving GitHub","type":"task","priority":2,"blocks":[8],"labels":["multi-forge","falsification"]},{"title":"GitLab provider, after Forgejo has proven the path (MR vocabulary, project-id-vs-path, /-/merge_requests/ URL infix)","type":"task","priority":3,"blocks":[],"labels":["multi-forge","gitlab"]}],"risks":["parseRepoRef must move from a pure URL function to one closing over a configured base URL, and RepoRef.forge must carry an instance id rather than a kind. This touches the one shipped forge, so a regression here breaks GitHub, not just the new path.","A new server-level config file is warren's first: src/warren-config/ is entirely per-project. Upstream will ask why env was not enough, and the answer must be on the record.","Forgejo reports CI as commit statuses rather than check runs, so listChecks may degrade to capabilities.checkRuns=false. The fallback is already specified (CI-fixer poller stays idle, one notice per project) but it means no CI-fixer on Forgejo at first.","mx-9cf91f: a provider's parseRepoRef must round-trip its OWN PR web URLs or the merge gate breaks. This is a contract obligation the interface signature does not express and is the most likely thing a new provider gets wrong.","mx-7f711e / check:size: src/server/main/index.ts sits at 486/500 lines. Router boot wiring must land in a new module; the budget is never raised.","mx-c7c3ab: a gate-then-push chained with ';' instead of '&&' pushed a known-corrupt .seeds/issues.jsonl to main. Always chain check:seeds-integrity with &&.","mx-74fdd4: wrong doc citations propagate by imitation across agent runs. The section numbers in docs/design/multi-forge-support.md must stay stable once code comments cite them.","Upstream may still refuse the in-core shape despite the falsification evidence, in which case steps 1-4 remain valuable and step 6 becomes fork-only."],"acceptance":["One warren instance serves a GitHub project and a Forgejo project simultaneously, each routed to its own forge instance and credential.","Falsification test passes: a Forgejo-hosted project completes dispatch to reap to push to PR with ZERO domain-code changes. A required domain change is a contract failure and is reported as the finding.","With no config file present, WARREN_FORGE plus GITHUB_TOKEN behaves exactly as it does today. The change is additive for every existing deployment.","Registering a forge instance whose host is not the selected kind is rejected at registration with a message naming what was found instead (a Gitea host selected as Forgejo must fail).","No domain code outside src/forge/ names a forge host, a credential username, or a vendor API path. check:layers enforces the bare github.com literal, not only api.github.com.","bun run check:all passes 12/12, and acceptance scenario 39 (the public-instance leak guard) stays green."]},"children":["warren-f012","warren-1154","warren-56bb","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"],"createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T22:53:38.240Z","name":"Multi-forge support (Forgejo first)"} +{"id":"pl-f0e3","seed":"warren-9b6b","template":"feature","status":"approved","revision":1,"sections":{"context":"Warren speaks only to GitHub. The Forge seam (pl-d1c9) already decoupled the domain from it: the contract carries no GitHub noun, capabilities are flags with stated fallbacks, and FakeForge proved the seam holds against a fake. What has never been tried is a real foreign vendor. The operator needs one instance to host GitHub, Forgejo and GitLab projects at once, selecting the host explicitly at project creation. Upstream (jayminwest/warren) recorded 'Gitea/GitLab demand - refused for now (capability-minimal Forge)' at planning-session-record:252, a refusal of speculative generality rather than a technical objection. A working provider plus a passing falsification test is the evidence that answers it. Full research and every citation: docs/design/multi-forge-support.md.","approach":"In-core provider (research doc section 5, Shape A), built to falsify rather than merely to work. Forge instances are declared in a new server-level config file holding only non-secret shape (id, kind, baseUrl, tokenEnv); every credential resolves from a named env var, honoring the standing precedent at src/forge/github-app/registration.ts:28 that warren never stores a forge credential. Selection stays explicit and warren validates it: URL-grammar routing cannot distinguish self-hosted Forgejo from Gitea from GitLab, since all three are just https://git.example.com/o/r, so discovery is replaced by an identity probe that confirms a stated answer. Probes measured live 2026-08-19: Forgejo answers GET /api/forgejo/v1/version with 200 while Gitea 404s on that exact path; GitLab emits x-gitlab-meta even on a 401; GitHub emits x-github-request-id. Sequencing front-loads the work defensible on its own merit (the credential-env invariant fix, the router) and defers the work that needs the refusal overturned (the Forgejo provider itself). Backward compatibility is the upstream-acceptability lever: with no config file, WARREN_FORGE plus GITHUB_TOKEN must behave exactly as today.","alternatives":[{"name":"RemoteForge bridge over a warren-forge/v1 wire protocol, mirroring the tracker decision (ROADMAP.md:136)","rejected_because":"Rejected for now: the tracker bridge itself is unbuilt and blocked (warren-53ea, P1, open), a wire protocol is a far heavier promise than a TypeScript interface (extensions.md section 5), PR-opening sits directly behind the kernel's push, and under multi-forge each instance becomes a container - three sidecars to serve a control plane whose pitch is one container, one volume, one HTTP API, one UI."},{"name":"Automatic forge detection from the clone URL","rejected_because":"Rejected as incorrect, not merely undesirable: self-hosted Forgejo, Gitea and GitLab share an indistinguishable URL shape, so grammar-based ownership can only guess, and a wrong guess sends the wrong credential to a real server."},{"name":"Forge instances in a DB table registered through the UI","rejected_because":"Rejected because it would store credentials in warren's database, contradicting registration.ts:28."},{"name":"Indexed env vars (WARREN_FORGE_1_KIND etc)","rejected_because":"Rejected: an ordered list with per-entry base URLs and capability overrides is a nested structure that indexed env keys express badly."}],"steps":[{"title":"Forge-instance config surface: forges: schema, loader, boot resolution, backward compat with WARREN_FORGE","type":"task","priority":2,"blocks":[3,4],"labels":["multi-forge","config"]},{"title":"Seam invariant fix: credential-env.ts must not hardcode github.com or x-access-token; widen check:layers to the bare host","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","seam"]},{"title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","type":"task","priority":2,"blocks":[4],"labels":["multi-forge","validation"]},{"title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","router"]},{"title":"Forgejo empirical spike: answer the six research questions against the operator's own instance and amend the design doc with observed evidence","type":"task","priority":2,"blocks":[6],"labels":["multi-forge","spike"]},{"title":"src/forge/forgejo/: transport core, error classifier, retry policy, provider + registry arm","type":"task","priority":2,"blocks":[7],"labels":["multi-forge","forgejo"]},{"title":"Falsification test: a Forgejo project completes dispatch to reap to push to PR with zero domain changes, on an instance simultaneously serving GitHub","type":"task","priority":2,"blocks":[8],"labels":["multi-forge","falsification"]},{"title":"GitLab provider, after Forgejo has proven the path (MR vocabulary, project-id-vs-path, /-/merge_requests/ URL infix)","type":"task","priority":3,"blocks":[],"labels":["multi-forge","gitlab"]}],"risks":["parseRepoRef must move from a pure URL function to one closing over a configured base URL, and RepoRef.forge must carry an instance id rather than a kind. This touches the one shipped forge, so a regression here breaks GitHub, not just the new path.","A new server-level config file is warren's first: src/warren-config/ is entirely per-project. Upstream will ask why env was not enough, and the answer must be on the record.","Forgejo reports CI as commit statuses rather than check runs, so listChecks may degrade to capabilities.checkRuns=false. The fallback is already specified (CI-fixer poller stays idle, one notice per project) but it means no CI-fixer on Forgejo at first.","mx-9cf91f: a provider's parseRepoRef must round-trip its OWN PR web URLs or the merge gate breaks. This is a contract obligation the interface signature does not express and is the most likely thing a new provider gets wrong.","mx-7f711e / check:size: src/server/main/index.ts sits at 486/500 lines. Router boot wiring must land in a new module; the budget is never raised.","mx-c7c3ab: a gate-then-push chained with ';' instead of '&&' pushed a known-corrupt .seeds/issues.jsonl to main. Always chain check:seeds-integrity with &&.","mx-74fdd4: wrong doc citations propagate by imitation across agent runs. The section numbers in docs/design/multi-forge-support.md must stay stable once code comments cite them.","Upstream may still refuse the in-core shape despite the falsification evidence, in which case steps 1-4 remain valuable and step 6 becomes fork-only."],"acceptance":["One warren instance serves a GitHub project and a Forgejo project simultaneously, each routed to its own forge instance and credential.","Falsification test passes: a Forgejo-hosted project completes dispatch to reap to push to PR with ZERO domain-code changes. A required domain change is a contract failure and is reported as the finding.","With no config file present, WARREN_FORGE plus GITHUB_TOKEN behaves exactly as it does today. The change is additive for every existing deployment.","Registering a forge instance whose host is not the selected kind is rejected at registration with a message naming what was found instead (a Gitea host selected as Forgejo must fail).","No domain code outside src/forge/ names a forge host, a credential username, or a vendor API path. check:layers enforces the bare github.com literal, not only api.github.com.","bun run check:all passes 12/12, and acceptance scenario 39 (the public-instance leak guard) stays green."]},"children":["warren-f012","warren-1154","warren-56bb","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"],"createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:27:26.644Z","name":"Multi-forge support (Forgejo first)"}