mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +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>
400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
/**
|
|
* skill-brain-first.ts — Pure analyzer for the v0.36.x `skill_brain_first`
|
|
* doctor check + skillify-check gate + dry-fix `--fix` MISSING_RULE pattern.
|
|
*
|
|
* Motivation (tweet-shield incident, 2026-05-19): cross-modal eval flagged
|
|
* Garry's Palantir tweet as risky because no model knew he built it. The
|
|
* brain already had "designed the entire Finance product UI" and "150+
|
|
* PSDs from April-December 2006." Brain-first compliance — search the brain
|
|
* before any external API — would have caught it. This module catches
|
|
* authors who didn't declare brain-first or didn't opt out explicitly.
|
|
*
|
|
* One pure function, three consumers (Q1 from /plan-eng-review):
|
|
* - `runDoctor()` maps it across the manifest for `skill_brain_first`
|
|
* - `skillify-check` calls it on a single SKILL.md as required item 12
|
|
* - `dry-fix.ts` MISSING_RULE_PATTERNS calls it as the "should I insert
|
|
* a Convention callout here?" gate
|
|
*
|
|
* Exemption order (top wins):
|
|
* 1. Frontmatter `brain_first: exempt` → exempt_explicit
|
|
* 2. No external-lookup pattern in body → exempt_no_external
|
|
* 3. Otherwise apply compliance detection (3-tier ladder)
|
|
*
|
|
* Compliance ladder (any one passes):
|
|
* a. Canonical `> **Convention:** ... brain-first ...` callout → compliant_callout
|
|
* b. Explicit `## Phase 1 [brain]` or `## Step 0 [brain]` heading → compliant_phase
|
|
* c. First brain reference offset < first external reference offset → compliant_position
|
|
*
|
|
* **NOTE (CMT2 from /plan-eng-review):** there is NO `tools + writes_pages`
|
|
* structural exemption. Skills like `idea-ingest`, `meeting-ingestion`,
|
|
* and `data-research` write pages AND call external APIs — the exact mixed
|
|
* class brain-first targets. They get flagged so authors declare stance,
|
|
* not hidden behind a structural rule. Skills that genuinely ARE the brain
|
|
* (`brain-ops`, `signal-detector`) exempt via compliance detection because
|
|
* they already carry the canonical callout — no structural rule needed.
|
|
*
|
|
* **NOTE (F6 from /plan-eng-review):** all position-relative scanning is
|
|
* BODY-ONLY. YAML frontmatter is excluded from offset comparison so a
|
|
* `tools: [web_search]` declaration doesn't false-flag the skill (the
|
|
* declaration is metadata, not execution). The body extractor returns the
|
|
* content starting AFTER the closing `---` of the frontmatter fence.
|
|
*
|
|
* **NOTE (F7 from /plan-eng-review):** the canonical-callout regex anchors
|
|
* on the literal `> **Convention:**` + `brain-first` substring, agnostic to
|
|
* path syntax (backtick / markdown-link / plain text). The three existing
|
|
* compliant skills (brain-ops, perplexity-research, academic-verify) use
|
|
* plain-text paths, so this is the load-bearing detection shape, not
|
|
* `extractDelegationTargets` which only matches backtick paths.
|
|
*/
|
|
|
|
import type { ParsedFrontmatter } from './skill-frontmatter.ts';
|
|
import { formatBrainFirstTypoHint } from './skill-frontmatter.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type BrainFirstReason =
|
|
| 'exempt_explicit' // brain_first: exempt in frontmatter
|
|
| 'exempt_no_external' // no external-lookup pattern present in body
|
|
| 'compliant_callout' // canonical > **Convention:** ... brain-first ... callout
|
|
| 'compliant_phase' // explicit ## Phase 1 / Step 0 brain heading
|
|
| 'compliant_position' // first brain ref appears before first external ref (body)
|
|
| 'missing_brain_first'; // external pattern + no compliance signal
|
|
|
|
export interface BrainFirstAnalysis {
|
|
/** Stable identifier — the manifest entry name OR the dir name. */
|
|
skill: string;
|
|
/** OK if any exemption or compliance path matched; warn otherwise. */
|
|
status: 'ok' | 'warn';
|
|
/** Why the analyzer landed where it did. Drives the doctor message. */
|
|
reason: BrainFirstReason;
|
|
/**
|
|
* The literal pattern names that matched external-lookup detection.
|
|
* Empty when `exempt_no_external` (no external pattern present).
|
|
* Populated even on compliance paths so callers can surface what the
|
|
* skill is calling (e.g. "perplexity + exa, compliant via callout").
|
|
*/
|
|
external_patterns_matched: string[];
|
|
/**
|
|
* Paste-ready typo hint when the frontmatter has a near-miss
|
|
* declaration (`brain-first: exempt`, `BrainFirst: Exempt`, quoted
|
|
* values, etc.). Surfaced in the doctor message and skillify-check
|
|
* error output.
|
|
*/
|
|
typo_hint?: string;
|
|
/**
|
|
* True when this skill was in the v0.36.x-and-earlier PR #1206
|
|
* hardcoded EXEMPT_SKILLS allowlist. The doctor message appends a
|
|
* dedicated hint for these on first detection (CMT1 from plan review:
|
|
* replaces the dropped upgrade migration with a guided opt-in via
|
|
* `gbrain doctor --fix`).
|
|
*/
|
|
formerly_hardcoded_exempt: boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pattern constants (exported for shared use in tests + dry-fix.ts detect)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* External-lookup tools that trigger the brain-first compliance gate.
|
|
* Each entry pairs a stable identifier (for telemetry) with a regex.
|
|
*
|
|
* Word-boundary anchored so a comment about `web_search_history` won't
|
|
* match `web_search`. Case-insensitive because skill authors capitalize
|
|
* inconsistently (`Perplexity`, `PERPLEXITY`, `perplexity`).
|
|
*
|
|
* Captain API specifically allows `captain api`, `captain_api`,
|
|
* `captain-api`, and `captainapi` because the actual product is
|
|
* referenced with all four shapes across skills.
|
|
*/
|
|
export const EXTERNAL_LOOKUP_PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [
|
|
{ name: 'web_search', re: /\bweb_search\b/i },
|
|
{ name: 'web_fetch', re: /\bweb_fetch\b/i },
|
|
{ name: 'exa', re: /\bexa[\s._-]/i },
|
|
{ name: 'perplexity', re: /\bperplexity\b/i },
|
|
{ name: 'happenstance', re: /\bhappenstance\b/i },
|
|
{ name: 'crustdata', re: /\bcrustdata\b/i },
|
|
{ name: 'captain_api', re: /\bcaptain[\s._-]?api\b/i },
|
|
{ name: 'firecrawl', re: /\bfirecrawl\b/i },
|
|
];
|
|
|
|
/**
|
|
* Brain-reference patterns that signal compliance. These match the
|
|
* canonical brain-tool invocations developers actually write.
|
|
*/
|
|
export const BRAIN_REFERENCE_PATTERNS: ReadonlyArray<RegExp> = [
|
|
/\bgbrain[\s_]+search\b/i,
|
|
/\bgbrain[\s_]+query\b/i,
|
|
/\bgbrain[\s_]+get[_-]?page\b/i,
|
|
/\bgbrain[\s_]+find[_-]?experts\b/i,
|
|
/\bgbrain[\s_]+get[_-]?backlinks\b/i,
|
|
/\bgbrain[\s_]+get[_-]?timeline\b/i,
|
|
/\bgbrain[\s_]+traverse[_-]?graph\b/i,
|
|
// Bare tool names (subagent context, OpenClaw plugin form):
|
|
/\bsearch\s+the\s+brain\b/i,
|
|
/\bquery\s+the\s+brain\b/i,
|
|
/\bcheck\s+the\s+brain\b/i,
|
|
];
|
|
|
|
/**
|
|
* Canonical Convention callout regex. Anchored at start-of-line on a
|
|
* blockquote that contains BOTH the literal `**Convention:**` marker
|
|
* AND the `brain-first` substring (with optional `.md` extension).
|
|
*
|
|
* Path syntax is intentionally ignored — the three existing compliant
|
|
* skills use varied forms (`> **Convention:** see skills/conventions/
|
|
* brain-first.md ...`, `> **Convention:** see conventions/brain-first.md
|
|
* ...`, `> **Convention:** see [conventions/brain-first.md](...) ...`).
|
|
* The literal `**Convention:**` + `brain-first` is the load-bearing
|
|
* signal, not the path shape.
|
|
*/
|
|
export const CONVENTION_CALLOUT_RE = /^>\s*\*\*Convention:\*\*[^\n]*brain-first/im;
|
|
|
|
/**
|
|
* Explicit phase-heading regex. Matches `## Phase 1: Brain-First Lookup`,
|
|
* `### Step 0: Brain Context`, etc. — any H2+ heading that names a brain
|
|
* phase as step 0 or phase 1.
|
|
*/
|
|
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.
|
|
*/
|
|
const FRONTMATTER_RE = /^---\n[\s\S]*?\n---\n?/;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hardcoded EXEMPT_SKILLS (CMT1 — replaces the dropped upgrade migration)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Skills that were in PR #1206's hardcoded EXEMPT_SKILLS allowlist
|
|
* (committed to v0.36.x-and-earlier `doctor.ts`). The list is
|
|
* preserved here ONLY for the doctor hint flow: when a skill in this
|
|
* set newly flags after v0.36.x ships the structural-signal exemption,
|
|
* the doctor message guides the user to either:
|
|
* 1. `gbrain doctor --fix` to auto-add the canonical callout, OR
|
|
* 2. add `brain_first: exempt` to frontmatter (if genuinely infra)
|
|
*
|
|
* The list is informational — removing it later doesn't break
|
|
* compliance detection. It's a guided opt-in surface, NOT an
|
|
* exemption rule.
|
|
*
|
|
* Per CMT1 from /plan-eng-review (codex direction): doctor surfaces +
|
|
* `--fix` applies via dry-fix safety gates, replacing the originally-
|
|
* planned silent migration. User stays in the loop.
|
|
*
|
|
* Source: PR #1206 `feature/doctor-brain-first-check`,
|
|
* `src/commands/doctor.ts` (pre-supersede).
|
|
*/
|
|
export const FORMERLY_HARDCODED_EXEMPT: ReadonlySet<string> = new Set([
|
|
// Brain-internal skills (PR rationale: "ARE the brain")
|
|
'brain-ops', 'brain-commit', 'brain-enrichment-pipeline', 'brain-export',
|
|
'brain-ingest-gate', 'brain-librarian', 'brain-link-refs', 'brain-link-report',
|
|
'brain-pdf', 'brain-pdf-auto', 'brain-plan', 'brain-publish', 'brain-storage',
|
|
'brain-storage-links', 'brain-taxonomist',
|
|
'gbrain', 'gbrain-pr', 'gbrain-upgrade', 'benchmark-gbrain',
|
|
// External-tool wrappers (their entire job IS external lookup)
|
|
'exa', 'happenstance', 'crustdata', 'captain-api',
|
|
// Pure-infra skills (system, not knowledge)
|
|
'healthcheck', 'backblaze', 'browser', 'browser-use', 'binary-deps',
|
|
'captcha-solver', 'container-restart', 'durable-service', 'data-loss-gate',
|
|
'channel-discovery', 'clawvisor', 'clawvisor-shield',
|
|
'cron-scheduler', 'cronify', 'correction-pipeline',
|
|
'acknowledge', 'ask-user', 'backoff',
|
|
'acp-coding', 'code-pr', 'skill-creator', 'ingest', 'freshness-monitor',
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure analyzer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Analyze a single SKILL.md for brain-first compliance. Pure function —
|
|
* no I/O, no side effects, no random clocks. Drives the doctor check,
|
|
* the skillify-check gate, and the dry-fix MISSING_RULE detector.
|
|
*
|
|
* @param content — raw SKILL.md content (incl. frontmatter)
|
|
* @param skillName — stable identifier (manifest entry name or dir name)
|
|
* @param frontmatter — pre-parsed frontmatter from `parseSkillFrontmatter()`,
|
|
* or null if the skill has no YAML fence (treated as
|
|
* empty frontmatter for analysis purposes)
|
|
*/
|
|
export function analyzeSkillBrainFirst(
|
|
content: string,
|
|
skillName: string,
|
|
frontmatter: ParsedFrontmatter | null,
|
|
): BrainFirstAnalysis {
|
|
const formerly = FORMERLY_HARDCODED_EXEMPT.has(skillName);
|
|
const typo_hint = frontmatter?.brain_first_typo
|
|
? formatBrainFirstTypoHint(frontmatter.brain_first_typo) ?? undefined
|
|
: undefined;
|
|
|
|
// Exemption 1: explicit declarative opt-out
|
|
if (frontmatter?.brain_first === 'exempt') {
|
|
return {
|
|
skill: skillName,
|
|
status: 'ok',
|
|
reason: 'exempt_explicit',
|
|
external_patterns_matched: [],
|
|
typo_hint,
|
|
formerly_hardcoded_exempt: formerly,
|
|
};
|
|
}
|
|
|
|
// Body extraction — strip frontmatter so a `tools: [web_search]`
|
|
// declaration in YAML doesn't false-flag the skill (F6 from review).
|
|
const body = stripFrontmatter(content);
|
|
|
|
// Scan body for external-lookup patterns.
|
|
const external_patterns_matched = EXTERNAL_LOOKUP_PATTERNS
|
|
.filter(p => p.re.test(body))
|
|
.map(p => p.name);
|
|
|
|
// Exemption 2: no external pattern present anywhere in body. Trivially
|
|
// doesn't need brain-first — the skill never reaches for external data.
|
|
if (external_patterns_matched.length === 0) {
|
|
return {
|
|
skill: skillName,
|
|
status: 'ok',
|
|
reason: 'exempt_no_external',
|
|
external_patterns_matched: [],
|
|
typo_hint,
|
|
formerly_hardcoded_exempt: formerly,
|
|
};
|
|
}
|
|
|
|
// External pattern present → apply compliance ladder.
|
|
|
|
// Compliance a: canonical Convention callout referencing brain-first.
|
|
if (CONVENTION_CALLOUT_RE.test(body)) {
|
|
return {
|
|
skill: skillName,
|
|
status: 'ok',
|
|
reason: 'compliant_callout',
|
|
external_patterns_matched,
|
|
typo_hint,
|
|
formerly_hardcoded_exempt: formerly,
|
|
};
|
|
}
|
|
|
|
// Compliance b: explicit Phase 1 / Step 0 brain heading.
|
|
if (PHASE_HEADING_RE.test(body)) {
|
|
return {
|
|
skill: skillName,
|
|
status: 'ok',
|
|
reason: 'compliant_phase',
|
|
external_patterns_matched,
|
|
typo_hint,
|
|
formerly_hardcoded_exempt: formerly,
|
|
};
|
|
}
|
|
|
|
// Compliance c: first brain reference appears BEFORE first external
|
|
// reference in body. Position-relative ordering — body-only by
|
|
// construction (we're scanning `body`, not `content`).
|
|
const firstBrainOffset = findFirstBrainRefOffset(body);
|
|
const firstExternalOffset = findFirstExternalRefOffset(body);
|
|
if (
|
|
firstBrainOffset !== -1 &&
|
|
firstExternalOffset !== -1 &&
|
|
firstBrainOffset < firstExternalOffset
|
|
) {
|
|
return {
|
|
skill: skillName,
|
|
status: 'ok',
|
|
reason: 'compliant_position',
|
|
external_patterns_matched,
|
|
typo_hint,
|
|
formerly_hardcoded_exempt: formerly,
|
|
};
|
|
}
|
|
|
|
// Otherwise: external pattern present, no compliance signal. Warn.
|
|
return {
|
|
skill: skillName,
|
|
status: 'warn',
|
|
reason: 'missing_brain_first',
|
|
external_patterns_matched,
|
|
typo_hint,
|
|
formerly_hardcoded_exempt: formerly,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers (exported for unit tests)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return the SKILL.md content with the leading YAML frontmatter fence
|
|
* stripped. If no fence is present, returns the input unchanged.
|
|
*
|
|
* Critical for position-relative scanning (F6): `tools: [web_search]`
|
|
* in frontmatter must not count as the "first external reference."
|
|
*/
|
|
export function stripFrontmatter(content: string): string {
|
|
return content.replace(FRONTMATTER_RE, '');
|
|
}
|
|
|
|
/**
|
|
* Offset (0-indexed) of the first brain-reference match in body, or -1.
|
|
* Scans through BRAIN_REFERENCE_PATTERNS in declared order; returns the
|
|
* MINIMUM matching offset across all patterns (so multi-pattern hits
|
|
* still surface the earliest).
|
|
*/
|
|
export function findFirstBrainRefOffset(body: string): number {
|
|
let min = -1;
|
|
for (const re of BRAIN_REFERENCE_PATTERNS) {
|
|
const m = body.match(re);
|
|
if (m && m.index !== undefined) {
|
|
if (min === -1 || m.index < min) min = m.index;
|
|
}
|
|
}
|
|
return min;
|
|
}
|
|
|
|
/**
|
|
* Offset (0-indexed) of the first external-reference match in body, or -1.
|
|
* Sibling of findFirstBrainRefOffset; same minimum-across-patterns semantics.
|
|
*/
|
|
export function findFirstExternalRefOffset(body: string): number {
|
|
let min = -1;
|
|
for (const { re } of EXTERNAL_LOOKUP_PATTERNS) {
|
|
const m = body.match(re);
|
|
if (m && m.index !== undefined) {
|
|
if (min === -1 || m.index < min) min = m.index;
|
|
}
|
|
}
|
|
return min;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Message builder — used by doctor + skillify-check + dry-fix
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a human-readable per-skill summary line. Used by the doctor
|
|
* message + skillify-check error output. Includes the typo hint and
|
|
* the formerly-hardcoded-exempt note when applicable.
|
|
*/
|
|
export function buildBrainFirstSummaryLine(a: BrainFirstAnalysis): string {
|
|
if (a.status === 'ok') {
|
|
return `${a.skill}: ok (${a.reason})`;
|
|
}
|
|
const parts: string[] = [
|
|
`${a.skill}: external lookup (${a.external_patterns_matched.join(', ')}) without brain-first compliance`,
|
|
];
|
|
if (a.formerly_hardcoded_exempt) {
|
|
parts.push(
|
|
`(was hardcoded-exempt in PR #1206 — opt out explicitly via 'brain_first: exempt' or run 'gbrain doctor --fix' to add the canonical callout)`,
|
|
);
|
|
}
|
|
if (a.typo_hint) {
|
|
parts.push(`(typo: ${a.typo_hint})`);
|
|
}
|
|
return parts.join(' ');
|
|
}
|