Skip to content

Commit f92b298

Browse files
authored
feat(miner): add .gittensory-ams.yml operator execution-policy config (#5249)
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).
1 parent e3523a1 commit f92b298

9 files changed

Lines changed: 707 additions & 1 deletion

File tree

.gittensory-ams.yml.example

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# ============================================================================
2+
# .gittensory-ams.yml - operator execution policy (EXAMPLE)
3+
# ============================================================================
4+
# This is the OPERATOR's own execution-risk policy for their autonomous miner
5+
# (AMS) -- a deliberate structural sibling to `.gittensory-miner.yml`
6+
# (MinerGoalSpec), but answering a different question. MinerGoalSpec is what a
7+
# TARGET REPO wants from being mined (paths, labels, whether it's minable at
8+
# all). This file is how aggressive the OPERATOR wants their own agent to be
9+
# (submission mode, slop threshold, per-attempt budget/turn/time ceilings). No
10+
# field here ever lets a target repo loosen what an operator's agent is
11+
# willing to do -- see below for why.
12+
#
13+
# TWO SCOPES, mirroring `.gittensory.yml`'s own established self-host
14+
# precedent: a target repo MAY drop this file in as a proposed DEFAULT policy
15+
# for operators who haven't set their own (e.g. "please use a strict slop
16+
# threshold mining me"). But the OPERATOR's own local copy of this file (in
17+
# their gittensory-miner config dir, not this repo) -- when present -- FULLY
18+
# REPLACES whatever this repo's file says. Never a field-by-field merge. An
19+
# operator's explicit choice always wins; a repo's file can only ever propose
20+
# a fallback default for an unconfigured operator, never override an
21+
# operator's own decision.
22+
#
23+
# Every field is OPTIONAL and has a safe, deny-by-default default (shown as
24+
# "Default: X" below). The file is parsed tolerantly: an unknown key is
25+
# ignored, and a single malformed field falls back to its default with a
26+
# warning.
27+
#
28+
# Discovery order (first match wins):
29+
# .gittensory-ams.yml -> .github/gittensory-ams.yml
30+
# -> .gittensory-ams.json -> .github/gittensory-ams.json
31+
#
32+
# Copy to `.gittensory-ams.yml` and edit. YAML or JSON are both accepted.
33+
34+
# Whether a real attempt is allowed to actually submit (open a PR), or only
35+
# compute + log its decision. "observe" still runs every real signal/decision
36+
# for real, it just never lets the result become a real write -- the safe
37+
# default for calibrating against live traffic before trusting it.
38+
# Values: observe | enforce. Default: observe.
39+
submissionMode: observe
40+
41+
# The strictest self-review slop band still allowed to reach submission.
42+
# Lower is stricter: "clean" only lets the cleanest band through.
43+
# Values: clean | low | elevated | high. Default: low.
44+
slopThreshold: low
45+
46+
# Governor cap ceilings for one attempt (budget-cap.ts). `budget` may be
47+
# fractional (e.g. a dollar cost); `turns` is a whole-count ceiling;
48+
# `elapsedMs` is a termination ceiling in milliseconds.
49+
# Default: { budget: 5, turns: 20, elapsedMs: 1800000 } (30 minutes).
50+
capLimits:
51+
budget: 5
52+
turns: 20
53+
elapsedMs: 1800000
54+
55+
# Non-convergence detector thresholds (non-convergence.ts): streak lengths at
56+
# which a still-unfinished portfolio-queue item reads non-convergent.
57+
# Default: { maxConsecutiveFailures: 3, maxReenqueues: 3 }.
58+
convergenceThresholds:
59+
maxConsecutiveFailures: 3
60+
maxReenqueues: 3

packages/gittensory-engine/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,25 @@ safe defaults and `warnings` explains any dropped or invalid fields.
643643
the existence check — so a caller reads the returned path and feeds its content to `parseMinerGoalSpecContent`. See
644644
`.gittensory-miner.yml.example` for the documented fields.
645645

646+
## AmsPolicySpec
647+
648+
`AmsPolicySpec` is the type surface for `.gittensory-ams.yml` — the OPERATOR's own execution-risk policy for
649+
their miner (`submissionMode`, `slopThreshold`, `capLimits`, `convergenceThresholds`), a deliberate structural
650+
sibling to `MinerGoalSpec` but answering a different question: `MinerGoalSpec` is what the target repo wants
651+
from being mined; `AmsPolicySpec` is how aggressive the operator wants their own agent to be. No field on this
652+
type lets a target repo's own file loosen what an operator's agent is willing to do — see the type's own header
653+
comment for why that boundary is load-bearing. `DEFAULT_AMS_POLICY_SPEC` is deny-by-default: `"observe"`
654+
submission mode (computes real decisions but never actually submits) and a `"low"` (strict) slop threshold.
655+
656+
`parseAmsPolicySpec(raw)` / `parseAmsPolicySpecContent(content)` are the same tolerant-parser pair shape as
657+
`MinerGoalSpec`'s — never throw, return `{ present, spec, warnings }`.
658+
659+
Unlike `MinerGoalSpec`, this package does not resolve `.gittensory-ams.yml`'s two-scope precedence itself (this
660+
package is IO-free) — `packages/gittensory-miner/lib/ams-policy.js`'s `resolveAmsPolicy` is the real caller,
661+
mirroring `.gittensory.yml`'s own established self-host precedent: the operator's own local file, when present,
662+
FULLY REPLACES the repo's proposed file (never a field-by-field merge) — the repo's file is only ever a fallback
663+
default for an operator who hasn't set their own local policy. See `.gittensory-ams.yml.example`.
664+
646665
## Repo map builder
647666

648667
`buildRepoMap(files)` gives a coding-agent driver (or the acceptance-criteria/prompt-packet builders upstream of
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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;

packages/gittensory-engine/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,17 @@ export {
451451
type MinerKillSwitchPolicy,
452452
type ParsedMinerGoalSpec,
453453
} from "./miner-goal-spec.js";
454+
export {
455+
DEFAULT_AMS_POLICY_SPEC,
456+
parseAmsPolicySpec,
457+
parseAmsPolicySpecContent,
458+
AMS_POLICY_SPEC_FILENAMES,
459+
type AmsCapLimits,
460+
type AmsPolicySpec,
461+
type AmsSlopThreshold,
462+
type AmsSubmissionMode,
463+
type ParsedAmsPolicySpec,
464+
} from "./ams-policy-spec.js";
454465
export {
455466
DEFAULT_FLEET_RUN_MANIFEST,
456467
parseFleetRunManifest,

0 commit comments

Comments
 (0)