diff --git a/CHANGELOG.md b/CHANGELOG.md index 290f5859..d5cbb553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to PBrain will be documented in this file. ## [Unreleased] +- **`pbrain doctor` is quiet when there's nothing to fix.** Three more false-positive classes eliminated: (1) the `skill_symlinks` check no longer dumps "shadowed by other plugins: claude:..., cursor:..., windsurf:..." for all 78 entries when your installed symlinks happen to point at a sibling PBrain checkout (e.g. `~/.pbrain-repo` while you're running doctor from a dev clone) — a new `ours-elsewhere` state detects any pbrain tree via `package.json.name === 'pbrain'` and the message now reads `installed — claude: 26, cursor: 26, windsurf: 26 (symlinks resolve to sibling checkout at )`. (2) The `resolver_health` MECE overlap between `maintain` and `citation-fixer` on the trigger `"citation audit"` is gone — removed from `maintain` since `citation-fixer` is the specialized skill for that phrase. (3) Seven skills (`ingest`, `enrich`, `setup`, `signal-detector`, `idea-ingest`, `media-ingest`, `meeting-ingestion`) now reference `skills/conventions/quality.md` alongside their local citation / Iron-Law recap, silencing the DRY-violation warnings while keeping the inline guidance readable. Doctor's health score on a fresh install now reflects only real, user-actionable issues. - **`pbrain doctor` no longer cries wolf on PGLite or on project/repo pairs that share a name.** The `DUPLICATE_SLUG` check was firing every time you onboarded a project whose repo name matches its product name — `projects/pbrain` + `repos/joedanz/pbrain` always collide on the tail "pbrain," even when every wikilink in the brain is path-qualified and there's no real ambiguity. It now fires only when a bare-slug `[[tail]]` wikilink actually references the ambiguous tail, and the message points at the exact page referencing it so you can fix it in one edit. Separately, the `pgvector` and `rls` checks stop reporting "Could not check ..." for PGLite users — pgvector is bundled with the PGLite WASM runtime and RLS is meaningless for a local embedded DB, so both now report green with an explanatory message. Your doctor health score now reflects real problems, not check-runner noise. - **`project-onboard` accepts the product name and domain as positional arguments in any order, and infers the repo from your current directory.** Call it with `project-onboard ` from inside a repo and that's all you need — no repo URL, no keyword wrapping. Args are classified by shape, not position: anything with a slash is the repo, anything with a TLD is the domain, everything else is the display name. The project slug resolves in priority order — explicit display name wins, then domain root, then `package.json`, then the repo name with suffixes like `-web` / `-app` / `-monorepo` stripped — so a repo called `-web` still gets filed under `projects/`. Use `project=` as a named escape hatch when a display name itself looks like a domain. - **Onboard a coding project once, and every future Claude Code session in that project already knows it.** The `project-onboard` skill now installs a short `## pbrain` declaration into the project's own `CLAUDE.md` at the end of its run — slug, brain-query guidance, and the `pbrain remember` recipe. Every subsequent session in that project auto-recognizes it and routes brain lookups to the right slug; the skill's new Phase 0 idempotency gate short-circuits in ~5ms so re-invocation is a free no-op. No machine-wide hook, no `~/.claude/settings.json` edit, no manually-written `.pbrain-project` marker — just one gesture per project, ever. Delete the `## pbrain` section to stop tracking. Per-project opt-in keeps client checkouts and scratch clones out of your brain by default. diff --git a/skills/enrich/SKILL.md b/skills/enrich/SKILL.md index 3850f3e5..182a6685 100644 --- a/skills/enrich/SKILL.md +++ b/skills/enrich/SKILL.md @@ -36,6 +36,7 @@ This skill guarantees: - No stubs: every new page has meaningful content from web search or existing brain context > **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page. +> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth. ## Iron Law: Back-Linking (MANDATORY) diff --git a/skills/idea-ingest/SKILL.md b/skills/idea-ingest/SKILL.md index 45c9e567..29b2359e 100644 --- a/skills/idea-ingest/SKILL.md +++ b/skills/idea-ingest/SKILL.md @@ -25,6 +25,7 @@ mutating: true # Idea Ingest Skill > **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page. +> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth. ## Contract diff --git a/skills/ingest/SKILL.md b/skills/ingest/SKILL.md index 5ea96bc4..e9b99f62 100644 --- a/skills/ingest/SKILL.md +++ b/skills/ingest/SKILL.md @@ -20,6 +20,7 @@ mutating: true Ingest meetings, articles, media, documents, and conversations into the brain. > **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page. +> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth. ## Contract diff --git a/skills/maintain/SKILL.md b/skills/maintain/SKILL.md index 0913900c..340d1328 100644 --- a/skills/maintain/SKILL.md +++ b/skills/maintain/SKILL.md @@ -8,7 +8,6 @@ description: | triggers: - "brain health" - "check backlinks" - - "citation audit" - "maintenance" - "orphan pages" - "stale pages" diff --git a/skills/media-ingest/SKILL.md b/skills/media-ingest/SKILL.md index e79822ba..60aead51 100644 --- a/skills/media-ingest/SKILL.md +++ b/skills/media-ingest/SKILL.md @@ -29,6 +29,7 @@ mutating: true Ingest video, audio, PDF, book, screenshot, and GitHub repo content into the brain. > **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page. +> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth. ## Contract diff --git a/skills/meeting-ingestion/SKILL.md b/skills/meeting-ingestion/SKILL.md index a0d98784..ce8d9109 100644 --- a/skills/meeting-ingestion/SKILL.md +++ b/skills/meeting-ingestion/SKILL.md @@ -23,6 +23,7 @@ mutating: true # Meeting Ingestion Skill > **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page. +> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth. ## Contract diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 0cc7a157..c8dcc3c1 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -223,8 +223,8 @@ Inject the key patterns into the agent's system context or AGENTS.md: 1. **Brain-agent loop** (Section 2): read before responding, write after learning 2. **Entity detection** (Section 3): spawn on every message, capture people/companies/ideas -3. **Source attribution** (Section 7): every fact needs `[Source: ...]` -4. **Iron law back-linking** (Section 15.4): every mention links back to the entity page +3. **Source attribution** (Section 7): every fact needs `[Source: ...]` — see `skills/conventions/quality.md` for the canonical format +4. **Iron law back-linking** (Section 15.4): every mention links back to the entity page — see `skills/conventions/quality.md` Tell the user: "The production agent guide is at docs/PBRAIN_SKILLPACK.md. It covers the brain-agent loop, entity detection, enrichment, meeting ingestion, and cron diff --git a/skills/signal-detector/SKILL.md b/skills/signal-detector/SKILL.md index 56c36a30..ef2ea90e 100644 --- a/skills/signal-detector/SKILL.md +++ b/skills/signal-detector/SKILL.md @@ -41,6 +41,8 @@ This skill guarantees: ## Iron Law: Back-Linking (MANDATORY) +> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format and back-link rules. The steps below are the local procedure; the convention file is the source of truth. + Every time this skill creates or updates a brain page that mentions a person or company: 1. Check if that person/company has a brain page 2. If yes → add a back-link FROM their page TO the page you just created/updated diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index de00ec32..4bd8a7f0 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -225,6 +225,29 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) { // Helpers // --------------------------------------------------------------------------- +/** + * Walk up from a resolved symlink target looking for the repo root + * (directory with package.json name=pbrain). Used to collapse per-skill + * `resolvedTo` paths to the single checkout directory for the status message. + */ +function resolveCheckoutRoot(resolvedPath: string): string { + if (!resolvedPath) return ''; + let dir = resolvedPath; + for (let i = 0; i < 12; i++) { + const pkgPath = join(dir, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + if (pkg && pkg.name === 'pbrain') return dir; + } catch { /* keep walking */ } + } + const parent = join(dir, '..'); + if (parent === dir) break; + dir = parent; + } + return ''; +} + /** Find the PBrain repo root by walking up from cwd looking for skills/RESOLVER.md */ function findRepoRoot(): string | null { let dir = process.cwd(); @@ -300,12 +323,25 @@ function checkSkillSymlinks(repoRoot: string): Check { const shadowed: string[] = []; const partial: string[] = []; + // `ours-elsewhere` (symlink to another pbrain checkout, e.g. ~/.pbrain-repo + // when doctor runs from a dev clone) counts as installed — the skills fire + // correctly, the symlinks just don't point at this particular tree. + const elsewhereByTarget = new Map(); for (const t of targets) { - const present = new Set(entries.filter(e => e.target.dir === t.dir && e.state === 'ours-ok').map(e => e.name)); + const present = new Set(entries.filter(e => e.target.dir === t.dir && (e.state === 'ours-ok' || e.state === 'ours-elsewhere')).map(e => e.name)); const targetBroken = entries.filter(e => e.target.dir === t.dir && e.state === 'ours-broken'); const targetShadowed = entries.filter(e => e.target.dir === t.dir && (e.state === 'foreign-symlink' || e.state === 'foreign-file' || e.state === 'foreign-dir') && skillNames.has(e.name)); + const targetElsewhere = entries.filter(e => e.target.dir === t.dir && e.state === 'ours-elsewhere'); for (const e of targetBroken) broken.push(`${t.client}:${e.name}`); for (const e of targetShadowed) shadowed.push(`${t.client}:${e.name}`); + if (targetElsewhere.length > 0) { + const checkouts = [...new Set(targetElsewhere.map(e => resolveCheckoutRoot(e.resolvedTo || '')).filter(Boolean))]; + for (const root of checkouts) { + const list = elsewhereByTarget.get(root) || []; + list.push(`${t.client} ${targetElsewhere.length}`); + elsewhereByTarget.set(root, list); + } + } if (present.size > 0 && present.size < skills.length) { partial.push(`${t.client} ${present.size}/${skills.length}`); } @@ -316,7 +352,7 @@ function checkSkillSymlinks(repoRoot: string): Check { const installedByClient = targets .map(t => ({ client: t.client, - count: entries.filter(e => e.target.dir === t.dir && e.state === 'ours-ok').length, + count: entries.filter(e => e.target.dir === t.dir && (e.state === 'ours-ok' || e.state === 'ours-elsewhere')).length, })) .filter(x => x.count > 0); if (installedByClient.length === 0) { @@ -327,10 +363,13 @@ function checkSkillSymlinks(repoRoot: string): Check { }; } const summary = installedByClient.map(x => `${x.client}: ${x.count}`).join(', '); + const elsewhereNote = elsewhereByTarget.size > 0 + ? ` (symlinks resolve to sibling checkout at ${[...elsewhereByTarget.keys()].join(', ')})` + : ''; return { name: 'skill_symlinks', status: 'ok', - message: `installed — ${summary}`, + message: `installed — ${summary}${elsewhereNote}`, }; } diff --git a/src/core/skill-installer.ts b/src/core/skill-installer.ts index e2716933..32d1c791 100644 --- a/src/core/skill-installer.ts +++ b/src/core/skill-installer.ts @@ -268,11 +268,42 @@ export interface StatusEntry { /** Skill name at that dir. */ name: string; dst: string; - state: 'ours-ok' | 'ours-broken' | 'foreign-symlink' | 'foreign-file' | 'foreign-dir'; + /** + * - `ours-ok`: symlink resolves into the current repo root. + * - `ours-broken`: symlink was clearly ours but now dangles. + * - `ours-elsewhere`: symlink resolves into a *different* PBrain checkout + * (e.g., `~/.pbrain-repo` when doctor is run from a dev clone). Still a + * valid install — just not pointing at this tree. + * - `foreign-*`: a real other-plugin file / symlink / directory. + */ + state: 'ours-ok' | 'ours-broken' | 'ours-elsewhere' | 'foreign-symlink' | 'foreign-file' | 'foreign-dir'; /** Where the symlink points (resolved). */ resolvedTo?: string; } +/** + * Walk up from `pathInside` looking for a `package.json` whose `name` field + * is `pbrain`. Used to classify symlinks that land in a different PBrain + * checkout (e.g. the global `~/.pbrain-repo` install) as `ours-elsewhere` + * rather than `foreign-symlink` — they're valid installs, just not this tree. + */ +function isPbrainCheckout(pathInside: string): boolean { + let dir = pathInside; + for (let i = 0; i < 12; i++) { + const pkgPath = join(dir, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + if (pkg && pkg.name === 'pbrain') return true; + } catch { /* malformed — keep walking */ } + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return false; +} + /** * Inspect every entry under each target dir and classify it. Used by * `install-skills status` and by the doctor check. @@ -321,6 +352,8 @@ export function scanTargets(targets: Target[], repoRoot: string): StatusEntry[] } if (rootList.some(r => pathInsideRoot(resolved!, r))) { entries.push({ target, name, dst, state: 'ours-ok', resolvedTo: resolved }); + } else if (isPbrainCheckout(resolved!)) { + entries.push({ target, name, dst, state: 'ours-elsewhere', resolvedTo: resolved }); } else { entries.push({ target, name, dst, state: 'foreign-symlink', resolvedTo: resolved }); } diff --git a/test/install-skills.test.ts b/test/install-skills.test.ts index 7ca52f94..87b4e3e1 100644 --- a/test/install-skills.test.ts +++ b/test/install-skills.test.ts @@ -239,6 +239,46 @@ describe('scanTargets', () => { const entries = scanTargets([{ client: 'claude', dir: targetDir }], repoRoot); expect(entries[0].state).toBe('ours-broken'); }); + + test('symlink into a sibling pbrain checkout is classified as ours-elsewhere', () => { + // Two pbrain checkouts: repoRoot (where doctor is running) and siblingRoot + // (where the installed symlinks actually point — e.g. ~/.pbrain-repo). + const repoRoot = join(scratchDir, 'dev-clone'); + const siblingRoot = join(scratchDir, 'sibling-pbrain'); + mkdirSync(join(repoRoot, 'skills', 'foo'), { recursive: true }); + writeFileSync(join(repoRoot, 'skills', 'foo', 'SKILL.md'), '---\nname: foo\n---'); + writeFileSync(join(repoRoot, 'package.json'), JSON.stringify({ name: 'pbrain' })); + mkdirSync(join(siblingRoot, 'skills', 'foo'), { recursive: true }); + const siblingSkill = join(siblingRoot, 'skills', 'foo', 'SKILL.md'); + writeFileSync(siblingSkill, '---\nname: foo\n---'); + writeFileSync(join(siblingRoot, 'package.json'), JSON.stringify({ name: 'pbrain' })); + + const targetDir = join(scratchDir, 'client2', 'skills'); + mkdirSync(targetDir, { recursive: true }); + symlinkSync(siblingSkill, join(targetDir, 'foo')); + + const entries = scanTargets([{ client: 'claude', dir: targetDir }], repoRoot); + expect(entries[0].state).toBe('ours-elsewhere'); + expect(entries[0].resolvedTo).toContain('sibling-pbrain'); + }); + + test('symlink into a non-pbrain foreign project stays foreign-symlink', () => { + const repoRoot = join(scratchDir, 'r1'); + mkdirSync(repoRoot, { recursive: true }); + writeFileSync(join(repoRoot, 'package.json'), JSON.stringify({ name: 'pbrain' })); + const foreignRoot = join(scratchDir, 'not-pbrain'); + mkdirSync(join(foreignRoot, 'skills', 'foo'), { recursive: true }); + const foreignSkill = join(foreignRoot, 'skills', 'foo', 'SKILL.md'); + writeFileSync(foreignSkill, 'x'); + writeFileSync(join(foreignRoot, 'package.json'), JSON.stringify({ name: 'something-else' })); + + const targetDir = join(scratchDir, 'client3', 'skills'); + mkdirSync(targetDir, { recursive: true }); + symlinkSync(foreignSkill, join(targetDir, 'foo')); + + const entries = scanTargets([{ client: 'claude', dir: targetDir }], repoRoot); + expect(entries[0].state).toBe('foreign-symlink'); + }); }); describe('detectClients', () => {