diff --git a/tools/workflow-engine/git-world.test.ts b/tools/workflow-engine/git-world.test.ts new file mode 100644 index 0000000000..1910c1af0d --- /dev/null +++ b/tools/workflow-engine/git-world.test.ts @@ -0,0 +1,236 @@ +/** + * tools/workflow-engine/git-world.test.ts + * + * Invariant tests for git-world + github-world specialization substrate. + */ + +import { describe, expect, it } from "bun:test"; +import { + GITHUB_PR_UNIVERSE, + GITHUB_REVIEW_THREAD_UNIVERSE, + REQUIRE_RESOLVED_VERDICT, + buildGitHubWorld, + buildGitWorld, + canAfford, + rateLimitTier, + registerInGitHub, + type ComposedKey, + type PrLifetime, + type ReviewThreadLifetime, +} from "./git-world"; +import type { StandardVerdict } from "./world"; + +describe("git-world + github-world specialization substrate", () => { + it("buildGitWorld: forgeName='git' + branch+commit universes populated", () => { + const gitWorld = buildGitWorld(); + expect(gitWorld.forgeName).toBe("git"); + expect(gitWorld.branchUniverse.length).toBe(4); // fresh, active, merged, deleted + expect(gitWorld.commitUniverse.length).toBe(5); // pending, signed, pushed, merged, reverted + expect(gitWorld.registry.size).toBe(0); + }); + + it("buildGitHubWorld: inherits GitWorld + adds PR + review-thread universes", () => { + const gitWorld = buildGitWorld(); + const githubWorld = buildGitHubWorld(gitWorld); + expect(githubWorld.forgeName).toBe("git"); // inherited + expect(githubWorld.forgeSpecialization).toBe("github"); // added + expect(githubWorld.branchUniverse.length).toBe(4); // inherited + expect(githubWorld.commitUniverse.length).toBe(5); // inherited + expect(githubWorld.prUniverse.length).toBe(6); // added + expect(githubWorld.reviewThreadUniverse.length).toBe(3); // added + }); + + it("buildGitHubWorld: optional resource budget", () => { + const gitWorld = buildGitWorld(); + const githubWorld = buildGitHubWorld(gitWorld, { + restCoreRemaining: 4500, + restCoreLimit: 5000, + restCoreResetAt: 1700000000, + graphqlRemaining: 4800, + graphqlLimit: 5000, + graphqlResetAt: 1700000000, + }); + expect(githubWorld.resourceBudget).toBeDefined(); + expect(githubWorld.resourceBudget?.restCoreRemaining).toBe(4500); + }); + + it("rateLimitTier: > 2000 → normal", () => { + expect(rateLimitTier(5000)).toBe("normal"); + expect(rateLimitTier(2001)).toBe("normal"); + }); + + it("rateLimitTier: 1000-2000 → cost-aware", () => { + expect(rateLimitTier(2000)).toBe("cost-aware"); + expect(rateLimitTier(1001)).toBe("cost-aware"); + }); + + it("rateLimitTier: 200-1000 → extreme-cost-aware", () => { + expect(rateLimitTier(1000)).toBe("extreme-cost-aware"); + expect(rateLimitTier(201)).toBe("extreme-cost-aware"); + }); + + it("rateLimitTier: 0-200 → pure-git", () => { + expect(rateLimitTier(200)).toBe("pure-git"); + expect(rateLimitTier(0)).toBe("pure-git"); + }); + + it("canAfford: operation within budget → ok", () => { + const gitWorld = buildGitWorld(); + const githubWorld = buildGitHubWorld(gitWorld, { + restCoreRemaining: 100, + restCoreLimit: 5000, + restCoreResetAt: 1700000000, + graphqlRemaining: 100, + graphqlLimit: 5000, + graphqlResetAt: 1700000000, + }); + const result = canAfford(githubWorld, { restCoreCost: 50, graphqlCost: 50 }); + expect(result.ok).toBe(true); + }); + + it("canAfford: rest-core exhausted → ResourceBudgetExhausted/rest-core", () => { + const gitWorld = buildGitWorld(); + const githubWorld = buildGitHubWorld(gitWorld, { + restCoreRemaining: 10, + restCoreLimit: 5000, + restCoreResetAt: 1700000000, + graphqlRemaining: 5000, + graphqlLimit: 5000, + graphqlResetAt: 1700000000, + }); + const result = canAfford(githubWorld, { restCoreCost: 100 }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.feedback.kind).toBe("ResourceBudgetExhausted"); + if (result.feedback.kind === "ResourceBudgetExhausted") { + expect(result.feedback.budget).toBe("rest-core"); + } + }); + + it("canAfford: graphql exhausted → ResourceBudgetExhausted/graphql", () => { + const gitWorld = buildGitWorld(); + const githubWorld = buildGitHubWorld(gitWorld, { + restCoreRemaining: 5000, + restCoreLimit: 5000, + restCoreResetAt: 1700000000, + graphqlRemaining: 10, + graphqlLimit: 5000, + graphqlResetAt: 1700000000, + }); + const result = canAfford(githubWorld, { graphqlCost: 100 }); + expect(result.ok).toBe(false); + if (result.ok) return; + if (result.feedback.kind === "ResourceBudgetExhausted") { + expect(result.feedback.budget).toBe("graphql"); + } + }); + + it("canAfford: no budget loaded → ok (caller manages discipline)", () => { + const gitWorld = buildGitWorld(); + const githubWorld = buildGitHubWorld(gitWorld); // no budget + const result = canAfford(githubWorld, { restCoreCost: 1000000 }); + expect(result.ok).toBe(true); // permissive when budget not loaded + }); + + it("registerInGitHub: adds lifetime pair; preserves GitHubWorld substrate", () => { + const gitWorld = buildGitWorld(); + const githubWorld = buildGitHubWorld(gitWorld); + const matrix = new Map, StandardVerdict>([ + ["open:unresolved", REQUIRE_RESOLVED_VERDICT], + ["approved:resolved", { kind: "advance" }], + ]); + const updated = registerInGitHub( + githubWorld, + "pr-review-thread", + matrix, + ); + expect(updated.registry.size).toBe(1); + expect(updated.registry.has("pr-review-thread")).toBe(true); + // GitHubWorld-specific substrate preserved + expect(updated.forgeSpecialization).toBe("github"); + expect(updated.prUniverse.length).toBe(6); + }); + + it("REQUIRE_RESOLVED_VERDICT: block with substrate-honest reason", () => { + expect(REQUIRE_RESOLVED_VERDICT.kind).toBe("block"); + if (REQUIRE_RESOLVED_VERDICT.kind === "block") { + expect(REQUIRE_RESOLVED_VERDICT.reason).toContain("required_conversation_resolution"); + } + }); + + it("GITHUB_PR_UNIVERSE exports all 6 PR states", () => { + expect(GITHUB_PR_UNIVERSE.length).toBe(6); + const kinds = GITHUB_PR_UNIVERSE.map((p) => p.kind).sort(); + expect(kinds).toEqual(["approved", "closed", "draft", "merged", "open", "review-requested"]); + }); + + it("GITHUB_REVIEW_THREAD_UNIVERSE exports 3 thread states", () => { + expect(GITHUB_REVIEW_THREAD_UNIVERSE.length).toBe(3); + const kinds = GITHUB_REVIEW_THREAD_UNIVERSE.map((t) => t.kind).sort(); + expect(kinds).toEqual(["outdated", "resolved", "unresolved"]); + }); + + it("substrate-engineering substrate: GitHubWorld composes git+forge substrate cleanly", () => { + // Showcase: build GitHubWorld + register pr-review pair + verify + // composition works end-to-end + const gitWorld = buildGitWorld(); + const githubWorld = buildGitHubWorld(gitWorld, { + restCoreRemaining: 3000, + restCoreLimit: 5000, + restCoreResetAt: 1700000000, + graphqlRemaining: 1500, + graphqlLimit: 5000, + graphqlResetAt: 1700000000, + }); + // Check current tier + expect(rateLimitTier(githubWorld.resourceBudget!.graphqlRemaining)).toBe("cost-aware"); + // Register PR-review composed-lifetime matrix + const matrix = new Map, StandardVerdict>([ + ["draft:unresolved", { kind: "no-op" }], + ["open:unresolved", REQUIRE_RESOLVED_VERDICT], + ["open:resolved", { kind: "advance" }], + ["approved:resolved", { kind: "advance" }], + ["merged:resolved", { kind: "complete" }], + ]); + const enriched = registerInGitHub( + githubWorld, + "pr-review", + matrix, + ); + expect(enriched.registry.size).toBe(1); + expect(enriched.forgeSpecialization).toBe("github"); + expect(enriched.resourceBudget?.graphqlRemaining).toBe(1500); + }); + + it("registerLifetimePair preserves subclass fields when called with specialized world", () => { + // Regression test for the spread-replace pattern: registerLifetimePair + // returning `{ registry: newRegistry }` would silently drop all + // GitHubWorld-specific fields (forgeName, forgeSpecialization, + // branchUniverse, commitUniverse, prUniverse, reviewThreadUniverse, + // resourceBudget). Generic-over-W signature + spread preserves them. + const githubWorld = buildGitHubWorld(buildGitWorld(), { + restCoreRemaining: 4000, + restCoreLimit: 5000, + restCoreResetAt: 1_700_000_000, + graphqlRemaining: 4500, + graphqlLimit: 5000, + graphqlResetAt: 1_700_000_000, + }); + const matrix = new Map, StandardVerdict>([ + ["open:resolved", { kind: "advance" }], + ]); + const after = registerInGitHub(githubWorld, "pr-review", matrix); + // Registry updated + expect(after.registry.size).toBe(1); + expect(after.registry.has("pr-review")).toBe(true); + // ALL GitHubWorld-specific fields survive + expect(after.forgeName).toBe("git"); + expect(after.forgeSpecialization).toBe("github"); + expect(after.branchUniverse.length).toBeGreaterThan(0); + expect(after.commitUniverse.length).toBeGreaterThan(0); + expect(after.prUniverse.length).toBeGreaterThan(0); + expect(after.reviewThreadUniverse.length).toBeGreaterThan(0); + expect(after.resourceBudget?.graphqlRemaining).toBe(4500); + // Return type is GitHubWorld (compile-time check via field access above) + }); +}); diff --git a/tools/workflow-engine/git-world.ts b/tools/workflow-engine/git-world.ts new file mode 100644 index 0000000000..13014ca4f6 --- /dev/null +++ b/tools/workflow-engine/git-world.ts @@ -0,0 +1,322 @@ +/** + * tools/workflow-engine/git-world.ts + * + * Git world substrate + forge-specialization hierarchy. + * + * Per the human maintainer (2026-05-28): 'we have a git world and a + * github specilazation of it for REST/graphql enhancements/optimizations/ + * resource allocations/etc...' + * + * Substrate-engineering substrate-naming substrate (operator-explicit): + * - GitWorld = base substrate where git lifetimes interact (commit, + * branch, merge, rebase via git protocol) + * - GitHubWorld / GitLabWorld / GiteaWorld / etc. = specializations that + * add forge-specific substrate (REST API, GraphQL, webhooks, PR + * substrate, resource allocations, optimizations) + * + * Each specialization INHERITS GitWorld substrate + adds its own + * forge-specific lifetimes + dispatch matrices + resource-allocation + * substrate. Same dispatch substrate (per composed-lifetime.ts + + * world.ts) works for all; specialization is data not code. + * + * Composes with: + * - tools/workflow-engine/world.ts (PR #5774) — base World substrate + * - tools/workflow-engine/composed-lifetime.ts (PR #5771) — dispatch + * - B-0867.15 per-host adapters (github/gitlab/gitea/bitbucket + * isomorphic cross-host substrate) + * - B-0904 GitHub-as-free-event-store (specific GitHub optimization) + * - B-0865.17 cross-vendor benchmark (cross-vendor scoring; same + * shape applies cross-forge) + * - .claude/rules/monad-propagation-pattern + * - .claude/rules/asymmetric-authorship + * - .claude/rules/additive-not-zero-sum (specialization is additive + * extension; doesn't subtract from base GitWorld substrate) + */ + +import { + EMPTY_WORLD, + registerLifetimePair, + type ComposedKey, + type LifetimeState, + type StandardVerdict, + type World, +} from "./world"; + +// Re-export ComposedKey so downstream substrate (e.g., git-world.test.ts) +// can import it from this module without reaching into ./world directly. +export type { ComposedKey }; + +/** + * Branch lifetime — canonical git lifetime; every GitWorld has it. + * + * Edit-able substrate per the human maintainer's lifetime discipline; + * variants can be extended as forge-specific substrate emerges (e.g., + * GitHub adds 'protected' branch state; GitLab adds 'wip' draft state). + */ +export interface BranchLifetime extends LifetimeState { + readonly kind: "fresh" | "active" | "merged" | "deleted"; +} + +/** + * Commit lifetime — canonical git lifetime; every GitWorld has it. + */ +export interface CommitLifetime extends LifetimeState { + readonly kind: "pending" | "signed" | "pushed" | "merged" | "reverted"; +} + +/** + * GitWorld — base substrate where git lifetimes interact. + * + * Per the human maintainer (2026-05-28): the WORLD is the shared + * git-flow substrate; GitWorld is the BASE substrate that + * forge-specializations inherit. + * + * Base substrate operations: commit, branch, merge, rebase, push, pull, + * cherry-pick, revert. All operate on BranchLifetime + CommitLifetime + * + ref-lifetime + working-tree-lifetime. + */ +export interface GitWorld extends World { + readonly forgeName: "git"; // base substrate (no forge specialization) + readonly branchUniverse: ReadonlyArray; + readonly commitUniverse: ReadonlyArray; +} + +/** + * Build the base GitWorld substrate — every GitWorld starts here. + */ +export function buildGitWorld(): GitWorld { + return { + ...EMPTY_WORLD, + forgeName: "git", + branchUniverse: [ + { kind: "fresh" }, + { kind: "active" }, + { kind: "merged" }, + { kind: "deleted" }, + ], + commitUniverse: [ + { kind: "pending" }, + { kind: "signed" }, + { kind: "pushed" }, + { kind: "merged" }, + { kind: "reverted" }, + ], + }; +} + +/** + * PR lifetime (GitHub specialization). + * + * Per the human maintainer (2026-05-28): GitHub adds PR substrate as + * forge-specific specialization. Lifetime variants per GitHub PR + * state machine. + */ +export interface PrLifetime extends LifetimeState { + readonly kind: "draft" | "open" | "review-requested" | "approved" | "merged" | "closed"; +} + +/** + * Review-thread lifetime (GitHub specialization). + */ +export interface ReviewThreadLifetime extends LifetimeState { + readonly kind: "unresolved" | "outdated" | "resolved"; +} + +/** + * GitHub resource-allocation substrate (forge-specific). + * + * Per the human maintainer (2026-05-28) 'REST/graphql enhancements/ + * optimizations/resource allocations'. GitHub has 2 distinct rate-limit + * budgets per token: REST (resources.core) + GraphQL (resources.graphql). + * Both 5000/hour for authenticated requests. + */ +export interface GitHubResourceBudget { + readonly restCoreRemaining: number; + readonly restCoreLimit: number; // typically 5000 + readonly restCoreResetAt: number; // unix timestamp + readonly graphqlRemaining: number; + readonly graphqlLimit: number; // typically 5000 + readonly graphqlResetAt: number; +} + +/** + * Operational tier per the framework's rate-limit-tier substrate (per + * .claude/rules/refresh-world-model-poll-pr-gate.md). + */ +export type RateLimitTier = + | "normal" // > 2000 remaining + | "cost-aware" // 1000-2000 + | "extreme-cost-aware" // 200-1000 + | "pure-git"; // 0-200 + +/** + * Compute rate-limit tier from current GitHub resource budget. + * + * Per framework's tier table; substrate-engineering substrate-honest + * naming preserved. + */ +export function rateLimitTier(remaining: number): RateLimitTier { + if (remaining > 2000) return "normal"; + if (remaining > 1000) return "cost-aware"; + if (remaining > 200) return "extreme-cost-aware"; + return "pure-git"; +} + +/** + * GitHubWorld — specialization of GitWorld for GitHub forge. + * + * Inherits all GitWorld substrate + adds: + * - PR substrate (PrLifetime) + * - Review-thread substrate (ReviewThreadLifetime) + * - Resource-allocation substrate (REST/GraphQL budgets + tier) + * - GitHub-specific optimizations (auto-merge, merge-queue, etc.) + */ +export interface GitHubWorld extends GitWorld { + readonly forgeName: "git"; // inherits GitWorld base + readonly forgeSpecialization: "github"; + readonly prUniverse: ReadonlyArray; + readonly reviewThreadUniverse: ReadonlyArray; + readonly resourceBudget?: GitHubResourceBudget; // optional; populated by caller +} + +/** + * Build the GitHubWorld substrate from a base GitWorld. + * + * Adds PR + review-thread universes + optional resource-budget. Caller + * registers any lifetime-pair matrices needed for the substrate- + * engineering work. + */ +export function buildGitHubWorld( + gitWorld: GitWorld, + resourceBudget?: GitHubResourceBudget, +): GitHubWorld { + return { + ...gitWorld, + forgeSpecialization: "github", + prUniverse: [ + { kind: "draft" }, + { kind: "open" }, + { kind: "review-requested" }, + { kind: "approved" }, + { kind: "merged" }, + { kind: "closed" }, + ], + reviewThreadUniverse: [ + { kind: "unresolved" }, + { kind: "outdated" }, + { kind: "resolved" }, + ], + ...(resourceBudget !== undefined && { resourceBudget }), + }; +} + +/** + * Forge-specialization feedback per asymmetric-authorship rule. + */ +export type ForgeSpecializationFeedback = + | { kind: "UnsupportedForge"; forge: string } + | { kind: "ResourceBudgetExhausted"; budget: "rest-core" | "graphql"; resetAt: number }; + +export type ForgeResult = + | { ok: true; world: T } + | { ok: false; feedback: ForgeSpecializationFeedback }; + +/** + * Check if a GitHub operation is within budget. + * + * Per framework's rate-limit tier substrate: pure-git tier means GraphQL + * is exhausted; substrate should defer GraphQL operations + use pure-git + * substrate (commit/push/local) until reset. + */ +export interface OperationCost { + readonly restCoreCost?: number; // default 0 + readonly graphqlCost?: number; // default 0 +} + +export function canAfford( + world: GitHubWorld, + cost: OperationCost, +): ForgeResult { + const budget = world.resourceBudget; + if (!budget) { + // No budget loaded; assume operation can proceed (caller loads budget + // explicitly when discipline matters) + return { ok: true, world }; + } + const restCost = cost.restCoreCost ?? 0; + const graphqlCost = cost.graphqlCost ?? 0; + if (restCost > budget.restCoreRemaining) { + return { + ok: false, + feedback: { + kind: "ResourceBudgetExhausted", + budget: "rest-core", + resetAt: budget.restCoreResetAt, + }, + }; + } + if (graphqlCost > budget.graphqlRemaining) { + return { + ok: false, + feedback: { + kind: "ResourceBudgetExhausted", + budget: "graphql", + resetAt: budget.graphqlResetAt, + }, + }; + } + return { ok: true, world }; +} + +/** + * Convenience: register a lifetime pair in the GitHubWorld (inherits + * registerLifetimePair from base World). + * + * Returns NEW GitHubWorld with the pair registered (immutable substrate). + * + * Since registerLifetimePair is now generic over the World subtype + * (it returns the input world's type with subclass fields preserved), + * this helper simply delegates and lets the generic propagate + * GitHubWorld through. + */ +export function registerInGitHub< + A extends LifetimeState, + B extends LifetimeState, + T, +>( + world: GitHubWorld, + pairName: string, + matrix: ReadonlyMap, T>, +): GitHubWorld { + return registerLifetimePair(world, pairName, matrix); +} + +/** + * Reusable PR lifetime universe export (for caller composition). + */ +export const GITHUB_PR_UNIVERSE: ReadonlyArray = [ + { kind: "draft" }, + { kind: "open" }, + { kind: "review-requested" }, + { kind: "approved" }, + { kind: "merged" }, + { kind: "closed" }, +]; + +/** + * Reusable review-thread universe. + */ +export const GITHUB_REVIEW_THREAD_UNIVERSE: ReadonlyArray = [ + { kind: "unresolved" }, + { kind: "outdated" }, + { kind: "resolved" }, +]; + +/** + * Reusable verdict for "must resolve threads before merge" pattern. + * Used in GitHub's required_conversation_resolution branch protection. + */ +export const REQUIRE_RESOLVED_VERDICT: StandardVerdict = { + kind: "block", + reason: "GitHub required_conversation_resolution: unresolved threads block merge", +}; diff --git a/tools/workflow-engine/world.test.ts b/tools/workflow-engine/world.test.ts new file mode 100644 index 0000000000..6f8361e7a3 --- /dev/null +++ b/tools/workflow-engine/world.test.ts @@ -0,0 +1,257 @@ +/** + * tools/workflow-engine/world.test.ts + * + * Invariant tests for world substrate + reusable lifetime composition helpers. + */ + +import { describe, expect, it } from "bun:test"; +import { + EMPTY_WORLD, + defaultAdvanceMatrix, + dispatchInWorld, + lookupLifetimePair, + predicateMatrix, + registerLifetimePair, + terminalMatrix, + type ComposedKey, + type LifetimeState, + type StandardVerdict, +} from "./world"; + +interface WorkflowLifetime extends LifetimeState { + readonly kind: "draft" | "submitted" | "approved"; +} + +interface ReviewLifetime extends LifetimeState { + readonly kind: "pending" | "in-review" | "merged"; +} + +const workflowUniverse: WorkflowLifetime[] = [ + { kind: "draft" }, + { kind: "submitted" }, + { kind: "approved" }, +]; + +const reviewUniverse: ReviewLifetime[] = [ + { kind: "pending" }, + { kind: "in-review" }, + { kind: "merged" }, +]; + +describe("world substrate + reusable lifetime composition helpers", () => { + it("EMPTY_WORLD has zero registered pairs", () => { + expect(EMPTY_WORLD.registry.size).toBe(0); + }); + + it("registerLifetimePair: returns new world with pair registered", () => { + const matrix = new Map, StandardVerdict>([ + ["draft:pending", { kind: "advance" }], + ]); + const world = registerLifetimePair( + EMPTY_WORLD, + "workflow-review", + matrix, + ); + expect(world.registry.size).toBe(1); + expect(world.registry.has("workflow-review")).toBe(true); + // Immutable: original unchanged + expect(EMPTY_WORLD.registry.size).toBe(0); + }); + + it("lookupLifetimePair: returns matrix when registered", () => { + const matrix = new Map, StandardVerdict>([ + ["draft:pending", { kind: "advance" }], + ]); + const world = registerLifetimePair( + EMPTY_WORLD, + "workflow-review", + matrix, + ); + const found = lookupLifetimePair( + world, + "workflow-review", + ); + expect(found).toBeDefined(); + expect(found?.get("draft:pending")?.kind).toBe("advance"); + }); + + it("lookupLifetimePair: undefined for unregistered pair", () => { + const found = lookupLifetimePair( + EMPTY_WORLD, + "nonexistent", + ); + expect(found).toBeUndefined(); + }); + + it("defaultAdvanceMatrix: every-cell defaults to advance", () => { + const matrix = defaultAdvanceMatrix(workflowUniverse, reviewUniverse); + expect(matrix.size).toBe(9); // 3 × 3 + for (const verdict of matrix.values()) { + expect(verdict.kind).toBe("advance"); + } + }); + + it("defaultAdvanceMatrix: overrides applied at specific cells", () => { + const overrides = new Map, StandardVerdict>([ + ["draft:in-review", { kind: "block", reason: "can't review draft" }], + ["approved:merged", { kind: "complete" }], + ]); + const matrix = defaultAdvanceMatrix(workflowUniverse, reviewUniverse, overrides); + expect(matrix.size).toBe(9); + expect(matrix.get("draft:in-review")?.kind).toBe("block"); + expect(matrix.get("approved:merged")?.kind).toBe("complete"); + expect(matrix.get("draft:pending")?.kind).toBe("advance"); // not overridden + }); + + it("terminalMatrix: terminal cell is complete; other cells from terminal A are block", () => { + const matrix = terminalMatrix( + workflowUniverse, + reviewUniverse, + { kind: "approved" } as WorkflowLifetime, + { kind: "merged" } as ReviewLifetime, + ); + expect(matrix.get("approved:merged")?.kind).toBe("complete"); + expect(matrix.get("approved:pending")?.kind).toBe("block"); + expect(matrix.get("approved:in-review")?.kind).toBe("block"); + // Non-terminal A cells default to advance + expect(matrix.get("draft:pending")?.kind).toBe("advance"); + }); + + it("terminalMatrix: custom block reason", () => { + const matrix = terminalMatrix( + workflowUniverse, + reviewUniverse, + { kind: "approved" } as WorkflowLifetime, + { kind: "merged" } as ReviewLifetime, + "approval is terminal", + ); + const blocked = matrix.get("approved:pending"); + if (blocked?.kind === "block") { + expect(blocked.reason).toBe("approval is terminal"); + } else { + throw new Error("expected block verdict"); + } + }); + + it("predicateMatrix: dispatches via caller-supplied predicate", () => { + const matrix = predicateMatrix(workflowUniverse, reviewUniverse, (a, b): StandardVerdict => { + if (a.kind === "draft" && b.kind !== "pending") { + return { kind: "block", reason: "draft only valid with pending review" }; + } + if (a.kind === "approved" && b.kind === "merged") { + return { kind: "complete" }; + } + return { kind: "advance" }; + }); + expect(matrix.size).toBe(9); + expect(matrix.get("draft:in-review")?.kind).toBe("block"); + expect(matrix.get("approved:merged")?.kind).toBe("complete"); + expect(matrix.get("submitted:in-review")?.kind).toBe("advance"); + }); + + it("dispatchInWorld: looks up registered pair + dispatches", () => { + const matrix = defaultAdvanceMatrix(workflowUniverse, reviewUniverse); + const world = registerLifetimePair( + EMPTY_WORLD, + "workflow-review", + matrix, + ); + const result = dispatchInWorld( + world, + "workflow-review", + { kind: "draft" } as WorkflowLifetime, + { kind: "pending" } as ReviewLifetime, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect((result as { ok: true; verdict: StandardVerdict; fromKey: string }).verdict.kind).toBe("advance"); + }); + + it("dispatchInWorld: unregistered pair returns UnregisteredPair", () => { + const result = dispatchInWorld( + EMPTY_WORLD, + "missing-pair", + { kind: "draft" } as WorkflowLifetime, + { kind: "pending" } as ReviewLifetime, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + // UnregisteredPair has its own kind + if ("feedback" in result && "kind" in result.feedback && result.feedback.kind === "UnregisteredPair") { + expect(result.feedback.pairName).toBe("missing-pair"); + } else { + throw new Error("expected UnregisteredPair feedback"); + } + }); + + it("StandardVerdict exhaustive switch (compile-time check)", () => { + const acknowledge = (v: StandardVerdict): string => { + switch (v.kind) { + case "advance": + case "block": + case "complete": + case "no-op": + case "escalate-to-operator": + return v.kind; + } + }; + expect(acknowledge({ kind: "advance" })).toBe("advance"); + expect(acknowledge({ kind: "block", reason: "x" })).toBe("block"); + expect(acknowledge({ kind: "complete" })).toBe("complete"); + expect(acknowledge({ kind: "no-op" })).toBe("no-op"); + expect(acknowledge({ kind: "escalate-to-operator", reason: "x" })).toBe("escalate-to-operator"); + }); + + it("substrate-engineering reusability test: workflow-review world built with helpers (no per-cell custom code)", () => { + // Showcase: full 9-transition matrix built with predicateMatrix helper + // (no per-cell custom code; recurring pattern factored into predicate) + const matrix = predicateMatrix(workflowUniverse, reviewUniverse, (a, b): StandardVerdict => { + if (a.kind === "approved" && b.kind === "merged") return { kind: "complete" }; + if (a.kind === "draft" && b.kind !== "pending") return { kind: "block", reason: "draft+non-pending" }; + if (a.kind === "submitted" && b.kind === "merged") return { kind: "block", reason: "not approved" }; + return { kind: "advance" }; + }); + const world = registerLifetimePair( + EMPTY_WORLD, + "workflow-review", + matrix, + ); + + // Test multiple dispatch lookups + const advanceResult = dispatchInWorld( + world, "workflow-review", + { kind: "submitted" } as WorkflowLifetime, { kind: "in-review" } as ReviewLifetime, + ); + expect(advanceResult.ok).toBe(true); + + const completeResult = dispatchInWorld( + world, "workflow-review", + { kind: "approved" } as WorkflowLifetime, { kind: "merged" } as ReviewLifetime, + ); + expect(completeResult.ok).toBe(true); + }); + + it("multiple lifetime pairs registered in single world (workflow-review + workflow-encryption)", () => { + interface EncryptionLifetime extends LifetimeState { + readonly kind: "plain" | "encrypted" | "sealed"; + } + const encryptionUniverse: EncryptionLifetime[] = [ + { kind: "plain" }, { kind: "encrypted" }, { kind: "sealed" }, + ]; + + const wrMatrix = defaultAdvanceMatrix(workflowUniverse, reviewUniverse); + const weMatrix = defaultAdvanceMatrix(workflowUniverse, encryptionUniverse); + + let world = EMPTY_WORLD; + world = registerLifetimePair( + world, "workflow-review", wrMatrix, + ); + world = registerLifetimePair( + world, "workflow-encryption", weMatrix, + ); + + expect(world.registry.size).toBe(2); + expect(world.registry.has("workflow-review")).toBe(true); + expect(world.registry.has("workflow-encryption")).toBe(true); + }); +}); diff --git a/tools/workflow-engine/world.ts b/tools/workflow-engine/world.ts new file mode 100644 index 0000000000..ade73d0d4b --- /dev/null +++ b/tools/workflow-engine/world.ts @@ -0,0 +1,271 @@ +/** + * tools/workflow-engine/world.ts + * + * World substrate + reusable lifetime composition helpers. + * + * Per the human maintainer (2026-05-28) two substantive substrate-engineering + * questions: + * 1. 'do you have to write custom code everytime you compose two lifetimes' + * 2. '(do we still call the shared git flow a lifetime or world or + * shared space?)' + * + * Substrate-engineering naming substrate (acknowledged by the human + * maintainer): use 'WORLD' for the shared git-flow substrate where + * multiple lifetimes interact. Different scope from 'lifetime' + * (per-substrate-entity DU); world contains lifetimes; lifetime + * composition happens IN the world. + * + * Substrate-engineering substrate-reusability substrate (answers Q1): + * dispatch substrate is REUSABLE; only the matrix is per-pair. This file + * ships reusable helpers + matrix-builder substrate that factors + * recurring transition patterns (advance/block/complete) so caller + * doesn't write custom code per lifetime pair when patterns recur. + * + * Composes with: + * - tools/workflow-engine/composed-lifetime.ts (PR #5771) — base dispatch + * - B-0422 Clifford-algebraic narrative engine for Pauli-symmetry- + * breaking-from-agenda-conservation prediction (civ-sim / game-world + * substrate-engineering target this world substrate composes with) + * - B-0867 workflow engine (multiple lifetimes interact in workflow world) + * - 13th-ferry §33.7 multi-AI cascade (each AI inhabits the world) + * - .claude/rules/additive-not-zero-sum (world substrate compounds) + * - .claude/rules/honor-those-that-came-before (lifetime variants + * preserved in world substrate when adding new ones) + * - .claude/rules/monad-propagation-pattern (Result) + * + * Naming canon (the human maintainer, 2026-05-28): + * - LIFETIME = editable per-substrate-entity DU (per the human + * maintainer's earlier 'lifetime not lifecycle because you can edit + * it FYI the DUs') + * - WORLD = shared substrate where multiple lifetimes interact + * - GIT FLOW = operational form of the world (substrate-engineering + * substrate the world IS realized as) + */ + +import { + buildComposedMatrix, + composeFromDispatcher, + composeKey, + dispatchComposed, + type ComposedKey, + type ComposedLifetimeContext, + type LifetimeState, + type TransitionResult, +} from "./composed-lifetime"; + +// Re-export the composed-lifetime substrate as part of the world substrate +// (callers compose with world.ts for both naming + helpers). +export { + buildComposedMatrix, + composeFromDispatcher, + composeKey, + dispatchComposed, + type ComposedKey, + type ComposedLifetimeContext, + type LifetimeState, + type TransitionResult, +}; + +/** + * World — the shared substrate where multiple lifetimes interact. + * + * Per the human maintainer (2026-05-28) naming substrate-engineering: + * 'world' is the shared git-flow substrate. Different scope from + * per-substrate-entity lifetimes; world contains lifetimes + their + * composition rules. + * + * Generic over the named lifetimes the world contains. PoC scope: + * world holds a registry of composed-lifetime matrices keyed by + * lifetime-pair name. Caller registers matrices when introducing new + * lifetime pairs; dispatch lookups go through the world's registry. + */ +export interface World { + readonly registry: ReadonlyMap>; +} + +/** + * Empty world — no lifetime pairs registered. + */ +export const EMPTY_WORLD: World = { + registry: new Map(), +}; + +/** + * Standard transition verdict — used across MANY lifetime pairs. + * + * Substrate-engineering substrate-honesty: the recurring verbs are + * advance / block / complete / no-op / escalate. This discriminated + * union factors out the recurring vocabulary so per-pair matrices + * reuse it instead of inventing parallel verdict types. + * + * Per the human maintainer (2026-05-28) substrate-engineering question + * 'do you have to write custom code everytime' — NO; this StandardVerdict + * factors out the recurring substrate so most lifetime pairs reuse it. + */ +export type StandardVerdict = + | { kind: "advance" } + | { kind: "block"; reason: string } + | { kind: "complete" } + | { kind: "no-op" } + | { kind: "escalate-to-operator"; reason: string }; + +/** + * Register a composed-lifetime matrix in the world. + * + * Returns a NEW world (immutable substrate per asymmetric-authorship); + * world authors its own substrate via consent-channel. + * + * Generic over `W extends World` so callers passing a specialized + * subclass (GitWorld / GitHubWorld / GitLabWorld / etc.) receive the + * SAME specialized type back with subclass fields (forgeName, + * branchUniverse, prUniverse, etc.) preserved. Returning a bare + * `World` here would silently drop those fields under structural + * typing — caller would see them disappear despite the function + * signature claiming a `World` round-trip. + */ +export function registerLifetimePair< + W extends World, + A extends LifetimeState, + B extends LifetimeState, + T, +>( + world: W, + pairName: string, + matrix: ReadonlyMap, T>, +): W { + const newRegistry = new Map(world.registry); + newRegistry.set(pairName, matrix as ReadonlyMap); + return { ...world, registry: newRegistry }; +} + +/** + * Look up a composed-lifetime matrix by pair name. + * + * Returns undefined if pair not registered. + */ +export function lookupLifetimePair< + A extends LifetimeState, + B extends LifetimeState, + T, +>( + world: World, + pairName: string, +): ReadonlyMap, T> | undefined { + return world.registry.get(pairName) as ReadonlyMap, T> | undefined; +} + +/** + * Reusable matrix builder: every-pair → advance. + * + * Caller often wants the common case of "all valid transitions advance; + * the matrix surfaces only the EXCEPTIONS (block / complete / no-op)." + * This helper builds the advance-by-default matrix; caller overrides + * specific cells with block/complete as needed. + * + * Substrate-engineering substrate-reusability: factors out the recurring + * "every-cell-defaults-to-advance" pattern so caller doesn't write the + * cross-product manually. + */ +export function defaultAdvanceMatrix< + A extends LifetimeState, + B extends LifetimeState, +>( + universeA: ReadonlyArray, + universeB: ReadonlyArray, + overrides?: ReadonlyMap, StandardVerdict>, +): ReadonlyMap, StandardVerdict> { + const result = new Map, StandardVerdict>(); + for (const a of universeA) { + for (const b of universeB) { + const key = composeKey(a, b); + const override = overrides?.get(key); + result.set(key, override ?? { kind: "advance" }); + } + } + return result; +} + +/** + * Reusable matrix builder: terminal state at specific cell. + * + * Substrate-engineering shortcut: when a single (A, B) cell signals + * "the lifetime composition terminates here with `complete` verdict", + * this builds the matrix from defaults + the terminal cell. + */ +export function terminalMatrix< + A extends LifetimeState, + B extends LifetimeState, +>( + universeA: ReadonlyArray, + universeB: ReadonlyArray, + terminalA: A, + terminalB: B, + blockReason?: string, +): ReadonlyMap, StandardVerdict> { + const overrides = new Map, StandardVerdict>(); + overrides.set(composeKey(terminalA, terminalB), { kind: "complete" }); + // Block all OTHER cells where A is at the terminal state (can't go elsewhere) + for (const b of universeB) { + if (b.kind !== terminalB.kind) { + const key = composeKey(terminalA, b); + overrides.set(key, { + kind: "block", + reason: blockReason ?? `terminal A=${terminalA.kind}; can't transition B≠${terminalB.kind}`, + }); + } + } + return defaultAdvanceMatrix(universeA, universeB, overrides); +} + +/** + * Reusable matrix builder: gate matrix from a predicate. + * + * Most general helper: dispatch verdict per-cell via predicate. Used + * when transitions follow a SIMPLE PATTERN expressible in code rather + * than enumerated by hand. + * + * Composes with composeFromDispatcher; this is the StandardVerdict-typed + * specialization. + */ +export function predicateMatrix< + A extends LifetimeState, + B extends LifetimeState, +>( + universeA: ReadonlyArray, + universeB: ReadonlyArray, + predicate: (a: A, b: B) => StandardVerdict, +): ReadonlyMap, StandardVerdict> { + const result = new Map, StandardVerdict>(); + for (const a of universeA) { + for (const b of universeB) { + const verdict = predicate(a, b); + result.set(composeKey(a, b), verdict); + } + } + return result; +} + +/** + * World-level dispatch: look up the matrix by pair name + dispatch + * the composed transition. + * + * Per asymmetric-authorship: substrate-entity (the world) authors what + * lifetime pairs it knows about; caller acknowledges by registering + * pairs before dispatch. + */ +export function dispatchInWorld< + A extends LifetimeState, + B extends LifetimeState, + T, +>( + world: World, + pairName: string, + a: A, + b: B, +): TransitionResult | { ok: false; feedback: { kind: "UnregisteredPair"; pairName: string } } { + const matrix = lookupLifetimePair(world, pairName); + if (matrix === undefined) { + return { ok: false, feedback: { kind: "UnregisteredPair", pairName } }; + } + return dispatchComposed({ matrix }, a, b); +}