Skip to content

Commit ea7a4c0

Browse files
feat(selfhost): let a review skill opt out via enabled: false frontmatter (#2590)
* feat(selfhost): let a review skill opt out via `enabled: false` frontmatter Add isReviewSkillEnabled(text): a self-host review skill can set `enabled: false` (or no/off/0) in its frontmatter to be omitted from the review context, so an operator can turn a rubric off without deleting the file. The local review-context reader skips disabled skills. Fully backward-compatible: a skill without an `enabled` key (every existing one) stays enabled, so behavior is unchanged unless the directive is explicitly set. Cover the parse vocabulary and the reader-level omission. * fix(selfhost): ignore YAML inline comment on review-skill enabled directive `enabled: true # note` captured the whole value tail (`true # note`), failed the truthy test, and wrongly disabled the skill. Strip a trailing ` # …` comment before matching so an inline-commented directive reads as its bare value.
1 parent 0e85cee commit ea7a4c0

2 files changed

Lines changed: 46 additions & 4 deletions

File tree

src/selfhost/private-config.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,22 @@ export function parseReviewSkill(filename: string, text: string): RepoReviewSkil
110110
return { name, when, body };
111111
}
112112

113+
/** True unless a skill's frontmatter explicitly disables it with `enabled: false` (or `no`/`off`/`0`). Absent or
114+
* truthy `enabled` keeps the skill, so existing skills are unaffected — this only lets an operator turn a rubric
115+
* OFF without deleting the file. Truthy vocabulary matches the codebase flag convention. (#review-skills) */
116+
export function isReviewSkillEnabled(text: string): boolean {
117+
const head = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(text)?.[1] ?? "";
118+
// Drop a YAML inline comment (` # …`) before matching, so `enabled: true # explicit` reads as `true`, not
119+
// `true # explicit` (which would fail the truthy test and wrongly disable the skill).
120+
const raw = /(?:^|\n)enabled:\s*(.+)/.exec(head)?.[1]?.replace(/\s+#.*$/, "").trim().replace(/^["']|["']$/g, "");
121+
return raw === undefined ? true : /^(1|true|yes|on)$/i.test(raw);
122+
}
123+
113124
/** Build the container-local review-context reader over GITTENSORY_REPO_CONFIG_DIR, or null when the dir is unset. Per
114125
* repo (first existing folder wins) reads `review/AGENTS.md` (Codex) or `review/CLAUDE.md` (Claude Code) as the
115-
* guide + every `review/skills/*.md` rubric module, sorted. Missing files/dir degrade to nulls/empty; a per-file
116-
* read error skips that file. (#review-skills) */
126+
* guide + every `review/skills/*.md` rubric module, sorted. A skill whose frontmatter sets `enabled: false` is
127+
* omitted (turned off without deleting the file). Missing files/dir degrade to nulls/empty; a per-file read
128+
* error skips that file. (#review-skills) */
117129
export function makeLocalReviewContextReader(dir: string | undefined): RepoReviewContextReader | null {
118130
const trimmed = (dir ?? "").trim();
119131
if (!trimmed) return null;
@@ -135,7 +147,9 @@ export function makeLocalReviewContextReader(dir: string | undefined): RepoRevie
135147
const entries = (await readdir(resolve(abs, "skills"))).filter((f) => f.toLowerCase().endsWith(".md")).sort();
136148
for (const f of entries) {
137149
try {
138-
skills.push(parseReviewSkill(f, await readFile(resolve(abs, "skills", f), "utf8")));
150+
const text = await readFile(resolve(abs, "skills", f), "utf8");
151+
if (!isReviewSkillEnabled(text)) continue; // `enabled: false` frontmatter disables a skill without deleting it
152+
skills.push(parseReviewSkill(f, text));
139153
} catch {
140154
// unreadable skill file → skip it
141155
}

test/unit/private-config.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { dirname, join } from "node:path";
44
import { describe, expect, it } from "vitest";
5-
import { GLOBAL_CONFIG_CANDIDATES, localConfigCandidates, makeLocalManifestReader, makeLocalReviewContextReader, parseReviewSkill } from "../../src/selfhost/private-config";
5+
import { GLOBAL_CONFIG_CANDIDATES, isReviewSkillEnabled, localConfigCandidates, makeLocalManifestReader, makeLocalReviewContextReader, parseReviewSkill } from "../../src/selfhost/private-config";
66
import { loadRepoReviewContext, setLocalReviewContextReader } from "../../src/signals/focus-manifest-loader";
77

88
describe("localConfigCandidates (container-private config paths)", () => {
@@ -125,6 +125,24 @@ describe("parseReviewSkill (#review-skills)", () => {
125125
});
126126
});
127127

128+
describe("isReviewSkillEnabled (#review-skills)", () => {
129+
it("keeps a skill by default and honors an explicit enabled directive", () => {
130+
expect(isReviewSkillEnabled("no frontmatter at all")).toBe(true); // no frontmatter → enabled
131+
expect(isReviewSkillEnabled("---\nname: x\n---\nbody")).toBe(true); // frontmatter without `enabled` → enabled
132+
expect(isReviewSkillEnabled("---\nenabled: true\n---\nbody")).toBe(true);
133+
expect(isReviewSkillEnabled('---\nenabled: "on"\n---\nbody')).toBe(true); // quoted truthy stripped
134+
expect(isReviewSkillEnabled("---\nenabled: false\n---\nbody")).toBe(false);
135+
expect(isReviewSkillEnabled("---\nname: x\nenabled: no\n---\nbody")).toBe(false);
136+
expect(isReviewSkillEnabled("---\nenabled: 0\n---\nbody")).toBe(false); // any non-truthy value disables
137+
});
138+
it("ignores a YAML inline comment on the enabled directive", () => {
139+
// A trailing ` # …` is a YAML comment, not part of the value — it must not flip a truthy directive to disabled.
140+
expect(isReviewSkillEnabled("---\nenabled: true # temporarily explicit\n---\nbody")).toBe(true);
141+
expect(isReviewSkillEnabled('---\nenabled: "on" # keep the rubric on\n---\nbody')).toBe(true); // comment after quoted value
142+
expect(isReviewSkillEnabled("---\nenabled: false # parked for now\n---\nbody")).toBe(false); // still disables
143+
});
144+
});
145+
128146
describe("makeLocalReviewContextReader (#review-skills)", () => {
129147
it("returns null when the dir is unset/blank", () => {
130148
expect(makeLocalReviewContextReader(undefined)).toBeNull();
@@ -147,6 +165,16 @@ describe("makeLocalReviewContextReader (#review-skills)", () => {
147165
expect(ctx.skills.map((s) => s.name)).toEqual(["a-first", "second"]); // sorted by filename; .txt ignored
148166
});
149167

168+
it("omits a skill whose frontmatter sets enabled: false", async () => {
169+
const dir = mkdtempSync(join(tmpdir(), "gt-review-"));
170+
const rev = join(dir, "jsonbored__gittensory", "review");
171+
mkdirSync(join(rev, "skills"), { recursive: true });
172+
writeFileSync(join(rev, "skills", "a-active.md"), "---\nname: active\nwhen: always\n---\nActive rubric.\n");
173+
writeFileSync(join(rev, "skills", "b-disabled.md"), "---\nname: disabled\nenabled: false\n---\nParked rubric.\n");
174+
const ctx = await makeLocalReviewContextReader(dir)!("JSONbored/gittensory");
175+
expect(ctx.skills.map((s) => s.name)).toEqual(["active"]); // the disabled skill is dropped, not deleted
176+
});
177+
150178
it("falls back to legacy CLAUDE.md in the bare repo-name folder; returns empty for a missing or invalid repo", async () => {
151179
const dir = mkdtempSync(join(tmpdir(), "gt-review-"));
152180
mkdirSync(join(dir, "metagraphed", "review"), { recursive: true });

0 commit comments

Comments
 (0)