From 215363594d023037c86085ce81728f3df5d08397 Mon Sep 17 00:00:00 2001 From: tommy-ca <140900186+tommy-ca@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:09:31 +0200 Subject: [PATCH] refactor(orch): decompose store.ts into modular subcomponents (< 500 lines) (#648) --- .audit/evidence/antigravity-receipt.json | 2 +- .audit/evidence/codex-receipt.json | 2 +- .audit/evidence/grok-receipt.json | 2 +- .audit/evidence/index.json | 10 +- .audit/evidence/omp-receipt.json | 2 +- .audit/evidence/opencode-receipt.json | 2 +- skills/poteto-mode/scripts/orch/frontier.ts | 299 ++++ skills/poteto-mode/scripts/orch/lock.ts | 84 ++ skills/poteto-mode/scripts/orch/orch.test.ts | 17 + skills/poteto-mode/scripts/orch/report.ts | 230 +++ skills/poteto-mode/scripts/orch/store.ts | 1320 +----------------- skills/poteto-mode/scripts/orch/tables.ts | 383 +++++ skills/poteto-mode/scripts/orch/types.ts | 312 +++++ 13 files changed, 1389 insertions(+), 1276 deletions(-) create mode 100644 skills/poteto-mode/scripts/orch/frontier.ts create mode 100644 skills/poteto-mode/scripts/orch/lock.ts create mode 100644 skills/poteto-mode/scripts/orch/report.ts create mode 100644 skills/poteto-mode/scripts/orch/tables.ts create mode 100644 skills/poteto-mode/scripts/orch/types.ts diff --git a/.audit/evidence/antigravity-receipt.json b/.audit/evidence/antigravity-receipt.json index 7eb9ba4d..43097dd1 100644 --- a/.audit/evidence/antigravity-receipt.json +++ b/.audit/evidence/antigravity-receipt.json @@ -14,7 +14,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "fc9258dd04cfc5bde2a02e9e8f9ae57f1c1f9aa224a36fb5e754a2a37f73b4a2", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "6f52fd4f83e334a9486d679ea10ae8697c7f4fadc9f60ca089458b1734d60157", diff --git a/.audit/evidence/codex-receipt.json b/.audit/evidence/codex-receipt.json index 7adf20c5..4250ec0e 100644 --- a/.audit/evidence/codex-receipt.json +++ b/.audit/evidence/codex-receipt.json @@ -14,7 +14,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "6508f23e3fd2e3558706b36bcf9e16a5430cc6d6dea1a793ed1ad62d67710d0c", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "c49bb63726171f13f4390a60291cd60baf8e95b947b28a225dec9c1ce699e42c", diff --git a/.audit/evidence/grok-receipt.json b/.audit/evidence/grok-receipt.json index e81ddf7f..bbdbaad0 100644 --- a/.audit/evidence/grok-receipt.json +++ b/.audit/evidence/grok-receipt.json @@ -14,7 +14,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "8843a79c3bc719865a29ae9028b91743d7e3c2db18d11ce038f2c51fe62bdf5a", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "7a376b005b83bf64c2d37729be88328f393bfad25b4e666c5eb2f78633def037", diff --git a/.audit/evidence/index.json b/.audit/evidence/index.json index 8bbcecfa..aa869006 100644 --- a/.audit/evidence/index.json +++ b/.audit/evidence/index.json @@ -16,7 +16,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "8843a79c3bc719865a29ae9028b91743d7e3c2db18d11ce038f2c51fe62bdf5a", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "7a376b005b83bf64c2d37729be88328f393bfad25b4e666c5eb2f78633def037", @@ -40,7 +40,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "6508f23e3fd2e3558706b36bcf9e16a5430cc6d6dea1a793ed1ad62d67710d0c", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "c49bb63726171f13f4390a60291cd60baf8e95b947b28a225dec9c1ce699e42c", @@ -64,7 +64,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "1c56d0d43197fceb1f48d4c7195ea2df0d25463a3364ab3eb7fd998d94a8cd86", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "ce6115598948c4b9c508a4fb143528f38d5452792a6b08c49ae4c8c42ba10243", @@ -88,7 +88,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "76a2fc4a240a3b64da37275a6216c1e930e78bea8c215cff35e0d0f04690d073", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "a1c76eac8dc853128e63609d0f5bea60ae4e128de7a8577ca58730a297d8af0e", @@ -112,7 +112,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "fc9258dd04cfc5bde2a02e9e8f9ae57f1c1f9aa224a36fb5e754a2a37f73b4a2", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "6f52fd4f83e334a9486d679ea10ae8697c7f4fadc9f60ca089458b1734d60157", diff --git a/.audit/evidence/omp-receipt.json b/.audit/evidence/omp-receipt.json index ff26aae5..c4e810c4 100644 --- a/.audit/evidence/omp-receipt.json +++ b/.audit/evidence/omp-receipt.json @@ -14,7 +14,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "1c56d0d43197fceb1f48d4c7195ea2df0d25463a3364ab3eb7fd998d94a8cd86", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "ce6115598948c4b9c508a4fb143528f38d5452792a6b08c49ae4c8c42ba10243", diff --git a/.audit/evidence/opencode-receipt.json b/.audit/evidence/opencode-receipt.json index 2502241c..b5cd3461 100644 --- a/.audit/evidence/opencode-receipt.json +++ b/.audit/evidence/opencode-receipt.json @@ -14,7 +14,7 @@ "canonical_pin": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "upstream_head": "88d47514b82e7c44f00f2b2a72ff6dd12158038c", "spec_hash": "f403a35e9ce91413047544cd3cbfc1bba3baf9d56bd90a2e3863432d3fc5bf63", - "skills_tree_hash": "b56c01974623e7b66ae449aa83580787d4eecafc85a22954320e5a59744f1d0a", + "skills_tree_hash": "a6e1c952923504d7a836b183f267b2812c50c73b3f83e5e90802317c45d9d1ff", "profile_hash": "76a2fc4a240a3b64da37275a6216c1e930e78bea8c215cff35e0d0f04690d073", "package_descriptor_hash": "bfcfbca94227eb86857af749c975b55240f0c656d01b51e8349ad2780233ce99", "manifest_hash": "a1c76eac8dc853128e63609d0f5bea60ae4e128de7a8577ca58730a297d8af0e", diff --git a/skills/poteto-mode/scripts/orch/frontier.ts b/skills/poteto-mode/scripts/orch/frontier.ts new file mode 100644 index 00000000..5c11737b --- /dev/null +++ b/skills/poteto-mode/scripts/orch/frontier.ts @@ -0,0 +1,299 @@ +import { execFileSync } from "node:child_process"; +import { + type Forge, + type FrontierPr, + type FrontierPrState, + UserError, + errorMessage, + isRecord, + isUnknownArray, +} from "./types.ts"; + +interface ForgePullRequestQuery { + readonly stateField: string; + readonly headField: string; + readonly baseField: string; + readonly states: ReadonlyMap; +} + +export const FORGE_PULL_REQUEST_QUERIES: Readonly< + Record +> = { + gh: { + stateField: "state", + headField: "headRefName", + baseField: "baseRefName", + states: new Map([ + ["OPEN", "OPEN"], + ["MERGED", "MERGED"], + ["CLOSED", "CLOSED"], + ]), + }, + origin: { + stateField: "status", + headField: "headRef", + baseField: "baseRef", + states: new Map([ + ["open", "OPEN"], + ["draft", "OPEN"], + ["merged", "MERGED"], + ["closed", "CLOSED"], + ]), + }, +}; + +interface ForgePullRequest { + readonly pr: number; + readonly state: FrontierPrState; + readonly branch: string; + readonly base: string; +} + +export function runTool({ + command, + args, + repo, +}: { + command: string; + args: readonly string[]; + repo: string; +}): string { + try { + return execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + throw new UserError( + `${command} ${args.join(" ")} failed: ${errorMessage(error)}` + ); + } +} + +export function listPullRequests({ + filter, + branch, + forge, + repo, +}: { + filter: "--head" | "--base"; + branch: string; + forge: Forge; + repo: string; +}): readonly ForgePullRequest[] { + const query = FORGE_PULL_REQUEST_QUERIES[forge]; + const command = `${forge} pr list ${filter} ${branch}`; + // Every state is fetched because a merged or closed PR still anchors the + // chain, and a draft counts as OPEN. + const raw = runTool({ + command: forge, + args: [ + "pr", + "list", + filter, + branch, + "--state", + "all", + "--json", + ["number", query.stateField, query.headField, query.baseField].join( + "," + ), + ], + repo, + }); + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new UserError(`${command} output is not valid JSON`); + } + if (!isUnknownArray(value)) { + throw new UserError(`${command} output is not a JSON array`); + } + return value.map((row) => { + const status = isRecord(row) ? row[query.stateField] : undefined; + const state = + typeof status === "string" ? (query.states.get(status) ?? null) : null; + const head = isRecord(row) ? row[query.headField] : undefined; + const base = isRecord(row) ? row[query.baseField] : undefined; + if ( + !isRecord(row) || + typeof row.number !== "number" || + !Number.isSafeInteger(row.number) || + row.number < 1 || + state === null || + typeof head !== "string" || + head.length === 0 || + typeof base !== "string" || + base.length === 0 + ) { + throw new UserError(`${command} output has an invalid PR row`); + } + return { pr: row.number, state, branch: head, base }; + }); +} + +export function branchPullRequest({ + branch, + forge, + repo, +}: { + branch: string; + forge: Forge; + repo: string; +}): ForgePullRequest { + const rows = listPullRequests({ filter: "--head", branch, forge, repo }); + const row = rows[0]; + if (row === undefined) { + throw new UserError( + `branch ${branch} has no pull request; open one with ${forge} pr create, or resolve the frontier from a branch whose chain is fully submitted` + ); + } + if (rows.length > 1) { + throw new UserError( + `branch ${branch} has multiple pull requests (${rows.map((item) => `#${item.pr}`).join(", ")}); close the stale ones first` + ); + } + return row; +} + +export function openChildren({ + branch, + forge, + repo, +}: { + branch: string; + forge: Forge; + repo: string; +}): readonly ForgePullRequest[] { + return listPullRequests({ filter: "--base", branch, forge, repo }).filter( + (row) => row.state === "OPEN" + ); +} + +export function runGit({ + args, + repo, +}: { + args: readonly string[]; + repo: string; +}): string { + return runTool({ command: "git", args, repo }).trim(); +} + +export function branchSha({ + branch, + repo, +}: { + branch: string; + repo: string; +}): string { + const sha = runGit({ args: ["rev-parse", branch], repo }); + if (!/^[0-9a-f]{40,64}$/i.test(sha)) { + throw new UserError(`git rev-parse ${branch} returned an invalid SHA`); + } + return sha; +} + +export function currentBranch(repo: string): string { + return runGit({ args: ["symbolic-ref", "--short", "HEAD"], repo }); +} + +export function trunkBranch(repo: string): string { + try { + return runGit({ + args: ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + repo, + }).replace(/^origin\//, ""); + } catch { + // Init-plus-fetch checkouts and local fixtures lack origin/HEAD. + return "main"; + } +} + +export function resolveFrontier(repo: string, forge: Forge): readonly FrontierPr[] { + const trunk = trunkBranch(repo); + const current = currentBranch(repo); + if (current === trunk) { + return []; + } + const seen = new Set([current]); + let bottom = branchPullRequest({ branch: current, forge, repo }); + const chain = [bottom]; + while (bottom.base !== trunk) { + if (seen.has(bottom.base)) { + throw new UserError( + `pull request base chain loops at branch ${bottom.base}` + ); + } + seen.add(bottom.base); + bottom = branchPullRequest({ branch: bottom.base, forge, repo }); + chain.unshift(bottom); + } + let tip = current; + for (;;) { + const children = openChildren({ branch: tip, forge, repo }); + const child = children[0]; + if (child === undefined) { + break; + } + if (children.length > 1) { + throw new UserError( + `stack is not linear: branches ${children.map((row) => row.branch).join(", ")} all target ${tip}` + ); + } + if (seen.has(child.branch)) { + throw new UserError( + `pull request base chain loops at branch ${child.branch}` + ); + } + seen.add(child.branch); + chain.push(child); + tip = child.branch; + } + if (new Set(chain.map((row) => row.pr)).size !== chain.length) { + throw new UserError( + "pull request base chain contains duplicate pull requests" + ); + } + return chain.map((row) => ({ + pr: row.pr, + branches: row.branch, + sha: branchSha({ branch: row.branch, repo }), + state: row.state, + })); +} + +export function validateFrontierPin({ + actual, + expected, +}: { + actual: readonly number[]; + expected: readonly number[]; +}): void { + if ( + actual.length === expected.length && + actual.every((pr, index) => pr === expected[index]) + ) { + return; + } + const actualSet = new Set(actual); + const expectedSet = new Set(expected); + const missing = expected.filter((pr) => !actualSet.has(pr)); + const extra = actual.filter((pr) => !expectedSet.has(pr)); + const drift: string[] = []; + if (missing.length > 0) { + drift.push(`missing from resolved chain: ${missing.join(",")}`); + } + if (extra.length > 0) { + drift.push(`extra in resolved chain: ${extra.join(",")}`); + } + if (missing.length === 0 && extra.length === 0) { + drift.push( + `order differs: expected ${expected.join(",")}; resolved ${actual.join(",")}` + ); + } + throw new UserError(`frontier pin mismatch: ${drift.join("; ")}`); +} diff --git a/skills/poteto-mode/scripts/orch/lock.ts b/skills/poteto-mode/scripts/orch/lock.ts new file mode 100644 index 00000000..810746dd --- /dev/null +++ b/skills/poteto-mode/scripts/orch/lock.ts @@ -0,0 +1,84 @@ +import { open, readFile, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { + type OpenStoreOptions, + UserError, + errorCode, +} from "./types.ts"; + +export const LOCK_FILE = ".orch.lock"; + +export function holderIsDead(holder: string): boolean { + const pid = Number.parseInt(holder, 10); + if (!Number.isSafeInteger(pid) || pid <= 0 || String(pid) !== holder) { + return false; + } + try { + process.kill(pid, 0); + return false; + } catch (error) { + return errorCode(error) === "ESRCH"; + } +} + +export async function acquireLock( + store: string, + options: OpenStoreOptions +): Promise<() => Promise> { + const path = join(store, LOCK_FILE); + const pid = String(process.pid); + const create = async (): Promise => { + const handle = await open(path, "wx"); + await handle.writeFile(`${pid}\n`); + await handle.close(); + }; + + const takeOver = async (): Promise => { + await unlink(path); + try { + await create(); + } catch (retryError) { + if (errorCode(retryError) === "EEXIST") { + const retryHolder = + (await readFile(path, "utf8")).trim() || "unknown"; + throw new UserError(`store lock held by pid ${retryHolder}`); + } + throw retryError; + } + }; + + try { + await create(); + } catch (error) { + if (errorCode(error) !== "EEXIST") { + throw error; + } + let holder = "unknown"; + try { + holder = (await readFile(path, "utf8")).trim() || "unknown"; + } catch { + holder = "unknown"; + } + if (holderIsDead(holder)) { + options.onStaleLock?.(holder); + await takeOver(); + } else if (options.force) { + options.onLockStolen?.(holder); + await takeOver(); + } else { + throw new UserError(`store lock held by pid ${holder}`); + } + } + + return async (): Promise => { + try { + if ((await readFile(path, "utf8")).trim() === pid) { + await unlink(path); + } + } catch (error) { + if (errorCode(error) !== "ENOENT") { + throw error; + } + } + }; +} diff --git a/skills/poteto-mode/scripts/orch/orch.test.ts b/skills/poteto-mode/scripts/orch/orch.test.ts index 2ef435d7..f3cde487 100644 --- a/skills/poteto-mode/scripts/orch/orch.test.ts +++ b/skills/poteto-mode/scripts/orch/orch.test.ts @@ -648,6 +648,23 @@ describe("Store", () => { await expect(store.units.list()).rejects.toThrow("store is closed"); await expect(store.status.render()).rejects.toBeInstanceOf(UserError); }); + + it("decomposes orch modules cleanly below 500 lines each", async () => { + const modules = [ + "types.ts", + "lock.ts", + "frontier.ts", + "report.ts", + "tables.ts", + "store.ts", + ]; + for (const mod of modules) { + const file = join(import.meta.dir, mod); + const content = await readFile(file, "utf8"); + const lines = content.split("\n").length; + expect(lines).toBeLessThan(500); + } + }); }); describe("orch CLI", () => { diff --git a/skills/poteto-mode/scripts/orch/report.ts b/skills/poteto-mode/scripts/orch/report.ts new file mode 100644 index 00000000..7abc3db1 --- /dev/null +++ b/skills/poteto-mode/scripts/orch/report.ts @@ -0,0 +1,230 @@ +import { + type Counts, + type Frontier, + type Gate, + type LedgerEntry, + type OpenGate, + type StatusSummary, + type Unit, + isRecord, + isUnknownArray, +} from "./types.ts"; +import { ledgerCells, unitCells } from "./tables.ts"; + +export function countValues(values: readonly string[]): Counts { + const result: Record = {}; + for (const value of values) { + result[value] = (result[value] ?? 0) + 1; + } + return Object.fromEntries( + Object.entries(result).sort(([left], [right]) => + left.localeCompare(right) + ) + ); +} + +export function summarize( + unitRows: readonly Unit[], + ledgerRows: readonly LedgerEntry[], + currentFrontier: Frontier, + gateRows: readonly Gate[] +): StatusSummary { + return { + unitStates: countValues(unitRows.map((unit) => unit.state)), + ledgerVerdicts: countValues(ledgerRows.map((row) => row.verdict)), + frontierGeneration: currentFrontier.generation, + openGateIds: gateRows + .filter((gate): gate is OpenGate => gate.kind === "open") + .map((gate) => gate.id) + .sort(), + }; +} + +export function countRecord(value: unknown): Record | null { + if (!isRecord(value)) { + return null; + } + const result: Record = {}; + for (const [name, count] of Object.entries(value)) { + if ( + typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 0 + ) { + return null; + } + result[name] = count; + } + return result; +} + +export function previousSummary(raw: string): StatusSummary | null { + const match = //.exec(raw); + if (match === null) { + return null; + } + let value: unknown; + try { + value = JSON.parse(match[1] ?? ""); + } catch { + return null; + } + if ( + !isRecord(value) || + typeof value.frontierGeneration !== "number" || + !isUnknownArray(value.openGateIds) + ) { + return null; + } + const unitStates = countRecord(value.unitStates); + const ledgerVerdicts = countRecord(value.ledgerVerdicts); + const openGateIds = value.openGateIds.filter( + (item): item is string => typeof item === "string" + ); + if ( + unitStates === null || + ledgerVerdicts === null || + openGateIds.length !== value.openGateIds.length + ) { + return null; + } + return { + unitStates, + ledgerVerdicts, + frontierGeneration: value.frontierGeneration, + openGateIds, + }; +} + +export function changed(before: StatusSummary | null, after: StatusSummary): string { + if (before === null) { + return "first render"; + } + const result: string[] = []; + const groups: readonly { + readonly label: string; + readonly oldCounts: Counts; + readonly newCounts: Counts; + }[] = [ + { + label: "units", + oldCounts: before.unitStates, + newCounts: after.unitStates, + }, + { + label: "ledger", + oldCounts: before.ledgerVerdicts, + newCounts: after.ledgerVerdicts, + }, + ]; + for (const { label, oldCounts, newCounts } of groups) { + const names = [ + ...new Set([...Object.keys(oldCounts), ...Object.keys(newCounts)]), + ].sort(); + for (const name of names) { + const oldCount = oldCounts[name] ?? 0; + const newCount = newCounts[name] ?? 0; + if (oldCount !== newCount) { + result.push(`${label} ${name} ${oldCount}->${newCount}`); + } + } + } + if (before.frontierGeneration !== after.frontierGeneration) { + result.push( + `frontier generation ${before.frontierGeneration}->${after.frontierGeneration}` + ); + } + if (before.openGateIds.join("\0") !== after.openGateIds.join("\0")) { + result.push( + `open gates ${before.openGateIds.length}->${after.openGateIds.length}` + ); + } + return result.length === 0 ? "no derived changes" : result.join("; "); +} + +export function markdown(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); +} + +export function table( + headers: readonly string[], + rows: readonly (readonly string[])[] +): string { + if (rows.length === 0) { + return "(none)"; + } + return [ + `| ${headers.join(" | ")} |`, + `| ${headers.map(() => "---").join(" | ")} |`, + ...rows.map((row) => `| ${row.map(markdown).join(" | ")} |`), + ].join("\n"); +} + +export function countLine(value: Counts): string { + const entries = Object.entries(value); + return entries.length === 0 + ? "none" + : entries.map(([name, count]) => `${name}=${count}`).join(", "); +} + +export function statusMarkdown( + unitRows: readonly Unit[], + ledgerRows: readonly LedgerEntry[], + currentFrontier: Frontier, + gateRows: readonly Gate[], + currentSummary: StatusSummary +): string { + return `# Orchestrate status + +Generated: ${new Date().toISOString()} + +## Units + +States: ${countLine(currentSummary.unitStates)} + +${table( + ["ID", "Track", "State", "Branch", "PR", "SHA", "Brief"], + unitRows.map(unitCells) +)} + +## Verification ledger + +Verdicts: ${countLine(currentSummary.ledgerVerdicts)} + +${table( + ["PR", "SHA", "Verdict", "Evidence", "Verifier", "Timestamp"], + ledgerRows.map(ledgerCells) +)} + +## Frontier + +Generation: ${currentFrontier.generation} +Lowest unmerged: ${currentFrontier.lowestUnmerged ?? "none"} + +${table( + ["Branch", "PR", "SHA", "State"], + currentFrontier.prs.map((row) => [ + row.branches, + String(row.pr), + row.sha, + row.state, + ]) +)} + +## Gates + +${table( + ["ID", "Status", "Question", "Options", "Default", "Answer"], + gateRows.map((gate) => [ + gate.id, + gate.kind, + gate.question, + gate.options, + gate.defaultAnswer, + gate.kind === "resolved" ? gate.answer : "", + ]) +)} + + +`; +} diff --git a/skills/poteto-mode/scripts/orch/store.ts b/skills/poteto-mode/scripts/orch/store.ts index 41736891..c3b40655 100644 --- a/skills/poteto-mode/scripts/orch/store.ts +++ b/skills/poteto-mode/scripts/orch/store.ts @@ -1,1270 +1,56 @@ -import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import type { Dirent } from "node:fs"; +import { mkdir, readFile, rename, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { acquireLock } from "./lock.ts"; +import { resolveFrontier, validateFrontierPin } from "./frontier.ts"; import { - access, - mkdir, - open, - readFile, - readdir, - rename, - rm, - unlink, - writeFile, -} from "node:fs/promises"; -import { basename, dirname, join, resolve } from "node:path"; - -const UNIT_HEADER = "id\ttrack\tstate\tbranch\tpr\tsha\tbrief"; -const LEDGER_HEADER = "pr\tsha\tverdict\tevidence\tverifier\tts"; -const LOCK_FILE = ".orch.lock"; - -export type Verdict = - | "live-ui-verified" - | "unit-test-verified" - | "type-check-only" - | "verifier-blocked" - | "verifier-failed"; - -export interface Unit { - readonly id: string; - readonly track: string; - readonly state: string; - readonly branch: string; - readonly pr: string; - readonly sha: string; - readonly brief: string; -} - -export interface LedgerEntry { - readonly pr: string; - readonly sha: string; - readonly verdict: Verdict; - readonly evidence: string; - readonly verifier: string; - readonly ts: string; -} - -export interface InboxPointer { - readonly ts: string; - readonly agent: string; - readonly unit: string; - readonly status: string; - readonly report: string; -} - -export interface InboxPushResult { - readonly pointer: InboxPointer; - readonly filename: string; -} - -export interface OpenGate { - readonly kind: "open"; - readonly id: string; - readonly question: string; - readonly options: string; - readonly defaultAnswer: string; -} - -export interface ResolvedGate { - readonly kind: "resolved"; - readonly id: string; - readonly question: string; - readonly options: string; - readonly defaultAnswer: string; - readonly answer: string; -} - -export type Gate = OpenGate | ResolvedGate; - -export type FrontierPrState = "OPEN" | "MERGED" | "CLOSED"; - -export interface FrontierPr { - readonly pr: number; - readonly branches: string; - readonly sha: string; - readonly state: FrontierPrState; -} - -export interface Frontier { - readonly generation: number; - readonly prs: readonly FrontierPr[]; - readonly lowestUnmerged: number | null; -} - -export interface StandingLine { - readonly number: number; - readonly line: string; -} - -export type Counts = Readonly>; - -export interface StatusSummary { - readonly unitStates: Counts; - readonly ledgerVerdicts: Counts; - readonly frontierGeneration: number; - readonly openGateIds: readonly string[]; -} - -export interface StatusReport { - readonly units: readonly Unit[]; - readonly ledger: readonly LedgerEntry[]; - readonly frontier: Frontier; - readonly gates: readonly Gate[]; - readonly summary: StatusSummary; - readonly changed: string; -} - -export interface AddUnitParams { - readonly id: string; - readonly track: string; - readonly brief?: string; -} - -export interface SetUnitParams { - readonly id: string; - readonly state: string; - readonly branch?: string; - readonly pr?: number; - readonly sha?: string; -} - -export interface ListUnitsParams { - readonly state?: string; - readonly track?: string; -} - -export interface RecordLedgerParams { - readonly pr: number; - readonly sha: string; - readonly verdict: Verdict; - readonly evidence: string; - readonly verifier?: string; -} - -export interface CheckLedgerParams { - readonly pr: number; - readonly sha: string; -} - -export interface PushInboxParams { - readonly agent: string; - readonly unit: string; - readonly status: string; - readonly report?: string; -} - -export interface ParkGateParams { - readonly id: string; - readonly question: string; - readonly options: string; - readonly defaultAnswer: string; -} - -export interface ResolveGateParams { - readonly id: string; - readonly answer: string; -} - -export interface SetFrontierParams { - readonly repo: string; - readonly forge?: Forge; - readonly prs?: readonly number[]; -} - -export interface AddStandingParams { - readonly line: string; -} - -export interface OpenStoreOptions { - readonly force?: boolean; - readonly onLockStolen?: (holder: string) => void; - readonly onStaleLock?: (holder: string) => void; -} - -export interface Store { - readonly units: { - readonly add: (params: AddUnitParams) => Promise; - readonly set: (params: SetUnitParams) => Promise; - readonly get: (id: string) => Promise; - readonly list: (params?: ListUnitsParams) => Promise; - readonly counts: () => Promise; - }; - readonly ledger: { - readonly record: (params: RecordLedgerParams) => Promise; - readonly check: (params: CheckLedgerParams) => Promise; - readonly summary: () => Promise; - }; - readonly inbox: { - readonly push: (params: PushInboxParams) => Promise; - readonly drain: () => Promise; - readonly peek: () => Promise; - readonly count: () => Promise; - }; - readonly gates: { - readonly park: (params: ParkGateParams) => Promise; - readonly list: () => Promise; - readonly resolve: (params: ResolveGateParams) => Promise; - }; - readonly frontier: { - readonly set: (params: SetFrontierParams) => Promise; - readonly show: () => Promise; - }; - readonly standing: { - readonly show: () => Promise; - readonly add: (params: AddStandingParams) => Promise; - }; - readonly status: { - readonly render: () => Promise; - }; - readonly init: () => Promise<{ readonly store: string }>; - readonly close: () => Promise; -} - -export interface NotFoundOutput { - readonly compact: string; - readonly json: unknown; -} - -export class UserError extends Error {} -export class UsageError extends UserError {} -export class NotFoundError extends UserError { - public constructor( - message: string, - public readonly output?: NotFoundOutput - ) { - super(message); - } -} - -function errorCode(error: unknown): string | null { - if ( - error !== null && - typeof error === "object" && - "code" in error && - typeof error.code === "string" - ) { - return error.code; - } - return null; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function isUnknownArray(value: unknown): value is readonly unknown[] { - return Array.isArray(value); -} - -function verdictOrNull(value: string): Verdict | null { - switch (value) { - case "live-ui-verified": - case "unit-test-verified": - case "type-check-only": - case "verifier-blocked": - case "verifier-failed": - return value; - default: - return null; - } -} - -function frontierPrStateOrNull(value: unknown): FrontierPrState | null { - switch (value) { - case "OPEN": - case "MERGED": - case "CLOSED": - return value; - default: - return null; - } -} - -export function parseVerdict(value: string): Verdict { - const verdict = verdictOrNull(value); - if (verdict === null) { - throw new UserError( - "verdict must be live-ui-verified, unit-test-verified, type-check-only, verifier-blocked, or verifier-failed" - ); - } - return verdict; -} - -function cleanCell(value: string): string { - const cleaned = value.replace(/[\t\n\r]/g, " "); - return /^[=+\-@]/.test(cleaned) ? `'${cleaned}` : cleaned; -} - -function requiredCell(value: string, label: string): string { - const cleaned = cleanCell(value); - if (cleaned.trim().length === 0) { - throw new UserError(`${label} must not be empty`); - } - return cleaned; -} - -function requiredLine(value: string, label: string): string { - const cleaned = value.replace(/[\n\r]/g, " ").trim(); - if (cleaned.length === 0) { - throw new UserError(`${label} must not be empty`); - } - return cleaned; -} - -function positiveInteger(value: number, label: string): number { - if (!Number.isSafeInteger(value) || value < 1) { - throw new UserError(`${label} must be a positive integer`); - } - return value; -} - -async function exists(path: string): Promise { - try { - await access(path); - return true; - } catch (error) { - if (errorCode(error) === "ENOENT") { - return false; - } - throw error; - } -} - -async function atomicWrite(path: string, contents: string): Promise { - const temporary = join( - dirname(path), - `.${basename(path)}.${process.pid}.${randomUUID()}.tmp` - ); - try { - await writeFile(temporary, contents, { flag: "wx" }); - await rename(temporary, path); - } finally { - await rm(temporary, { force: true }); - } -} - -async function writeIfMissing(path: string, contents: string): Promise { - if (!(await exists(path))) { - await atomicWrite(path, contents); - } -} - -async function requiredFile(path: string): Promise { - try { - return await readFile(path, "utf8"); - } catch (error) { - if (errorCode(error) === "ENOENT") { - throw new UserError( - `store is not initialized at ${dirname(path)}; run orch init` - ); - } - throw error; - } -} - -function holderIsDead(holder: string): boolean { - const pid = Number.parseInt(holder, 10); - if (!Number.isSafeInteger(pid) || pid <= 0 || String(pid) !== holder) { - return false; - } - try { - process.kill(pid, 0); - return false; - } catch (error) { - return errorCode(error) === "ESRCH"; - } -} - -async function acquireLock( - store: string, - options: OpenStoreOptions -): Promise<() => Promise> { - const path = join(store, LOCK_FILE); - const pid = String(process.pid); - const create = async (): Promise => { - const handle = await open(path, "wx"); - await handle.writeFile(`${pid}\n`); - await handle.close(); - }; - - const takeOver = async (): Promise => { - await unlink(path); - try { - await create(); - } catch (retryError) { - if (errorCode(retryError) === "EEXIST") { - const retryHolder = - (await readFile(path, "utf8")).trim() || "unknown"; - throw new UserError(`store lock held by pid ${retryHolder}`); - } - throw retryError; - } - }; - - try { - await create(); - } catch (error) { - if (errorCode(error) !== "EEXIST") { - throw error; - } - let holder = "unknown"; - try { - holder = (await readFile(path, "utf8")).trim() || "unknown"; - } catch { - holder = "unknown"; - } - if (holderIsDead(holder)) { - options.onStaleLock?.(holder); - await takeOver(); - } else if (options.force) { - options.onLockStolen?.(holder); - await takeOver(); - } else { - throw new UserError(`store lock held by pid ${holder}`); - } - } - - return async (): Promise => { - try { - if ((await readFile(path, "utf8")).trim() === pid) { - await unlink(path); - } - } catch (error) { - if (errorCode(error) !== "ENOENT") { - throw error; - } - } - }; -} - -async function readTsv( - path: string, - header: string, - width: number -): Promise { - const lines = (await requiredFile(path)).replace(/\r/g, "").split("\n"); - if (lines.shift() !== header) { - throw new UserError(`${basename(path)} has an invalid header`); - } - return lines - .filter((value) => value.length > 0) - .map((value) => { - const cells = value.split("\t"); - if (cells.length !== width) { - throw new UserError(`${basename(path)} has a malformed row`); - } - return cells; - }); -} - -async function writeTsv( - path: string, - header: string, - rows: readonly (readonly string[])[] -): Promise { - const body = rows.map((row) => row.map(cleanCell).join("\t")).join("\n"); - await atomicWrite(path, `${header}\n${body}${body.length > 0 ? "\n" : ""}`); -} - -async function readUnits(store: string): Promise { - return (await readTsv(join(store, "units.tsv"), UNIT_HEADER, 7)).map( - (row) => ({ - id: row[0] ?? "", - track: row[1] ?? "", - state: row[2] ?? "", - branch: row[3] ?? "", - pr: row[4] ?? "", - sha: row[5] ?? "", - brief: row[6] ?? "", - }) - ); -} - -function unitCells(unit: Unit): readonly string[] { - return [ - unit.id, - unit.track, - unit.state, - unit.branch, - unit.pr, - unit.sha, - unit.brief, - ]; -} - -async function saveUnits(store: string, rows: readonly Unit[]): Promise { - await writeTsv( - join(store, "units.tsv"), - UNIT_HEADER, - rows.map(unitCells) - ); -} - -async function readLedger(store: string): Promise { - return (await readTsv(join(store, "ledger.tsv"), LEDGER_HEADER, 6)).map( - (row) => { - const rawVerdict = row[2] ?? ""; - const verdict = verdictOrNull(rawVerdict); - if (verdict === null) { - throw new UserError(`ledger.tsv has invalid verdict ${rawVerdict}`); - } - return { - pr: row[0] ?? "", - sha: row[1] ?? "", - verdict, - evidence: row[3] ?? "", - verifier: row[4] ?? "", - ts: row[5] ?? "", - }; - } - ); -} - -function ledgerCells(row: LedgerEntry): readonly string[] { - return [ - row.pr, - row.sha, - row.verdict, - row.evidence, - row.verifier, - row.ts, - ]; -} - -async function saveLedger( - store: string, - rows: readonly LedgerEntry[] -): Promise { - await writeTsv( - join(store, "ledger.tsv"), - LEDGER_HEADER, - rows.map(ledgerCells) - ); -} - -function pointerCells(pointer: InboxPointer): readonly string[] { - return [ - pointer.ts, - pointer.agent, - pointer.unit, - pointer.status, - pointer.report, - ]; -} - -async function readPointers( - directory: string -): Promise { - let entries: Dirent[]; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch (error) { - if (errorCode(error) === "ENOENT") { - throw new UserError( - `store is not initialized at ${dirname(directory)}; run orch init` - ); - } - throw error; - } - const result: InboxPointer[] = []; - const files = entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".tsv")) - .sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of files) { - const raw = (await readFile(join(directory, entry.name), "utf8")).replace( - /\r?\n$/, - "" - ); - const row = raw.split("\t"); - if (/[\r\n]/.test(raw) || row.length !== 5) { - throw new UserError(`inbox pointer ${entry.name} is malformed`); - } - result.push({ - ts: row[0] ?? "", - agent: row[1] ?? "", - unit: row[2] ?? "", - status: row[3] ?? "", - report: row[4] ?? "", - }); - } - return result; -} - -function renderGates(rows: readonly Gate[]): string { - if (rows.length === 0) { - return ""; - } - const blocks = rows.map((gate) => { - const answer = - gate.kind === "resolved" ? `\n- Answer: ${gate.answer}` : ""; - return `## ${gate.id} - -- Status: ${gate.kind} -- Question: ${gate.question} -- Options: ${gate.options} -- Default: ${gate.defaultAnswer}${answer}`; - }); - return `# Gates\n\n${blocks.join("\n\n")}\n`; -} - -async function readGates(store: string): Promise { - const raw = (await requiredFile(join(store, "gates.md"))) - .replace(/\r/g, "") - .trim(); - if (raw.length === 0) { - return []; - } - const prefix = "# Gates\n\n## "; - if (!raw.startsWith(prefix)) { - throw new UserError("gates.md has an invalid heading"); - } - const result: Gate[] = []; - for (const block of raw.slice(prefix.length).split("\n\n## ")) { - const lines = block.split("\n").filter((value) => value.length > 0); - const id = lines.shift() ?? ""; - const fields = new Map(); - for (const value of lines) { - const match = /^- ([^:]+): (.*)$/.exec(value); - if (match === null) { - throw new UserError(`gates.md has a malformed gate ${id}`); - } - fields.set(match[1] ?? "", match[2] ?? ""); - } - const status = fields.get("Status"); - const question = fields.get("Question"); - const options = fields.get("Options"); - const defaultAnswer = fields.get("Default"); - if ( - id.length === 0 || - question === undefined || - options === undefined || - defaultAnswer === undefined - ) { - throw new UserError(`gates.md has a malformed gate ${id}`); - } - if (status === "open") { - result.push({ kind: "open", id, question, options, defaultAnswer }); - } else if (status === "resolved" && fields.has("Answer")) { - result.push({ - kind: "resolved", - id, - question, - options, - defaultAnswer, - answer: fields.get("Answer") ?? "", - }); - } else { - throw new UserError(`gates.md has invalid status ${status ?? ""}`); - } - } - if (new Set(result.map((gate) => gate.id)).size !== result.length) { - throw new UserError("gates.md has duplicate gate ids"); - } - return result; -} - -function parseFrontier(raw: string): Frontier { - let value: unknown; - try { - value = JSON.parse(raw); - } catch { - throw new UserError("frontier.json is not valid JSON"); - } - if (!isRecord(value)) { - throw new UserError("frontier.json must contain an object"); - } - if (Object.keys(value).length === 0) { - return { generation: 0, prs: [], lowestUnmerged: null }; - } - if ( - typeof value.generation !== "number" || - !Number.isSafeInteger(value.generation) || - value.generation < 0 || - !isUnknownArray(value.prs) || - !( - value.lowestUnmerged === null || - (typeof value.lowestUnmerged === "number" && - Number.isSafeInteger(value.lowestUnmerged)) - ) - ) { - throw new UserError("frontier.json has an invalid shape"); - } - const prs: FrontierPr[] = []; - for (const row of value.prs) { - const state = isRecord(row) - ? frontierPrStateOrNull(row.state) - : null; - if ( - !isRecord(row) || - typeof row.pr !== "number" || - !Number.isSafeInteger(row.pr) || - row.pr < 1 || - typeof row.branches !== "string" || - row.branches.length === 0 || - typeof row.sha !== "string" || - state === null - ) { - throw new UserError("frontier.json has an invalid PR row"); - } - prs.push({ - pr: row.pr, - branches: row.branches, - sha: row.sha, - state, - }); - } - return { - generation: value.generation, - prs, - lowestUnmerged: value.lowestUnmerged, - }; -} - -async function readFrontier(store: string): Promise { - return parseFrontier(await requiredFile(join(store, "frontier.json"))); -} - -async function readStanding( - store: string -): Promise { - const raw = (await requiredFile(join(store, "preferences.md"))).replace( - /\r/g, - "" - ); - if (raw.trim().length === 0) { - return []; - } - const result: StandingLine[] = []; - for (const value of raw.split("\n").filter((item) => item.length > 0)) { - const match = /^([1-9]\d*)\. (.+)$/.exec(value); - const number = Number(match?.[1] ?? 0); - if (match === null || number !== result.length + 1) { - throw new UserError("preferences.md has malformed numbering"); - } - result.push({ number, line: match[2] ?? "" }); - } - return result; -} - -function countValues(values: readonly string[]): Counts { - const result: Record = {}; - for (const value of values) { - result[value] = (result[value] ?? 0) + 1; - } - return Object.fromEntries( - Object.entries(result).sort(([left], [right]) => - left.localeCompare(right) - ) - ); -} - -function summarize( - unitRows: readonly Unit[], - ledgerRows: readonly LedgerEntry[], - currentFrontier: Frontier, - gateRows: readonly Gate[] -): StatusSummary { - return { - unitStates: countValues(unitRows.map((unit) => unit.state)), - ledgerVerdicts: countValues(ledgerRows.map((row) => row.verdict)), - frontierGeneration: currentFrontier.generation, - openGateIds: gateRows - .filter((gate): gate is OpenGate => gate.kind === "open") - .map((gate) => gate.id) - .sort(), - }; -} - -function countRecord(value: unknown): Record | null { - if (!isRecord(value)) { - return null; - } - const result: Record = {}; - for (const [name, count] of Object.entries(value)) { - if ( - typeof count !== "number" || - !Number.isSafeInteger(count) || - count < 0 - ) { - return null; - } - result[name] = count; - } - return result; -} - -function previousSummary(raw: string): StatusSummary | null { - const match = //.exec(raw); - if (match === null) { - return null; - } - let value: unknown; - try { - value = JSON.parse(match[1] ?? ""); - } catch { - return null; - } - if ( - !isRecord(value) || - typeof value.frontierGeneration !== "number" || - !isUnknownArray(value.openGateIds) - ) { - return null; - } - const unitStates = countRecord(value.unitStates); - const ledgerVerdicts = countRecord(value.ledgerVerdicts); - const openGateIds = value.openGateIds.filter( - (item): item is string => typeof item === "string" - ); - if ( - unitStates === null || - ledgerVerdicts === null || - openGateIds.length !== value.openGateIds.length - ) { - return null; - } - return { - unitStates, - ledgerVerdicts, - frontierGeneration: value.frontierGeneration, - openGateIds, - }; -} - -function changed(before: StatusSummary | null, after: StatusSummary): string { - if (before === null) { - return "first render"; - } - const result: string[] = []; - const groups: readonly { - readonly label: string; - readonly oldCounts: Counts; - readonly newCounts: Counts; - }[] = [ - { - label: "units", - oldCounts: before.unitStates, - newCounts: after.unitStates, - }, - { - label: "ledger", - oldCounts: before.ledgerVerdicts, - newCounts: after.ledgerVerdicts, - }, - ]; - for (const { label, oldCounts, newCounts } of groups) { - const names = [ - ...new Set([...Object.keys(oldCounts), ...Object.keys(newCounts)]), - ].sort(); - for (const name of names) { - const oldCount = oldCounts[name] ?? 0; - const newCount = newCounts[name] ?? 0; - if (oldCount !== newCount) { - result.push(`${label} ${name} ${oldCount}->${newCount}`); - } - } - } - if (before.frontierGeneration !== after.frontierGeneration) { - result.push( - `frontier generation ${before.frontierGeneration}->${after.frontierGeneration}` - ); - } - if (before.openGateIds.join("\0") !== after.openGateIds.join("\0")) { - result.push( - `open gates ${before.openGateIds.length}->${after.openGateIds.length}` - ); - } - return result.length === 0 ? "no derived changes" : result.join("; "); -} - -function markdown(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); -} - -function table( - headers: readonly string[], - rows: readonly (readonly string[])[] -): string { - if (rows.length === 0) { - return "(none)"; - } - return [ - `| ${headers.join(" | ")} |`, - `| ${headers.map(() => "---").join(" | ")} |`, - ...rows.map((row) => `| ${row.map(markdown).join(" | ")} |`), - ].join("\n"); -} - -function statusMarkdown( - unitRows: readonly Unit[], - ledgerRows: readonly LedgerEntry[], - currentFrontier: Frontier, - gateRows: readonly Gate[], - currentSummary: StatusSummary -): string { - return `# Orchestrate status - -Generated: ${new Date().toISOString()} - -## Units - -States: ${countLine(currentSummary.unitStates)} - -${table( - ["ID", "Track", "State", "Branch", "PR", "SHA", "Brief"], - unitRows.map(unitCells) -)} - -## Verification ledger - -Verdicts: ${countLine(currentSummary.ledgerVerdicts)} - -${table( - ["PR", "SHA", "Verdict", "Evidence", "Verifier", "Timestamp"], - ledgerRows.map(ledgerCells) -)} - -## Frontier - -Generation: ${currentFrontier.generation} -Lowest unmerged: ${currentFrontier.lowestUnmerged ?? "none"} - -${table( - ["Branch", "PR", "SHA", "State"], - currentFrontier.prs.map((row) => [ - row.branches, - String(row.pr), - row.sha, - row.state, - ]) -)} - -## Gates - -${table( - ["ID", "Status", "Question", "Options", "Default", "Answer"], - gateRows.map((gate) => [ - gate.id, - gate.kind, - gate.question, - gate.options, - gate.defaultAnswer, - gate.kind === "resolved" ? gate.answer : "", - ]) -)} - - -`; -} - -function countLine(value: Counts): string { - const entries = Object.entries(value); - return entries.length === 0 - ? "none" - : entries.map(([name, count]) => `${name}=${count}`).join(", "); -} - -export type Forge = "gh" | "origin"; - -interface ForgePullRequestQuery { - readonly stateField: string; - readonly headField: string; - readonly baseField: string; - readonly states: ReadonlyMap; -} - -const FORGE_PULL_REQUEST_QUERIES: Readonly< - Record -> = { - gh: { - stateField: "state", - headField: "headRefName", - baseField: "baseRefName", - states: new Map([ - ["OPEN", "OPEN"], - ["MERGED", "MERGED"], - ["CLOSED", "CLOSED"], - ]), - }, - origin: { - stateField: "status", - headField: "headRef", - baseField: "baseRef", - states: new Map([ - ["open", "OPEN"], - ["draft", "OPEN"], - ["merged", "MERGED"], - ["closed", "CLOSED"], - ]), - }, -}; - -export function parseForge(value: string): Forge { - if (value === "gh" || value === "origin") { - return value; - } - throw new UserError("forge must be gh or origin"); -} - -interface ForgePullRequest { - readonly pr: number; - readonly state: FrontierPrState; - readonly branch: string; - readonly base: string; -} - -function runTool({ - command, - args, - repo, -}: { - command: string; - args: readonly string[]; - repo: string; -}): string { - try { - return execFileSync(command, args, { - cwd: repo, - encoding: "utf8", - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - throw new UserError( - `${command} ${args.join(" ")} failed: ${errorMessage(error)}` - ); - } -} - -function listPullRequests({ - filter, - branch, - forge, - repo, -}: { - filter: "--head" | "--base"; - branch: string; - forge: Forge; - repo: string; -}): readonly ForgePullRequest[] { - const query = FORGE_PULL_REQUEST_QUERIES[forge]; - const command = `${forge} pr list ${filter} ${branch}`; - // Every state is fetched because a merged or closed PR still anchors the - // chain, and a draft counts as OPEN. - const raw = runTool({ - command: forge, - args: [ - "pr", - "list", - filter, - branch, - "--state", - "all", - "--json", - ["number", query.stateField, query.headField, query.baseField].join( - "," - ), - ], - repo, - }); - let value: unknown; - try { - value = JSON.parse(raw); - } catch { - throw new UserError(`${command} output is not valid JSON`); - } - if (!isUnknownArray(value)) { - throw new UserError(`${command} output is not a JSON array`); - } - return value.map((row) => { - const status = isRecord(row) ? row[query.stateField] : undefined; - const state = - typeof status === "string" ? (query.states.get(status) ?? null) : null; - const head = isRecord(row) ? row[query.headField] : undefined; - const base = isRecord(row) ? row[query.baseField] : undefined; - if ( - !isRecord(row) || - typeof row.number !== "number" || - !Number.isSafeInteger(row.number) || - row.number < 1 || - state === null || - typeof head !== "string" || - head.length === 0 || - typeof base !== "string" || - base.length === 0 - ) { - throw new UserError(`${command} output has an invalid PR row`); - } - return { pr: row.number, state, branch: head, base }; - }); -} - -function branchPullRequest({ - branch, - forge, - repo, -}: { - branch: string; - forge: Forge; - repo: string; -}): ForgePullRequest { - const rows = listPullRequests({ filter: "--head", branch, forge, repo }); - const row = rows[0]; - if (row === undefined) { - throw new UserError( - `branch ${branch} has no pull request; open one with ${forge} pr create, or resolve the frontier from a branch whose chain is fully submitted` - ); - } - if (rows.length > 1) { - throw new UserError( - `branch ${branch} has multiple pull requests (${rows.map((item) => `#${item.pr}`).join(", ")}); close the stale ones first` - ); - } - return row; -} - -function openChildren({ - branch, - forge, - repo, -}: { - branch: string; - forge: Forge; - repo: string; -}): readonly ForgePullRequest[] { - return listPullRequests({ filter: "--base", branch, forge, repo }).filter( - (row) => row.state === "OPEN" - ); -} - -function runGit({ - args, - repo, -}: { - args: readonly string[]; - repo: string; -}): string { - return runTool({ command: "git", args, repo }).trim(); -} - -function branchSha({ - branch, - repo, -}: { - branch: string; - repo: string; -}): string { - const sha = runGit({ args: ["rev-parse", branch], repo }); - if (!/^[0-9a-f]{40,64}$/i.test(sha)) { - throw new UserError(`git rev-parse ${branch} returned an invalid SHA`); - } - return sha; -} - -function currentBranch(repo: string): string { - return runGit({ args: ["symbolic-ref", "--short", "HEAD"], repo }); -} - -function trunkBranch(repo: string): string { - try { - return runGit({ - args: ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], - repo, - }).replace(/^origin\//, ""); - } catch { - // Init-plus-fetch checkouts and local fixtures lack origin/HEAD. - return "main"; - } -} - -function resolveFrontier(repo: string, forge: Forge): readonly FrontierPr[] { - const trunk = trunkBranch(repo); - const current = currentBranch(repo); - if (current === trunk) { - return []; - } - const seen = new Set([current]); - let bottom = branchPullRequest({ branch: current, forge, repo }); - const chain = [bottom]; - while (bottom.base !== trunk) { - if (seen.has(bottom.base)) { - throw new UserError( - `pull request base chain loops at branch ${bottom.base}` - ); - } - seen.add(bottom.base); - bottom = branchPullRequest({ branch: bottom.base, forge, repo }); - chain.unshift(bottom); - } - let tip = current; - for (;;) { - const children = openChildren({ branch: tip, forge, repo }); - const child = children[0]; - if (child === undefined) { - break; - } - if (children.length > 1) { - throw new UserError( - `stack is not linear: branches ${children.map((row) => row.branch).join(", ")} all target ${tip}` - ); - } - if (seen.has(child.branch)) { - throw new UserError( - `pull request base chain loops at branch ${child.branch}` - ); - } - seen.add(child.branch); - chain.push(child); - tip = child.branch; - } - if (new Set(chain.map((row) => row.pr)).size !== chain.length) { - throw new UserError( - "pull request base chain contains duplicate pull requests" - ); - } - return chain.map((row) => ({ - pr: row.pr, - branches: row.branch, - sha: branchSha({ branch: row.branch, repo }), - state: row.state, - })); -} - -function validateFrontierPin({ - actual, - expected, -}: { - actual: readonly number[]; - expected: readonly number[]; -}): void { - if ( - actual.length === expected.length && - actual.every((pr, index) => pr === expected[index]) - ) { - return; - } - const actualSet = new Set(actual); - const expectedSet = new Set(expected); - const missing = expected.filter((pr) => !actualSet.has(pr)); - const extra = actual.filter((pr) => !expectedSet.has(pr)); - const drift: string[] = []; - if (missing.length > 0) { - drift.push(`missing from resolved chain: ${missing.join(",")}`); - } - if (extra.length > 0) { - drift.push(`extra in resolved chain: ${extra.join(",")}`); - } - if (missing.length === 0 && extra.length === 0) { - drift.push( - `order differs: expected ${expected.join(",")}; resolved ${actual.join(",")}` - ); - } - throw new UserError(`frontier pin mismatch: ${drift.join("; ")}`); -} + changed, + previousSummary, + statusMarkdown, + summarize, +} from "./report.ts"; +import { + LEDGER_HEADER, + UNIT_HEADER, + atomicWrite, + exists, + pointerCells, + readGates, + readFrontier, + readLedger, + readPointers, + readStanding, + readUnits, + renderGates, + saveLedger, + saveUnits, + writeIfMissing, +} from "./tables.ts"; +import { + NotFoundError, + type OpenStoreOptions, + type Store, + type Unit, + type LedgerEntry, + type InboxPointer, + type OpenGate, + type ResolvedGate, + type Frontier, + type StandingLine, + UserError, + cleanCell as cleanTableCell, + parseForge, + parseVerdict, + positiveInteger, + requiredCell, + requiredLine, +} from "./types.ts"; + +export * from "./types.ts"; +export * from "./lock.ts"; +export * from "./frontier.ts"; +export * from "./report.ts"; +export * from "./tables.ts"; export function openStore( directory: string, @@ -1392,6 +178,7 @@ export function openStore( }, counts: async () => { ensureOpen(); + const { countValues } = await import("./report.ts"); return countValues( (await readUnits(store)).map((unit) => unit.state) ); @@ -1441,6 +228,7 @@ export function openStore( }, summary: async () => { ensureOpen(); + const { countValues } = await import("./report.ts"); return countValues( (await readLedger(store)).map((row) => row.verdict) ); @@ -1467,7 +255,7 @@ export function openStore( } const timestamp = pointer.ts.replace(/[:.]/g, "-"); const filename = `${timestamp}-${process.pid}-${randomUUID()}.tsv`; - const contents = `${pointerCells(pointer).map(cleanCell).join("\t")}\n`; + const contents = `${pointerCells(pointer).map(cleanTableCell).join("\t")}\n`; await atomicWrite(join(inbox, filename), contents); return { pointer, filename }; }, diff --git a/skills/poteto-mode/scripts/orch/tables.ts b/skills/poteto-mode/scripts/orch/tables.ts new file mode 100644 index 00000000..785f5473 --- /dev/null +++ b/skills/poteto-mode/scripts/orch/tables.ts @@ -0,0 +1,383 @@ +import { randomUUID } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { + access, + readFile, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { + type Frontier, + type FrontierPr, + type Gate, + type InboxPointer, + type LedgerEntry, + type StandingLine, + type Unit, + UserError, + cleanCell, + errorCode, + frontierPrStateOrNull, + isRecord, + isUnknownArray, + verdictOrNull, +} from "./types.ts"; + +export const UNIT_HEADER = "id\ttrack\tstate\tbranch\tpr\tsha\tbrief"; +export const LEDGER_HEADER = "pr\tsha\tverdict\tevidence\tverifier\tts"; + +export async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") { + return false; + } + throw error; + } +} + +export async function atomicWrite(path: string, contents: string): Promise { + const temporary = join( + dirname(path), + `.${basename(path)}.${process.pid}.${randomUUID()}.tmp` + ); + try { + await writeFile(temporary, contents, { flag: "wx" }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +export async function writeIfMissing(path: string, contents: string): Promise { + if (!(await exists(path))) { + await atomicWrite(path, contents); + } +} + +export async function requiredFile(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if (errorCode(error) === "ENOENT") { + throw new UserError( + `store is not initialized at ${dirname(path)}; run orch init` + ); + } + throw error; + } +} + +export async function readTsv( + path: string, + header: string, + width: number +): Promise { + const lines = (await requiredFile(path)).replace(/\r/g, "").split("\n"); + if (lines.shift() !== header) { + throw new UserError(`${basename(path)} has an invalid header`); + } + return lines + .filter((value) => value.length > 0) + .map((value) => { + const cells = value.split("\t"); + if (cells.length !== width) { + throw new UserError(`${basename(path)} has a malformed row`); + } + return cells; + }); +} + +export async function writeTsv( + path: string, + header: string, + rows: readonly (readonly string[])[] +): Promise { + const body = rows.map((row) => row.map(cleanCell).join("\t")).join("\n"); + await atomicWrite(path, `${header}\n${body}${body.length > 0 ? "\n" : ""}`); +} + +export function unitCells(unit: Unit): readonly string[] { + return [ + unit.id, + unit.track, + unit.state, + unit.branch, + unit.pr, + unit.sha, + unit.brief, + ]; +} + +export async function readUnits(store: string): Promise { + return (await readTsv(join(store, "units.tsv"), UNIT_HEADER, 7)).map( + (row) => ({ + id: row[0] ?? "", + track: row[1] ?? "", + state: row[2] ?? "", + branch: row[3] ?? "", + pr: row[4] ?? "", + sha: row[5] ?? "", + brief: row[6] ?? "", + }) + ); +} + +export async function saveUnits(store: string, rows: readonly Unit[]): Promise { + await writeTsv( + join(store, "units.tsv"), + UNIT_HEADER, + rows.map(unitCells) + ); +} + +export function ledgerCells(row: LedgerEntry): readonly string[] { + return [ + row.pr, + row.sha, + row.verdict, + row.evidence, + row.verifier, + row.ts, + ]; +} + +export async function readLedger(store: string): Promise { + return (await readTsv(join(store, "ledger.tsv"), LEDGER_HEADER, 6)).map( + (row) => { + const rawVerdict = row[2] ?? ""; + const verdict = verdictOrNull(rawVerdict); + if (verdict === null) { + throw new UserError(`ledger.tsv has invalid verdict ${rawVerdict}`); + } + return { + pr: row[0] ?? "", + sha: row[1] ?? "", + verdict, + evidence: row[3] ?? "", + verifier: row[4] ?? "", + ts: row[5] ?? "", + }; + } + ); +} + +export async function saveLedger( + store: string, + rows: readonly LedgerEntry[] +): Promise { + await writeTsv( + join(store, "ledger.tsv"), + LEDGER_HEADER, + rows.map(ledgerCells) + ); +} + +export function pointerCells(pointer: InboxPointer): readonly string[] { + return [ + pointer.ts, + pointer.agent, + pointer.unit, + pointer.status, + pointer.report, + ]; +} + +export async function readPointers( + directory: string +): Promise { + let entries: Dirent[]; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") { + throw new UserError( + `store is not initialized at ${dirname(directory)}; run orch init` + ); + } + throw error; + } + const result: InboxPointer[] = []; + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".tsv")) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of files) { + const raw = (await readFile(join(directory, entry.name), "utf8")).replace( + /\r?\n$/, + "" + ); + const row = raw.split("\t"); + if (/[\r\n]/.test(raw) || row.length !== 5) { + throw new UserError(`inbox pointer ${entry.name} is malformed`); + } + result.push({ + ts: row[0] ?? "", + agent: row[1] ?? "", + unit: row[2] ?? "", + status: row[3] ?? "", + report: row[4] ?? "", + }); + } + return result; +} + +export function renderGates(rows: readonly Gate[]): string { + if (rows.length === 0) { + return ""; + } + const blocks = rows.map((gate) => { + const answer = + gate.kind === "resolved" ? `\n- Answer: ${gate.answer}` : ""; + return `## ${gate.id} + +- Status: ${gate.kind} +- Question: ${gate.question} +- Options: ${gate.options} +- Default: ${gate.defaultAnswer}${answer}`; + }); + return `# Gates\n\n${blocks.join("\n\n")}\n`; +} + +export async function readGates(store: string): Promise { + const raw = (await requiredFile(join(store, "gates.md"))) + .replace(/\r/g, "") + .trim(); + if (raw.length === 0) { + return []; + } + const prefix = "# Gates\n\n## "; + if (!raw.startsWith(prefix)) { + throw new UserError("gates.md has an invalid heading"); + } + const result: Gate[] = []; + for (const block of raw.slice(prefix.length).split("\n\n## ")) { + const lines = block.split("\n").filter((value) => value.length > 0); + const id = lines.shift() ?? ""; + const fields = new Map(); + for (const value of lines) { + const match = /^- ([^:]+): (.*)$/.exec(value); + if (match === null) { + throw new UserError(`gates.md has a malformed gate ${id}`); + } + fields.set(match[1] ?? "", match[2] ?? ""); + } + const status = fields.get("Status"); + const question = fields.get("Question"); + const options = fields.get("Options"); + const defaultAnswer = fields.get("Default"); + if ( + id.length === 0 || + question === undefined || + options === undefined || + defaultAnswer === undefined + ) { + throw new UserError(`gates.md has a malformed gate ${id}`); + } + if (status === "open") { + result.push({ kind: "open", id, question, options, defaultAnswer }); + } else if (status === "resolved" && fields.has("Answer")) { + result.push({ + kind: "resolved", + id, + question, + options, + defaultAnswer, + answer: fields.get("Answer") ?? "", + }); + } else { + throw new UserError(`gates.md has invalid status ${status ?? ""}`); + } + } + if (new Set(result.map((gate) => gate.id)).size !== result.length) { + throw new UserError("gates.md has duplicate gate ids"); + } + return result; +} + +export function parseFrontier(raw: string): Frontier { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new UserError("frontier.json is not valid JSON"); + } + if (!isRecord(value)) { + throw new UserError("frontier.json must contain an object"); + } + if (Object.keys(value).length === 0) { + return { generation: 0, prs: [], lowestUnmerged: null }; + } + if ( + typeof value.generation !== "number" || + !Number.isSafeInteger(value.generation) || + value.generation < 0 || + !isUnknownArray(value.prs) || + !( + value.lowestUnmerged === null || + (typeof value.lowestUnmerged === "number" && + Number.isSafeInteger(value.lowestUnmerged)) + ) + ) { + throw new UserError("frontier.json has an invalid shape"); + } + const prs: FrontierPr[] = []; + for (const row of value.prs) { + const state = isRecord(row) + ? frontierPrStateOrNull(row.state) + : null; + if ( + !isRecord(row) || + typeof row.pr !== "number" || + !Number.isSafeInteger(row.pr) || + row.pr < 1 || + typeof row.branches !== "string" || + row.branches.length === 0 || + typeof row.sha !== "string" || + state === null + ) { + throw new UserError("frontier.json has an invalid PR row"); + } + prs.push({ + pr: row.pr, + branches: row.branches, + sha: row.sha, + state, + }); + } + return { + generation: value.generation, + prs, + lowestUnmerged: value.lowestUnmerged, + }; +} + +export async function readFrontier(store: string): Promise { + return parseFrontier(await requiredFile(join(store, "frontier.json"))); +} + +export async function readStanding( + store: string +): Promise { + const raw = (await requiredFile(join(store, "preferences.md"))).replace( + /\r/g, + "" + ); + if (raw.trim().length === 0) { + return []; + } + const result: StandingLine[] = []; + for (const value of raw.split("\n").filter((item) => item.length > 0)) { + const match = /^([1-9]\d*)\. (.+)$/.exec(value); + const number = Number(match?.[1] ?? 0); + if (match === null || number !== result.length + 1) { + throw new UserError("preferences.md has malformed numbering"); + } + result.push({ number, line: match[2] ?? "" }); + } + return result; +} diff --git a/skills/poteto-mode/scripts/orch/types.ts b/skills/poteto-mode/scripts/orch/types.ts new file mode 100644 index 00000000..a36e7c37 --- /dev/null +++ b/skills/poteto-mode/scripts/orch/types.ts @@ -0,0 +1,312 @@ +export type Verdict = + | "live-ui-verified" + | "unit-test-verified" + | "type-check-only" + | "verifier-blocked" + | "verifier-failed"; + +export interface Unit { + readonly id: string; + readonly track: string; + readonly state: string; + readonly branch: string; + readonly pr: string; + readonly sha: string; + readonly brief: string; +} + +export interface LedgerEntry { + readonly pr: string; + readonly sha: string; + readonly verdict: Verdict; + readonly evidence: string; + readonly verifier: string; + readonly ts: string; +} + +export interface InboxPointer { + readonly ts: string; + readonly agent: string; + readonly unit: string; + readonly status: string; + readonly report: string; +} + +export interface InboxPushResult { + readonly pointer: InboxPointer; + readonly filename: string; +} + +export interface OpenGate { + readonly kind: "open"; + readonly id: string; + readonly question: string; + readonly options: string; + readonly defaultAnswer: string; +} + +export interface ResolvedGate { + readonly kind: "resolved"; + readonly id: string; + readonly question: string; + readonly options: string; + readonly defaultAnswer: string; + readonly answer: string; +} + +export type Gate = OpenGate | ResolvedGate; + +export type FrontierPrState = "OPEN" | "MERGED" | "CLOSED"; + +export interface FrontierPr { + readonly pr: number; + readonly branches: string; + readonly sha: string; + readonly state: FrontierPrState; +} + +export interface Frontier { + readonly generation: number; + readonly prs: readonly FrontierPr[]; + readonly lowestUnmerged: number | null; +} + +export interface StandingLine { + readonly number: number; + readonly line: string; +} + +export type Counts = Readonly>; + +export interface StatusSummary { + readonly unitStates: Counts; + readonly ledgerVerdicts: Counts; + readonly frontierGeneration: number; + readonly openGateIds: readonly string[]; +} + +export interface StatusReport { + readonly units: readonly Unit[]; + readonly ledger: readonly LedgerEntry[]; + readonly frontier: Frontier; + readonly gates: readonly Gate[]; + readonly summary: StatusSummary; + readonly changed: string; +} + +export interface AddUnitParams { + readonly id: string; + readonly track: string; + readonly brief?: string; +} + +export interface SetUnitParams { + readonly id: string; + readonly state: string; + readonly branch?: string; + readonly pr?: number; + readonly sha?: string; +} + +export interface ListUnitsParams { + readonly state?: string; + readonly track?: string; +} + +export interface RecordLedgerParams { + readonly pr: number; + readonly sha: string; + readonly verdict: Verdict; + readonly evidence: string; + readonly verifier?: string; +} + +export interface CheckLedgerParams { + readonly pr: number; + readonly sha: string; +} + +export interface PushInboxParams { + readonly agent: string; + readonly unit: string; + readonly status: string; + readonly report?: string; +} + +export interface ParkGateParams { + readonly id: string; + readonly question: string; + readonly options: string; + readonly defaultAnswer: string; +} + +export interface ResolveGateParams { + readonly id: string; + readonly answer: string; +} + +export type Forge = "gh" | "origin"; + +export interface SetFrontierParams { + readonly repo: string; + readonly forge?: Forge; + readonly prs?: readonly number[]; +} + +export interface AddStandingParams { + readonly line: string; +} + +export interface OpenStoreOptions { + readonly force?: boolean; + readonly onLockStolen?: (holder: string) => void; + readonly onStaleLock?: (holder: string) => void; +} + +export interface Store { + readonly units: { + readonly add: (params: AddUnitParams) => Promise; + readonly set: (params: SetUnitParams) => Promise; + readonly get: (id: string) => Promise; + readonly list: (params?: ListUnitsParams) => Promise; + readonly counts: () => Promise; + }; + readonly ledger: { + readonly record: (params: RecordLedgerParams) => Promise; + readonly check: (params: CheckLedgerParams) => Promise; + readonly summary: () => Promise; + }; + readonly inbox: { + readonly push: (params: PushInboxParams) => Promise; + readonly drain: () => Promise; + readonly peek: () => Promise; + readonly count: () => Promise; + }; + readonly gates: { + readonly park: (params: ParkGateParams) => Promise; + readonly list: () => Promise; + readonly resolve: (params: ResolveGateParams) => Promise; + }; + readonly frontier: { + readonly set: (params: SetFrontierParams) => Promise; + readonly show: () => Promise; + }; + readonly standing: { + readonly show: () => Promise; + readonly add: (params: AddStandingParams) => Promise; + }; + readonly status: { + readonly render: () => Promise; + }; + readonly init: () => Promise<{ readonly store: string }>; + readonly close: () => Promise; +} + +export interface NotFoundOutput { + readonly compact: string; + readonly json: unknown; +} + +export class UserError extends Error {} +export class UsageError extends UserError {} +export class NotFoundError extends UserError { + public constructor( + message: string, + public readonly output?: NotFoundOutput + ) { + super(message); + } +} + +export function errorCode(error: unknown): string | null { + if ( + error !== null && + typeof error === "object" && + "code" in error && + typeof error.code === "string" + ) { + return error.code; + } + return null; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function isUnknownArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +export function verdictOrNull(value: string): Verdict | null { + switch (value) { + case "live-ui-verified": + case "unit-test-verified": + case "type-check-only": + case "verifier-blocked": + case "verifier-failed": + return value; + default: + return null; + } +} + +export function frontierPrStateOrNull(value: unknown): FrontierPrState | null { + switch (value) { + case "OPEN": + case "MERGED": + case "CLOSED": + return value; + default: + return null; + } +} + +export function parseVerdict(value: string): Verdict { + const verdict = verdictOrNull(value); + if (verdict === null) { + throw new UserError( + "verdict must be live-ui-verified, unit-test-verified, type-check-only, verifier-blocked, or verifier-failed" + ); + } + return verdict; +} + +export function cleanCell(value: string): string { + const cleaned = value.replace(/[\t\n\r]/g, " "); + return /^[=+\-@]/.test(cleaned) ? `'${cleaned}` : cleaned; +} + +export function requiredCell(value: string, label: string): string { + const cleaned = cleanCell(value); + if (cleaned.trim().length === 0) { + throw new UserError(`${label} must not be empty`); + } + return cleaned; +} + +export function requiredLine(value: string, label: string): string { + const cleaned = value.replace(/[\n\r]/g, " ").trim(); + if (cleaned.length === 0) { + throw new UserError(`${label} must not be empty`); + } + return cleaned; +} + +export function positiveInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new UserError(`${label} must be a positive integer`); + } + return value; +} + +export function parseForge(value: string): Forge { + if (value === "gh" || value === "origin") { + return value; + } + throw new UserError("forge must be gh or origin"); +}