From c717075895fae7c62ce40537cd6950e34ee593cd Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Sun, 12 Jul 2026 22:41:39 -0700
Subject: [PATCH] refactor(selfhost): dual-read .loopover.yml config filename
alongside .gittensory.yml
Both the self-host per-repo config loader (src/selfhost/private-config.ts)
and the public-repo focus-manifest loader (src/signals/focus-manifest-loader.ts)
now try every .loopover.{yml,yaml,json} candidate, at every location, before
any .gittensory.{yml,yaml,json} candidate -- an existing repo/self-hoster
with only the legacy filename keeps working byte-identical, and a new-brand
file at the same location wins outright over a stale legacy one sitting
next to it. src/review/guardrail-config.ts's config-as-code guardrail globs
gain the same 6 new-brand entries so a .loopover.yml edit gets the identical
contributor-hold protection a .gittensory.yml edit already gets.
Fixes a real latent bug found while extending the candidate list: the flat
{owner}__{repo}. fallback path was built via
`base.slice(".gittensory".length)`, a magic-number slice that would have
silently produced garbage paths (".loopover.yml".slice(11) -> "ml") once a
13-character new-brand basename was prepended. Replaced with a
brand-agnostic CONFIG_EXTENSIONS constant derived positionally.
Also fixes two gaps the diff's own verification caught, neither in the
originally-scoped file list:
- packages/gittensory-engine/src/review/guardrail-config.ts -- a
hand-duplicated, CI-enforced twin of the src/ file above
(scripts/check-engine-parity.ts) that had drifted after only the src/
copy was updated.
- packages/gittensory-engine/src/objective-anchor.ts's CONFIG_FILENAMES set
(feeds anti-slop/objective-anchor change-kind scoring) still only
recognized the legacy filename; added the new-brand entry plus a new
vitest-side test, since this file's own change-kind logic is otherwise
covered almost exclusively by its node:test suite, which Codecov's
vitest-based coverage can't see.
Updates every self-hosting docs page, the config-generator UI's rendered
filename, and README/CONTRIBUTING/example templates to present the new
filename as canonical while noting the legacy one still works.
Deliberately unchanged: docker-compose.yml's ./gittensory-config volume
mount and the gittensory-config/ example directory name (a hard bind path,
not a graceful-fallback read -- renaming it would silently mount a new,
empty directory for anyone still using the stock default, with no error to
surface); the .gittensory.yml/.gittensory.yml.example template files
themselves; scripts/gittensory-config-lint.ts's CLI default (explicit-arg
tool, not a silent runtime path).
Closes #4773.
---
CONTRIBUTING.md | 9 +-
README.md | 6 +-
.../ai-provider-mode-field-group.tsx | 4 +-
.../config-generator-yaml-preview.test.tsx | 4 +-
.../config-generator-yaml-preview.tsx | 12 +-
.../site/app-panels/maintainer-settings.tsx | 8 +-
.../src/lib/config-generator-yaml.test.ts | 2 +-
.../src/lib/config-generator-yaml.ts | 8 +-
.../src/routes/app.config-generator.tsx | 2 +-
.../src/routes/docs.beta-onboarding.tsx | 2 +-
.../src/routes/docs.github-app.tsx | 9 +-
.../src/routes/docs.gittensory-commands.tsx | 7 +-
.../src/routes/docs.how-reviews-work.tsx | 9 +-
.../routes/docs.maintainer-self-hosting.tsx | 22 ++--
.../src/routes/docs.owner-checklist.tsx | 12 +-
.../src/routes/docs.privacy-security.tsx | 7 +-
.../docs.self-hosting-configuration.tsx | 61 +++++----
.../routes/docs.self-hosting-operations.tsx | 2 +-
.../routes/docs.self-hosting-quickstart.tsx | 7 +-
apps/gittensory-ui/src/routes/docs.tuning.tsx | 59 +++++----
config/examples/README.md | 124 ++++++++++--------
config/examples/TEMPLATES.md | 40 ++++--
packages/gittensory-engine/README.md | 9 +-
.../gittensory-engine/src/objective-anchor.ts | 1 +
.../src/review/guardrail-config.ts | 10 ++
packages/gittensory-mcp/README.md | 2 +-
.../docs/config-precedence.md | 2 +-
.../gittensory-miner/docs/miner-goal-spec.md | 6 +-
src/review/guardrail-config.ts | 10 ++
src/selfhost/private-config.ts | 76 ++++++++---
src/signals/focus-manifest-loader.ts | 33 +++--
...ctive-anchor-config-classification.test.ts | 20 +++
test/unit/focus-manifest-loader.test.ts | 84 +++++++++++-
test/unit/gittensory-focus-manifest.test.ts | 7 +-
test/unit/guardrail-config.test.ts | 12 ++
test/unit/private-config.test.ts | 112 +++++++++++++++-
test/unit/queue-2.test.ts | 4 +
test/unit/queue-4.test.ts | 16 +++
test/unit/selfhost-config-examples.test.ts | 22 ++++
39 files changed, 612 insertions(+), 230 deletions(-)
create mode 100644 test/unit/engine-objective-anchor-config-classification.test.ts
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8243a9fa49..e874a551b7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -282,7 +282,7 @@ Public GitHub surfaces:
app/infra state) resolves to a neutral, non-blocking gate. Adding a blocker must keep it
confirmed-contributor-gated through `evaluateGateCheck`.
-Config as code (`.gittensory.yml`) — every repository setting is controllable from the config file:
+Config as code (`.loopover.yml`; the legacy `.gittensory.yml` filename is dual-read indefinitely, #4773) — every repository setting is controllable from the config file:
- **`settings:`** is a partial of the repository settings: any behaviour a maintainer can toggle in the
dashboard can be set here as code — `commentMode`, `publicAudienceMode`, `publicSurface`, `checkRunMode`,
@@ -296,14 +296,15 @@ Config as code (`.gittensory.yml`) — every repository setting is controllable
`validationEvidence`, `openPrQueue`, `contributorContext`, `gateResult`). Maintainer text that fails the
public-safe filter (reward/score/wallet/hotkey/etc.) is dropped, never published.
- **`repoDocGeneration:`** opts a repo into the AGENTS.md/CLAUDE.md generation roadmap (#2993) — a
- `.gittensory.yml`-only surface with no dashboard/DB counterpart. `enabled` (default `false`) turns it on;
+ `.loopover.yml`-only surface with no dashboard/DB counterpart. `enabled` (default `false`) turns it on;
`scope` (default `["agents"]`) picks which generated file types are in play (`"agents"` for
AGENTS.md/CLAUDE.md, plus `"skills"` once skill-file generation lands); `allowOverwriteExisting` (default
`false`) is a separate opt-in required before the engine proposes an overwrite for a repo that already has
a hand-maintained AGENTS.md/CLAUDE.md — absent it, an existing hand-written file is left alone and
generation is skipped.
-- Precedence: `.gittensory.yml` `gate:` > `.gittensory.yml` `settings:` > dashboard repository settings >
- safe defaults; unset fields fall back to the next layer. The committed root `.gittensory.yml` is the
+- Precedence: `.loopover.yml` `gate:` > `.loopover.yml` `settings:` > dashboard repository settings >
+ safe defaults; unset fields fall back to the next layer (the legacy `.gittensory.yml` filename works
+ identically wherever `.loopover.yml` does, #4773). The committed root `.gittensory.yml` is the
worked example. Resolved once in `resolveRepositorySettings`, so the whole app honours the file.
- The config chooses **what** LoopOver does (gate on/off, blockers, comments, labels, surface, panel
content); it never changes **who** can be blocked — only confirmed Gittensor contributors are ever
diff --git a/README.md b/README.md
index 780cffe324..f4a7d607c5 100644
--- a/README.md
+++ b/README.md
@@ -37,16 +37,16 @@ LoopOver CI and LoopOver review score, gate, and comment on pull requests. The r
- **CI + full-file grounding** — grounds the AI reviewer with the PR's finished CI status and the full post-change content of the changed files, so claims are verified against reality instead of predicted.
- **Codebase RAG** — retrieval-augmented context that queries the codebase vector index for related callers, modules, and conventions and appends them to the reviewer prompt (additive only; inert until an index exists).
- **Submitter-reputation gating** — an internal-only spend control that downgrades new / burst / low-reputation submitters to a deterministic-only review, never surfaced on any public comment, label, or check.
-- **Unified review comment** — renders the public PR feedback as one in-place comment instead of multiple panels. With `.gittensory.yml`'s `review.changed_files_summary` also on (off by default), it gains a deterministic, no-AI "Changed files" collapsible: one row per file category (source/test/docs/config/generated), with file counts and +/- totals.
+- **Unified review comment** — renders the public PR feedback as one in-place comment instead of multiple panels. With `.loopover.yml`'s `review.changed_files_summary` also on (off by default; legacy `.gittensory.yml` name also still works, indefinitely), it gains a deterministic, no-AI "Changed files" collapsible: one row per file category (source/test/docs/config/generated), with file counts and +/- totals.
- **Per-repo activation** — capabilities roll forward (and back) one flag and one repo at a time via the `GITTENSORY_REVIEW_REPOS` allowlist.
**Check-run and comment surfaces, disambiguated** (a common point of confusion — these are three independent, separately-configured things, not layers of the same feature):
- **`LoopOver Orb Review Agent`** (`gate.*` / `settings.reviewCheckMode`, off by default) — the authoritative GitHub Check Run carrying the gate's pass/fail verdict. This is the one worth making a required status check.
- **`LoopOver Context`** (`settings.checkRunMode` / `settings.checkRunDetailLevel`, off by default) — a separate, purely advisory Check Run. At its default `checkRunDetailLevel: minimal` it publishes no findings at all; even at `standard`/`deep` it only re-renders content already shown elsewhere. Never make this one required.
-- **Inline review comments** (`GITTENSORY_REVIEW_INLINE_COMMENTS` + `.gittensory.yml`'s `review.inline_comments`, off by both by default) — real, reply-able line-anchored PR review comment threads (CodeRabbit-style). This is the ONLY one of the three that posts an interactive per-line thread; the two check runs above never do. With `.gittensory.yml`'s `review.suggestions` also on, a precise line-anchored fix is additionally rendered as a one-click, committable GitHub suggested-change block. With `review.finding_categories` also on (off by default), each finding is additionally tagged with a category — security/correctness/performance/maintainability/tests/style — in both the inline comment label and the unified comment's "Finding categories" collapsible; a deterministic path/keyword fallback covers whatever the model omits.
+- **Inline review comments** (`GITTENSORY_REVIEW_INLINE_COMMENTS` + `.loopover.yml`'s `review.inline_comments`, off by both by default) — real, reply-able line-anchored PR review comment threads (CodeRabbit-style). This is the ONLY one of the three that posts an interactive per-line thread; the two check runs above never do. With `.loopover.yml`'s `review.suggestions` also on, a precise line-anchored fix is additionally rendered as a one-click, committable GitHub suggested-change block. With `review.finding_categories` also on (off by default), each finding is additionally tagged with a category — security/correctness/performance/maintainability/tests/style — in both the inline comment label and the unified comment's "Finding categories" collapsible; a deterministic path/keyword fallback covers whatever the model omits.
-See [Tuning your reviews](https://gittensory.aethereal.dev/docs/tuning) for the full flag, setting, and `.gittensory.yml` reference.
+See [Tuning your reviews](https://gittensory.aethereal.dev/docs/tuning) for the full flag, setting, and `.loopover.yml` reference (the legacy `.gittensory.yml` filename is dual-read indefinitely — #4773 — so an existing repo's committed file keeps working unchanged).
## Start Here
diff --git a/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.tsx b/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.tsx
index 1a9dea2e1d..290533d480 100644
--- a/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.tsx
+++ b/apps/gittensory-ui/src/components/site/app-panels/ai-provider-mode-field-group.tsx
@@ -66,8 +66,8 @@ export function AiProviderModeFieldGroup({
Provider API keys are configured via environment variables, encrypted key storage, or the
maintainer BYOK dashboard — never in generated{" "}
- .gittensory.yml files. This field group only records
- mode and model names.
+ .loopover.yml files. This field group only records mode
+ and model names.
diff --git a/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.test.tsx
index c57ac34c7d..43902c70ee 100644
--- a/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.test.tsx
+++ b/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.test.tsx
@@ -13,7 +13,9 @@ describe("ConfigGeneratorYamlPreview", () => {
expect(screen.getByText("Preview")).toBeTruthy();
// The filename appears both in the descriptive copy and the CodeBlock's own filename label.
- expect(screen.getAllByText(".gittensory.yml").length).toBeGreaterThanOrEqual(2);
+ expect(screen.getAllByText(".loopover.yml").length).toBeGreaterThanOrEqual(2);
+ // The legacy filename is still mentioned as also-supported (#4773), not silently dropped.
+ expect(screen.getAllByText(".gittensory.yml").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/combine: consensus/)).toBeTruthy();
expect(screen.getByText(/provider: anthropic/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Copy code" })).toBeTruthy();
diff --git a/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.tsx b/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.tsx
index 5968e88cd3..789e1e1692 100644
--- a/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.tsx
+++ b/apps/gittensory-ui/src/components/site/app-panels/config-generator-yaml-preview.tsx
@@ -2,21 +2,23 @@ import { CodeBlock } from "@/components/site/primitives";
import { formStateToYaml, type GeneratorFormState } from "@/lib/config-generator-yaml";
/**
- * Read-only `.gittensory.yml` preview for the config generator (#2210, part of #1683): renders the
+ * Read-only `.loopover.yml` preview for the config generator (#2210, part of #1683): renders the
* current GeneratorFormState as text via CodeBlock (built-in copy-to-clipboard) so the output is
* explicit and reviewable before a self-hoster saves or copies it. Purely presentational — field-group
- * panels own collecting the form state.
+ * panels own collecting the form state. The legacy `.gittensory.yml` filename works identically if a
+ * self-hoster already has one committed (#4773) — this preview just names the new canonical filename.
*/
export function ConfigGeneratorYamlPreview({ formState }: { formState: GeneratorFormState }) {
return (
Preview
- The exact .gittensory.yml this configuration would
- produce. Nothing is saved until you copy it into your repo.
+ The exact .loopover.yml this configuration would produce
+ (the legacy .gittensory.yml name also still works).
+ Nothing is saved until you copy it into your repo.
-
+
);
diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx
index 9d712ee98c..9ae062fd7f 100644
--- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx
+++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx
@@ -142,7 +142,7 @@ const GATE_FIELDS: FieldDef[] = [
label: "Review agent check",
kind: "select",
// "visible" (publishes but never required in branch protection) is deliberately not offered here --
- // this toggle keeps its historical off/enabled shape; set .gittensory.yml gate.checkMode: visible directly
+ // this toggle keeps its historical off/enabled shape; set .loopover.yml gate.checkMode: visible directly
// for that finer-grained mode.
options: [
["disabled", "off"],
@@ -718,7 +718,8 @@ type FocusManifestResponse = { manifest: unknown };
/**
* Edit the repo's focus manifest as JSON. The manifest is repo-public config-as-code (it mirrors
- * `.gittensory.yml`); this surface lets a maintainer edit the API-record copy without committing a file.
+ * `.loopover.yml`, or the legacy `.gittensory.yml`, #4773); this surface lets a maintainer edit the
+ * API-record copy without committing a file.
*/
function FocusManifestEditor({ base }: { base: string | null }) {
const [text, setText] = useState("");
@@ -776,8 +777,7 @@ function FocusManifestEditor({ base }: { base: string | null }) {
The repo's maintainer focus policy as JSON — wanted paths, linked-issue policy, test
- expectations, and gate overrides. Mirrors .gittensory.yml
- .
+ expectations, and gate overrides. Mirrors .loopover.yml.
- Configure as code (.gittensory.yml)
+ Configure as code (.loopover.yml)
- Every setting can be committed to .gittensory.yml at the repo root instead of,
- or layered over, the dashboard. Precedence is .gittensory.yml > repository
- settings > safe defaults; an unset field falls back to the next layer. It only chooses{" "}
+ Every setting can be committed to .loopover.yml at the repo root instead of, or
+ layered over, the dashboard (the legacy .gittensory.yml name also still works,
+ indefinitely — #4773). Precedence is .loopover.yml > repository settings
+ > safe defaults; an unset field falls back to the next layer. It only chooses{" "}
what LoopOver does — a configured blocker gates every author identically,
regardless of config.
diff --git a/apps/gittensory-ui/src/routes/docs.gittensory-commands.tsx b/apps/gittensory-ui/src/routes/docs.gittensory-commands.tsx
index b34af8c5fe..04a77c2b9e 100644
--- a/apps/gittensory-ui/src/routes/docs.gittensory-commands.tsx
+++ b/apps/gittensory-ui/src/routes/docs.gittensory-commands.tsx
@@ -102,7 +102,7 @@ function GittensoryCommandsReference() {
Commands never flip the gate to advisory and never bypass the one-shot disposition.{" "}
@@ -126,8 +126,9 @@ function GittensoryCommandsReference() {
Per-command authorization overrides
Default allowed roles ship in the worker configuration. A maintainer can tighten or widen a
- single verb via commandAuthorization in .gittensory.yml (resolved
- in the same order as other per-repo settings: manifest → database → defaults).
+ single verb via commandAuthorization in .loopover.yml (or legacy{" "}
+ .gittensory.yml, #4773; resolved in the same order as other per-repo settings:
+ manifest → database → defaults).
- Defaults are quiet. With no settings and no .gittensory.yml, the gate is{" "}
- off, AI review is off, and the comment is posted only to detected
- contributors. Every capability is an explicit opt-in.
+ Defaults are quiet. With no settings and no .loopover.yml (or legacy{" "}
+ .gittensory.yml, #4773), the gate is off, AI review is{" "}
+ off, and the comment is posted only to detected contributors. Every capability
+ is an explicit opt-in.
1. The gate: advisory vs. block
@@ -169,7 +170,7 @@ function HowReviewsWork() {
author on any repo, with no confirmed-contributor tracking at all).
Mount ./gittensory-config and copy{" "}
config/examples/global.gittensory.yml →{" "}
- gittensory-config/.gittensory.yml for a centralized private default (per-repo
- files deep-merge on top).
+ gittensory-config/.loopover.yml (the legacy{" "}
+ gittensory-config/.gittensory.yml name still works too, #4773) for a
+ centralized private default (per-repo files deep-merge on top).
Add each pilot repo to GITTENSORY_REVIEW_REPOS, watch a PR in{" "}
@@ -245,12 +246,12 @@ function MaintainerSelfHosting() {
{
title: "Single-command repo onboarding",
description:
- "Today: edit .env allowlist, copy YAML templates, sign into the panel, click activate. Proposed: one CLI/API command that adds owner/repo to GITTENSORY_REVIEW_REPOS, seeds gittensory-config/owner__repo/.gittensory.yml from global.gittensory.yml, and POSTs activation — idempotent, dry-run aware.",
+ "Today: edit .env allowlist, copy YAML templates, sign into the panel, click activate. Proposed: one CLI/API command that adds owner/repo to GITTENSORY_REVIEW_REPOS, seeds gittensory-config/owner__repo/.loopover.yml (or legacy .gittensory.yml, #4773) from global.gittensory.yml, and POSTs activation — idempotent, dry-run aware.",
},
{
title: "Centralized private default only",
description:
- "Most fleets need one gittensory-config/.gittensory.yml (already supported) with optional per-repo overrides — docs now treat that as the default story instead of implying every repo needs its own file.",
+ "Most fleets need one gittensory-config/.loopover.yml (legacy .gittensory.yml also still supported, #4773) with optional per-repo overrides — docs now treat that as the default story instead of implying every repo needs its own file.",
},
{
title: "Advisory-by-default on first install",
@@ -337,7 +338,8 @@ function MaintainerSelfHosting() {
- One thing genuinely does carry over: a repo's own .gittensory.yml
+ One thing genuinely does carry over: a repo's own .loopover.yml (or
+ legacy .gittensory.yml, #4773)
{" "}
(config-as-code), because it lives in the repository's git history, not in either
service's database. resolveRepositorySettings overlays it on top of
@@ -367,11 +369,11 @@ function MaintainerSelfHosting() {
reopens (see the callout above). Once it does, the reverse migration has the same shape and
the same gap: uninstall your self-host App from the repo, install the shared hosted App (see{" "}
GitHub App configuration), and re-create any DB-backed
- settings on the hosted side. .gittensory.yml again carries over for free since
- it travels with the repo; nothing else does. Your self-host instance's data volumes are
- untouched by this — see{" "}
- Uninstalling and decommissioning if you also
- intend to shut the instance down rather than keep it idle or reuse it for other repos.
+ settings on the hosted side. .loopover.yml (or legacy{" "}
+ .gittensory.yml, #4773) again carries over for free since it travels with the
+ repo; nothing else does. Your self-host instance's data volumes are untouched by this —
+ see Uninstalling and decommissioning if you
+ also intend to shut the instance down rather than keep it idle or reuse it for other repos.
);
diff --git a/apps/gittensory-ui/src/routes/docs.owner-checklist.tsx b/apps/gittensory-ui/src/routes/docs.owner-checklist.tsx
index 2542ca2295..a124065f39 100644
--- a/apps/gittensory-ui/src/routes/docs.owner-checklist.tsx
+++ b/apps/gittensory-ui/src/routes/docs.owner-checklist.tsx
@@ -63,11 +63,11 @@ GET /v1/repos/:owner/:repo/gittensor-config-recommendation`}
2. Repo policy & config quality
- Your policy lives in .gittensory.yml (sections: settings,{" "}
- gate, review). The readiness report grades it as{" "}
- configQuality (excellent / good / needs_attention / fragile); a{" "}
- fragile config is a hard blocker. Preview exactly what a given config would
- do on a real PR before you commit it:
+ Your policy lives in .loopover.yml (or legacy .gittensory.yml,
+ #4773; sections: settings, gate, review). The
+ readiness report grades it as configQuality (excellent / good / needs_attention
+ / fragile); a fragile config is a hard blocker. Preview exactly what a
+ given config would do on a real PR before you commit it:
@@ -122,7 +122,7 @@ GET /v1/repos/:owner/:repo/gittensor-config-recommendation`}
6. Validation expectations & gate readiness
- Declare your validation commands in .gittensory.yml so contributors know what
+ Declare your validation commands in .loopover.yml so contributors know what
"done" means, and so the gate can run. The report reports{" "}
testCoverageHealth as gate_ready or gate_unknown;
gate checks only run when you have explicitly configured them. Until then the gate stays
diff --git a/apps/gittensory-ui/src/routes/docs.privacy-security.tsx b/apps/gittensory-ui/src/routes/docs.privacy-security.tsx
index 5aeffb7d64..ce301ebd84 100644
--- a/apps/gittensory-ui/src/routes/docs.privacy-security.tsx
+++ b/apps/gittensory-ui/src/routes/docs.privacy-security.tsx
@@ -62,9 +62,10 @@ function PrivacySecurity() {
Per-repo settings — gate modes, score thresholds, and guardrails, stored
in the operator's database (set through the dashboard/API) or declared as config-as-code
- in a repo's .gittensory.yml. Choosing gate.slop.minScore or
- setting settings.hardGuardrailGlobs tightens the gate without telling a
- contributor how to bypass it.
+ in a repo's .loopover.yml (or legacy .gittensory.yml, #4773).
+ Choosing gate.slop.minScore or setting{" "}
+ settings.hardGuardrailGlobs tightens the gate without telling a contributor
+ how to bypass it.
Operator feature flags — the GITTENSORY_REVIEW_* family of
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
index 1bee52a51e..906a7e279a 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
@@ -55,7 +55,7 @@ function SelfHostingConfiguration() {
{
title: "Public repo config",
description:
- "The repo .gittensory.yml. Useful for transparent policy, but not for thresholds or rules you need to keep private.",
+ "The repo .loopover.yml (legacy .gittensory.yml still works, #4773). Useful for transparent policy, but not for thresholds or rules you need to keep private.",
},
{
title: "Built-in defaults",
@@ -72,15 +72,16 @@ function SelfHostingConfiguration() {
- the repo's .gittensory.yml (public repo config, or the mounted private
- per-repo config file below if GITTENSORY_REPO_CONFIG_DIR is set), then
+ the repo's .loopover.yml (public repo config, or the mounted private
+ per-repo config file below if GITTENSORY_REPO_CONFIG_DIR is set — the legacy{" "}
+ .gittensory.yml name still works everywhere, indefinitely, #4773), then
the per-repo database settings (the dashboard), then
built-in safe defaults.
- Within .gittensory.yml itself, the typed gate: block is an alias
- for the gate-related fields and wins over the generic settings: block for those
+ Within .loopover.yml itself, the typed gate: block is an alias for
+ the gate-related fields and wins over the generic settings: block for those
same fields — so a value written under both gate.duplicates and{" "}
settings.duplicates resolves to whatever gate.duplicates says. One
exception to the whole precedence chain: hard path guardrails (
@@ -102,8 +103,9 @@ function SelfHostingConfiguration() {
Start from a template instead of reverse-engineering env flags, private-config precedence,
and the parser. Every template uses the same schema for a public repo-root{" "}
- .gittensory.yml or a container-private GITTENSORY_REPO_CONFIG_DIR{" "}
- mount — only what you put in each file differs.
+ .loopover.yml (or the legacy .gittensory.yml, still fully
+ supported, #4773) or a container-private GITTENSORY_REPO_CONFIG_DIR mount —
+ only what you put in each file differs.
Keep anti-abuse thresholds, maintainer allowlists, and autonomy dials in the{" "}
- private mount — not in a public .gittensory.yml contributors
- can read. config/examples/TEMPLATES.md documents the public-vs-private split
- and how to apply the templates to gittensory, awesome-claude, and{" "}
+ private mount — not in a public .loopover.yml contributors can
+ read. config/examples/TEMPLATES.md documents the public-vs-private split and
+ how to apply the templates to gittensory, awesome-claude, and{" "}
metagraphed without committing private policy. Lint before deploy:{" "}
- npx tsx scripts/gittensory-config-lint.ts path/to/.gittensory.yml.
+ npx tsx scripts/gittensory-config-lint.ts path/to/.loopover.yml (or the legacy{" "}
+ .gittensory.yml, #4773).
Authoritative copies in git:
@@ -406,12 +410,12 @@ GITHUB_METADATA_CACHE_TTL_SECONDS=600`}
{
title: "Feature allowlist (env)",
description:
- "GITTENSORY_REVIEW_REPOS lists which repos run the converged per-PR path (safety, unified comment, grounding, RAG, reputation, …). Empty/unset ⇒ no repo runs those features, regardless of individual GITTENSORY_REVIEW_* flags. Per-repo features: overrides in a private or public .gittensory.yml features: block can force on/off per repo (subject to env kill-switches).",
+ "GITTENSORY_REVIEW_REPOS lists which repos run the converged per-PR path (safety, unified comment, grounding, RAG, reputation, …). Empty/unset ⇒ no repo runs those features, regardless of individual GITTENSORY_REVIEW_* flags. Per-repo features: overrides in a private or public .loopover.yml (or legacy .gittensory.yml, #4773) features: block can force on/off per repo (subject to env kill-switches).",
},
{
title: "Gate activation (DB or private config)",
description:
- "The one-click POST …/activation endpoint bundles two independent axes into one advisory-first default: the review-check publish mode (reviewCheckMode: required, checkRunMode: enabled) and the actual per-dimension gate rules (linkedIssueGateMode, duplicatePrGateMode, qualityGateMode: all advisory; AI review still off). .gittensory.yml's gate.checkMode / gate.enabled only ever set the first axis (the check-run publish mode) — the dimension rules themselves are configured separately via gate.linkedIssue, gate.duplicates, gate.readiness.mode, etc. (see Tuning your reviews). Gate rule evaluation itself is never gated by checkMode/enabled/checkRunMode; those only control whether/how the check-run publishes on GitHub.",
+ "The one-click POST …/activation endpoint bundles two independent axes into one advisory-first default: the review-check publish mode (reviewCheckMode: required, checkRunMode: enabled) and the actual per-dimension gate rules (linkedIssueGateMode, duplicatePrGateMode, qualityGateMode: all advisory; AI review still off). .loopover.yml's (or legacy .gittensory.yml's, #4773) gate.checkMode / gate.enabled only ever set the first axis (the check-run publish mode) — the dimension rules themselves are configured separately via gate.linkedIssue, gate.duplicates, gate.readiness.mode, etc. (see Tuning your reviews). Gate rule evaluation itself is never gated by checkMode/enabled/checkRunMode; those only control whether/how the check-run publishes on GitHub.",
},
{
title: "Gittensor registration (is_registered)",
@@ -451,22 +455,25 @@ GITTENSORY_REVIEW_REPUTATION=false`}
Private per-repo config
Mount a gitignored directory and point GITTENSORY_REPO_CONFIG_DIR at it. If
- either a per-repo file or the dir-root global default (.gittensory.yml at the
- mount root) exists, the public repo .gittensory.yml is never fetched for that
+ either a per-repo file or the dir-root global default (.loopover.yml at the
+ mount root) exists, the public repo .loopover.yml is never fetched for that
review. With only one of the two present, its contents are used as-is; with both present,
they are deep-merged — the per-repo file overlaid onto the global default, nested mappings
- merging key by key and arrays replacing wholesale.
+ merging key by key and arrays replacing wholesale. The legacy .gittensory.yml{" "}
+ name is accepted everywhere .loopover.yml is (#4773) — when both names exist at
+ the same location, the new-brand file wins outright rather than being merged with the legacy
+ one.
Config-as-code blocks with no dashboard equivalent
Everything above has a dashboard row it mirrors. The fields below exist{" "}
- only in .gittensory.yml — there is no DB column or dashboard
- toggle for them, so a self-host operator who never reads the example file may not know they
- exist.
+ only in .loopover.yml (or the legacy{" "}
+ .gittensory.yml, #4773) — there is no DB column or dashboard toggle for them,
+ so a self-host operator who never reads the example file may not know they exist.
gate.checkMode
@@ -673,7 +680,7 @@ features:
— the whole block is ignored with a warning if either is missing).
enabled: false, means no repo-doc refresh ever runs for it.
Named data volumes — especially loopover-data (SQLite DB, Codex/Claude auth
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx
index 4f17aa56b8..87df53eeb0 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx
@@ -202,7 +202,7 @@ review_context_fetch_failed # REES/RAG/grounding context failure`}
{
title: "Gate activation (DB or private config)",
description:
- "Turns on the LoopOver check-run and deterministic gate rules for a repo. One-click via the control panel or POST /v1/repos/:owner/:repo/activation; or set gate.checkMode / gate.enabled in a mounted private .gittensory.yml.",
+ "Turns on the LoopOver check-run and deterministic gate rules for a repo. One-click via the control panel or POST /v1/repos/:owner/:repo/activation; or set gate.checkMode / gate.enabled in a mounted private .loopover.yml (legacy .gittensory.yml also still works, #4773).",
},
{
title: "is_registered (Gittensor registry)",
@@ -228,10 +228,11 @@ SELFHOST_DEPLOYMENT_MODE=dry-run # keep shadowing until you trust output`}
Sign in to the control panel (ADMIN_GITHUB_LOGINS must include your GitHub
diff --git a/apps/gittensory-ui/src/routes/docs.tuning.tsx b/apps/gittensory-ui/src/routes/docs.tuning.tsx
index 8193d607fa..6849c17b7b 100644
--- a/apps/gittensory-ui/src/routes/docs.tuning.tsx
+++ b/apps/gittensory-ui/src/routes/docs.tuning.tsx
@@ -10,13 +10,13 @@ export const Route = createFileRoute("/docs/tuning")({
{
name: "description",
content:
- "Configure LoopOver CI and LoopOver review: gate modes, score thresholds, guardrails, and feature flags via .gittensory.yml and repo settings.",
+ "Configure LoopOver CI and LoopOver review: gate modes, score thresholds, guardrails, and feature flags via .loopover.yml (or legacy .gittensory.yml) and repo settings.",
},
{ property: "og:title", content: "Tuning your reviews — LoopOver docs" },
{
property: "og:description",
content:
- "Configure LoopOver CI and LoopOver review: gate modes, score thresholds, guardrails, and feature flags via .gittensory.yml and repo settings.",
+ "Configure LoopOver CI and LoopOver review: gate modes, score thresholds, guardrails, and feature flags via .loopover.yml (or legacy .gittensory.yml) and repo settings.",
},
{ property: "og:url", content: "/docs/tuning" },
],
@@ -30,7 +30,7 @@ function Tuning() {
How configuration fits together
@@ -42,7 +42,8 @@ function Tuning() {
Per-repo settings — gate modes, score thresholds, guardrails, and which
surfaces are enabled. Set them in the dashboard, or declare them as config-as-code in a{" "}
- .gittensory.yml file in the repo.
+ .loopover.yml file in the repo (legacy .gittensory.yml also
+ still works, indefinitely — #4773).
Feature flags — the GITTENSORY_REVIEW_* family of
@@ -68,18 +69,19 @@ function Tuning() {
Every feature flag ships OFF. A repo with no settings and no{" "}
- .gittensory.yml falls back to a quiet, non-blocking profile: the gate is{" "}
- off, AI review is off, slop scoring is off, comments
- go only to detected contributors, and no check-run is published. Turning anything on is
- always an explicit opt-in — you roll capabilities forward, and back, one flag and one repo
- at a time.
+ .loopover.yml (or legacy .gittensory.yml) falls back to a quiet,
+ non-blocking profile: the gate is off, AI review is off, slop
+ scoring is off, comments go only to detected contributors, and no check-run is
+ published. Turning anything on is always an explicit opt-in — you roll capabilities forward,
+ and back, one flag and one repo at a time.
Precedence
Most specific wins:
- .gittensory.yml in the repo, then
+ .loopover.yml in the repo (or legacy .gittensory.yml, #4773),
+ then
per-repo database settings, then
built-in safe defaults.
@@ -91,11 +93,13 @@ function Tuning() {
means only those built-in invariants hold.
- The friendly gate: block in .gittensory.yml is a typed alias for
- the gate-related fields and wins over the generic settings: block for those
- same fields. LoopOver looks for the manifest at the first match of{" "}
- .gittensory.yml → .github/gittensory.yml →{" "}
- .gittensory.json → .github/gittensory.json.
+ The friendly gate: block in .loopover.yml is a typed alias for the
+ gate-related fields and wins over the generic settings: block for those same
+ fields. LoopOver looks for the manifest at the first match of .loopover.yml →{" "}
+ .github/loopover.yml → .loopover.json →{" "}
+ .github/loopover.json → (legacy, #4773) .gittensory.yml →{" "}
+ .github/gittensory.yml → .gittensory.json →{" "}
+ .github/gittensory.json.
Feature flags (GITTENSORY_REVIEW_*)
@@ -136,8 +140,8 @@ function Tuning() {
GITTENSORY_REVIEW_E2E_TESTS — master kill-switch for the opt-in,
maintainer-triggered AI-generated E2E test coverage feature. Off by default; a repo also
- needs its own features.e2eTests: true override in{" "}
- .gittensory.yml before the feature is active for it. Per-PR.
+ needs its own features.e2eTests: true override in .loopover.yml{" "}
+ before the feature is active for it. Per-PR.
GITTENSORY_REVIEW_IMPROVEMENT_SIGNAL — master kill-switch for the read-only,
@@ -154,7 +158,7 @@ function Tuning() {
@gittensory review as a maintainer) spends a fresh call. Truthy switches the
fleet default to continuous — every push/CI-completion/sweep re-runs AI content
generation. A repo's own review.auto_review.cadence in{" "}
- .gittensory.yml always overrides this default, in either direction. Never
+ .loopover.yml always overrides this default, in either direction. Never
affects the deterministic gate (CI status, mergeability, static-rule blockers), which
always re-evaluates regardless.
@@ -177,7 +181,7 @@ function Tuning() {
reference block to the reviewer prompt: typical merged-PR size and common accepted labels,
derived from this repo's own merge history. Additive reference only — never a gate or
scoring input. Also requires the per-repo review.culture_profile: true opt-in
- in .gittensory.yml. Per-PR.
+ in .loopover.yml. Per-PR.
GITTENSORY_REVIEW_MEMORY — repeat-false-positive suppression: matches an
@@ -187,7 +191,7 @@ function Tuning() {
@gittensory resolve [finding-code] (or a whole-PR{" "}
@gittensory resolve ack). Advisory-only by construction — never applied to
gate blockers, so it can never change the merge/close disposition. Also requires the
- per-repo review.memory: true opt-in in .gittensory.yml. Per-PR.
+ per-repo review.memory: true opt-in in .loopover.yml. Per-PR.
GITTENSORY_REVIEW_REPUTATION — submitter-reputation spend control. A new,
@@ -272,7 +276,7 @@ function Tuning() {
Gate modes
Per-repo behavior is the effective settings: the database row for the repo,
- overlaid with the repo's .gittensory.yml. Most gate dimensions are tri-state:
+ overlaid with the repo's .loopover.yml. Most gate dimensions are tri-state:
@@ -415,7 +419,7 @@ function Tuning() {
- The provider key itself never lives in .gittensory.yml. It is held only in the
+ The provider key itself never lives in .loopover.yml. It is held only in the
encrypted key store and unlocked by the TOKEN_ENCRYPTION_SECRET worker secret —
absent that secret, BYOK is unavailable and AI review silently falls back to the free
built-in model pair.
@@ -423,7 +427,7 @@ function Tuning() {
Guardrails and scope
- Top-level keys in .gittensory.yml declare the repo's focus and validation
+ Top-level keys in .loopover.yml declare the repo's focus and validation
expectations. These feed deterministic findings such as manifest_missing_tests{" "}
and — when gate.manifestPolicy: block — can become enforceable blockers. Manual
path holds are configured only through settings.hardGuardrailGlobs.
@@ -463,7 +467,7 @@ function Tuning() {
Other repo settings
Anything you can toggle in the dashboard can also be set as code under{" "}
- settings: in .gittensory.yml. Common ones, all defaulting to the
+ settings: in .loopover.yml. Common ones, all defaulting to the
safe values shown:
@@ -523,13 +527,14 @@ function Tuning() {
-
Example .gittensory.yml
+
Example .loopover.yml
A worked manifest: focus and validation up top, a refined gate, BYOK AI review, and a few
- dashboard-equivalent overrides.
+ dashboard-equivalent overrides. Same schema, same effect, if you name the file{" "}
+ .gittensory.yml instead (legacy name, still fully supported — #4773).
**Filename note (#4773):** the canonical manifest filename is now **`.loopover.yml`** (LoopOver's
+> new brand); the pre-rebrand name **`.gittensory.yml`** is still accepted everywhere `.loopover.yml`
+> is, indefinitely and with no code changes required from an existing self-hoster. Every path below
+> is shown with the new-brand name; mentally substitute `.gittensory.yml` anywhere you already have
+> one on disk today — it keeps working unchanged. When BOTH names exist at the exact same location,
+> the new-brand file wins outright (its content is used; the legacy file there is not merged in) —
+> see `src/selfhost/private-config.ts`'s `CONFIG_BASENAMES` for the authoritative precedence.
## Directory layout
For a repo `owner/repo`, the reader tries, in priority order:
```
-${GITTENSORY_REPO_CONFIG_DIR}/owner__repo/.gittensory.yml # 1. owner-qualified folder (recommended)
-${GITTENSORY_REPO_CONFIG_DIR}/repo/.gittensory.yml # 2. bare repo-name folder
-${GITTENSORY_REPO_CONFIG_DIR}/owner__repo.yml # 3. flat file (back-compat)
-${GITTENSORY_REPO_CONFIG_DIR}/.gittensory.yml # 4. global default, shared by every repo
-${GITTENSORY_REPO_CONFIG_DIR}/_shared/.gittensory.yml # 5. shared base (#1959), lowest priority
+${GITTENSORY_REPO_CONFIG_DIR}/owner__repo/.loopover.yml # 1. owner-qualified folder (recommended)
+${GITTENSORY_REPO_CONFIG_DIR}/repo/.loopover.yml # 2. bare repo-name folder
+${GITTENSORY_REPO_CONFIG_DIR}/owner__repo.yml # 3. flat file (back-compat, brand-agnostic)
+${GITTENSORY_REPO_CONFIG_DIR}/.loopover.yml # 4. global default, shared by every repo
+${GITTENSORY_REPO_CONFIG_DIR}/_shared/.loopover.yml # 5. shared base (#1959), lowest priority
```
-`.yaml` and `.json` are accepted everywhere `.yml` is. Every one of these files uses the **exact
-same schema** as the public `.gittensory.yml` — see [`gittensory.full.yml`](./gittensory.full.yml)
-(or [`.gittensory.yml.example`](../../.gittensory.yml.example) at the repo root) for the exhaustive,
-field-by-field reference. For the smallest safe starter, copy [`gittensory.minimal.yml`](./gittensory.minimal.yml)
-(or [`.gittensory.minimal.yml`](../../.gittensory.minimal.yml)) to your repo root as `.gittensory.yml`
-or into your private mount and customize from there.
+Every one of 1, 2, 4, and 5 above ALSO accepts the legacy `.gittensory.yml` name at that same
+location (tried after `.loopover.yml`, #4773) — omitted from the listing above purely for brevity,
+not because it stopped working. `.yaml` and `.json` are accepted everywhere `.yml` is, for both
+brand names. Every one of these files uses the **exact same schema** as the public manifest — see
+[`gittensory.full.yml`](./gittensory.full.yml) (or [`.gittensory.yml.example`](../../.gittensory.yml.example)
+at the repo root) for the exhaustive, field-by-field reference. For the smallest safe starter, copy
+[`gittensory.minimal.yml`](./gittensory.minimal.yml) (or [`.gittensory.minimal.yml`](../../.gittensory.minimal.yml))
+to your repo root as `.loopover.yml` or into your private mount and customize from there.
## Precedence chain
@@ -39,17 +49,19 @@ From highest to lowest priority:
1. **Private per-repo file**, deep-merged over **2** and **3** when more than one exists (see
below) — or used alone when it is the only private layer present.
-2. **Private global default** (`${GITTENSORY_REPO_CONFIG_DIR}/.gittensory.yml`) — deep-merged
- under **1** when both exist; used alone when a repo has no per-repo file of its own and no
- shared base is mounted.
-3. **Private shared base** (`${GITTENSORY_REPO_CONFIG_DIR}/_shared/.gittensory.yml`, #1959) — the
+2. **Private global default** (`${GITTENSORY_REPO_CONFIG_DIR}/.loopover.yml`, or legacy
+ `.gittensory.yml`, #4773) — deep-merged under **1** when both exist; used alone when a repo has
+ no per-repo file of its own and no shared base is mounted.
+3. **Private shared base** (`${GITTENSORY_REPO_CONFIG_DIR}/_shared/.loopover.yml`, #1959) — the
lowest-priority private layer, deep-merged under both **1** and **2**. An operator running many
repos writes a house review policy (e.g. a default `review.tone`, `path_filters`, or
`exclude_paths`) here **once** instead of copy-pasting it into every repo's per-repo file or
- the global default. `.yaml`/`.json` are accepted, same as every other candidate. Absent (the
- default, common case) ⇒ byte-identical behavior to the pre-#1959 2-layer chain.
+ the global default. `.yaml`/`.json` are accepted, same as every other candidate, and so is the
+ legacy `.gittensory.yml` name (#4773). Absent (the default, common case) ⇒ byte-identical
+ behavior to the pre-#1959 2-layer chain.
4. When **none** of the three private layers above exists, the loader falls back to the **public
- repo `.gittensory.yml`** (or `.github/gittensory.yml`) fetched from GitHub.
+ repo `.loopover.yml`** (or `.github/loopover.yml`, or the legacy `.gittensory.yml`/
+ `.github/gittensory.yml`, #4773) fetched from GitHub.
5. **Dashboard/API-stored settings** for the repo.
6. **Built-in safe defaults.**
@@ -62,7 +74,7 @@ shipped first; the shared base is the newest, lowest layer, #1959).
This chain governs *per-repo review policy* only. A separate, lower-level set of **deployment
environment variables** (`GITTENSORY_REVIEW_*` flags, AI provider keys/models, self-host runtime
knobs, etc.) configures the deployment itself and sits **underneath** all 5 layers above — a
-`.gittensory.yml`/private-config value never overrides an operator's env-level kill-switch, it only
+`.loopover.yml`/private-config value never overrides an operator's env-level kill-switch, it only
narrows what's already permitted. See the generated, always-current
[`SELFHOST_ENV_REFERENCE_ROWS`](../../apps/gittensory-ui/src/lib/selfhost-env-reference.ts) (built by
`npm run selfhost:env-reference` from every `env.SOMETHING` read in the codebase) for the full list.
@@ -94,7 +106,7 @@ below for the shared base specifically):
### Example 1 — global defaults + a per-repo override
-`.gittensory.yml` (global default, at the config dir root):
+`.loopover.yml` (global default, at the config dir root; legacy `.gittensory.yml` works the same, #4773):
```yaml
settings:
@@ -106,7 +118,7 @@ gate:
duplicates: block
```
-`owner__repo/.gittensory.yml` (per-repo override — only touches what's different for this repo):
+`owner__repo/.loopover.yml` (per-repo override — only touches what's different for this repo):
```yaml
gate:
@@ -123,7 +135,7 @@ The effective config for `owner/repo` has `gate.duplicates: block` (from global)
### Example 2 — disabling a global setting for one high-trust repo
```yaml
-# owner__repo/.gittensory.yml
+# owner__repo/.loopover.yml
settings:
contributorOpenPrCap: null # explicitly clears the global cap of 3 for this repo only
```
@@ -134,7 +146,7 @@ Shared anti-abuse mechanisms (the review-request-nag cooldown, the contributor o
exempt configured logins on top of the standing owner/admin/automation-bot exemption:
```yaml
-# .gittensory.yml (global default)
+# .loopover.yml (global default)
settings:
autoCloseExemptLogins:
- your-trusted-regular
@@ -148,27 +160,27 @@ common `exclude_paths` — **once**, instead of copy-pasting it into every repo'
even the global default. That policy lives at:
```
-${GITTENSORY_REPO_CONFIG_DIR}/_shared/.gittensory.yml
+${GITTENSORY_REPO_CONFIG_DIR}/_shared/.loopover.yml
```
-(`.yaml`/`.json` also accepted, same lookup order as every other candidate — see
-[`shared.gittensory.yml`](./shared.gittensory.yml) for a starter). It sits at the **lowest**
-priority of the three private layers: a per-repo file overlays a global default, which overlays
-the shared base — the shared base fills in only the fields a higher layer is silent on. This is
-the exact same deep-merge helper and array-replace/explicit-null-clear semantics described above,
-folded across one more layer; it is not a new merge algorithm.
-
-**Absent shared base is the default, common case** — with no `_shared/.gittensory.yml` mounted,
-behavior is byte-identical to the pre-#1959 2-layer chain. A malformed or unreadable shared file
-fails safe exactly like a malformed per-repo or global file always has: it is dropped from the
-merge and the remaining, still-valid layers combine as if it were never mounted — a broken shared
-base never blocks a review. When a shared `review:` block contributes, the parsed manifest carries
-`review.sharedConfigSource` (runtime provenance only, #2046) with the relative path of the shared
-file that supplied the base layer.
+(`.yaml`/`.json`, and the legacy `.gittensory.yml`/`.yaml`/`.json` names (#4773), also accepted, same
+lookup order as every other candidate — see [`shared.gittensory.yml`](./shared.gittensory.yml) for a
+starter). It sits at the **lowest** priority of the three private layers: a per-repo file overlays a
+global default, which overlays the shared base — the shared base fills in only the fields a higher
+layer is silent on. This is the exact same deep-merge helper and array-replace/explicit-null-clear
+semantics described above, folded across one more layer; it is not a new merge algorithm.
+
+**Absent shared base is the default, common case** — with no `_shared/.loopover.yml` (or legacy
+`_shared/.gittensory.yml`) mounted, behavior is byte-identical to the pre-#1959 2-layer chain. A
+malformed or unreadable shared file fails safe exactly like a malformed per-repo or global file
+always has: it is dropped from the merge and the remaining, still-valid layers combine as if it
+were never mounted — a broken shared base never blocks a review. When a shared `review:` block
+contributes, the parsed manifest carries `review.sharedConfigSource` (runtime provenance only,
+#2046) with the relative path of the shared file that supplied the base layer.
### Example 4 — shared base + global default + a per-repo override, all three present
-`_shared/.gittensory.yml` (shared base — one house policy for every repo on this instance):
+`_shared/.loopover.yml` (shared base — one house policy for every repo on this instance):
```yaml
review:
@@ -177,14 +189,14 @@ gate:
duplicates: block
```
-`.gittensory.yml` (global default — this instance's own baseline, silent on `review.tone`):
+`.loopover.yml` (global default — this instance's own baseline, silent on `review.tone`):
```yaml
gate:
enabled: true
```
-`owner__repo/.gittensory.yml` (per-repo override — only touches what's different for this repo):
+`owner__repo/.loopover.yml` (per-repo override — only touches what's different for this repo):
```yaml
gate:
@@ -220,7 +232,7 @@ config-as-code only: omitting `settings.hardGuardrailGlobs` or setting it to `[]
guardrails, and a concrete list replaces any lower-layer private global default.
```yaml
-# .gittensory.yml (global default) — recommended one-shot baseline
+# .loopover.yml (global default) — recommended one-shot baseline
settings:
autonomy:
close: auto
@@ -238,7 +250,7 @@ one shared policy) to **also** throttle a thread's own author repeatedly @-menti
maintainer login, counted independently per login and independently of the `@gittensory` counter:
```yaml
-# .gittensory.yml (global default)
+# .loopover.yml (global default)
settings:
reviewNagPolicy: hold
reviewNagMonitoredMentions:
@@ -255,12 +267,12 @@ PR/issue never counts.
the issue already carries it — the only mechanism that can ever select a maintainer-reward or
moderation-weighted label; it is never inferred from a PR's title, changed files, AI output, or
existing PR labels. If your labels carry that kind of weight, this is exactly the sort of rule that
-belongs in the private layer rather than the public `.gittensory.yml`, so a contributor can see
+belongs in the private layer rather than the public manifest, so a contributor can see
*that* the mapping exists (via its effect) without being able to read the exact issue-label ->
PR-label rules and game them:
```yaml
-# .gittensory.yml (global default)
+# .loopover.yml (global default)
settings:
linkedIssueLabelPropagation:
enabled: true
@@ -274,18 +286,20 @@ settings:
A per-repo override's `mappings` list **replaces** the global default wholesale (the standard
array-replace overlay semantics above) — it does not merge with it.
-## What belongs here vs. in the public `.gittensory.yml`
+## What belongs here vs. in the public `.loopover.yml`
- **Private config** (this directory): anti-abuse thresholds, the contributor cap, maintainer/
admin exemption logins, autonomy dials, model/effort overrides, and anything else you don't want
a contributor reading and gaming.
-- **Public `.gittensory.yml`** (repo root, contributor-visible): work-area guidance
- (`wantedPaths`), test expectations, and review-panel presentation — nothing here
- should describe your private enforcement strategy.
+- **Public `.loopover.yml`** (repo root, contributor-visible; legacy `.gittensory.yml` still works,
+ #4773): work-area guidance (`wantedPaths`), test expectations, and review-panel presentation —
+ nothing here should describe your private enforcement strategy.
## Safety
Never commit real policy into this directory or into these example files: no maintainer usernames,
-no repo names, no thresholds beyond illustrative placeholders, no secrets or tokens. The two
-`.gittensory.yml` files shipped alongside this README are deliberately generic and inert — copy
-them into your own mounted `GITTENSORY_REPO_CONFIG_DIR` and edit the copy, not this one.
+no repo names, no thresholds beyond illustrative placeholders, no secrets or tokens. The
+`.gittensory.yml`-named template files shipped alongside this README (see the catalog in
+[TEMPLATES.md](./TEMPLATES.md)) are deliberately generic and inert — copy one into your own mounted
+`GITTENSORY_REPO_CONFIG_DIR`, name the copy `.loopover.yml` (or keep the legacy name, #4773 — both
+work), and edit the copy, not this one.
diff --git a/config/examples/TEMPLATES.md b/config/examples/TEMPLATES.md
index fa7b8f1070..9566b3e696 100644
--- a/config/examples/TEMPLATES.md
+++ b/config/examples/TEMPLATES.md
@@ -1,8 +1,17 @@
# Gittensory review config templates
-Copy-paste templates for `.gittensory.yml` — the per-repo review manifest. Every file in this
-directory uses the **same schema** whether it lives in a public repo root or a self-host private
-mount (`GITTENSORY_REPO_CONFIG_DIR`).
+Copy-paste templates for the per-repo review manifest. Every file in this directory uses the
+**same schema** whether it lives in a public repo root or a self-host private mount
+(`GITTENSORY_REPO_CONFIG_DIR`).
+
+> **Filename note (#4773):** the canonical manifest filename is now **`.loopover.yml`** (LoopOver's
+> new brand). The pre-rebrand name, **`.gittensory.yml`**, is still read everywhere `.loopover.yml`
+> is — indefinitely, additively, with no expiry — so an existing self-hoster's committed
+> `.gittensory.yml` keeps working with zero changes required. When both names are present at the
+> same location, the new-brand `.loopover.yml` wins. New setups should name their own file
+> `.loopover.yml`; the *template* filenames catalogued below (e.g. `gittensory.minimal.yml`) are a
+> separate, unrelated naming concern and are left as-is — see the "Quick start" section for the
+> destination filename you actually create.
## Template catalog
@@ -21,12 +30,15 @@ in sync with those files.
## Public repo root vs private self-host mount
+Paths below show the new-brand `.loopover.yml` name; `.gittensory.yml` (and `.github/gittensory.yml`
+for the public row) still works everywhere, indefinitely (#4773) — see the filename note above.
+
| Layer | Path | Who can read it | Typical contents |
|-------|------|-----------------|------------------|
-| **Public** | `.gittensory.yml` or `.github/gittensory.yml` in git | Contributors | `wantedPaths`, test expectations, public review presentation |
-| **Private global** | `${GITTENSORY_REPO_CONFIG_DIR}/.gittensory.yml` | Operator only | Shared autonomy baseline, contributor caps, maintainer allowlists |
-| **Private per-repo** | `${GITTENSORY_REPO_CONFIG_DIR}/owner__repo/.gittensory.yml` | Operator only | Repo-specific CI context names, AI mode, overrides |
-| **Private shared base** | `${GITTENSORY_REPO_CONFIG_DIR}/_shared/.gittensory.yml` | Operator only | Lowest-priority cross-repo house policy for an operator running many repos (#1959) — see [README's "Shared base layer" section](./README.md#shared-base-layer-multi-repo-operators-1959) |
+| **Public** | `.loopover.yml` or `.github/loopover.yml` in git | Contributors | `wantedPaths`, test expectations, public review presentation |
+| **Private global** | `${GITTENSORY_REPO_CONFIG_DIR}/.loopover.yml` | Operator only | Shared autonomy baseline, contributor caps, maintainer allowlists |
+| **Private per-repo** | `${GITTENSORY_REPO_CONFIG_DIR}/owner__repo/.loopover.yml` | Operator only | Repo-specific CI context names, AI mode, overrides |
+| **Private shared base** | `${GITTENSORY_REPO_CONFIG_DIR}/_shared/.loopover.yml` | Operator only | Lowest-priority cross-repo house policy for an operator running many repos (#1959) — see [README's "Shared base layer" section](./README.md#shared-base-layer-multi-repo-operators-1959) |
When **either** a private global or private per-repo file exists, the loader **never fetches** the
public repo file for that review — mount private policy deliberately. See [README.md](./README.md)
@@ -41,19 +53,20 @@ contributors to read) into a public repository. Copy `global.gittensory.yml` int
### Public repo (contributor-visible config)
```bash
-cp config/examples/gittensory.minimal.yml .gittensory.yml
+cp config/examples/gittensory.minimal.yml .loopover.yml
# edit wantedPaths / gate when ready
+# (an existing .gittensory.yml at repo root also still works -- #4773 -- no need to rename it)
```
### Self-host private mount (operator-only policy)
```bash
mkdir -p gittensory-config
-cp config/examples/global.gittensory.yml gittensory-config/.gittensory.yml
+cp config/examples/global.gittensory.yml gittensory-config/.loopover.yml
# edit your-admin-login placeholders before going live
# optional per-repo overlay:
mkdir -p gittensory-config/myorg__myrepo
-cp config/examples/repo-override.gittensory.yml gittensory-config/myorg__myrepo/.gittensory.yml
+cp config/examples/repo-override.gittensory.yml gittensory-config/myorg__myrepo/.loopover.yml
```
Point `GITTENSORY_REPO_CONFIG_DIR` at that directory (default `/config` in `docker-compose.yml` maps
@@ -66,8 +79,9 @@ into public git** — use the private mount for anything marked *private* below.
### `JSONbored/gittensory` (dogfooding)
-- **Public** `.gittensory.yml` in the repo: work-area guardrails, test expectations, gate dimensions
- contributors should understand.
+- **Public** `.gittensory.yml` in the repo (the legacy name this repo's own committed config still
+ uses, per #4773's dual-read — a new repo would name this `.loopover.yml` instead): work-area
+ guardrails, test expectations, gate dimensions contributors should understand.
- **Private** `gittensory-config/` (gitignored locally, operator mount in production): fleet
autonomy, anti-abuse caps, maintainer exemption lists — the same split described in
[`global.gittensory.yml`](./global.gittensory.yml).
@@ -96,5 +110,5 @@ Every template in this directory is parsed in CI (`test/unit/config-templates.te
`.gittensory.yml.example` from `# WHERE IT LIVES` onward. Lint a local file before deploy:
```bash
-npx tsx scripts/gittensory-config-lint.ts path/to/.gittensory.yml
+npx tsx scripts/gittensory-config-lint.ts path/to/.loopover.yml # or path/to/.gittensory.yml (legacy, #4773)
```
diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md
index a8ed688c01..7e6e0ea05f 100644
--- a/packages/gittensory-engine/README.md
+++ b/packages/gittensory-engine/README.md
@@ -194,8 +194,9 @@ samples in `metrics` rather than averaging noise into the calibration signal.
`resolveGateVerdictCalibrationConfig()`, `ingestGateVerdictCalibrationSignals()`, and
`computeGateVerdictCompositeCalibrationScore()` provide the pure engine contract for opt-in cross-product calibration.
-The hosted review stack remains responsible for loading the repo's current `.gittensory.yml` or private config; the
-engine contract is deliberately default-off and safe to call at ingestion time.
+The hosted review stack remains responsible for loading the repo's current `.loopover.yml` (or legacy
+`.gittensory.yml`, dual-read indefinitely — #4773) or private config; the engine contract is
+deliberately default-off and safe to call at ingestion time.
The preferred config-as-code surface is:
@@ -386,8 +387,8 @@ never copied into the Markdown, and the renderer fails closed if a blocked priva
`resolveFindingSeverityCalibrationConfig()`, `ingestFindingSeverityCalibrationSignals()`, and
`computeFindingSeverityCompositeCalibrationScore()` provide the pure engine contract for the opt-in finding-severity
calibration signal. It sits in the same family as objective-anchor and pairwise-judge: the hosted review stack decides
-whether a repo is opted in from its resolved `.gittensory.yml`/private config, and the engine contract is deliberately
-default-off and safe to call at ingestion time.
+whether a repo is opted in from its resolved `.loopover.yml`/private config (legacy `.gittensory.yml` also still
+works, #4773), and the engine contract is deliberately default-off and safe to call at ingestion time.
The preferred config-as-code surface is:
diff --git a/packages/gittensory-engine/src/objective-anchor.ts b/packages/gittensory-engine/src/objective-anchor.ts
index 86d7bb72d1..4e5a28e1cc 100644
--- a/packages/gittensory-engine/src/objective-anchor.ts
+++ b/packages/gittensory-engine/src/objective-anchor.ts
@@ -150,6 +150,7 @@ const CONFIG_FILENAMES = new Set([
".env",
".env.example",
".env.selfhost.example",
+ ".loopover.yml", // new-brand manifest name (legacy .gittensory.yml kept below, dual-read indefinitely, #4773)
".gittensory.yml",
"package.json",
"package-lock.json",
diff --git a/packages/gittensory-engine/src/review/guardrail-config.ts b/packages/gittensory-engine/src/review/guardrail-config.ts
index 1900d8415b..023ab4ffb4 100644
--- a/packages/gittensory-engine/src/review/guardrail-config.ts
+++ b/packages/gittensory-engine/src/review/guardrail-config.ts
@@ -1,6 +1,16 @@
import type { RepositorySettings } from "../types/predicted-gate-types.js";
+// New-brand (`loopover`) and legacy (`gittensory`) entries are both listed — this is a Set-membership guardrail
+// list (order doesn't matter, unlike the loaders' priority-ordered candidate lists), so a contributor PR touching
+// EITHER the new canonical `.loopover.*` config file or a still-supported legacy `.gittensory.*` one gets the same
+// hard-guardrail protection (#4773 — dual-read, additive; the legacy globs are never removed).
export const CONFIG_AS_CODE_GUARDRAIL_GLOBS = [
+ ".loopover.yml",
+ ".loopover.yaml",
+ ".loopover.json",
+ ".github/loopover.yml",
+ ".github/loopover.yaml",
+ ".github/loopover.json",
".gittensory.yml",
".gittensory.yaml",
".gittensory.json",
diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md
index 3681be958d..9bb77ff011 100644
--- a/packages/gittensory-mcp/README.md
+++ b/packages/gittensory-mcp/README.md
@@ -61,7 +61,7 @@ gittensory-mcp analyze-branch --login jsonbored --json
gittensory-mcp preflight --login jsonbored --json
gittensory-mcp review-pr --login jsonbored --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json
gittensory-mcp lint-pr-text --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json
-gittensory-mcp validate-config --file ./.gittensory.yml --json
+gittensory-mcp validate-config --file ./.loopover.yml --json # or ./.gittensory.yml (legacy name, #4773)
gittensory-mcp slop-risk --changed-file src/widget.ts:80:2 --description "Adds retry handling." --test-file test/unit/widget.test.ts --json
gittensory-mcp issue-slop --title "Add retry handling" --body "Widget reconnects fail without bounded retries." --json
gittensory-mcp agent plan --login jsonbored --json
diff --git a/packages/gittensory-miner/docs/config-precedence.md b/packages/gittensory-miner/docs/config-precedence.md
index e6a995fc06..a00dc547ce 100644
--- a/packages/gittensory-miner/docs/config-precedence.md
+++ b/packages/gittensory-miner/docs/config-precedence.md
@@ -93,4 +93,4 @@ If a future change adds yml or CLI for a setting documented here as env-only, up
- [`miner-goal-spec.md`](miner-goal-spec.md) — goal-spec field reference
- [`env-reference.md`](env-reference.md) — generated operator env list
-- ORB `.gittensory.yml` precedence (`yml > DB > defaults`) in the main app — analogous documentation style, different runtime
+- ORB `.loopover.yml` (or legacy `.gittensory.yml`, #4773) precedence (`yml > DB > defaults`) in the main app — analogous documentation style, different runtime
diff --git a/packages/gittensory-miner/docs/miner-goal-spec.md b/packages/gittensory-miner/docs/miner-goal-spec.md
index 8fae73888d..65dfcbc716 100644
--- a/packages/gittensory-miner/docs/miner-goal-spec.md
+++ b/packages/gittensory-miner/docs/miner-goal-spec.md
@@ -15,10 +15,10 @@ Every field is optional. Unknown keys are ignored; a malformed field falls back
| File | Actor | Purpose |
|------|-------|---------|
-| `.gittensory.yml` | Review stack | How a maintainer's repo **reviews** incoming PRs (focus manifest, gate, scoring knobs). |
-| `.gittensory-miner.yml` | Miner runtime | How a miner **searches for and prioritizes** work in a target repo. |
+| `.loopover.yml` (or legacy `.gittensory.yml`, dual-read indefinitely — #4773) | Review stack | How a maintainer's repo **reviews** incoming PRs (focus manifest, gate, scoring knobs). |
+| `.gittensory-miner.yml` | Miner runtime | How a miner **searches for and prioritizes** work in a target repo. Unrelated naming concern — not affected by the review-config rebrand above. |
-They are read by different components and do not conflict. A miner should still treat a target repo's public `.gittensory.yml` `wantedPaths` / `blockedPaths` as a hard floor when both files exist.
+They are read by different components and do not conflict. A miner should still treat a target repo's public `.loopover.yml`/`.gittensory.yml` `wantedPaths` / `blockedPaths` as a hard floor when both files exist.
## Fields
diff --git a/src/review/guardrail-config.ts b/src/review/guardrail-config.ts
index 8a07af33d1..82d74f9d48 100644
--- a/src/review/guardrail-config.ts
+++ b/src/review/guardrail-config.ts
@@ -1,6 +1,16 @@
import type { RepositorySettings } from "../types";
+// New-brand (`loopover`) and legacy (`gittensory`) entries are both listed — this is a Set-membership guardrail
+// list (order doesn't matter, unlike the loaders' priority-ordered candidate lists), so a contributor PR touching
+// EITHER the new canonical `.loopover.*` config file or a still-supported legacy `.gittensory.*` one gets the same
+// hard-guardrail protection (#4773 — dual-read, additive; the legacy globs are never removed).
export const CONFIG_AS_CODE_GUARDRAIL_GLOBS = [
+ ".loopover.yml",
+ ".loopover.yaml",
+ ".loopover.json",
+ ".github/loopover.yml",
+ ".github/loopover.yaml",
+ ".github/loopover.json",
".gittensory.yml",
".gittensory.yaml",
".gittensory.json",
diff --git a/src/selfhost/private-config.ts b/src/selfhost/private-config.ts
index 5abfb08ba8..4a2b5ee9f5 100644
--- a/src/selfhost/private-config.ts
+++ b/src/selfhost/private-config.ts
@@ -1,19 +1,27 @@
// Container-private per-repo config (self-host). A self-host operator mounts a directory at
// GITTENSORY_REPO_CONFIG_DIR and configures each repo's review policy there; the focus-manifest loader reads it
-// INSTEAD of fetching the public `.gittensory.yml`, so policy (gate, autonomy, labels, model/effort) is configured
-// PRIVATELY and never exposed to contributors who could read and game the public file. Node-only — it is registered
-// into the Workers-safe loader via setLocalManifestReader at boot (server.ts), so this module's fs import never
-// reaches the Cloudflare bundle.
+// INSTEAD of fetching the public `.gittensory.yml`/`.loopover.yml`, so policy (gate, autonomy, labels, model/effort)
+// is configured PRIVATELY and never exposed to contributors who could read and game the public file. Node-only — it
+// is registered into the Workers-safe loader via setLocalManifestReader at boot (server.ts), so this module's fs
+// import never reaches the Cloudflare bundle.
//
// Layout (CodeRabbit-style: per-repo override, layered over a global default, layered over a cross-repo shared
// base — #1959). For a repo `JSONbored/gittensory` the reader tries, in priority order:
-// 1. `jsonbored__gittensory/.gittensory.yml` — owner-qualified folder (robust to repo-name collisions across owners)
-// 2. `gittensory/.gittensory.yml` — bare repo-name folder (the clean, human-readable layout)
+// 1. `jsonbored__gittensory/.loopover.yml` — owner-qualified folder (robust to repo-name collisions across owners)
+// 2. `gittensory/.loopover.yml` — bare repo-name folder (the clean, human-readable layout)
// 3. `jsonbored__gittensory.yml` — flat owner__repo file (the original #1390 layout; back-compat)
-// 4. `.gittensory.yml` — GLOBAL default at the dir root, shared by every repo.
-// 5. `_shared/.gittensory.yml` — SHARED BASE (#1959), the lowest-priority layer: one house policy
+// 4. `.loopover.yml` — GLOBAL default at the dir root, shared by every repo.
+// 5. `_shared/.loopover.yml` — SHARED BASE (#1959), the lowest-priority layer: one house policy
// an operator running many repos writes once instead of copy-pasting into every repo's private config.
-// `.yaml` / `.json` are accepted everywhere `.yml` is. With only ONE of {a per-repo candidate, the global default,
+// `.yaml` / `.json` are accepted everywhere `.yml` is, and `.gittensory.*` (the pre-rebrand name) is accepted
+// everywhere `.loopover.*` is (#4773 — dual-read, additive only, indefinitely: an existing self-hoster's committed
+// `.gittensory.*` file keeps working with zero changes on their part). CONFIG_BASENAMES below lists every accepted
+// basename with the new-brand `.loopover.*` entries FIRST and the legacy `.gittensory.*` entries LAST; `readFirstExisting`
+// (and its `WithPath` sibling) return the first candidate that exists, in list order, so within any single location
+// (a given folder, or the dir root) a `.loopover.*` file present there is read and an old `.gittensory.*` file
+// sitting alongside it is ignored (not merged) — see CONFIG_BASENAMES for why new-brand-first is the right
+// precedence. An operator who has done nothing keeps working unchanged: only `.gittensory.*` exists anywhere, so it
+// is still the first (and only) hit at every location. With only ONE of {a per-repo candidate, the global default,
// the shared base} present, its raw text is returned unchanged — byte-identical to the original #1390 behavior
// (and to the pre-#1959 2-layer behavior when no shared base is mounted, the common case). With more than one
// present, they are DEEP-MERGED in ascending priority (shared base → global default → per-repo file): nested
@@ -44,8 +52,32 @@ import type {
RepoReviewContextReader,
} from "../signals/focus-manifest-loader";
-/** The bare config filenames tried inside a per-repo folder and at the dir root (global default), in priority order. */
-const CONFIG_BASENAMES = [".gittensory.yml", ".gittensory.yaml", ".gittensory.json"] as const;
+/** The bare config filenames tried inside a per-repo folder and at the dir root (global default), in priority
+ * order. New-brand (`.loopover.*`) candidates are listed FIRST and legacy (`.gittensory.*`) candidates LAST — this
+ * IS the precedence rule (#4773): every helper below (`GLOBAL_CONFIG_CANDIDATES`, `SHARED_BASE_CONFIG_CANDIDATES`,
+ * `localConfigCandidates`) derives its search order from this array's order, and `readFirstExisting` /
+ * `readFirstExistingWithPath` return the first candidate that EXISTS, so at any single location (a folder, or the
+ * dir root) a `.loopover.*` file wins over a `.gittensory.*` file sitting right next to it — they are never both
+ * read and merged as if they were two layers. Rationale: creating a `.loopover.*` file at all is clear, deliberate,
+ * recent migration intent, so a pre-existing `.gittensory.*` file alongside it is far more likely to be a stale
+ * leftover than something the operator means to keep authoritative; this mirrors the existing
+ * `review/AGENTS.md`-before-`review/CLAUDE.md` precedent below (new name tried first, legacy as pure fallback),
+ * not a new rule invented for this change. An operator with ONLY the legacy file present is completely unaffected
+ * — dual-read is additive, the legacy name is never removed, and there is no expiry planned. */
+const CONFIG_BASENAMES = [
+ ".loopover.yml", ".loopover.yaml", ".loopover.json",
+ ".gittensory.yml", ".gittensory.yaml", ".gittensory.json", // legacy — dual-read indefinitely (#4773)
+] as const;
+
+/** The extensions accepted by CONFIG_BASENAMES (derived positionally — `base.slice(base.lastIndexOf("."))` — never
+ * by slicing off a hardcoded brand-name length, which would silently corrupt this for any basename whose brand
+ * prefix isn't exactly 11 characters like `.gittensory` happens to be), deduped in first-seen order: `.yml`,
+ * `.yaml`, `.json`. Used only to build the flat `{owner}__{repo}.` candidate below (#1390 back-compat), which
+ * is brand-agnostic — it was never spelled with a brand name in it (`owner__repo.yml`, not
+ * `owner__repo.gittensory.yml`), so there is nothing to dual-read there beyond the plain extension. Mapping
+ * CONFIG_BASENAMES directly (one flat candidate per brand basename) would instead repeat the same 3 flat paths
+ * once per brand — redundant, not "more compatible". */
+const CONFIG_EXTENSIONS: string[] = [...new Set(CONFIG_BASENAMES.map((base) => base.slice(base.lastIndexOf("."))))];
const GITHUB_OWNER_SEGMENT = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
const GITHUB_REPO_SEGMENT = /^[a-z0-9._-]+$/;
@@ -53,21 +85,25 @@ function isSafeRepoSegment(segment: string): boolean {
return segment !== "." && segment !== ".." && GITHUB_REPO_SEGMENT.test(segment);
}
-/** Global-default candidates (relative to GITTENSORY_REPO_CONFIG_DIR): the dir-root `.gittensory.{yml,yaml,json}`,
+/** Global-default candidates (relative to GITTENSORY_REPO_CONFIG_DIR): the dir-root `.loopover.{yml,yaml,json}` /
+ * `.gittensory.{yml,yaml,json}` (new-brand first, see CONFIG_BASENAMES for the precedence rule, #4773),
* deep-merged under any per-repo file (or applied alone, when a repo has no per-repo file of its own). */
export const GLOBAL_CONFIG_CANDIDATES: string[] = [...CONFIG_BASENAMES];
-/** Shared-base candidates (#1959, relative to GITTENSORY_REPO_CONFIG_DIR): `_shared/.gittensory.{yml,yaml,json}`,
- * sibling to the per-repo folders inside the SAME container-private directory — no new env var. This is the
- * lowest-priority layer: a cross-repo "house policy" an operator running many repos writes once, deep-merged
- * UNDER both the global default and any per-repo file (or applied alone, when neither of those exists). */
+/** Shared-base candidates (#1959, relative to GITTENSORY_REPO_CONFIG_DIR): `_shared/.loopover.{yml,yaml,json}` /
+ * `_shared/.gittensory.{yml,yaml,json}` (new-brand first, #4773), sibling to the per-repo folders inside the SAME
+ * container-private directory — no new env var. This is the lowest-priority layer: a cross-repo "house policy" an
+ * operator running many repos writes once, deep-merged UNDER both the global default and any per-repo file (or
+ * applied alone, when neither of those exists). */
export const SHARED_BASE_CONFIG_CANDIDATES: string[] = CONFIG_BASENAMES.map((base) => join("_shared", base));
const SHARED_BASE_CONFIG_CANDIDATE_SET = new Set(SHARED_BASE_CONFIG_CANDIDATES);
/** Per-repo private-config candidate paths (relative to GITTENSORY_REPO_CONFIG_DIR), in priority order:
* owner-qualified folder → bare repo-name folder → flat `owner__repo` file (the #1390 back-compat form). The slug
* is the lowercased GitHub `owner__repo` (double underscore because `/` is not filename-safe); the bare folder is
- * the lowercased repo name. An invalid repo full name (no single interior slash) yields no candidates. */
+ * the lowercased repo name. An invalid repo full name (no single interior slash) yields no candidates. The two
+ * folder forms carry the brand name (new-brand `.loopover.*` before legacy `.gittensory.*`, per CONFIG_BASENAMES,
+ * #4773); the flat form doesn't (see CONFIG_EXTENSIONS) and is unaffected by the rebrand. */
export function localConfigCandidates(repoFullName: string): string[] {
const slash = repoFullName.indexOf("/");
if (slash <= 0 || slash === repoFullName.length - 1 || slash !== repoFullName.lastIndexOf("/")) return [];
@@ -76,14 +112,14 @@ export function localConfigCandidates(repoFullName: string): string[] {
if (!GITHUB_OWNER_SEGMENT.test(owner) || !isSafeRepoSegment(repo)) return [];
const slug = `${owner}__${repo}`;
return [
- // 1. owner-qualified folder — `{owner}__{repo}/.gittensory.{yml,yaml,json}`
+ // 1. owner-qualified folder — `{owner}__{repo}/.loopover.{yml,yaml,json}` then `/.gittensory.{yml,yaml,json}`
...CONFIG_BASENAMES.map((base) => join(slug, base)),
- // 2. bare repo-name folder — `{repo}/.gittensory.{yml,yaml,json}`
+ // 2. bare repo-name folder — `{repo}/.loopover.{yml,yaml,json}` then `/.gittensory.{yml,yaml,json}`
...CONFIG_BASENAMES.map((base) => join(repo, base)).filter(
(candidate) => !SHARED_BASE_CONFIG_CANDIDATE_SET.has(candidate),
),
// 3. flat owner__repo file (#1390) — `{owner}__{repo}.{yml,yaml,json}`
- ...CONFIG_BASENAMES.map((base) => `${slug}${base.slice(".gittensory".length)}`),
+ ...CONFIG_EXTENSIONS.map((ext) => `${slug}${ext}`),
];
}
diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts
index a879b658f6..21a149c3b4 100644
--- a/src/signals/focus-manifest-loader.ts
+++ b/src/signals/focus-manifest-loader.ts
@@ -10,8 +10,24 @@ export const REPO_PUBLIC_FOCUS_MANIFEST_SIGNAL = "repo-public-focus-manifest";
export const REPO_FOCUS_MANIFEST_MAX_AGE_MS = 6 * 60 * 60 * 1000;
export const REPO_FOCUS_MANIFEST_MAX_CONCURRENT_LOADS = 4;
+/**
+ * Public-repo manifest candidate paths, tried in order via {@link fetchRepoFocusManifestFile}. New-brand
+ * (`loopover`) candidates are listed FIRST and legacy (`gittensory`) candidates LAST — this is the dual-read
+ * precedence rule (#4773): the loader returns the first candidate that responds `200 OK`, so a repo that publishes
+ * BOTH `.loopover.yml` and `.gittensory.yml` at its root gets the new-brand file, and a repo with only the legacy
+ * file (the common case today, and forever if an operator never migrates) is completely unaffected — it is still
+ * the first (and only) 200. Mirrors the existing `.gittensory.yml`-before-`.github/gittensory.yml`-before-`.json`
+ * ordering used within each brand: `.yml` anywhere beats `.json` anywhere, and root beats `.github/` within the
+ * same extension. Deliberately does NOT add a `.loopover.yaml` variant here — the legacy brand never had a public
+ * `.yaml` candidate either (only the self-host loader in `../selfhost/private-config.ts` accepts `.yaml`), so this
+ * stays a like-for-like widening rather than a scope increase.
+ */
export const MANIFEST_FILE_CANDIDATES = [
- ".gittensory.yml",
+ ".loopover.yml",
+ ".github/loopover.yml",
+ ".loopover.json",
+ ".github/loopover.json",
+ ".gittensory.yml", // legacy — dual-read indefinitely (#4773)
".github/gittensory.yml",
".gittensory.json",
".github/gittensory.json",
@@ -26,8 +42,8 @@ export type RepoFocusManifestFetcher = (repoFullName: string) => Promise {
- // Container-private per-repo config (self-host) takes priority over the public `.gittensory.yml`: read fresh from
- // local fs each call (cheap, no network) so operator edits apply immediately. NEVER consulted on the publicOnly
+ // Container-private per-repo config (self-host) takes priority over the public `.loopover.yml` / `.gittensory.yml`:
+ // read fresh from local fs each call (cheap, no network) so operator edits apply immediately. NEVER consulted on the publicOnly
// (contributor-preview) path, and never persisted — so private policy can't leak into previews or the cache.
if (!cachePolicy.publicOnly && localManifestReader) {
const localRaw = await localManifestReader(repoFullName);
@@ -163,7 +180,7 @@ async function loadRepoFocusManifestWithCachePolicy(
await persistRepoFocusManifest(env, repoFullName, manifest, REPO_PUBLIC_FOCUS_MANIFEST_SIGNAL);
} else {
// Persist even an ABSENT manifest (negative cache): effective settings are resolved from
- // `.gittensory.yml` on every webhook, so a repo without one must not re-fetch the raw file each time.
+ // `.loopover.yml` / `.gittensory.yml` on every webhook, so a repo without one must not re-fetch the raw file each time.
// The TTL still refreshes it, so a newly-added manifest is picked up on the next window.
await persistRepoFocusManifest(env, repoFullName, manifest);
}
diff --git a/test/unit/engine-objective-anchor-config-classification.test.ts b/test/unit/engine-objective-anchor-config-classification.test.ts
new file mode 100644
index 0000000000..21d8156dcd
--- /dev/null
+++ b/test/unit/engine-objective-anchor-config-classification.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { extractObjectiveAnchorFeatures } from "../../packages/gittensory-engine/src/objective-anchor";
+
+// packages/gittensory-engine/src/objective-anchor.ts's CONFIG_FILENAMES set is exercised almost
+// exclusively by its own node:test suite (invisible to Codecov's vitest-based coverage), so the
+// #4773 dual-read addition of ".loopover.yml" alongside ".gittensory.yml" needs a real vitest-side
+// assertion, not just a top-level module-load hit.
+describe("gittensory-engine objective-anchor config-filename classification (#4773)", () => {
+ it("classifies both .loopover.yml and .gittensory.yml as a 'config' change kind", () => {
+ const features = extractObjectiveAnchorFeatures({
+ paths: [".loopover.yml", ".gittensory.yml"],
+ labels: [],
+ titles: [],
+ notes: [],
+ });
+
+ expect(features.changeKinds).toContain("config");
+ expect(features.paths).toEqual([".gittensory.yml", ".loopover.yml"]);
+ });
+});
diff --git a/test/unit/focus-manifest-loader.test.ts b/test/unit/focus-manifest-loader.test.ts
index 907371dcfe..d7c0aeb00d 100644
--- a/test/unit/focus-manifest-loader.test.ts
+++ b/test/unit/focus-manifest-loader.test.ts
@@ -8,6 +8,7 @@ import {
loadRepoFocusManifests,
setLocalManifestReader,
upsertRepoFocusManifest,
+ MANIFEST_FILE_CANDIDATES,
REPO_FOCUS_MANIFEST_MAX_AGE_MS,
REPO_FOCUS_MANIFEST_MAX_CONCURRENT_LOADS,
REPO_PUBLIC_FOCUS_MANIFEST_SIGNAL,
@@ -289,7 +290,18 @@ describe("focus-manifest loader", () => {
expect(await fetchRepoFocusManifestFile("trailing/")).toBeNull();
});
- it("returns raw text from the first 200 OK candidate path", async () => {
+ it("returns raw text from the first 200 OK candidate path (new-brand .loopover.yml, tried before legacy)", async () => {
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
+ const stringUrl = String(url);
+ if (stringUrl.endsWith("/.loopover.yml")) return new Response("wantedPaths:\n - src/\n", { status: 200 });
+ return new Response("not found", { status: 404 });
+ });
+ const text = await fetchRepoFocusManifestFile("owner/repo");
+ expect(text).toBe("wantedPaths:\n - src/\n");
+ expect(fetchSpy).toHaveBeenCalledTimes(1); // first candidate in MANIFEST_FILE_CANDIDATES is a 200, no fallback needed
+ });
+
+ it("falls back to the legacy .gittensory.yml when none of the new-brand candidates respond (#4773 — dual-read)", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const stringUrl = String(url);
if (stringUrl.endsWith("/.gittensory.yml")) return new Response("wantedPaths:\n - src/\n", { status: 200 });
@@ -297,12 +309,23 @@ describe("focus-manifest loader", () => {
});
const text = await fetchRepoFocusManifestFile("owner/repo");
expect(text).toBe("wantedPaths:\n - src/\n");
- expect(fetchSpy).toHaveBeenCalledTimes(1);
+ // all 4 new-brand candidates 404 first, then legacy `.gittensory.yml` (5th in MANIFEST_FILE_CANDIDATES) hits.
+ expect(fetchSpy).toHaveBeenCalledTimes(MANIFEST_FILE_CANDIDATES.indexOf(".gittensory.yml") + 1);
});
it("does not read public manifest responses when Content-Length is too large", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const stringUrl = String(url);
+ if (stringUrl.endsWith("/.loopover.yml") || stringUrl.endsWith("/.github/loopover.yml")) {
+ return new Response("not found", { status: 404 });
+ }
+ if (stringUrl.endsWith("/.loopover.json")) {
+ return new Response('{"wantedPaths":["too-large-loopover/"]}', {
+ status: 200,
+ headers: { "content-length": String(MAX_FOCUS_MANIFEST_BYTES + 1) },
+ });
+ }
+ if (stringUrl.endsWith("/.github/loopover.json")) return new Response("not found", { status: 404 });
if (stringUrl.endsWith("/.gittensory.yml") || stringUrl.endsWith("/.github/gittensory.yml")) {
return new Response("not found", { status: 404 });
}
@@ -316,7 +339,7 @@ describe("focus-manifest loader", () => {
});
const text = await fetchRepoFocusManifestFile("owner/repo");
expect(text).toBe('{"wantedPaths":["src/"]}');
- expect(fetchSpy).toHaveBeenCalledTimes(4);
+ expect(fetchSpy).toHaveBeenCalledTimes(MANIFEST_FILE_CANDIDATES.length); // every candidate (both brands) tried; last one wins
});
it("aborts public manifest streams that grow beyond the byte cap", async () => {
@@ -357,6 +380,61 @@ describe("focus-manifest loader", () => {
expect(text).toBe('{"wantedPaths":["src/"]}');
});
+ describe("public manifest dual-brand filename support (#4773)", () => {
+ it("(a) keeps working with ONLY the legacy .gittensory.yml candidates present — zero changes required", async () => {
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
+ const stringUrl = String(url);
+ if (stringUrl.endsWith("/.gittensory.yml")) return new Response("wantedPaths:\n - src/\n", { status: 200 });
+ return new Response("not found", { status: 404 });
+ });
+ expect(await fetchRepoFocusManifestFile("owner/repo")).toBe("wantedPaths:\n - src/\n");
+ });
+
+ it("(b) reads the new-brand .loopover.yml when no legacy candidate is published", async () => {
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
+ const stringUrl = String(url);
+ if (stringUrl.endsWith("/.loopover.yml")) return new Response("wantedPaths:\n - src/\n", { status: 200 });
+ return new Response("not found", { status: 404 });
+ });
+ expect(await fetchRepoFocusManifestFile("owner/repo")).toBe("wantedPaths:\n - src/\n");
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("(b) reads the new-brand .loopover.json (root) and .github/loopover.yml the same as their legacy equivalents", async () => {
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
+ const stringUrl = String(url);
+ if (stringUrl.endsWith("/.loopover.json")) return new Response('{"wantedPaths":["src/"]}', { status: 200 });
+ return new Response("not found", { status: 404 });
+ });
+ expect(await fetchRepoFocusManifestFile("owner/repo")).toBe('{"wantedPaths":["src/"]}');
+ });
+
+ it("(c) prefers the new-brand .loopover.yml over a legacy .gittensory.yml published at the SAME repo", async () => {
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
+ const stringUrl = String(url);
+ if (stringUrl.endsWith("/.loopover.yml")) return new Response("wantedPaths:\n - new-brand/\n", { status: 200 });
+ if (stringUrl.endsWith("/.gittensory.yml")) return new Response("wantedPaths:\n - legacy/\n", { status: 200 });
+ return new Response("not found", { status: 404 });
+ });
+ expect(await fetchRepoFocusManifestFile("owner/repo")).toBe("wantedPaths:\n - new-brand/\n");
+ expect(fetchSpy).toHaveBeenCalledTimes(1); // the new-brand root candidate is tried (and hits) first
+ });
+
+ it("(c) prefers a new-brand .github/loopover.json over a legacy root .gittensory.yml — brand is the outer sort key", async () => {
+ // MANIFEST_FILE_CANDIDATES orders new-brand root .yml/.github .yml/root .json/.github .json before ANY
+ // legacy candidate, so a legacy root .yml never even gets a chance once any new-brand candidate 200s.
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
+ const stringUrl = String(url);
+ if (stringUrl.endsWith("/.github/loopover.json")) {
+ return new Response('{"wantedPaths":["new-brand/"]}', { status: 200 });
+ }
+ if (stringUrl.endsWith("/.gittensory.yml")) return new Response("wantedPaths:\n - legacy/\n", { status: 200 });
+ return new Response("not found", { status: 404 });
+ });
+ expect(await fetchRepoFocusManifestFile("owner/repo")).toBe('{"wantedPaths":["new-brand/"]}');
+ });
+ });
+
it("exposes a reasonable default max-age", () => {
expect(REPO_FOCUS_MANIFEST_MAX_AGE_MS).toBeGreaterThan(60 * 1000);
});
diff --git a/test/unit/gittensory-focus-manifest.test.ts b/test/unit/gittensory-focus-manifest.test.ts
index d9ef64e610..50c0e48ae6 100644
--- a/test/unit/gittensory-focus-manifest.test.ts
+++ b/test/unit/gittensory-focus-manifest.test.ts
@@ -128,8 +128,11 @@ describe("Gittensory repo focus manifest", () => {
}
});
- it("prefers YAML manifest file candidates before JSON", () => {
- expect(MANIFEST_FILE_CANDIDATES[0]).toBe(".gittensory.yml");
+ it("prefers YAML manifest file candidates before JSON, and new-brand candidates before legacy ones (#4773)", () => {
+ expect(MANIFEST_FILE_CANDIDATES[0]).toBe(".loopover.yml");
+ expect(MANIFEST_FILE_CANDIDATES).toContain(".loopover.json");
+ expect(MANIFEST_FILE_CANDIDATES).toContain(".gittensory.yml"); // legacy — dual-read indefinitely
expect(MANIFEST_FILE_CANDIDATES).toContain(".gittensory.json");
+ expect(MANIFEST_FILE_CANDIDATES.indexOf(".loopover.yml")).toBeLessThan(MANIFEST_FILE_CANDIDATES.indexOf(".gittensory.yml"));
});
});
diff --git a/test/unit/guardrail-config.test.ts b/test/unit/guardrail-config.test.ts
index 96e8613b02..6d0e1aa451 100644
--- a/test/unit/guardrail-config.test.ts
+++ b/test/unit/guardrail-config.test.ts
@@ -1,9 +1,21 @@
import { describe, expect, it } from "vitest";
import {
+ CONFIG_AS_CODE_GUARDRAIL_GLOBS,
DEFAULT_HARD_GUARDRAIL_GLOBS,
resolveHardGuardrailGlobs,
} from "../../src/review/guardrail-config";
+describe("CONFIG_AS_CODE_GUARDRAIL_GLOBS (#4773 — dual-brand config filenames)", () => {
+ it("guards both the new-brand .loopover.* config files and the legacy .gittensory.* ones", () => {
+ for (const ext of ["yml", "yaml", "json"]) {
+ expect(CONFIG_AS_CODE_GUARDRAIL_GLOBS).toContain(`.loopover.${ext}`);
+ expect(CONFIG_AS_CODE_GUARDRAIL_GLOBS).toContain(`.github/loopover.${ext}`);
+ expect(CONFIG_AS_CODE_GUARDRAIL_GLOBS).toContain(`.gittensory.${ext}`);
+ expect(CONFIG_AS_CODE_GUARDRAIL_GLOBS).toContain(`.github/gittensory.${ext}`);
+ }
+ });
+});
+
describe("resolveHardGuardrailGlobs", () => {
it("uses invariant guardrails when effective settings omit hardGuardrailGlobs", () => {
expect(resolveHardGuardrailGlobs(undefined)).toEqual(DEFAULT_HARD_GUARDRAIL_GLOBS);
diff --git a/test/unit/private-config.test.ts b/test/unit/private-config.test.ts
index 464bc0fe49..44bc9e6fe2 100644
--- a/test/unit/private-config.test.ts
+++ b/test/unit/private-config.test.ts
@@ -20,17 +20,23 @@ async function readLocalManifestLoad(reader: RepoFocusManifestFetcher, repo: str
}
describe("localConfigCandidates (container-private config paths)", () => {
- it("builds owner-folder → repo-folder → flat candidates (lowercased), each in .yml/.yaml/.json order", () => {
+ it("builds owner-folder → repo-folder → flat candidates (lowercased), new-brand before legacy, each in .yml/.yaml/.json order (#4773)", () => {
expect(localConfigCandidates("JSONbored/metagraphed")).toEqual([
- // 1. owner-qualified folder
+ // 1. owner-qualified folder — new-brand (.loopover.*) before legacy (.gittensory.*)
+ join("jsonbored__metagraphed", ".loopover.yml"),
+ join("jsonbored__metagraphed", ".loopover.yaml"),
+ join("jsonbored__metagraphed", ".loopover.json"),
join("jsonbored__metagraphed", ".gittensory.yml"),
join("jsonbored__metagraphed", ".gittensory.yaml"),
join("jsonbored__metagraphed", ".gittensory.json"),
- // 2. bare repo-name folder
+ // 2. bare repo-name folder — same new-brand-before-legacy order
+ join("metagraphed", ".loopover.yml"),
+ join("metagraphed", ".loopover.yaml"),
+ join("metagraphed", ".loopover.json"),
join("metagraphed", ".gittensory.yml"),
join("metagraphed", ".gittensory.yaml"),
join("metagraphed", ".gittensory.json"),
- // 3. flat owner__repo file (#1390 back-compat)
+ // 3. flat owner__repo file (#1390 back-compat) — brand-agnostic, so only 3 (not 6) candidates
"jsonbored__metagraphed.yml",
"jsonbored__metagraphed.yaml",
"jsonbored__metagraphed.json",
@@ -47,11 +53,17 @@ describe("localConfigCandidates (container-private config paths)", () => {
expect(localConfigCandidates("bad_owner/repo")).toEqual([]);
expect(localConfigCandidates("-owner/repo")).toEqual([]);
});
- it("exposes the dir-root global-fallback candidates", () => {
- expect(GLOBAL_CONFIG_CANDIDATES).toEqual([".gittensory.yml", ".gittensory.yaml", ".gittensory.json"]);
+ it("exposes the dir-root global-fallback candidates, new-brand before legacy (#4773)", () => {
+ expect(GLOBAL_CONFIG_CANDIDATES).toEqual([
+ ".loopover.yml", ".loopover.yaml", ".loopover.json",
+ ".gittensory.yml", ".gittensory.yaml", ".gittensory.json",
+ ]);
});
- it("exposes the shared-base candidates, sibling to the per-repo folders (#1959)", () => {
+ it("exposes the shared-base candidates, sibling to the per-repo folders, new-brand before legacy (#1959, #4773)", () => {
expect(SHARED_BASE_CONFIG_CANDIDATES).toEqual([
+ join("_shared", ".loopover.yml"),
+ join("_shared", ".loopover.yaml"),
+ join("_shared", ".loopover.json"),
join("_shared", ".gittensory.yml"),
join("_shared", ".gittensory.yaml"),
join("_shared", ".gittensory.json"),
@@ -60,6 +72,9 @@ describe("localConfigCandidates (container-private config paths)", () => {
it("reserves the shared-base folder instead of treating it as the bare folder for a repo named _shared", () => {
expect(localConfigCandidates("owner/_shared")).toEqual([
+ join("owner___shared", ".loopover.yml"),
+ join("owner___shared", ".loopover.yaml"),
+ join("owner___shared", ".loopover.json"),
join("owner___shared", ".gittensory.yml"),
join("owner___shared", ".gittensory.yaml"),
join("owner___shared", ".gittensory.json"),
@@ -240,6 +255,89 @@ describe("makeLocalManifestReader (GITTENSORY_REPO_CONFIG_DIR)", () => {
});
});
+describe("makeLocalManifestReader — dual-brand filename support (#4773)", () => {
+ it("(a) keeps working with ONLY the legacy .gittensory.yml present in the per-repo folder — zero changes required", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
+ mkdirSync(join(dir, "repo"));
+ writeFileSync(join(dir, "repo", ".gittensory.yml"), "gate:\n enabled: true\n");
+ const reader = makeLocalManifestReader(dir);
+ expect(await readLocalManifestContent(reader!, "owner/repo")).toBe("gate:\n enabled: true\n");
+ });
+
+ it("(a) keeps working with ONLY the legacy .gittensory.yml present as the dir-root global default", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
+ writeFileSync(join(dir, ".gittensory.yml"), "gate:\n enabled: false\n");
+ const reader = makeLocalManifestReader(dir);
+ expect(await readLocalManifestContent(reader!, "owner/unconfigured")).toBe("gate:\n enabled: false\n");
+ });
+
+ it("(b) reads the new-brand .loopover.yml when no legacy file is present, in the per-repo folder", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
+ mkdirSync(join(dir, "repo"));
+ writeFileSync(join(dir, "repo", ".loopover.yml"), "gate:\n enabled: true\n");
+ const reader = makeLocalManifestReader(dir);
+ expect(await readLocalManifestContent(reader!, "owner/repo")).toBe("gate:\n enabled: true\n");
+ });
+
+ it("(b) reads the new-brand .loopover.yml when no legacy file is present, as the dir-root global default", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
+ writeFileSync(join(dir, ".loopover.yml"), "gate:\n enabled: false\n");
+ const reader = makeLocalManifestReader(dir);
+ expect(await readLocalManifestContent(reader!, "owner/unconfigured")).toBe("gate:\n enabled: false\n");
+ });
+
+ it("(b) reads new-brand .loopover.yaml / .loopover.json too, same as the legacy extensions", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
+ mkdirSync(join(dir, "yaml-repo"));
+ writeFileSync(join(dir, "yaml-repo", ".loopover.yaml"), "gate:\n enabled: true\n");
+ mkdirSync(join(dir, "json-repo"));
+ writeFileSync(join(dir, "json-repo", ".loopover.json"), '{"gate":{"enabled":false}}');
+ const reader = makeLocalManifestReader(dir);
+ expect(await readLocalManifestContent(reader!, "owner/yaml-repo")).toBe("gate:\n enabled: true\n");
+ expect(await readLocalManifestContent(reader!, "owner/json-repo")).toBe('{"gate":{"enabled":false}}');
+ });
+
+ it("(c) prefers new-brand .loopover.yml over a legacy .gittensory.yml sitting in the SAME per-repo folder — not merged", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
+ mkdirSync(join(dir, "repo"));
+ writeFileSync(join(dir, "repo", ".loopover.yml"), "gate:\n enabled: true\n");
+ writeFileSync(join(dir, "repo", ".gittensory.yml"), "gate:\n enabled: false\n duplicates: block\n");
+ const reader = makeLocalManifestReader(dir);
+ const manifest = parseFocusManifestContent(await readLocalManifestContent(reader!, "owner/repo"));
+ expect(manifest.gate.enabled).toBe(true); // new-brand file wins outright
+ expect(manifest.gate.duplicates).toBeNull(); // legacy file's content is NOT merged in, only the winner is read
+ });
+
+ it("(c) prefers new-brand .loopover.yml over a legacy .gittensory.yml at the SAME dir-root global default", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
+ writeFileSync(join(dir, ".loopover.yml"), "gate:\n enabled: true\n");
+ writeFileSync(join(dir, ".gittensory.yml"), "gate:\n enabled: false\n");
+ const reader = makeLocalManifestReader(dir);
+ expect(await readLocalManifestContent(reader!, "owner/unconfigured")).toBe("gate:\n enabled: true\n");
+ });
+
+ it("(c) prefers new-brand .loopover.json over a legacy .gittensory.yml, even though .yml normally beats .json within a brand", async () => {
+ // Brand is the OUTERMOST sort key: every .loopover.* extension is tried before any .gittensory.* extension.
+ const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
+ mkdirSync(join(dir, "repo"));
+ writeFileSync(join(dir, "repo", ".loopover.json"), '{"gate":{"enabled":true}}');
+ writeFileSync(join(dir, "repo", ".gittensory.yml"), "gate:\n enabled: false\n");
+ const reader = makeLocalManifestReader(dir);
+ expect(await readLocalManifestContent(reader!, "owner/repo")).toBe('{"gate":{"enabled":true}}');
+ });
+
+ it("(c) a per-repo new-brand file still wins over a legacy global default (per-repo priority is unaffected by brand)", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
+ writeFileSync(join(dir, ".gittensory.yml"), "gate:\n enabled: false\n duplicates: block\n"); // legacy global
+ mkdirSync(join(dir, "repo"));
+ writeFileSync(join(dir, "repo", ".loopover.yml"), "gate:\n enabled: true\n"); // new-brand per-repo
+ const reader = makeLocalManifestReader(dir);
+ const manifest = parseFocusManifestContent(await readLocalManifestContent(reader!, "owner/repo"));
+ expect(manifest.gate.enabled).toBe(true); // per-repo (new-brand) still outranks the global layer
+ expect(manifest.gate.duplicates).toBe("block"); // deep-merge across LAYERS still applies once each layer's winner is picked
+ });
+});
+
describe("makeLocalManifestReader — shared base layer (#1959)", () => {
it("falls back to the shared base alone when neither a per-repo file nor a global default exists", async () => {
const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
diff --git a/test/unit/queue-2.test.ts b/test/unit/queue-2.test.ts
index ee5f70fe5d..3d2e73c8c4 100644
--- a/test/unit/queue-2.test.ts
+++ b/test/unit/queue-2.test.ts
@@ -801,6 +801,10 @@ describe("queue processors", () => {
if (url === "https://raw.githubusercontent.com/JSONbored/gittensory/HEAD/.gittensory.yml") {
return new Response("review:\n impact_map: true\n");
}
+ // Real GitHub raw-content 404s for every other manifest candidate (incl. the new-brand `.loopover.*`
+ // candidates tried first, #4773) -- without this, Response.json({}) below would 200 the first candidate
+ // tried and mask the review.impact_map config crafted above.
+ if (url.startsWith("https://raw.githubusercontent.com/")) return new Response("not found", { status: 404 });
return Response.json({});
});
diff --git a/test/unit/queue-4.test.ts b/test/unit/queue-4.test.ts
index 3484dd57ca..37fb0f4a8b 100644
--- a/test/unit/queue-4.test.ts
+++ b/test/unit/queue-4.test.ts
@@ -4190,6 +4190,10 @@ describe("queue processors", () => {
if (url === "https://raw.githubusercontent.com/JSONbored/gittensory/HEAD/.gittensory.yml") {
return new Response("review:\n tone: Keep findings terse and skip pleasantries\n");
}
+ // Real GitHub raw-content 404s for every other manifest candidate (incl. the new-brand `.loopover.*`
+ // candidates tried first, #4773) -- without this, Response.json({}) below would 200 the first candidate
+ // tried and mask the review.tone config crafted above.
+ if (url.startsWith("https://raw.githubusercontent.com/")) return new Response("not found", { status: 404 });
return Response.json({});
});
@@ -4278,6 +4282,10 @@ describe("queue processors", () => {
if (url === "https://raw.githubusercontent.com/JSONbored/gittensory/HEAD/.gittensory.yml") {
return new Response('review:\n exclude_paths:\n - "**/*.generated.ts"\n');
}
+ // Real GitHub raw-content 404s for every other manifest candidate (incl. the new-brand `.loopover.*`
+ // candidates tried first, #4773) -- without this, the generic Response.json({}) catch-all below would
+ // otherwise 200 the FIRST candidate tried and mask the exclude_paths config crafted above.
+ if (url.startsWith("https://raw.githubusercontent.com/")) return new Response("not found", { status: 404 });
return Response.json({});
});
@@ -4825,6 +4833,10 @@ describe("queue processors", () => {
if (url === "https://raw.githubusercontent.com/JSONbored/gittensory/HEAD/.gittensory.yml") {
return new Response("review:\n inline_comments: true\n finding_categories: true\n");
}
+ // Real GitHub raw-content 404s for every other manifest candidate (incl. the new-brand `.loopover.*`
+ // candidates tried first, #4773) -- without this, Response.json({}) below would 200 the first candidate
+ // tried and mask the inline_comments/finding_categories config crafted above.
+ if (url.startsWith("https://raw.githubusercontent.com/")) return new Response("not found", { status: 404 });
if (url.includes("/pulls/8/files"))
return Response.json([{ filename: "src/db.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@ -1,1 +1,2 @@\n ctx\n+export const ok = true;" }]);
if (url.endsWith("/pulls/8")) return Response.json({ number: 8, title: "Add query helper", state: "open", user: { login: "contributor" }, head: { sha: "a8" }, labels: [], body: "Closes #1", mergeable_state: "clean" });
@@ -4950,6 +4962,10 @@ describe("queue processors", () => {
// NOTE: the manifest key is camelCase `fixHandoff` (unlike snake-case `finding_categories`) — see focus-manifest parse.
return new Response("review:\n inline_comments: true\n fixHandoff: true\n");
}
+ // Real GitHub raw-content 404s for every other manifest candidate (incl. the new-brand `.loopover.*`
+ // candidates tried first, #4773) -- without this, Response.json({}) below would 200 the first candidate
+ // tried and mask the inline_comments/fixHandoff config crafted above.
+ if (url.startsWith("https://raw.githubusercontent.com/")) return new Response("not found", { status: 404 });
if (url.includes("/pulls/9/files"))
return Response.json([{ filename: "src/db.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@ -1,1 +1,2 @@\n ctx\n+export const ok = true;" }]);
if (url.endsWith("/pulls/9")) return Response.json({ number: 9, title: "Add query helper", state: "open", user: { login: "contributor" }, head: { sha: "a9" }, labels: [], body: "Closes #1", mergeable_state: "clean" });
diff --git a/test/unit/selfhost-config-examples.test.ts b/test/unit/selfhost-config-examples.test.ts
index fd625e2d86..06bc01fda9 100644
--- a/test/unit/selfhost-config-examples.test.ts
+++ b/test/unit/selfhost-config-examples.test.ts
@@ -98,3 +98,25 @@ describe("all three examples together demonstrate the documented shared-base ove
expect(manifest.settings.contributorOpenPrCap).toBeNull(); // repo-override's explicit null still clears global's 3
});
});
+
+describe("the same shipped examples parse and merge identically under the new-brand .loopover.yml filename (#4773)", () => {
+ it("merges shared → global → per-repo the same way when every layer is saved as .loopover.yml instead of .gittensory.yml", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "gt-example-config-"));
+ mkdirSync(join(dir, "_shared"));
+ writeFileSync(join(dir, "_shared", ".loopover.yml"), readExample("shared.gittensory.yml"));
+ writeFileSync(join(dir, ".loopover.yml"), readExample("global.gittensory.yml"));
+ mkdirSync(join(dir, "owner__repo"));
+ writeFileSync(join(dir, "owner__repo", ".loopover.yml"), readExample("repo-override.gittensory.yml"));
+ const reader = makeLocalManifestReader(dir)!;
+ const result = await reader("owner/repo");
+ const content = typeof result === "string" ? result : result!.content!;
+ const manifest = parseFocusManifestContent(content);
+
+ // Identical assertions to the .gittensory.yml-named version above — only the on-disk filename changed.
+ expect(manifest.review.tone).toBe("friendly-terse");
+ expect(manifest.gate.duplicates).toBe("block");
+ expect(manifest.gate.enabled).toBe(true);
+ expect(manifest.wantedPaths).toEqual(["src/**"]);
+ expect(manifest.settings.contributorOpenPrCap).toBeNull();
+ });
+});