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
65 changes: 65 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8025,6 +8025,71 @@
"advisory",
"block"
]
},
"autonomy": {
"type": "object",
"properties": {
"review": {
"type": "string",
"enum": [
"observe",
"suggest",
"propose",
"auto_with_approval",
"auto"
]
},
"request_changes": {
"type": "string",
"enum": [
"observe",
"suggest",
"propose",
"auto_with_approval",
"auto"
]
},
"approve": {
"type": "string",
"enum": [
"observe",
"suggest",
"propose",
"auto_with_approval",
"auto"
]
},
"merge": {
"type": "string",
"enum": [
"observe",
"suggest",
"propose",
"auto_with_approval",
"auto"
]
},
"close": {
"type": "string",
"enum": [
"observe",
"suggest",
"propose",
"auto_with_approval",
"auto"
]
},
"label": {
"type": "string",
"enum": [
"observe",
"suggest",
"propose",
"auto_with_approval",
"auto"
]
}
}
}
},
"required": [
Expand Down
5 changes: 5 additions & 0 deletions migrations/0042_agent_autonomy.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Agent-layer autonomy dial (#773, Wave 2 Phase 0). Per-action-class autonomy level stored as a JSON map
-- (action class -> observe|suggest|propose|auto_with_approval|auto). Default '{}' = deny-by-default: every
-- action class resolves to `observe` (gittensory watches but never acts) until a maintainer opts in. The
-- single source the action layer (#778) reads via resolveAutonomy. Additive; existing repos are unaffected.
ALTER TABLE repository_settings ADD COLUMN autonomy_json TEXT NOT NULL DEFAULT '{}';
11 changes: 11 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import type {
AgentActionRecord,
AgentActionStatus,
AgentActionType,
AutonomyPolicy,
AgentCommandAnswerRecord,
AgentCommandFeedbackRecord,
AgentContextSnapshotRecord,
Expand Down Expand Up @@ -150,6 +151,7 @@ import type {
import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api";
import { classifyMcpClientVersion, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "../services/mcp-compatibility";
import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization";
import { normalizeAutonomyPolicy } from "../settings/autonomy";
import { decryptSecret, encryptSecret, sha256Hex } from "../utils/crypto";
import { jsonString, nowIso, parseJson, repoParts } from "../utils/json";

Expand Down Expand Up @@ -422,6 +424,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
privateTrustEnabled: true,
badgeEnabled: false,
commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy,
autonomy: {},
};
}
return {
Expand Down Expand Up @@ -457,6 +460,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
privateTrustEnabled: row.privateTrustEnabled,
badgeEnabled: row.badgeEnabled,
commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson),
autonomy: parseAutonomyPolicy(row.autonomyJson),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down Expand Up @@ -496,6 +500,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
privateTrustEnabled: settings.privateTrustEnabled ?? true,
badgeEnabled: settings.badgeEnabled ?? false,
commandAuthorization: normalizeCommandAuthorizationPolicy(settings.commandAuthorization).policy,
autonomy: normalizeAutonomyPolicy(settings.autonomy),
};
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -533,6 +538,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
privateTrustEnabled: resolved.privateTrustEnabled,
badgeEnabled: resolved.badgeEnabled,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
autonomyJson: jsonString(resolved.autonomy),
updatedAt: nowIso(),
})
.onConflictDoUpdate({
Expand Down Expand Up @@ -571,6 +577,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
privateTrustEnabled: resolved.privateTrustEnabled,
badgeEnabled: resolved.badgeEnabled,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
autonomyJson: jsonString(resolved.autonomy),
updatedAt: nowIso(),
},
});
Expand Down Expand Up @@ -4961,6 +4968,10 @@ function parseCommandAuthorizationPolicy(value: string): RepositorySettings["com
return normalizeCommandAuthorizationPolicy(parseJson<unknown>(value, null)).policy;
}

function parseAutonomyPolicy(value: string): AutonomyPolicy {
return normalizeAutonomyPolicy(parseJson<unknown>(value, null));
}

