mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
* v0.37.1.0 feat: skill_brain_first doctor check + auto-fix + declarative opt-out Cathedral wave superseding PR #1206. Doctor now scans every SKILL.md for external-lookup tools (web_search / web_fetch / exa / perplexity / happenstance / crustdata / captain_api / firecrawl) and warns when the skill has no brain- first compliance signal. gbrain doctor --fix auto-inserts the canonical > **Convention:** see [conventions/brain-first.md](...) callout via the dry-fix.ts MISSING_RULE_PATTERNS extension (sharing safety gates with the existing REPLACE patterns). Motivated by the 2026-05-19 tweet-shield incident: cross-modal eval flagged Garry's Palantir tweet as risky because no model knew he built it, but the brain already had "designed the entire Finance product UI" and "150+ PSDs from April-December 2006." Static check catches authorship; v0.37+ runtime gate (filed in TODOS.md) closes the dispatch side. Key design decisions locked via /plan-eng-review + codex outside-voice review: - A1: frontmatter ships only brain_first: exempt (no required/n/a enum) - A2: snapshot+diff audit at ~/.gbrain/audit/skill-brain-first-YYYY-Www.jsonl with transition-only writes (stable brains = 0 lines/run) - A3: scaffold template pre-inserts callout; skillify check fails (exit 1) on external + no callout + no exempt - A4: position-relative gate is BODY-ONLY (frontmatter tools: [web_search] declaration doesn't false-flag the skill) - Q1: single pure analyzeSkillBrainFirst() helper consumed by 3 surfaces - CMT1: no upgrade migration — doctor surfaces hint, --fix applies via dry-fix safety gates (user stays in loop) - CMT2: dropped tools+writes_pages auto-exemption (was hiding mixed-class skills like idea-ingest/meeting-ingestion/data-research) Trio: VERSION + package.json + CHANGELOG aligned at 0.37.1.0. 56 unit cases + 12 E2E cases pass. 170 related existing tests pass unchanged. Self-dogfood: gbrain doctor against this repo's skills/ reports skill_brain_first: ok across 43 skills (compliant or exempt). functional-area-resolver and strategic-reading skills gained brain_first: exempt to validate the declarative opt-out in production code (both name perplexity in dispatcher prose without calling it). Co-Authored-By: garrytan-agents <noreply@github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update CLAUDE.md for v0.37.1.0 skill_brain_first wave Added Key Files entries for the four new modules: - src/core/skill-frontmatter.ts (shared parser) - src/core/skill-brain-first.ts (analyzer + FORMERLY_HARDCODED_EXEMPT) - src/core/skill-fix-gates.ts (extracted safety primitives) - src/core/audit-skill-brain-first.ts (snapshot+diff JSONL) Extended existing entries: - src/core/filing-audit.ts: rewired to shared parser - src/core/dry-fix.ts: MISSING_RULE_PATTERNS INSERT pattern type - src/commands/doctor.ts: skill_brain_first check + tweet-shield framing - src/commands/skillify-check.ts: required item 12 + scaffold pre-insert Added test inventory entries: - test/skill-brain-first.test.ts (56 unit cases) - test/e2e/skill-brain-first.test.ts (12 E2E cases) Regenerated llms-full.txt via bun run build:llms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): skill_brain_first guard uses doctor --fast to skip engine connect CI run #76881161092 failed because scripts/check-skill-brain-first.sh invoked plain `gbrain doctor --json`, which routes through connectEngine(). With no ~/.gbrain/config.json present (CI's case — runner is bun-only, no brain init), connectEngine() exits 1 with "No brain configured." and emits zero stdout. The python parser sees an empty file and returns parse_error, failing the verify gate. Fix: pass --fast to doctor. --fast routes through runDoctor(null, ...) which runs the filesystem-only check set (resolver_health, skill_conformance, skill_brain_first) and emits the standard single-line JSON envelope the parser expects. skill_brain_first is filesystem-only by design (scans SKILL.md, no DB touch), so --fast is the correct knob, not a workaround. Verified by reproducing the CI failure mode locally with GBRAIN_HOME=/tmp/empty-... — gate now passes both with and without a configured brain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rebump v0.37.1.0 → v0.37.3.0 (queue collision with #1214) PR #1214 (brainstorm + lsd) claimed v0.37.1.0 concurrently with #1215. Skipping 0.37.2.0 leaves a buffer for #1214's adjacent slot. Trio (VERSION + package.json + CHANGELOG header + inline "To take advantage of v0.37.3.0" block) aligned at 0.37.3.0. No behavior changes — version metadata only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garrytan-agents <noreply@github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
241 lines
8.1 KiB
TypeScript
241 lines
8.1 KiB
TypeScript
/**
|
|
* filing-audit.ts — Check 6 of the skillify checklist (W3).
|
|
*
|
|
* For every skill that writes brain pages (`writes_pages: true`),
|
|
* verify that:
|
|
* 1. The skill declares a non-empty `writes_to: [dir, ...]` frontmatter.
|
|
* 2. Each directory in `writes_to:` is a valid filing target per
|
|
* `skills/_brain-filing-rules.json`. `sources/` is explicitly
|
|
* allowed (bulk data capture is a legitimate filing target).
|
|
*
|
|
* Important distinction: `writes_pages: true` is distinct from the
|
|
* pre-existing `mutating: true` field. `mutating:true` means "has
|
|
* side effects" (any side effect — cron, config, report write).
|
|
* `writes_pages:true` means "writes brain pages to a semantic
|
|
* directory." Cron/config/report-writer skills set `mutating:true`
|
|
* but NOT `writes_pages:true`, and so are correctly exempted from
|
|
* filing-audit noise.
|
|
*
|
|
* Current scope: declaration-level audit only (cheap, deterministic).
|
|
* A future release may add `filing-audit --pages` to walk brain pages
|
|
* and infer primary subject via LLM (catches real misfilings vs
|
|
* declarations); that is tracked as follow-up work, not in this scope.
|
|
*/
|
|
|
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { parseSkillFrontmatter } from './skill-frontmatter.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface FilingRule {
|
|
kind: string;
|
|
directory: string;
|
|
examples?: string[];
|
|
description?: string;
|
|
}
|
|
|
|
export interface FilingRulesDoc {
|
|
version: string;
|
|
companion?: string;
|
|
description?: string;
|
|
rules: FilingRule[];
|
|
sources_dir?: {
|
|
directory: string;
|
|
purpose: string;
|
|
not_for?: string[];
|
|
};
|
|
notes?: string[];
|
|
}
|
|
|
|
export interface FilingIssue {
|
|
type: 'filing_missing_writes_to' | 'filing_unknown_directory';
|
|
severity: 'warning';
|
|
skill: string;
|
|
directory?: string;
|
|
message: string;
|
|
action: string;
|
|
}
|
|
|
|
export interface FilingReport {
|
|
totalScanned: number;
|
|
writesPagesSkills: number;
|
|
issues: FilingIssue[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rules loader
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Load canonical filing rules from `skillsDir/_brain-filing-rules.json`.
|
|
* Returns null if the file is missing — filing-audit is a no-op until
|
|
* the rules doc is in place. Throws on malformed JSON so the caller
|
|
* surfaces a loud "rules doc is broken" signal instead of silently
|
|
* degrading.
|
|
*/
|
|
export function loadFilingRules(skillsDir: string): FilingRulesDoc | null {
|
|
const path = join(skillsDir, '_brain-filing-rules.json');
|
|
if (!existsSync(path)) return null;
|
|
const content = readFileSync(path, 'utf-8');
|
|
const parsed = JSON.parse(content);
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
throw new Error('_brain-filing-rules.json: top-level must be an object');
|
|
}
|
|
if (!Array.isArray(parsed.rules)) {
|
|
throw new Error('_brain-filing-rules.json: "rules" must be an array');
|
|
}
|
|
return parsed as FilingRulesDoc;
|
|
}
|
|
|
|
/**
|
|
* Return the canonical set of directories a skill is allowed to list in
|
|
* `writes_to:`. Includes every rule's directory plus the special
|
|
* `sources_dir` entry.
|
|
*/
|
|
export function allowedDirectories(rules: FilingRulesDoc): Set<string> {
|
|
const set = new Set<string>();
|
|
for (const r of rules.rules) set.add(normalizeDir(r.directory));
|
|
if (rules.sources_dir?.directory) set.add(normalizeDir(rules.sources_dir.directory));
|
|
return set;
|
|
}
|
|
|
|
function normalizeDir(dir: string): string {
|
|
// Accept `people`, `people/`, `/people`, `/people/` — normalize to
|
|
// `people/` so comparisons are consistent.
|
|
const trimmed = dir.trim().replace(/^\/+/, '').replace(/\/+$/, '');
|
|
return trimmed.length > 0 ? `${trimmed}/` : '';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Skill frontmatter parsing (minimal, tolerant)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Public surface preserved for back-compat: SkillFrontmatter remains a
|
|
* narrow alias here, but the underlying parser now lives in
|
|
* `skill-frontmatter.ts` (`parseSkillFrontmatter`). The wider
|
|
* `ParsedFrontmatter` type from that module is structurally compatible
|
|
* with this narrower one — every field on SkillFrontmatter is optional
|
|
* and present on ParsedFrontmatter.
|
|
*
|
|
* If you're writing new code, import `parseSkillFrontmatter` and
|
|
* `ParsedFrontmatter` from `./skill-frontmatter.ts` directly. This
|
|
* thin wrapper exists so existing filing-audit callers don't need to
|
|
* be touched.
|
|
*/
|
|
export interface SkillFrontmatter {
|
|
name?: string;
|
|
writes_pages?: boolean;
|
|
writes_to?: string[];
|
|
mutating?: boolean;
|
|
raw: string;
|
|
}
|
|
|
|
function parseFrontmatter(skillMdPath: string): SkillFrontmatter | null {
|
|
let content: string;
|
|
try {
|
|
content = readFileSync(skillMdPath, 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
const parsed = parseSkillFrontmatter(content);
|
|
if (!parsed) return null;
|
|
// Project the wider ParsedFrontmatter onto the narrower SkillFrontmatter
|
|
// shape filing-audit callers expect. Field order matches the original
|
|
// shape so tests that compare object keys via JSON.stringify stay stable.
|
|
return {
|
|
raw: parsed.raw,
|
|
name: parsed.name,
|
|
writes_pages: parsed.writes_pages,
|
|
writes_to: parsed.writes_to,
|
|
mutating: parsed.mutating,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Audit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Scan every skill under `skillsDir`. For skills with
|
|
* `writes_pages: true`:
|
|
* - Missing `writes_to:` → warning.
|
|
* - Any dir in `writes_to:` not in allowedDirectories → warning.
|
|
*
|
|
* Skills without `writes_pages:` (or with `writes_pages: false`) are
|
|
* skipped — regardless of `mutating:` value. This is deliberate
|
|
* (D-CX-7): filing-audit targets brain-page writers, not arbitrary
|
|
* side effects.
|
|
*/
|
|
export function runFilingAudit(skillsDir: string): FilingReport {
|
|
const issues: FilingIssue[] = [];
|
|
const rules = loadFilingRules(skillsDir);
|
|
if (!rules) {
|
|
return { totalScanned: 0, writesPagesSkills: 0, issues };
|
|
}
|
|
const allowed = allowedDirectories(rules);
|
|
|
|
let totalScanned = 0;
|
|
let writesPagesSkills = 0;
|
|
|
|
if (!existsSync(skillsDir)) {
|
|
return { totalScanned, writesPagesSkills, issues };
|
|
}
|
|
let entries: string[];
|
|
try {
|
|
entries = readdirSync(skillsDir);
|
|
} catch {
|
|
return { totalScanned, writesPagesSkills, issues };
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry.startsWith('.') || entry.startsWith('_')) continue;
|
|
const dir = join(skillsDir, entry);
|
|
try {
|
|
if (!statSync(dir).isDirectory()) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
const skillMd = join(dir, 'SKILL.md');
|
|
if (!existsSync(skillMd)) continue;
|
|
totalScanned++;
|
|
|
|
const fm = parseFrontmatter(skillMd);
|
|
if (!fm) continue;
|
|
if (fm.writes_pages !== true) continue;
|
|
writesPagesSkills++;
|
|
|
|
const skillName = fm.name ?? entry;
|
|
|
|
if (!fm.writes_to || fm.writes_to.length === 0) {
|
|
issues.push({
|
|
type: 'filing_missing_writes_to',
|
|
severity: 'warning',
|
|
skill: skillName,
|
|
message: `Skill '${skillName}' has writes_pages: true but no writes_to: list`,
|
|
action: `Add a writes_to: [dir, ...] list to skills/${entry}/SKILL.md frontmatter (see skills/_brain-filing-rules.json for valid directories)`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
for (const rawDir of fm.writes_to) {
|
|
const normalized = normalizeDir(rawDir);
|
|
if (!allowed.has(normalized)) {
|
|
issues.push({
|
|
type: 'filing_unknown_directory',
|
|
severity: 'warning',
|
|
skill: skillName,
|
|
directory: rawDir,
|
|
message: `Skill '${skillName}' declares writes_to: '${rawDir}' which is not listed in _brain-filing-rules.json`,
|
|
action: `Fix the writes_to: entry in skills/${entry}/SKILL.md or add '${normalized}' to skills/_brain-filing-rules.json rules[]`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return { totalScanned, writesPagesSkills, issues };
|
|
}
|