diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 218c4392bb..e7538a43c1 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -47,7 +47,9 @@ package manager, and the build / test / lint / format commands — for Node (npm (pip/poetry/pipenv/uv), Rust, Go, Maven, and Gradle. It is pure (injectable `existsSync` / `readFileSync`), never throws, and per its acceptance criteria **fails closed** — a repo with no recognized manifest returns `{ detected: false, reason }` and a command that can't be inferred without guessing stays `null`, rather than being -assumed. Detection only; wiring the description into the attempt prompt is the follow-up ([#4786](https://github.com/JSONbored/gittensory/issues/4786)). (#4785) +assumed. The attempt path consumes it: `buildCodingTaskSpec` appends the real stack summary (and any +confidently-inferred build/test/lint/format commands) to the coding-agent instructions so validation uses +the target repo's own tooling rather than assuming LoopOver/gittensory CI ([#4786](https://github.com/JSONbored/gittensory/issues/4786)). (#4785) The package also includes an append-only governor decision ledger: `initGovernorLedger` / `appendGovernorEvent` persist structured allow/deny/throttle/kill-switch outcomes in local SQLite for contributor audit. Insert-only — diff --git a/packages/gittensory-miner/lib/coding-task-spec.d.ts b/packages/gittensory-miner/lib/coding-task-spec.d.ts index 711851d2c8..3398e8696d 100644 --- a/packages/gittensory-miner/lib/coding-task-spec.d.ts +++ b/packages/gittensory-miner/lib/coding-task-spec.d.ts @@ -1,4 +1,5 @@ import type { AcceptanceCriteria, FeasibilityGateResult, FeasibilityVerdict, IssueRecord, PullRequestRecord } from "@loopover/engine"; +import type { RepoStackResult } from "./stack-detection.js"; export type CodingTaskIssue = { number: number; title: string; body?: string | null | undefined; labels?: string[] | undefined }; @@ -25,6 +26,8 @@ export type CodingTaskSpecInput = { context: CodingTaskContext; claimLedger: CodingTaskClaimLedger; workingDirectory: string; + /** Injectable stack detector (#4786); omitted falls back to stack-detection.js's real `detectRepoStack`. */ + detectRepoStack?: (repoPath: string) => RepoStackResult; }; export type CodingTaskSpecResult = diff --git a/packages/gittensory-miner/lib/coding-task-spec.js b/packages/gittensory-miner/lib/coding-task-spec.js index 8fd2864d53..e5d2697f88 100644 --- a/packages/gittensory-miner/lib/coding-task-spec.js +++ b/packages/gittensory-miner/lib/coding-task-spec.js @@ -10,6 +10,7 @@ import { serializeAcceptanceCriteria, shouldWriteAcceptanceCriteria, } from "@loopover/engine"; +import { detectRepoStack, renderStackSummary } from "./stack-detection.js"; // 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 @@ -24,6 +25,12 @@ import { // @loopover/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. +// +// Target-repo stack detection (#4786 / #4785 follow-up): `detectRepoStack` already returned a structured +// language/package-manager/command description, but nothing in the attempt path consumed it -- instructions +// were issue text + an acceptance-criteria path only. This module now appends that real stack summary (and +// any confidently-inferred validation commands) to the coding-agent prompt so the agent validates against +// THIS repository's tooling rather than assuming LoopOver/gittensory CI, Codecov, or `npm run test:ci`. function buildTaskBrief(issue) { const body = (issue.body ?? "").trim(); @@ -131,32 +138,78 @@ export function writeAcceptanceCriteriaFile(workingDirectory, acceptanceCriteria return { written: true, path }; } +/** + * Prompt guidance derived from a real `detectRepoStack` result (#4786). Lists only commands the detector + * confidently inferred -- a `null` command stays omitted rather than guessed -- and always tells the agent + * not to assume LoopOver/gittensory's own CI/coverage conventions. + * + * @param {import("./stack-detection.js").RepoStackResult} stack + * @returns {string} + */ +function buildValidationGuidance(stack) { + const lines = [ + `Detected target-repo stack: ${renderStackSummary(stack)}`, + "", + "Validate your change with THIS repository's own build/test/lint tooling from the stack summary above.", + "Do not assume LoopOver/gittensory CI conventions, Codecov patch coverage, or `npm run test:ci` unless those commands appear in the detected stack.", + ]; + if (stack?.detected === true) { + const commands = [ + stack.testCommand ? `- test: \`${stack.testCommand}\`` : null, + stack.lintCommand ? `- lint: \`${stack.lintCommand}\`` : null, + stack.buildCommand ? `- build: \`${stack.buildCommand}\`` : null, + stack.formatCommand ? `- format: \`${stack.formatCommand}\`` : null, + ].filter((entry) => entry !== null); + if (commands.length > 0) { + lines.push("", "Run these commands before finishing:", ...commands); + } else { + lines.push( + "", + "No build/test/lint/format commands were confidently inferred — discover and use this repo's own tooling rather than guessing.", + ); + } + } + return lines.join("\n"); +} + /** * 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). + * rather than repeating its content). Also carries the target repo's detected stack + validation commands + * (#4786) so the agent does not default to gittensory-specific CI assumptions. + * + * @param {{ number: number, title: string, body?: string | null }} issue + * @param {string} acceptanceCriteriaPath + * @param {import("./stack-detection.js").RepoStackResult} stack */ -function buildInstructions(issue, acceptanceCriteriaPath) { +function buildInstructions(issue, acceptanceCriteriaPath, stack) { 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.`, + "", + buildValidationGuidance(stack), ].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. + * Full composition: feasibility -> acceptance criteria -> (if authorized) write the file -> detect the + * target-repo stack (#4786) -> 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. + * + * `detectRepoStack` is injectable so tests can assert both the detected and fail-closed undiscovered stack + * branches without depending on real filesystem probes; omitted falls back to stack-detection.js's real + * `detectRepoStack` (the production default). * * @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, + * detectRepoStack?: (repoPath: string) => import("./stack-detection.js").RepoStackResult, * }} input * @returns {import("./coding-task-spec.js").CodingTaskSpecResult} */ @@ -169,12 +222,18 @@ export function buildCodingTaskSpec(input) { return { ready: false, verdict: feasibility.verdict, feasibility }; } + // Real target-repo stack (#4786): detected from the prepared worktree's own manifests, not guessed from + // gittensory conventions. Fail-closed `{ detected: false }` results still reach the prompt (via + // renderStackSummary) so the agent is told detection failed rather than silently defaulting to npm/Codecov. + const detect = input.detectRepoStack ?? detectRepoStack; + const stack = detect(input.workingDirectory); + return { ready: true, verdict: feasibility.verdict, feasibility, acceptanceCriteriaPath: writeResult.path, - instructions: buildInstructions(input.issue, writeResult.path), + instructions: buildInstructions(input.issue, writeResult.path, stack), title: input.issue.title, body: input.issue.body ?? undefined, labels: input.issue.labels, diff --git a/test/unit/miner-coding-task-spec.test.ts b/test/unit/miner-coding-task-spec.test.ts index 6c0db2025c..3f86961152 100644 --- a/test/unit/miner-coding-task-spec.test.ts +++ b/test/unit/miner-coding-task-spec.test.ts @@ -258,4 +258,152 @@ describe("buildCodingTaskSpec (#5132)", () => { if (!result.ready) throw new Error("expected ready"); expect(result.body).toBeUndefined(); }); + + it("REGRESSION (#4786): embeds a detected Node stack's real test/build/lint commands in the coding-agent instructions", () => { + const dir = tempDir(); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "widgets", + scripts: { test: "vitest run", build: "tsc -b", lint: "eslint .", format: "prettier --write ." }, + }), + ); + 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.instructions).toContain("Detected target-repo stack:"); + expect(result.instructions).toMatch(/javascript|typescript/i); + expect(result.instructions).toContain("npm"); + expect(result.instructions).toContain("Do not assume LoopOver/gittensory CI conventions"); + expect(result.instructions).toContain("- test: `"); + expect(result.instructions).toContain("- build: `"); + expect(result.instructions).toContain("- lint: `"); + expect(result.instructions).toContain("- format: `"); + expect(result.instructions).not.toContain("No build/test/lint/format commands were confidently inferred"); + }); + + it("REGRESSION (#4786): a fail-closed undetected stack still reaches the prompt (no silent gittensory default)", () => { + 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.instructions).toContain("Detected target-repo stack: stack not detected:"); + expect(result.instructions).toContain("Do not assume LoopOver/gittensory CI conventions"); + expect(result.instructions).not.toContain("Run these commands before finishing:"); + }); + + it("REGRESSION (#4786): a detected stack with no confidently-inferred commands tells the agent not to guess", () => { + const dir = tempDir(); + const target = issue(); + const result = buildCodingTaskSpec({ + repoFullName: "acme/widgets", + issue: target, + context: { issues: [target], pullRequests: [] }, + claimLedger: claimLedger(), + workingDirectory: dir, + detectRepoStack: () => ({ + detected: true, + language: "python", + packageManager: "pip", + buildCommand: null, + testCommand: null, + lintCommand: null, + formatCommand: null, + evidence: { manifest: "requirements.txt", lockfile: null }, + }), + }); + + expect(result.ready).toBe(true); + if (!result.ready) throw new Error("expected ready"); + expect(result.instructions).toContain("python via pip"); + expect(result.instructions).toContain("no validation commands detected"); + expect(result.instructions).toContain("No build/test/lint/format commands were confidently inferred"); + expect(result.instructions).not.toContain("Run these commands before finishing:"); + }); + + it("REGRESSION (#4786): when input.detectRepoStack is omitted, uses the REAL stack-detection.js default against the worktree", () => { + const dir = tempDir(); + writeFileSync(join(dir, "Cargo.toml"), '[package]\nname = "widgets"\nversion = "0.1.0"\n'); + 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.instructions).toContain("rust via cargo"); + expect(result.instructions).toContain("- test: `cargo test`"); + expect(result.instructions).toContain("- build: `cargo build`"); + }); + + it("REGRESSION (#4786): includes only the non-null commands from a partial injected stack (both sides of each command ternary)", () => { + const target = issue(); + const withBuildAndTest = buildCodingTaskSpec({ + repoFullName: "acme/widgets", + issue: target, + context: { issues: [target], pullRequests: [] }, + claimLedger: claimLedger(), + workingDirectory: tempDir(), + detectRepoStack: () => ({ + detected: true, + language: "go", + packageManager: "go", + buildCommand: "go build ./...", + testCommand: "go test ./...", + lintCommand: null, + formatCommand: null, + evidence: { manifest: "go.mod", lockfile: null }, + }), + }); + expect(withBuildAndTest.ready).toBe(true); + if (!withBuildAndTest.ready) throw new Error("expected ready"); + expect(withBuildAndTest.instructions).toContain("- test: `go test ./...`"); + expect(withBuildAndTest.instructions).toContain("- build: `go build ./...`"); + expect(withBuildAndTest.instructions).not.toContain("- lint:"); + expect(withBuildAndTest.instructions).not.toContain("- format:"); + + const withLintAndFormat = buildCodingTaskSpec({ + repoFullName: "acme/widgets", + issue: target, + context: { issues: [target], pullRequests: [] }, + claimLedger: claimLedger(), + workingDirectory: tempDir(), + detectRepoStack: () => ({ + detected: true, + language: "javascript", + packageManager: "npm", + buildCommand: null, + testCommand: null, + lintCommand: "npm run lint", + formatCommand: "npm run format", + evidence: { manifest: "package.json", lockfile: null }, + }), + }); + expect(withLintAndFormat.ready).toBe(true); + if (!withLintAndFormat.ready) throw new Error("expected ready"); + expect(withLintAndFormat.instructions).toContain("- lint: `npm run lint`"); + expect(withLintAndFormat.instructions).toContain("- format: `npm run format`"); + expect(withLintAndFormat.instructions).not.toContain("- test:"); + expect(withLintAndFormat.instructions).not.toContain("- build:"); + }); });