function parseSyncStatus(value: string): RepoSyncStateRecord["status"] {
if (
value === "running" ||
Expand Down
1 change: 1 addition & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const repositorySettings = sqliteTable("repository_settings", {
privateTrustEnabled: integer("private_trust_enabled", { mode: "boolean" }).notNull().default(true),
badgeEnabled: integer("badge_enabled", { mode: "boolean" }).notNull().default(false),
commandAuthorizationJson: text("command_authorization_json").notNull().default("{}"),
autonomyJson: text("autonomy_json").notNull().default("{}"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});
Expand Down
3 changes: 3 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,9 @@ export const RepositorySettingsSchema = z
default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])),
commands: z.record(z.string(), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"]))),
}),
autonomy: z
.record(z.enum(["review", "request_changes", "approve", "merge", "close", "label"]), z.enum(["observe", "suggest", "propose", "auto_with_approval", "auto"]))
.optional(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
})
Expand Down
50 changes: 50 additions & 0 deletions src/settings/autonomy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { AgentActionClass, AutonomyLevel, AutonomyPolicy } from "../types";

// The graduated autonomy dial (#773), ordered least → most autonomous. Every later agent-layer phase reads
// this BEFORE acting. `observe` is the deny-by-default floor — gittensory watches but never takes an action.
export const AUTONOMY_LEVELS = ["observe", "suggest", "propose", "auto_with_approval", "auto"] as const;

// The write-action classes the maintainer auto-maintain layer (#778) can take on a PR.
export const AGENT_ACTION_CLASSES = ["review", "request_changes", "approve", "merge", "close", "label"] as const;

// Deny-by-default: any action class with no explicit, valid level resolves to this.
export const DEFAULT_AUTONOMY_LEVEL: AutonomyLevel = "observe";

const AUTONOMY_LEVEL_SET = new Set<string>(AUTONOMY_LEVELS);

/**
* Resolve the configured autonomy level for one action class on a repo. THE single gate the action layer
* (#778) consults before any write action. Deny-by-default: an unset (or malformed) action class is
* `observe` — gittensory observes but never acts. Pure.
*/
export function resolveAutonomy(autonomy: AutonomyPolicy | null | undefined, actionClass: AgentActionClass): AutonomyLevel {
return autonomy?.[actionClass] ?? DEFAULT_AUTONOMY_LEVEL;
}

/** True when the level permits the agent to actually execute the action (directly or behind an approval). */
export function isActingAutonomyLevel(level: AutonomyLevel): boolean {
return level === "auto" || level === "auto_with_approval";
}

/** True when the action must pass a human approval gate (#779) before it executes. */
export function autonomyRequiresApproval(level: AutonomyLevel): boolean {
return level === "auto_with_approval";
}

/**
* Parse/validate an arbitrary value into an AutonomyPolicy: keep only known action classes mapped to known
* levels, drop everything else. Deny-by-default by omission. Used for the DB row, the API body, and the
* `.gittensory.yml` settings block. Pure.
*/
export function normalizeAutonomyPolicy(input: unknown): AutonomyPolicy {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const record = input as Record<string, unknown>;
const policy: AutonomyPolicy = {};
for (const actionClass of AGENT_ACTION_CLASSES) {
const value = record[actionClass];
if (typeof value === "string" && AUTONOMY_LEVEL_SET.has(value)) {
policy[actionClass] = value as AutonomyLevel;
}
}
return policy;
}
9 changes: 9 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parse as parseYaml } from "yaml";
import type { GatePolicyPack, GateRuleMode, JsonValue, RepositorySettings } from "../types";
import { normalizeAutonomyPolicy } from "../settings/autonomy";

export type FocusManifestSource = "repo_file" | "api_record" | "none";
export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional";
Expand Down Expand Up @@ -66,6 +67,7 @@ export type FocusManifestSettings = Partial<
| "requireLinkedIssue"
| "backfillEnabled"
| "privateTrustEnabled"
| "autonomy"
>
>;

Expand Down Expand Up @@ -425,6 +427,13 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings);
if (flag !== null) out[key] = flag;
}
// Agent-layer autonomy dial (#773): `settings.autonomy` maps each action class to a level. Only set it
// when at least one valid class→level pair survives normalization, so a malformed block never blanks the
// DB-configured policy via the resolver's `{...dbSettings, ...manifest.settings}` overlay.
if (r.autonomy !== undefined) {
const autonomy = normalizeAutonomyPolicy(r.autonomy);
if (Object.keys(autonomy).length > 0) out.autonomy = autonomy;
}
return out;
}

