diff --git a/AGENTS.md b/AGENTS.md index 8b46791..efd19a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -325,32 +325,30 @@ Use this table to decide where a piece of knowledge belongs: | Personal workflow preference ("I want terse responses") | auto-memory (`user` or `feedback`) | | Active project state, deadlines, blockers | auto-memory (`project`) | | Pointer to external system (Linear board, dashboard URL) | auto-memory (`reference`) | -| Architecture decision the team must follow | **GitHub Project** (`gh project item-create`) | -| Library gotcha that affects everyone | **GitHub Project** (`gh project item-create`) | -| Convention ("All API routes return `{ data, error }`") | **GitHub Project** (`gh project item-create`) | -| Incident postmortem worth team awareness | **GitHub Project** (`gh project item-create`) | +| Architecture decision the team must follow | **team-knowledge repo** (`/share-learning`) | +| Library gotcha that affects everyone | **team-knowledge repo** (`/share-learning`) | +| Convention ("All API routes return `{ data, error }`") | **team-knowledge repo** (`/share-learning`) | +| Incident postmortem worth team awareness | **team-knowledge repo** (`/share-learning`) | -**Rule of thumb:** if another team member's AI agent would benefit from knowing it, post it to the GitHub Project. Otherwise let auto-memory handle it. +**Rule of thumb:** if another team member's AI agent would benefit from knowing it, post it to the team-knowledge repo. Otherwise let auto-memory handle it. Examples of team-wide entries: ```bash +# Read: browse index or search across notes +cat $KNOWLEDGE_REPO_PATH/INDEX.md +rg "smooth-scroll" $KNOWLEDGE_REPO_PATH/ + # Architecture decision -gh project item-create "$KNOWLEDGE_PROJECT_NUMBER" --owner darkroomengineering \ - --title "decision: Lenis over native smooth-scroll" \ - --body "Cross-browser consistency. Native smooth-scroll diverges on Safari/Firefox; Lenis normalizes inertia + delta math. See ADR-007." +/share-learning decision "Lenis over native smooth-scroll for cross-browser consistency" # Team convention -gh project item-create "$KNOWLEDGE_PROJECT_NUMBER" --owner darkroomengineering \ - --title "convention: API response shape" \ - --body "All API routes return { data, error } — never throw to the caller." +/share-learning convention "All API routes return { data, error } — never throw to the caller" # Cross-cutting gotcha -gh project item-create "$KNOWLEDGE_PROJECT_NUMBER" --owner darkroomengineering \ - --title "gotcha: Sanity UTC dates" \ - --body "Sanity API returns UTC dates — always convert to local before display." +/share-learning gotcha "Sanity API returns UTC dates — always convert to local before display" ``` -See `docs/knowledge-system.md` for full setup instructions and `gh api graphql` recall patterns. +See `docs/knowledge-system.md` for full setup instructions and recall patterns. ## Self-Evolving Learnings (agent convention) @@ -369,7 +367,7 @@ Categories: [categories vary per agent] This project uses a two-tier knowledge system: -**Shared (Team)** — Stored in the project's GitHub Project board. Architecture decisions, team conventions, cross-cutting gotchas. Accessible to any team member's AI agent via `gh` CLI. +**Shared (Team)** — Stored in the team-knowledge repo (`darkroomengineering/team-knowledge`). Architecture decisions, team conventions, cross-cutting gotchas. Accessible to any team member's AI agent via `rg`/`cat` on a local clone or via `gh api`. **Local (Personal)** — Stored in auto-memory and local config files. Workflow preferences, individual learnings, session context. Private to each developer. diff --git a/CHANGELOG.md b/CHANGELOG.md index d240229..e38a38a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to cc-settings are documented here. ## [Unreleased] +### Changed + +- **Shared team-knowledge migrated from GitHub Project #7 to a markdown repo** (`darkroomengineering/team-knowledge`). The board was structurally hostile to its primary consumers (agents): network-gated GraphQL reads, not greppable offline, and no linter-enforced structure — `gh project item-create` never set the `Kind` field, so every `/share-learning` post landed `kind: None` and was invisible to a Kind-filtered query. The repo is one note per file + a generated `INDEX.md`, mirroring the local auto-memory tier. Decision + roadmap: `docs/plans/knowledge-repo-migration.md` (weighted comparison scored repo 795 vs board 515). + - **New** — `src/schemas/knowledge.ts` (zod frontmatter contract: `name`/`kind`/`tags`/`added-by`/`supersedes`), `src/lib/lint-knowledge.ts` + `src/scripts/lint-knowledge.ts` (`bun run lint:knowledge`), `src/scripts/new-note.ts` (`bun run new-note`), `tests/lint-knowledge.test.ts`, emitted `schemas/knowledge.schema.json`. + - **Changed** — `/share-learning` now reads `INDEX.md` for dedup and writes notes via `gh api` (was `gh project item-list`/`item-create`); `docs/knowledge-system.md`, the `AGENTS.md` Knowledge Routing section, and assorted docs retargeted; env `KNOWLEDGE_PROJECT_NUMBER` → `KNOWLEDGE_REPO`. + - **Companion** — darky's `search_team_knowledge` reader rewritten for the repo substrate (darkroom-os#18); env `DARKY_KNOWLEDGE_PROJECT_NUMBER` → `DARKY_KNOWLEDGE_REPO`. + ## [11.16.0] — 2026-06-02 Two additions: a **deslop advisory probe** in the proof-of-work gate (the framework-agnostic sibling to react-doctor), and a shift to **PR-by-default** as the standard workflow (with a repo PR template that dogfoods the plain-English standard). diff --git a/CLAUDE-FULL.md b/CLAUDE-FULL.md index 54f420d..2f4910d 100644 --- a/CLAUDE-FULL.md +++ b/CLAUDE-FULL.md @@ -102,7 +102,7 @@ Scope: consumer hardware and platform-integration questions specifically. Librar - **TLDR** (token-efficient codebase exploration via `llm-tldr`) — see `docs/tldr-cheatsheet.md` - **Hooks** (29 events, 8 categories, conditional `if` filtering) — see `docs/hooks-reference.md` - **Agent frontmatter** (`tools`, `disallowedTools`, `maxTurns`, `permissionMode`, `effort`, `isolation`, `hooks`, `mcpServers`, `initialPrompt`) — see `docs/frontmatter-reference.md` -- **Knowledge system** (shared GitHub Project board + local auto-memory) — see `docs/knowledge-system.md` +- **Knowledge system** (shared team-knowledge repo + local auto-memory) — see `docs/knowledge-system.md` - **Agent teams** (parallel independent workstreams, `teammateMode: "auto"`) — see `docs/feature-agents-guide.md` Skill matching is handled by the native `Skill` tool (v2.1.108). diff --git a/MANUAL.md b/MANUAL.md index 22aacac..f5982aa 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -289,7 +289,7 @@ Triggers `/consolidate` — audits rules, skills, and learnings for contradictio ### Store a Learning -Say: *"remember this"* — the auto-memory system in `~/.claude/CLAUDE.md` captures personal notes automatically (types: `user`, `feedback`, `project`, `reference`). For team-wide gotchas, decisions, and conventions: post directly with `gh project item-create` (see `AGENTS.md` Knowledge Routing section) or describe what to share and the agent will format and post it. +Say: *"remember this"* — the auto-memory system in `~/.claude/CLAUDE.md` captures personal notes automatically (types: `user`, `feedback`, `project`, `reference`). For team-wide gotchas, decisions, and conventions: use `/share-learning` (see `AGENTS.md` Knowledge Routing section) or describe what to share and the agent will format and post it to the team-knowledge repo. ### Fetch Docs & Check Versions diff --git a/USAGE.md b/USAGE.md index 3942f65..7c3d3e3 100644 --- a/USAGE.md +++ b/USAGE.md @@ -199,15 +199,16 @@ Use TLDR when files are large or you need meaning-based search. Use Read/Grep fo ### Shared (Team Knowledge Base) ```bash -# Store to the project's GitHub Project board -/learn store --shared gotcha "Biome ignores .mdx files by default" -/learn store --shared decision "Chose Lenis over native smooth-scroll" +# Post to the team-knowledge repo (handles dedup automatically) +/share-learning gotcha "Biome ignores .mdx files by default" +/share-learning decision "Chose Lenis over native smooth-scroll" -# Recall shared knowledge -/learn recall shared +# Recall shared knowledge (requires local clone at $KNOWLEDGE_REPO_PATH) +cat $KNOWLEDGE_REPO_PATH/INDEX.md +rg "biome" $KNOWLEDGE_REPO_PATH/ ``` -See `docs/knowledge-system.md` for GitHub Projects setup. +See `docs/knowledge-system.md` for setup. --- diff --git a/docs/github-workflow.md b/docs/github-workflow.md index eb57475..46caf46 100644 --- a/docs/github-workflow.md +++ b/docs/github-workflow.md @@ -136,7 +136,7 @@ Use Projects for the big picture. Use Issues for the actual plan. Don't conflate | `/project` | Reads linked issue on session start, updates on end | | `/create-handoff` | Posts progress comment on linked issue | | `/resume-handoff` | Reads linked issue as primary context source | -| `/share-learning` | Stores team knowledge in GitHub Project board | +| `/share-learning` | Stores team knowledge in the team-knowledge repo | | `/build`, `/fix` | Can reference issue tasks for scope | --- diff --git a/docs/knowledge-system.md b/docs/knowledge-system.md index 9e16c2f..00d761e 100644 --- a/docs/knowledge-system.md +++ b/docs/knowledge-system.md @@ -8,7 +8,7 @@ Two-tier knowledge management for AI-assisted development teams. | Tier | Storage | Scope | Access | |------|---------|-------|--------| -| **Shared** | GitHub Project board | Team-wide | `gh api graphql` | +| **Shared** | team-knowledge repo | Team-wide | `gh api` + `rg` | | **Local** | Auto-memory + learnings | Per-developer | File system | **Shared knowledge** is for things the whole team benefits from knowing — architecture decisions, cross-cutting gotchas, resolved incidents, team conventions. @@ -17,27 +17,43 @@ Two-tier knowledge management for AI-assisted development teams. --- -## Shared Knowledge (GitHub Projects) +## Shared Knowledge (team-knowledge repo) ### Setup -1. Create a GitHub Project on your repository: - - Name: `Knowledge Base` (or similar) - - Add custom fields: - - **Kind** (single select): `decision`, `convention`, `gotcha`, `incident`, `pattern` — name it `Kind`, **not** `Type`; GitHub reserves the field name `Type`. The agent flow keys off the `:` title prefix regardless, so this field is for board grouping/filtering. - - **Tags** (text): free-form tags like `auth`, `api`, `css`, `performance` - - **Added By** (text): who added it +The shared corpus lives at `darkroomengineering/team-knowledge`. One markdown file per note, plus a generated `INDEX.md`. -2. Note the Project number (visible in the URL: `github.com/orgs/ORG/projects/NUMBER`) +**For `/share-learning` (write path):** no local clone needed — the skill writes via `gh api`. Set the repo slug in your environment if you want to override the default: +``` +KNOWLEDGE_REPO=darkroomengineering/team-knowledge +``` + +**For dev CLIs (`bun run lint:knowledge`, `bun run new-note`):** these tools operate on a local clone. Point them at it: +``` +KNOWLEDGE_REPO_PATH=/path/to/your/clone/of/team-knowledge +``` + +Clone once with: +```bash +git clone https://github.com/darkroomengineering/team-knowledge ~/team-knowledge +``` -3. Add to your project's `CLAUDE.local.md` or environment: - ``` - Knowledge base: gh project #NUMBER on ORG/REPO - ``` +### Note frontmatter contract + +``` +--- +name: +kind: decision | convention | gotcha | incident | pattern +tags: [kebab, strings] # optional +added-by: +supersedes: # optional +--- + +``` ### What Goes in Shared Knowledge -| Type | Example | +| Kind | Example | |------|---------| | `decision` | "Chose Lenis over native smooth-scroll for cross-browser consistency" | | `convention` | "All API routes return `{ data, error }` shape" | @@ -47,37 +63,38 @@ Two-tier knowledge management for AI-assisted development teams. ### Agent Usage -**Reading shared knowledge:** +**Reading shared knowledge (agents on dev machines):** ```bash -# List all entries -gh project item-list NUMBER --owner ORG --format json +# Browse the index +cat $KNOWLEDGE_REPO_PATH/INDEX.md + +# Search across all notes +rg "biome" $KNOWLEDGE_REPO_PATH/ -# AI agents can query this on session start or when exploring a new area +# Read a specific note +cat $KNOWLEDGE_REPO_PATH/biome-mdx-ignored.md ``` **Adding shared knowledge:** ```bash -# Via the share-learning skill +# Via the share-learning skill (preferred — handles dedup + gh api write) /share-learning gotcha "Biome ignores .mdx files by default" - -# Manually via gh CLI -gh project item-create NUMBER --owner ORG --title "gotcha: Biome ignores .mdx" --body "Add .mdx to biome.json includes array" ``` ### Consumers -Two kinds of agents read this board: +Two kinds of agents read this corpus: -- **Dev-machine agents** (Claude Code) — read with the `gh project item-list` command above and post via `/share-learning`, which dedups against the board before creating an item. -- **darky** (the studio Slack bot) — reads the board **on-demand** through its `search_team_knowledge` tool (GraphQL `organization{projectV2}`), gated to questions that touch a team convention/decision/gotcha rather than fulltime ingestion. Wired in [darky PR #474](https://github.com/darkroomengineering/darky/pull/474); requires `DARKY_KNOWLEDGE_PROJECT_NUMBER` in darky's environment. darky keeps its own internal knowledge brain (`search_knowledge`) separate — this board is the cross-studio tier only. +- **Dev-machine agents** (Claude Code) — read via `cat INDEX.md` + `rg` across a local clone of the repo and post via `/share-learning`, which fetches INDEX.md for dedup before writing via `gh api`. +- **darky** (the studio Slack bot, now in `darkroomengineering/darkroom-os` under `darky-hermes/`; standalone `darky` repo frozen 2026-06-01) — reads team-knowledge **on-demand** via the GitHub REST contents API, gated to questions that touch a team convention/decision/gotcha. ### Best Practices -- Keep entries atomic — one learning per entry +- Keep notes atomic — one learning per file - Include the **why**, not just the **what** - Add tags for discoverability -- Review periodically — remove outdated entries -- If a gotcha gets fixed upstream, archive the entry +- Review periodically — remove outdated notes +- If a gotcha gets fixed upstream, supersede the note with a new one --- @@ -135,17 +152,11 @@ the team-wide tier (above). ```bash # Architecture decision -gh project item-create "$KNOWLEDGE_PROJECT_NUMBER" --owner darkroomengineering \ - --title "decision: Lenis over native smooth-scroll" \ - --body "Cross-browser consistency. Native smooth-scroll diverges on Safari/Firefox; Lenis normalizes inertia + delta math. See ADR-007." +/share-learning decision "Lenis over native smooth-scroll for cross-browser consistency" # Team convention -gh project item-create "$KNOWLEDGE_PROJECT_NUMBER" --owner darkroomengineering \ - --title "convention: API response shape" \ - --body "All API routes return { data, error } — never throw to the caller. Enforced by Biome rule in services/api/." +/share-learning convention "All API routes return { data, error } — never throw to the caller" # Cross-cutting gotcha -gh project item-create "$KNOWLEDGE_PROJECT_NUMBER" --owner darkroomengineering \ - --title "gotcha: Sanity UTC dates" \ - --body "Sanity API returns UTC dates — always convert to local before display. Affects all date renderers." +/share-learning gotcha "Sanity API returns UTC dates — always convert to local before display" ``` diff --git a/package.json b/package.json index 5087a9f..f9ec859 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "compose": "bun -e 'import { composeSettings } from \"./src/lib/compose-settings.ts\"; console.log(JSON.stringify(await composeSettings(\".\"), null, \"\\t\"))'", "check:cli-tools": "bun src/scripts/check-cli-tools.ts", "new-skill": "bun src/scripts/new-skill.ts", + "lint:knowledge": "bun src/scripts/lint-knowledge.ts", + "new-note": "bun src/scripts/new-note.ts", "proof": "bun src/scripts/proof.ts", "review-batch": "bun src/scripts/review-batch.ts" }, diff --git a/schemas/knowledge.schema.json b/schemas/knowledge.schema.json new file mode 100644 index 0000000..da04643 --- /dev/null +++ b/schemas/knowledge.schema.json @@ -0,0 +1,42 @@ +{ + "$id": "https://raw.githubusercontent.com/darkroomengineering/cc-settings/main/schemas/knowledge.schema.json", + "title": "Darkroom knowledge note frontmatter", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "kind": { + "type": "string", + "enum": [ + "decision", + "convention", + "gotcha", + "incident", + "pattern" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "added-by": { + "type": "string", + "minLength": 1 + }, + "supersedes": { + "type": "string" + } + }, + "required": [ + "name", + "kind", + "added-by" + ], + "additionalProperties": {} +} diff --git a/skills/README.md b/skills/README.md index e858fa2..04521b7 100644 --- a/skills/README.md +++ b/skills/README.md @@ -95,7 +95,7 @@ These fork context for clean exploration: ### The `share-learning` Skill (Manual, Team-Only) -This skill posts architectural decisions, cross-cutting gotchas, and conventions to the shared GitHub Project board so other team members' AI agents pick them up on session start. Invoke it manually or follow the `promote-memory` hook nudge that fires when a `project` or `feedback` auto-memory is written. +This skill posts architectural decisions, cross-cutting gotchas, and conventions to the shared team-knowledge repo so other team members' AI agents can find them via `rg`/`cat` on a local clone. Invoke it manually or follow the `promote-memory` hook nudge that fires when a `project` or `feedback` auto-memory is written. For personal notes, the cc-settings auto-memory system (stored under `~/.claude/projects//memory/`) captures them automatically. See the "auto memory" section of `~/.claude/CLAUDE.md` for how that works. The rule of thumb: if another team member's AI agent would benefit from knowing it, use `/share-learning`; otherwise let auto-memory handle it. diff --git a/skills/fix/SKILL.md b/skills/fix/SKILL.md index dc8d694..3b581b4 100644 --- a/skills/fix/SKILL.md +++ b/skills/fix/SKILL.md @@ -22,7 +22,7 @@ You are in **Maestro orchestration mode**. Delegate immediately to specialized a 3. **Diagnose** - Analyze findings to identify root cause 4. **Implement** - Spawn `implementer` agent to fix the issue 5. **Verify** - Spawn `tester` agent to confirm the fix -6. **Learn** - If this was a non-obvious fix, the auto-memory system in `~/.claude/CLAUDE.md` captures it; for team-wide gotchas use `/share-learning` to post to the GitHub Project board +6. **Learn** - If this was a non-obvious fix, the auto-memory system in `~/.claude/CLAUDE.md` captures it; for team-wide gotchas use `/share-learning` to post to the team-knowledge repo ## Scope Rules diff --git a/skills/share-learning/SKILL.md b/skills/share-learning/SKILL.md index 7ec2ef9..b358580 100644 --- a/skills/share-learning/SKILL.md +++ b/skills/share-learning/SKILL.md @@ -1,15 +1,15 @@ --- name: share-learning -description: Promote a team-relevant learning to the shared GitHub Project knowledge board, deduping against existing items first. Triggers "share this", "promote to the team board", "add to the knowledge base", or after a gotcha/decision/convention worth team-wide awareness. +description: Promote a team-relevant learning to the shared team-knowledge repo, deduping against existing notes first. Triggers "share this", "promote to the team repo", "add to the knowledge base", or after a gotcha/decision/convention worth team-wide awareness. allowed-tools: - - Bash(gh project item-list*) - - Bash(gh project item-create*) + - Bash(gh api*) + - Bash(base64*) --- # share-learning -Promote a single learning to the team's shared GitHub Project knowledge board — the -"public corpus" tier of the knowledge system (see `docs/knowledge-system.md`). Local, +Promote a single learning to the team's shared knowledge repo (`darkroomengineering/team-knowledge`) — +the "public corpus" tier of the knowledge system (see `docs/knowledge-system.md`). Local, personal knowledge stays in auto-memory; this skill is only for things another teammate's agent would benefit from knowing. @@ -23,48 +23,73 @@ post it. ## Inputs -Invoked as `/share-learning ""` where `` is one of: -`decision`, `convention`, `gotcha`, `incident`, `pattern` (the board's **Type** field). +Invoked as `/share-learning ""` where `` is one of: +`decision`, `convention`, `gotcha`, `incident`, `pattern`. -If invoked without arguments, infer the most likely `type` and a concise `text` from the +If invoked without arguments, infer the most likely `kind` and a concise `text` from the recent conversation, then show the user what you intend to post and confirm before posting. ## Steps -1. **Resolve the board.** Read `$KNOWLEDGE_PROJECT_NUMBER` from the environment; the owner - is `darkroomengineering`. If `$KNOWLEDGE_PROJECT_NUMBER` is unset, stop and tell the user - to set it (see `docs/knowledge-system.md` for setup) — do not guess a project number. +1. **Resolve the repo.** Read `$KNOWLEDGE_REPO` from the environment; default is + `darkroomengineering/team-knowledge`. If `$KNOWLEDGE_REPO` is unset and you do not want + to use the default, stop and tell the user to set it (see `docs/knowledge-system.md` for + setup). -2. **Dedup against the board (required).** List existing items: +2. **Dedup against the index (required).** Fetch the current index: ```bash - gh project item-list "$KNOWLEDGE_PROJECT_NUMBER" --owner darkroomengineering --format json + gh api repos/$KNOWLEDGE_REPO/contents/INDEX.md --jq .content | base64 -d ``` - Scan the returned titles and bodies for an item that already captures this learning - (semantic near-duplicate, not just exact match). If you find one: - - Show the user the existing item. + Scan the note names and titles in the index for an entry that already captures this + learning (semantic near-duplicate, not just exact match). If you find one: + - Show the user the existing note name and its summary line. - Ask whether to **skip** (already covered), **post anyway** (genuinely distinct), or **revise** your proposed entry to complement it. Only continue to step 3 once the user has chosen, or when there is clearly no duplicate. -3. **Post.** Create the item: +3. **Post.** Derive a `name` (kebab-case slug from the essence of the learning). Assemble + the note: + - Frontmatter: `name` = the slug; `kind` from the argument; `added-by` from + `gh api user --jq .login` (fall back to `git config user.name` if that fails); + `tags` optional; `supersedes` only when this note replaces an existing one. + - Body: what happened + why it matters + how to apply it. One learning per note, + atomic and self-contained. + + If creating a new note: ```bash - gh project item-create "$KNOWLEDGE_PROJECT_NUMBER" --owner darkroomengineering \ - --title ": " \ - --body "" + NOTE="--- + name: + kind: + tags: [, ] + added-by: + --- + + " + + gh api -X PUT repos/$KNOWLEDGE_REPO/contents/.md \ + -f message="knowledge: add " \ + -f content="$(printf '%s' "$NOTE" | base64)" ``` - Keep entries atomic (one learning per item) and self-contained, per - `docs/knowledge-system.md`. + If updating an existing note, first GET its current `sha`: + ```bash + SHA=$(gh api repos/$KNOWLEDGE_REPO/contents/.md --jq .sha) + gh api -X PUT repos/$KNOWLEDGE_REPO/contents/.md \ + -f message="knowledge: update " \ + -f content="$(printf '%s' "$NOTE" | base64)" \ + -f sha="$SHA" + ``` -4. **Report.** Surface the created item's URL to the user. +4. **Report.** Surface the blob URL to the user: + `https://github.com/$KNOWLEDGE_REPO/blob/main/.md` ## Notes -- This skill posts to a shared, team-visible board — treat it like publishing. Never post +- This skill posts to a shared, team-visible repo — treat it like publishing. Never post secrets, credentials, or anything from `.env`. When unsure whether something is team-relevant, ask the user rather than over-sharing. - The dedup step is what makes this more than a `gh` wrapper: you are exercising judgment diff --git a/src/lib/lint-knowledge.ts b/src/lib/lint-knowledge.ts new file mode 100644 index 0000000..a9c2d99 --- /dev/null +++ b/src/lib/lint-knowledge.ts @@ -0,0 +1,162 @@ +// Knowledge-note linter. Validates frontmatter in each .md file in a +// team-knowledge repo directory. +// +// KnowledgeSeverity: +// error — blocks (CI fails, lint:knowledge exits non-zero) +// warning — surfaced but non-blocking + +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; +import { KnowledgeFrontmatter } from "../schemas/knowledge.ts"; +import { extractFrontmatterBlock, parseFrontmatterStrict } from "./frontmatter.ts"; + +export type KnowledgeSeverity = "error" | "warning"; + +export interface KnowledgeFinding { + note: string; + severity: KnowledgeSeverity; + rule: string; + message: string; +} + +export interface KnowledgeLintResult { + findings: KnowledgeFinding[]; + noteCount: number; +} + +// Files to skip at the root of the knowledge repo (repo meta, not notes). +const SKIP_FILES = new Set(["README.md", "INDEX.md", "CONTRIBUTING.md"]); + +function bodyAfterFrontmatter(md: string): string { + // Body starts after the closing --- of the frontmatter block. + const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)/.exec(md); + return match ? (match[1] ?? "") : md; +} + +async function lintOne( + dir: string, + filename: string, + allNames: Set, +): Promise { + const findings: KnowledgeFinding[] = []; + const stem = basename(filename, extname(filename)); + const notePath = join(dir, filename); + + const text = await readFile(notePath, "utf8"); + const block = extractFrontmatterBlock(text); + + if (!block) { + findings.push({ + note: filename, + severity: "error", + rule: "frontmatter-missing", + message: "no `---`-delimited YAML frontmatter at top of file", + }); + return findings; + } + + const { data: parsed, errors: yamlErrors } = parseFrontmatterStrict(text); + if (yamlErrors.length > 0) { + for (const e of yamlErrors) { + findings.push({ + note: filename, + severity: "error", + rule: "yaml-parse", + message: `${e.code ?? "YAML"} at line ${e.line ?? "?"}, col ${e.col ?? "?"}: ${e.message}`, + }); + } + return findings; + } + + const result = KnowledgeFrontmatter.safeParse(parsed); + if (!result.success) { + for (const issue of result.error.issues) { + findings.push({ + note: filename, + severity: "error", + rule: "schema", + message: `${issue.path.join(".") || "(root)"}: ${issue.message}`, + }); + } + return findings; + } + + const fm = result.data; + + // name must equal the filename stem. + if (fm.name !== stem) { + findings.push({ + note: filename, + severity: "error", + rule: "name-filename-mismatch", + message: `frontmatter name "${fm.name}" does not match filename stem "${stem}"`, + }); + } + + // supersedes must reference a name that exists in the directory. + if (fm.supersedes !== undefined && !allNames.has(fm.supersedes)) { + findings.push({ + note: filename, + severity: "warning", + rule: "supersedes-unknown", + message: `supersedes "${fm.supersedes}" does not match any note name in this directory`, + }); + } + + // Body must be non-empty (meaningful content after frontmatter). + const body = bodyAfterFrontmatter(text).trim(); + if (!body) { + findings.push({ + note: filename, + severity: "error", + rule: "empty-body", + message: "note body is empty — add what/why/how-to-apply content", + }); + } + + return findings; +} + +export async function lintKnowledgeDir(dir: string): Promise { + if (!existsSync(dir)) { + return { findings: [], noteCount: 0 }; + } + + const findings: KnowledgeFinding[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + + const mdFiles = entries + .filter((e) => e.isFile() && e.name.endsWith(".md") && !SKIP_FILES.has(e.name)) + .map((e) => e.name); + + // Build a set of all note stems for supersedes cross-reference checks. + const allNames = new Set(mdFiles.map((f) => basename(f, ".md"))); + + for (const filename of mdFiles) { + findings.push(...(await lintOne(dir, filename, allNames))); + } + + return { findings, noteCount: mdFiles.length }; +} + +export function formatKnowledgeFindings(result: KnowledgeLintResult): string { + const errors = result.findings.filter((f) => f.severity === "error"); + const warnings = result.findings.filter((f) => f.severity === "warning"); + + const lines: string[] = []; + lines.push(`Linted ${result.noteCount} note(s).`); + + for (const f of result.findings) { + const icon = f.severity === "error" ? "✖" : "⚠"; + lines.push(` ${icon} ${f.note} [${f.rule}] ${f.message}`); + } + + lines.push(""); + lines.push(`${errors.length} error(s), ${warnings.length} warning(s).`); + return lines.join("\n"); +} + +export function hasKnowledgeErrors(result: KnowledgeLintResult): boolean { + return result.findings.some((f) => f.severity === "error"); +} diff --git a/src/schemas/emit.ts b/src/schemas/emit.ts index 9a88398..eeca02a 100644 --- a/src/schemas/emit.ts +++ b/src/schemas/emit.ts @@ -13,6 +13,7 @@ import { z } from "zod"; import { AgentFrontmatter } from "./agent.ts"; import { ClaudeJson } from "./claude-json.ts"; import { HooksConfig } from "./hooks-config.ts"; +import { KnowledgeFrontmatter } from "./knowledge.ts"; import { ProfileFrontmatter } from "./profile.ts"; import { Settings } from "./settings.ts"; import { SkillFrontmatter } from "./skill.ts"; @@ -65,6 +66,12 @@ export const targets: Target[] = [ id: `${SCHEMA_BASE}/profile.schema.json`, title: "Darkroom profile frontmatter", }, + { + file: "knowledge.schema.json", + schema: KnowledgeFrontmatter, + id: `${SCHEMA_BASE}/knowledge.schema.json`, + title: "Darkroom knowledge note frontmatter", + }, ]; // Single source of truth for a target's emitted JSON text — used by the CLI diff --git a/src/schemas/knowledge.ts b/src/schemas/knowledge.ts new file mode 100644 index 0000000..d04625a --- /dev/null +++ b/src/schemas/knowledge.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +// Knowledge note frontmatter lives at the top of each .md file in the +// team-knowledge repo. The content between the `---` delimiters is YAML, +// parsed separately. This schema validates the parsed object. + +// The five knowledge kinds: +// decision — an architectural or product decision that was made +// convention — a team-wide naming/style/process rule +// gotcha — a non-obvious trap or foot-gun to watch out for +// incident — a post-mortem or incident record +// pattern — a reusable solution pattern +export const KnowledgeKind = z.enum(["decision", "convention", "gotcha", "incident", "pattern"]); + +export const KnowledgeFrontmatter = z + .object({ + name: z + .string() + .min(1) + .regex( + /^[a-z0-9]+(-[a-z0-9]+)*$/, + "name must be kebab-case (a-z, 0-9, segments joined by single hyphens)", + ), + kind: KnowledgeKind, + tags: z.array(z.string()).optional(), + "added-by": z.string().min(1), + supersedes: z.string().optional(), + + // Forward-compat: accept unknown keys rather than rejecting a note that + // uses a field added in a later schema revision. + }) + .passthrough(); + +export type KnowledgeFrontmatter = z.infer; +export type KnowledgeKind = z.infer; diff --git a/src/scripts/lint-knowledge.ts b/src/scripts/lint-knowledge.ts new file mode 100644 index 0000000..ceb3409 --- /dev/null +++ b/src/scripts/lint-knowledge.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env bun +// CLI for the knowledge-note linter. Exits non-zero on any error; warnings pass. +// +// Usage: +// bun run lint:knowledge # needs KNOWLEDGE_REPO_PATH set +// bun run lint:knowledge # lints a specific directory +// KNOWLEDGE_REPO_PATH=/path bun run lint:knowledge + +import { resolve } from "node:path"; +import { + formatKnowledgeFindings, + hasKnowledgeErrors, + lintKnowledgeDir, +} from "../lib/lint-knowledge.ts"; + +async function main(): Promise { + const arg = process.argv[2]; + const envPath = process.env.KNOWLEDGE_REPO_PATH; + + if (!arg && !envPath) { + console.log("No knowledge directory specified."); + console.log("Set KNOWLEDGE_REPO_PATH or pass a dir:"); + console.log(" bun run lint:knowledge "); + console.log(" KNOWLEDGE_REPO_PATH=/path/to/repo bun run lint:knowledge"); + return 0; + } + + const knowledgeDir = arg ? resolve(arg) : resolve(envPath as string); + const result = await lintKnowledgeDir(knowledgeDir); + console.log(formatKnowledgeFindings(result)); + return hasKnowledgeErrors(result) ? 1 : 0; +} + +process.exit(await main()); diff --git a/src/scripts/new-note.ts b/src/scripts/new-note.ts new file mode 100644 index 0000000..b6e4796 --- /dev/null +++ b/src/scripts/new-note.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env bun +/** + * new-note — scaffold a new team-knowledge note + * + * Usage: bun src/scripts/new-note.ts [--dir ] + * + * Creates .md with starter frontmatter and body template. + * kind must be one of: decision | convention | gotcha | incident | pattern + * Target dir defaults to KNOWLEDGE_REPO_PATH env var if --dir is not given. + */ + +import { existsSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const VALID_KINDS = ["decision", "convention", "gotcha", "incident", "pattern"] as const; +type KnowledgeKind = (typeof VALID_KINDS)[number]; + +const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +function usage(): never { + console.error("Usage: bun src/scripts/new-note.ts [--dir ]"); + console.error(` name — kebab-case slug (e.g. use-rsc-for-data-fetching)`); + console.error(` kind — one of: ${VALID_KINDS.join(" | ")}`); + console.error(` --dir — target directory (or set KNOWLEDGE_REPO_PATH)`); + process.exit(1); +} + +function getGitUserName(): string { + try { + const result = Bun.spawnSync(["git", "config", "user.name"]); + if (result.exitCode === 0) { + const name = new TextDecoder().decode(result.stdout).trim(); + if (name) return name; + } + } catch { + // fall through + } + return process.env.USER ?? "unknown"; +} + +function parseArgs(): { name: string; kind: KnowledgeKind; dir: string | null } { + const args = process.argv.slice(2); + + const dirIdx = args.indexOf("--dir"); + let dir: string | null = null; + if (dirIdx !== -1) { + dir = args[dirIdx + 1] ?? null; + args.splice(dirIdx, 2); + } + + const [name, kind] = args; + return { name: name ?? "", kind: (kind ?? "") as KnowledgeKind, dir }; +} + +function main() { + const { name, kind, dir: dirFlag } = parseArgs(); + + if (!name) { + console.error("Error: note name is required"); + usage(); + } + + if (!NAME_PATTERN.test(name)) { + console.error(`Error: name "${name}" is invalid. Must be kebab-case (a-z, 0-9, hyphens only).`); + process.exit(1); + } + + if (!kind) { + console.error("Error: kind is required"); + usage(); + } + + if (!VALID_KINDS.includes(kind as KnowledgeKind)) { + console.error(`Error: kind "${kind}" is invalid. Must be one of: ${VALID_KINDS.join(", ")}`); + process.exit(1); + } + + const envPath = process.env.KNOWLEDGE_REPO_PATH; + if (!dirFlag && !envPath) { + console.error("Error: no target directory. Pass --dir or set KNOWLEDGE_REPO_PATH."); + process.exit(1); + } + + const targetDir = resolve(dirFlag ?? (envPath as string)); + const outPath = join(targetDir, `${name}.md`); + + if (existsSync(outPath)) { + console.error( + `Error: ${outPath} already exists. Choose a different name or edit the existing note.`, + ); + process.exit(1); + } + + const addedBy = getGitUserName(); + + const content = `--- +name: ${name} +kind: ${kind} +tags: [] +added-by: ${addedBy} +--- + +## What + +TODO: describe what this note is about. + +## Why + +TODO: explain the reasoning or motivation. + +## How to apply + +TODO: describe how to put this knowledge into practice. +`; + + writeFileSync(outPath, content, "utf-8"); + + console.log(`Created ${outPath}`); + console.log(""); + console.log("Next steps:"); + console.log(` 1. Edit ${outPath} — fill in What / Why / How to apply`); + console.log(" 2. bun run lint:knowledge — validate (0 errors required)"); + console.log(" 3. git add, commit, and push"); +} + +main(); diff --git a/tests/lint-knowledge.test.ts b/tests/lint-knowledge.test.ts new file mode 100644 index 0000000..f861b24 --- /dev/null +++ b/tests/lint-knowledge.test.ts @@ -0,0 +1,375 @@ +// Knowledge-note linter tests. Validates each rule fires on the correct shape +// and that the happy-path passes cleanly. + +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + formatKnowledgeFindings, + hasKnowledgeErrors, + lintKnowledgeDir, +} from "../src/lib/lint-knowledge.ts"; + +async function sandbox(): Promise { + return mkdtemp(join(tmpdir(), "cc-lint-knowledge-")); +} + +async function writeNote(root: string, filename: string, body: string): Promise { + await writeFile(join(root, filename), body); +} + +const goodNote = (name: string) => + `--- +name: ${name} +kind: convention +added-by: test-user +--- + +## What + +A concise description of the convention. + +## Why + +Reason for adopting this convention. + +## How to apply + +Apply it like this. +`; + +describe("lintKnowledgeDir — happy path", () => { + test("clean note produces no findings", async () => { + const dir = await sandbox(); + try { + await writeNote(dir, "my-note.md", goodNote("my-note")); + const result = await lintKnowledgeDir(dir); + expect(result.findings).toEqual([]); + expect(result.noteCount).toBe(1); + expect(hasKnowledgeErrors(result)).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("missing dir returns empty result", async () => { + const result = await lintKnowledgeDir("/nonexistent/knowledge-xyz"); + expect(result.noteCount).toBe(0); + expect(result.findings).toEqual([]); + }); + + test("README.md, INDEX.md and CONTRIBUTING.md are skipped", async () => { + const dir = await sandbox(); + try { + await writeNote(dir, "README.md", "no frontmatter here"); + await writeNote(dir, "INDEX.md", "no frontmatter here"); + await writeNote(dir, "CONTRIBUTING.md", "no frontmatter here"); + const result = await lintKnowledgeDir(dir); + expect(result.noteCount).toBe(0); + expect(result.findings).toEqual([]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("clean multi-note dir produces 0 errors", async () => { + const dir = await sandbox(); + try { + await writeNote(dir, "note-one.md", goodNote("note-one")); + await writeNote(dir, "note-two.md", goodNote("note-two")); + await writeNote(dir, "note-three.md", goodNote("note-three")); + const result = await lintKnowledgeDir(dir); + expect(hasKnowledgeErrors(result)).toBe(false); + expect(result.noteCount).toBe(3); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("lintKnowledgeDir — missing/invalid kind", () => { + test("missing kind → error", async () => { + const dir = await sandbox(); + try { + await writeNote( + dir, + "bad-note.md", + `--- +name: bad-note +added-by: test-user +--- + +Some body content here. +`, + ); + const result = await lintKnowledgeDir(dir); + expect(hasKnowledgeErrors(result)).toBe(true); + expect(result.findings.some((f) => f.rule === "schema")).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("invalid kind value → error", async () => { + const dir = await sandbox(); + try { + await writeNote( + dir, + "bad-kind.md", + `--- +name: bad-kind +kind: unknown-type +added-by: test-user +--- + +Body content. +`, + ); + const result = await lintKnowledgeDir(dir); + expect(hasKnowledgeErrors(result)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("lintKnowledgeDir — name/filename mismatch", () => { + test("name differs from filename stem → error", async () => { + const dir = await sandbox(); + try { + // filename is alpha.md but frontmatter name is beta + await writeNote(dir, "alpha.md", goodNote("beta")); + const result = await lintKnowledgeDir(dir); + expect(result.findings.some((f) => f.rule === "name-filename-mismatch")).toBe(true); + expect(hasKnowledgeErrors(result)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("lintKnowledgeDir — non-kebab name", () => { + test("CamelCase name → error", async () => { + const dir = await sandbox(); + try { + await writeNote( + dir, + "BadName.md", + `--- +name: BadName +kind: pattern +added-by: test-user +--- + +Body content. +`, + ); + const result = await lintKnowledgeDir(dir); + expect(hasKnowledgeErrors(result)).toBe(true); + // Schema rule fires for invalid kebab-case name + expect(result.findings.some((f) => f.rule === "schema")).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("name with underscore → error", async () => { + const dir = await sandbox(); + try { + await writeNote( + dir, + "bad_name.md", + `--- +name: bad_name +kind: gotcha +added-by: test-user +--- + +Body content. +`, + ); + const result = await lintKnowledgeDir(dir); + expect(hasKnowledgeErrors(result)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("lintKnowledgeDir — empty body", () => { + test("note with no body content → error", async () => { + const dir = await sandbox(); + try { + await writeNote( + dir, + "empty-body.md", + `--- +name: empty-body +kind: decision +added-by: test-user +--- +`, + ); + const result = await lintKnowledgeDir(dir); + expect(result.findings.some((f) => f.rule === "empty-body")).toBe(true); + expect(hasKnowledgeErrors(result)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("note with only whitespace body → error", async () => { + const dir = await sandbox(); + try { + await writeNote( + dir, + "ws-body.md", + `--- +name: ws-body +kind: incident +added-by: test-user +--- + + + +`, + ); + const result = await lintKnowledgeDir(dir); + expect(result.findings.some((f) => f.rule === "empty-body")).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("lintKnowledgeDir — angle brackets in body are allowed", () => { + test("unescaped < or > in body is NOT flagged (knowledge notes are plain markdown)", async () => { + const dir = await sandbox(); + try { + await writeNote( + dir, + "angle-note.md", + `--- +name: angle-note +kind: convention +added-by: test-user +--- + +Use here, or a Slack ref like <#C123> — both are fine. +`, + ); + const result = await lintKnowledgeDir(dir); + expect(hasKnowledgeErrors(result)).toBe(false); + expect(result.findings).toEqual([]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("lintKnowledgeDir — supersedes warning", () => { + test("supersedes referencing missing note → warning", async () => { + const dir = await sandbox(); + try { + await writeNote( + dir, + "new-note.md", + `--- +name: new-note +kind: convention +added-by: test-user +supersedes: old-note +--- + +Body content here explaining the change. +`, + ); + const result = await lintKnowledgeDir(dir); + expect(result.findings.some((f) => f.rule === "supersedes-unknown")).toBe(true); + const finding = result.findings.find((f) => f.rule === "supersedes-unknown"); + expect(finding?.severity).toBe("warning"); + // It's a warning not an error + expect(hasKnowledgeErrors(result)).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("supersedes referencing existing note → no warning", async () => { + const dir = await sandbox(); + try { + await writeNote(dir, "old-convention.md", goodNote("old-convention")); + await writeNote( + dir, + "new-convention.md", + `--- +name: new-convention +kind: convention +added-by: test-user +supersedes: old-convention +--- + +Updated convention body. +`, + ); + const result = await lintKnowledgeDir(dir); + expect(result.findings.some((f) => f.rule === "supersedes-unknown")).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("lintKnowledgeDir — missing frontmatter", () => { + test("no frontmatter → error", async () => { + const dir = await sandbox(); + try { + await writeNote(dir, "no-fm.md", "# Just a heading\n\nNo frontmatter here."); + const result = await lintKnowledgeDir(dir); + expect(result.findings.some((f) => f.rule === "frontmatter-missing")).toBe(true); + expect(hasKnowledgeErrors(result)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("lintKnowledgeDir — missing added-by", () => { + test("missing added-by → error", async () => { + const dir = await sandbox(); + try { + await writeNote( + dir, + "no-author.md", + `--- +name: no-author +kind: decision +--- + +Body content here. +`, + ); + const result = await lintKnowledgeDir(dir); + expect(hasKnowledgeErrors(result)).toBe(true); + expect(result.findings.some((f) => f.rule === "schema")).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("formatKnowledgeFindings", () => { + test("emits header and rule lines", async () => { + const dir = await sandbox(); + try { + await writeNote(dir, "alpha.md", goodNote("beta")); // mismatch + const result = await lintKnowledgeDir(dir); + const out = formatKnowledgeFindings(result); + expect(out).toContain("Linted 1 note(s)."); + expect(out).toContain("[name-filename-mismatch]"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +});