From b927588255253890995e0a9b0ff5436b4ddd80d7 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:34:45 -0700 Subject: [PATCH] feat(miner): add .gittensory-ams.yml operator execution-policy config Advances #5132 runMinerAttempt needs submissionMode/slopThreshold/capLimits/ convergenceThresholds, none of which had any config or env backing anywhere in the codebase -- pure policy dials that would otherwise get hardcoded with no way to tune them. Adds AmsPolicySpec (engine package, structural sibling to MinerGoalSpec) and resolveAmsPolicy (miner package), mirroring .gittensory.yml's own established self-host precedent: a target repo may propose a .gittensory-ams.yml default, but the operator's own local copy (in their gittensory-miner config dir), when present, fully REPLACES it -- never a field-by-field merge. A repo's own file can only ever propose a fallback default for an unconfigured operator; it can never loosen what an operator's agent is willing to do against that same repo. Deny-by-default: submissionMode defaults to "observe" (computes real decisions, never actually submits), slopThreshold to "low" (strict). --- .gittensory-ams.yml.example | 60 +++++ packages/gittensory-engine/README.md | 19 ++ .../gittensory-engine/src/ams-policy-spec.ts | 226 ++++++++++++++++++ packages/gittensory-engine/src/index.ts | 11 + .../test/ams-policy-spec-parser.test.ts | 120 ++++++++++ packages/gittensory-miner/lib/ams-policy.d.ts | 23 ++ packages/gittensory-miner/lib/ams-policy.js | 107 +++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-ams-policy.test.ts | 140 +++++++++++ 9 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 .gittensory-ams.yml.example create mode 100644 packages/gittensory-engine/src/ams-policy-spec.ts create mode 100644 packages/gittensory-engine/test/ams-policy-spec-parser.test.ts create mode 100644 packages/gittensory-miner/lib/ams-policy.d.ts create mode 100644 packages/gittensory-miner/lib/ams-policy.js create mode 100644 test/unit/miner-ams-policy.test.ts diff --git a/.gittensory-ams.yml.example b/.gittensory-ams.yml.example new file mode 100644 index 0000000000..907d4f0aac --- /dev/null +++ b/.gittensory-ams.yml.example @@ -0,0 +1,60 @@ +# ============================================================================ +# .gittensory-ams.yml - operator execution policy (EXAMPLE) +# ============================================================================ +# This is the OPERATOR's own execution-risk policy for their autonomous miner +# (AMS) -- a deliberate structural sibling to `.gittensory-miner.yml` +# (MinerGoalSpec), but answering a different question. MinerGoalSpec is what a +# TARGET REPO wants from being mined (paths, labels, whether it's minable at +# all). This file is how aggressive the OPERATOR wants their own agent to be +# (submission mode, slop threshold, per-attempt budget/turn/time ceilings). No +# field here ever lets a target repo loosen what an operator's agent is +# willing to do -- see below for why. +# +# TWO SCOPES, mirroring `.gittensory.yml`'s own established self-host +# precedent: a target repo MAY drop this file in as a proposed DEFAULT policy +# for operators who haven't set their own (e.g. "please use a strict slop +# threshold mining me"). But the OPERATOR's own local copy of this file (in +# their gittensory-miner config dir, not this repo) -- when present -- FULLY +# REPLACES whatever this repo's file says. Never a field-by-field merge. An +# operator's explicit choice always wins; a repo's file can only ever propose +# a fallback default for an unconfigured operator, never override an +# operator's own decision. +# +# Every field is OPTIONAL and has a safe, deny-by-default default (shown as +# "Default: X" below). The file is parsed tolerantly: an unknown key is +# ignored, and a single malformed field falls back to its default with a +# warning. +# +# Discovery order (first match wins): +# .gittensory-ams.yml -> .github/gittensory-ams.yml +# -> .gittensory-ams.json -> .github/gittensory-ams.json +# +# Copy to `.gittensory-ams.yml` and edit. YAML or JSON are both accepted. + +# Whether a real attempt is allowed to actually submit (open a PR), or only +# compute + log its decision. "observe" still runs every real signal/decision +# for real, it just never lets the result become a real write -- the safe +# default for calibrating against live traffic before trusting it. +# Values: observe | enforce. Default: observe. +submissionMode: observe + +# The strictest self-review slop band still allowed to reach submission. +# Lower is stricter: "clean" only lets the cleanest band through. +# Values: clean | low | elevated | high. Default: low. +slopThreshold: low + +# Governor cap ceilings for one attempt (budget-cap.ts). `budget` may be +# fractional (e.g. a dollar cost); `turns` is a whole-count ceiling; +# `elapsedMs` is a termination ceiling in milliseconds. +# Default: { budget: 5, turns: 20, elapsedMs: 1800000 } (30 minutes). +capLimits: + budget: 5 + turns: 20 + elapsedMs: 1800000 + +# Non-convergence detector thresholds (non-convergence.ts): streak lengths at +# which a still-unfinished portfolio-queue item reads non-convergent. +# Default: { maxConsecutiveFailures: 3, maxReenqueues: 3 }. +convergenceThresholds: + maxConsecutiveFailures: 3 + maxReenqueues: 3 diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 651d0e1382..7f9fdaa5e7 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -643,6 +643,25 @@ safe defaults and `warnings` explains any dropped or invalid fields. the existence check — so a caller reads the returned path and feeds its content to `parseMinerGoalSpecContent`. See `.gittensory-miner.yml.example` for the documented fields. +## AmsPolicySpec + +`AmsPolicySpec` is the type surface for `.gittensory-ams.yml` — the OPERATOR's own execution-risk policy for +their miner (`submissionMode`, `slopThreshold`, `capLimits`, `convergenceThresholds`), a deliberate structural +sibling to `MinerGoalSpec` but answering a different question: `MinerGoalSpec` is what the target repo wants +from being mined; `AmsPolicySpec` is how aggressive the operator wants their own agent to be. No field on this +type lets a target repo's own file loosen what an operator's agent is willing to do — see the type's own header +comment for why that boundary is load-bearing. `DEFAULT_AMS_POLICY_SPEC` is deny-by-default: `"observe"` +submission mode (computes real decisions but never actually submits) and a `"low"` (strict) slop threshold. + +`parseAmsPolicySpec(raw)` / `parseAmsPolicySpecContent(content)` are the same tolerant-parser pair shape as +`MinerGoalSpec`'s — never throw, return `{ present, spec, warnings }`. + +Unlike `MinerGoalSpec`, this package does not resolve `.gittensory-ams.yml`'s two-scope precedence itself (this +package is IO-free) — `packages/gittensory-miner/lib/ams-policy.js`'s `resolveAmsPolicy` is the real caller, +mirroring `.gittensory.yml`'s own established self-host precedent: the operator's own local file, when present, +FULLY REPLACES the repo's proposed file (never a field-by-field merge) — the repo's file is only ever a fallback +default for an operator who hasn't set their own local policy. See `.gittensory-ams.yml.example`. + ## Repo map builder `buildRepoMap(files)` gives a coding-agent driver (or the acceptance-criteria/prompt-packet builders upstream of diff --git a/packages/gittensory-engine/src/ams-policy-spec.ts b/packages/gittensory-engine/src/ams-policy-spec.ts new file mode 100644 index 0000000000..0a32fb0b4d --- /dev/null +++ b/packages/gittensory-engine/src/ams-policy-spec.ts @@ -0,0 +1,226 @@ +import { parse as parseYaml } from "yaml"; + +import { DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS, type PortfolioConvergenceThresholds } from "./portfolio/non-convergence.js"; + +// AmsPolicySpec (#5132, Wave 3.5 follow-up). The type surface for `.gittensory-ams.yml` -- the OPERATOR's own +// execution-risk policy for their miner (AMS: the autonomous mining system this file's fields configure), as +// opposed to `.gittensory-miner.yml` / MinerGoalSpec (this file's direct structural sibling), which is the +// TARGET REPO's own preferences about being mined at all. That distinction is deliberate and load-bearing: a +// target repo's own checked-in file legitimately gets to say "don't mine me" or "focus on these paths" -- +// but it must NEVER get to say "let the operator's agent spend more budget" or "submit live instead of +// observing", since that would let a malicious or compromised repo talk an operator's own miner into raising +// its own risk tolerance against that exact repo. So this type is intentionally free of any field a target +// repo could use to loosen what an operator's agent is willing to do. +// +// Two-scope resolution, mirroring `.gittensory.yml`'s own established self-host precedent (see +// `src/selfhost/private-config.ts`'s `makeLocalManifestReader`, whose own doc comment is explicit: a +// self-host operator's local file "takes priority over -- and fully REPLACES -- the public .gittensory.yml"): +// 1. A repo-scoped `.gittensory-ams.yml` MAY exist in the target repo, proposing a DEFAULT execution policy +// for operators who haven't set their own (e.g. "please use a strict slop threshold mining me"). +// 2. The operator's own local `.gittensory-ams.yml` (in their `gittensory-miner` config dir), when present, +// FULLY REPLACES the repo's proposed file -- never a field-by-field merge. An operator's explicit choice +// always wins; the repo's file is only ever a fallback default for an unconfigured operator. +// The actual two-scope fetch+resolve lives in packages/gittensory-miner/lib/ams-policy.js (this package is +// IO-free, same discipline as miner-goal-spec.ts) -- this module is the type/parser surface only. + +/** Whether a real attempt is allowed to actually submit (open a PR), or only compute + log its decision. + * Mirrors `src/settings/autonomy.ts`'s deny-by-default dial: "observe" still runs every real signal/decision, + * it just never lets `wouldBeAction` become a real write. */ +export type AmsSubmissionMode = "observe" | "enforce"; + +/** The strictest self-review slop band still allowed to reach submission (`isSlopBandWithinThreshold`, + * submission-gate.ts). Lower = stricter: "clean" only lets the cleanest band through. */ +export type AmsSlopThreshold = "clean" | "low" | "elevated" | "high"; + +/** The three Governor cap ceilings (`GovernorCapLimits`, budget-cap.ts) for one attempt. */ +export type AmsCapLimits = { + /** Maximum cumulative budget/cost units (may be fractional, e.g. a dollar cost) permitted for one attempt. */ + budget: number; + /** Maximum cumulative turns/iterations permitted for one attempt. */ + turns: number; + /** Termination ceiling: maximum elapsed session time in milliseconds for one attempt. */ + elapsedMs: number; +}; + +/** Per-operator AMS execution policy parsed from `.gittensory-ams.yml`. See {@link DEFAULT_AMS_POLICY_SPEC}. */ +export type AmsPolicySpec = { + /** Whether a real attempt may actually submit. Default: "observe" (deny-by-default). */ + submissionMode: AmsSubmissionMode; + /** The strictest self-review slop band still allowed to reach submission. Default: "low" (conservative). */ + slopThreshold: AmsSlopThreshold; + /** Governor cap ceilings for one attempt. Default: { budget: 5, turns: 20, elapsedMs: 1_800_000 } (30 min). */ + capLimits: AmsCapLimits; + /** Non-convergence detector thresholds. Default: {@link DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS}. */ + convergenceThresholds: PortfolioConvergenceThresholds; +}; + +/** The tolerant parser result for `.gittensory-ams.yml`. Mirrors `ParsedMinerGoalSpec`'s present/warnings shape. */ +export type ParsedAmsPolicySpec = { + present: boolean; + spec: AmsPolicySpec; + warnings: string[]; +}; + +/** + * The safe defaults applied when a field is absent from `.gittensory-ams.yml` (or the file itself is + * missing). Deep-frozen: a shared singleton, clone before layering overrides on top. + */ +export const DEFAULT_AMS_POLICY_SPEC: Readonly = Object.freeze({ + submissionMode: "observe", + slopThreshold: "low", + capLimits: Object.freeze({ budget: 5, turns: 20, elapsedMs: 1_800_000 }), + convergenceThresholds: Object.freeze({ ...DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS }), +}); + +const MAX_AMS_POLICY_SPEC_BYTES = 8_192; + +function cloneDefaultAmsPolicySpec(): AmsPolicySpec { + return { + submissionMode: DEFAULT_AMS_POLICY_SPEC.submissionMode, + slopThreshold: DEFAULT_AMS_POLICY_SPEC.slopThreshold, + capLimits: { ...DEFAULT_AMS_POLICY_SPEC.capLimits }, + convergenceThresholds: { ...DEFAULT_AMS_POLICY_SPEC.convergenceThresholds }, + }; +} + +function emptyAmsPolicySpec(warnings: string[] = []): ParsedAmsPolicySpec { + return { present: false, spec: cloneDefaultAmsPolicySpec(), warnings }; +} + +function normalizeSubmissionMode(value: unknown, fallback: AmsSubmissionMode, warnings: string[]): AmsSubmissionMode { + if (value === undefined || value === null) return fallback; + if (value === "observe" || value === "enforce") return value; + warnings.push(`AmsPolicySpec field "submissionMode" must be one of observe, enforce; falling back to "${fallback}".`); + return fallback; +} + +function normalizeSlopThreshold(value: unknown, fallback: AmsSlopThreshold, warnings: string[]): AmsSlopThreshold { + if (value === undefined || value === null) return fallback; + if (value === "clean" || value === "low" || value === "elevated" || value === "high") return value; + warnings.push(`AmsPolicySpec field "slopThreshold" must be one of clean, low, elevated, high; falling back to "${fallback}".`); + return fallback; +} + +function normalizePositiveNumber(value: unknown, field: string, fallback: number, warnings: string[]): number { + if (value === undefined || value === null) return fallback; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + warnings.push(`AmsPolicySpec field "${field}" must be a non-negative number; falling back to ${fallback}.`); + return fallback; + } + return value; +} + +function normalizeCapLimits(value: unknown, fallback: AmsCapLimits, warnings: string[]): AmsCapLimits { + if (value === undefined || value === null) return fallback; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('AmsPolicySpec field "capLimits" must be a mapping; falling back to defaults.'); + return fallback; + } + const record = value as Record; + return { + budget: normalizePositiveNumber(record.budget, "capLimits.budget", fallback.budget, warnings), + turns: normalizePositiveNumber(record.turns, "capLimits.turns", fallback.turns, warnings), + elapsedMs: normalizePositiveNumber(record.elapsedMs, "capLimits.elapsedMs", fallback.elapsedMs, warnings), + }; +} + +function normalizeConvergenceThresholds( + value: unknown, + fallback: PortfolioConvergenceThresholds, + warnings: string[], +): PortfolioConvergenceThresholds { + if (value === undefined || value === null) return fallback; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('AmsPolicySpec field "convergenceThresholds" must be a mapping; falling back to defaults.'); + return fallback; + } + const record = value as Record; + return { + maxConsecutiveFailures: normalizePositiveNumber( + record.maxConsecutiveFailures, + "convergenceThresholds.maxConsecutiveFailures", + fallback.maxConsecutiveFailures, + warnings, + ), + maxReenqueues: normalizePositiveNumber(record.maxReenqueues, "convergenceThresholds.maxReenqueues", fallback.maxReenqueues, warnings), + }; +} + +function hasConfiguredPolicyFields(spec: AmsPolicySpec): boolean { + return ( + spec.submissionMode !== DEFAULT_AMS_POLICY_SPEC.submissionMode || + spec.slopThreshold !== DEFAULT_AMS_POLICY_SPEC.slopThreshold || + spec.capLimits.budget !== DEFAULT_AMS_POLICY_SPEC.capLimits.budget || + spec.capLimits.turns !== DEFAULT_AMS_POLICY_SPEC.capLimits.turns || + spec.capLimits.elapsedMs !== DEFAULT_AMS_POLICY_SPEC.capLimits.elapsedMs || + spec.convergenceThresholds.maxConsecutiveFailures !== DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxConsecutiveFailures || + spec.convergenceThresholds.maxReenqueues !== DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxReenqueues + ); +} + +function utf8ByteLength(value: string): number { + let bytes = 0; + for (const char of value) { + const codePoint = char.codePointAt(0) as number; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else bytes += 4; + } + return bytes; +} + +/** + * Tolerantly normalize an already-parsed `.gittensory-ams.yml` object into a {@link ParsedAmsPolicySpec}. + * Never throws: malformed shapes degrade to safe defaults and accumulate warnings. + */ +export function parseAmsPolicySpec(raw: unknown): ParsedAmsPolicySpec { + if (raw === undefined || raw === null) return emptyAmsPolicySpec(); + if (typeof raw !== "object" || Array.isArray(raw)) { + return emptyAmsPolicySpec(["AmsPolicySpec must be a mapping of fields; ignoring malformed config and falling back to safe defaults."]); + } + const record = raw as Record; + const warnings: string[] = []; + const spec: AmsPolicySpec = { + submissionMode: normalizeSubmissionMode(record.submissionMode, DEFAULT_AMS_POLICY_SPEC.submissionMode, warnings), + slopThreshold: normalizeSlopThreshold(record.slopThreshold, DEFAULT_AMS_POLICY_SPEC.slopThreshold, warnings), + capLimits: normalizeCapLimits(record.capLimits, DEFAULT_AMS_POLICY_SPEC.capLimits, warnings), + convergenceThresholds: normalizeConvergenceThresholds( + record.convergenceThresholds, + DEFAULT_AMS_POLICY_SPEC.convergenceThresholds, + warnings, + ), + }; + if (!hasConfiguredPolicyFields(spec)) { + warnings.push("AmsPolicySpec contained no recognized non-default policy fields; falling back to safe defaults."); + return { present: false, spec: cloneDefaultAmsPolicySpec(), warnings }; + } + return { present: true, spec, warnings }; +} + +/** + * Parse raw `.gittensory-ams.yml` file content (JSON or YAML). Malformed content degrades to an absent + * policy spec with a warning rather than throwing, mirroring `parseMinerGoalSpecContent`. + */ +export function parseAmsPolicySpecContent(content: string | null | undefined): ParsedAmsPolicySpec { + if (content === undefined || content === null || content.trim() === "") return emptyAmsPolicySpec(); + if (utf8ByteLength(content) > MAX_AMS_POLICY_SPEC_BYTES) { + return emptyAmsPolicySpec([`AmsPolicySpec content exceeded ${MAX_AMS_POLICY_SPEC_BYTES} bytes; ignoring it and falling back to safe defaults.`]); + } + const trimmed = content.trim(); + const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("["); + let parsed: unknown; + try { + parsed = looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed); + } catch { + return emptyAmsPolicySpec([ + looksLikeJson + ? "AmsPolicySpec content was not valid JSON; ignoring it and falling back to safe defaults." + : "AmsPolicySpec content was not valid YAML; ignoring it and falling back to safe defaults.", + ]); + } + return parseAmsPolicySpec(parsed); +} + +/** The documented `.gittensory-ams` file-discovery order (first match wins), mirroring `MINER_GOAL_SPEC_FILENAMES`. */ +export const AMS_POLICY_SPEC_FILENAMES = [".gittensory-ams.yml", ".github/gittensory-ams.yml", ".gittensory-ams.json", ".github/gittensory-ams.json"] as const; diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 80fb1ca442..9189a2669b 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -451,6 +451,17 @@ export { type MinerKillSwitchPolicy, type ParsedMinerGoalSpec, } from "./miner-goal-spec.js"; +export { + DEFAULT_AMS_POLICY_SPEC, + parseAmsPolicySpec, + parseAmsPolicySpecContent, + AMS_POLICY_SPEC_FILENAMES, + type AmsCapLimits, + type AmsPolicySpec, + type AmsSlopThreshold, + type AmsSubmissionMode, + type ParsedAmsPolicySpec, +} from "./ams-policy-spec.js"; export { DEFAULT_FLEET_RUN_MANIFEST, parseFleetRunManifest, diff --git a/packages/gittensory-engine/test/ams-policy-spec-parser.test.ts b/packages/gittensory-engine/test/ams-policy-spec-parser.test.ts new file mode 100644 index 0000000000..63589ff677 --- /dev/null +++ b/packages/gittensory-engine/test/ams-policy-spec-parser.test.ts @@ -0,0 +1,120 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + AMS_POLICY_SPEC_FILENAMES, + DEFAULT_AMS_POLICY_SPEC, + parseAmsPolicySpec, + parseAmsPolicySpecContent, +} from "../dist/index.js"; + +test("barrel: the public entrypoint re-exports the AmsPolicySpec parser API", () => { + assert.equal(typeof parseAmsPolicySpec, "function"); + assert.equal(typeof parseAmsPolicySpecContent, "function"); + assert.deepEqual(AMS_POLICY_SPEC_FILENAMES, [ + ".gittensory-ams.yml", + ".github/gittensory-ams.yml", + ".gittensory-ams.json", + ".github/gittensory-ams.json", + ]); +}); + +test("parseAmsPolicySpec: missing raw input returns an absent safe-default spec with no warnings", () => { + const parsed = parseAmsPolicySpec(undefined); + assert.equal(parsed.present, false); + assert.deepEqual(parsed.spec, DEFAULT_AMS_POLICY_SPEC); + assert.deepEqual(parsed.warnings, []); +}); + +test("parseAmsPolicySpec: a non-mapping raw value degrades to safe defaults with a warning", () => { + const parsed = parseAmsPolicySpec(["not", "a", "mapping"]); + assert.equal(parsed.present, false); + assert.deepEqual(parsed.spec, DEFAULT_AMS_POLICY_SPEC); + assert.match(parsed.warnings.join(" "), /must be a mapping/i); +}); + +test("parseAmsPolicySpec: valid raw config normalizes every field and keeps non-default input present", () => { + const parsed = parseAmsPolicySpec({ + submissionMode: "enforce", + slopThreshold: "clean", + capLimits: { budget: 10, turns: 40, elapsedMs: 3_600_000 }, + convergenceThresholds: { maxConsecutiveFailures: 5, maxReenqueues: 2 }, + }); + + assert.equal(parsed.present, true); + assert.deepEqual(parsed.spec, { + submissionMode: "enforce", + slopThreshold: "clean", + capLimits: { budget: 10, turns: 40, elapsedMs: 3_600_000 }, + convergenceThresholds: { maxConsecutiveFailures: 5, maxReenqueues: 2 }, + }); + assert.deepEqual(parsed.warnings, []); +}); + +test("parseAmsPolicySpec: submissionMode rejects an unrecognized value", () => { + const parsed = parseAmsPolicySpec({ submissionMode: "yolo" }); + assert.equal(parsed.spec.submissionMode, "observe"); + assert.match(parsed.warnings.join(" "), /submissionMode.*observe, enforce/i); +}); + +test("parseAmsPolicySpec: slopThreshold rejects an unrecognized value", () => { + const parsed = parseAmsPolicySpec({ slopThreshold: "spicy" }); + assert.equal(parsed.spec.slopThreshold, "low"); + assert.match(parsed.warnings.join(" "), /slopThreshold.*clean, low, elevated, high/i); +}); + +test("parseAmsPolicySpec: capLimits normalizes independently, rejects negative/non-numeric fields, and rejects a non-mapping value", () => { + const valid = parseAmsPolicySpec({ capLimits: { budget: 1, turns: 2, elapsedMs: 3 } }); + assert.deepEqual(valid.spec.capLimits, { budget: 1, turns: 2, elapsedMs: 3 }); + assert.deepEqual(valid.warnings, []); + + const negative = parseAmsPolicySpec({ capLimits: { budget: -1 } }); + assert.equal(negative.spec.capLimits.budget, DEFAULT_AMS_POLICY_SPEC.capLimits.budget); + assert.match(negative.warnings.join(" "), /capLimits\.budget/i); + + const nonNumeric = parseAmsPolicySpec({ capLimits: { turns: "many" } }); + assert.equal(nonNumeric.spec.capLimits.turns, DEFAULT_AMS_POLICY_SPEC.capLimits.turns); + assert.match(nonNumeric.warnings.join(" "), /capLimits\.turns/i); + + const arrayValue = parseAmsPolicySpec({ capLimits: ["not", "a", "mapping"] }); + assert.deepEqual(arrayValue.spec.capLimits, DEFAULT_AMS_POLICY_SPEC.capLimits); + assert.match(arrayValue.warnings.join(" "), /capLimits.*must be a mapping/i); +}); + +test("parseAmsPolicySpec: convergenceThresholds normalizes independently and rejects a non-mapping value", () => { + const valid = parseAmsPolicySpec({ convergenceThresholds: { maxConsecutiveFailures: 1, maxReenqueues: 1 } }); + assert.deepEqual(valid.spec.convergenceThresholds, { maxConsecutiveFailures: 1, maxReenqueues: 1 }); + + const arrayValue = parseAmsPolicySpec({ convergenceThresholds: ["nope"] }); + assert.deepEqual(arrayValue.spec.convergenceThresholds, DEFAULT_AMS_POLICY_SPEC.convergenceThresholds); + assert.match(arrayValue.warnings.join(" "), /convergenceThresholds.*must be a mapping/i); +}); + +test("parseAmsPolicySpecContent: JSON and YAML both parse, malformed content degrades to safe defaults", () => { + const fromJson = parseAmsPolicySpecContent(JSON.stringify({ submissionMode: "enforce" })); + assert.equal(fromJson.present, true); + assert.equal(fromJson.spec.submissionMode, "enforce"); + + const fromYaml = parseAmsPolicySpecContent("submissionMode: enforce\nslopThreshold: clean\n"); + assert.equal(fromYaml.present, true); + assert.equal(fromYaml.spec.submissionMode, "enforce"); + assert.equal(fromYaml.spec.slopThreshold, "clean"); + + const emptyContent = parseAmsPolicySpecContent(""); + assert.equal(emptyContent.present, false); + assert.deepEqual(emptyContent.warnings, []); + + const nullContent = parseAmsPolicySpecContent(null); + assert.equal(nullContent.present, false); + + const malformedJson = parseAmsPolicySpecContent("{ not valid json"); + assert.equal(malformedJson.present, false); + assert.match(malformedJson.warnings.join(" "), /not valid JSON/i); + + const malformedYaml = parseAmsPolicySpecContent("submissionMode: [unterminated"); + assert.equal(malformedYaml.present, false); + assert.match(malformedYaml.warnings.join(" "), /not valid YAML/i); + + const oversized = parseAmsPolicySpecContent("submissionMode: enforce\n# padding\n" + "x".repeat(9_000)); + assert.equal(oversized.present, false); + assert.match(oversized.warnings.join(" "), /exceeded/i); +}); diff --git a/packages/gittensory-miner/lib/ams-policy.d.ts b/packages/gittensory-miner/lib/ams-policy.d.ts new file mode 100644 index 0000000000..3be9c27d47 --- /dev/null +++ b/packages/gittensory-miner/lib/ams-policy.d.ts @@ -0,0 +1,23 @@ +import type { AmsPolicySpec } from "@jsonbored/gittensory-engine"; +import type { SelfReviewContextFetch } from "./self-review-context.js"; + +export function resolveAmsPolicyConfigPath(env?: Record): string; + +export type AmsPolicySource = "local" | "repo" | "default"; + +export type ResolvedAmsPolicy = { + spec: AmsPolicySpec; + source: AmsPolicySource; + warnings: string[]; +}; + +export function resolveAmsPolicy( + repoFullName: string, + options?: { + rawContentBaseUrl?: string; + fetchImpl?: SelfReviewContextFetch; + readFileSync?: (path: string, encoding: "utf8") => string; + existsSync?: (path: string) => boolean; + env?: Record; + }, +): Promise; diff --git a/packages/gittensory-miner/lib/ams-policy.js b/packages/gittensory-miner/lib/ams-policy.js new file mode 100644 index 0000000000..0340651849 --- /dev/null +++ b/packages/gittensory-miner/lib/ams-policy.js @@ -0,0 +1,107 @@ +import { existsSync, readFileSync } from "node:fs"; +import { AMS_POLICY_SPEC_FILENAMES, DEFAULT_AMS_POLICY_SPEC, parseAmsPolicySpecContent } from "@jsonbored/gittensory-engine"; +import { resolveLocalStoreDbPath } from "./local-store.js"; + +// Real two-scope resolver for `.gittensory-ams.yml` (#5132, Wave 3.5 follow-up). AmsPolicySpec +// (ams-policy-spec.ts, engine package) is the type/parser surface; this module is the actual fetch+resolve +// caller, mirroring `.gittensory.yml`'s own established self-host precedent (src/selfhost/private-config.ts's +// `makeLocalManifestReader`): the operator's own local file, when present, FULLY REPLACES whatever the +// target repo's own file says -- never a field-by-field merge. The repo's file is only ever consulted as a +// fallback default for an operator who hasn't set their own local policy. +// +// This is deliberately NOT the same resolution shape as self-review-context.js/rejection-signal.js, which +// only ever read from the target repo: AmsPolicySpec's fields are the OPERATOR's own execution-risk policy +// (see ams-policy-spec.ts's own header for why a target repo must never get final say over that). + +const AMS_POLICY_FILENAME = ".gittensory-ams.yml"; +const DEFAULT_RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com"; + +/** Resolve the operator's local AMS policy file path: explicit env var > `GITTENSORY_MINER_CONFIG_DIR` > + * `XDG_CONFIG_HOME`/`~/.config`, mirroring every other local-store path in this package. */ +export function resolveAmsPolicyConfigPath(env = process.env) { + return resolveLocalStoreDbPath(AMS_POLICY_FILENAME, "GITTENSORY_MINER_AMS_POLICY_PATH", env); +} + +function normalizeOptions(options = {}) { + return { + rawContentBaseUrl: + typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL, + fetchImpl: options.fetchImpl ?? fetch, + readFileSync: options.readFileSync ?? readFileSync, + existsSync: options.existsSync ?? existsSync, + env: options.env ?? process.env, + }; +} + +function parseRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return { owner, repo }; +} + +/** Read the operator's own local `.gittensory-ams.yml`, if one exists. Never throws: an unreadable file is + * treated the same as an absent one, falling through to the next resolution layer. */ +function readLocalAmsPolicyContent(resolved) { + const path = resolveAmsPolicyConfigPath(resolved.env); + if (!resolved.existsSync(path)) return null; + try { + return resolved.readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** Fetch the target repo's own proposed `.gittensory-ams.yml`, trying each candidate path in order (first + * 200 OK wins), mirroring self-review-context.js's `fetchManifestContent`. */ +async function fetchRepoAmsPolicyContent(target, resolved) { + for (const path of AMS_POLICY_SPEC_FILENAMES) { + const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`; + try { + const response = await resolved.fetchImpl(url, { method: "GET", headers: { accept: "application/json", "user-agent": "gittensory-miner" } }); + if (response.ok) { + const text = await response.text(); + if (typeof text === "string") return text; + } + } catch { + // Try the next candidate path. + } + } + return null; +} + +/** + * Resolve the real, effective AMS execution policy for one attempt against `repoFullName`: the operator's + * own local `.gittensory-ams.yml` when present (source: "local"), else the target repo's own proposed file + * when present (source: "repo"), else the engine's safe defaults (source: "default"). Never throws -- an + * unreadable/malformed file at either layer degrades to the next layer or the safe defaults, same discipline + * as every other tolerant parser in this pipeline. + * + * @param {string} repoFullName + * @param {{ + * rawContentBaseUrl?: string, fetchImpl?: import("./self-review-context.js").SelfReviewContextFetch, + * readFileSync?: (path: string, encoding: "utf8") => string, existsSync?: (path: string) => boolean, + * env?: Record, + * }} [options] + * @returns {Promise<{ spec: import("@jsonbored/gittensory-engine").AmsPolicySpec, source: "local"|"repo"|"default", warnings: string[] }>} + */ +export async function resolveAmsPolicy(repoFullName, options = {}) { + const resolved = normalizeOptions(options); + + const localContent = readLocalAmsPolicyContent(resolved); + if (localContent !== null) { + const parsed = parseAmsPolicySpecContent(localContent); + return { spec: parsed.spec, source: "local", warnings: parsed.warnings }; + } + + const target = parseRepoFullName(repoFullName); + if (target) { + const repoContent = await fetchRepoAmsPolicyContent(target, resolved); + if (repoContent !== null) { + const parsed = parseAmsPolicySpecContent(repoContent); + return { spec: parsed.spec, source: "repo", warnings: parsed.warnings }; + } + } + + return { spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 9750cb55f3..29d876bf82 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/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 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" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-ams-policy.test.ts b/test/unit/miner-ams-policy.test.ts new file mode 100644 index 0000000000..db7b71a8dc --- /dev/null +++ b/test/unit/miner-ams-policy.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, 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 { DEFAULT_AMS_POLICY_SPEC } from "../../packages/gittensory-engine/src/index"; +import { resolveAmsPolicy, resolveAmsPolicyConfigPath } from "../../packages/gittensory-miner/lib/ams-policy.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-ams-policy-")); + roots.push(root); + return root; +} + +function textResponse(text: string | null, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async (): Promise => { + throw new Error("textResponse: json() is unused by ams-policy's fetch path"); + }, + text: async () => text ?? "", + }; +} + +function routedFetch(routes: Record ReturnType>) { + return async (url: string) => { + for (const [substring, respond] of Object.entries(routes)) { + if (url.includes(substring)) return respond(); + } + return textResponse(null, 404); + }; +} + +describe("resolveAmsPolicyConfigPath (#5132)", () => { + it("resolves from explicit env, config dir, and XDG default, in precedence order", () => { + expect(resolveAmsPolicyConfigPath({ GITTENSORY_MINER_AMS_POLICY_PATH: "/custom/policy.yml" })).toBe("/custom/policy.yml"); + expect(resolveAmsPolicyConfigPath({ GITTENSORY_MINER_CONFIG_DIR: "/cfg" })).toBe(join("/cfg", ".gittensory-ams.yml")); + }); +}); + +describe("resolveAmsPolicy (#5132)", () => { + it("returns the engine's safe defaults when neither a local file nor a repo file exists", async () => { + const root = tempRoot(); + const fetchImpl = routedFetch({}); + const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); + expect(result).toEqual({ spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }); + }); + + it("falls through to the repo's own .gittensory-ams.yml when no local file exists", async () => { + const root = tempRoot(); + const fetchImpl = routedFetch({ + ".gittensory-ams.yml": () => textResponse("submissionMode: enforce\nslopThreshold: clean\n"), + }); + const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); + expect(result.source).toBe("repo"); + expect(result.spec.submissionMode).toBe("enforce"); + expect(result.spec.slopThreshold).toBe("clean"); + }); + + it("REGRESSION: the operator's own local file fully REPLACES the repo's file, never merges", async () => { + const root = tempRoot(); + writeFileSync(join(root, ".gittensory-ams.yml"), "submissionMode: observe\n"); + // The repo's own file sets slopThreshold too -- if this leaked through via a merge, slopThreshold would + // read "clean" instead of the local file's own (unset -> default) "low". + const fetchImpl = vi.fn(routedFetch({ + ".gittensory-ams.yml": () => textResponse("submissionMode: enforce\nslopThreshold: clean\n"), + })); + const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); + expect(result.source).toBe("local"); + expect(result.spec.submissionMode).toBe("observe"); + expect(result.spec.slopThreshold).toBe(DEFAULT_AMS_POLICY_SPEC.slopThreshold); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("never calls fetch at all once a local file is found", async () => { + const root = tempRoot(); + writeFileSync(join(root, ".gittensory-ams.yml"), "submissionMode: enforce\n"); + let fetchCalls = 0; + const fetchImpl = async () => { + fetchCalls += 1; + return textResponse(null, 404); + }; + const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); + expect(result.source).toBe("local"); + expect(fetchCalls).toBe(0); + }); + + it("falls through to defaults on a malformed local file (invalid YAML), still never touching the repo file", async () => { + const root = tempRoot(); + writeFileSync(join(root, ".gittensory-ams.yml"), "submissionMode: [unterminated"); + let fetchCalls = 0; + const fetchImpl = async () => { + fetchCalls += 1; + return textResponse(null, 404); + }; + const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); + expect(result.source).toBe("local"); + expect(result.spec).toEqual(DEFAULT_AMS_POLICY_SPEC); + expect(result.warnings.join(" ")).toMatch(/not valid YAML/i); + expect(fetchCalls).toBe(0); + }); + + it("returns defaults for a malformed repoFullName, without ever calling fetch", async () => { + const root = tempRoot(); + const fetchImpl = vi.fn(); + const result = await resolveAmsPolicy("not-a-repo", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); + expect(result).toEqual({ spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns defaults on a repo fetch network error", async () => { + const root = tempRoot(); + const fetchImpl = async () => { + throw new Error("network unreachable"); + }; + const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); + expect(result).toEqual({ spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }); + }); + + it("tries the .github/ and .json candidate paths when the root .yml 404s", async () => { + const root = tempRoot(); + const fetchImpl = routedFetch({ + ".github/gittensory-ams.yml": () => textResponse("submissionMode: enforce\n"), + }); + const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); + expect(result.source).toBe("repo"); + expect(result.spec.submissionMode).toBe("enforce"); + }); +});