Expand Down
15 changes: 15 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,10 @@ export type RepositorySettings = {
* (default false); optional so existing settings fixtures/callers need not be touched. */
badgeEnabled?: boolean | undefined;
commandAuthorization?: RepositoryCommandAuthorizationPolicy | undefined;
/** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default
* `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers
* need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */
autonomy?: AutonomyPolicy | undefined;
createdAt?: string | null | undefined;
updatedAt?: string | null | undefined;
};
Expand All @@ -469,6 +473,17 @@ export type RepositoryCommandAuthorizationPolicy = {
commands: Record<string, CommandAuthorizationRole[]>;
};

/** Agent-layer graduated autonomy (#773), least → most autonomous. `observe` is the deny-by-default floor:
* gittensory watches but never acts. `suggest`/`propose` surface guidance/concrete proposals without
* executing; `auto_with_approval` executes behind a human approval gate (#779); `auto` executes directly. */
export type AutonomyLevel = "observe" | "suggest" | "propose" | "auto_with_approval" | "auto";

/** The write-action classes the maintainer auto-maintain layer (#778) can take on a PR. */
export type AgentActionClass = "review" | "request_changes" | "approve" | "merge" | "close" | "label";

/** Per-action-class autonomy. An unset class resolves to `observe` (deny-by-default). */
export type AutonomyPolicy = Partial<Record<AgentActionClass, AutonomyLevel>>;

export type RepoSyncStateRecord = {
repoFullName: string;
status: "never_synced" | "running" | "success" | "partial" | "error" | "skipped" | "capped" | "rate_limited" | "stale";
Expand Down
82 changes: 82 additions & 0 deletions test/unit/autonomy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
AGENT_ACTION_CLASSES,
AUTONOMY_LEVELS,
DEFAULT_AUTONOMY_LEVEL,
autonomyRequiresApproval,
isActingAutonomyLevel,
normalizeAutonomyPolicy,
resolveAutonomy,
} from "../../src/settings/autonomy";
import type { AutonomyPolicy } from "../../src/types";

describe("resolveAutonomy (#773 deny-by-default gate)", () => {
it("returns the configured level for an action class", () => {
const autonomy: AutonomyPolicy = { merge: "auto_with_approval", label: "auto" };
expect(resolveAutonomy(autonomy, "merge")).toBe("auto_with_approval");
expect(resolveAutonomy(autonomy, "label")).toBe("auto");
});

it("denies by default — an unset action class resolves to observe", () => {
expect(resolveAutonomy({ merge: "auto" }, "close")).toBe("observe");
expect(resolveAutonomy({}, "merge")).toBe(DEFAULT_AUTONOMY_LEVEL);
expect(DEFAULT_AUTONOMY_LEVEL).toBe("observe");
});

it("denies by default for a null/undefined policy (no config at all)", () => {
expect(resolveAutonomy(null, "merge")).toBe("observe");
expect(resolveAutonomy(undefined, "review")).toBe("observe");
});

it("every action class resolves to observe under an empty policy", () => {
for (const actionClass of AGENT_ACTION_CLASSES) {
expect(resolveAutonomy({}, actionClass)).toBe("observe");
}
});
});

describe("autonomy level predicates", () => {
it("isActingAutonomyLevel is true only for auto / auto_with_approval", () => {
expect(isActingAutonomyLevel("auto")).toBe(true);
expect(isActingAutonomyLevel("auto_with_approval")).toBe(true);
expect(isActingAutonomyLevel("propose")).toBe(false);
expect(isActingAutonomyLevel("suggest")).toBe(false);
expect(isActingAutonomyLevel("observe")).toBe(false);
});

it("autonomyRequiresApproval is true only for auto_with_approval", () => {
expect(autonomyRequiresApproval("auto_with_approval")).toBe(true);
expect(autonomyRequiresApproval("auto")).toBe(false);
expect(autonomyRequiresApproval("observe")).toBe(false);
});

it("the level ladder is ordered observe → … → auto with observe at the floor", () => {
expect(AUTONOMY_LEVELS[0]).toBe("observe");
expect(AUTONOMY_LEVELS[AUTONOMY_LEVELS.length - 1]).toBe("auto");
expect(AUTONOMY_LEVELS).toEqual(["observe", "suggest", "propose", "auto_with_approval", "auto"]);
});
});

