From c55e37cdb42dac96789df90e794cda07a259cb6d Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:36:31 -0700 Subject: [PATCH] feat(miner): build the coding-task-spec (title/instructions/acceptanceCriteriaPath) (#5132) The second gap discovered alongside #5132's CLI wiring: IterateLoopInput's title/instructions/acceptanceCriteriaPath had no builder anywhere in this package. packages/gittensory-engine/src/miner/acceptance-criteria.ts already composes a PromptPacket + FeasibilityGateResult into an immutable AcceptanceCriteria document, but deliberately does not write it -- "that's the worktree primitive's job" per its own header. This module is that caller. buildCodingTaskFeasibility computes a real FeasibilityGateResult from signals the miner already has: whether the issue is present in the fetched context, its real claim status (the claim ledger), and its real duplicate-cluster risk (buildCollisionReport). issueStatus is left to feasibilityInputFromPreStartCheck's own documented "ready" default when quality data is unavailable -- the same honest-default precedent #5145 established for SelfReviewContext's own bounties/issueQuality gap. buildCodingTaskAcceptanceCriteria + writeAcceptanceCriteriaFile compose and write the real file -- only for a go verdict, per the module's own design (raise/avoid means the attempt should not start). Deliberately duplicate-cluster risk here does NOT apply #5145's ">= 2 pull_request items" threshold: that gate exists to stop self-review inDuplicateCluster from false-flagging one legitimately-closing PR. Feasibility asks a different question -- should this attempt even start -- where any existing PR already working the issue is a real caution signal, documented inline. --- .../lib/coding-task-spec.d.ts | 44 ++++ .../gittensory-miner/lib/coding-task-spec.js | 167 +++++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-coding-task-spec.test.ts | 234 ++++++++++++++++++ 4 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/coding-task-spec.d.ts create mode 100644 packages/gittensory-miner/lib/coding-task-spec.js create mode 100644 test/unit/miner-coding-task-spec.test.ts diff --git a/packages/gittensory-miner/lib/coding-task-spec.d.ts b/packages/gittensory-miner/lib/coding-task-spec.d.ts new file mode 100644 index 0000000000..13a5bed0bc --- /dev/null +++ b/packages/gittensory-miner/lib/coding-task-spec.d.ts @@ -0,0 +1,44 @@ +import type { AcceptanceCriteria, FeasibilityGateResult, FeasibilityVerdict, IssueRecord, PullRequestRecord } from "@jsonbored/gittensory-engine"; + +export type CodingTaskIssue = { number: number; title: string; body?: string | null | undefined; labels?: string[] | undefined }; + +export type CodingTaskClaimLedger = { + listClaims(filter: { repoFullName: string; status: string }): Array<{ issueNumber: number }>; +}; + +export type CodingTaskContext = { issues: IssueRecord[]; pullRequests: PullRequestRecord[] }; + +export function buildCodingTaskFeasibility( + repoFullName: string, + issue: CodingTaskIssue, + context: CodingTaskContext, + claimLedger: CodingTaskClaimLedger, +): FeasibilityGateResult; + +export function buildCodingTaskAcceptanceCriteria(issue: CodingTaskIssue, feasibility: FeasibilityGateResult): AcceptanceCriteria; + +export function writeAcceptanceCriteriaFile(workingDirectory: string, acceptanceCriteria: AcceptanceCriteria): { written: boolean; path: string | null }; + +export type CodingTaskSpecInput = { + repoFullName: string; + issue: CodingTaskIssue; + context: CodingTaskContext; + claimLedger: CodingTaskClaimLedger; + workingDirectory: string; +}; + +export type CodingTaskSpecResult = + | { ready: false; verdict: FeasibilityVerdict; feasibility: FeasibilityGateResult } + | { + ready: true; + verdict: FeasibilityVerdict; + feasibility: FeasibilityGateResult; + acceptanceCriteriaPath: string; + instructions: string; + title: string; + body: string | undefined; + labels: string[] | undefined; + linkedIssues: number[]; + }; + +export function buildCodingTaskSpec(input: CodingTaskSpecInput): CodingTaskSpecResult; diff --git a/packages/gittensory-miner/lib/coding-task-spec.js b/packages/gittensory-miner/lib/coding-task-spec.js new file mode 100644 index 0000000000..ea77ee5bbb --- /dev/null +++ b/packages/gittensory-miner/lib/coding-task-spec.js @@ -0,0 +1,167 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + ACCEPTANCE_CRITERIA_FILENAME, + buildAcceptanceCriteria, + buildCollisionReport, + buildFeasibilityVerdict, + buildPromptPacket, + feasibilityInputFromPreStartCheck, + serializeAcceptanceCriteria, + shouldWriteAcceptanceCriteria, +} from "@jsonbored/gittensory-engine"; + +// Coding-task-spec builder (#5132, Wave 3.5 follow-up). The second gap discovered alongside #5132's CLI +// wiring: `IterateLoopInput.title`/`instructions`/`acceptanceCriteriaPath` had no builder anywhere in this +// package. `packages/gittensory-engine/src/miner/acceptance-criteria.ts` already composes a PromptPacket + +// FeasibilityGateResult into an immutable AcceptanceCriteria document (and deliberately does NOT write it -- +// "actually writing it into the attempt's worktree is the worktree primitive's job", per its own header) -- +// this module is that caller: derives the four inputs from a real target issue + the already-fetched +// SelfReviewContext (#5145), then writes the file for real. +// +// issueStatus is intentionally left undefined when computing feasibility: buildIssueQualityReport (the only +// thing that could supply it) lives only in root src/signals/engine.ts and has never been extracted into +// @jsonbored/gittensory-engine (same gap #5145's own header documents for `issueQuality`). This is not a +// fabrication -- feasibilityInputFromPreStartCheck's OWN documented default for a missing +// issueQualityStatus/lifecycle is "ready", the same honest-default precedent already established. + +function buildTaskBrief(issue) { + const body = (issue.body ?? "").trim(); + return body ? `${issue.title}\n\n${body}` : issue.title; +} + +function buildConstraints(issue) { + if (!Array.isArray(issue.labels) || issue.labels.length === 0) return ""; + return `Labels on this issue: ${issue.labels.join(", ")}.`; +} + +function buildFeasibilityNotes(feasibility) { + return [feasibility.summary, ...feasibility.avoidReasons, ...feasibility.raiseReasons].join("\n"); +} + +// Only ever resolves to "claimed"/"unclaimed": the claim ledger's own ClaimStatus vocabulary +// ("active"|"released"|"expired") has no "solved" concept for FeasibilityClaimStatus's "solved" value to +// map from -- that would need real evidence a PR already resolved the issue (e.g. a merged, linked PR), +// which this function doesn't have access to. Not fabricated; genuinely undetectable from claim data alone. +function resolveClaimStatus(claimLedger, repoFullName, issueNumber) { + const claims = claimLedger.listClaims({ repoFullName, status: "active" }); + return claims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; +} + +// The target issue's own raw cluster risk from buildCollisionReport (newly exported from +// @jsonbored/gittensory-engine's public barrel) -- "none" when the issue isn't part of any cluster at all. +// DELIBERATELY does NOT apply #5145's ">= 2 pull_request items" threshold: that gate exists specifically to +// stop inDuplicateCluster (self-review, "does MY OWN just-created submission look redundant") from firing on +// the ordinary case of one existing PR already legitimately closing the issue. Feasibility asks a different +// question -- "should I even START working on this issue" -- where an issue already having ANY open PR +// against it (buildCollisionReport's pairwise "shared linked issue" rule, which fires at "high" for exactly +// one PR) is a meaningful, real caution signal, not a false positive to filter out. +function resolveDuplicateClusterRisk(repoFullName, issues, pullRequests, issueNumber) { + const report = buildCollisionReport(repoFullName, issues, pullRequests); + const cluster = report.clusters.find((entry) => entry.items.some((item) => item.type === "issue" && item.number === issueNumber)); + return cluster ? cluster.risk : "none"; +} + +/** + * Compute the feasibility verdict for one target issue, from real signals: whether the issue is present in + * the fetched context, its real claim status (the claim ledger), and its real duplicate-cluster risk + * (buildCollisionReport over the fetched issues/pullRequests). issueStatus is left to its documented + * "ready" default -- see this file's header for why that's honest, not fabricated. + * + * @param {string} repoFullName + * @param {{ number: number }} issue + * @param {{ issues: Array<{ number: number }>, pullRequests: unknown[] }} context + * @param {{ listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }} claimLedger + * @returns {import("@jsonbored/gittensory-engine").FeasibilityGateResult} + */ +export function buildCodingTaskFeasibility(repoFullName, issue, context, claimLedger) { + const found = context.issues.some((candidate) => candidate.number === issue.number); + const claimStatus = resolveClaimStatus(claimLedger, repoFullName, issue.number); + const duplicateClusterRisk = resolveDuplicateClusterRisk(repoFullName, context.issues, context.pullRequests, issue.number); + const feasibilityInput = feasibilityInputFromPreStartCheck({ found, claimStatus, duplicateClusterRisk }); + return buildFeasibilityVerdict(feasibilityInput); +} + +/** + * Compose the immutable AcceptanceCriteria document for one target issue + its feasibility verdict. + * + * @param {{ title: string, body?: string | null, labels?: string[] }} issue + * @param {import("@jsonbored/gittensory-engine").FeasibilityGateResult} feasibility + * @returns {import("@jsonbored/gittensory-engine").AcceptanceCriteria} + */ +export function buildCodingTaskAcceptanceCriteria(issue, feasibility) { + const promptPacket = buildPromptPacket({ + taskBrief: buildTaskBrief(issue), + constraints: buildConstraints(issue), + feasibilityNotes: buildFeasibilityNotes(feasibility), + retrievalContext: "", + }); + return buildAcceptanceCriteria({ promptPacket, feasibility }); +} + +/** + * Write the acceptance-criteria document into the prepared worktree -- only when its own verdict authorizes + * it (shouldWriteAcceptanceCriteria: verdict === "go"). A raise/avoid verdict writes nothing; the caller is + * expected to abandon the attempt rather than start it, per acceptance-criteria.ts's own documented design. + * + * @param {string} workingDirectory + * @param {import("@jsonbored/gittensory-engine").AcceptanceCriteria} acceptanceCriteria + * @returns {{ written: boolean, path: string | null }} + */ +export function writeAcceptanceCriteriaFile(workingDirectory, acceptanceCriteria) { + if (!shouldWriteAcceptanceCriteria(acceptanceCriteria.verdict)) return { written: false, path: null }; + const path = join(workingDirectory, ACCEPTANCE_CRITERIA_FILENAME); + writeFileSync(path, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); + return { written: true, path }; +} + +/** + * The coding-agent driver's own prompt text (agent-sdk-driver.ts's header: "forwarded verbatim as the + * prompt -- the acceptance-criteria document already lives inside the worktree", so this points to it + * rather than repeating its content). + */ +function buildInstructions(issue, acceptanceCriteriaPath) { + return [ + `Resolve the following GitHub issue in this repository: #${issue.number} -- ${issue.title}`, + "", + (issue.body ?? "").trim(), + "", + `A structured acceptance-criteria document describing what "done" means for this attempt is at ${acceptanceCriteriaPath} -- read it and ensure your change satisfies every criterion before finishing.`, + ].join("\n"); +} + +/** + * Full composition: feasibility -> acceptance criteria -> (if authorized) write the file -> instructions. + * Returns `ready: false` (with the computed feasibility verdict, for the caller to report) when the + * verdict is `raise`/`avoid` -- the caller should abandon the attempt rather than proceed with no real + * acceptance-criteria file on disk. + * + * @param {{ + * repoFullName: string, issue: { number: number, title: string, body?: string | null, labels?: string[] }, + * context: { issues: Array<{ number: number }>, pullRequests: unknown[] }, + * claimLedger: { listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }, + * workingDirectory: string, + * }} input + * @returns {import("./coding-task-spec.js").CodingTaskSpecResult} + */ +export function buildCodingTaskSpec(input) { + const feasibility = buildCodingTaskFeasibility(input.repoFullName, input.issue, input.context, input.claimLedger); + const acceptanceCriteria = buildCodingTaskAcceptanceCriteria(input.issue, feasibility); + const writeResult = writeAcceptanceCriteriaFile(input.workingDirectory, acceptanceCriteria); + + if (!writeResult.written) { + return { ready: false, verdict: feasibility.verdict, feasibility }; + } + + return { + ready: true, + verdict: feasibility.verdict, + feasibility, + acceptanceCriteriaPath: writeResult.path, + instructions: buildInstructions(input.issue, writeResult.path), + title: input.issue.title, + body: input.issue.body ?? undefined, + labels: input.issue.labels, + linkedIssues: [input.issue.number], + }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 5b0addd37e..79a9db3434 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-coding-task-spec.test.ts b/test/unit/miner-coding-task-spec.test.ts new file mode 100644 index 0000000000..d587f4aca1 --- /dev/null +++ b/test/unit/miner-coding-task-spec.test.ts @@ -0,0 +1,234 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { + buildCodingTaskAcceptanceCriteria, + buildCodingTaskFeasibility, + buildCodingTaskSpec, + writeAcceptanceCriteriaFile, +} from "../../packages/gittensory-miner/lib/coding-task-spec.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempDir() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-coding-task-spec-")); + roots.push(root); + return root; +} + +function issue(overrides: Record = {}) { + return { + repoFullName: "acme/widgets", + number: 7, + title: "Uploads should retry on 5xx", + state: "open", + authorLogin: "reporter", + authorAssociation: "NONE", + htmlUrl: null, + body: "Uploads fail silently on transient errors.", + createdAt: null, + updatedAt: null, + closedAt: null, + labels: ["bug"], + linkedPrs: [], + ...overrides, + }; +} + +function pr(overrides: Record = {}) { + return { + repoFullName: "acme/widgets", + number: 42, + title: "Unrelated docs fix", + state: "open", + authorLogin: "someone-else", + authorAssociation: "NONE", + headSha: null, + headRef: null, + baseRef: "main", + htmlUrl: null, + mergedAt: null, + isDraft: false, + mergeableState: "clean", + reviewDecision: null, + body: "", + createdAt: null, + updatedAt: null, + closedAt: null, + labels: [], + linkedIssues: [], + ...overrides, + }; +} + +function claimLedger(activeClaims: Array<{ issueNumber: number }> = []) { + return { listClaims: () => activeClaims }; +} + +describe("buildCodingTaskFeasibility (#5132)", () => { + it("returns go for an unclaimed, un-clustered, present issue", () => { + const context = { issues: [issue()], pullRequests: [] }; + const result = buildCodingTaskFeasibility("acme/widgets", issue(), context, claimLedger()); + expect(result.verdict).toBe("go"); + expect(result.avoidReasons).toEqual([]); + expect(result.raiseReasons).toEqual([]); + }); + + it("returns raise with claim_status_claimed when another active claim exists on the target issue", () => { + const context = { issues: [issue()], pullRequests: [] }; + const result = buildCodingTaskFeasibility("acme/widgets", issue(), context, claimLedger([{ issueNumber: 7 }])); + expect(result.verdict).toBe("raise"); + expect(result.raiseReasons).toContain("claim_status_claimed"); + }); + + it("returns avoid with duplicate_cluster_high when the issue already sits in a high-risk cluster (one legitimately-closing PR is enough to trigger this, unlike #5145's stricter check)", () => { + const context = { issues: [issue()], pullRequests: [pr({ body: "Closes #7", linkedIssues: [7] })] }; + const result = buildCodingTaskFeasibility("acme/widgets", issue(), context, claimLedger()); + expect(result.verdict).toBe("avoid"); + expect(result.avoidReasons).toContain("duplicate_cluster_high"); + }); + + it("returns raise with target_not_found when the issue isn't present in the fetched context", () => { + const context = { issues: [], pullRequests: [] }; + const result = buildCodingTaskFeasibility("acme/widgets", issue(), context, claimLedger()); + expect(result.verdict).toBe("raise"); + expect(result.raiseReasons).toContain("target_not_found"); + }); + + it("defaults issueStatus to ready (no fabricated quality data, matching feasibilityInputFromPreStartCheck's own documented default)", () => { + const context = { issues: [issue()], pullRequests: [] }; + const result = buildCodingTaskFeasibility("acme/widgets", issue(), context, claimLedger()); + // A "ready" issueStatus contributes no avoid/raise reason at all -- confirmed by the clean go verdict above + // with zero reasons, since none of the other collect* functions fire for issueStatus === "ready". + expect(result.avoidReasons.some((reason) => reason.includes("issue"))).toBe(false); + expect(result.raiseReasons.some((reason) => reason.includes("issue_quality") || reason.includes("issue_missing"))).toBe(false); + }); +}); + +describe("buildCodingTaskAcceptanceCriteria (#5132)", () => { + it("composes a go document with sanitized text fields", () => { + const feasibility = buildCodingTaskFeasibility("acme/widgets", issue(), { issues: [issue()], pullRequests: [] }, claimLedger()); + const doc = buildCodingTaskAcceptanceCriteria(issue(), feasibility); + expect(doc.verdict).toBe("go"); + expect(doc.writable).toBe(true); + expect(doc.taskBrief).toContain("Uploads should retry on 5xx"); + expect(doc.taskBrief).toContain("Uploads fail silently on transient errors."); + expect(doc.constraints).toBe("Labels on this issue: bug."); + }); + + it("scrubs unsafe economic/identity terms from the issue body via the shared sanitizer", () => { + const withUnsafeTerm = issue({ body: "This issue affects the miner's reward calculation." }); + const feasibility = buildCodingTaskFeasibility("acme/widgets", withUnsafeTerm, { issues: [withUnsafeTerm], pullRequests: [] }, claimLedger()); + const doc = buildCodingTaskAcceptanceCriteria(withUnsafeTerm, feasibility); + expect(doc.taskBrief).not.toContain("reward"); + expect(doc.taskBrief).toContain("[redacted]"); + }); + + it("produces empty constraints when the issue has no labels", () => { + const noLabels = issue({ labels: [] }); + const feasibility = buildCodingTaskFeasibility("acme/widgets", noLabels, { issues: [noLabels], pullRequests: [] }, claimLedger()); + const doc = buildCodingTaskAcceptanceCriteria(noLabels, feasibility); + expect(doc.constraints).toBe(""); + }); + + it("marks a raise/avoid verdict document as not writable", () => { + const feasibility = buildCodingTaskFeasibility("acme/widgets", issue(), { issues: [], pullRequests: [] }, claimLedger()); + const doc = buildCodingTaskAcceptanceCriteria(issue(), feasibility); + expect(doc.verdict).toBe("raise"); + expect(doc.writable).toBe(false); + }); +}); + +describe("writeAcceptanceCriteriaFile (#5132)", () => { + it("writes the file only when the document's verdict is go", () => { + const dir = tempDir(); + const feasibility = buildCodingTaskFeasibility("acme/widgets", issue(), { issues: [issue()], pullRequests: [] }, claimLedger()); + const doc = buildCodingTaskAcceptanceCriteria(issue(), feasibility); + + const result = writeAcceptanceCriteriaFile(dir, doc); + expect(result.written).toBe(true); + expect(result.path).toBe(join(dir, "acceptance-criteria.json")); + const parsed = JSON.parse(readFileSync(result.path!, "utf8")); + expect(parsed.verdict).toBe("go"); + expect(parsed.taskBrief).toContain("Uploads should retry on 5xx"); + }); + + it("does not write anything for a raise/avoid verdict document", () => { + const dir = tempDir(); + const feasibility = buildCodingTaskFeasibility("acme/widgets", issue(), { issues: [], pullRequests: [] }, claimLedger()); + const doc = buildCodingTaskAcceptanceCriteria(issue(), feasibility); + + const result = writeAcceptanceCriteriaFile(dir, doc); + expect(result).toEqual({ written: false, path: null }); + }); +}); + +describe("buildCodingTaskSpec (#5132)", () => { + it("REGRESSION: assembles a real, ready spec for a legitimate go verdict", () => { + const dir = tempDir(); + const target = issue(); + const result = buildCodingTaskSpec({ + repoFullName: "acme/widgets", + issue: target, + context: { issues: [target], pullRequests: [] }, + claimLedger: claimLedger(), + workingDirectory: dir, + }); + + expect(result.ready).toBe(true); + if (!result.ready) throw new Error("expected ready"); + expect(result.verdict).toBe("go"); + expect(result.acceptanceCriteriaPath).toBe(join(dir, "acceptance-criteria.json")); + expect(result.instructions).toContain("#7 -- Uploads should retry on 5xx"); + expect(result.instructions).toContain(result.acceptanceCriteriaPath); + expect(result.title).toBe("Uploads should retry on 5xx"); + expect(result.body).toBe("Uploads fail silently on transient errors."); + expect(result.labels).toEqual(["bug"]); + expect(result.linkedIssues).toEqual([7]); + // The file genuinely landed on disk -- not just returned in memory. + expect(JSON.parse(readFileSync(result.acceptanceCriteriaPath, "utf8")).writable).toBe(true); + }); + + it("returns ready:false and writes nothing when the verdict is raise/avoid", () => { + const dir = tempDir(); + const target = issue(); + const result = buildCodingTaskSpec({ + repoFullName: "acme/widgets", + issue: target, + context: { issues: [], pullRequests: [] }, + claimLedger: claimLedger(), + workingDirectory: dir, + }); + + expect(result.ready).toBe(false); + if (result.ready) throw new Error("expected not ready"); + expect(result.verdict).toBe("raise"); + expect(result.feasibility.raiseReasons).toContain("target_not_found"); + expect(() => readFileSync(join(dir, "acceptance-criteria.json"), "utf8")).toThrow(); + }); + + it("omits body from the result when the issue has no body", () => { + const dir = tempDir(); + const target = issue({ body: null }); + const result = buildCodingTaskSpec({ + repoFullName: "acme/widgets", + issue: target, + context: { issues: [target], pullRequests: [] }, + claimLedger: claimLedger(), + workingDirectory: dir, + }); + expect(result.ready).toBe(true); + if (!result.ready) throw new Error("expected ready"); + expect(result.body).toBeUndefined(); + }); +});