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"); + }); +});