Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .gittensory-ams.yml.example
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions packages/gittensory-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
226 changes: 226 additions & 0 deletions packages/gittensory-engine/src/ams-policy-spec.ts
Original file line number Diff line number Diff line change
@@ -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<AmsPolicySpec> = 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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;
11 changes: 11 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading