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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gittensory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ gate:
# relatedWork: false # linkedIssue | relatedWork | reviewLoad (Change scope) |
# openPrQueue: false # validationEvidence (Validation posture) | openPrQueue (Contributor workload) | contributorContext | gateResult

# Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a
# PR generating AGENTS.md/CLAUDE.md from this repo's own profile.
# repoDocGeneration:
# enabled: true # default false — must be explicitly turned on per repo
# scope: [agents] # agents | skills — which generated file types are in play
# allowOverwriteExisting: false # required before Gittensory will touch an existing hand-maintained file

publicNotes:
- Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows.
- Focused control-panel UI changes are welcome when they use live API data or honest empty/error states and tie to safety, release readiness, or operator-facing analytics.
Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@ Config as code (`.gittensory.yml`) — every repository setting is controllable
`fields: { <row>: false }` to show/hide individual panel rows (`linkedIssue`, `relatedWork`, `reviewLoad`,
`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;
`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
worked example. Resolved once in `resolveRepositorySettings`, so the whole app honours the file.
Expand Down
7 changes: 7 additions & 0 deletions src/config/gittensory-repo-focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ gate:
# relatedWork: false # linkedIssue | relatedWork | reviewLoad (Change scope) |
# openPrQueue: false # validationEvidence (Validation posture) | openPrQueue (Contributor workload) | contributorContext | gateResult

# Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a
# PR generating AGENTS.md/CLAUDE.md from this repo's own profile.
# repoDocGeneration:
# enabled: true # default false — must be explicitly turned on per repo
# scope: [agents] # agents | skills — which generated file types are in play
# allowOverwriteExisting: false # required before Gittensory will touch an existing hand-maintained file

publicNotes:
- Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows.
- Focused control-panel UI changes are welcome when they use live API data or honest empty/error states and tie to safety, release readiness, or operator-facing analytics.
Expand Down
25 changes: 22 additions & 3 deletions src/github/repo-doc-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@
// generated section is unchanged gets NO pull request at all (no-op), a repo whose generated section changed
// gets a pull request with everything outside the markers preserved byte-for-byte, and a repo whose marker
// block is missing or malformed gets neither a silent overwrite nor a guess -- just a reported reason.
//
// CONFIG-AS-CODE GATE (#3002): this whole feature is opt-in per repo via `.gittensory.yml repoDocGeneration:`
// (src/signals/focus-manifest.ts) -- a manifest-only surface with no DB-backed counterpart, since there is no
// dashboard toggle for it. `enabled`/`scope` are checked BEFORE any profile extraction or GitHub call (the
// common case is disabled, so this must be cheap); `allowOverwriteExisting` is checked later, once refresh
// reports `manual-review-required` (the "this file looks hand-maintained" signal), and lets that specific case
// proceed as a fresh wholesale generate instead of skipping.
import { githubErrorStatus, withInstallationTokenRetry } from "./app";
import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "./client";
import { getRepository } from "../db/repositories";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { extractRepoProfile } from "../review/repo-profile";
import { REPO_DOC_MARKERS, renderRepoDocContent } from "../review/repo-doc-render";
import { refreshGeneratedDoc } from "../review/generated-doc-refresh";
Expand Down Expand Up @@ -96,7 +104,7 @@ Every fact above was read directly from this repository, not templated or guesse

## Opting out

Disable repo-doc generation for this repository, or simply close this pull request -- no further action is taken until it is re-enabled.
Set \`repoDocGeneration.enabled: false\` in this repository's \`.gittensory.yml\` (or simply close this pull request) -- no further action is taken until it is re-enabled.
`;
}

Expand All @@ -117,6 +125,10 @@ export async function openRepoDocPullRequest(env: Env, repoFullName: string, mod
const repository = await getRepository(env, repoFullName);
if (!repository?.installationId) return { opened: false, reason: "repository is not installed" };

const manifest = await loadRepoFocusManifest(env, repoFullName);
if (!manifest.repoDocGeneration.enabled) return { opened: false, reason: "repo-doc generation is not enabled for this repository (.gittensory.yml repoDocGeneration.enabled)" };
if (!manifest.repoDocGeneration.scope.includes("agents")) return { opened: false, reason: 'repo-doc generation scope does not include "agents" for this repository (.gittensory.yml repoDocGeneration.scope)' };

const profile = await extractRepoProfile(env, repoFullName);
if (!profile.present) return { opened: false, reason: profile.reason };
const generatedSection = renderRepoDocContent(profile);
Expand All @@ -141,9 +153,16 @@ export async function openRepoDocPullRequest(env: Env, repoFullName: string, mod
if (existing) return { opened: true, reused: true, pullNumber: existing.number, url: existing.html_url, claudeMode: "unknown" };

const currentAgentsContent = await fetchExistingAgentsMdContent(octokit, owner, repo, baseBranch);
const refresh = refreshGeneratedDoc(currentAgentsContent, generatedSection, REPO_DOC_MARKERS);
let refresh = refreshGeneratedDoc(currentAgentsContent, generatedSection, REPO_DOC_MARKERS);
if (refresh.action === "manual-review-required") {
// "manual-review-required" is generated-doc-refresh.ts's proxy for "this file looks hand-maintained,
// not machine-generated" (no recognizable marker block). #3002's allowOverwriteExisting is the explicit
// opt-in required before that content is discarded in favor of a fresh generate -- without it, stay
// skipped exactly as #3004 already behaves.
if (!manifest.repoDocGeneration.allowOverwriteExisting) return { opened: false, reason: `AGENTS.md needs manual review before it can be refreshed: ${refresh.reason}` };
refresh = { action: "generate", content: generatedSection };
}
if (refresh.action === "no-change") return { opened: false, reason: "no meaningful change since the last generated AGENTS.md" };
if (refresh.action === "manual-review-required") return { opened: false, reason: `AGENTS.md needs manual review before it can be refreshed: ${refresh.reason}` };
const agentsContent = refresh.content;

const branchInfo = await octokit.request("GET /repos/{owner}/{repo}/branches/{branch}", { owner, repo, branch: baseBranch });
Expand Down
3 changes: 2 additions & 1 deletion src/signals/focus-manifest-loader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { listSignalSnapshots, persistSignalSnapshot } from "../db/repositories";
import type { JsonValue } from "../types";
import { nowIso } from "../utils/json";
import { contentLaneConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, reviewConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest";
import { contentLaneConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, repoDocGenerationConfigToJson, reviewConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest";
import { GITTENSORY_REPO_FOCUS_MANIFEST_YAML, resolveGittensorySelfRepoFullName } from "../config/gittensory-repo-focus-manifest";

export const REPO_FOCUS_MANIFEST_SIGNAL = "repo-focus-manifest";
Expand Down Expand Up @@ -283,6 +283,7 @@ function manifestToJson(manifest: FocusManifest): Record<string, JsonValue> {
review: reviewConfigToJson(manifest.review),
features: featuresConfigToJson(manifest.features),
contentLane: contentLaneConfigToJson(manifest.contentLane),
repoDocGeneration: repoDocGenerationConfigToJson(manifest.repoDocGeneration),
};
}

Expand Down
83 changes: 82 additions & 1 deletion src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,28 @@ export type FocusManifestContentLaneConfig = {
validatorId: string | null;
};

/** Which generated-file types the repo-doc generation roadmap (#2993) is allowed to touch for a repo.
* "agents" covers AGENTS.md/CLAUDE.md (#3000/#3004); "skills" covers generated Claude Code/Codex skill
* files once that generator lands (#3001) -- listed here now so a maintainer can opt in ahead of time. */
export type FocusManifestRepoDocGenerationScope = "agents" | "skills";

/**
* Per-repo opt-in for the repo-doc generation roadmap (#2993/#3002), declared as code under
* `repoDocGeneration:`. Purely a `.gittensory.yml` surface -- there is no DB-backed dashboard counterpart,
* so precedence is simply "the manifest value, or the default below when unset" (no DB layer to overlay).
* Defaults to fully disabled: a repo with no `repoDocGeneration:` block, or an explicit `enabled: false`,
* is never touched by the generator. `allowOverwriteExisting` is a SEPARATE opt-in specifically for a repo
* that already has a hand-maintained AGENTS.md/CLAUDE.md (no recognizable generated-content marker block,
* per generated-doc-refresh.ts's `manual-review-required` outcome) -- without it, that repo is left alone
* rather than proposed for a wholesale overwrite, even when `enabled` is true.
*/
export type FocusManifestRepoDocGenerationConfig = {
present: boolean;
enabled: boolean;
scope: FocusManifestRepoDocGenerationScope[];
allowOverwriteExisting: boolean;
};

/**
* Generic repository-settings override declared in `.gittensory.yml` under `settings:`. A partial of
* {@link RepositorySettings} — every behaviour a maintainer can toggle in the dashboard can be set here
Expand Down Expand Up @@ -336,6 +358,7 @@ export type FocusManifest = {
review: FocusManifestReviewConfig;
features: FocusManifestFeaturesConfig;
contentLane: FocusManifestContentLaneConfig;
repoDocGeneration: FocusManifestRepoDocGenerationConfig;
warnings: string[];
};

Expand Down Expand Up @@ -429,6 +452,13 @@ const EMPTY_CONTENT_LANE_CONFIG: FocusManifestContentLaneConfig = {
validatorId: null,
};

const EMPTY_REPO_DOC_GENERATION_CONFIG: FocusManifestRepoDocGenerationConfig = {
present: false,
enabled: false,
scope: ["agents"],
allowOverwriteExisting: false,
};

const EMPTY_MANIFEST: FocusManifest = {
present: false,
source: "none",
Expand All @@ -444,6 +474,7 @@ const EMPTY_MANIFEST: FocusManifest = {
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, securityFocus: null, inlineComments: null, pathInstructions: [], instructions: null, excludePaths: [], preMergeChecks: [] },
features: { ...EMPTY_FEATURES_CONFIG },
contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
warnings: [],
};

Expand Down Expand Up @@ -473,6 +504,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, securityFocus: null, inlineComments: null, pathInstructions: [], instructions: null, excludePaths: [], preMergeChecks: [] },
features: { ...EMPTY_FEATURES_CONFIG },
contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
};
}

Expand Down Expand Up @@ -927,6 +959,53 @@ export function contentLaneConfigToJson(contentLane: FocusManifestContentLaneCon
return out;
}

const REPO_DOC_GENERATION_SCOPES: readonly FocusManifestRepoDocGenerationScope[] = ["agents", "skills"];

/** `undefined`/`null` (key omitted) falls back to the default scope; a non-list value is a genuine type error
* and ALSO falls back to the default (rather than emptying it out, which would silently disable an otherwise
* `enabled: true` config); an actual list -- even an explicitly empty one, or one where every entry is
* invalid -- is respected as "nothing in scope", since that is a deliberate, well-typed value. */
function parseRepoDocGenerationScope(value: JsonValue | undefined, warnings: string[]): FocusManifestRepoDocGenerationScope[] {
if (value === undefined || value === null) return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope];
if (!Array.isArray(value)) {
warnings.push('Manifest field "repoDocGeneration.scope" must be a list; falling back to the default scope.');
return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope];
}
const raw = normalizeStringList(value, "repoDocGeneration.scope", warnings);
return raw.filter((entry): entry is FocusManifestRepoDocGenerationScope => {
if ((REPO_DOC_GENERATION_SCOPES as readonly string[]).includes(entry)) return true;
warnings.push(`Manifest field "repoDocGeneration.scope" has an unrecognized entry "${entry}"; ignoring it.`);
return false;
});
}

/**
* Parse the optional `repoDocGeneration:` mapping (#3002). Unlike `gate:`/`settings:`, every field here has a
* concrete default rather than a null "unconfigured" sentinel -- there is no DB layer to overlay onto, so the
* parsed value (or the default, when a key is omitted) IS the effective value. An explicitly empty `scope: []`
* is honored as "nothing in scope" (not coerced back to the default); only an OMITTED `scope` key falls back to
* `["agents"]`, mirroring how `undefined`/`null` mean "unset" everywhere else in this file.
*/
function parseRepoDocGenerationConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestRepoDocGenerationConfig {
if (value === undefined || value === null) return { ...EMPTY_REPO_DOC_GENERATION_CONFIG };
if (typeof value !== "object" || Array.isArray(value)) {
warnings.push('Manifest field "repoDocGeneration" must be a mapping; ignoring it.');
return { ...EMPTY_REPO_DOC_GENERATION_CONFIG };
}
const record = value as Record<string, JsonValue>;
const enabled = normalizeOptionalBoolean(record.enabled, "repoDocGeneration.enabled", warnings) ?? false;
const allowOverwriteExisting = normalizeOptionalBoolean(record.allowOverwriteExisting, "repoDocGeneration.allowOverwriteExisting", warnings) ?? false;
const scope = parseRepoDocGenerationScope(record.scope, warnings);
return { present: true, enabled, scope, allowOverwriteExisting };
}

/** Serialize a repoDocGeneration config back into the parse-compatible shape so a cached snapshot round-trips
* through {@link parseRepoDocGenerationConfig} unchanged. Returns null when nothing is configured. */
export function repoDocGenerationConfigToJson(config: FocusManifestRepoDocGenerationConfig): JsonValue {
if (!config.present) return null;
return { enabled: config.enabled, scope: config.scope, allowOverwriteExisting: config.allowOverwriteExisting };
}

function normalizeOptionalEnum<T extends string>(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null {
if (value === undefined || value === null) return null;
if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T;
Expand Down Expand Up @@ -1769,6 +1848,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource):
review: parseReviewConfig(record.review, warnings),
features: parseFeaturesConfig(record.features, warnings),
contentLane: parseContentLaneConfig(record.contentLane, warnings),
repoDocGeneration: parseRepoDocGenerationConfig(record.repoDocGeneration, warnings),
warnings,
};
if (
Expand All @@ -1783,7 +1863,8 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource):
Object.keys(manifest.settings).length === 0 &&
!manifest.review.present &&
!manifest.features.present &&
!manifest.contentLane.present
!manifest.contentLane.present &&
!manifest.repoDocGeneration.present
) {
warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals.");
manifest.present = false;
Expand Down
Loading
Loading