Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 e45566d0e8 fix(skills): extend CRLF fence handling to stripFrontmatter + derived-manifest name parse
Same root cause as the parent commit, two sibling LF-only fence regexes:
- src/core/skill-brain-first.ts FRONTMATTER_RE: on CRLF skills,
  stripFrontmatter was a no-op, so a frontmatter 'tools: [web_search]'
  leaked into the body scan and false-flagged the skill (the exact F6
  case the code comments guard against). Regression test added.
- src/core/skill-manifest.ts parseSkillName: derived-manifest mode
  (no manifest.json) fell back to the dirname for CRLF skills instead
  of the frontmatter name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:06:35 -07:00
6f52ee8321 fix(skills): handle CRLF skill frontmatter in health checks (takeover of #2444)
Windows SKILL.md files with CRLF line endings were invisible to the
frontmatter fence + triggers parsing in skill-frontmatter.ts and
check-resolvable.ts (LF-only /^---\n.../ fences), so CRLF skills were
silently skipped by health checks and MECE overlap detection.

Salvaged from #2444 with two repairs:
- The original blockRe rewrite used raw \s / \t inside a template
  literal, which degrades \s to a literal "s" (so `triggers: ` with
  trailing whitespace stopped matching). Kept doubled escapes
  (\\s*\\r?\\n ...) and added a regression test for the trailing-
  whitespace case.
- Dropped the undisclosed write-through.ts Windows casing/rename
  changes; that work belongs in its own described PR.

Also adds the missing skill-optimizer route to skills/RESOLVER.md
(disclosed in the original PR) and regenerates the llms bundles.

Co-authored-by: Cjaewon0708 <Cjaewon0708@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:41:12 -07:00
8 changed files with 100 additions and 9 deletions
+1
View File
@@ -1411,6 +1411,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "get more out of gbrain", "is my brain set up right", "weekly brain checkup", "advise me on my brain", "gbrain advisor" | `skills/gbrain-advisor/SKILL.md` |
| Save or load reports | `skills/reports/SKILL.md` |
| "Create a skill", "improve this skill" | `skills/skill-creator/SKILL.md` |
| "optimize this skill", "tune the skill against the benchmark", "make the skill better", "run skillopt" | `skills/skill-optimizer/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper" | `skills/skillify/SKILL.md` |
| "Compress my resolver", "AGENTS.md too large", "RESOLVER.md too big", "functional area dispatcher", "shrink routing table" | `skills/functional-area-resolver/SKILL.md` |
| "Is gbrain healthy?", morning health check, skillpack-check | `skills/skillpack-check/SKILL.md` |
+1
View File
@@ -60,6 +60,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "get more out of gbrain", "is my brain set up right", "weekly brain checkup", "advise me on my brain", "gbrain advisor" | `skills/gbrain-advisor/SKILL.md` |
| Save or load reports | `skills/reports/SKILL.md` |
| "Create a skill", "improve this skill" | `skills/skill-creator/SKILL.md` |
| "optimize this skill", "tune the skill against the benchmark", "make the skill better", "run skillopt" | `skills/skill-optimizer/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper" | `skills/skillify/SKILL.md` |
| "Compress my resolver", "AGENTS.md too large", "RESOLVER.md too big", "functional area dispatcher", "shrink routing table" | `skills/functional-area-resolver/SKILL.md` |
| "Is gbrain healthy?", morning health check, skillpack-check | `skills/skillpack-check/SKILL.md` |
+3 -3
View File
@@ -219,13 +219,13 @@ export function parseResolverEntries(resolverContent: string): ResolverEntry[] {
/** Simple YAML frontmatter parser — extracts triggers array if present. */
function extractTriggers(skillContent: string): string[] {
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
const fmMatch = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) return [];
const fm = fmMatch[1];
const triggersMatch = fm.match(/^triggers:\s*\n((?:\s+-\s+.+\n?)*)/m);
const triggersMatch = fm.match(/^triggers:\s*\r?\n((?:\s+-\s+.+\r?\n?)*)/m);
if (!triggersMatch) return [];
return triggersMatch[1]
.split('\n')
.split(/\r?\n/)
.map(l => l.replace(/^\s+-\s+/, '').replace(/^["']|["']$/g, '').trim())
.filter(Boolean);
}
+4 -2
View File
@@ -162,9 +162,11 @@ export const PHASE_HEADING_RE = /^##+\s*(?:Phase\s*1|Step\s*0)\b[^\n]*brain/im;
/**
* Frontmatter fence regex used by body extraction. Conservative match:
* leading `---\n` through the next `\n---` (greedy stop). Matches the
* shape `parseSkillFrontmatter` already accepts.
* shape `parseSkillFrontmatter` already accepts, including CRLF fences —
* if this stayed LF-only, a CRLF skill's `tools: [web_search]` frontmatter
* would survive into the body scan and false-flag the skill (F6).
*/
const FRONTMATTER_RE = /^---\n[\s\S]*?\n---\n?/;
const FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
// ---------------------------------------------------------------------------
// Hardcoded EXEMPT_SKILLS (CMT1 — replaces the dropped upgrade migration)
+3 -3
View File
@@ -82,7 +82,7 @@ export interface ParsedFrontmatter {
* `readFileSync(path, 'utf-8')` at the boundary.
*/
export function parseSkillFrontmatter(content: string): ParsedFrontmatter | null {
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) return null;
const raw = fmMatch[1];
const out: ParsedFrontmatter = { raw };
@@ -139,11 +139,11 @@ function parseArrayField(raw: string, field: string): string[] | undefined {
.filter(Boolean);
}
// Block form: `field:` + indented `- value` lines on subsequent lines.
const blockRe = new RegExp(`^${field}:\\s*\\n((?:[ \\t]+-[ \\t]+[^\\n]+\\n?)+)`, 'm');
const blockRe = new RegExp(`^${field}:\\s*\\r?\\n((?:[ \\t]+-[ \\t]+[^\\r\\n]+\\r?\\n?)+)`, 'm');
const blockMatch = raw.match(blockRe);
if (blockMatch) {
return blockMatch[1]
.split('\n')
.split(/\r?\n/)
.map(l => l.replace(/^[ \t]+-[ \t]+/, '').replace(/^["']|["']$/g, '').trim())
.filter(Boolean);
}
+1 -1
View File
@@ -47,7 +47,7 @@ export interface ManifestLoadResult {
function parseSkillName(skillMdPath: string): string | null {
try {
const content = readFileSync(skillMdPath, 'utf-8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) return null;
const fm = fmMatch[1];
// Match `name: foo` or `name: "foo"` or `name: 'foo'`
+43
View File
@@ -382,6 +382,49 @@ describe("DRY detection — checkResolvable", () => {
});
});
describe("CRLF frontmatter — checkResolvable", () => {
let dir: string;
afterEachCleanup(() => dir && rmSync(dir, { recursive: true, force: true }));
test("extracts triggers from Windows CRLF skill frontmatter", () => {
dir = mkdtempSync(join(tmpdir(), "gbrain-crlf-"));
writeFileSync(
join(dir, "RESOLVER.md"),
'## Test\n| Trigger | Skill |\n|-----|-----|\n| "one" | `skills/one/SKILL.md` |\n| "two" | `skills/two/SKILL.md` |\n',
);
writeFileSync(
join(dir, "manifest.json"),
JSON.stringify({
skills: [
{ name: "one", path: "one/SKILL.md" },
{ name: "two", path: "two/SKILL.md" },
],
}, null, 2),
);
for (const name of ["one", "two"]) {
mkdirSync(join(dir, name), { recursive: true });
writeFileSync(
join(dir, name, "SKILL.md"),
[
"---",
`name: ${name}`,
"description: test",
"triggers:",
" - shared windows trigger",
"---",
`# ${name}`,
].join("\r\n"),
);
}
const report = checkResolvable(dir);
// Both skills declare the same frontmatter trigger; with LF-only
// parsing the CRLF triggers were invisible and the overlap went
// undetected.
expect(report.issues.filter(i => i.type === "mece_overlap")).toHaveLength(1);
});
});
describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
test("repo skills/ pass check-resolvable cleanly (zero errors AND zero warnings)", () => {
// The v0.22.4 (Part A) contract was zero warnings AND zero errors.
+44
View File
@@ -103,6 +103,36 @@ describe('parseSkillFrontmatter', () => {
expect(fm!.triggers).toEqual(['foo', 'bar']);
});
test('parses frontmatter fences and block arrays with CRLF line endings', () => {
const content = [
'---',
'name: windows-skill',
'writes_to:',
' - people/',
'tools:',
' - search',
'triggers:',
' - windows trigger',
'---',
'# Windows Skill',
].join('\r\n');
const fm = parseSkillFrontmatter(content);
expect(fm).not.toBeNull();
expect(fm!.name).toBe('windows-skill');
expect(fm!.writes_to).toEqual(['people/']);
expect(fm!.tools).toEqual(['search']);
expect(fm!.triggers).toEqual(['windows trigger']);
});
test('block arrays still tolerate trailing whitespace after the field colon', () => {
// Regression guard: the CRLF blockRe must keep `\\s*` as a real
// whitespace class inside the template literal (a raw `\s` would turn
// it into a literal "s" and silently break `triggers: ` + spaces).
const content = '---\nname: x\ntriggers: \n - foo\n---\n';
const fm = parseSkillFrontmatter(content);
expect(fm!.triggers).toEqual(['foo']);
});
test('canonical brain_first: exempt populates the typed field', () => {
const content = '---\nname: x\nbrain_first: exempt\n---\n';
const fm = parseSkillFrontmatter(content);
@@ -197,6 +227,20 @@ describe('stripFrontmatter', () => {
// Body should NOT contain `web_search` (it was in the stripped frontmatter).
expect(body.includes('web_search')).toBe(false);
});
test('F6 holds for CRLF fences — frontmatter tools do not leak into the body scan', () => {
const content = [
'---',
'name: x',
'tools: [web_search]',
'---',
'',
'# x',
'Body says gbrain search comes first.',
].join('\r\n');
const body = stripFrontmatter(content);
expect(body.includes('web_search')).toBe(false);
});
});
describe('offset helpers', () => {