describe("normalizeAutonomyPolicy", () => {
it("keeps only known action classes mapped to known levels", () => {
expect(normalizeAutonomyPolicy({ merge: "auto", review: "suggest" })).toEqual({ merge: "auto", review: "suggest" });
});

it("drops unknown action classes and unknown levels (deny-by-omission)", () => {
expect(
normalizeAutonomyPolicy({ merge: "auto", deploy: "auto", close: "rampage", label: 7 }),
).toEqual({ merge: "auto" });
});

it("returns an empty policy for non-object / array / null input", () => {
expect(normalizeAutonomyPolicy(null)).toEqual({});
expect(normalizeAutonomyPolicy("auto")).toEqual({});
expect(normalizeAutonomyPolicy(["merge"])).toEqual({});
expect(normalizeAutonomyPolicy(undefined)).toEqual({});
});

it("round-trips a valid policy through normalization", () => {
const policy: AutonomyPolicy = { review: "propose", request_changes: "auto_with_approval", merge: "observe" };
expect(normalizeAutonomyPolicy(policy)).toEqual(policy);
});
});
7 changes: 7 additions & 0 deletions test/unit/data-spine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ describe("data spine repositories", () => {
publicSurface: "comment_and_label",
gatePack: "gittensor",
slopGateMode: "off",
autonomy: {}, // #773 deny-by-default: no autonomy configured for a missing repo
});
// gatePack (#692) round-trips and defaults to gittensor.
await upsertRepositorySettings(env, { repoFullName: "owner/repo", gatePack: "oss-anti-slop" });
Expand All @@ -260,6 +261,12 @@ describe("data spine repositories", () => {
const updated = await getRepositorySettings(env, "owner/sloprepo");
expect(updated.slopGateMode).toBe("advisory");
expect(updated.slopGateMinScore).toBe(40);
// #773 agent autonomy round-trips (insert + update), drops invalid entries, and defaults to {}.
await upsertRepositorySettings(env, { repoFullName: "owner/autonomyrepo", autonomy: { merge: "auto_with_approval", label: "auto", deploy: "auto" } as never });
expect((await getRepositorySettings(env, "owner/autonomyrepo")).autonomy).toEqual({ merge: "auto_with_approval", label: "auto" });
await upsertRepositorySettings(env, { repoFullName: "owner/autonomyrepo", autonomy: { merge: "observe" } });
expect((await getRepositorySettings(env, "owner/autonomyrepo")).autonomy).toEqual({ merge: "observe" }); // update persists
expect((await getRepositorySettings(env, "owner/defaultpack")).autonomy).toEqual({}); // deny-by-default
expect(updated.slopAiAdvisory).toBe(false);
expect(await getRepoSyncState(env, "missing/repo")).toBeNull();
expect(await getPullRequest(env, "owner/repo", 404)).toBeNull();
Expand Down
10 changes: 10 additions & 0 deletions test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,16 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () =
expect(settingsOverrideToJson(parseFocusManifest({}).settings)).toBeNull();
});

it("parses + resolves agent autonomy from the settings: block, dropping invalid entries (#773)", () => {
const manifest = parseFocusManifest({ settings: { autonomy: { merge: "auto", close: "auto_with_approval", deploy: "auto", label: "nope" } } });
expect(manifest.settings.autonomy).toEqual({ merge: "auto", close: "auto_with_approval" }); // unknown class + invalid level dropped
const eff = resolveEffectiveSettings({ autonomy: { review: "observe" } } as unknown as RepositorySettings, manifest);
expect(eff.autonomy).toEqual({ merge: "auto", close: "auto_with_approval" }); // yml overlays DB
// A malformed/empty autonomy block never blanks the DB-configured policy.
const noOverride = resolveEffectiveSettings({ autonomy: { merge: "auto" } } as unknown as RepositorySettings, parseFocusManifest({ settings: { autonomy: { bogus: "x" } } }));
expect(noOverride.autonomy).toEqual({ merge: "auto" });
});

it("resolveEffectiveSettings overlays settings: over DB and lets gate: win for gate fields", () => {
const db = { commentMode: "off", gateCheckMode: "off", linkedIssueGateMode: "off", duplicatePrGateMode: "off", autoLabelEnabled: true } as unknown as RepositorySettings;
const eff = resolveEffectiveSettings(
Expand Down
Loading