diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index cb7ff228d2..af7bd6f173 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -155,6 +155,15 @@ export { type AttemptLogEventType, type NormalizedAttemptLogEvent, } from "./miner/attempt-log.js"; +export { + ACCEPTANCE_CRITERIA_FILENAME, + ACCEPTANCE_CRITERIA_VERSION, + buildAcceptanceCriteria, + serializeAcceptanceCriteria, + shouldWriteAcceptanceCriteria, + type AcceptanceCriteria, + type AcceptanceCriteriaInput, +} from "./miner/acceptance-criteria.js"; export { codingAgentModeExecutes, isGlobalMinerCodingAgentPause, diff --git a/packages/gittensory-engine/src/miner/acceptance-criteria.ts b/packages/gittensory-engine/src/miner/acceptance-criteria.ts new file mode 100644 index 0000000000..d60e763799 --- /dev/null +++ b/packages/gittensory-engine/src/miner/acceptance-criteria.ts @@ -0,0 +1,105 @@ +import { sanitizePromptPacketField, type PromptPacket } from "../prompt-packet.js"; +import type { FeasibilityGateResult, FeasibilityVerdict } from "../feasibility.js"; + +// Acceptance-criteria composer (#4271). Before a coding-agent driver (#4262's interface; #4266/#4267's +// implementations) starts editing, the miner pins down — immutably, so the agent cannot quietly redefine its own +// success bar mid-attempt — what "done" means for this attempt. This module is the pure composition step: it folds +// the two already-shipped Phase 2 primitives — the sanitized `PromptPacket` (prompt-packet.ts, the analyze→coding- +// agent "boundary membrane") and the `FeasibilityGateResult` go/raise/avoid verdict (feasibility.ts) — into one +// document. Producing the document is this module's job; actually writing it into the attempt's worktree is the +// worktree primitive's (#4269), and handing it to the driver is the driver interface's (#4262) — neither is here, +// so this stays pure and side-effect-free like the rest of gittensory-engine. +// +// DECISIONS this file makes (per the issue's open questions): +// - Serialization format: JSON, not markdown. The acceptance criteria are an immutable, checksum-verifiable success +// bar consumed by tooling (a self-review step must be able to prove the target did not move across iterations), so +// a deterministic, canonically-ordered JSON document beats prose. `serializeAcceptanceCriteria` emits stable-key- +// order JSON with a trailing newline so a checksum recorded alongside stays byte-stable. +// - Filename: a single fixed name, `ACCEPTANCE_CRITERIA_FILENAME`. +// - Immutability: the built document is deep-frozen (`Object.freeze`, arrays copied+frozen) so it cannot be mutated +// in-memory for the lifetime of the attempt; the byte-stable serialization is what a caller checksums on disk. +// - Written only on `go`: a `raise`/`avoid` verdict means the attempt should not start, so no criteria file is +// written. The builder still returns a document (with `writable: false`) so a caller can log *why* it was skipped; +// `shouldWriteAcceptanceCriteria` is the gate for the write itself. +// +// Redaction is delegated to `sanitizePromptPacketField` rather than re-implemented: this document is exactly as +// exposed to a prompt-injectable coding-agent session as the prompt packet, so it gets the same scrub (idempotent +// on already-sanitized packet text). + +/** Fixed on-disk filename for the per-attempt acceptance-criteria document written into the attempt worktree. */ +export const ACCEPTANCE_CRITERIA_FILENAME = "acceptance-criteria.json"; + +/** Schema version of the serialized document; bump on any field-shape change. */ +export const ACCEPTANCE_CRITERIA_VERSION = 1; + +/** Inputs to the composer: the sanitized prompt packet and the feasibility verdict for this attempt. */ +export type AcceptanceCriteriaInput = { + promptPacket: PromptPacket; + feasibility: FeasibilityGateResult; +}; + +/** The composed, immutable per-attempt success bar. All fields are read-only; the document is frozen once built. */ +export type AcceptanceCriteria = { + readonly version: number; + readonly verdict: FeasibilityVerdict; + /** Whether this attempt is authorized to start (and therefore the file should be written): `verdict === "go"`. */ + readonly writable: boolean; + readonly taskBrief: string; + readonly constraints: string; + readonly feasibilityNotes: string; + readonly retrievalContext: string; + readonly feasibilitySummary: string; + readonly avoidReasons: readonly string[]; + readonly raiseReasons: readonly string[]; +}; + +/** + * Only a `go` feasibility verdict authorizes the attempt to start, so only `go` gets an acceptance-criteria file + * written to the worktree. `raise`/`avoid` should be handled upstream (the attempt does not begin) — this predicate + * is the single source of truth for that gate. + */ +export function shouldWriteAcceptanceCriteria(verdict: FeasibilityVerdict): boolean { + return verdict === "go"; +} + +/** + * Pure builder: compose a {@link PromptPacket} and a {@link FeasibilityGateResult} into one immutable + * acceptance-criteria document. Text fields are re-sanitized with {@link sanitizePromptPacketField} (idempotent), + * and the returned document is deep-frozen so it cannot be mutated for the lifetime of the attempt. + */ +export function buildAcceptanceCriteria(input: AcceptanceCriteriaInput): AcceptanceCriteria { + const { promptPacket, feasibility } = input; + return Object.freeze({ + version: ACCEPTANCE_CRITERIA_VERSION, + verdict: feasibility.verdict, + writable: shouldWriteAcceptanceCriteria(feasibility.verdict), + taskBrief: sanitizePromptPacketField(promptPacket.taskBrief), + constraints: sanitizePromptPacketField(promptPacket.constraints), + feasibilityNotes: sanitizePromptPacketField(promptPacket.feasibilityNotes), + retrievalContext: sanitizePromptPacketField(promptPacket.retrievalContext), + feasibilitySummary: feasibility.summary, + avoidReasons: Object.freeze([...feasibility.avoidReasons]), + raiseReasons: Object.freeze([...feasibility.raiseReasons]), + }); +} + +/** + * Deterministic canonical JSON serialization of a built document: fixed key order (independent of the input + * object's own key order) plus a trailing newline, so a checksum recorded alongside the file stays byte-stable + * across processes. `JSON.parse` round-trips it back to the same field values. + */ +export function serializeAcceptanceCriteria(doc: AcceptanceCriteria): string { + const ordered = { + version: doc.version, + verdict: doc.verdict, + writable: doc.writable, + taskBrief: doc.taskBrief, + constraints: doc.constraints, + feasibilityNotes: doc.feasibilityNotes, + retrievalContext: doc.retrievalContext, + feasibilitySummary: doc.feasibilitySummary, + avoidReasons: [...doc.avoidReasons], + raiseReasons: [...doc.raiseReasons], + }; + return `${JSON.stringify(ordered, null, 2)}\n`; +} diff --git a/test/unit/miner-acceptance-criteria.test.ts b/test/unit/miner-acceptance-criteria.test.ts new file mode 100644 index 0000000000..c9e7c539b0 --- /dev/null +++ b/test/unit/miner-acceptance-criteria.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + ACCEPTANCE_CRITERIA_FILENAME, + ACCEPTANCE_CRITERIA_VERSION, + buildAcceptanceCriteria, + serializeAcceptanceCriteria, + shouldWriteAcceptanceCriteria, + buildFeasibilityVerdict, + type AcceptanceCriteria, + type PromptPacket, +} from "../../packages/gittensory-engine/src/index"; + +const CLEAN_PACKET: PromptPacket = { + taskBrief: "Fix the off-by-one in the pagination cursor.", + feasibilityNotes: "Unclaimed, no dup cluster, issue ready.", + retrievalContext: "cursor.ts handles the page window.", + constraints: "No public API changes; add a unit test.", +}; + +const GO = buildFeasibilityVerdict({ claimStatus: "unclaimed", duplicateClusterRisk: "none", issueStatus: "ready" }); +const RAISE = buildFeasibilityVerdict({ claimStatus: "claimed", duplicateClusterRisk: "none", issueStatus: "ready" }); +const AVOID = buildFeasibilityVerdict({ claimStatus: "solved", duplicateClusterRisk: "none", issueStatus: "ready" }); + +describe("acceptance-criteria composer (#4271)", () => { + it("re-exports the composer API from the engine barrel", () => { + expect(typeof buildAcceptanceCriteria).toBe("function"); + expect(typeof serializeAcceptanceCriteria).toBe("function"); + expect(typeof shouldWriteAcceptanceCriteria).toBe("function"); + expect(ACCEPTANCE_CRITERIA_FILENAME).toBe("acceptance-criteria.json"); + expect(ACCEPTANCE_CRITERIA_VERSION).toBe(1); + }); + + it("composes a go-verdict document from both inputs and marks it writable", () => { + const doc = buildAcceptanceCriteria({ promptPacket: CLEAN_PACKET, feasibility: GO }); + expect(doc.version).toBe(ACCEPTANCE_CRITERIA_VERSION); + expect(doc.verdict).toBe("go"); + expect(doc.writable).toBe(true); + expect(doc.taskBrief).toBe(CLEAN_PACKET.taskBrief); + expect(doc.constraints).toBe(CLEAN_PACKET.constraints); + expect(doc.feasibilityNotes).toBe(CLEAN_PACKET.feasibilityNotes); + expect(doc.retrievalContext).toBe(CLEAN_PACKET.retrievalContext); + expect(doc.feasibilitySummary).toBe(GO.summary); + expect(doc.avoidReasons).toEqual([]); + expect(doc.raiseReasons).toEqual([]); + }); + + it("delegates redaction to the prompt-packet scrubber (unsafe terms + local paths)", () => { + const dirty: PromptPacket = { + taskBrief: "Stop leaking the wallet hotkey in the payout log.", + feasibilityNotes: "Notes live at /home/miner/notes.txt on the box.", + retrievalContext: "Windows path C:\\Users\\miner\\ctx.md is fine to mention.", + constraints: "Do not change the reward weighting.", + }; + const doc = buildAcceptanceCriteria({ promptPacket: dirty, feasibility: GO }); + expect(doc.taskBrief).toBe("Stop leaking the [redacted] [redacted] in the [redacted] log."); + expect(doc.feasibilityNotes).toBe("Notes live at on the box."); + expect(doc.retrievalContext).toBe("Windows path is fine to mention."); + expect(doc.constraints).toBe("Do not change the [redacted] weighting."); + }); + + it("marks a raise verdict non-writable and carries its raiseReasons", () => { + const doc = buildAcceptanceCriteria({ promptPacket: CLEAN_PACKET, feasibility: RAISE }); + expect(doc.verdict).toBe("raise"); + expect(doc.writable).toBe(false); + expect(doc.raiseReasons).toEqual(["claim_status_claimed"]); + expect(doc.avoidReasons).toEqual([]); + }); + + it("marks an avoid verdict non-writable and carries its avoidReasons", () => { + const doc = buildAcceptanceCriteria({ promptPacket: CLEAN_PACKET, feasibility: AVOID }); + expect(doc.verdict).toBe("avoid"); + expect(doc.writable).toBe(false); + expect(doc.avoidReasons).toEqual(["claim_status_solved"]); + expect(doc.raiseReasons).toEqual([]); + }); + + it("gates the write on a go verdict only", () => { + expect(shouldWriteAcceptanceCriteria("go")).toBe(true); + expect(shouldWriteAcceptanceCriteria("raise")).toBe(false); + expect(shouldWriteAcceptanceCriteria("avoid")).toBe(false); + }); + + it("deep-freezes the built document so the success bar cannot be mutated mid-attempt", () => { + const doc = buildAcceptanceCriteria({ promptPacket: CLEAN_PACKET, feasibility: AVOID }); + expect(Object.isFrozen(doc)).toBe(true); + expect(Object.isFrozen(doc.avoidReasons)).toBe(true); + expect(() => { + (doc as { taskBrief: string }).taskBrief = "tampered"; + }).toThrow(); + expect(() => { + (doc.avoidReasons as string[]).push("injected"); + }).toThrow(); + }); + + it("serializes deterministically with a stable key order and trailing newline", () => { + const doc = buildAcceptanceCriteria({ promptPacket: CLEAN_PACKET, feasibility: GO }); + const a = serializeAcceptanceCriteria(doc); + const b = serializeAcceptanceCriteria(doc); + expect(a).toBe(b); + expect(a.endsWith("\n")).toBe(true); + expect(a.startsWith('{\n "version": 1,')).toBe(true); + expect(JSON.parse(a)).toMatchObject({ verdict: "go", writable: true, taskBrief: CLEAN_PACKET.taskBrief }); + + // Canonical order is independent of the input object's own key order. + const shuffled: AcceptanceCriteria = { + raiseReasons: [], + avoidReasons: [], + feasibilitySummary: GO.summary, + retrievalContext: CLEAN_PACKET.retrievalContext, + feasibilityNotes: CLEAN_PACKET.feasibilityNotes, + constraints: CLEAN_PACKET.constraints, + taskBrief: CLEAN_PACKET.taskBrief, + writable: true, + verdict: "go", + version: ACCEPTANCE_CRITERIA_VERSION, + }; + expect(serializeAcceptanceCriteria(shuffled)).toBe(a); + }); +});