diff --git a/src/mission-view.test.ts b/src/mission-view.test.ts
new file mode 100644
index 0000000..454d0b2
--- /dev/null
+++ b/src/mission-view.test.ts
@@ -0,0 +1,303 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { launchNext } from "./coordination.js";
+import type { CoordinationChild, CoordinationRecord } from "./coordination.js";
+import { CHANGE_EVENT } from "./plan.js";
+import { coordinationFacts, coordinationMissionView, missionViewEnabled, missionViewFor, NO_FACTS } from "./mission-view.js";
+import type { CoordinationFacts } from "./mission-view.js";
+import type { ShipRuntime } from "./runtime.js";
+
+const A = "a".repeat(40);
+const A2 = "b".repeat(40);
+const C = "c".repeat(40);
+const API = "https://forge.example/team/api.git";
+const CLIENT = "https://forge.example/team/client.git";
+const NOW = "2026-10-04T10:00:00.000Z";
+
+function child(repo: string, patch: Partial = {}): CoordinationChild {
+ return { repo, state: "pending", attempts: 0, ...patch };
+}
+function record(api: Partial, client: Partial, extra: Partial = {}): CoordinationRecord {
+ return {
+ id: "coord-fixture",
+ parentIntent: "add /v2/quotes and surface it in the app",
+ model: "m",
+ api: child(API, api),
+ client: child(CLIENT, client),
+ createdAt: NOW,
+ updatedAt: NOW,
+ ...extra,
+ };
+}
+const facts = (api: CoordinationFacts["api"], client: CoordinationFacts["client"] = { acceptance: "not-recorded" }, plannedOn?: string): CoordinationFacts => ({
+ api,
+ client,
+ ...(plannedOn !== undefined ? { plannedOn } : {}),
+});
+const accepted = { acceptance: "accepted" as const };
+
+// ---- recorded fixtures (shapes a real record takes at each stage) ----
+
+const fresh = () => record({}, {});
+const apiRunning = () => record({ state: "running", attempts: 1, runId: "run-coord-api1" }, {});
+const apiMergedUnaccepted = () => record({ state: "merged", attempts: 1, runId: "run-coord-api1", anchorSha: A, mergedSha: A }, {});
+const apiFailedHold = () =>
+ record(
+ { state: "failed", attempts: 1, runId: "run-coord-api1", failReason: "the run failed" },
+ { state: "held", holdReason: "The API change failed before merging (the run failed)." },
+ );
+const clientRunning = () =>
+ record({ state: "delivered", attempts: 1, runId: "r-api", anchorSha: A, mergedSha: A }, { state: "running", attempts: 1, runId: "r-client", anchorSha: A });
+const bothMerged = (clientCheck?: CoordinationChild["clientCheck"], extra: Partial = {}) =>
+ record(
+ { state: "delivered", attempts: 1, runId: "r-api", anchorSha: A, mergedSha: A },
+ { state: "merged", attempts: 1, runId: "r-client", anchorSha: C, mergedSha: C, ...(clientCheck !== undefined ? { clientCheck } : {}), ...extra },
+ );
+
+test("default flag: off unless exactly SHIP_MISSION_VIEW=on", () => {
+ assert.equal(missionViewEnabled({}), false);
+ assert.equal(missionViewEnabled({ SHIP_MISSION_VIEW: "off" }), false);
+ assert.equal(missionViewEnabled({ SHIP_MISSION_VIEW: "1" }), false);
+ assert.equal(missionViewEnabled({ SHIP_MISSION_VIEW: " ON " }), true);
+});
+
+test("a fresh pair: only the api node is ready; the client waits on an unaccepted api", () => {
+ const v = coordinationMissionView(fresh());
+ assert.deepEqual(v.ready, ["api"]);
+ assert.deepEqual(v.blocked, []);
+ assert.equal(v.verdict, "not-accepted");
+ const client = v.nodes.find((n) => n.id === "client")!;
+ assert.match(client.reasons.join(";"), /"api" is pending, not accepted/);
+});
+
+test("api running: nothing is ready", () => {
+ assert.deepEqual(coordinationMissionView(apiRunning()).ready, []);
+});
+
+test("api accepted at the anchor: the client becomes ready (view only, nothing launched)", () => {
+ const v = coordinationMissionView(apiMergedUnaccepted(), facts(accepted));
+ assert.deepEqual(v.ready, ["client"]);
+ assert.deepEqual(v.stale, []);
+ assert.equal(v.nodes.find((n) => n.id === "client")!.plannedOn, A);
+});
+
+test("NEGATIVE CONTROL: a finished (merged) but UNACCEPTED api child does not make the client ready or count toward acceptance", () => {
+ for (const acceptance of ["not-recorded", "pending", "superseded"] as const) {
+ const v = coordinationMissionView(apiMergedUnaccepted(), facts({ acceptance }));
+ assert.equal(v.nodes.find((n) => n.id === "api")!.state, "completed", acceptance);
+ assert.deepEqual(v.ready, [], acceptance);
+ assert.deepEqual(v.aggregate.covered, [], acceptance);
+ assert.ok(v.aggregate.unmet.includes("api-change"), acceptance);
+ assert.equal(v.verdict, "not-accepted", acceptance);
+ }
+});
+
+test("a rejected api child blocks the client, like a failure", () => {
+ const v = coordinationMissionView(apiMergedUnaccepted(), facts({ acceptance: "rejected" }));
+ assert.equal(v.nodes.find((n) => n.id === "api")!.state, "rejected");
+ assert.deepEqual(v.blocked, [{ id: "client", by: ["api"] }]);
+});
+
+test("failed-API hold: client is blocked by api, nothing ready", () => {
+ const v = coordinationMissionView(apiFailedHold());
+ assert.equal(v.nodes.find((n) => n.id === "api")!.state, "failed");
+ assert.deepEqual(v.blocked, [{ id: "client", by: ["api"] }]);
+ assert.deepEqual(v.ready, []);
+ assert.equal(v.verdict, "not-accepted");
+ assert.ok(v.notes.some((n) => /Client held: The API change failed/.test(n)));
+ assert.deepEqual(v.aggregate.openNodes.map((n) => n.state).sort(), ["blocked", "failed"]);
+});
+
+test("a retry that returns the api to pending clears the block", () => {
+ const retried = record({ state: "pending", attempts: 1 }, { state: "pending" });
+ const v = coordinationMissionView(retried);
+ assert.deepEqual(v.blocked, []);
+ assert.deepEqual(v.ready, ["api"]);
+});
+
+test("full pair accepted with a compatible check: aggregate accepted", () => {
+ const v = coordinationMissionView(bothMerged("compatible"), facts(accepted, accepted, A));
+ assert.equal(v.aggregate.accepted, true);
+ assert.equal(v.verdict, "accepted");
+ assert.deepEqual(v.aggregate.unmet, []);
+ assert.deepEqual(v.stale, []);
+});
+
+test("NEGATIVE CONTROL: both children merged and compatible but the client has no recorded acceptance => not accepted", () => {
+ const v = coordinationMissionView(bothMerged("compatible"), facts(accepted, { acceptance: "not-recorded" }, A));
+ assert.equal(v.aggregate.accepted, false);
+ assert.equal(v.verdict, "not-accepted");
+ assert.ok(v.aggregate.unmet.includes("client-change"));
+});
+
+test("compatibility not yet compatible (running/incompatible without accept-risk) => requirement unmet", () => {
+ for (const check of ["running", "incompatible", "uncertain", "pending"] as const) {
+ const v = coordinationMissionView(bothMerged(check), facts(accepted, accepted, A));
+ assert.ok(v.aggregate.unmet.includes("compatibility"), check);
+ assert.equal(v.verdict, "not-accepted", check);
+ }
+});
+
+test("waiver: accept-risk over an incompatible check is WAIVED, never accepted", () => {
+ const rec = bothMerged("incompatible", { checkAccepted: { by: "user-9", at: NOW } });
+ const v = coordinationMissionView(rec, facts(accepted, accepted, A));
+ assert.equal(v.verdict, "waived");
+ assert.equal(v.aggregate.accepted, false);
+ assert.deepEqual(v.aggregate.waived, [{ requirement: "compatibility", actor: "user-9", reason: "accepted the risk of an incompatible compatibility check" }]);
+ assert.deepEqual(v.aggregate.unmet, []);
+});
+
+test("waiver without a named actor is invalid and does not waive", () => {
+ const rec = bothMerged("uncertain");
+ const v = coordinationMissionView(rec, facts(accepted, accepted, A), { waivers: [{ requirementId: "compatibility", actor: " ", reason: "ship it" }] });
+ assert.equal(v.verdict, "not-accepted");
+ assert.deepEqual(v.aggregate.invalidWaivers, ["compatibility"]);
+});
+
+test("stale revision: the delivery record's merge sha differs from the anchor the client was built on", () => {
+ // The coordination anchored the client on A; the delivery record says the api merged B.
+ const v = coordinationMissionView(bothMerged("compatible"), facts({ acceptance: "accepted", deliverySha: A2 }, accepted));
+ assert.deepEqual(v.stale, ["client"]);
+ assert.deepEqual(v.aggregate.staleNodes, ["client"]);
+ assert.equal(v.verdict, "not-accepted");
+ assert.equal(v.nodes.find((n) => n.id === "api")!.revision, A2);
+ // Stale accepted work covers nothing.
+ assert.ok(v.aggregate.unmet.includes("client-change"));
+});
+
+test("stale revision on a pending client: not ready (stale, not satisfied, not missing)", () => {
+ // launchNext would launch the client on api.anchorSha (A); the delivery record says B.
+ const rec = record({ state: "delivered", attempts: 1, anchorSha: A, mergedSha: A, runId: "r-api" }, { state: "pending" });
+ const v = coordinationMissionView(rec, facts({ acceptance: "accepted", deliverySha: A2 }));
+ assert.deepEqual(v.ready, []);
+ assert.deepEqual(v.stale, ["client"]);
+ assert.match(v.nodes.find((n) => n.id === "client")!.reasons.join(";"), /stale/);
+ // Control: the same record with a matching delivery sha is ready.
+ assert.deepEqual(coordinationMissionView(rec, facts({ acceptance: "accepted", deliverySha: A })).ready, ["client"]);
+ // A running client planned on A against a moved api is flagged stale too.
+ const running = coordinationMissionView(clientRunning(), facts({ acceptance: "accepted", deliverySha: A2 }));
+ assert.deepEqual(running.stale, ["client"]);
+});
+
+test("a merge recorded without a sha has no deliverable: it is not completed, accepted or ready-making", () => {
+ const rec = record({ state: "merged", attempts: 1, runId: "r-api" }, { state: "held", holdReason: "no anchor" });
+ const v = coordinationMissionView(rec, facts(accepted));
+ assert.equal(v.nodes.find((n) => n.id === "api")!.state, "running");
+ assert.deepEqual(v.ready, []);
+ assert.ok(v.notes.some((n) => /without a recorded commit sha/.test(n)));
+});
+
+test("integration gate: declared+flag on => missing evidence blocks an otherwise accepted pair", () => {
+ const rec = { ...bothMerged("compatible"), integrationCheck: { command: "pnpm test:pair", evidence: [] } };
+ const on = coordinationMissionView(rec, facts(accepted, accepted, A), { env: { SHIP_INTEGRATION_CHECK: "on" } });
+ assert.equal(on.integration?.state, "missing");
+ assert.equal(on.aggregate.accepted, true); // the node aggregate alone is satisfied...
+ assert.equal(on.verdict, "not-accepted"); // ...the integration gate holds the combined verdict.
+ const off = coordinationMissionView(rec, facts(accepted, accepted, A), { env: {} });
+ assert.equal(off.integration, null);
+ assert.equal(off.verdict, "accepted");
+});
+
+test("integration gate: passing evidence at the exact pair heads satisfies it", () => {
+ const rec = {
+ ...bothMerged("compatible"),
+ integrationCheck: {
+ command: "pnpm test:pair",
+ evidence: [
+ {
+ class: "executed-pair" as const,
+ producer: { repo: API, sha: A },
+ consumer: { repo: CLIENT, sha: C },
+ command: "pnpm test:pair",
+ result: "passed" as const,
+ at: NOW,
+ },
+ ],
+ },
+ };
+ const v = coordinationMissionView(rec, facts(accepted, accepted, A), { env: { SHIP_INTEGRATION_CHECK: "on" } });
+ assert.equal(v.integration?.state, "satisfied");
+ assert.equal(v.verdict, "accepted");
+});
+
+test("the projection does not mutate the coordination record", () => {
+ const rec = bothMerged("compatible");
+ const before = JSON.stringify(rec);
+ coordinationMissionView(rec, facts(accepted, accepted, A));
+ assert.equal(JSON.stringify(rec), before);
+});
+
+// ---- real call path: stores -> facts -> view, and launchNext unaffected ----
+
+function ev(seq: number, type: string, name?: string, data?: unknown) {
+ return { v: 1, seq, type, at: NOW, ...(name !== undefined ? { name } : {}), ...(data !== undefined ? { data } : {}) };
+}
+function runtimeWith(events: Record, deliveries: Record): ShipRuntime {
+ return {
+ store: { load: async (id: string) => events[id] ?? [] },
+ deliveryRecords: { get: async (id: string) => deliveries[id] ?? null },
+ } as unknown as ShipRuntime;
+}
+const started = (extra: object = {}) => ev(1, "run-started", undefined, { input: { task: "t" }, ...extra });
+const approve = (approved: boolean) => [ev(2, "event-waiting", CHANGE_EVENT), ev(3, "event-received", CHANGE_EVENT, { payload: { approved, by: "user-7" } })];
+
+test("facts read from the stores: recorded change decision => accepted; delivery sha is carried", async () => {
+ const rec = bothMerged("compatible");
+ const rt = runtimeWith(
+ { "r-api": [started(), ...approve(true)], "r-client": [started(), ...approve(true)] },
+ { "r-api": { state: "confirmed", mergedSha: A }, "r-client": { state: "confirmed", mergedSha: C } },
+ );
+ const f = await coordinationFacts(rt, rec);
+ assert.deepEqual(f.api, { acceptance: "accepted", deliverySha: A });
+ assert.deepEqual(f.client, { acceptance: "accepted", deliverySha: C });
+ const out = await missionViewFor(rt, rec, {});
+ assert.ok("view" in out);
+ assert.equal(out.view.verdict, "accepted");
+});
+
+test("NEGATIVE CONTROL (real path): a confirmed delivery without a recorded decision is NOT acceptance", async () => {
+ const rec = bothMerged("compatible");
+ const rt = runtimeWith(
+ { "r-api": [started(), ev(2, "run-completed", undefined, { output: { status: "finished" } })], "r-client": [started(), ...approve(true)] },
+ { "r-api": { state: "confirmed", mergedSha: A }, "r-client": { state: "confirmed", mergedSha: C } },
+ );
+ const out = await missionViewFor(rt, rec, {});
+ assert.ok("view" in out);
+ assert.equal(out.view.nodes.find((n) => n.id === "api")!.state, "completed");
+ assert.equal(out.view.verdict, "not-accepted");
+});
+
+test("a denied decision is rejected and blocks the client; an unreadable store is not-recorded, never accepted", async () => {
+ const rec = bothMerged("compatible");
+ const rt = runtimeWith({ "r-api": [started(), ...approve(false)], "r-client": [] }, {});
+ const out = await missionViewFor(rt, rec, {});
+ assert.ok("view" in out);
+ assert.equal(out.view.nodes.find((n) => n.id === "api")!.state, "rejected");
+ const broken = { store: { load: async () => { throw new Error("boom"); } } } as unknown as ShipRuntime;
+ assert.deepEqual((await coordinationFacts(broken, rec)).api, { acceptance: "not-recorded" });
+});
+
+test("launchNext on a failed-API coordination behaves as before with the view module loaded and used", async () => {
+ // Minimal in-memory runtime for the real launchNext path (api failed => client held, no enqueue).
+ const store = new Map();
+ const rec = record({ state: "failed", attempts: 1, runId: "r-api", failReason: "the run failed" }, { state: "pending" });
+ store.set("SHIP_COORDINATION_coord-fixture", JSON.stringify(rec));
+ const runtime = {
+ config: {
+ get: async (k: string) => store.get(k),
+ set: async (k: string, v: string) => void store.set(k, v),
+ },
+ store: { load: async () => [] },
+ loadMeta: async () => null,
+ deliveryRecords: { get: async () => null },
+ } as unknown as ShipRuntime;
+ const first = await launchNext(runtime, "coord-fixture");
+ assert.equal(first.launched, null);
+ assert.equal(first.record.client.state, "held");
+ const viewed = coordinationMissionView(first.record, NO_FACTS);
+ assert.deepEqual(viewed.blocked, [{ id: "client", by: ["api"] }]);
+ const second = await launchNext(runtime, "coord-fixture");
+ assert.equal(second.launched, null);
+ assert.equal(second.record.client.state, "held");
+});
diff --git a/src/mission-view.ts b/src/mission-view.ts
new file mode 100644
index 0000000..6458a95
--- /dev/null
+++ b/src/mission-view.ts
@@ -0,0 +1,307 @@
+/**
+ * S22 wiring (wave 4) — a READ-ONLY, derived "Mission view" of an existing
+ * coordination record, built with mission.ts. SHADOW ONLY: this module launches
+ * nothing, stores nothing and is never consulted by coordination.ts; the
+ * coordination's own states, launchNext idempotency and completion rule are
+ * untouched. The view answers "what would the general mission contract say
+ * about this pair?" so that contract can be compared with the fixed
+ * coordinator before anything is ever migrated onto it.
+ *
+ * THE PROJECTION (mission.ts header, "HOW THIS SUBSUMES coordination.ts"):
+ * nodes `api` and `client`, client.dependsOn = ["api"]
+ * inputRevisions client.inputRevisions.api = the API merge's sha, as the
+ * coordination recorded it (api.anchorSha) — what the client
+ * task was told to build against. The record stores only
+ * one anchor; a client that has merged has had its own
+ * anchorSha overwritten, so the planned-on revision for a
+ * launched client is client.anchorSha only while it is
+ * running/failed, and api.anchorSha otherwise. `plannedOn`
+ * in the facts overrides it when a caller has a better
+ * source.
+ * Deliverable type "patch"; revision = the DELIVERY RECORD's merge sha
+ * (an independent reading), else the child's own recorded
+ * merge sha. A delivery sha that differs from the anchor the
+ * client was planned on is a STALE input, as in rule 5.
+ * Outcome accepted ONLY from the task record's `acceptance === "accepted"`
+ * (task-record.ts: a recorded change decision on the latest
+ * attempt). A merged child with no recorded acceptance is
+ * `completed`, not `accepted`: "finished" never counts.
+ * failed-API hold api failed => client `blocked` (mission.ts settle()).
+ * waivers a recorded accept-risk over an incompatible/uncertain
+ * compatibility verdict is a WAIVER of the `compatibility`
+ * requirement (actor = who accepted), reported as waived,
+ * never as accepted.
+ * aggregate gate requirements api-change, client-change, compatibility,
+ * plus integration-evidence's status (via integrationGate,
+ * so SHIP_INTEGRATION_CHECK and the declaration decide
+ * whether it applies, exactly as coordinationComplete does).
+ *
+ * Flag: SHIP_MISSION_VIEW=on. Default off; the page renders nothing extra and
+ * this module is not called.
+ */
+import type { WorkflowEvent } from "@neutron-build/workflow";
+import { integrationGate } from "./coordination.js";
+import type { CoordinationChild, CoordinationRecord } from "./coordination.js";
+import type { IntegrationStatus } from "./integration-evidence.js";
+import {
+ aggregateAcceptance,
+ createMission,
+ nodeReadiness,
+ readyNodes,
+} from "./mission.js";
+import type { AggregateResult, Mission, MissionNode, MissionSpec, NodeSpec, NodeState, Requirement, Waiver } from "./mission.js";
+import { taskRecord } from "./task-record.js";
+import type { AcceptanceState } from "./task-record.js";
+import type { ShipRuntime } from "./runtime.js";
+
+export function missionViewEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
+ return (env.SHIP_MISSION_VIEW ?? "").trim().toLowerCase() === "on";
+}
+
+/** What the task record and delivery record say about one child's latest run. */
+export interface ChildFacts {
+ /** task-record.ts acceptance of the child's latest attempt. */
+ acceptance: AcceptanceState;
+ /** The delivery record's merged sha, when a record exists and carries one. */
+ deliverySha?: string;
+}
+
+export interface CoordinationFacts {
+ api: ChildFacts;
+ client: ChildFacts;
+ /** Overrides the planned-on API revision when the caller can state it. */
+ plannedOn?: string;
+}
+
+export const NO_FACTS: CoordinationFacts = { api: { acceptance: "not-recorded" }, client: { acceptance: "not-recorded" } };
+
+const REQUIREMENTS: readonly Requirement[] = [
+ { id: "api-change", deliverableType: "patch" },
+ { id: "client-change", deliverableType: "patch" },
+ { id: "compatibility" },
+];
+
+const ZERO = { costCents: 0, steps: 0 };
+
+interface Built {
+ mission: Mission;
+ waivers: Waiver[];
+ notes: string[];
+}
+
+/** The child's own merge sha as the coordination recorded it. */
+function nonEmpty(sha: string | undefined): string | undefined {
+ return sha === undefined || sha === "" ? undefined : sha;
+}
+
+/** mergedSha when recorded, else the starter's anchorSha (the same fallback coordination.ts uses for the client). */
+function ownSha(child: CoordinationChild): string | undefined {
+ return nonEmpty(child.mergedSha ?? child.anchorSha);
+}
+
+function nodeStateOf(child: CoordinationChild, facts: ChildFacts, revision: string | undefined): NodeState {
+ switch (child.state) {
+ case "pending":
+ case "held":
+ return "pending";
+ case "running":
+ return "running";
+ case "failed":
+ return "failed";
+ case "merged":
+ case "delivered":
+ // Merged without a provable sha has no deliverable to accept; it stays
+ // `running` (unfinished as far as the contract can tell) and is noted.
+ if (revision === undefined) return "running";
+ if (facts.acceptance === "accepted") return "accepted";
+ if (facts.acceptance === "rejected") return "rejected";
+ return "completed";
+ }
+}
+
+export function missionSpecFromCoordination(
+ record: CoordinationRecord,
+ facts: CoordinationFacts = NO_FACTS,
+): { spec: MissionSpec; waivers: Waiver[]; notes: string[]; deliverables: Map } {
+ const notes: string[] = [];
+ const deliverables = new Map();
+ const merged = (c: CoordinationChild) => c.state === "merged" || c.state === "delivered";
+
+ const apiRevision = merged(record.api) ? (facts.api.deliverySha ?? ownSha(record.api)) : undefined;
+ const clientRevision = merged(record.client) ? (facts.client.deliverySha ?? ownSha(record.client)) : undefined;
+ if (merged(record.api) && apiRevision === undefined) notes.push("The API child merged without a recorded commit sha; its deliverable is unproven.");
+ if (merged(record.client) && clientRevision === undefined) notes.push("The client child merged without a recorded commit sha; its deliverable is unproven.");
+ if (apiRevision !== undefined) deliverables.set("api", { revision: apiRevision });
+ if (clientRevision !== undefined) deliverables.set("client", { revision: clientRevision });
+
+ // The revision the client was planned on (see the header).
+ const launchedAnchor = record.client.state === "running" || record.client.state === "failed" ? record.client.anchorSha : undefined;
+ const plannedOn = nonEmpty(facts.plannedOn) ?? nonEmpty(launchedAnchor) ?? nonEmpty(record.api.anchorSha) ?? apiRevision ?? "";
+
+ const clientCompatible = record.client.clientCheck === "compatible";
+ const nodes: NodeSpec[] = [
+ {
+ id: "api",
+ goal: `API change in ${record.api.repo}`,
+ deliverableType: "patch",
+ dependsOn: [],
+ inputRevisions: {},
+ authority: { grants: ["write:api"] },
+ budget: ZERO,
+ acceptance: { contract: "task record: recorded change decision accepted", covers: ["api-change"] },
+ state: nodeStateOf(record.api, facts.api, apiRevision),
+ attempts: record.api.attempts,
+ },
+ {
+ id: "client",
+ goal: `Client change in ${record.client.repo}`,
+ deliverableType: "patch",
+ dependsOn: ["api"],
+ inputRevisions: { api: plannedOn },
+ authority: { grants: ["write:client"] },
+ budget: ZERO,
+ acceptance: {
+ contract: "task record: recorded change decision accepted; compatibility check compatible",
+ covers: ["client-change", ...(clientCompatible ? ["compatibility"] : [])],
+ },
+ state: nodeStateOf(record.client, facts.client, clientRevision),
+ attempts: record.client.attempts,
+ },
+ ];
+
+ const waivers: Waiver[] = [];
+ const check = record.client.clientCheck;
+ if ((check === "incompatible" || check === "uncertain") && record.client.checkAccepted !== undefined) {
+ waivers.push({
+ requirementId: "compatibility",
+ actor: record.client.checkAccepted.by,
+ reason: `accepted the risk of an ${check} compatibility check`,
+ });
+ }
+ if (record.client.state === "held" && record.client.holdReason !== undefined) notes.push(`Client held: ${record.client.holdReason}`);
+
+ return {
+ spec: {
+ id: record.id,
+ goal: record.parentIntent,
+ authority: { grants: ["write:api", "write:client"] },
+ budget: ZERO,
+ nodes,
+ },
+ waivers,
+ notes,
+ deliverables,
+ };
+}
+
+/** Build the Mission: createMission settles blocked; deliverables/failures are attached afterwards (they never affect settle). */
+function build(record: CoordinationRecord, facts: CoordinationFacts): Built {
+ const { spec, waivers, notes, deliverables } = missionSpecFromCoordination(record, facts);
+ const created = createMission(spec);
+ const nodes: MissionNode[] = created.nodes.map((n) => {
+ const d = deliverables.get(n.id);
+ const child = n.id === "api" ? record.api : record.client;
+ const withDeliverable = d !== undefined && n.state !== "pending" && n.state !== "running" && n.state !== "failed" ? { ...n, deliverable: { type: "patch", revision: d.revision } } : n;
+ return n.state === "failed" ? { ...withDeliverable, failure: child.failReason ?? "failed" } : withDeliverable;
+ });
+ return { mission: { ...created, nodes }, waivers, notes };
+}
+
+export interface MissionNodeView {
+ id: string;
+ repo: string;
+ state: NodeState;
+ revision?: string;
+ plannedOn?: string;
+ blockedBy: string[];
+ /** nodeReadiness reasons while pending; empty otherwise. */
+ reasons: string[];
+}
+
+export interface MissionView {
+ missionId: string;
+ nodes: MissionNodeView[];
+ ready: string[];
+ blocked: Array<{ id: string; by: string[] }>;
+ /** Nodes whose planned-on API revision no longer equals the API deliverable's. */
+ stale: string[];
+ aggregate: AggregateResult;
+ integration: IntegrationStatus | null;
+ /** The combined answer: aggregate acceptance AND the integration gate. */
+ verdict: "accepted" | "waived" | "not-accepted";
+ notes: string[];
+}
+
+export function coordinationMissionView(
+ record: CoordinationRecord,
+ facts: CoordinationFacts = NO_FACTS,
+ options: { env?: NodeJS.ProcessEnv; waivers?: readonly Waiver[] } = {},
+): MissionView {
+ const { mission, waivers, notes } = build(record, facts);
+ const aggregate = aggregateAcceptance(mission, REQUIREMENTS, [...waivers, ...(options.waivers ?? [])]);
+ const integration = integrationGate(record, options.env !== undefined ? { env: options.env } : {});
+ const stale = mission.nodes
+ .filter((n) => n.dependsOn.some((d) => {
+ const dep = mission.nodes.find((x) => x.id === d);
+ return dep?.deliverable !== undefined && dep.deliverable.revision !== n.inputRevisions[d];
+ }))
+ .map((n) => n.id);
+ const verdict = aggregate.verdict === "not-accepted" || integration?.blocking === true ? "not-accepted" : aggregate.verdict;
+ return {
+ missionId: mission.id,
+ nodes: mission.nodes.map((n) => ({
+ id: n.id,
+ repo: n.id === "api" ? record.api.repo : record.client.repo,
+ state: n.state,
+ ...(n.deliverable !== undefined ? { revision: n.deliverable.revision } : {}),
+ ...(n.dependsOn.length > 0 ? { plannedOn: n.inputRevisions[n.dependsOn[0]!] } : {}),
+ blockedBy: [...(n.blockedBy ?? [])],
+ reasons: n.state === "pending" ? nodeReadiness(mission, n.id).reasons : [],
+ })),
+ ready: readyNodes(mission).map((n) => n.id),
+ blocked: mission.nodes.filter((n) => n.state === "blocked").map((n) => ({ id: n.id, by: [...(n.blockedBy ?? [])] })),
+ stale,
+ aggregate,
+ integration,
+ verdict,
+ notes,
+ };
+}
+
+/**
+ * Read the two facts the projection needs from the stores, READ-ONLY: the
+ * child's own latest run as a one-attempt task record (acceptance) and its
+ * delivery record (merge sha). A child with no run, or an unreadable one, gets
+ * "not-recorded" — unknown is never read as accepted.
+ */
+export async function coordinationFacts(runtime: ShipRuntime, record: CoordinationRecord): Promise {
+ const read = async (child: CoordinationChild): Promise => {
+ if (child.runId === undefined) return { acceptance: "not-recorded" };
+ try {
+ const events = (await runtime.store.load(child.runId)) as WorkflowEvent[];
+ const tr = taskRecord(child.runId, [{ meta: { runId: child.runId, task: "", status: "", model: "", createdAt: "", updatedAt: "" }, events }]);
+ const delivery = await runtime.deliveryRecords?.get(child.runId).catch(() => null);
+ return {
+ acceptance: tr.acceptance,
+ ...(delivery?.mergedSha !== undefined && delivery.mergedSha !== "" ? { deliverySha: delivery.mergedSha } : {}),
+ };
+ } catch {
+ return { acceptance: "not-recorded" };
+ }
+ };
+ const [api, client] = await Promise.all([read(record.api), read(record.client)]);
+ return { api, client };
+}
+
+/** The page loader's one call. Never throws: a failure costs the panel only. */
+export async function missionViewFor(
+ runtime: ShipRuntime,
+ record: CoordinationRecord,
+ env: NodeJS.ProcessEnv = process.env,
+): Promise<{ view: MissionView } | { error: string }> {
+ try {
+ return { view: coordinationMissionView(record, await coordinationFacts(runtime, record), { env }) };
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : "the mission view could not be built" };
+ }
+}
diff --git a/web/src/routes/coordination.tsx b/web/src/routes/coordination.tsx
index c503b6f..264816b 100644
--- a/web/src/routes/coordination.tsx
+++ b/web/src/routes/coordination.tsx
@@ -17,6 +17,8 @@ import {
retryCoordinationChild,
rollupCoordinationCost,
} from "../../../dist/coordination.js";
+import { missionViewEnabled, missionViewFor } from "../../../dist/mission-view.js";
+import type { MissionView } from "../../../dist/mission-view.js";
import type { CoordinationChild, CoordinationCheckState, CoordinationCost, CoordinationRecord } from "../../../dist/coordination.js";
export const config = { mode: "app" };
@@ -34,7 +36,7 @@ interface ChildView {
}
interface CoordinationData {
- coordinations: Array<{ record: CoordinationRecord; api: ChildView; client: ChildView; cost: CoordinationCost; complete: boolean }>;
+ coordinations: Array<{ record: CoordinationRecord; api: ChildView; client: ChildView; cost: CoordinationCost; complete: boolean; mission?: { view: MissionView } | { error: string } }>;
canApprove: boolean;
created: string | null;
error: string | null;
@@ -63,6 +65,8 @@ export async function loader({ request }: { request: Request }): PromiseMission view unavailable: {mission.error}
;
+ const v = mission.view;
+ const list = (ids: string[]) => (ids.length > 0 ? ids.join(", ") : "none");
+ return (
+
+
+ Mission view
+ read-only, derived
+
+ {v.verdict}
+
+
Ready to start: {list(v.ready)}
+
Blocked: {v.blocked.length > 0 ? v.blocked.map((b) => `${b.id} (by ${b.by.join(", ")})`).join("; ") : "none"}
+
Stale (planned on a moved revision): {list(v.stale)}
+
+ Aggregate acceptance: {v.aggregate.accepted ? "accepted" : v.aggregate.verdict}
+ {v.aggregate.unmet.length > 0 ? ` — unmet: ${v.aggregate.unmet.join(", ")}` : ""}
+ {v.aggregate.waived.length > 0 ? ` — waived (not accepted): ${v.aggregate.waived.map((w) => `${w.requirement} by ${w.actor}`).join(", ")}` : ""}
+
+ {v.integration !== null &&
Integration evidence: {v.integration.state} — {v.integration.reason}
}
+ {v.notes.map((n) =>
{n}
)}
+
A finished child counts only once its change is accepted in the task record. Nothing here launches or changes the coordination.
+
+ );
+}
+
export default function Coordination({ data }: { data: CoordinationData }) {
return (
<>
@@ -277,7 +308,7 @@ export default function Coordination({ data }: { data: CoordinationData }) {
Coordinated changes ({data.coordinations.length})
- {data.coordinations.map(({ record, api, client, cost, complete }) => (
+ {data.coordinations.map(({ record, api, client, cost, complete, mission }) => (
{record.parentIntent.length > 120 ? `${record.parentIntent.slice(0, 120)}…` : record.parentIntent}
@@ -367,6 +398,7 @@ export default function Coordination({ data }: { data: CoordinationData }) {
)}
)}
+ {mission !== undefined && }
Total spend: {costLine(cost.total)}
))}