diff --git a/packages/gittensory-miner/lib/miner-goal-spec.d.ts b/packages/gittensory-miner/lib/miner-goal-spec.d.ts new file mode 100644 index 000000000..bf385824f --- /dev/null +++ b/packages/gittensory-miner/lib/miner-goal-spec.d.ts @@ -0,0 +1,9 @@ +import type { ParsedMinerGoalSpec } from "@jsonbored/gittensory-engine"; + +export function resolveMinerGoalSpec( + repoPath: string, + options?: { + existsSync?: (path: string) => boolean; + readFileSync?: (path: string, encoding: "utf8") => string; + }, +): ParsedMinerGoalSpec; diff --git a/packages/gittensory-miner/lib/miner-goal-spec.js b/packages/gittensory-miner/lib/miner-goal-spec.js new file mode 100644 index 000000000..411809524 --- /dev/null +++ b/packages/gittensory-miner/lib/miner-goal-spec.js @@ -0,0 +1,38 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { discoverMinerGoalSpecPath, parseMinerGoalSpecContent } from "@jsonbored/gittensory-engine"; + +// Real local .gittensory-miner.yml resolver (#5132, Wave 3.5 follow-up). MinerGoalSpec's own discovery +// helper (discoverMinerGoalSpecPath, packages/gittensory-engine) is deliberately IO-free -- the caller +// injects the existence check. Unlike self-review-context.js/rejection-signal.js/ams-policy.js, which fetch +// their target repo's files live over raw.githubusercontent.com BEFORE any clone exists, this resolver reads +// the ALREADY-CLONED repo on disk (attempt-worktree.js's prepareAttemptWorktree runs first in the real +// attempt-cli.js flow) -- no extra network round trip needed for a file that's already sitting in the +// worktree. + +/** + * Resolve the real, parsed MinerGoalSpec for an already-cloned repo at `repoPath`, trying each + * MINER_GOAL_SPEC_FILENAMES candidate in the documented discovery order. Never throws: a missing file, an + * unreadable file, or malformed content all degrade to the tolerant parser's own absent/safe-default result. + * + * `options.existsSync`/`options.readFileSync`, when injected, always receive the FULL joined path (same + * convention as `node:fs`'s own functions), not a repoPath-relative candidate. + * + * @param {string} repoPath + * @param {{ existsSync?: (path: string) => boolean, readFileSync?: (path: string, encoding: "utf8") => string }} [options] + * @returns {import("@jsonbored/gittensory-engine").ParsedMinerGoalSpec} + */ +export function resolveMinerGoalSpec(repoPath, options = {}) { + const existsImpl = options.existsSync ?? existsSync; + const readImpl = options.readFileSync ?? readFileSync; + + const relativePath = discoverMinerGoalSpecPath((candidate) => existsImpl(join(repoPath, candidate))); + if (!relativePath) return parseMinerGoalSpecContent(null); + + try { + const content = readImpl(join(repoPath, relativePath), "utf8"); + return parseMinerGoalSpecContent(content); + } catch { + return parseMinerGoalSpecContent(null); + } +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index f95cbd0c1..d47b4cfec 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -33,7 +33,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.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-signal.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/repo-clone.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 bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.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/miner-goal-spec.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-signal.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/repo-clone.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-goal-spec-resolver.test.ts b/test/unit/miner-goal-spec-resolver.test.ts new file mode 100644 index 000000000..d00059658 --- /dev/null +++ b/test/unit/miner-goal-spec-resolver.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } 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 { resolveMinerGoalSpec } from "../../packages/gittensory-miner/lib/miner-goal-spec.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRepo() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-goal-spec-")); + roots.push(root); + return root; +} + +describe("resolveMinerGoalSpec (#5132)", () => { + it("returns an absent safe-default spec when no candidate file exists", () => { + const repoPath = tempRepo(); + const parsed = resolveMinerGoalSpec(repoPath); + expect(parsed.present).toBe(false); + expect(parsed.spec.killSwitch).toEqual({ paused: false }); + }); + + it("REGRESSION: reads a real .gittensory-miner.yml from the cloned repo's root", () => { + const repoPath = tempRepo(); + writeFileSync(join(repoPath, ".gittensory-miner.yml"), "killSwitch:\n paused: true\n"); + const parsed = resolveMinerGoalSpec(repoPath); + expect(parsed.present).toBe(true); + expect(parsed.spec.killSwitch).toEqual({ paused: true }); + }); + + it("tries .github/gittensory-miner.yml when the root .yml is absent", () => { + const repoPath = tempRepo(); + mkdirSync(join(repoPath, ".github"), { recursive: true }); + writeFileSync(join(repoPath, ".github", "gittensory-miner.yml"), "killSwitch:\n paused: true\n"); + const parsed = resolveMinerGoalSpec(repoPath); + expect(parsed.present).toBe(true); + expect(parsed.spec.killSwitch.paused).toBe(true); + }); + + it("tries the .json variants after both .yml candidates are absent", () => { + const repoPath = tempRepo(); + writeFileSync(join(repoPath, ".gittensory-miner.json"), JSON.stringify({ killSwitch: { paused: true } })); + const parsed = resolveMinerGoalSpec(repoPath); + expect(parsed.present).toBe(true); + expect(parsed.spec.killSwitch.paused).toBe(true); + }); + + it("degrades to safe defaults on malformed content instead of throwing", () => { + const repoPath = tempRepo(); + writeFileSync(join(repoPath, ".gittensory-miner.yml"), "killSwitch: [unterminated"); + const parsed = resolveMinerGoalSpec(repoPath); + expect(parsed.present).toBe(false); + expect(parsed.spec.killSwitch).toEqual({ paused: false }); + expect(parsed.warnings.join(" ")).toMatch(/not valid YAML/i); + }); + + it("degrades to safe defaults when the discovered file can't actually be read", () => { + const repoPath = tempRepo(); + const parsed = resolveMinerGoalSpec(repoPath, { + existsSync: (path) => path.endsWith(".gittensory-miner.yml") && !path.includes(".github"), + readFileSync: () => { + throw new Error("EACCES: permission denied"); + }, + }); + expect(parsed.present).toBe(false); + expect(parsed.spec.killSwitch).toEqual({ paused: false }); + }); +});