|
| 1 | +import { parse as parseYaml } from "yaml"; |
| 2 | + |
| 3 | +import { DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS, type PortfolioConvergenceThresholds } from "./portfolio/non-convergence.js"; |
| 4 | + |
| 5 | +// AmsPolicySpec (#5132, Wave 3.5 follow-up). The type surface for `.gittensory-ams.yml` -- the OPERATOR's own |
| 6 | +// execution-risk policy for their miner (AMS: the autonomous mining system this file's fields configure), as |
| 7 | +// opposed to `.gittensory-miner.yml` / MinerGoalSpec (this file's direct structural sibling), which is the |
| 8 | +// TARGET REPO's own preferences about being mined at all. That distinction is deliberate and load-bearing: a |
| 9 | +// target repo's own checked-in file legitimately gets to say "don't mine me" or "focus on these paths" -- |
| 10 | +// but it must NEVER get to say "let the operator's agent spend more budget" or "submit live instead of |
| 11 | +// observing", since that would let a malicious or compromised repo talk an operator's own miner into raising |
| 12 | +// its own risk tolerance against that exact repo. So this type is intentionally free of any field a target |
| 13 | +// repo could use to loosen what an operator's agent is willing to do. |
| 14 | +// |
| 15 | +// Two-scope resolution, mirroring `.gittensory.yml`'s own established self-host precedent (see |
| 16 | +// `src/selfhost/private-config.ts`'s `makeLocalManifestReader`, whose own doc comment is explicit: a |
| 17 | +// self-host operator's local file "takes priority over -- and fully REPLACES -- the public .gittensory.yml"): |
| 18 | +// 1. A repo-scoped `.gittensory-ams.yml` MAY exist in the target repo, proposing a DEFAULT execution policy |
| 19 | +// for operators who haven't set their own (e.g. "please use a strict slop threshold mining me"). |
| 20 | +// 2. The operator's own local `.gittensory-ams.yml` (in their `gittensory-miner` config dir), when present, |
| 21 | +// FULLY REPLACES the repo's proposed file -- never a field-by-field merge. An operator's explicit choice |
| 22 | +// always wins; the repo's file is only ever a fallback default for an unconfigured operator. |
| 23 | +// The actual two-scope fetch+resolve lives in packages/gittensory-miner/lib/ams-policy.js (this package is |
| 24 | +// IO-free, same discipline as miner-goal-spec.ts) -- this module is the type/parser surface only. |
| 25 | + |
| 26 | +/** Whether a real attempt is allowed to actually submit (open a PR), or only compute + log its decision. |
| 27 | + * Mirrors `src/settings/autonomy.ts`'s deny-by-default dial: "observe" still runs every real signal/decision, |
| 28 | + * it just never lets `wouldBeAction` become a real write. */ |
| 29 | +export type AmsSubmissionMode = "observe" | "enforce"; |
| 30 | + |
| 31 | +/** The strictest self-review slop band still allowed to reach submission (`isSlopBandWithinThreshold`, |
| 32 | + * submission-gate.ts). Lower = stricter: "clean" only lets the cleanest band through. */ |
| 33 | +export type AmsSlopThreshold = "clean" | "low" | "elevated" | "high"; |
| 34 | + |
| 35 | +/** The three Governor cap ceilings (`GovernorCapLimits`, budget-cap.ts) for one attempt. */ |
| 36 | +export type AmsCapLimits = { |
| 37 | + /** Maximum cumulative budget/cost units (may be fractional, e.g. a dollar cost) permitted for one attempt. */ |
| 38 | + budget: number; |
| 39 | + /** Maximum cumulative turns/iterations permitted for one attempt. */ |
| 40 | + turns: number; |
| 41 | + /** Termination ceiling: maximum elapsed session time in milliseconds for one attempt. */ |
| 42 | + elapsedMs: number; |
| 43 | +}; |
| 44 | + |
| 45 | +/** Per-operator AMS execution policy parsed from `.gittensory-ams.yml`. See {@link DEFAULT_AMS_POLICY_SPEC}. */ |
| 46 | +export type AmsPolicySpec = { |
| 47 | + /** Whether a real attempt may actually submit. Default: "observe" (deny-by-default). */ |
| 48 | + submissionMode: AmsSubmissionMode; |
| 49 | + /** The strictest self-review slop band still allowed to reach submission. Default: "low" (conservative). */ |
| 50 | + slopThreshold: AmsSlopThreshold; |
| 51 | + /** Governor cap ceilings for one attempt. Default: { budget: 5, turns: 20, elapsedMs: 1_800_000 } (30 min). */ |
| 52 | + capLimits: AmsCapLimits; |
| 53 | + /** Non-convergence detector thresholds. Default: {@link DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS}. */ |
| 54 | + convergenceThresholds: PortfolioConvergenceThresholds; |
| 55 | +}; |
| 56 | + |
| 57 | +/** The tolerant parser result for `.gittensory-ams.yml`. Mirrors `ParsedMinerGoalSpec`'s present/warnings shape. */ |
| 58 | +export type ParsedAmsPolicySpec = { |
| 59 | + present: boolean; |
| 60 | + spec: AmsPolicySpec; |
| 61 | + warnings: string[]; |
| 62 | +}; |
| 63 | + |
| 64 | +/** |
| 65 | + * The safe defaults applied when a field is absent from `.gittensory-ams.yml` (or the file itself is |
| 66 | + * missing). Deep-frozen: a shared singleton, clone before layering overrides on top. |
| 67 | + */ |
| 68 | +export const DEFAULT_AMS_POLICY_SPEC: Readonly<AmsPolicySpec> = Object.freeze({ |
| 69 | + submissionMode: "observe", |
| 70 | + slopThreshold: "low", |
| 71 | + capLimits: Object.freeze({ budget: 5, turns: 20, elapsedMs: 1_800_000 }), |
| 72 | + convergenceThresholds: Object.freeze({ ...DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS }), |
| 73 | +}); |
| 74 | + |
| 75 | +const MAX_AMS_POLICY_SPEC_BYTES = 8_192; |
| 76 | + |
| 77 | +function cloneDefaultAmsPolicySpec(): AmsPolicySpec { |
| 78 | + return { |
| 79 | + submissionMode: DEFAULT_AMS_POLICY_SPEC.submissionMode, |
| 80 | + slopThreshold: DEFAULT_AMS_POLICY_SPEC.slopThreshold, |
| 81 | + capLimits: { ...DEFAULT_AMS_POLICY_SPEC.capLimits }, |
| 82 | + convergenceThresholds: { ...DEFAULT_AMS_POLICY_SPEC.convergenceThresholds }, |
| 83 | + }; |
| 84 | +} |
| 85 | + |
| 86 | +function emptyAmsPolicySpec(warnings: string[] = []): ParsedAmsPolicySpec { |
| 87 | + return { present: false, spec: cloneDefaultAmsPolicySpec(), warnings }; |
| 88 | +} |
| 89 | + |
| 90 | +function normalizeSubmissionMode(value: unknown, fallback: AmsSubmissionMode, warnings: string[]): AmsSubmissionMode { |
| 91 | + if (value === undefined || value === null) return fallback; |
| 92 | + if (value === "observe" || value === "enforce") return value; |
| 93 | + warnings.push(`AmsPolicySpec field "submissionMode" must be one of observe, enforce; falling back to "${fallback}".`); |
| 94 | + return fallback; |
| 95 | +} |
| 96 | + |
| 97 | +function normalizeSlopThreshold(value: unknown, fallback: AmsSlopThreshold, warnings: string[]): AmsSlopThreshold { |
| 98 | + if (value === undefined || value === null) return fallback; |
| 99 | + if (value === "clean" || value === "low" || value === "elevated" || value === "high") return value; |
| 100 | + warnings.push(`AmsPolicySpec field "slopThreshold" must be one of clean, low, elevated, high; falling back to "${fallback}".`); |
| 101 | + return fallback; |
| 102 | +} |
| 103 | + |
| 104 | +function normalizePositiveNumber(value: unknown, field: string, fallback: number, warnings: string[]): number { |
| 105 | + if (value === undefined || value === null) return fallback; |
| 106 | + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { |
| 107 | + warnings.push(`AmsPolicySpec field "${field}" must be a non-negative number; falling back to ${fallback}.`); |
| 108 | + return fallback; |
| 109 | + } |
| 110 | + return value; |
| 111 | +} |
| 112 | + |
| 113 | +function normalizeCapLimits(value: unknown, fallback: AmsCapLimits, warnings: string[]): AmsCapLimits { |
| 114 | + if (value === undefined || value === null) return fallback; |
| 115 | + if (typeof value !== "object" || Array.isArray(value)) { |
| 116 | + warnings.push('AmsPolicySpec field "capLimits" must be a mapping; falling back to defaults.'); |
| 117 | + return fallback; |
| 118 | + } |
| 119 | + const record = value as Record<string, unknown>; |
| 120 | + return { |
| 121 | + budget: normalizePositiveNumber(record.budget, "capLimits.budget", fallback.budget, warnings), |
| 122 | + turns: normalizePositiveNumber(record.turns, "capLimits.turns", fallback.turns, warnings), |
| 123 | + elapsedMs: normalizePositiveNumber(record.elapsedMs, "capLimits.elapsedMs", fallback.elapsedMs, warnings), |
| 124 | + }; |
| 125 | +} |
| 126 | + |
| 127 | +function normalizeConvergenceThresholds( |
| 128 | + value: unknown, |
| 129 | + fallback: PortfolioConvergenceThresholds, |
| 130 | + warnings: string[], |
| 131 | +): PortfolioConvergenceThresholds { |
| 132 | + if (value === undefined || value === null) return fallback; |
| 133 | + if (typeof value !== "object" || Array.isArray(value)) { |
| 134 | + warnings.push('AmsPolicySpec field "convergenceThresholds" must be a mapping; falling back to defaults.'); |
| 135 | + return fallback; |
| 136 | + } |
| 137 | + const record = value as Record<string, unknown>; |
| 138 | + return { |
| 139 | + maxConsecutiveFailures: normalizePositiveNumber( |
| 140 | + record.maxConsecutiveFailures, |
| 141 | + "convergenceThresholds.maxConsecutiveFailures", |
| 142 | + fallback.maxConsecutiveFailures, |
| 143 | + warnings, |
| 144 | + ), |
| 145 | + maxReenqueues: normalizePositiveNumber(record.maxReenqueues, "convergenceThresholds.maxReenqueues", fallback.maxReenqueues, warnings), |
| 146 | + }; |
| 147 | +} |
| 148 | + |
| 149 | +function hasConfiguredPolicyFields(spec: AmsPolicySpec): boolean { |
| 150 | + return ( |
| 151 | + spec.submissionMode !== DEFAULT_AMS_POLICY_SPEC.submissionMode || |
| 152 | + spec.slopThreshold !== DEFAULT_AMS_POLICY_SPEC.slopThreshold || |
| 153 | + spec.capLimits.budget !== DEFAULT_AMS_POLICY_SPEC.capLimits.budget || |
| 154 | + spec.capLimits.turns !== DEFAULT_AMS_POLICY_SPEC.capLimits.turns || |
| 155 | + spec.capLimits.elapsedMs !== DEFAULT_AMS_POLICY_SPEC.capLimits.elapsedMs || |
| 156 | + spec.convergenceThresholds.maxConsecutiveFailures !== DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxConsecutiveFailures || |
| 157 | + spec.convergenceThresholds.maxReenqueues !== DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxReenqueues |
| 158 | + ); |
| 159 | +} |
| 160 | + |
| 161 | +function utf8ByteLength(value: string): number { |
| 162 | + let bytes = 0; |
| 163 | + for (const char of value) { |
| 164 | + const codePoint = char.codePointAt(0) as number; |
| 165 | + if (codePoint <= 0x7f) bytes += 1; |
| 166 | + else if (codePoint <= 0x7ff) bytes += 2; |
| 167 | + else if (codePoint <= 0xffff) bytes += 3; |
| 168 | + else bytes += 4; |
| 169 | + } |
| 170 | + return bytes; |
| 171 | +} |
| 172 | + |
| 173 | +/** |
| 174 | + * Tolerantly normalize an already-parsed `.gittensory-ams.yml` object into a {@link ParsedAmsPolicySpec}. |
| 175 | + * Never throws: malformed shapes degrade to safe defaults and accumulate warnings. |
| 176 | + */ |
| 177 | +export function parseAmsPolicySpec(raw: unknown): ParsedAmsPolicySpec { |
| 178 | + if (raw === undefined || raw === null) return emptyAmsPolicySpec(); |
| 179 | + if (typeof raw !== "object" || Array.isArray(raw)) { |
| 180 | + return emptyAmsPolicySpec(["AmsPolicySpec must be a mapping of fields; ignoring malformed config and falling back to safe defaults."]); |
| 181 | + } |
| 182 | + const record = raw as Record<string, unknown>; |
| 183 | + const warnings: string[] = []; |
| 184 | + const spec: AmsPolicySpec = { |
| 185 | + submissionMode: normalizeSubmissionMode(record.submissionMode, DEFAULT_AMS_POLICY_SPEC.submissionMode, warnings), |
| 186 | + slopThreshold: normalizeSlopThreshold(record.slopThreshold, DEFAULT_AMS_POLICY_SPEC.slopThreshold, warnings), |
| 187 | + capLimits: normalizeCapLimits(record.capLimits, DEFAULT_AMS_POLICY_SPEC.capLimits, warnings), |
| 188 | + convergenceThresholds: normalizeConvergenceThresholds( |
| 189 | + record.convergenceThresholds, |
| 190 | + DEFAULT_AMS_POLICY_SPEC.convergenceThresholds, |
| 191 | + warnings, |
| 192 | + ), |
| 193 | + }; |
| 194 | + if (!hasConfiguredPolicyFields(spec)) { |
| 195 | + warnings.push("AmsPolicySpec contained no recognized non-default policy fields; falling back to safe defaults."); |
| 196 | + return { present: false, spec: cloneDefaultAmsPolicySpec(), warnings }; |
| 197 | + } |
| 198 | + return { present: true, spec, warnings }; |
| 199 | +} |
| 200 | + |
| 201 | +/** |
| 202 | + * Parse raw `.gittensory-ams.yml` file content (JSON or YAML). Malformed content degrades to an absent |
| 203 | + * policy spec with a warning rather than throwing, mirroring `parseMinerGoalSpecContent`. |
| 204 | + */ |
| 205 | +export function parseAmsPolicySpecContent(content: string | null | undefined): ParsedAmsPolicySpec { |
| 206 | + if (content === undefined || content === null || content.trim() === "") return emptyAmsPolicySpec(); |
| 207 | + if (utf8ByteLength(content) > MAX_AMS_POLICY_SPEC_BYTES) { |
| 208 | + return emptyAmsPolicySpec([`AmsPolicySpec content exceeded ${MAX_AMS_POLICY_SPEC_BYTES} bytes; ignoring it and falling back to safe defaults.`]); |
| 209 | + } |
| 210 | + const trimmed = content.trim(); |
| 211 | + const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("["); |
| 212 | + let parsed: unknown; |
| 213 | + try { |
| 214 | + parsed = looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed); |
| 215 | + } catch { |
| 216 | + return emptyAmsPolicySpec([ |
| 217 | + looksLikeJson |
| 218 | + ? "AmsPolicySpec content was not valid JSON; ignoring it and falling back to safe defaults." |
| 219 | + : "AmsPolicySpec content was not valid YAML; ignoring it and falling back to safe defaults.", |
| 220 | + ]); |
| 221 | + } |
| 222 | + return parseAmsPolicySpec(parsed); |
| 223 | +} |
| 224 | + |
| 225 | +/** The documented `.gittensory-ams` file-discovery order (first match wins), mirroring `MINER_GOAL_SPEC_FILENAMES`. */ |
| 226 | +export const AMS_POLICY_SPEC_FILENAMES = [".gittensory-ams.yml", ".github/gittensory-ams.yml", ".gittensory-ams.json", ".github/gittensory-ams.json"] as const; |
0 commit comments