From cf34e1b3511f75b614fcbc4074092716428ef0a2 Mon Sep 17 00:00:00 2001 From: Tye Date: Tue, 7 Jul 2026 20:28:48 -0700 Subject: [PATCH 01/28] feat(goals): edit a goal's criteria in place (B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goal_update gains addCriteria / removeCriteriaIds / editCriteria so a wrong or incomplete criterion can be refined without forking a duplicate goal (the "-v2" portfolio-fragmentation pain this closes). Criteria not referenced are preserved unchanged; edits are re-validated through the same gate as goal_create (non-empty, mcp criteria reference a registered connector). Editing criteria on a converged goal reopens it (active, or blocked if the budget is exhausted) — safe default, no force flag, mirroring the existing drift-detection auto-reactivation. The edit is recorded into the goal's trace as a new ActionRecord source:"system" — visible in goal_get/history for the learning loop, but excluded from workflow-step promotion and the totalActions stat since it's bookkeeping, not a reusable action. Backward compatible: a goal_update call with none of the three new params behaves exactly as before (same patch logic, same response shape). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 42 +++++++++ src/engine.ts | 167 +++++++++++++++++++++++++++++---- src/guidance.ts | 8 +- src/server.ts | 22 ++++- src/types.ts | 21 ++++- tests/engine.test.ts | 209 ++++++++++++++++++++++++++++++++++++++++++ tests/mcp-e2e.test.ts | 58 ++++++++++++ 7 files changed, 503 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 402d193..33ad558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ ## Unreleased +### Added +- **B2: edit a goal's criteria in place.** `goal_update` gains `addCriteria` / + `removeCriteriaIds` / `editCriteria`, so a wrong or incomplete criterion no + longer forces creating a whole new goal (which was fragmenting the loop + portfolio into duplicate `-v2`-style goals with their own, disconnected + learned workflow). `addCriteria` appends new criteria (ids continue the + goal's `c` sequence, never colliding with survivors after a removal); + `removeCriteriaIds` drops criteria by id; `editCriteria` patches an + existing criterion's `description`/`probe`/`assert` by id — fields left + out of the patch are preserved. Criteria not referenced by any of the three + pass through completely unchanged (verified by identity-equality in tests, + not just value equality). Backward compatible: a `goal_update` call with + none of the three new params behaves byte-for-byte as before (same patch + application, same response shape — the redacted `criteria` array is only + added to the response when a criteria edit actually happened). + - **Re-validated on every edit**, through the same gate `goal_create` uses: + at least one criterion must remain, and any `mcp` criterion (added or + edited-in) must reference a connector that's actually registered. A + rejected edit never partially applies — the goal's criteria are left + exactly as they were. + - **Converged-goal guard, safe default (no force flag):** editing criteria + on a `converged` goal reopens it — to `active`, or to `blocked` if its + iteration budget is already exhausted — mirroring the existing + drift-detection auto-reactivation in `assess()`. Rationale: a + `converged` status is a proof that criteria held; changing the criteria + invalidates that proof, so leaving the status untouched would be a + second silent-false-convergence hole right next to the one closed in + 2.18.0. No flag is needed to opt into the safe behavior — the harness + never leaves a goal claiming a convergence it hasn't re-verified. + - **The edit lands in the goal's trace/history** (visible via `goal_get`) + as a new `source:"system"` `ActionRecord` — distinct from `"recorded"` + (an explicit `goal_record` corrective action) and `"activity"` (live + capture). It does NOT spend the corrective-action iteration budget, and + it is excluded from workflow-step promotion and the `totalActions` stat + — a criteria edit is bookkeeping about the goal's definition of done, not + a reusable action toward it, so it must not pollute a learned workflow's + steps. + - Server instructions (`PROTOCOL`) and `goal_update`'s tool description + updated — the old "criteria are IMMUTABLE after goal_create... make a + NEW goal to change them" guidance is now actively wrong and has been + replaced with guidance to refine in place instead. + ## 2.18.0 — 2026-07-02 Security + correctness hardening. A full-codebase adversarial validation diff --git a/src/engine.ts b/src/engine.ts index bf49cc3..fa8f64f 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -15,6 +15,7 @@ import type { Autonomy, ConvergenceReport, Criterion, + CriterionEditInput, CriterionEvaluation, CriterionInput, FocusState, @@ -215,18 +216,7 @@ export class Harness { // ----- goal lifecycle ----- createGoal(input: CreateGoalInput): Goal { - if (input.criteria.length === 0) { - throw new Error( - "A goal needs at least one machine-checkable criterion — otherwise convergence can never be detected. Ask the user how success would be verified, then encode it as a probe + assertion.", - ); - } - for (const c of input.criteria) { - if (c.probe.kind === "mcp" && !this.store.getConnector(c.probe.connector)) { - throw new Error( - `Criterion '${c.description}' references unknown connector '${c.probe.connector}'. Register it first with connector_add.`, - ); - } - } + this.assertValidCriteria(input.criteria); const now = new Date().toISOString(); const goal: Goal = { id: newId("goal"), @@ -260,11 +250,51 @@ export class Harness { return goal; } + /** + * Shared well-formedness gate for a goal's criteria set — used at + * goal_create AND after any goal_update criteria edit, so a goal can never + * end up with zero criteria or a criterion pointing at an unregistered mcp + * connector, however it got there. + */ + private assertValidCriteria(criteria: { description: string; probe: Probe }[]): void { + if (criteria.length === 0) { + throw new Error( + "A goal needs at least one machine-checkable criterion — otherwise convergence can never be detected. Ask the user how success would be verified, then encode it as a probe + assertion.", + ); + } + for (const c of criteria) { + if (c.probe.kind === "mcp" && !this.store.getConnector(c.probe.connector)) { + throw new Error( + `Criterion '${c.description}' references unknown connector '${c.probe.connector}'. Register it first with connector_add.`, + ); + } + } + } + + /** Next free `c` id given the criteria already on the goal (post-edit), + * so appended criteria never collide with survivors even after removals. */ + private nextCriterionId(criteria: Criterion[]): string { + let max = 0; + for (const c of criteria) { + const m = /^c(\d+)$/.exec(c.id); + if (m) max = Math.max(max, Number(m[1])); + } + return `c${max + 1}`; + } + updateGoal( ref: string, patch: Partial< Pick - > & { status?: "active" | "abandoned" }, + > & { + status?: "active" | "abandoned"; + /** New criteria to append. */ + addCriteria?: CriterionInput[]; + /** Ids of existing criteria to drop (see goal_get for ids). */ + removeCriteriaIds?: string[]; + /** Patch existing criteria by id; fields not given are preserved. */ + editCriteria?: CriterionEditInput[]; + }, ): Goal { const goal = this.getGoal(ref); if (patch.objective !== undefined) goal.objective = patch.objective; @@ -291,6 +321,95 @@ export class Harness { } goal.status = patch.status; } + + const addCriteria = patch.addCriteria ?? []; + const removeIds = new Set(patch.removeCriteriaIds ?? []); + const edits = patch.editCriteria ?? []; + const editsById = new Map(edits.map((e) => [e.id, e])); + const editsCriteria = addCriteria.length > 0 || removeIds.size > 0 || edits.length > 0; + + if (editsCriteria) { + const knownIds = new Set(goal.criteria.map((c) => c.id)); + const unknown = [...new Set([...removeIds, ...editsById.keys()])].filter( + (id) => !knownIds.has(id), + ); + if (unknown.length > 0) { + throw new Error( + `Unknown criterion id(s) on goal '${goal.slug}': ${unknown.join(", ")}. Known ids: ${ + [...knownIds].join(", ") || "(none)" + } (see goal_get).`, + ); + } + + // Criteria not referenced by removeCriteriaIds/editCriteria pass through + // unchanged. Order: survivors (edited in place) first, then appended + // criteria — id assignment for appends only ever grows the counter, so + // it never collides with a survivor even after removals. + const next: Criterion[] = []; + for (const c of goal.criteria) { + if (removeIds.has(c.id)) continue; + const edit = editsById.get(c.id); + next.push( + edit + ? { + id: c.id, + description: edit.description ?? c.description, + probe: edit.probe ?? c.probe, + assert: edit.assert ?? c.assert, + } + : c, + ); + } + for (const added of addCriteria) { + next.push({ ...added, id: this.nextCriterionId(next) }); + } + + // Re-validate: the edited set must still be machine-checkable — non-empty, + // and any mcp criterion (added or edited-in) must reference a registered + // connector. + this.assertValidCriteria(next); + goal.criteria = next; + + // Converged-goal guard: criteria changed, so any prior convergence was + // proven against a DIFFERENT definition of done and no longer holds. + // Safe default (no force flag needed): reopen automatically, exactly + // like drift-detection's auto-reactivation elsewhere in assess() — never + // leave a goal reporting 'converged' against criteria that were never + // actually verified. Budget rules still apply: exhausted stays blocked. + if (goal.status === "converged") { + goal.status = goal.usedIterations >= goal.maxIterations ? "blocked" : "active"; + goal.convergedAt = null; + } + + // Recorded into the goal's trace (not the corrective-action budget) as a + // source:"system" entry — visible in goal_get / the learning loop's + // input, but excluded from workflow-step promotion since a criteria + // edit isn't a reusable action toward the goal. + const parts = [ + addCriteria.length > 0 ? `+${addCriteria.length}` : null, + removeIds.size > 0 ? `-${removeIds.size}` : null, + edits.length > 0 ? `~${edits.length}` : null, + ].filter((p): p is string => p !== null); + const detailParts = [ + addCriteria.length > 0 + ? `added: ${addCriteria.map((c) => c.description).join("; ")}` + : null, + removeIds.size > 0 ? `removed: ${[...removeIds].join(", ")}` : null, + edits.length > 0 ? `edited: ${[...editsById.keys()].join(", ")}` : null, + ].filter((p): p is string => p !== null); + this.store.appendRecord({ + id: newId("act"), + goalId: goal.id, + iteration: goal.usedIterations, + summary: `Edited criteria (${parts.join(" ")}) for '${goal.slug}'`, + detail: detailParts.join(" | "), + tool: "goal_update", + result: "success", + source: "system", + at: new Date().toISOString(), + }); + } + goal.updatedAt = new Date().toISOString(); this.store.saveGoal(goal); return goal; @@ -613,7 +732,14 @@ export class Harness { private promoteWorkflow(goal: Goal, opts: { countConvergence?: boolean } = {}): WorkflowArtifact | null { const countConvergence = opts.countConvergence ?? true; const trace = this.store.listRecords(goal.id); - const steps = trace + // System bookkeeping records (e.g. a goal_update criteria edit) are + // visible history but not a reusable ACTION toward the goal — exclude + // them from workflow steps and action-count stats so they don't pollute + // the learned trace. + const actionTrace = trace.filter( + (r): r is ActionRecord & { source?: "recorded" | "activity" } => r.source !== "system", + ); + const steps = actionTrace .filter((r) => r.result !== "failure") .map((r) => ({ summary: r.summary, @@ -624,7 +750,7 @@ export class Harness { })); // Failed approaches are negative muscle memory — capture them so a similar // goal doesn't repeat the dead ends. - const failures = trace.filter((r) => r.result === "failure").map((r) => r.summary); + const failures = actionTrace.filter((r) => r.result === "failure").map((r) => r.summary); const existing = this.store.getWorkflow(goal.slug); // Build-then-verify: the goal converged but the agent recorded nothing @@ -661,7 +787,7 @@ export class Harness { convergences: existing.stats.convergences + (countConvergence ? 1 : 0), // The trace is cumulative (all records ever), so this is an // absolute count, not an increment. - totalActions: trace.length, + totalActions: actionTrace.length, }, updatedAt: now, } @@ -672,7 +798,7 @@ export class Harness { steps: effectiveSteps, criteria: goal.criteria.map((c) => c.description), ...(pitfalls.length > 0 ? { pitfalls } : {}), - stats: { convergences: 1, totalActions: trace.length }, + stats: { convergences: 1, totalActions: actionTrace.length }, createdAt: now, updatedAt: now, }; @@ -848,7 +974,12 @@ export class Harness { report.push({ slug: goal.slug, before, inferred: before, status: "skipped" }); continue; } - const recorded = this.store.listRecords(goal.id).filter((r) => r.result !== "failure"); + const recorded = this.store + .listRecords(goal.id) + .filter( + (r): r is ActionRecord & { source?: "recorded" | "activity" } => + r.result !== "failure" && r.source !== "system", + ); const inferred: WorkflowStep[] = recorded.length > 0 ? recorded.map((r) => ({ diff --git a/src/guidance.ts b/src/guidance.ts index db4c7e2..4c8df3e 100644 --- a/src/guidance.ts +++ b/src/guidance.ts @@ -63,8 +63,12 @@ guidance — prefer them; they encode how this user does things. Everything consequential lands in the audit trail (audit_list). Discipline that makes the loop work: make criteria BEHAVIORAL ("does the thing -actually work?") not mere file-presence; criteria are IMMUTABLE after -goal_create — get them right, and make a NEW goal to change them; call +actually work?") not mere file-presence; if a criterion turns out wrong or +incomplete, REFINE it in place with goal_update (addCriteria/ +removeCriteriaIds/editCriteria) rather than creating a duplicate goal — a new +goal per refinement just fragments the loop's history and its learned +workflow. Editing criteria on a converged goal reopens it (its old +convergence no longer holds against the new definition of done). Call goal_record for EVERY action honestly, including failures (failures teach the harness what to avoid and become pitfalls in future suggestions); never fudge a criterion that's blocked by the environment — report it honestly and leave the diff --git a/src/server.ts b/src/server.ts index cec7ef7..4d5e6c3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,6 +34,7 @@ import { ActionResultSchema, AutonomySchema, ConnectorTransportSchema, + CriterionEditSchema, CriterionInputSchema, effectiveStability, type ActivityEvent, @@ -253,7 +254,7 @@ export function buildServer(harness: Harness): McpServer { { title: "Update a goal", description: - "Update objective, autonomy, constraints, or iteration budget. Raising maxIterations unblocks a blocked goal. Set status to 'abandoned' to retire a goal, or 'active' to resume one.", + "Update objective, autonomy, constraints, iteration budget, or CRITERIA. Raising maxIterations unblocks a blocked goal. Set status to 'abandoned' to retire a goal, or 'active' to resume one. Refine criteria in place — no need to create a new goal to fix a wrong/incomplete probe: addCriteria appends new ones, removeCriteriaIds drops by id (see goal_get), editCriteria patches an existing criterion's description/probe/assert by id (fields you omit are preserved). Criteria not referenced by any of these are left unchanged. Editing criteria on a converged goal reopens it to 'active' (or 'blocked' if the budget is exhausted) — its convergence was proven against the OLD criteria and no longer holds; re-run goal_assess.", inputSchema: { goal: GOAL_REF, objective: z.string().optional(), @@ -261,13 +262,30 @@ export function buildServer(harness: Harness): McpServer { constraints: z.array(z.string()).optional(), maxIterations: z.number().int().positive().max(1000).optional(), status: z.enum(["active", "abandoned"]).optional(), + addCriteria: z.array(CriterionInputSchema).optional().describe("New criteria to append."), + removeCriteriaIds: z + .array(z.string()) + .optional() + .describe("Ids of existing criteria to drop (see goal_get for ids)."), + editCriteria: z + .array(CriterionEditSchema) + .optional() + .describe( + "Patch existing criteria by id. Only the fields given (description/probe/assert) are changed; everything else on that criterion is preserved.", + ), }, }, async ({ goal: ref, ...patch }) => { try { const goal = harness.updateGoal(ref, patch); + const criteriaTouched = + (patch.addCriteria?.length ?? 0) + (patch.removeCriteriaIds?.length ?? 0) + (patch.editCriteria?.length ?? 0) > + 0; logAudit("goal_update", goal.slug, Object.keys(patch).join(","), true); - return json({ goal: goalSummary(goal) }); + return json({ + goal: goalSummary(goal), + ...(criteriaTouched ? { criteria: redactCriteria(goal.criteria) } : {}), + }); } catch (err) { return fail(err); } diff --git a/src/types.ts b/src/types.ts index 84470b8..12091c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -151,6 +151,20 @@ export interface Criterion extends CriterionInput { id: string; } +/** + * A patch to an EXISTING criterion (goal_update's editCriteria). Only the + * fields present are changed; everything else on the criterion is preserved. + * `id` is required and must reference a criterion already on the goal. + */ +export const CriterionEditSchema = z.object({ + id: z.string().min(1).describe("Id of the criterion to edit (see goal_get)."), + description: z.string().min(1).optional(), + probe: ProbeSchema.optional(), + assert: AssertionSchema.optional(), +}); + +export type CriterionEditInput = z.infer; + export type GoalStatus = "active" | "converged" | "blocked" | "abandoned"; export interface Goal { @@ -234,8 +248,11 @@ export interface ActionRecord { at: string; /** Provenance: "recorded" = an explicit goal_record corrective action; * "activity" = captured live from the activity stream while this goal was - * the focus (goal_focus). Absent means recorded (back-compat). */ - source?: "recorded" | "activity"; + * the focus (goal_focus); "system" = the harness's own bookkeeping (e.g. a + * goal_update criteria edit) — visible in the trace/history but excluded + * from workflow-step promotion (it's not a reusable action). Absent means + * recorded (back-compat). */ + source?: "recorded" | "activity" | "system"; } // --------------------------------------------------------------------------- diff --git a/tests/engine.test.ts b/tests/engine.test.ts index 33a5aa0..d24b425 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -75,6 +75,215 @@ describe("Harness goal lifecycle", () => { }); }); +describe("goal_update — editing criteria in place (B2)", () => { + const threeCriteriaGoal = (): CreateGoalInput => ({ + objective: "three things must be true", + criteria: [ + { + description: "c1: first thing", + probe: { kind: "command", run: "echo one", parse: "text" }, + assert: { op: "contains", value: "one" }, + }, + { + description: "c2: second thing", + probe: { kind: "command", run: "echo two", parse: "text" }, + assert: { op: "contains", value: "two" }, + }, + { + description: "c3: third thing", + probe: { kind: "command", run: "echo three", parse: "text" }, + assert: { op: "contains", value: "three" }, + }, + ], + }); + + it("is a no-op on criteria when no criteria args are given (backward compat)", () => { + const goal = harness.createGoal(threeCriteriaGoal()); + const before = goal.criteria; + const updated = harness.updateGoal(goal.slug, { objective: "renamed" }); + expect(updated.objective).toBe("renamed"); + expect(updated.criteria).toEqual(before); + // No criteria args ⇒ no system bookkeeping record either. + expect(harness.store.listRecords(goal.id)).toHaveLength(0); + }); + + it("appends a new criterion via addCriteria", () => { + const goal = harness.createGoal(fileGoal("/dev/null")); + const updated = harness.updateGoal(goal.slug, { + addCriteria: [ + { + description: "a second thing must hold", + probe: { kind: "command", run: "echo ok", parse: "text" }, + assert: { op: "contains", value: "ok" }, + }, + ], + }); + expect(updated.criteria).toHaveLength(2); + expect(updated.criteria[1].id).toBe("c2"); + expect(updated.criteria[1].description).toBe("a second thing must hold"); + }); + + it("removes a criterion by id via removeCriteriaIds", () => { + const goal = harness.createGoal(threeCriteriaGoal()); + const updated = harness.updateGoal(goal.slug, { removeCriteriaIds: ["c2"] }); + expect(updated.criteria.map((c) => c.id)).toEqual(["c1", "c3"]); + expect(updated.criteria.map((c) => c.description)).toEqual([ + "c1: first thing", + "c3: third thing", + ]); + }); + + it("edits an existing criterion's probe/assert/description by id, preserving omitted fields", () => { + const goal = harness.createGoal(threeCriteriaGoal()); + const updated = harness.updateGoal(goal.slug, { + editCriteria: [{ id: "c2", description: "c2: revised second thing" }], + }); + const c2 = updated.criteria.find((c) => c.id === "c2")!; + expect(c2.description).toBe("c2: revised second thing"); + // Probe/assert untouched since only description was given. + expect(c2.probe).toEqual({ kind: "command", run: "echo two", parse: "text" }); + expect(c2.assert).toEqual({ op: "contains", value: "two" }); + + const reprobed = harness.updateGoal(goal.slug, { + editCriteria: [{ id: "c2", probe: { kind: "command", run: "echo TWO", parse: "text" } }], + }); + const c2b = reprobed.criteria.find((c) => c.id === "c2")!; + expect(c2b.probe).toEqual({ kind: "command", run: "echo TWO", parse: "text" }); + // Description from the previous edit survives (not clobbered by this edit). + expect(c2b.description).toBe("c2: revised second thing"); + }); + + it("preserves criteria not referenced by add/remove/edit unchanged", () => { + const goal = harness.createGoal(threeCriteriaGoal()); + const c1Before = goal.criteria.find((c) => c.id === "c1"); + const c3Before = goal.criteria.find((c) => c.id === "c3"); + const updated = harness.updateGoal(goal.slug, { + editCriteria: [{ id: "c2", description: "c2: changed" }], + addCriteria: [ + { + description: "c4: new", + probe: { kind: "command", run: "echo four", parse: "text" }, + assert: { op: "contains", value: "four" }, + }, + ], + }); + expect(updated.criteria.find((c) => c.id === "c1")).toEqual(c1Before); + expect(updated.criteria.find((c) => c.id === "c3")).toEqual(c3Before); + expect(updated.criteria.map((c) => c.id)).toEqual(["c1", "c2", "c3", "c4"]); + }); + + it("reopens a converged goal when its criteria are edited, and re-validates", async () => { + const state = join(dir, "state.txt"); + writeFileSync(state, "ready"); + const goal = harness.createGoal(fileGoal(state)); + const first = await harness.assess(goal.slug); + expect(first.converged).toBe(true); + expect(harness.getGoal(goal.slug).status).toBe("converged"); + + const updated = harness.updateGoal(goal.slug, { + addCriteria: [ + { + description: "state file must also say more", + probe: { kind: "command", run: `cat ${state}`, parse: "text" }, + assert: { op: "contains", value: "more" }, + }, + ], + }); + expect(updated.status).toBe("active"); + expect(updated.convergedAt).toBeNull(); + + // The new criterion isn't satisfied yet, so re-assessing stays unconverged. + const second = await harness.assess(goal.slug); + expect(second.converged).toBe(false); + }); + + it("reopens a converged goal to 'blocked' (not 'active') when its budget is already exhausted", async () => { + const state = join(dir, "state.txt"); + writeFileSync(state, "not yet"); + const goal = harness.createGoal(fileGoal(state, { maxIterations: 1 })); + harness.recordAction(goal.slug, { summary: "fix it" }); + writeFileSync(state, "ready"); + const assessed = await harness.assess(goal.slug); + expect(assessed.converged).toBe(true); + expect(harness.getGoal(goal.slug).usedIterations).toBe(1); + + const updated = harness.updateGoal(goal.slug, { + editCriteria: [{ id: "c1", description: "state file contains 'ready' (revised)" }], + }); + expect(updated.status).toBe("blocked"); + }); + + it("records the criteria edit into the goal's trace as source:'system', visible in history but excluded from the learned workflow's steps", async () => { + const state = join(dir, "state.txt"); + writeFileSync(state, "not yet"); + const goal = harness.createGoal(fileGoal(state)); + + harness.updateGoal(goal.slug, { + editCriteria: [{ id: "c1", description: "state file contains 'ready' (tightened)" }], + }); + const trace = harness.store.listRecords(goal.id); + expect(trace).toHaveLength(1); + expect(trace[0].source).toBe("system"); + expect(trace[0].tool).toBe("goal_update"); + + // Editing criteria must not itself spend the corrective-action budget. + expect(harness.getGoal(goal.slug).usedIterations).toBe(0); + + writeFileSync(state, "ready"); + harness.recordAction(goal.slug, { summary: "wrote ready to the file" }); + await harness.assess(goal.slug); + + const wf = harness.store.getWorkflow(goal.slug); + expect(wf?.steps.map((s) => s.summary)).toEqual(["wrote ready to the file"]); + expect(wf?.steps.some((s) => s.summary.startsWith("Edited criteria"))).toBe(false); + }); + + it("rejects an unknown criterion id in editCriteria or removeCriteriaIds", () => { + const goal = harness.createGoal(threeCriteriaGoal()); + expect(() => harness.updateGoal(goal.slug, { removeCriteriaIds: ["c99"] })).toThrow( + /Unknown criterion id.*c99/, + ); + expect(() => + harness.updateGoal(goal.slug, { editCriteria: [{ id: "ghost", description: "x" }] }), + ).toThrow(/Unknown criterion id.*ghost/); + // Rejected edits must not partially apply. + expect(harness.getGoal(goal.slug).criteria).toHaveLength(3); + }); + + it("rejects removing every criterion (would leave the goal unverifiable)", () => { + const goal = harness.createGoal(fileGoal("/dev/null")); + expect(() => + harness.updateGoal(goal.slug, { removeCriteriaIds: ["c1"] }), + ).toThrow(/machine-checkable/); + expect(harness.getGoal(goal.slug).criteria).toHaveLength(1); + }); + + it("rejects a criterion (added or edited-in) referencing an unknown mcp connector", () => { + const goal = harness.createGoal(threeCriteriaGoal()); + expect(() => + harness.updateGoal(goal.slug, { + addCriteria: [ + { + description: "y", + probe: { kind: "mcp", connector: "ghost", tool: "t" }, + assert: { op: "truthy" }, + }, + ], + }), + ).toThrow(/unknown connector 'ghost'/); + expect(() => + harness.updateGoal(goal.slug, { + editCriteria: [ + { id: "c1", probe: { kind: "mcp", connector: "ghost", tool: "t" } }, + ], + }), + ).toThrow(/unknown connector 'ghost'/); + // Neither rejected edit applied. + expect(harness.getGoal(goal.slug).criteria).toHaveLength(3); + expect(harness.getGoal(goal.slug).criteria[0].probe.kind).toBe("command"); + }); +}); + describe("the convergence loop", () => { it("assess → act → record → assess converges and promotes a workflow", async () => { const state = join(dir, "state.txt"); diff --git a/tests/mcp-e2e.test.ts b/tests/mcp-e2e.test.ts index 0c0ead2..23ea276 100644 --- a/tests/mcp-e2e.test.ts +++ b/tests/mcp-e2e.test.ts @@ -178,4 +178,62 @@ describe("MCP protocol e2e — muscle memory", () => { const wf = after.workflows.find((w: any) => w.slug === "e2e-bv"); expect(wf?.steps.map((s: any) => s.summary)).toEqual(["Dated the changelog section"]); }); + + it("goal_update edits criteria in place over the wire — no need to fork a new goal (B2)", async () => { + await client.tool("goal_create", { + objective: "criteria can be refined in place", + slug: "e2e-criteria-edit", + criteria: crit("nope", "ready"), + }); + + const preConverge = await client.tool("goal_assess", { goal: "e2e-criteria-edit" }); + expect(preConverge.converged).toBe(false); + + // Fix the wrong criterion in place via editCriteria — the whole point of B2. + const edited = await client.tool("goal_update", { + goal: "e2e-criteria-edit", + editCriteria: [ + { + id: "c1", + probe: { kind: "command", run: "echo nope", parse: "text" }, + assert: { op: "contains", value: "nope" }, + }, + ], + }); + expect(edited.goal.criteria).toBe(1); + expect(edited.criteria[0].id).toBe("c1"); + expect(edited.criteria[0].assert).toEqual({ op: "contains", value: "nope" }); + + const converged = await client.tool("goal_assess", { goal: "e2e-criteria-edit" }); + expect(converged.converged).toBe(true); + + // Add a criterion post-convergence: must reopen the goal, not silently stay converged. + const reopened = await client.tool("goal_update", { + goal: "e2e-criteria-edit", + addCriteria: [ + { + description: "a second thing", + probe: { kind: "command", run: "echo ready", parse: "text" }, + assert: { op: "contains", value: "ready" }, + }, + ], + }); + expect(reopened.goal.status).toBe("active"); + expect(reopened.goal.criteria).toBe(2); + + const got = await client.tool("goal_get", { goal: "e2e-criteria-edit" }); + expect(got.goal.criteria.map((c: any) => c.id)).toEqual(["c1", "c2"]); + // The edit landed in the trace/history so the learning loop sees it. + expect(got.trace.some((r: any) => r.source === "system" && r.tool === "goal_update")).toBe( + true, + ); + + // Plain goal_update calls with no criteria args are unaffected (backward compat). + const plain = await client.tool("goal_update", { + goal: "e2e-criteria-edit", + objective: "criteria can be refined in place (renamed)", + }); + expect(plain.criteria).toBeUndefined(); + expect(plain.goal.objective).toBe("criteria can be refined in place (renamed)"); + }); }); From 00c0aca010e6295af0592881d4910f623e3b1efc Mon Sep 17 00:00:00 2001 From: Tye Date: Tue, 7 Jul 2026 21:00:52 -0700 Subject: [PATCH 02/28] feat(goals): stamp project/cwd on the goal record (ADR-35, coordinated w/ belay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit belay's loop portfolio now scopes proposals by project to stop cross-project leakage (goalOwnProject reads a project/cwd off the goal row) — but the goal record had no such field. Adds Goal.project (git repo root of a cwd, or the cwd itself outside a repo, via projectForCwd() — never throws) and Goal.cwd. MCP does not hand a tool call the client's cwd; the only reliable signals are an explicit caller-supplied cwd or the long-lived server process's own process.cwd() (fixed at spawn time by Claude Code, typically the project dir). goal_focus already used exactly this convention, so goal_create now follows it: an optional cwd param defaulting to process.cwd(), so every newly created goal is stamped going forward. goal_focus backfills project/cwd on a goal that has neither, from the focus cwd — first stamp wins, so re-focusing an already-stamped goal from elsewhere never reassigns it. Surfaced in goal_get, goal_list/goal_create (goalSummary), and directly in goals.json (how belay reads it). Backward compat: goals persisted before this field existed have neither key and are NOT retroactively scoped — no backfill migration, since inferring a project from free text would produce false positives. They become scopeable only once re-focused or recreated; belay must treat an absent project/cwd as "unknown," not "global." 333 -> 340 tests, typecheck clean. Not published (staged for review). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 40 ++++++++++++++++++++ src/engine.ts | 48 ++++++++++++++++++++++- src/server.ts | 13 ++++++- src/types.ts | 18 +++++++++ tests/engine.test.ts | 88 ++++++++++++++++++++++++++++++++++++++++++- tests/mcp-e2e.test.ts | 33 +++++++++++++++- 6 files changed, 235 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ad558..77cfb80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,46 @@ ## Unreleased ### Added +- **ADR-35: `Goal.project`/`Goal.cwd` — the keyoku side of belay's cross-project + scoping fix.** belay's loop portfolio/proposals now scope by project to stop + goals bleeding across unrelated repos sharing one `~/.keyoku`; that read a + `project`/`cwd` off the goal row, but the goal record had no such field — + this adds it. + - **What cwd context is actually available to an MCP tool handler** (the + key finding): MCP does not hand a tool call the client's cwd — there is + no protocol-level "caller's cwd" param. The only two reliable signals are + (a) an explicit `cwd` argument the calling agent chooses to pass, and (b) + `process.cwd()` of the long-lived stdio server process itself, fixed at + the moment Claude Code spawned it (typically the project dir the session + started in). `goal_focus` already leaned on exactly this — `cwd` optional, + defaulting to `process.cwd()` — so that's the established, trusted + convention this change follows for `goal_create` too, rather than + inventing a new mechanism. + - **`goal_create` gains an optional `cwd` param**, defaulting to the + server's `process.cwd()` when omitted — so every newly created goal is + stamped going forward, not just focused ones. Stored as two fields: + `Goal.cwd` (the raw dir) and `Goal.project` (the git repo root of that + dir, or the dir itself outside a repo — `never-throw`, via + `projectForCwd()` in `engine.ts`) — repo-root normalization means a goal + created from any subdir of a monorepo checkout lands on the same + `project` value. + - **`goal_focus` backfills `project`/`cwd`** on a goal that doesn't have + them yet, from the focus `cwd` (itself already optional-with-a- + `process.cwd()`-default on that tool). **First stamp wins** — focusing an + already-stamped goal from a different directory never reassigns it, so a + shared/portfolio goal can't get bounced between projects by whoever + focuses it next. + - **Surfaced** in `goal_get` (full goal object) and `goal_list`/`goal_create` + responses (`goalSummary`, `project` only, when set) — as well as directly + in `goals.json`, which is how belay itself reads it. + - **Backward compat, stated plainly:** the ~97 goals that existed before + this field shipped have neither `project` nor `cwd` and are **NOT** + retroactively scoped — there is no backfill migration, because inferring + a project from a goal's free-text objective/activity would produce false + positives that are worse than "unscoped." An old goal becomes scopeable + only once it is re-focused (`goal_focus`) from a real cwd, or recreated. + belay-side scoping logic must treat an absent `project`/`cwd` as + "unknown," not "global." - **B2: edit a goal's criteria in place.** `goal_update` gains `addCriteria` / `removeCriteriaIds` / `editCriteria`, so a wrong or incomplete criterion no longer forces creating a whole new goal (which was fragmenting the loop diff --git a/src/engine.ts b/src/engine.ts index fa8f64f..3b3ad5d 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,3 +1,5 @@ +import { execSync } from "node:child_process"; + import { isActionEvent } from "./activity.js"; import { evaluateAssertion } from "./assert.js"; import type { ConnectorManager } from "./connectors.js"; @@ -122,6 +124,28 @@ function sameProject(a: string, b: string): boolean { return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`); } +/** + * Resolve the git repo root for `cwd`, or `cwd` itself if it isn't inside a + * git repo (or git isn't on PATH) — NEVER throws. Used to stamp `Goal.project` + * (belay's ADR-35 cross-project scoping): normalizing to the repo root means + * a goal created/focused from any monorepo subdir lands on the same project + * value, instead of `sameProject`'s prefix-subtree matching having to do that + * work again on every read. + */ +export function projectForCwd(cwd: string): string { + try { + const root = execSync("git rev-parse --show-toplevel", { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + }).trim(); + return root || cwd; + } catch { + return cwd; + } +} + /** Cap a step list keeping the first `head` (setup) and the most recent * `max - head` (build/verify); a marker records the omission so the draft stays * honest about the gap. Lists at or under `max` pass through unchanged. */ @@ -159,6 +183,14 @@ export interface CreateGoalInput { constraints?: string[]; autonomy?: Autonomy; maxIterations?: number; + /** + * Project dir this goal is being created from, for cross-project scoping + * (see `Goal.project`). Optional — the MCP handler supplies its best + * available signal (an explicit caller-supplied cwd, else the server + * process's own cwd); direct engine callers (tests, scripts) may omit it, + * in which case the goal is created unstamped (backward-compat shape). + */ + cwd?: string; } export interface RecordActionInput { @@ -234,6 +266,7 @@ export class Harness { updatedAt: now, convergedAt: null, lastAssessedAt: null, + ...(input.cwd ? { cwd: input.cwd, project: projectForCwd(input.cwd) } : {}), }; this.store.saveGoal(goal); return goal; @@ -428,7 +461,14 @@ export class Harness { /** Mark a goal as the live-capture focus: while focused, the activity * recorder also appends each real action to this goal's trace (source: * "activity"), so the run becomes muscle memory live. Scoped to the given - * cwd/session so concurrent work on one ~/.keyoku doesn't bleed in. */ + * cwd/session so concurrent work on one ~/.keyoku doesn't bleed in. + * + * Also backfills `Goal.project`/`Goal.cwd` (belay's ADR-35 cross-project + * scoping) the first time a goal with neither is focused from a known + * cwd — this is how a goal created before the field existed, or created + * without a cwd, becomes scopeable. Never overwrites an already-stamped + * goal: first stamp wins, so re-focusing an established goal from a + * different directory can't reassign its project. */ setFocus(ref: string, scope: { cwd?: string; sessionId?: string } = {}): FocusState { const goal = this.getGoal(ref); if (goal.status !== "active") { @@ -436,6 +476,12 @@ export class Harness { `Can only focus an active goal — '${goal.slug}' is ${goal.status}. Reactivate it first if you mean to keep working on it.`, ); } + if (scope.cwd && !goal.project) { + goal.project = projectForCwd(scope.cwd); + goal.cwd = scope.cwd; + goal.updatedAt = new Date().toISOString(); + this.store.saveGoal(goal); + } const focus: FocusState = { goalId: goal.id, goalSlug: goal.slug, diff --git a/src/server.ts b/src/server.ts index 4d5e6c3..e39ebf3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -83,6 +83,9 @@ function goalSummary(goal: Goal) { iterations: `${goal.usedIterations}/${goal.maxIterations}`, lastAssessedAt: goal.lastAssessedAt, convergedAt: goal.convergedAt, + // Cross-project scoping (ADR-35). Absent on goals stamped before this + // field existed and never re-focused since — see CHANGELOG. + ...(goal.project ? { project: goal.project } : {}), }; } @@ -192,11 +195,17 @@ export function buildServer(harness: Harness): McpServer { constraints: z.array(z.string()).optional().describe("Hard constraints the agent must respect while acting."), autonomy: AutonomySchema.optional().describe("Default: suggest."), maxIterations: z.number().int().positive().max(1000).optional().describe("Action budget before the goal blocks (default 10)."), + cwd: z + .string() + .optional() + .describe( + "Project dir this goal belongs to, for cross-project scoping (default: the server's own cwd). Stamped once at creation as `project` (the git repo root of this dir, or the dir itself outside a repo); pass it explicitly if the server's cwd doesn't match where the goal is really being worked.", + ), }, }, async (args) => { try { - const goal = harness.createGoal(args); + const goal = harness.createGoal({ ...args, cwd: args.cwd ?? process.cwd() }); logAudit("goal_create", goal.slug, goal.objective.slice(0, 120), true); return json({ goal: goalSummary(goal), @@ -485,7 +494,7 @@ export function buildServer(harness: Harness): McpServer { { title: "Focus a goal for live capture", description: - "Declare that you are now working toward this goal. While focused, every real action you take (Bash/Edit/Write/connector — not inspection) is captured into the goal's trace LIVE as a source:'activity' record, so a build-then-verify run becomes a reusable workflow without you calling goal_record by hand. Capture is scoped to this session/project so it won't absorb other work. Clears automatically when the goal converges; call goal_unfocus to stop early.", + "Declare that you are now working toward this goal. While focused, every real action you take (Bash/Edit/Write/connector — not inspection) is captured into the goal's trace LIVE as a source:'activity' record, so a build-then-verify run becomes a reusable workflow without you calling goal_record by hand. Capture is scoped to this session/project so it won't absorb other work. Also backfills the goal's `project` (cross-project scoping) from this cwd if it wasn't already stamped at goal_create. Clears automatically when the goal converges; call goal_unfocus to stop early.", inputSchema: { goal: GOAL_REF, cwd: z.string().optional().describe("Project dir to scope capture to (default: the server's cwd)."), diff --git a/src/types.ts b/src/types.ts index 12091c8..b268440 100644 --- a/src/types.ts +++ b/src/types.ts @@ -182,6 +182,24 @@ export interface Goal { updatedAt: string; convergedAt: string | null; lastAssessedAt: string | null; + /** + * Project this goal belongs to — cross-project scoping so a portfolio/ + * proposal view over one `~/.keyoku` doesn't bleed goals between unrelated + * repos (the coordinated keyoku side of belay's ADR-35; belay's + * `goalOwnProject` reads this field off the goal row when present). The + * git repo root of the cwd it was stamped from, or that raw cwd if it + * isn't inside a git repo. Stamped once — at `goal_create` when a cwd is + * available, and backfilled at `goal_focus` if still unset — and never + * overwritten afterward (first stamp wins), so re-focusing an existing + * goal from a different directory can't reassign it. Optional for + * backward compat: goals persisted before this field existed have neither + * `project` nor `cwd` and are NOT retroactively scoped (see CHANGELOG). + */ + project?: string; + /** Raw cwd captured at the same time as `project` (pre-git-root + * normalization) — kept alongside for provenance/debugging. Optional for + * the same backward-compat reason as `project`. */ + cwd?: string; } export interface CriterionEvaluation { diff --git a/tests/engine.test.ts b/tests/engine.test.ts index d24b425..0f7535e 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ConnectorManager } from "../src/connectors.js"; -import { Harness, autoRecordToFocusGoal, type CreateGoalInput } from "../src/engine.js"; +import { Harness, autoRecordToFocusGoal, projectForCwd, type CreateGoalInput } from "../src/engine.js"; import { Store } from "../src/store.js"; import type { SlmProvider } from "../src/slm.js"; import type { ActivityEvent } from "../src/types.js"; @@ -1259,6 +1259,92 @@ describe("live capture (goal_focus + auto-record)", () => { }); }); +describe("cross-project scoping — Goal.project / Goal.cwd (belay ADR-35)", () => { + const echoGoal = (objective: string, extra: Partial = {}): CreateGoalInput => ({ + objective, + criteria: [ + { + description: "echo ok", + probe: { kind: "command", run: "echo ok", parse: "text" }, + assert: { op: "eq", value: "ok" }, + }, + ], + ...extra, + }); + + it("projectForCwd resolves the git repo root for a cwd inside this repo, never throwing", async () => { + const { execSync } = await import("node:child_process"); + const expectedRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); + expect(projectForCwd(process.cwd())).toBe(expectedRoot); + }); + + it("projectForCwd falls back to the raw cwd outside a git repo (never throws)", () => { + // `dir` (the per-test tmpdir) is not inside a git repo. + expect(projectForCwd(dir)).toBe(dir); + }); + + it("goal_create stamps project + cwd when a cwd is supplied, and leaves both unset when it isn't (backward-compat shape)", () => { + const stamped = harness.createGoal(echoGoal("stamped at create", { cwd: dir })); + expect(stamped.cwd).toBe(dir); + expect(stamped.project).toBe(dir); // dir isn't a git repo — project falls back to cwd + + const unstamped = harness.createGoal(echoGoal("no cwd supplied")); + expect(unstamped.cwd).toBeUndefined(); + expect(unstamped.project).toBeUndefined(); + }); + + it("goal_focus backfills project/cwd on a goal that has neither (the pre-ADR-35 shape)", () => { + const goal = harness.createGoal(echoGoal("backfill on focus")); + expect(goal.project).toBeUndefined(); + + harness.setFocus(goal.slug, { cwd: dir }); + const refocused = harness.getGoal(goal.slug); + expect(refocused.cwd).toBe(dir); + expect(refocused.project).toBe(dir); + expect(refocused.updatedAt).not.toBe(goal.updatedAt); + }); + + it("first stamp wins: re-focusing an already-stamped goal from a different cwd does not reassign it", () => { + const goal = harness.createGoal(echoGoal("first stamp wins", { cwd: "/proj-a" })); + expect(goal.project).toBe("/proj-a"); + + harness.setFocus(goal.slug, { cwd: "/proj-b" }); + expect(harness.getGoal(goal.slug).project).toBe("/proj-a"); + }); + + it("backward compat: a goal persisted before this field existed (no project/cwd keys at all) loads fine and can still be backfilled", () => { + const legacy = { + id: "goal_legacy1", + slug: "legacy-goal", + objective: "predates ADR-35", + criteria: [ + { + id: "c1", + description: "echo ok", + probe: { kind: "command" as const, run: "echo ok", parse: "text" as const }, + assert: { op: "eq" as const, value: "ok" }, + }, + ], + constraints: [], + autonomy: "suggest" as const, + maxIterations: 10, + usedIterations: 0, + status: "active" as const, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + convergedAt: null, + lastAssessedAt: null, + // deliberately no `project` / `cwd` keys — the real shape of ~97 + // pre-existing goals in the field. + }; + harness.store.saveGoal(legacy); + expect(harness.getGoal("legacy-goal").project).toBeUndefined(); + + harness.setFocus("legacy-goal", { cwd: dir }); + expect(harness.getGoal("legacy-goal").project).toBe(dir); + }); +}); + describe("repairWorkflows (backfill repair of hollow muscle memory)", () => { let seq2 = 0; const ev2 = (over: Partial): ActivityEvent => ({ diff --git a/tests/mcp-e2e.test.ts b/tests/mcp-e2e.test.ts index 23ea276..dba53ef 100644 --- a/tests/mcp-e2e.test.ts +++ b/tests/mcp-e2e.test.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { execSync, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -236,4 +236,35 @@ describe("MCP protocol e2e — muscle memory", () => { expect(plain.criteria).toBeUndefined(); expect(plain.goal.objective).toBe("criteria can be refined in place (renamed)"); }); + + it("stamps `project` at goal_create from the server's own cwd (belay ADR-35 cross-project scoping), surfaced in goal_get and goal_list", async () => { + // The child server was spawned with no explicit `cwd` override, so it + // inherits this test process's cwd — the package root, a real git repo. + const expectedRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); + + await client.tool("goal_create", { + objective: "cross-project scoping stamp", + slug: "e2e-project-stamp", + criteria: crit("ready", "ready"), + }); + + const got = await client.tool("goal_get", { goal: "e2e-project-stamp" }); + expect(got.goal.project).toBe(expectedRoot); + expect(typeof got.goal.cwd).toBe("string"); + + const listed = await client.tool("goal_list", { status: "active" }); + const summary = listed.goals.find((g: any) => g.slug === "e2e-project-stamp"); + expect(summary.project).toBe(expectedRoot); + + // An explicit cwd wins over the server's own — the escape hatch for a + // caller that knows better than the server process's cwd. + await client.tool("goal_create", { + objective: "cross-project scoping explicit cwd", + slug: "e2e-project-stamp-explicit", + criteria: crit("ready", "ready"), + cwd: "/tmp/not-this-repo", + }); + const got2 = await client.tool("goal_get", { goal: "e2e-project-stamp-explicit" }); + expect(got2.goal.project).not.toBe(expectedRoot); + }); }); From 53a5fd4ec37a668a7954ce4b29dc1bf251ea1ea9 Mon Sep 17 00:00:00 2001 From: Tye Date: Sun, 23 Aug 2026 11:00:00 -0700 Subject: [PATCH 03/28] feat(demo): add demo evidence abstraction (record -> watch -> gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic, project-agnostic workflow that makes a recorded product demo first-class Keyoku evidence: src/demo.ts implements the demo.yaml schema, a Playwright-driven recorder (screenshots per stop, resolved from the target project so keyoku itself stays playwright-free), and an agent-watch step that produces a zod-validated verdict.json usable as an outcome criterion probe via `keyoku demo record && keyoku demo watch --assert`. Also raises CommandProbeSchema/HttpProbeSchema's timeoutMs cap from 300_000 to 900_000 (src/types.ts) since real record/watch pipelines and frontend builds exceed 5 minutes. Not wired into src/index.ts's CLI dispatch/help text or CHANGELOG.md in this commit: both files already carry a large, unrelated, pre-existing uncommitted diff (a 3.0.0-alpha.1 contribution/proof-session rewrite) that predates this change and is outside this task's scope; committing them whole would sweep that unrelated work in under this message. The wiring (case "demo" in main(), demoCmd import, help text line) and CHANGELOG entries are applied in the working tree and build/typecheck/behaviorally tested clean — left uncommitted for the orchestrator to fold in alongside that pre-existing baseline. --- docs/demo-evidence.md | 224 +++++++++++++++++++ src/demo.ts | 506 ++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 7 +- 3 files changed, 735 insertions(+), 2 deletions(-) create mode 100644 docs/demo-evidence.md create mode 100644 src/demo.ts diff --git a/docs/demo-evidence.md b/docs/demo-evidence.md new file mode 100644 index 0000000..110f33e --- /dev/null +++ b/docs/demo-evidence.md @@ -0,0 +1,224 @@ +# Demo evidence: record → watch → gate + +`keyoku demo` turns a scripted product demo into a recorded, agent-watched, +machine-checkable piece of evidence — usable in **any** project, not just +Keyoku itself. + +## Why + +Humans digest a demo instantly: open the app, click through the flow, look +at what's on screen. That's exactly the evidence a reviewer actually trusts +— more than a green test suite, because it shows the thing the user will +see. But a demo is normally a one-off, unrecorded, unverifiable act: someone +clicked through it once, said "looks good," and that moment is gone. + +Coding agents can watch a demo the same way a human does — by looking at +screenshots and checking them against stated expectations. `keyoku demo` +makes that watching step first-class: + +1. **`keyoku demo record`** drives a real browser through a scripted walk of + the running app and captures one screenshot ("frame") per stop, plus a + manifest of what each frame is supposed to show. +2. **`keyoku demo watch`** has an agent actually look at each frame, check + it against the stated expectations, and also run a general UI/UX audit + across the whole demo — then write a structured verdict. +3. **`keyoku demo watch --assert`** turns that verdict into a pass/fail exit + code, so `keyoku demo record && keyoku demo watch --assert` can be pasted + straight into an outcome's `criteria[].probe.run` — the watched demo + becomes part of the proof, not a claim about the proof. + +Keyoku's evidence model (`EvidencePresentationSchema` in `src/contribution.ts`) +already supports `artifacts[].kind: "screenshot"` / `"video"` in a Factfile. +`keyoku demo` is the workflow that actually *produces* those artifacts and +validates them before they're presented, instead of leaving it to whoever +proposed the change to attach (or not attach) a screenshot by hand. + +## The `demo.yaml` schema + +`keyoku demo init` writes `.keyoku/demo.yaml` (never overwrites an existing +one) with a commented template. Full shape: + +```yaml +baseUrl: http://localhost:3000 # required — where the app is running + +viewport: # optional, default 1440x900 + width: 1440 + height: 900 + +settleMs: 2500 # optional, default 2500 — wait after + # navigating/acting, before the shot + +fullPage: true # optional, default true — can be + # overridden per-stop + +auth: # optional — runs ONCE, before stop 1 + url: /login # relative to baseUrl, or absolute + steps: # same Action union as stops[].actions + - fill: { selector: "#email", value: "demo@example.com" } + - fill: { selector: "#password", value: "demo-password" } + - click: "button[type=submit]" + - waitMs: 1000 + +stops: # required, at least one + - id: dashboard # required, slug (lowercase/digits/-._) + title: Dashboard # optional, shown to the watching agent + url: /dashboard # optional — relative to baseUrl or + # absolute; omit to stay on the current + # page (e.g. after a click from a + # previous stop) + actions: # optional, run in order after goto + - click: "#nav-settings" + - waitMs: 500 + fullPage: false # optional, overrides the global default + expect: # REQUIRED, at least one — plain + # language assertions about what must + # be VISIBLE in this frame + - "The main navigation is visible" + - "At least one summary metric card is rendered with a non-empty value" + caption: "Landing view after login" # optional, shown in the report +``` + +`Action` (used in both `auth.steps` and `stops[].actions`) is one of: + +```ts +type Action = + | { goto: string } + | { click: string } + | { fill: { selector: string; value: string } } + | { select: { selector: string; label: string } } + | { press: string } + | { waitMs: number }; +``` + +## Recording + +`keyoku demo record`: + +1. Reads and zod-validates `.keyoku/demo.yaml`. +2. Launches Chromium via `playwright`, resolved from **the target project** + (via `createRequire` against that project's own `package.json`) — not + from keyoku's own dependencies. If `playwright` isn't installed there, + the command exits with a clear message: `npm i -D playwright`. +3. Runs `auth` once, if present. +4. For each stop, in order: `goto` (if `url` given) → run `actions` → wait + `settleMs` → screenshot to + `.keyoku/demo/frames/-.jpeg` (JPEG, quality 80, animations + disabled, full-page unless overridden). +5. Writes `.keyoku/demo/manifest.json`: + +```json +{ + "recordedAt": "2026-08-23T12:00:00.000Z", + "baseUrl": "http://localhost:3000", + "stops": [ + { + "id": "dashboard", + "order": 1, + "frame": ".keyoku/demo/frames/01-dashboard.jpeg", + "title": "Dashboard", + "expect": ["The main navigation is visible", "..."], + "caption": "Landing view after login" + } + ] +} +``` + +A stop that throws (bad selector, navigation failure, timeout, ...) is +recorded and reported by id, and the command exits non-zero — but every +*other* stop still gets attempted and included in the manifest. + +## Watching — the verdict contract + +`keyoku demo watch` reads the manifest, builds one prompt covering every +frame plus its `expect` list, and runs it through an agent. The default +runner is the `claude` CLI: + +``` +claude -p "" --permission-mode acceptEdits +``` + +run with `cwd` set to the project root. If `claude` isn't on `PATH`, the +command exits with code `2` and names the contract below, so **any other +agent runner can be substituted** — the only requirement is that it writes +`.keyoku/demo/verdict.json` matching this shape: + +```json +{ + "watched_at": "2026-08-23T12:05:00.000Z", + "frames": [ + { + "id": "dashboard", + "requirement_met": true, + "evidence_seen": "Top nav with 4 links is visible; 3 metric cards show non-empty values.", + "concerns": [] + } + ], + "overall": { + "frames_pass": 1, + "frames_partial": 0, + "frames_fail": 0, + "verdict": "pass", + "summary": "Dashboard renders as expected; no missing elements." + }, + "uiux_audit": { + "findings": [ + { "severity": "low", "description": "Metric card labels truncate on narrow viewports.", "suggested_fix": "Wrap instead of truncating, or shorten labels." } + ], + "top_priorities": ["Fix metric card label truncation"] + } +} +``` + +- `requirement_met` is `true`, `false`, or `"partial"` per frame. +- `overall.verdict` is `"pass"` **if and only if** no frame has + `requirement_met === false`. +- `uiux_audit` is a separate, cross-frame pass — visual hierarchy, density, + truncation, empty/broken charts, color semantics, cross-page consistency — + independent of whether the per-stop `expect` assertions held. + +After the run, `keyoku demo watch` validates `verdict.json` against this +contract with zod and prints a summary (pass/partial/fail counts, failing +frames, top UI/UX findings). + +## Gating: `--assert` + +`keyoku demo watch --assert` exits: + +- **`0`** only if `overall.verdict === "pass"` **and** + `verdict.watched_at` is strictly newer than `manifest.recordedAt` (i.e. + the verdict is actually about the demo that was just recorded, not a + stale one from a previous run). +- **`1`** otherwise, printing which frames failed/partialed or why the + verdict was considered stale. +- **`2`** for setup problems (no manifest, no `claude` CLI, a verdict that + doesn't match the contract). + +This makes `keyoku demo record && keyoku demo watch --assert` a valid +`command` probe for an outcome criterion — `keyoku demo init` prints a +ready-to-paste snippet: + +```yaml +- description: "The recorded product demo passes agent review" + probe: + kind: command + run: "keyoku demo record && keyoku demo watch --assert" + timeoutMs: 900000 + assert: + path: exitCode + op: eq + value: 0 + evidence: + summary: "An agent watched the recorded demo frames against their stated expectations and ran a UI/UX audit." + whyItMatters: "The demo is recorded evidence, not a claim about it — the same frames a human would watch are what the agent checked." + code: [] + artifacts: + - kind: screenshot + path: ".keyoku/demo/frames/*.jpeg" + label: "Recorded demo frames" + caption: "One frame per stop, captured by keyoku demo record" +``` + +`timeoutMs: 900000` (15 minutes) matches the raised `CommandProbeSchema` / +`HttpProbeSchema` cap in `src/types.ts` — a real record → launch-agent → +watch round trip, on a real frontend build, routinely exceeds the old +5-minute cap. diff --git a/src/demo.ts b/src/demo.ts new file mode 100644 index 0000000..eced96c --- /dev/null +++ b/src/demo.ts @@ -0,0 +1,506 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parse } from "yaml"; +import { z } from "zod"; + +import { KEYOKU_DIR } from "./contribution.js"; + +// --------------------------------------------------------------------------- +// "Demo evidence" — a recorded, agent-watched product demo as first-class +// Keyoku evidence. Generic for any project: `keyoku demo init` writes a +// .keyoku/demo.yaml script, `keyoku demo record` drives a real browser +// through it and captures one screenshot ("frame") per stop, and `keyoku +// demo watch` has an agent look at the frames against the expectations the +// project author wrote and produce a machine-checkable verdict. `keyoku demo +// watch --assert` closes the loop as an outcome criterion probe. +// --------------------------------------------------------------------------- + +const SlugSchema = z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9._-]*$/, "must be lowercase letters, numbers, dots, dashes, or underscores"); + +const ActionSchema = z.union([ + z.object({ goto: z.string().min(1) }).strict(), + z.object({ click: z.string().min(1) }).strict(), + z.object({ fill: z.object({ selector: z.string().min(1), value: z.string() }) }).strict(), + z.object({ select: z.object({ selector: z.string().min(1), label: z.string().min(1) }) }).strict(), + z.object({ press: z.string().min(1) }).strict(), + z.object({ waitMs: z.number().int().nonnegative() }).strict(), +]); + +const StopSchema = z.object({ + id: SlugSchema, + title: z.string().min(1).optional(), + url: z.string().min(1).optional(), + actions: z.array(ActionSchema).default([]), + expect: z.array(z.string().min(1)).min(1), + caption: z.string().min(1).optional(), + fullPage: z.boolean().optional(), +}); + +const DemoConfigSchema = z.object({ + baseUrl: z.string().min(1), + viewport: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(), + settleMs: z.number().int().nonnegative().default(2500), + fullPage: z.boolean().default(true), + auth: z.object({ url: z.string().min(1), steps: z.array(ActionSchema).default([]) }).optional(), + stops: z.array(StopSchema).min(1), +}); + +const ManifestSchema = z.object({ + recordedAt: z.string().min(1), + baseUrl: z.string().min(1), + stops: z.array(z.object({ + id: z.string().min(1), + order: z.number().int().nonnegative(), + frame: z.string().min(1), + title: z.string().optional(), + expect: z.array(z.string()), + caption: z.string().optional(), + })), +}); + +const VerdictSchema = z.object({ + watched_at: z.string().min(1), + frames: z.array(z.object({ + id: z.string().min(1), + requirement_met: z.union([z.boolean(), z.literal("partial")]), + evidence_seen: z.string().min(1), + concerns: z.array(z.string()).default([]), + })), + overall: z.object({ + frames_pass: z.number().int().nonnegative(), + frames_partial: z.number().int().nonnegative(), + frames_fail: z.number().int().nonnegative(), + verdict: z.enum(["pass", "fail"]), + summary: z.string().min(1), + }), + uiux_audit: z.object({ + findings: z.array(z.object({ + severity: z.enum(["high", "medium", "low"]), + description: z.string().min(1), + suggested_fix: z.string().optional(), + })).default([]), + top_priorities: z.array(z.string()).default([]), + }), +}); + +type Action = z.infer; +type DemoConfig = z.infer; +type DemoManifest = z.infer; +type DemoVerdict = z.infer; + +const DEMO_TEMPLATE = `# .keyoku/demo.yaml — Keyoku demo evidence: record -> watch -> gate +# +# This file describes a scripted walk through your running app. +# \`keyoku demo record\` drives a real browser through it and captures one +# screenshot per "stop"; \`keyoku demo watch\` has an agent look at those +# screenshots against the expectations you write below and writes a verdict +# you can gate on with \`keyoku demo watch --assert\`. + +# baseUrl: where the app is running (e.g. your local dev server). +baseUrl: http://localhost:3000 + +# viewport: optional, defaults to 1440x900. +# viewport: +# width: 1440 +# height: 900 + +# settleMs: how long to wait after navigating/acting, before the screenshot +# (default 2500). Give async UI (spinners, charts, animations) time to settle. +# settleMs: 2500 + +# fullPage: capture the full scrollable page, not just the viewport (default +# true). Can also be set per-stop below. +# fullPage: true + +# auth: optional. Runs ONCE, before the first stop — e.g. to log in. +# auth: +# url: /login +# steps: +# - fill: +# selector: "#email" +# value: "demo@example.com" +# - fill: +# selector: "#password" +# value: "demo-password" +# - click: "button[type=submit]" +# - waitMs: 1000 + +# stops: the ordered walk through the product. Each stop navigates (if url is +# given, relative to baseUrl or absolute), runs its actions in order, waits +# settleMs, then takes one screenshot ("frame"). +# +# expect: REQUIRED, at least one per stop. Plain-language, human-readable +# assertions about what must be VISIBLE in that frame — this is what the +# watching agent checks the screenshot against. +stops: + - id: dashboard + title: Dashboard + url: /dashboard + expect: + - "The main navigation is visible" + - "At least one summary metric card is rendered with a non-empty value" + caption: "Landing view after login" + + # - id: settings + # url: /settings + # actions: + # - click: "#nav-settings" + # - waitMs: 500 + # expect: + # - "The settings form is visible with labeled fields" + # caption: "Settings page" +`; + +function configPath(root: string): string { + return join(root, KEYOKU_DIR, "demo.yaml"); +} + +function demoDir(root: string): string { + return join(root, KEYOKU_DIR, "demo"); +} + +function manifestPath(root: string): string { + return join(demoDir(root), "manifest.json"); +} + +function verdictPath(root: string): string { + return join(demoDir(root), "verdict.json"); +} + +function demoInit(): void { + const root = resolve(process.cwd()); + mkdirSync(join(root, KEYOKU_DIR), { recursive: true }); + const path = configPath(root); + if (existsSync(path)) { + throw new Error(`${relative(root, path) || "demo.yaml"} already exists; Keyoku will not overwrite it.`); + } + writeFileSync(path, DEMO_TEMPLATE, "utf8"); + console.log(`Created ${relative(root, path)}. + +Edit baseUrl and stops for your app, then: + keyoku demo record Launch a real browser, capture one screenshot per stop + keyoku demo watch An agent watches the frames, writes .keyoku/demo/verdict.json + keyoku demo watch --assert Exit 0 only if the watched demo passed AND is fresh + +Paste this into an outcome's criteria (.keyoku/outcomes/.yaml) to make the +watched demo part of the proof: + + - description: "The recorded product demo passes agent review" + probe: + kind: command + run: "keyoku demo record && keyoku demo watch --assert" + timeoutMs: 900000 + assert: + path: exitCode + op: eq + value: 0 + evidence: + summary: "An agent watched the recorded demo frames against their stated expectations and ran a UI/UX audit." + whyItMatters: "The demo is recorded evidence, not a claim about it — the same frames a human would watch are what the agent checked." + code: [] + artifacts: + - kind: screenshot + path: ".keyoku/demo/frames/*.jpeg" + label: "Recorded demo frames" + caption: "One frame per stop, captured by keyoku demo record" +`); +} + +function readDemoConfig(path: string): DemoConfig { + let raw: unknown; + try { + raw = parse(readFileSync(path, "utf8")); + } catch (error) { + throw new Error(`Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`); + } + const result = DemoConfigSchema.safeParse(raw); + if (!result.success) { + throw new Error(`Invalid ${path}: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`); + } + return result.data; +} + +/** Resolve playwright from the TARGET project (the one being demoed), not + * from keyoku's own install — keyoku itself does not depend on playwright. + * The `specifier` indirection keeps this a non-literal dynamic import so + * TypeScript doesn't try (and fail) to resolve playwright's types at + * keyoku's own compile time. */ +async function loadChromium(root: string): Promise<{ launch: (options?: Record) => Promise }> { + let mod: any; + try { + const req = createRequire(join(root, "package.json")); + const resolved = req.resolve("playwright"); + mod = await import(pathToFileURL(resolved).href); + } catch { + try { + const specifier = "playwright"; + mod = await import(specifier); + } catch { + throw new Error( + "Playwright is not installed in this project.\n" + + "Run `npm i -D playwright` (and `npx playwright install chromium` if browsers " + + "aren't installed yet), then re-run `keyoku demo record`.", + ); + } + } + return mod.chromium; +} + +function resolveAgainst(baseUrl: string, target: string): string { + if (/^https?:\/\//i.test(target)) return target; + return `${baseUrl.replace(/\/+$/, "")}/${target.replace(/^\/+/, "")}`; +} + +async function runAction(page: any, action: Action, baseUrl: string): Promise { + if ("goto" in action) { + await page.goto(resolveAgainst(baseUrl, action.goto)); + } else if ("click" in action) { + await page.click(action.click); + } else if ("fill" in action) { + await page.fill(action.fill.selector, action.fill.value); + } else if ("select" in action) { + await page.selectOption(action.select.selector, { label: action.select.label }); + } else if ("press" in action) { + await page.keyboard.press(action.press); + } else { + await page.waitForTimeout(action.waitMs); + } +} + +async function demoRecord(): Promise { + const root = resolve(process.cwd()); + const path = configPath(root); + if (!existsSync(path)) { + throw new Error(`No ${relative(root, path)} found. Run 'keyoku demo init' first.`); + } + const config = readDemoConfig(path); + const chromium = await loadChromium(root); + const framesDir = join(demoDir(root), "frames"); + mkdirSync(framesDir, { recursive: true }); + + console.log(`Recording ${config.stops.length} stop(s) from ${config.baseUrl}…`); + const browser = await chromium.launch(); + const results: DemoManifest["stops"] = []; + const failures: string[] = []; + try { + const context = await browser.newContext({ viewport: config.viewport ?? { width: 1440, height: 900 } }); + const page = await context.newPage(); + + if (config.auth) { + await page.goto(resolveAgainst(config.baseUrl, config.auth.url)); + for (const action of config.auth.steps) await runAction(page, action, config.baseUrl); + } + + let order = 0; + for (const stop of config.stops) { + order += 1; + try { + if (stop.url) await page.goto(resolveAgainst(config.baseUrl, stop.url)); + for (const action of stop.actions) await runAction(page, action, config.baseUrl); + await page.waitForTimeout(config.settleMs); + const frameName = `${String(order).padStart(2, "0")}-${stop.id}.jpeg`; + const framePath = join(framesDir, frameName); + await page.screenshot({ + path: framePath, + type: "jpeg", + quality: 80, + animations: "disabled", + fullPage: stop.fullPage ?? config.fullPage, + }); + results.push({ + id: stop.id, + order, + frame: `${KEYOKU_DIR}/demo/frames/${frameName}`, + ...(stop.title ? { title: stop.title } : {}), + expect: stop.expect, + ...(stop.caption ? { caption: stop.caption } : {}), + }); + console.log(` [${order}/${config.stops.length}] ${stop.id} -> ${frameName}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`${stop.id}: ${message}`); + console.error(` [${order}/${config.stops.length}] ${stop.id} FAILED — ${message}`); + } + } + } finally { + await browser.close(); + } + + const manifest: DemoManifest = { + recordedAt: new Date().toISOString(), + baseUrl: config.baseUrl, + stops: results, + }; + mkdirSync(demoDir(root), { recursive: true }); + writeFileSync(manifestPath(root), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + + console.log(`\nRecorded ${results.length}/${config.stops.length} stop(s) -> ${relative(root, manifestPath(root))}`); + if (failures.length > 0) { + console.error(`\n${failures.length} stop(s) failed:\n${failures.map((f) => ` - ${f}`).join("\n")}`); + process.exitCode = 1; + } +} + +function buildWatchPrompt(root: string, manifest: DemoManifest): string { + const target = verdictPath(root); + const framesBlock = manifest.stops + .map((stop) => { + const heading = `Stop "${stop.id}"${stop.title ? ` (${stop.title})` : ""} — frame: ${stop.frame}`; + const expectations = stop.expect.map((e) => ` - ${e}`).join("\n"); + const caption = stop.caption ? `\n Caption: ${stop.caption}` : ""; + return `${heading}\n Must be visible / true in this frame:\n${expectations}${caption}`; + }) + .join("\n\n"); + + return `You are reviewing a recorded product demo captured as a sequence of screenshots ("frames"), one per "stop". Base URL: ${manifest.baseUrl}. Recorded at: ${manifest.recordedAt}. + +For EACH stop below, open its frame (an image file — actually look at it, do not assume) and: + (a) Check every "must be visible" expectation against what is ACTUALLY visible in the frame. + (b) Record requirement_met as true (every expectation for this stop is clearly satisfied), false (something required is missing, wrong, or broken), or "partial" (some expectations met, some not, or ambiguous) — plus a short evidence_seen note describing what you actually observed, and any concerns. + +Stops: + +${framesBlock} + +ALSO run a UI/UX audit across ALL the frames together, independent of the per-stop expectations above: visual hierarchy, information density, text truncation, empty or broken charts/tables, color semantics (e.g. red/green misuse), and cross-page consistency (spacing, typography, component reuse). Produce findings with a severity of "high", "medium", or "low" and a suggested_fix for each; then pick the handful that matter most as top_priorities. + +When you are done, WRITE the file ${target} containing EXACTLY this JSON shape (no markdown code fences, no extra top-level keys): + +{ + "watched_at": "", + "frames": [ + { "id": "", "requirement_met": true, "evidence_seen": "", "concerns": [] } + ], + "overall": { + "frames_pass": , + "frames_partial": , + "frames_fail": , + "verdict": "pass", + "summary": "<1-3 sentence summary of what the demo shows and any risk>" + }, + "uiux_audit": { + "findings": [ + { "severity": "medium", "description": "", "suggested_fix": "" } + ], + "top_priorities": [""] + } +} + +Rules for "overall.verdict": it must be "pass" if and only if NO frame has requirement_met === false. If any frame is false, "overall.verdict" must be "fail". + +Contract note for any agent runner substituted for this CLI wrapper: the only hard requirement is that ${target} exists after the run and matches this exact shape — how you get there (tool calls, reasoning) is up to you.`; +} + +async function demoWatch(rest: string[]): Promise { + const assertMode = rest.includes("--assert"); + const root = resolve(process.cwd()); + const mPath = manifestPath(root); + if (!existsSync(mPath)) { + throw new Error(`No ${relative(root, mPath)} found. Run 'keyoku demo record' first.`); + } + let manifest: DemoManifest; + { + let raw: unknown; + try { + raw = JSON.parse(readFileSync(mPath, "utf8")); + } catch (error) { + throw new Error(`Cannot parse ${mPath}: ${error instanceof Error ? error.message : String(error)}`); + } + const result = ManifestSchema.safeParse(raw); + if (!result.success) { + throw new Error(`Invalid ${mPath}: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`); + } + manifest = result.data; + } + + const availability = spawnSync("claude", ["--version"], { stdio: "ignore" }); + if (availability.status !== 0) { + console.error( + "No agent runner available: the `claude` CLI was not found on PATH.\n" + + "`keyoku demo watch` needs an agent that can view images and write a file.\n" + + `Any runner may be substituted, as long as it writes ${verdictPath(root)} matching the\n` + + "verdict contract (watched_at, frames[], overall{frames_pass,frames_partial,frames_fail,verdict,summary}, uiux_audit{findings[],top_priorities[]}) — see docs/demo-evidence.md.", + ); + process.exit(2); + } + + const prompt = buildWatchPrompt(root, manifest); + console.log(`Watching ${manifest.stops.length} frame(s) with \`claude\`…`); + const run = spawnSync("claude", ["-p", prompt, "--permission-mode", "acceptEdits"], { + cwd: root, + stdio: "inherit", + }); + if (run.status !== 0) { + console.error(`\`claude\` exited with status ${run.status ?? "unknown"}.`); + process.exit(run.status && run.status > 0 ? run.status : 1); + } + + const vPath = verdictPath(root); + if (!existsSync(vPath)) { + console.error(`The agent run finished but ${vPath} was not written. See the verdict contract in docs/demo-evidence.md.`); + process.exit(assertMode ? 1 : 2); + } + let verdict: DemoVerdict; + try { + const raw = JSON.parse(readFileSync(vPath, "utf8")); + const result = VerdictSchema.safeParse(raw); + if (!result.success) { + throw new Error(result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")); + } + verdict = result.data; + } catch (error) { + console.error(`${vPath} does not match the verdict contract: ${error instanceof Error ? error.message : String(error)}`); + process.exit(assertMode ? 1 : 2); + return; + } + + console.log( + `\nVerdict: ${verdict.overall.verdict.toUpperCase()} — ${verdict.overall.frames_pass} pass, ${verdict.overall.frames_partial} partial, ${verdict.overall.frames_fail} fail`, + ); + console.log(verdict.overall.summary); + const failing = verdict.frames.filter((f) => f.requirement_met !== true); + if (failing.length > 0) { + console.log("\nFrames needing attention:"); + for (const f of failing) { + const tag = f.requirement_met === false ? "FAIL" : "PARTIAL"; + console.log(` [${tag}] ${f.id} — ${f.evidence_seen}${f.concerns.length ? ` (${f.concerns.join("; ")})` : ""}`); + } + } + if (verdict.uiux_audit.findings.length > 0) { + console.log(`\nUI/UX audit: ${verdict.uiux_audit.findings.length} finding(s)`); + for (const finding of verdict.uiux_audit.findings.slice(0, 5)) { + console.log(` [${finding.severity}] ${finding.description}`); + } + } + + if (!assertMode) return; + + const fresh = new Date(verdict.watched_at).getTime() > new Date(manifest.recordedAt).getTime(); + if (verdict.overall.verdict === "pass" && fresh) { + console.log("\nkeyoku demo watch --assert: PASS"); + process.exitCode = 0; + return; + } + console.error( + `\nkeyoku demo watch --assert: FAIL — ${ + verdict.overall.verdict !== "pass" + ? `verdict is '${verdict.overall.verdict}' (${verdict.overall.frames_fail} failing frame(s))` + : `verdict.watched_at (${verdict.watched_at}) is not newer than manifest.recordedAt (${manifest.recordedAt}); re-run 'keyoku demo record' then 'keyoku demo watch'` + }`, + ); + process.exitCode = 1; +} + +export async function demoCmd(args: string[]): Promise { + const [sub, ...rest] = args; + if (sub === "init") return demoInit(); + if (sub === "record") return demoRecord(); + if (sub === "watch") return demoWatch(rest); + throw new Error("Usage: keyoku demo init|record|watch [--assert]"); +} diff --git a/src/types.ts b/src/types.ts index b268440..860d703 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,7 +18,10 @@ export const CommandProbeSchema = z.object({ .min(1) .describe("Shell command to execute. Its stdout becomes the probe output."), cwd: z.string().optional().describe("Working directory for the command."), - timeoutMs: z.number().int().positive().max(300_000).optional(), + // Cap raised from 5min to 15min (300_000 -> 900_000): real frontend + // production builds (and demo record/watch pipelines) routinely exceed 5 + // minutes — the old cap made those probes untimeoutable-but-still-fail. + timeoutMs: z.number().int().positive().max(900_000).optional(), parse: ParseModeSchema.optional(), }); @@ -28,7 +31,7 @@ export const HttpProbeSchema = z.object({ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]).optional(), headers: z.record(z.string()).optional(), body: z.string().optional(), - timeoutMs: z.number().int().positive().max(300_000).optional(), + timeoutMs: z.number().int().positive().max(900_000).optional(), parse: ParseModeSchema.optional(), }); From 7521c0a468fce0dfc5536553541091d916302372 Mon Sep 17 00:00:00 2001 From: Tye Date: Sun, 23 Aug 2026 11:30:19 -0700 Subject: [PATCH 04/28] feat(factfile): redesign proof page visual-proof-first, monotone dual-theme Replace the purple-gradient dossier/dashboard layout with a single-scroll, grayscale light/dark (prefers-color-scheme) Factfile: a demo-replay hero (auto-advancing screenshot filmstrip, or a terminal-style CLI replay of every probe when no visual artifacts exist), a short verdict summary, an Insight section for pending human decisions and proposed directions, and everything else (full evidence, work log, session, repository info) reachable in collapsed
folds. New minimal monotone logo mark. Preserves all existing live-session behavior (decision/direction POSTs, EventSource refresh, theme toggle) and self-contained CSP constraints. Co-Authored-By: Claude Fable 5 --- scripts/rerender.mjs | 76 ++ src/contribution.ts | 1703 +++++++++++++++++++++++++++++++++++ tests/contribution.test.ts | 332 +++++++ tests/proof-session.test.ts | 119 +++ 4 files changed, 2230 insertions(+) create mode 100644 scripts/rerender.mjs create mode 100644 src/contribution.ts create mode 100644 tests/contribution.test.ts create mode 100644 tests/proof-session.test.ts diff --git a/scripts/rerender.mjs b/scripts/rerender.mjs new file mode 100644 index 0000000..cb09418 --- /dev/null +++ b/scripts/rerender.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Re-renders a Factfile HTML from an existing factfile.json without re-running any probes. +// +// Usage: +// node scripts/rerender.mjs [output.html] +// node scripts/rerender.mjs [output.html] --with-demo-watch +// +// --with-demo-watch optionally patches in the "demo-watch-pass" criterion's +// screenshot/report artifacts (base64-embedded, same as resolveEvidencePresentation does at +// gate time) so the filmstrip hero can be exercised even when the on-disk factfile.json predates +// that criterion. It never writes back to the source project — only to this script's output. + +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { dirname, resolve } from "node:path"; +import { renderFactfileHtml } from "../dist/index.js"; + +const args = process.argv.slice(2); +const input = args[0]; +const output = args[1] && !args[1].startsWith("--") ? args[1] : "preview-factfile.html"; +const demoWatchFlagIndex = args.indexOf("--with-demo-watch"); +const demoWatchRoot = demoWatchFlagIndex !== -1 ? args[demoWatchFlagIndex + 1] : undefined; + +if (!input) { + console.error("Usage: node scripts/rerender.mjs [output.html] [--with-demo-watch ]"); + process.exit(2); +} + +const snapshot = JSON.parse(readFileSync(resolve(input), "utf8")); + +if (demoWatchRoot) { + const artifactSpecs = [ + { kind: "screenshot", path: "demo-captures/01-cfo-hero.jpeg", label: "CFO executive glance", caption: "Score 58/critical, 20 controls (12 key/8 non-key), systems 4, entities 5, control/risk deltas +2." }, + { kind: "screenshot", path: "demo-captures/09-reviewer-full.jpeg", label: "Reviewer dashboard (new persona)", caption: "Pending review 6, reviewed 3, avg 4.0 days waiting, pending-by-tester breakdown." }, + { kind: "screenshot", path: "demo-captures/12-pbc-testing-period.jpeg", label: "PBC Testing Period column", caption: "Every evidence request shows the testing period it covers." }, + { kind: "report", path: ".keyoku/contributions/dashboards-feedback-round-2026-08-23-0ad97b6a/demo-walkthrough.html", label: "Full demo walkthrough", caption: "All 14 captioned stops, published as a shareable gallery." }, + ]; + const artifacts = artifactSpecs.map((spec) => { + const absolute = resolve(demoWatchRoot, spec.path); + if (!existsSync(absolute) || !statSync(absolute).isFile()) return { ...spec, annotations: [], unavailable: "Artifact was not found for this preview." }; + const bytes = readFileSync(absolute); + const digest = createHash("sha256").update(bytes).digest("hex"); + if (spec.kind !== "screenshot" && spec.kind !== "video") return { ...spec, annotations: [], digest }; + const lower = spec.path.toLowerCase(); + const mediaType = lower.endsWith(".jpeg") || lower.endsWith(".jpg") ? "image/jpeg" : lower.endsWith(".png") ? "image/png" : lower.endsWith(".webp") ? "image/webp" : undefined; + if (!mediaType) return { ...spec, annotations: [], digest, unavailable: "Unsupported screenshot format." }; + return { ...spec, annotations: [], digest, mediaType, dataUrl: `data:${mediaType};base64,${bytes.toString("base64")}` }; + }); + snapshot.evidence.push({ + id: "c8", + description: "An agent watched the recorded demo and every stop's expectations are visibly met", + pass: true, + actual: { exitCode: 0 }, + expected: { path: "exitCode", op: "eq", value: 0 }, + durationMs: 41200, + verification: { + kind: "command", + label: "Repository command", + reproduce: "bash .keyoku/probes/demo-watch-pass.sh", + assertion: { path: "exitCode", op: "eq", value: 0 }, + }, + presentation: { + summary: "A vision agent reviewed all 14 Playwright-captured demo frames against their declared expectations (and ran a UI/UX audit); the verdict is pass and postdates the frames.", + whyItMatters: "Humans digest demos — this proves the demo a stakeholder would watch actually shows the claimed behavior, not just that endpoints return 200.", + code: [], + artifacts, + }, + }); + snapshot.summary = { ...snapshot.summary, passed: snapshot.summary.passed + 1, total: snapshot.summary.total + 1 }; + console.error(`Patched in criterion c8 (demo-watch-pass) with ${artifacts.filter((a) => a.dataUrl).length} embedded screenshot(s) for filmstrip verification.`); +} + +const html = renderFactfileHtml(snapshot, {}); +const target = resolve(output); +writeFileSync(target, html, "utf8"); +console.log(target); diff --git a/src/contribution.ts b/src/contribution.ts new file mode 100644 index 0000000..73fd5ee --- /dev/null +++ b/src/contribution.ts @@ -0,0 +1,1703 @@ +import { createHash, randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { parse, stringify } from "yaml"; +import { z } from "zod"; + +import { redactSecrets } from "./activity.js"; +import { renderArchitectureSvg, scanArchitecture, type ArchitectureProjection } from "./architecture.js"; +import { ConnectorManager } from "./connectors.js"; +import { Harness } from "./engine.js"; +import { Store } from "./store.js"; +import { readProofSession, type ProofSessionState } from "./proof-session.js"; +import { + CriterionInputSchema, + type ConvergenceReport, + type CriterionInput, +} from "./types.js"; + +export const KEYOKU_DIR = ".keyoku"; +export const PROJECT_FILE = "project.yaml"; + +const SlugSchema = z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9._-]*$/, "must be lowercase letters, numbers, dots, dashes, or underscores"); + +export const ActorSchema = z.object({ + kind: z.enum(["human", "agent", "organization"]), + id: z.string().min(1), + name: z.string().min(1), + role: z.string().optional(), + ownerId: z.string().optional(), + harness: z.string().optional(), + model: z.string().optional(), +}); + +export const ProjectManifestSchema = z.object({ + schemaVersion: z.literal("keyoku.dev/project/v1alpha1"), + id: SlugSchema, + name: z.string().min(1), + summary: z.string().min(1), + repository: z.string().optional(), + defaultBranch: z.string().optional(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +const EvidencePresentationSchema = z.object({ + summary: z.string().min(1), + whyItMatters: z.string().min(1), + code: z.array(z.object({ + path: z.string().min(1), + purpose: z.string().min(1), + })).default([]), + artifacts: z.array(z.object({ + kind: z.enum(["screenshot", "video", "trace", "report", "log", "code"]), + path: z.string().min(1), + label: z.string().min(1), + caption: z.string().min(1), + annotations: z.array(z.object({ + label: z.string().min(1), + detail: z.string().optional(), + x: z.number().min(0).max(100).optional(), + y: z.number().min(0).max(100).optional(), + width: z.number().positive().max(100).optional(), + height: z.number().positive().max(100).optional(), + atMs: z.number().int().nonnegative().optional(), + })).default([]), + })).default([]), +}); + +const OutcomeCriterionSchema = CriterionInputSchema.extend({ + evidence: EvidencePresentationSchema.optional(), +}); + +export const OutcomeSchema = z.object({ + schemaVersion: z.literal("keyoku.dev/outcome/v1alpha1"), + id: SlugSchema, + revision: z.number().int().positive(), + title: z.string().min(1), + objective: z.string().min(1), + owner: ActorSchema, + constraints: z.array(z.string()), + scope: z.object({ + include: z.array(z.string().min(1)).default([]), + exclude: z.array(z.string().min(1)).default([]), + maxChangedFiles: z.number().int().positive().optional(), + }).optional(), + criteria: z.array(OutcomeCriterionSchema).min(1), + humanCriteria: z.array(z.object({ + id: SlugSchema, + description: z.string().min(1), + guidance: z.string().optional(), + })).default([]), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export const ContributionManifestSchema = z.object({ + schemaVersion: z.literal("keyoku.dev/contribution/v1alpha1"), + id: SlugSchema, + title: z.string().min(1), + summary: z.string().min(1).optional(), + knownLimits: z.array(z.string().min(1)).optional(), + outcomeId: SlugSchema, + outcomeRevision: z.number().int().positive(), + outcomeDigest: z.string().regex(/^[a-f0-9]{64}$/).optional(), + baseSha: z.string().min(1), + actors: z.array(ActorSchema).min(1), + status: z.enum(["draft", "evaluating", "evidence_gaps", "human_review_required", "review_blocked", "ready_for_review", "accepted"]), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export const ReviewEventSchema = z.object({ + id: SlugSchema, + decision: z.enum(["note", "accepted"]), + reviewer: ActorSchema.refine((actor) => actor.kind === "human", "reviewer must be a human"), + comment: z.string().min(1), + criterionId: SlugSchema.optional(), + verdict: z.enum(["pass", "fail"]).optional(), + reviewedAt: z.string().datetime(), + factfileId: SlugSchema, + factfileDigest: z.string().regex(/^[a-f0-9]{64}$/), + repository: z.object({ + headSha: z.string().min(1), + worktreeDigest: z.string().regex(/^[a-f0-9]{64}$/), + }), +}).refine((event) => Boolean(event.criterionId) === Boolean(event.verdict), { + message: "criterionId and verdict must be supplied together", +}); + +export type Actor = z.infer; +export type ProjectManifest = z.infer; +export type Outcome = z.infer; +export type ContributionManifest = z.infer; +export type ReviewEvent = z.infer; +export type EvidencePresentation = z.infer; + +export interface OutcomeHistoryEntry { + sha: string; + authoredAt: string; + author: string; + subject: string; + revision?: number; +} + +export interface ResolvedEvidencePresentation extends Omit { + artifacts: Array; +} + +export interface VerificationMethod { + kind: "command" | "http" | "mcp"; + label: string; + reproduce: string; + assertion: ConvergenceReport["criteria"][number]["expected"]; +} + +export interface RepositorySnapshot { + repositoryRoot: string; + branch: string; + upstream?: string; + ahead: number; + behind: number; + remote?: string; + lastCommit: string; + baseSha: string; + headSha: string; + worktreeDigest: string; + dirty: boolean; + changedFiles: string[]; +} + +export interface ScopeAssessment { + declared: boolean; + passed: boolean; + includedPaths: string[]; + unexpectedPaths: string[]; + excludedPaths: string[]; + maxChangedFiles?: number; + topLevelAreas: Array<{ name: string; files: number }>; + note: string; +} + +export interface ReviewAttentionItem { + priority: "critical" | "high" | "normal"; + title: string; + why: string; + paths: string[]; + basis: "deterministic" | "declared"; +} + +export interface GateSnapshot { + schemaVersion: "keyoku.dev/factfile/v1alpha1"; + id: string; + project: Pick; + outcome: Pick; + contribution: ContributionManifest; + repository: RepositorySnapshot; + scope: ScopeAssessment; + reviewPlan: ReviewAttentionItem[]; + session: ProofSessionState; + architecture?: ArchitectureProjection; + state: "evidence_gaps" | "human_review_required" | "review_blocked" | "ready_for_review" | "accepted"; + generatedAt: string; + reviews: ReviewEvent[]; + evidence: Array; + summary: { + passed: number; + failed: number; + total: number; + verified: boolean; + }; + humanReview: { + passed: number; + failed: number; + pending: number; + total: number; + }; + digest: string; +} + +export interface FactfileHistoryItem { + id: string; + generatedAt: string; + state: GateSnapshot["state"]; + digest: string; + headSha: string; + worktreeDigest: string; + passed: number; + total: number; + humanPassed: number; + humanTotal: number; +} + +export interface InitProjectInput { + root?: string; + id?: string; + name?: string; + summary?: string; +} + +export interface StartContributionInput { + root?: string; + outcomeId: string; + title?: string; + summary?: string; + knownLimits?: string[]; + actor?: Actor; + baseSha?: string; + reuseActive?: boolean; +} + +export interface ReviewContributionInput { + root?: string; + contributionId: string; + decision: "note" | "accepted"; + comment: string; + criterionId?: string; + verdict?: "pass" | "fail"; + reviewer?: Actor; +} + +function now(): string { + return new Date().toISOString(); +} + +function slug(value: string): string { + const normalized = value + .toLowerCase() + .trim() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ""); + return normalized || "project"; +} + +function hash(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function git(root: string, args: string[], fallback = "unknown"): string { + try { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() || fallback; + } catch { + return fallback; + } +} + +function gitRaw(root: string, args: string[]): string { + try { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + return ""; + } +} + +export function findProjectRoot(start = process.cwd()): string { + let cursor = resolve(start); + for (;;) { + if (existsSync(join(cursor, KEYOKU_DIR, PROJECT_FILE))) return cursor; + const parent = dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + throw new Error(`No ${KEYOKU_DIR}/${PROJECT_FILE} found from ${resolve(start)}. Run 'keyoku project init' first.`); +} + +function readYaml(path: string, schema: S): z.output { + let value: unknown; + try { + value = parse(readFileSync(path, "utf8")); + } catch (error) { + throw new Error(`Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`); + } + const result = schema.safeParse(value); + if (!result.success) { + throw new Error(`Invalid ${path}: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`); + } + return result.data; +} + +function writeYaml(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, stringify(value, { lineWidth: 100 }), "utf8"); +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function readReviews(root: string, contributionId: string): ReviewEvent[] { + const path = join(contributionDir(root, contributionId), "reviews.jsonl"); + if (!existsSync(path)) return []; + return readFileSync(path, "utf8") + .split("\n") + .filter(Boolean) + .map((line, index) => { + let value: unknown; + try { value = JSON.parse(line); } catch (error) { + throw new Error(`Invalid ${relative(root, path)} line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`); + } + const result = ReviewEventSchema.safeParse(value); + if (!result.success) throw new Error(`Invalid review event at line ${index + 1}: ${result.error.message}`); + return result.data; + }); +} + +export function listFactfileHistory(rootInput: string, contributionId: string): FactfileHistoryItem[] { + const root = findProjectRoot(rootInput); + const dir = join(contributionDir(root, contributionId), "snapshots"); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((name) => name.endsWith(".json")) + .flatMap((name) => { + try { + const item = JSON.parse(readFileSync(join(dir, name), "utf8")) as GateSnapshot; + return [{ + id: item.id, + generatedAt: item.generatedAt, + state: item.state, + digest: item.digest, + headSha: item.repository.headSha, + worktreeDigest: item.repository.worktreeDigest, + passed: item.summary.passed, + total: item.summary.total, + humanPassed: item.humanReview.passed, + humanTotal: item.humanReview.total, + } satisfies FactfileHistoryItem]; + } catch { return []; } + }) + .sort((a, b) => b.generatedAt.localeCompare(a.generatedAt)); +} + +function persistSnapshot(root: string, contribution: ContributionManifest, snapshot: GateSnapshot): void { + const dir = contributionDir(root, contribution.id); + const { digest: _previousDigest, ...unsignedSnapshot } = snapshot; + snapshot.digest = hash(stableJson(unsignedSnapshot)); + writeYaml(join(dir, "manifest.yaml"), contribution); + writeJson(join(dir, "snapshots", `${snapshot.id}.json`), snapshot); + const history = listFactfileHistory(root, contribution.id); + writeFileSync(join(dir, "snapshots", `${snapshot.id}.html`), renderFactfileHtml(snapshot, { history, historical: true }), "utf8"); + writeJson(join(dir, "factfile.json"), snapshot); + writeFileSync(join(dir, "factfile.md"), renderFactfileMarkdown(snapshot), "utf8"); + writeFileSync(join(dir, "factfile.github.md"), renderFactfileGithubMarkdown(snapshot), "utf8"); + writeFileSync(join(dir, "factfile.html"), renderFactfileHtml(snapshot, { history }), "utf8"); +} + +function remoteUrl(root: string): string | undefined { + const value = git(root, ["config", "--get", "remote.origin.url"], ""); + return value || undefined; +} + +export function initProject(input: InitProjectInput = {}): ProjectManifest { + const root = resolve(input.root ?? process.cwd()); + const path = join(root, KEYOKU_DIR, PROJECT_FILE); + if (existsSync(path)) throw new Error(`${relative(root, path)} already exists; Keyoku will not overwrite it.`); + const timestamp = now(); + const name = input.name?.trim() || basename(root); + const manifest: ProjectManifest = { + schemaVersion: "keyoku.dev/project/v1alpha1", + id: slug(input.id ?? name), + name, + summary: input.summary?.trim() || `Outcomes and contribution evidence for ${name}.`, + ...(remoteUrl(root) ? { repository: remoteUrl(root) } : {}), + defaultBranch: git(root, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], "").replace(/^origin\//, "") || "main", + createdAt: timestamp, + updatedAt: timestamp, + }; + writeYaml(path, manifest); + mkdirSync(join(root, KEYOKU_DIR, "outcomes"), { recursive: true }); + mkdirSync(join(root, KEYOKU_DIR, "contributions"), { recursive: true }); + return manifest; +} + +export function loadProject(root = findProjectRoot()): ProjectManifest { + return readYaml(join(root, KEYOKU_DIR, PROJECT_FILE), ProjectManifestSchema); +} + +export function loadOutcome(root: string, id: string): Outcome { + return readYaml(join(root, KEYOKU_DIR, "outcomes", `${slug(id)}.yaml`), OutcomeSchema); +} + +export function listOutcomes(root = findProjectRoot()): Outcome[] { + const dir = join(root, KEYOKU_DIR, "outcomes"); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((name) => name.endsWith(".yaml") || name.endsWith(".yml")) + .sort() + .map((name) => readYaml(join(dir, name), OutcomeSchema)); +} + +/** Outcome contracts are normal repository files. Their canonical version + * history is Git, so reviewers do not need a second opaque database. */ +export function listOutcomeHistory(rootInput: string | undefined, id: string): OutcomeHistoryEntry[] { + const root = findProjectRoot(rootInput); + const path = `${KEYOKU_DIR}/outcomes/${slug(id)}.yaml`; + const output = gitRaw(root, ["log", "--follow", "--format=%H%x1f%aI%x1f%an%x1f%s", "--", path]); + return output.split("\n").filter(Boolean).map((line) => { + const [sha = "unknown", authoredAt = "unknown", author = "unknown", subject = ""] = line.split("\x1f"); + let revision: number | undefined; + try { + const contents = execFileSync("git", ["show", `${sha}:${path}`], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + revision = OutcomeSchema.parse(parse(contents)).revision; + } catch { /* a historical revision may predate the current schema */ } + return { sha, authoredAt, author, subject, ...(revision ? { revision } : {}) }; + }); +} + +function defaultActor(root: string): Actor { + const email = git(root, ["config", "user.email"], "local-human"); + const name = git(root, ["config", "user.name"], "Local human"); + return { kind: "human", id: email, name, role: "accountable owner" }; +} + +function contributionDir(root: string, id: string): string { + return join(root, KEYOKU_DIR, "contributions", slug(id)); +} + +interface ActiveContributionIndex { schemaVersion: "keyoku.dev/active-contributions/v1alpha1"; active: Record; } + +function activeContributionKey(root: string, outcomeId: string): string { + return `${git(root, ["branch", "--show-current"], "detached")}:${slug(outcomeId)}`; +} + +function activeContributionPath(root: string): string { return join(root, KEYOKU_DIR, "runtime", "active-contributions.json"); } + +function readActiveIndex(root: string): ActiveContributionIndex { + const path = activeContributionPath(root); + if (!existsSync(path)) return { schemaVersion: "keyoku.dev/active-contributions/v1alpha1", active: {} }; + try { + const value = JSON.parse(readFileSync(path, "utf8")) as ActiveContributionIndex; + return value.schemaVersion === "keyoku.dev/active-contributions/v1alpha1" && value.active ? value : { schemaVersion: "keyoku.dev/active-contributions/v1alpha1", active: {} }; + } catch { return { schemaVersion: "keyoku.dev/active-contributions/v1alpha1", active: {} }; } +} + +export function getActiveContribution(rootInput: string | undefined, outcomeId: string): ContributionManifest | undefined { + const root = findProjectRoot(rootInput); + const id = readActiveIndex(root).active[activeContributionKey(root, outcomeId)]; + if (!id) return undefined; + try { + const contribution = loadContribution(root, id); + const outcome = loadOutcome(root, outcomeId); + const digest = hash(stableJson(outcome)); + return contribution.outcomeRevision === outcome.revision && (!contribution.outcomeDigest || contribution.outcomeDigest === digest) && contribution.status !== "accepted" ? contribution : undefined; + } catch { return undefined; } +} + +function setActiveContribution(root: string, outcomeId: string, contributionId: string): void { + const index = readActiveIndex(root); + index.active[activeContributionKey(root, outcomeId)] = contributionId; + writeJson(activeContributionPath(root), index); +} + +export function loadContribution(root: string, id: string): ContributionManifest { + return readYaml(join(contributionDir(root, id), "manifest.yaml"), ContributionManifestSchema); +} + +export function startContribution(input: StartContributionInput): ContributionManifest { + const root = findProjectRoot(input.root); + const outcome = loadOutcome(root, input.outcomeId); + if (input.reuseActive) { + const active = getActiveContribution(root, outcome.id); + if (active) return active; + } + const timestamp = now(); + const id = slug(`${outcome.id}-${timestamp.slice(0, 10)}-${randomUUID().slice(0, 8)}`); + const primaryActor = input.actor ?? defaultActor(root); + const actors = primaryActor.kind === "human" + ? [primaryActor] + : [outcome.owner, primaryActor]; + const manifest: ContributionManifest = { + schemaVersion: "keyoku.dev/contribution/v1alpha1", + id, + title: input.title?.trim() || outcome.title, + summary: input.summary?.trim() || input.title?.trim() || outcome.title, + ...(input.knownLimits?.length ? { knownLimits: input.knownLimits } : {}), + outcomeId: outcome.id, + outcomeRevision: outcome.revision, + outcomeDigest: hash(stableJson(outcome)), + baseSha: git(root, ["rev-parse", input.baseSha ?? "HEAD"]), + actors, + status: "draft", + createdAt: timestamp, + updatedAt: timestamp, + }; + writeYaml(join(contributionDir(root, id), "manifest.yaml"), manifest); + setActiveContribution(root, outcome.id, id); + return manifest; +} + +function ignoredEvidencePath(path: string): boolean { + // Evidence artifacts describe the source snapshot; they are not themselves + // part of that snapshot. Excluding them prevents generating a Factfile from + // making its own proof stale. Outcome and project contracts remain included. + return path.startsWith(".keyoku/contributions/") || path.startsWith(".keyoku/runtime/"); +} + +export function captureRepository(root: string, baseSha: string): RepositorySnapshot { + const headSha = git(root, ["rev-parse", "HEAD"]); + const branch = git(root, ["branch", "--show-current"], "detached"); + const upstream = git(root, ["rev-parse", "--abbrev-ref", "@{upstream}"], ""); + const [behind = 0, ahead = 0] = upstream + ? git(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`], "0\t0").split(/\s+/).map((value) => Number(value) || 0) + : [0, 0]; + // Porcelain's first column may intentionally be a space. Do not pass this + // through git(), which trims output and would corrupt the first path. + const porcelain = gitRaw(root, ["status", "--porcelain=v1", "--untracked-files=all"]); + const worktreeFiles = porcelain + .split("\n") + .filter(Boolean) + .map((line) => line.slice(3).replace(/^.* -> /, "")) + .filter((path) => !ignoredEvidencePath(path)) + .sort(); + const committedFiles = git(root, ["diff", "--name-only", `${baseSha}...${headSha}`], "") + .split("\n") + .filter(Boolean) + .filter((path) => !ignoredEvidencePath(path)); + const changedFiles = [...new Set([...committedFiles, ...worktreeFiles])].sort(); + const digest = createHash("sha256"); + digest.update(`base\0${baseSha}\0head\0${headSha}\0`); + digest.update(gitRaw(root, ["diff", "--binary", `${baseSha}...${headSha}`])); + digest.update(gitRaw(root, ["diff", "--binary", "HEAD"])); + digest.update(gitRaw(root, ["diff", "--binary", "--cached", "HEAD"])); + for (const path of changedFiles) { + digest.update(`\0${path}\0`); + const absolute = join(root, path); + if (existsSync(absolute) && statSync(absolute).isFile() && porcelain.includes(`?? ${path}`)) { + digest.update(readFileSync(absolute)); + } + } + return { + repositoryRoot: root, + branch, + ...(upstream ? { upstream } : {}), + ahead, + behind, + ...(remoteUrl(root) ? { remote: remoteUrl(root) } : {}), + lastCommit: git(root, ["log", "-1", "--pretty=%s"], "unknown"), + baseSha, + headSha, + worktreeDigest: digest.digest("hex"), + dirty: changedFiles.length > 0, + changedFiles, + }; +} + +function pathMatches(path: string, pattern: string): boolean { + const normalized = pattern.replace(/^\.\//, ""); + if (normalized === "**" || normalized === "**/*") return true; + if (normalized.endsWith("/**")) return path === normalized.slice(0, -3) || path.startsWith(normalized.slice(0, -2)); + if (normalized.endsWith("/")) return path.startsWith(normalized); + if (!normalized.includes("*")) return path === normalized; + const escaped = normalized.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*"); + return new RegExp(`^${escaped}$`).test(path); +} + +function assessScope(outcome: Outcome, changedFiles: string[]): ScopeAssessment { + const declared = Boolean(outcome.scope); + const include = outcome.scope?.include ?? []; + const exclude = outcome.scope?.exclude ?? []; + const excludedPaths = changedFiles.filter((path) => exclude.some((pattern) => pathMatches(path, pattern))); + const considered = changedFiles.filter((path) => !excludedPaths.includes(path)); + const unexpectedPaths = include.length + ? considered.filter((path) => !include.some((pattern) => pathMatches(path, pattern))) + : []; + const sizePassed = !outcome.scope?.maxChangedFiles || considered.length <= outcome.scope.maxChangedFiles; + const passed = unexpectedPaths.length === 0 && sizePassed; + const areas = new Map(); + for (const path of considered) { + const name = path.includes("/") ? path.split("/")[0]! : "repository root"; + areas.set(name, (areas.get(name) ?? 0) + 1); + } + return { + declared, + passed, + includedPaths: considered.filter((path) => !unexpectedPaths.includes(path)), + unexpectedPaths, + excludedPaths, + ...(outcome.scope?.maxChangedFiles ? { maxChangedFiles: outcome.scope.maxChangedFiles } : {}), + topLevelAreas: [...areas].map(([name, files]) => ({ name, files })).sort((a, b) => b.files - a.files || a.name.localeCompare(b.name)), + note: !declared + ? "No machine scope boundary was declared; a human must judge whether this is one coherent review unit." + : passed + ? "All changed paths fit the declared contribution boundary. Semantic coherence still requires human review." + : "Changed paths exceed the declared contribution boundary.", + }; +} + +function buildReviewPlan( + outcome: Outcome, + repository: RepositorySnapshot, + scope: ScopeAssessment, + report: ConvergenceReport, + reviews: ReviewEvent[], +): ReviewAttentionItem[] { + const items: ReviewAttentionItem[] = []; + if (scope.unexpectedPaths.length) items.push({ + priority: "critical", + title: "Resolve work outside the declared outcome boundary", + why: "These paths were changed but do not match the repository-owned scope contract.", + paths: scope.unexpectedPaths.slice(0, 8), + basis: "deterministic", + }); + const failed = report.criteria.filter((criterion) => !criterion.pass); + if (failed.length) items.push({ + priority: "critical", + title: `Investigate ${failed.length} unsupported ${failed.length === 1 ? "claim" : "claims"}`, + why: failed.map((criterion) => criterion.description).join(" · "), + paths: [], + basis: "deterministic", + }); + const sensitive = repository.changedFiles.filter((path) => /(^|\/)(auth|security|permission|policy|migration|migrations|schema|secrets?|\.github\/workflows)(\/|\.|$)|(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|go\.sum)$/i.test(path)); + if (sensitive.length) items.push({ + priority: "high", + title: "Inspect security-, data-, workflow-, or dependency-sensitive changes", + why: "These paths can alter trust boundaries, persisted data, automation privileges, or the resolved dependency graph.", + paths: sensitive.slice(0, 8), + basis: "deterministic", + }); + if (repository.changedFiles.length > 30 || scope.topLevelAreas.length > 6) items.push({ + priority: "high", + title: "Confirm this is still one reviewable outcome", + why: `${repository.changedFiles.length} files across ${scope.topLevelAreas.length} top-level areas increases reconstruction cost; split or stack unrelated work.`, + paths: [], + basis: "deterministic", + }); + const latest = new Map(reviews.filter((review) => review.criterionId && review.verdict).map((review) => [review.criterionId!, review.verdict!])); + const pending = outcome.humanCriteria.filter((criterion) => !latest.has(criterion.id)); + if (pending.length) items.push({ + priority: "normal", + title: `Make ${pending.length} explicit human ${pending.length === 1 ? "decision" : "decisions"}`, + why: pending.map((criterion) => criterion.description).join(" · "), + paths: [], + basis: "declared", + }); + if (!items.length) items.push({ + priority: "normal", + title: "Review the outcome evidence, then inspect the changed implementation", + why: "No deterministic scope, failure, or sensitive-path signal requires earlier attention.", + paths: repository.changedFiles.slice(0, 8), + basis: "deterministic", + }); + const weight = { critical: 0, high: 1, normal: 2 } as const; + return items.sort((a, b) => weight[a.priority] - weight[b.priority]); +} + +function resolveCriteria(root: string, criteria: CriterionInput[]): CriterionInput[] { + return criteria.map((criterion) => { + if (criterion.probe.kind !== "command") return criterion; + const cwd = criterion.probe.cwd; + return { + ...criterion, + probe: { + ...criterion.probe, + cwd: cwd ? (isAbsolute(cwd) ? cwd : resolve(root, cwd)) : root, + }, + }; + }); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function summarizeHumanReview(outcome: Pick, reviews: ReviewEvent[]): GateSnapshot["humanReview"] { + const latest = new Map(); + for (const review of reviews) { + if (review.criterionId && review.verdict) latest.set(review.criterionId, review.verdict); + } + let passed = 0; + let failed = 0; + for (const criterion of outcome.humanCriteria) { + const verdict = latest.get(criterion.id); + if (verdict === "pass") passed += 1; + if (verdict === "fail") failed += 1; + } + return { + passed, + failed, + pending: outcome.humanCriteria.length - passed - failed, + total: outcome.humanCriteria.length, + }; +} + +function gateState(machineVerified: boolean, human: GateSnapshot["humanReview"]): GateSnapshot["state"] { + if (!machineVerified) return "evidence_gaps"; + if (human.failed > 0) return "review_blocked"; + if (human.pending > 0) return "human_review_required"; + return "ready_for_review"; +} + +function resolveEvidencePresentation(root: string, presentation?: EvidencePresentation): ResolvedEvidencePresentation | undefined { + if (!presentation) return undefined; + return { + summary: presentation.summary, + whyItMatters: presentation.whyItMatters, + code: presentation.code, + artifacts: presentation.artifacts.map((artifact) => { + const absolute = resolve(root, artifact.path); + const relativePath = relative(root, absolute); + if (relativePath.startsWith("..") || isAbsolute(relativePath)) { + return { ...artifact, unavailable: "Artifact path is outside the project." }; + } + if (!existsSync(absolute) || !statSync(absolute).isFile()) { + return { ...artifact, unavailable: "Artifact was not found for this snapshot." }; + } + const bytes = readFileSync(absolute); + const digest = hash(bytes); + if (artifact.kind !== "screenshot" && artifact.kind !== "video") return { ...artifact, digest }; + const limit = artifact.kind === "screenshot" ? 2_000_000 : 12_000_000; + if (bytes.length > limit) return { ...artifact, digest, unavailable: `${artifact.kind === "screenshot" ? "Screenshot" : "Video"} exceeds the ${limit / 1_000_000} MB portable-report limit.` }; + const lower = artifact.path.toLowerCase(); + const mediaType = artifact.kind === "screenshot" + ? lower.endsWith(".png") ? "image/png" : lower.endsWith(".webp") ? "image/webp" : lower.endsWith(".jpg") || lower.endsWith(".jpeg") ? "image/jpeg" : undefined + : lower.endsWith(".mp4") ? "video/mp4" : lower.endsWith(".webm") ? "video/webm" : undefined; + if (!mediaType) return { ...artifact, digest, unavailable: artifact.kind === "screenshot" ? "Screenshot must be PNG, WebP, or JPEG." : "Video must be MP4 or WebM." }; + return { + ...artifact, + digest, + mediaType, + dataUrl: `data:${mediaType};base64,${bytes.toString("base64")}`, + }; + }), + }; +} + +function verificationMethod(criterion: CriterionInput): VerificationMethod { + const assertion = { + path: criterion.assert.path ?? "output", + op: criterion.assert.op, + ...(criterion.assert.value !== undefined ? { value: redactEvidence(criterion.assert.value) } : {}), + }; + if (criterion.probe.kind === "command") return { + kind: "command", + label: "Repository command", + reproduce: redactSecrets(criterion.probe.run), + assertion, + }; + if (criterion.probe.kind === "http") return { + kind: "http", + label: "HTTP observation", + reproduce: `${criterion.probe.method ?? "GET"} ${redactSecrets(criterion.probe.url)}`, + assertion, + }; + return { + kind: "mcp", + label: "MCP observation", + reproduce: `${criterion.probe.connector}.${criterion.probe.tool}`, + assertion, + }; +} + +export async function runGate(rootInput: string | undefined, contributionId: string): Promise { + const root = findProjectRoot(rootInput); + const project = loadProject(root); + const contribution = loadContribution(root, contributionId); + const outcome = loadOutcome(root, contribution.outcomeId); + if (outcome.revision !== contribution.outcomeRevision) { + throw new Error( + `Contribution ${contribution.id} targets outcome revision ${contribution.outcomeRevision}, but revision ${outcome.revision} is current. Start a new contribution or restore the referenced outcome.`, + ); + } + const currentOutcomeDigest = hash(stableJson(outcome)); + if (contribution.outcomeDigest && contribution.outcomeDigest !== currentOutcomeDigest) { + throw new Error(`Outcome '${outcome.id}' changed without a revision increment. Increment its revision and start a new contribution so the proof contract is explicit.`); + } + + const runtime = join(root, KEYOKU_DIR, "runtime"); + const store = new Store(runtime); + const harness = new Harness(store, new ConnectorManager(store)); + const criteria = resolveCriteria(root, outcome.criteria); + const definitionDigest = hash(stableJson({ objective: outcome.objective, criteria, humanCriteria: outcome.humanCriteria, constraints: outcome.constraints })).slice(0, 12); + const goalSlug = slug(`gate-${outcome.id}-r${outcome.revision}-${definitionDigest}`); + let goal = store.listGoals().find((candidate) => candidate.slug === goalSlug); + if (!goal) { + goal = harness.createGoal({ + slug: goalSlug, + objective: outcome.objective, + criteria, + constraints: outcome.constraints, + autonomy: "observe", + cwd: root, + }); + } + + contribution.status = "evaluating"; + contribution.updatedAt = now(); + writeYaml(join(contributionDir(root, contribution.id), "manifest.yaml"), contribution); + const report = await harness.assess(goal.id); + await harness.connectors.closeAll(); + const repository = captureRepository(root, contribution.baseSha); + const scope = assessScope(outcome, repository.changedFiles); + let architecture: ArchitectureProjection | undefined; + try { architecture = scanArchitecture(root); } catch { /* architecture is optional for adopted repositories */ } + const generatedAt = now(); + const reviews = readReviews(root, contribution.id); + const humanReview = summarizeHumanReview(outcome, reviews); + const reviewPlan = buildReviewPlan(outcome, repository, scope, report, reviews); + const snapshotBase = { + schemaVersion: "keyoku.dev/factfile/v1alpha1" as const, + id: `fact_${generatedAt.replace(/[-:.TZ]/g, "").slice(0, 14)}_${repository.worktreeDigest.slice(0, 8)}`, + project: { id: project.id, name: project.name, summary: project.summary }, + outcome: { + id: outcome.id, + revision: outcome.revision, + title: outcome.title, + objective: outcome.objective, + constraints: outcome.constraints, + ...(outcome.scope ? { scope: outcome.scope } : {}), + owner: outcome.owner, + humanCriteria: outcome.humanCriteria, + }, + contribution: { ...contribution }, + repository, + scope, + reviewPlan, + session: readProofSession(root, contribution.id), + ...(architecture ? { architecture } : {}), + state: gateState(report.converged && scope.passed, humanReview), + generatedAt, + reviews, + evidence: report.criteria.map((item, index) => ({ + ...item, + actual: redactEvidence(item.actual), + verification: verificationMethod(outcome.criteria[index]!), + ...(item.error ? { error: redactSecrets(item.error) } : {}), + ...(item.note ? { note: redactSecrets(item.note) } : {}), + ...(outcome.criteria[index]?.evidence ? { presentation: resolveEvidencePresentation(root, outcome.criteria[index].evidence) } : {}), + })), + summary: { + passed: report.criteria.filter((item) => item.pass).length, + failed: report.criteria.filter((item) => !item.pass).length, + total: report.criteria.length, + verified: report.converged && scope.passed, + }, + humanReview, + }; + const snapshot: GateSnapshot = { ...snapshotBase, digest: hash(stableJson(snapshotBase)) }; + contribution.status = snapshot.state; + contribution.updatedAt = generatedAt; + snapshot.contribution.status = contribution.status; + snapshot.contribution.updatedAt = contribution.updatedAt; + persistSnapshot(root, contribution, snapshot); + return snapshot; +} + +export function reviewContribution(input: ReviewContributionInput): GateSnapshot { + const root = findProjectRoot(input.root); + const contribution = loadContribution(root, input.contributionId); + const path = join(contributionDir(root, contribution.id), "factfile.json"); + if (!existsSync(path)) throw new Error(`No Factfile for '${contribution.id}'. Run 'keyoku gate ${contribution.id}' first.`); + const snapshot = JSON.parse(readFileSync(path, "utf8")) as GateSnapshot; + const current = captureRepository(root, contribution.baseSha); + if (snapshot.repository.headSha !== current.headSha || snapshot.repository.worktreeDigest !== current.worktreeDigest) { + throw new Error("The repository changed after this Factfile was generated. Run the gate again before reviewing or accepting it."); + } + const reviewer = input.reviewer ?? defaultActor(root); + const reviewerResult = ActorSchema.safeParse(reviewer); + if (!reviewerResult.success || reviewer.kind !== "human") { + throw new Error("Only an identified human can record review or acceptance."); + } + const comment = input.comment.trim(); + if (!comment) throw new Error("A review comment is required."); + if (Boolean(input.criterionId) !== Boolean(input.verdict)) { + throw new Error("A human criterion review requires both criterionId and verdict."); + } + if (input.criterionId && !snapshot.outcome.humanCriteria.some((criterion) => criterion.id === input.criterionId)) { + throw new Error(`Unknown human criterion '${input.criterionId}'.`); + } + if (input.decision === "accepted" && snapshot.state !== "ready_for_review") { + throw new Error("Only a ready-for-review Factfile can be accepted. Resolve evidence gaps and run the gate again."); + } + const reviewedAt = now(); + const event: ReviewEvent = ReviewEventSchema.parse({ + id: slug(`review-${reviewedAt.replace(/[-:.TZ]/g, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`), + decision: input.decision, + reviewer, + comment, + ...(input.criterionId ? { criterionId: input.criterionId, verdict: input.verdict } : {}), + reviewedAt, + factfileId: snapshot.id, + factfileDigest: snapshot.digest, + repository: { headSha: current.headSha, worktreeDigest: current.worktreeDigest }, + }); + const reviewsPath = join(contributionDir(root, contribution.id), "reviews.jsonl"); + mkdirSync(dirname(reviewsPath), { recursive: true }); + writeFileSync(reviewsPath, `${JSON.stringify(event)}\n`, { encoding: "utf8", flag: "a", mode: 0o600 }); + snapshot.id = `fact_${reviewedAt.replace(/[-:.TZ]/g, "").slice(0, 14)}_${randomUUID().slice(0, 6)}`; + snapshot.generatedAt = reviewedAt; + snapshot.reviews = [...(snapshot.reviews ?? []), event]; + snapshot.session = readProofSession(root, contribution.id); + snapshot.humanReview = summarizeHumanReview(snapshot.outcome, snapshot.reviews); + if (input.decision === "accepted") { + snapshot.state = "accepted"; + contribution.status = "accepted"; + } else { + snapshot.state = gateState(snapshot.summary.verified, snapshot.humanReview); + contribution.status = snapshot.state; + } + contribution.updatedAt = reviewedAt; + snapshot.contribution = { ...contribution }; + persistSnapshot(root, contribution, snapshot); + return snapshot; +} + +export async function publishFactfile( + rootInput: string | undefined, + contributionId: string, + engineUrl: string, + token?: string, +): Promise { + const root = findProjectRoot(rootInput); + const base = new URL(engineUrl); + if (base.username || base.password) { + throw new Error("Engine URLs must not embed credentials; use KEYOKU_ENGINE_TOKEN."); + } + const loopback = base.hostname === "127.0.0.1" || base.hostname === "localhost" || base.hostname === "::1"; + if (base.protocol !== "https:" && !(base.protocol === "http:" && loopback)) { + throw new Error("Factfiles may be published only over HTTPS or loopback HTTP."); + } + const path = join(contributionDir(root, contributionId), "factfile.json"); + if (!existsSync(path)) throw new Error(`No Factfile for '${contributionId}'. Run 'keyoku gate ${contributionId}' first.`); + const snapshot = JSON.parse(readFileSync(path, "utf8")) as GateSnapshot; + const current = captureRepository(root, snapshot.repository.baseSha); + if (snapshot.repository.headSha !== current.headSha || snapshot.repository.worktreeDigest !== current.worktreeDigest) { + throw new Error("The repository changed after this Factfile was generated. Run the gate again before publishing it."); + } + const endpoint = new URL("/api/v1/factfiles", base); + const response = await fetch(endpoint, { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(30_000), + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: readFileSync(path), + }); + const text = await response.text(); + let body: unknown = text; + try { body = JSON.parse(text); } catch { /* preserve non-JSON server detail */ } + if (!response.ok) { + throw new Error(`Engine rejected Factfile (${response.status}): ${typeof body === "string" ? body : JSON.stringify(body)}`); + } + return body; +} + +function esc(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function printable(value: unknown): string { + if (typeof value === "string") return value; + return JSON.stringify(value, null, 2); +} + +const SECRET_KEY = /token|secret|passwd|password|api[_-]?key|access[_-]?key|credential|authorization|cookie/i; + +/** Factfiles are designed to be shared. Redact both credential-shaped strings + * and values stored under credential-shaped object keys before evidence ever + * reaches JSON, Markdown, HTML, or the optional shared engine. */ +function redactEvidence(value: unknown): unknown { + if (typeof value === "string") return redactSecrets(value); + if (Array.isArray(value)) return value.map(redactEvidence); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + SECRET_KEY.test(key) ? "«redacted»" : redactEvidence(item), + ]), + ); + } + return value; +} + +export function renderFactfileMarkdown(snapshot: GateSnapshot): string { + const mark = snapshot.state.replaceAll("_", " ").toUpperCase(); + const evidence = snapshot.evidence + .map((item) => { + const story = item.presentation + ? `\n - What it shows: ${item.presentation.summary}\n - Why it matters: ${item.presentation.whyItMatters}${item.presentation.code.map((ref) => `\n - Code: \`${ref.path}\` — ${ref.purpose}`).join("")}${item.presentation.artifacts.map((artifact) => `\n - Artifact: ${artifact.label} (\`${artifact.path}\`) — ${artifact.caption}`).join("")}` + : "\n - No human-facing evidence explanation was supplied."; + return `- **${item.pass ? "PASS" : "FAIL"} — ${item.description}**${story}\n - Audit observation: \`${printable(item.actual).replace(/`/g, "\\`")}\`${item.error ? `\n - error: ${item.error}` : ""}`; + }) + .join("\n"); + const reviews = snapshot.reviews.length + ? snapshot.reviews.map((review) => `- **${review.decision === "accepted" ? "Accepted" : "Review note"}** by ${review.reviewer.name} at ${review.reviewedAt}: ${review.comment}`).join("\n") + : "No human review recorded yet."; + const latestHuman = new Map(snapshot.reviews.filter((review) => review.criterionId && review.verdict).map((review) => [review.criterionId!, review])); + const humanCriteria = snapshot.outcome.humanCriteria.length + ? snapshot.outcome.humanCriteria.map((criterion) => { + const review = latestHuman.get(criterion.id); + return `- **${review?.verdict?.toUpperCase() ?? "PENDING"} — ${criterion.description}**${review ? `\n - ${review.comment} — ${review.reviewer.name}` : criterion.guidance ? `\n - ${criterion.guidance}` : ""}`; + }).join("\n") + : "No additional human judgment criteria declared."; + const reviewPlan = snapshot.reviewPlan.map((item) => `- **${item.priority.toUpperCase()} — ${item.title}**\n - ${item.why}${item.paths.length ? `\n - Paths: ${item.paths.map((path) => `\`${path}\``).join(", ")}` : ""}`).join("\n"); + return `# ${snapshot.outcome.title}\n\n**Gate: ${mark}** · automated ${snapshot.summary.passed}/${snapshot.summary.total} · human ${snapshot.humanReview.passed}/${snapshot.humanReview.total} · exact snapshot \`${snapshot.repository.headSha.slice(0, 12)}+${snapshot.repository.worktreeDigest.slice(0, 12)}\`\n\n## Outcome\n\n${snapshot.outcome.objective}\n\n## Review this first\n\n${reviewPlan}\n\n## Accountable people and agents\n\n${snapshot.contribution.actors.map((actor) => `- ${actor.name} (${actor.kind}${actor.role ? `, ${actor.role}` : ""}${actor.harness ? `; harness: ${actor.harness}` : ""}${actor.model ? `; model: ${actor.model}` : ""})`).join("\n")}\n\n## Automated evidence\n\n${evidence}\n\n## Required human judgments\n\n${humanCriteria}\n\n## Human review history\n\n${reviews}\n\n## Scope\n\n- Base: \`${snapshot.repository.baseSha}\`\n- Head: \`${snapshot.repository.headSha}\`\n- Worktree digest: \`${snapshot.repository.worktreeDigest}\`\n- Changed files: ${snapshot.repository.changedFiles.length}\n- Factfile digest: \`${snapshot.digest}\`\n\nGenerated by Keyoku at ${snapshot.generatedAt}. Automated verification and human judgment are reported separately; neither is a universal safety claim. “Accepted” additionally means the named human accepted this exact snapshot.\n`; +} + +function githubState(snapshot: GateSnapshot): { icon: string; label: string; message: string } { + if (snapshot.state === "accepted") return { icon: "✅", label: "Accepted", message: "A named human accepted this exact snapshot." }; + if (snapshot.state === "ready_for_review") return { icon: "✅", label: "Ready for review", message: "Declared automated and human criteria pass; acceptance remains explicit." }; + if (snapshot.state === "human_review_required") return { icon: "🟡", label: "Human review needed", message: "Repository checks pass. The acceptance questions below still require maintainer judgment." }; + if (snapshot.state === "review_blocked") return { icon: "🔴", label: "Review blocked", message: "A required human judgment currently blocks acceptance." }; + return { icon: "🔴", label: "Evidence gaps", message: "One or more declared claims are not supported at this revision." }; +} + +/** A deliberately short GitHub surface. It gives a reviewer the result and + * remaining attention first; the portable HTML/JSON artifacts hold the full + * teaching and audit views. */ +export function renderFactfileGithubMarkdown(snapshot: GateSnapshot): string { + const status = githubState(snapshot); + const latestHuman = new Map(snapshot.reviews.filter((review) => review.criterionId && review.verdict).map((review) => [review.criterionId!, review])); + const supported = snapshot.evidence.filter((item) => item.pass).map((item) => { + const explanation = item.presentation?.summary ?? "The declared observation matched its rule; no reviewer-facing artifact was supplied."; + const artifactCount = item.presentation?.artifacts.filter((artifact) => artifact.digest && !artifact.unavailable).length ?? 0; + return `- ✅ **${item.description}** — ${explanation}${artifactCount ? ` _(${artifactCount} content-bound ${artifactCount === 1 ? "artifact" : "artifacts"})_` : ""}`; + }).join("\n") || "- No automated claim is currently supported."; + const gaps = snapshot.evidence.filter((item) => !item.pass).map((item) => `- ❌ **${item.description}** — ${item.error ?? "The observed result did not match the declared rule."}`).join("\n"); + const claimDetails = snapshot.evidence.map((item) => { + const presentation = item.presentation; + const artifacts = presentation?.artifacts.length + ? presentation.artifacts.map((artifact) => `- ${artifact.unavailable ? "⚠️" : "📎"} **${artifact.label}** — ${artifact.caption} (\`${artifact.path}\`${artifact.digest ? ` · SHA-256 \`${artifact.digest}\`` : ""}${artifact.unavailable ? ` · ${artifact.unavailable}` : ""})`).join("\n") + : "- No visual or report artifact was attached."; + const code = presentation?.code.length + ? presentation.code.map((ref) => `- \`${ref.path}\` — ${ref.purpose}`).join("\n") + : "- No code-tour paths were declared."; + return `
\n${item.pass ? "✅ Supported" : "❌ Evidence gap"} · ${item.description}\n\n${presentation?.summary ?? "No reviewer-facing explanation was supplied."}\n\n**Why this matters:** ${presentation?.whyItMatters ?? "The outcome author did not explain the relevance of this check."}\n\n**Inspectable artifacts**\n\n${artifacts}\n\n**Relevant code**\n\n${code}\n\n**Reproduce**\n\n\`${item.verification.reproduce.replace(/`/g, "\\`")}\`\n\n${item.verification.label} · completed in ${item.durationMs}ms · rule: \`${printable(item.verification.assertion).replace(/`/g, "\\`")}\`\n\n
`; + }).join("\n\n"); + const decisions = snapshot.outcome.humanCriteria.length + ? snapshot.outcome.humanCriteria.map((criterion) => { + const review = latestHuman.get(criterion.id); + const verdict = review?.verdict === "pass" ? "✅ Passed" : review?.verdict === "fail" ? "❌ Blocked" : "🟡 Needs reviewer"; + return `- **${verdict}:** ${criterion.description}${review ? ` — ${review.comment} _(${review.reviewer.name})_` : criterion.guidance ? `\n
${criterion.guidance}` : ""}`; + }).join("\n") + : "- No additional human judgment criteria were declared."; + const areas = snapshot.scope.topLevelAreas.length + ? snapshot.scope.topLevelAreas.map((area) => `\`${area.name}/\` ${area.files}`).join(" · ") + : "No changed files detected"; + const people = snapshot.contribution.actors.map((actor) => { + const provenance = [actor.role, actor.harness && `via ${actor.harness}`, actor.model].filter(Boolean).join(" · "); + return `- **${actor.name}** — ${actor.kind}${provenance ? ` · ${provenance}` : ""}${actor.ownerId ? ` · accountable to ${actor.ownerId}` : ""}`; + }).join("\n"); + const limits = snapshot.contribution.knownLimits?.length + ? snapshot.contribution.knownLimits.map((limit) => `- ${limit}`).join("\n") + : "- Only the claims listed here were evaluated.\n- Passing commands are not a judgment of product fit, maintainability, or universal safety.\n- Any source change requires a new Factfile."; + const unexpected = snapshot.scope.unexpectedPaths.length + ? `\n\n> [!CAUTION]\n> **Outside declared scope:** ${snapshot.scope.unexpectedPaths.map((path) => `\`${path}\``).join(", ")}` + : ""; + const attention = snapshot.reviewPlan.map((item, index) => `${index + 1}. **${item.priority === "critical" ? "🔴" : item.priority === "high" ? "🟠" : "🔵"} ${item.title}** — ${item.why}${item.paths.length ? `\n
${item.paths.map((path) => `\`${path}\``).join(" · ")}` : ""}`).join("\n"); + const decisionLine = snapshot.state === "human_review_required" + ? `**Decision: do not accept yet.** ${snapshot.humanReview.pending} named human ${snapshot.humanReview.pending === 1 ? "decision remains" : "decisions remain"}.` + : snapshot.state === "evidence_gaps" ? `**Decision: evidence is incomplete.** ${snapshot.summary.failed} declared ${snapshot.summary.failed === 1 ? "claim is" : "claims are"} unsupported.` + : snapshot.state === "review_blocked" ? `**Decision: blocked by human review.** ${snapshot.humanReview.failed} required ${snapshot.humanReview.failed === 1 ? "judgment failed" : "judgments failed"}.` + : snapshot.state === "accepted" ? "**Decision: accepted.** A named human accepted this exact source snapshot." + : "**Decision: ready for explicit acceptance.** Every declared automated and human criterion currently passes."; + return `## ${status.icon} Keyoku · ${status.label}\n\n### ${snapshot.outcome.title}\n\n${decisionLine}\n\n> **Requested outcome:** ${snapshot.outcome.objective}\n>\n> **Delivered change:** ${snapshot.contribution.summary ?? snapshot.contribution.title}\n\n**At a glance:** ${snapshot.summary.passed}/${snapshot.summary.total} automated claims supported · ${snapshot.humanReview.pending} human decisions pending · ${snapshot.repository.changedFiles.length} changed files · exact revision \`${snapshot.repository.headSha.slice(0, 12)}+${snapshot.repository.worktreeDigest.slice(0, 12)}\`\n\n> [!NOTE]\n> **What “proof” means here:** bounded evidence for the declared claims at this exact source snapshot—not proof that the whole project is correct, secure, or ready.\n\n### What is established\n\n${supported}${gaps ? `\n\n### What is not established\n\n${gaps}` : ""}${unexpected}\n\n### What only a human can decide\n\n${decisions}\n\n> [!IMPORTANT]\n> **Make the decision with GitHub's native PR review:** Approve when the outcome is satisfied, or Request changes with the next concrete instruction. The next push produces a new SHA-bound Factfile automatically.\n\n### Review path\n\n${attention}\n\n
\nOpen the evidence chain for every claim\n\n${claimDetails}\n\n
\n\n
\nChange boundary · ${snapshot.repository.changedFiles.length} files\n\n${areas}\n\n${snapshot.scope.note}\n\n
\n\n
\nPeople and agent provenance\n\n${people}\n\n
\n\n
\nLimits and exact audit identity\n\n${limits}\n\n- Base: \`${snapshot.repository.baseSha}\`\n- Head: \`${snapshot.repository.headSha}\`\n- Worktree: \`${snapshot.repository.worktreeDigest}\`\n- Factfile: \`${snapshot.digest}\`\n\n
\n\n---\nGenerated by free, provider-neutral Keyoku. The attached HTML Factfile contains the evidence gallery, code tour, architecture, and reproduction details.\n`; +} + +const FACTFILE_CSS = ` +:root{ + --bg:#fafafa;--bg-raised:#ffffff;--surface:#f2f2f3;--surface-strong:#ececee; + --ink:#18181b;--ink-muted:#52525b;--ink-soft:#84848c; + --line:rgba(24,24,27,.12);--line-strong:rgba(24,24,27,.24); + --good:#1a7f37;--good-bg:rgba(26,127,55,.09);--good-line:rgba(26,127,55,.28); + --bad:#b42318;--bad-bg:rgba(180,35,24,.08);--bad-line:rgba(180,35,24,.28); + --wait:#8a6d1a;--wait-bg:rgba(138,109,26,.09); + --focus:#18181b; + --mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,"Liberation Mono",monospace; + --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,Helvetica,Arial,sans-serif; + --radius:10px;--radius-lg:14px; +} +@media(prefers-color-scheme:dark){ + :root{ + --bg:#0a0a0b;--bg-raised:#141416;--surface:#19191c;--surface-strong:#1f1f23; + --ink:#f4f4f5;--ink-muted:#a1a1aa;--ink-soft:#77777f; + --line:rgba(255,255,255,.11);--line-strong:rgba(255,255,255,.2); + --good:#4ade80;--good-bg:rgba(74,222,128,.1);--good-line:rgba(74,222,128,.28); + --bad:#f87171;--bad-bg:rgba(248,113,113,.1);--bad-line:rgba(248,113,113,.28); + --wait:#f2c94c;--wait-bg:rgba(242,201,76,.1); + } +} +[data-theme="dark"]{--bg:#0a0a0b;--bg-raised:#141416;--surface:#19191c;--surface-strong:#1f1f23;--ink:#f4f4f5;--ink-muted:#a1a1aa;--ink-soft:#77777f;--line:rgba(255,255,255,.11);--line-strong:rgba(255,255,255,.2);--good:#4ade80;--good-bg:rgba(74,222,128,.1);--good-line:rgba(74,222,128,.28);--bad:#f87171;--bad-bg:rgba(248,113,113,.1);--bad-line:rgba(248,113,113,.28);--wait:#f2c94c;--wait-bg:rgba(242,201,76,.1)} +[data-theme="light"]{--bg:#fafafa;--bg-raised:#fff;--surface:#f2f2f3;--surface-strong:#ececee;--ink:#18181b;--ink-muted:#52525b;--ink-soft:#84848c;--line:rgba(24,24,27,.12);--line-strong:rgba(24,24,27,.24);--good:#1a7f37;--good-bg:rgba(26,127,55,.09);--good-line:rgba(26,127,55,.28);--bad:#b42318;--bad-bg:rgba(180,35,24,.08);--bad-line:rgba(180,35,24,.28);--wait:#8a6d1a;--wait-bg:rgba(138,109,26,.09)} + +*{box-sizing:border-box} +html{scroll-behavior:smooth} +body{margin:0;min-height:100vh;background:var(--bg);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;line-height:1.5} +a{color:inherit} +code,pre,kbd{font-family:var(--mono)} +button{font-family:inherit} +h1,h2,h3{font-weight:650;letter-spacing:-.01em} + +/* Header ------------------------------------------------------------- */ +.ff-header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 20px;border-bottom:1px solid var(--line);position:sticky;top:0;background:color-mix(in srgb, var(--bg) 88%, transparent);backdrop-filter:blur(10px);z-index:10} +.ff-brand{display:flex;align-items:center;gap:8px;color:var(--ink)} +.ff-mark{display:grid;place-items:center;width:20px;height:20px;flex:none} +.ff-mark svg{display:block;width:18px;height:18px} +.ff-word{font-family:var(--mono);font-size:14px;font-weight:600;letter-spacing:-.02em} +.ff-meta{display:flex;align-items:center;gap:10px;min-width:0;color:var(--ink-soft);font-size:12px} +.ff-project{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:32vw} +.ff-source{font-family:var(--mono);white-space:nowrap;border:1px solid var(--line);border-radius:999px;padding:4px 9px;color:var(--ink-muted)} +.theme-toggle{appearance:none;border:1px solid var(--line);border-radius:8px;background:var(--bg-raised);color:var(--ink-muted);width:30px;height:30px;display:grid;place-items:center;cursor:pointer;font-size:13px} +.theme-toggle:hover{color:var(--ink);border-color:var(--line-strong)} +.live-banner{display:flex;align-items:center;gap:8px;margin:0 0 18px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--ink-muted);font-size:12px} +.live-banner i{width:6px;height:6px;border-radius:50%;background:var(--ink-soft);flex:none} +.live-banner.live i{background:var(--good);box-shadow:0 0 0 3px var(--good-bg)} + +/* Layout --------------------------------------------------------------- */ +.ff-main{max-width:860px;margin:0 auto;padding:28px 20px 64px} +.ff-eyebrow{display:block;color:var(--ink-soft);font:11px var(--mono);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px} +.ff-title{font-size:clamp(22px,3.4vw,30px);line-height:1.16;letter-spacing:-.02em;margin:0 0 10px} +.ff-objective{color:var(--ink-muted);font-size:14px;line-height:1.6;margin:0 0 24px;max-width:70ch} + +/* Hero: shared ----------------------------------------------------------- */ +.hero{border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--bg-raised);overflow:hidden;margin-bottom:28px} + +/* Hero: filmstrip -------------------------------------------------------- */ +.film-stage{position:relative;background:#000;aspect-ratio:16/10;max-height:480px} +.film-frame{position:absolute;inset:0;display:none;align-items:center;justify-content:center;cursor:zoom-in} +.film-frame.active{display:flex} +.film-frame img,.film-frame video{display:block;width:100%;height:100%;object-fit:contain;background:#000} +.film-expand{position:absolute;right:10px;top:10px;z-index:2;appearance:none;border:1px solid rgba(255,255,255,.28);background:rgba(0,0,0,.5);color:#fff;border-radius:7px;width:30px;height:30px;cursor:pointer;font-size:14px} +.film-expand:hover{background:rgba(0,0,0,.7)} +.film-caption{display:flex;flex-direction:column;gap:2px;padding:12px 16px;border-top:1px solid var(--line)} +.film-caption strong{font-size:13px} +.film-caption span{color:var(--ink-muted);font-size:12px;line-height:1.5} +.film-dots{display:flex;gap:6px;flex-wrap:wrap;padding:0 16px 14px} +.film-dot{appearance:none;border:1px solid var(--line-strong);background:transparent;width:7px;height:7px;border-radius:50%;padding:0;cursor:pointer} +.film-dot.active{background:var(--ink);border-color:var(--ink)} +.lightbox{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.86);display:flex;align-items:center;justify-content:center;padding:32px} +.lightbox[hidden]{display:none} +.lightbox-stage{max-width:100%;max-height:100%} +.lightbox-stage img,.lightbox-stage video{max-width:100%;max-height:88vh;display:block;margin:0 auto} +.lightbox-close{position:absolute;top:18px;right:22px;appearance:none;border:1px solid rgba(255,255,255,.3);background:rgba(255,255,255,.08);color:#fff;width:34px;height:34px;border-radius:8px;cursor:pointer;font-size:15px} + +/* Hero: CLI replay --------------------------------------------------------- */ +.cli-window{background:#0b0b0c;color:#e4e4e7} +.cli-titlebar{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.12)} +.cli-dot{width:9px;height:9px;border-radius:50%;background:rgba(255,255,255,.22)} +.cli-path{margin-left:8px;font:11px var(--mono);color:rgba(255,255,255,.5)} +.cli-body{padding:16px 18px 20px;font:12.5px/1.9 var(--mono);max-height:420px;overflow:auto} +.cli-line{opacity:0;transform:translateY(3px);animation:cli-reveal .35s ease forwards;animation-delay:calc(var(--i) * .28s);display:flex;flex-wrap:wrap;gap:0 8px;align-items:baseline;color:rgba(255,255,255,.9)} +.cli-prompt{color:rgba(255,255,255,.4)} +.cli-cmd{word-break:break-word} +.cli-result{margin-left:auto;padding-left:14px;white-space:nowrap;font-size:11.5px} +.cli-result.pass{color:var(--good)} +.cli-result.fail{color:var(--bad)} +@keyframes cli-reveal{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}} + +/* Summary ------------------------------------------------------------------ */ +.summary{border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--bg-raised);padding:18px 20px;margin-bottom:22px} +.summary-verdict{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid var(--line)} +.summary-verdict .dot{width:8px;height:8px;border-radius:50%;background:var(--wait);flex:none;align-self:center} +.summary-verdict.tone-good .dot{background:var(--good)} +.summary-verdict.tone-bad .dot{background:var(--bad)} +.summary-verdict strong{font-size:14px} +.verdict-detail{color:var(--ink-muted);font-size:12.5px} +.summary-counts{display:flex;gap:18px;flex-wrap:wrap;margin-bottom:12px;font-size:12.5px;color:var(--ink-muted)} +.summary-counts b{font-family:var(--mono);color:var(--ink)} +.summary-list{list-style:none;margin:0;padding:0;display:grid;gap:6px} +.summary-list li{display:flex;gap:9px;align-items:flex-start;font-size:13px;color:var(--ink-muted);line-height:1.5} +.summary-list .mark{flex:none;width:15px;font-family:var(--mono);font-weight:700} +.summary-list li.pass .mark{color:var(--good)} +.summary-list li.fail .mark{color:var(--bad)} +.summary-list li.pass{color:var(--ink)} + +/* Insight -------------------------------------------------------------------- */ +.insight{margin-bottom:26px} +.insight>h2{font-size:16px;margin:0 0 12px} +.insight-group{margin-bottom:16px} +.insight-group>h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--ink-soft);margin:0 0 8px;font-weight:650} +.insight-item{border:1px solid var(--line);border-radius:var(--radius);background:var(--bg-raised);margin-bottom:8px;overflow:hidden} +.insight-item>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:10px;padding:12px 14px} +.insight-item>summary::-webkit-details-marker{display:none} +.insight-item .mark{flex:none;width:18px;height:18px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:800;background:var(--wait-bg);color:var(--wait)} +.insight-item .mark.pass{background:var(--good-bg);color:var(--good)} +.insight-item .mark.fail{background:var(--bad-bg);color:var(--bad)} +.insight-title{flex:1;min-width:0;font-size:13.5px;font-weight:560} +.insight-state{color:var(--ink-soft);font:10px var(--mono);text-transform:uppercase;letter-spacing:.04em} +.insight-chevron{color:var(--ink-soft);transition:transform .15s} +.insight-item[open] .insight-chevron{transform:rotate(90deg)} +.insight-body{padding:0 14px 16px 42px;color:var(--ink-muted);font-size:13px;line-height:1.6} +.insight-body p{margin:0 0 8px} +.insight-body .meta{color:var(--ink-soft);font-size:11.5px} +.decision-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:6px 0 14px} +.decision-facts div{padding:10px;border-radius:8px;background:var(--surface)} +.decision-facts b{display:block;color:var(--ink-soft);font:9.5px var(--mono);text-transform:uppercase;margin-bottom:4px} +.decision-facts span{font-size:12.5px;color:var(--ink)} +.option-list{display:grid;gap:7px;margin-bottom:10px} +.option{display:grid;grid-template-columns:16px minmax(0,1fr);gap:9px;padding:10px;border:1px solid var(--line);border-radius:8px;cursor:pointer} +.option:has(input:checked){border-color:var(--line-strong);background:var(--surface)} +.option input{margin-top:3px} +.option strong{font-size:12.5px} +.option span{display:block;color:var(--ink-muted);font-size:12px;margin-top:2px} +.outcome-effect{margin:10px 0;padding:9px 11px;border-radius:8px;background:var(--surface);font-size:12px;line-height:1.55;color:var(--ink-muted)} +.outcome-effect b{display:block;color:var(--ink-soft);font:9.5px var(--mono);text-transform:uppercase;margin-bottom:4px} +.direction-deep p,.direction-deep li{color:var(--ink-muted);font-size:12px;line-height:1.55} +.direction-deep ul{padding-left:16px;margin:6px 0 0} +.action-row{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px} +.action{appearance:none;border:1px solid var(--line-strong);border-radius:8px;background:var(--surface);color:var(--ink);padding:8px 12px;font:600 12px var(--sans);cursor:pointer} +.action.primary{background:var(--ink);color:var(--bg);border-color:var(--ink)} +.action:hover{filter:brightness(1.05)} +.action-result{min-height:14px;margin:8px 0 0;color:var(--ink-soft);font-size:11.5px} +.instruction-box{display:grid;gap:8px;margin-top:6px} +.instruction-box textarea{min-height:70px;resize:vertical;border:1px solid var(--line);border-radius:8px;background:var(--bg-raised);color:var(--ink);padding:10px;font:12.5px/1.5 var(--sans)} +.custom-direction{margin-top:8px;border:1px solid var(--line);border-radius:8px} +.custom-direction>summary{padding:10px 12px;cursor:pointer;font-size:12px;color:var(--ink-muted);list-style:none} +.custom-direction>summary::-webkit-details-marker{display:none} +.custom-body{padding:0 12px 12px} +.empty-state,.clear-state{padding:14px 16px;border:1px dashed var(--line-strong);border-radius:8px;color:var(--ink-muted);font-size:12.5px;line-height:1.55} +.clear-state{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--good-line);background:var(--good-bg)} +.clear-state i{color:var(--good);font-style:normal;font-weight:800} +.clear-state strong{display:block;color:var(--ink);font-size:13px} + +/* Folds (everything else) ----------------------------------------------------- */ +.fold{border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--bg-raised);margin-bottom:10px;overflow:hidden} +.fold>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:10px;padding:15px 18px} +.fold>summary::-webkit-details-marker{display:none} +.fold[open]>summary{border-bottom:1px solid var(--line)} +.fold-title{flex:1;font-size:13.5px;font-weight:600} +.fold-meta{color:var(--ink-soft);font:10.5px var(--mono)} +.chevron{color:var(--ink-soft);transition:transform .15s} +.fold[open] .chevron{transform:rotate(90deg)} +.fold-body{padding:18px} + +.subhead{font:10px var(--mono);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-soft);margin:16px 0 8px} +.subhead:first-child{margin-top:0} + +.evidence-list{display:grid;gap:8px} +.evidence-row{border:1px solid var(--line);border-radius:8px} +.evidence-row>summary{list-style:none;cursor:pointer;display:grid;grid-template-columns:22px minmax(0,1fr) auto 16px;gap:10px;align-items:center;padding:12px 14px} +.evidence-row>summary::-webkit-details-marker{display:none} +.evidence-mark{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;background:var(--good-bg);color:var(--good);font-size:10px;font-weight:900} +.evidence-row.fail .evidence-mark{background:var(--bad-bg);color:var(--bad)} +.evidence-copy strong{display:block;font-size:13px} +.evidence-copy span{display:block;color:var(--ink-muted);font-size:12px;margin-top:2px} +.evidence-meta{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end} +.meta-pill{border:1px solid var(--line);border-radius:999px;padding:3px 8px;color:var(--ink-muted);font:10px var(--mono)} +.evidence-body{padding:0 14px 16px 46px} +.story-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:12px 0;border-top:1px solid var(--line)} +.story-block b{display:block;font-size:11.5px;margin-bottom:5px} +.story-block p{font-size:12.5px;line-height:1.55;color:var(--ink-muted);margin:0} +.artifact-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} +.artifact{border:1px solid var(--line);border-radius:8px;padding:10px;background:var(--surface)} +.artifact.warning{background:var(--wait-bg)} +.artifact b{display:block;font-size:11.5px} +.artifact span{display:block;color:var(--ink-muted);font-size:11.5px;line-height:1.4;margin-top:3px} +.artifact code{display:block;font-size:10px;color:var(--ink-soft);margin-top:6px;word-break:break-all} +.artifact-role{display:block!important;margin:0 0 6px!important;color:var(--ink-soft)!important;font:9px var(--mono)!important;text-transform:uppercase} +.screenshot{margin:8px 0 0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#000} +.screenshot img,.screenshot video{display:block;width:100%;max-height:520px;object-fit:contain} +.screenshot figcaption{padding:9px 11px;color:var(--ink-muted);font-size:11px;line-height:1.45;border-top:1px solid var(--line);background:var(--bg-raised)} +.media-frame{position:relative} +.annotation-pin{position:absolute;left:var(--x);top:var(--y);transform:translate(-50%,-50%);width:22px;height:22px;border:2px solid #fff;border-radius:50%;background:var(--ink);color:var(--bg);display:grid;place-items:center;font:750 10px var(--mono)} +.annotation-list{display:grid;gap:4px;margin-top:8px} +.annotation-note{color:var(--ink-muted);font-size:11px;line-height:1.45} +.annotation-note b{color:var(--ink)} +.video-time{font:10px var(--mono);color:var(--ink-soft);margin-right:5px} +.code-list{border:1px solid var(--line);border-radius:8px;overflow:hidden} +.code-row{display:grid;grid-template-columns:minmax(160px,.7fr) minmax(0,1.3fr);gap:12px;padding:9px 11px;border-top:1px solid var(--line)} +.code-row:first-child{border-top:0} +.code-row code{font-size:10.5px;color:var(--ink-soft);word-break:break-word} +.code-row span{font-size:11.5px;color:var(--ink-muted)} +.reproduce{margin-top:8px;padding:11px;border-radius:8px;background:var(--surface)} +.reproduce b{display:block;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--ink-soft);margin-bottom:5px} +.reproduce code{font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word} +.raw{margin-top:8px} +.raw>summary{cursor:pointer;font-size:11.5px;color:var(--ink-muted);padding:6px 0;list-style:none} +.raw-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px} +pre{white-space:pre-wrap;word-break:break-word;margin:0;border:1px solid var(--line);border-radius:8px;background:var(--surface);padding:10px;color:var(--ink-muted);font:10.5px/1.55 var(--mono);max-height:220px;overflow:auto} +.raw p{color:var(--ink-soft);font:9.5px var(--mono)} + +.work-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} +.work-card{border:1px solid var(--line);border-radius:8px;padding:12px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px} +.work-card strong{font-size:12.5px} +.work-card p{grid-column:1/-1;margin:0;color:var(--ink-muted);font-size:11.5px;line-height:1.5} +.work-status{font:9.5px var(--mono);text-transform:uppercase;color:var(--ink-soft)} +.agent-line{display:flex;align-items:center;gap:7px;color:var(--ink-muted);font-size:11px;margin-top:10px} +.agent-line i{width:6px;height:6px;border-radius:50%;background:var(--ink-soft)} +.agent-line.connected i{background:var(--good)} +.local-time{white-space:nowrap;font-variant-numeric:tabular-nums} + +.queue{display:grid} +.queue-row{display:grid;grid-template-columns:22px minmax(0,1fr) 90px;gap:10px;align-items:start;padding:10px 0;border-top:1px solid var(--line)} +.queue-row:first-child{border-top:0} +.queue-mark{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;background:var(--surface);color:var(--ink-muted);font-size:11px;font-weight:800} +.queue-row.human .queue-mark{background:var(--wait-bg);color:var(--wait)} +.queue-row.pass .queue-mark{background:var(--good-bg);color:var(--good)} +.queue-row.fail .queue-mark{background:var(--bad-bg);color:var(--bad)} +.queue-copy strong{display:block;font-size:12.5px;line-height:1.4} +.queue-copy p{color:var(--ink-muted);font-size:11.5px;line-height:1.5;margin:3px 0 0} +.queue-copy code{display:block;color:var(--ink-soft);font-size:10.5px;line-height:1.5;margin-top:6px;word-break:break-word} +.queue-state{text-align:right;font:9.5px var(--mono);text-transform:uppercase;letter-spacing:.04em;color:var(--ink-soft);padding-top:3px} +.queue-row.human .queue-state{color:var(--wait)} +.queue-row.pass .queue-state{color:var(--good)} +.queue-row.fail .queue-state{color:var(--bad)} + +.history-list{display:grid;gap:6px} +.history-row{display:grid;grid-template-columns:12px minmax(0,1fr) auto;gap:8px;align-items:center;padding:9px 10px;border:1px solid var(--line);border-radius:8px;text-decoration:none;color:inherit} +.history-row.current{border-color:var(--line-strong);background:var(--surface)} +.history-node{color:var(--ink-soft);font-size:9px} +.history-row.current .history-node{color:var(--ink)} +.history-copy strong{display:block;font-size:11.5px} +.history-copy span{display:block;margin-top:2px;color:var(--ink-soft);font:10px var(--mono)} +.history-row code{color:var(--ink-soft);font:9.5px var(--mono)} +.history-empty{color:var(--ink-soft);font-size:12px;line-height:1.55} + +.identity{border:1px solid var(--line);border-radius:8px;overflow:hidden} +.identity-row{display:grid;grid-template-columns:110px minmax(0,1fr);padding:8px 10px;border-top:1px solid var(--line);gap:10px} +.identity-row:first-child{border-top:0} +.identity-row span{font-size:11px;color:var(--ink-soft)} +.identity-row code{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink-muted)} +.plain-list{list-style:none;padding:0;margin:0} +.plain-list li{font-size:11.5px;line-height:1.5;color:var(--ink-muted);padding:7px 0;border-top:1px solid var(--line)} +.plain-list li:first-child{border-top:0} +.file-list li{font-family:var(--mono);font-size:10px;word-break:break-word} +.area-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border:1px solid var(--line);border-radius:8px;overflow:hidden;margin-bottom:12px} +.area{padding:10px;border-left:1px solid var(--line);border-top:1px solid var(--line);margin:-1px 0 0 -1px} +.area b{display:block;font-size:11.5px} +.area span{font-size:10.5px;color:var(--ink-muted)} +.architecture-frame{border:1px solid var(--line);border-radius:8px;overflow:auto;background:var(--surface)} +.architecture-frame svg{display:block;width:100%;min-width:700px;height:auto} +.audit-columns{display:grid;grid-template-columns:1fr 1fr;gap:24px} +.audit-columns h3{font-size:12.5px;margin:18px 0 7px} +.audit-columns h3:first-child{margin-top:0} +.people{display:grid;gap:7px} +.person{display:grid;grid-template-columns:26px minmax(0,1fr);gap:9px;align-items:center} +.person i{width:26px;height:26px;border-radius:7px;background:var(--surface);border:1px solid var(--line);color:var(--ink-muted);display:grid;place-items:center;font-style:normal;font-size:10px;font-weight:800} +.person b{display:block;font-size:11.5px} +.person span{display:block;font-size:11px;color:var(--ink-muted)} + +.ff-footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;padding:18px 4px;color:var(--ink-soft);font-size:11px} +.ff-footer code{font-size:10px;word-break:break-all;text-align:right} + +@media(prefers-reduced-motion:reduce){ + html{scroll-behavior:auto} + .cli-line{animation:none!important;opacity:1!important;transform:none!important} +} +@media(max-width:720px){ + .ff-header{padding:12px 14px} + .ff-project{display:none} + .ff-main{padding:20px 14px 48px} + .story-grid,.artifact-list,.raw-grid,.audit-columns,.work-grid,.decision-facts{grid-template-columns:1fr} + .code-row{grid-template-columns:1fr} + .queue-row{grid-template-columns:22px minmax(0,1fr)} + .queue-state{grid-column:2;text-align:left} + .evidence-row>summary{grid-template-columns:22px minmax(0,1fr) 16px} + .evidence-meta{grid-column:2;justify-content:flex-start} + .evidence-body{padding-left:14px} + .area-grid{grid-template-columns:1fr 1fr} +} +`; + +interface DirectionSuggestion { + id: string; + eyebrow: string; + label: string; + summary: string; + outcomeEffect: string; + deepDive: string; + basis: string; + evidenceRefs: string[]; + tradeoffs: string[]; + instruction: string; + recommended?: boolean; + source: "agent" | "deterministic"; +} + +function buildDirectionSuggestions(snapshot: GateSnapshot): DirectionSuggestion[] { + const suggestions: DirectionSuggestion[] = []; + const firstAttention = snapshot.reviewPlan.find((item) => item.basis === "deterministic"); + if (firstAttention) suggestions.push({ + id: "resolve-review-attention", + eyebrow: "Reduce review risk", + label: firstAttention.title, + summary: firstAttention.why, + outcomeEffect: "The next Factfile can remove or narrow this attention signal and reduce the amount a reviewer must reconstruct.", + deepDive: firstAttention.paths.length + ? `Start with ${firstAttention.paths.join(", ")}. Explain whether each change is necessary for this outcome, then update implementation or scope evidence.` + : "Re-evaluate whether this contribution is one coherent outcome. Split unrelated work or explain why the breadth is necessary.", + basis: `Keyoku raised a ${firstAttention.priority} deterministic attention signal from the exact changed-source snapshot.`, + evidenceRefs: firstAttention.paths, + tradeoffs: ["May add an implementation iteration", "Does not replace the declared outcome checks"], + instruction: `${firstAttention.title}. ${firstAttention.why}${firstAttention.paths.length ? ` Start with: ${firstAttention.paths.join(", ")}.` : ""} Re-run the Keyoku gate and report exactly what changed.`, + recommended: firstAttention.priority === "critical" || firstAttention.priority === "high", + source: "deterministic", + }); + if (snapshot.humanReview.pending > 0) suggestions.push({ + id: "prepare-acceptance", + eyebrow: "Make review decisive", + label: "Prepare the human acceptance pass", + summary: `${snapshot.humanReview.pending} outcome-specific acceptance ${snapshot.humanReview.pending === 1 ? "question remains" : "questions remain"}. Assemble the shortest useful walkthrough for them.`, + outcomeEffect: "The evidence state will not be falsely upgraded, but the accountable reviewer gets a concrete path to make each remaining judgment.", + deepDive: snapshot.outcome.humanCriteria.map((criterion) => `${criterion.description}${criterion.guidance ? ` — ${criterion.guidance}` : ""}`).join(" "), + basis: "The current Factfile has supported automated observations but outcome-specific human acceptance criteria remain pending.", + evidenceRefs: snapshot.outcome.humanCriteria.map((criterion) => `human:${criterion.id}`), + tradeoffs: ["Requires real human judgment", "May reveal another implementation iteration"], + instruction: `Prepare an acceptance walkthrough for these human criteria: ${snapshot.outcome.humanCriteria.map((criterion) => criterion.description).join("; ")}. Point to the most relevant evidence for each criterion, call out what is still unknown, and do not mark any human verdict yourself.`, + source: "deterministic", + }); + if (snapshot.architecture?.components.length) suggestions.push({ + id: "trace-system-impact", + eyebrow: "Understand the system", + label: "Deep-dive the architecture impact", + summary: `Trace this contribution across ${snapshot.architecture.components.length} detected components and explain the changed data or control flow.`, + outcomeEffect: "The next Factfile will make ownership and downstream effects easier to understand; code changes occur only if the analysis exposes a real gap.", + deepDive: "Follow the changed areas through the architecture projection, verify component responsibilities against source, and annotate any boundary that the generated map cannot infer safely.", + basis: `The generated architecture projection contains ${snapshot.architecture.components.length} components and the contribution changes ${snapshot.repository.changedFiles.length} files.`, + evidenceRefs: snapshot.repository.changedFiles.slice(0, 6), + tradeoffs: ["Mostly improves understanding rather than test coverage", "Generated architecture still needs source verification"], + instruction: "Trace the contribution through the current architecture projection. Verify every affected component and relationship against source, update the architecture evidence where it is incomplete, and report any newly discovered risk without inventing dependencies.", + source: "deterministic", + }); + return suggestions.slice(0, 3); +} + +function renderLocalTime(value: string, relative = true): string { + const date = new Date(value); + const fallback = Number.isNaN(date.getTime()) + ? value + : new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", year: "numeric", month: "short", day: "numeric", + hour: "numeric", minute: "2-digit", timeZoneName: "short", + }).format(date); + return ``; +} + +export function renderFactfileHtml(snapshot: GateSnapshot, options: { live?: boolean; sessionToken?: string; history?: FactfileHistoryItem[]; historical?: boolean } = {}): string { + const session = snapshot.session ?? { work: [], decisions: [], instructions: [], agents: [], directions: [], eventCount: 0 }; + const latestHuman = new Map(snapshot.reviews.filter((review) => review.criterionId && review.verdict).map((review) => [review.criterionId!, review])); + const state = snapshot.state === "accepted" + ? { tone: "good", label: "Accepted exact snapshot", result: "Accepted", detail: "A named human accepted this exact source identity." } + : snapshot.state === "ready_for_review" + ? { tone: "good", label: "Ready for acceptance", result: "Acceptance remains explicit", detail: "Every declared criterion passes; an accountable person still owns final acceptance." } + : snapshot.state === "human_review_required" + ? { tone: "wait", label: "Human review remains", result: "Evidence supported", detail: `${snapshot.humanReview.pending} required acceptance ${snapshot.humanReview.pending === 1 ? "judgment remains" : "judgments remain"}. No agent work is necessarily blocked.` } + : snapshot.state === "review_blocked" + ? { tone: "bad", label: "Human review blocked", result: "Blocked", detail: "A named reviewer determined that a required condition is not met." } + : { tone: "bad", label: "Evidence gap", result: "Not ready to accept", detail: `${snapshot.summary.failed} declared ${snapshot.summary.failed === 1 ? "claim is" : "claims are"} unsupported at this snapshot.` }; + const summary = snapshot.contribution.summary ?? snapshot.contribution.title; + const allArtifacts = snapshot.evidence.flatMap((item) => item.presentation?.artifacts ?? []); + const artifactCount = allArtifacts.filter((artifact) => artifact.digest && !artifact.unavailable).length; + const deterministicAttention = snapshot.reviewPlan.filter((item) => item.basis === "deterministic"); + const attentionRows = deterministicAttention.length + ? deterministicAttention.map((item) => `
${esc(item.title)}

${esc(item.why)}

${item.paths.length ? `${item.paths.map(esc).join(" · ")}` : ""}
${esc(item.priority)}
`).join("") + : `
No deterministic attention signal was raised

Review still requires judgment; this only means the declared scope and repository heuristics found no additional hotspot.

baseline
`; + const workRows = session.work.length + ? session.work.map((item) => `
${esc(item.title)}${esc(item.status)}

${esc(item.detail ?? "No additional detail reported.")}

${esc(item.actorId)} · ${renderLocalTime(item.updatedAt)}
`).join("") + : `
No agent has reported work yet. Connected agents use contribution_report_work; this is execution status, not proof.
`; + const pendingDecisions = session.decisions.filter((decision) => decision.status === "pending"); + const resolvedDecisions = session.decisions.filter((decision) => decision.status === "resolved"); + const resolvedDecisionRows = resolvedDecisions.map((decision) => { const option = decision.options.find((candidate) => candidate.id === decision.selectedOptionId); return `
${esc(decision.title)}

${esc(option?.label ?? decision.resolutionNote ?? "Resolved with a custom instruction")}${decision.resolvedBy ? ` · ${esc(decision.resolvedBy)}` : ""}${decision.resolvedAt ? ` · ${renderLocalTime(decision.resolvedAt)}` : ""}

resolved
`; }).join(""); + const instructionRows = session.instructions.map((instruction) => `
  • ${esc(instruction.status)} instruction
    ${esc(instruction.text)}
    ${esc(instruction.id)} · ${renderLocalTime(instruction.createdAt)}${instruction.acknowledgedBy ? ` · acknowledged by ${esc(instruction.acknowledgedBy)}` : ""}
  • `).join("") || "
  • No human instruction has been queued in this session.
  • "; + const connectedAgents = session.agents.filter((agent) => agent.connected); + const agentSummary = session.agents.length ? session.agents.map((agent) => `${esc(agent.name)} · ${agent.connected ? "connected" : `last seen ${renderLocalTime(agent.lastSeenAt)}`}`).join("") : `No agent heartbeat yet · instructions will queue durably`; + const proposedDirections = session.directions ?? []; + const directionSuggestions: DirectionSuggestion[] = proposedDirections.length + ? proposedDirections.map((direction, index) => ({ ...direction, source: "agent" as const, recommended: index === 0 })) + : buildDirectionSuggestions(snapshot); + const history = options.history ?? []; + const historyHref = (id: string): string => { + if (options.sessionToken) return `/snapshots/${encodeURIComponent(id)}.html?token=${encodeURIComponent(options.sessionToken)}`; + return options.historical ? `${encodeURIComponent(id)}.html` : `snapshots/${encodeURIComponent(id)}.html`; + }; + const historyRows = history.slice(0, 6).map((item, index) => `${index === 0 ? "●" : "○"}${item.id === snapshot.id ? "Current snapshot" : item.state.replaceAll("_", " ")}${renderLocalTime(item.generatedAt)} · ${item.passed}/${item.total} checks · ${item.humanPassed}/${item.humanTotal} human${esc(item.worktreeDigest.slice(0, 8))}`).join("") || `
    The first snapshot will appear here after the gate runs.
    `; + + // Evidence, claim by claim (kept as the collapsed deep-dive) ------------------------------ + const claims = snapshot.evidence.map((item) => { + const presentation = item.presentation; + const boundArtifacts = presentation?.artifacts.filter((artifact) => artifact.digest && !artifact.unavailable).length ?? 0; + const codeCount = presentation?.code.length ?? 0; + const media = presentation?.artifacts.filter((artifact) => (artifact.kind === "screenshot" || artifact.kind === "video") && artifact.dataUrl).map((artifact) => { const annotations = artifact.annotations ?? []; const pins = artifact.kind === "screenshot" ? annotations.filter((annotation) => annotation.x !== undefined && annotation.y !== undefined).map((annotation, index) => `${index + 1}`).join("") : ""; const notes = annotations.length ? `
    ${annotations.map((annotation, index) => `
    ${artifact.kind === "video" && annotation.atMs !== undefined ? `${Math.floor(annotation.atMs / 60000)}:${String(Math.floor((annotation.atMs % 60000) / 1000)).padStart(2, "0")}` : `${index + 1}. `}${esc(annotation.label)}${annotation.detail ? ` — ${esc(annotation.detail)}` : ""}
    `).join("")}
    ` : ""; return `
    ${artifact.kind === "video" ? `` : `${esc(artifact.label)}`}${pins}
    ${esc(artifact.label)} · ${esc(artifact.caption)}${artifact.digest ? ` · SHA-256 ${esc(artifact.digest.slice(0, 16))}…` : ""}
    This ${artifact.kind === "video" ? "recording" : "image"} demonstrates observed behavior; it does not independently establish usability or correctness.${notes}
    `; }).join("") ?? ""; + const artifacts = presentation?.artifacts.filter((artifact) => !((artifact.kind === "screenshot" || artifact.kind === "video") && artifact.dataUrl)).map((artifact) => `
    ${artifact.kind === "screenshot" || artifact.kind === "video" ? "Demonstration" : "Supporting artifact"}${artifact.unavailable ? "Unavailable · " : ""}${esc(artifact.label)}${esc(artifact.caption)}${artifact.unavailable ? ` ${esc(artifact.unavailable)}` : ""}${esc(artifact.path)}${artifact.digest ? ` · sha256:${esc(artifact.digest)}` : ""}
    `).join("") ?? ""; + const code = presentation?.code.map((ref) => `
    ${esc(ref.path)}${esc(ref.purpose)}
    `).join("") ?? ""; + const resultLabel = item.pass ? "Supported" : "Gap"; + return `
    ${item.pass ? "✓" : "!"}${esc(item.description)}${esc(presentation?.summary ?? (item.pass ? "The observation matched its declared rule." : "The observation did not match its declared rule."))}${esc(item.verification.kind)}${boundArtifacts ? `${boundArtifacts} ${boundArtifacts === 1 ? "artifact" : "artifacts"}` : ""}${codeCount ? `${codeCount} code ${codeCount === 1 ? "path" : "paths"}` : ""}${item.durationMs}ms${resultLabel}
    What this establishes

    ${esc(presentation?.summary ?? "Only that the observation matched its declared rule at this exact snapshot.")}

    Why it matters

    ${esc(presentation?.whyItMatters ?? "No outcome-specific relevance was supplied. Treat this explanation as incomplete.")}

    ${media}${artifacts ? `
    Inspectable artifacts
    ${artifacts}
    ` : ""}${code ? `
    Relevant implementation
    ${code}
    ` : ""}
    Reproduce this observation${esc(item.verification.reproduce)}
    Open verifier internals · observation, rule, runtime
    Observed\n${esc(printable(item.actual))}
    Rule\n${esc(printable(item.verification.assertion))}

    ${esc(item.verification.label)} · ${item.durationMs}ms${item.error ? ` · ${esc(item.error)}` : ""}

    `; + }).join(""); + const areaNames = new Map(); + for (const file of snapshot.repository.changedFiles) { + const area = file.startsWith("archive/") ? "Archived legacy code" + : file.startsWith("src/") ? "Product code" + : file.startsWith("tests/") ? "Tests" + : file.startsWith("docs/") || file === "README.md" ? "Documentation" + : file.startsWith(".github/") ? "GitHub workflow" + : file.startsWith(".keyoku/") ? "Proof contracts" + : "Project configuration"; + areaNames.set(area, (areaNames.get(area) ?? 0) + 1); + } + const areas = [...areaNames].map(([name, count]) => `
    ${esc(name)}${count} changed ${count === 1 ? "file" : "files"}
    `).join("") || `
    No changed filesThe worktree matches Git head.
    `; + const architecture = snapshot.architecture ? `
    ${renderArchitectureSvg(snapshot.architecture)}
    ` : `

    No architecture projection was captured. This is explicitly unknown, not silently treated as unchanged.

    `; + const people = snapshot.contribution.actors.map((actor) => `
    ${actor.kind === "human" ? "H" : actor.kind === "agent" ? "A" : "O"}
    ${esc(actor.name)}${esc(actor.role ?? actor.kind)}${actor.harness ? ` · ${esc(actor.harness)}` : ""}${actor.model ? ` · ${esc(actor.model)}` : ""}
    `).join(""); + const files = snapshot.repository.changedFiles.map((file) => `
  • ${esc(file)}
  • `).join("") || "
  • Clean Git worktree
  • "; + const constraints = snapshot.outcome.constraints.map((constraint) => `
  • ${esc(constraint)}
  • `).join("") || "
  • No explicit constraints were declared.
  • "; + const limits = (snapshot.contribution.knownLimits?.length ? snapshot.contribution.knownLimits : ["Only the claims shown here were evaluated.", "Passing checks do not establish product fit, maintainability, or universal security.", "Any source change requires a new Factfile."]).map((limit) => `
  • ${esc(limit)}
  • `).join(""); + const reviews = snapshot.reviews.length ? snapshot.reviews.map((review) => `
  • ${esc(review.decision === "accepted" ? "Accepted" : review.criterionId ? `${review.verdict} · ${review.criterionId}` : "Review note")}
    ${esc(review.reviewer.name)} · ${renderLocalTime(review.reviewedAt)}
    ${esc(review.comment)}
  • `).join("") : `
  • No human review has been recorded for this snapshot.
  • `; + + // Hero: visual-proof-first — a filmstrip of bound screenshots/video, or a CLI replay of every probe ---- + const heroFrames = snapshot.evidence.flatMap((item) => (item.presentation?.artifacts ?? []).filter((artifact) => (artifact.kind === "screenshot" || artifact.kind === "video") && artifact.dataUrl && !artifact.unavailable)); + const heroHtml = heroFrames.length + ? `
    +
    ${heroFrames.map((frame, index) => `
    ${frame.kind === "video" ? `` : `${esc(frame.label)}`}
    `).join("")}
    +
    ${esc(heroFrames[0].label)}${esc(heroFrames[0].caption)}
    + ${heroFrames.length > 1 ? `
    ${heroFrames.map((frame, index) => ``).join("")}
    ` : ""} +
    + ` + : `
    ${esc(snapshot.repository.headSha.slice(0, 8))}+${esc(snapshot.repository.worktreeDigest.slice(0, 8))}
    ${snapshot.evidence.map((item, index) => `
    $${esc(item.verification.reproduce)}${item.pass ? "✓" : "✗"} ${item.durationMs}ms
    `).join("")}
    `; + + // Short written summary ------------------------------------------------------------------------ + const summaryBlockHtml = `
    +
    ${esc(state.label)}${esc(state.detail)}
    +
    ${snapshot.summary.passed}/${snapshot.summary.total} automated checks${snapshot.humanReview.passed}/${snapshot.humanReview.total} human decisions
    +
      ${snapshot.evidence.map((item) => `
    • ${item.pass ? "✓" : "✗"}${esc(item.description)}
    • `).join("")}
    +
    `; + + // Insight — pending human decisions + proposed directions, each expandable --------------------- + const humanInsightItems = snapshot.outcome.humanCriteria.length + ? snapshot.outcome.humanCriteria.map((criterion) => { + const review = latestHuman.get(criterion.id); + const verdict = review?.verdict ?? "pending"; + return `
    ${verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "?"}${esc(criterion.description)}${esc(verdict)}

    ${esc(review?.comment ?? criterion.guidance ?? "A named human must decide this against the evidence below.")}

    ${review ? `

    ${esc(review.reviewer.name)} · ${renderLocalTime(review.reviewedAt)}

    ` : ""}
    `; + }).join("") + : `
    No outcome-specific judgment questions were declared. A passing command does not silently accept a contribution.
    `; + const blockedInsightItems = pendingDecisions.length + ? pendingDecisions.map((decision) => `
    !${esc(decision.title)}blocked
    What the agent wants${esc(decision.agentIntent)}
    What blocks it${esc(decision.blocker)}
    Why you${esc(decision.whyHuman)}
    If you do nothing${esc(decision.noResponse)}
    ${decision.options.map((option) => `
    ${option.outcomeEffect ? `
    How the outcome changes${esc(option.outcomeEffect)}
    ` : ""}${option.deepDive || option.tradeoffs?.length ? `
    Context and tradeoffs${option.deepDive ? `

    ${esc(option.deepDive)}

    ` : ""}${option.tradeoffs?.length ? `
      ${option.tradeoffs.map((tradeoff) => `
    • ${esc(tradeoff)}
    • `).join("")}
    ` : ""}
    ` : ""}
    `).join("")}

    `).join("") + : `
    No agent work is waiting on you

    Keyoku will put only a material, blocked decision here. Optional steering has its own section below.

    `; + const directionInsightItems = directionSuggestions.length + ? directionSuggestions.map((suggestion, index) => `
    ${esc(suggestion.label)}${esc(suggestion.eyebrow)}

    ${esc(suggestion.summary)}

    How the outcome changes${esc(suggestion.outcomeEffect)}
    Deep-dive context

    ${esc(suggestion.deepDive)}

    Why this is suggested: ${esc(suggestion.basis)}

    ${suggestion.evidenceRefs.length ? `

    Evidence basis: ${suggestion.evidenceRefs.map((reference) => `${esc(reference)}`).join(" · ")}

    ` : ""}${suggestion.tradeoffs.length ? `
      ${suggestion.tradeoffs.map((tradeoff) => `
    • ${esc(tradeoff)}
    • `).join("")}
    ` : ""}
    `).join("") + : `
    No contextual direction has been prepared yet.
    `; + const directionActionsHtml = directionSuggestions.length + ? `

    Write a custom direction — when prepared paths miss your intent

    State what should change, the constraint to preserve, and which evidence should look different afterward.

    ` + : ""; + const insightHtml = `
    +

    Insight

    +

    Pending decisions

    ${blockedInsightItems}${humanInsightItems}
    +

    Proposed directions

    ${directionInsightItems}${directionActionsHtml}
    +
    `; + + // Everything else — reachable, collapsed by default ------------------------------------------ + const evidenceFold = `
    Full evidence & reproduction${snapshot.summary.passed}/${snapshot.summary.total} supported · ${artifactCount} artifacts

    Claim → observation → meaning → limits → reproduction → code.

    ${claims}
    Review attention (deterministic signal, not a verdict)
    ${attentionRows}
    `; + const workFold = `
    Work log${session.work.length} items · ${connectedAgents.length} connected
    ${workRows}
    ${agentSummary}
    `; + const sessionFold = `
    Session & proof history${history.length} snapshot${history.length === 1 ? "" : "s"} · ${session.eventCount} events
    ${resolvedDecisionRows ? `
    Resolved this session
    ${resolvedDecisionRows}
    ` : ""}
    Session instructions
      ${instructionRows}
    Proof history
    ${historyRows}
    `; + const repositoryFold = `
    Repository, scope & provenance${snapshot.repository.changedFiles.length} changed files
    ${areas}
    ${architecture}

    Exact source identity

    Base${esc(snapshot.repository.baseSha)}
    Head${esc(snapshot.repository.headSha)}
    Worktree digest${esc(snapshot.repository.worktreeDigest)}
    Generated${renderLocalTime(snapshot.generatedAt, false)}

    Responsibility

    ${people}

    Human review history

      ${reviews}

    Changed files (${snapshot.repository.changedFiles.length})

      ${files}

    Outcome constraints

      ${constraints}

    Known limits

      ${limits}
    `; + + const logoMark = ``; + + const liveScript = ``; + + return `${esc(snapshot.outcome.title)} · Keyoku +
    keyoku
    ${esc(snapshot.project.name)} · outcome r${snapshot.outcome.revision}${esc(snapshot.repository.headSha.slice(0, 8))}+${esc(snapshot.repository.worktreeDigest.slice(0, 8))}
    +
    + ${options.live || options.historical ? `
    ${options.live ? `Live proof session · ${connectedAgents.length} agent${connectedAgents.length === 1 ? "" : "s"} connected` : "Historical snapshot"}
    ` : ""} + ${esc(summary)} +

    ${esc(snapshot.outcome.title)}

    +

    ${esc(snapshot.outcome.objective)}

    + ${heroHtml} + ${summaryBlockHtml} + ${insightHtml} + ${evidenceFold} + ${workFold} + ${sessionFold} + ${repositoryFold} +
    Free, provider-neutral Keyoku · evidence and judgment remain separate${esc(snapshot.id)} · sha256:${esc(snapshot.digest)}
    +
    ${liveScript}`; +} diff --git a/tests/contribution.test.ts b/tests/contribution.test.ts new file mode 100644 index 0000000..5a7fd31 --- /dev/null +++ b/tests/contribution.test.ts @@ -0,0 +1,332 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { stringify } from "yaml"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + captureRepository, + initProject, + loadProject, + publishFactfile, + reviewContribution, + runGate, + startContribution, +} from "../src/contribution.js"; + +const roots: string[] = []; + +function repo(): string { + const root = mkdtempSync(join(tmpdir(), "keyoku-contribution-")); + roots.push(root); + execFileSync("git", ["init", "-q"], { cwd: root }); + execFileSync("git", ["config", "user.email", "owner@example.com"], { cwd: root }); + execFileSync("git", ["config", "user.name", "Project Owner"], { cwd: root }); + writeFileSync(join(root, "README.md"), "# Example\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: root }); + execFileSync("git", ["commit", "-qm", "initial"], { cwd: root }); + return root; +} + +afterEach(() => { + // Temporary roots are intentionally left to the OS temp cleaner. Avoiding a + // recursive delete also makes failed test fixtures available for debugging. + roots.length = 0; +}); + +describe("repository-local contribution gates", () => { + it("initializes without overwriting an existing project", () => { + const root = repo(); + const project = initProject({ root, name: "Example Project", summary: "A proof-first example." }); + expect(project.id).toBe("example-project"); + expect(loadProject(root).summary).toBe("A proof-first example."); + expect(() => initProject({ root })).toThrow(/will not overwrite/); + }); + + it("binds a passing Factfile to the exact repository snapshot", async () => { + const root = repo(); + initProject({ root, name: "Example" }); + writeFileSync(join(root, "evidence.png"), Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64")); + writeFileSync(join(root, "journey.webm"), Buffer.from("portable-test-video")); + const timestamp = new Date().toISOString(); + writeFileSync( + join(root, ".keyoku", "outcomes", "working-build.yaml"), + stringify({ + schemaVersion: "keyoku.dev/outcome/v1alpha1", + id: "working-build", + revision: 1, + title: "The build works", + objective: "The repository exposes a machine-verifiable build result.", + owner: { kind: "human", id: "owner@example.com", name: "Project Owner" }, + constraints: ["Fail closed when the command cannot run."], + criteria: [ + { + description: "The probe returns the expected result", + probe: { kind: "command", run: "node -e \"console.log(JSON.stringify({ok:true}))\"", parse: "json" }, + assert: { path: "output.ok", op: "eq", value: true }, + evidence: { + summary: "The generated receipt renders the verified outcome for a maintainer.", + whyItMatters: "A maintainer needs to see the result, not decode the assertion implementation.", + code: [{ path: "src/contribution.ts", purpose: "Builds portable receipts and binds them to the source snapshot." }], + artifacts: [ + { kind: "screenshot", path: "evidence.png", label: "Rendered outcome", caption: "A captured view of the behavior under review.", annotations: [{ label: "Visible result", detail: "The reviewer can see the outcome.", x: 50, y: 40 }] }, + { kind: "video", path: "journey.webm", label: "Outcome journey", caption: "A short recording of the behavior under review.", annotations: [{ label: "Interaction completes", atMs: 1200 }] }, + ], + }, + }, + ], + createdAt: timestamp, + updatedAt: timestamp, + }), + "utf8", + ); + const contribution = startContribution({ + root, + outcomeId: "working-build", + summary: "Created a portable receipt that ties the requested build outcome to this exact source snapshot.", + knownLimits: ["This receipt does not establish product usability."], + }); + const snapshot = await runGate(root, contribution.id); + + expect(snapshot.state).toBe("ready_for_review"); + expect(snapshot.summary).toMatchObject({ passed: 1, failed: 0, verified: true }); + expect(snapshot.contribution.actors[0]).toMatchObject({ name: "Project Owner", kind: "human" }); + expect(snapshot.repository.changedFiles).toContain(".keyoku/outcomes/working-build.yaml"); + expect(snapshot.repository.branch).toBeTruthy(); + expect(snapshot.repository.ahead).toBe(0); + expect(snapshot.repository.changedFiles.every((path) => !path.startsWith(".keyoku/contributions/"))).toBe(true); + + const dir = join(root, ".keyoku", "contributions", contribution.id); + expect(existsSync(join(dir, "factfile.html"))).toBe(true); + expect(existsSync(join(dir, "snapshots", `${snapshot.id}.html`))).toBe(true); + const html = readFileSync(join(dir, "factfile.html"), "utf8"); + expect(html).toContain("Insight"); + expect(html).toContain("Pending decisions"); + expect(html).toContain("Proposed directions"); + expect(html).toContain("Full evidence & reproduction"); + expect(html).toContain("Work log"); + expect(html).toContain("Session & proof history"); + expect(html).toContain("Repository, scope & provenance"); + expect(html).toContain("Responsibility"); + expect(html).toContain("How the outcome changes"); + expect(html).toContain("class=\"hero hero-film\""); + expect(html).toContain("film-caption"); + expect(html).toContain("Toggle light and dark appearance"); + expect(html).toContain("@media(prefers-color-scheme:dark)"); + expect(html).toContain(""); + expect(html).toContain("class=\"local-time\""); + expect(html).toContain("Intl.RelativeTimeFormat"); + expect(html).toContain("Copy direction"); + expect(html).not.toContain("Steer the agent directly"); + expect(html).toContain("Created a portable receipt"); + expect(html).toContain("This receipt does not establish product usability."); + expect(html).toContain("What this establishes"); + expect(html).toContain("Relevant implementation"); + expect(html).toContain("Reproduce this observation"); + expect(html).toContain("data:image/png;base64,"); + expect(html).toContain("data:video/webm;base64,"); + expect(html).toContain("