Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions packages/gittensory-engine/src/miner/acceptance-criteria.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
119 changes: 119 additions & 0 deletions test/unit/miner-acceptance-criteria.test.ts
Original file line number Diff line number Diff line change
@@ -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 <local-path> on the box.");
expect(doc.retrievalContext).toBe("Windows path <local-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);
});
});