From a66e3d347a5722f5c9bc585cc90ede1eb7064536 Mon Sep 17 00:00:00 2001 From: Ryan Sheppard Date: Wed, 8 Jul 2026 10:38:20 -0400 Subject: [PATCH 1/2] Add usage-driven knowledge-base curation loop Close the retrieval loop for the shared code/write knowledge bases: record trajectories and misses, then turn them into reviewed curation edits with an automatic prompt so nothing depends on remembering to run a review. - Enrich fetch telemetry (log-knowledge-fetch.sh) with session_id + cwd so fetches group into trajectories and co-fetch pairs are detectable. - kb_gap.py: code/write skills record a gap when KB traversal finds no fit, appended to /.stats/gaps.jsonl. - kb_reflect.py: mine fetches + gaps into candidate edits (new leaves from gaps, missing related: links from co-fetches, priority signals, cold leaves) against a last_reviewed watermark; --mark-reviewed advances it. - knowledge-reflect skill + /knowledge-reflect command: present candidates, apply only on approval, regen+validate, advance watermark. Human approves every write; fetch count treated as a weak proxy; foundational cold leaves protected from demotion. - kb-reflect-nudge.sh (SessionStart): prompt automatically when pending gaps cross a threshold or a review is stale; silent otherwise. Dual-agent + KB validators pass; settings template renders valid JSON in both .work branches. --- src/dot_agents/scripts/kb_gap.py | 57 ++++ src/dot_agents/scripts/kb_reflect.py | 286 ++++++++++++++++++ src/dot_agents/skills/code/SKILL.md | 10 + .../skills/knowledge-reflect/SKILL.md | 111 +++++++ .../skills/knowledge-stats/SKILL.md | 2 + src/dot_agents/skills/write/SKILL.md | 10 + src/dot_claude/commands/knowledge-reflect.md | 62 ++++ src/dot_claude/commands/write.md | 10 + .../hooks/executable_kb-reflect-nudge.sh | 42 +++ .../hooks/executable_log-knowledge-fetch.sh | 11 +- src/dot_claude/settings.json.tmpl | 12 +- 11 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 src/dot_agents/scripts/kb_gap.py create mode 100644 src/dot_agents/scripts/kb_reflect.py create mode 100644 src/dot_agents/skills/knowledge-reflect/SKILL.md create mode 100644 src/dot_claude/commands/knowledge-reflect.md create mode 100644 src/dot_claude/hooks/executable_kb-reflect-nudge.sh diff --git a/src/dot_agents/scripts/kb_gap.py b/src/dot_agents/scripts/kb_gap.py new file mode 100644 index 0000000..6e526a5 --- /dev/null +++ b/src/dot_agents/scripts/kb_gap.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Record a knowledge-base gap: a task where no existing leaf fit. + +Appended to `~/.agents/knowledge//.stats/gaps.jsonl`, one JSON object +per line. `/knowledge-reflect` reads these as new-leaf candidates and the +SessionStart nudge counts unreviewed ones to decide when to prompt. + +Domain must be `code` or `write`. Override the stats root with `--stats-root` +(used by tests); default is `~/.agents/knowledge`. +""" +import argparse +import json +import pathlib +import sys +from datetime import datetime, timezone + +DOMAINS = {"code", "write"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--domain", required=True, choices=sorted(DOMAINS)) + parser.add_argument("--note", required=True, help="one line: what was missing") + parser.add_argument("--task", default="", help="short task context") + parser.add_argument("--session-id", default="") + parser.add_argument( + "--stats-root", + type=pathlib.Path, + default=pathlib.Path.home() / ".agents" / "knowledge", + ) + args = parser.parse_args() + + note = args.note.strip() + if not note: + print("FAIL: --note must be non-empty", file=sys.stderr) + return 1 + + stats_dir = args.stats_root / args.domain / ".stats" + stats_dir.mkdir(parents=True, exist_ok=True) + log = stats_dir / "gaps.jsonl" + + event = { + "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "domain": args.domain, + "note": note, + "task": args.task.strip(), + "session_id": args.session_id.strip(), + } + with log.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(event, ensure_ascii=False) + "\n") + + print(f"Recorded {args.domain} gap → {log}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/dot_agents/scripts/kb_reflect.py b/src/dot_agents/scripts/kb_reflect.py new file mode 100644 index 0000000..0acc8e7 --- /dev/null +++ b/src/dot_agents/scripts/kb_reflect.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Mine knowledge-base telemetry into curation candidates for review. + +Reads three files under `/.stats/` (override with `--stats-dir`): + +- `fetches.jsonl` — one `{ts, path, domain, session_id?, cwd?}` per KB Read. +- `gaps.jsonl` — one `{ts, domain, note, task?, session_id?}` per recorded miss. +- `last_reviewed` — ISO-8601 UTC watermark written by `--mark-reviewed`. + +Emits a markdown report (or `--json`) of candidate edits, never mutating the +corpus. `/knowledge-reflect` presents it; the human approves each edit. The +deterministic signals here are proposals, not decisions — fetch counts are a +weak proxy, so priority and deletion calls stay with the reviewer. + +`--mark-reviewed` writes the watermark to now and exits; run it after a review +so the SessionStart nudge resets. +""" +import argparse +import json +import pathlib +import re +import sys +from collections import Counter, defaultdict +from datetime import datetime, timezone +from itertools import combinations + +MIN_COFETCH_SESSIONS = 2 +HOT_FETCH_MIN = 3 +COLD_KEEP_PRIORITY = 2 + + +class Leaf: + def __init__(self, slug: str, priority: int, related: list[str], categories: list[str]): + self.slug = slug + self.priority = priority + self.related = related + self.categories = categories + + +def parse_leaf(path: pathlib.Path) -> Leaf | None: + text = path.read_text(encoding="utf-8") + m = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) + if not m: + return None + fm = m.group(1) + if re.search(r"^type:\s*moc\s*$", fm, re.MULTILINE): + return None + + def scalar(name: str) -> str: + mm = re.search(rf"^{name}:\s*(.*)$", fm, re.MULTILINE) + return mm.group(1).strip() if mm else "" + + def list_field(name: str) -> list[str]: + inline = re.search(rf"^{name}:\s*\[(.*?)\]\s*$", fm, re.MULTILINE) + if inline: + raw = inline.group(1).strip() + return [x.strip() for x in raw.split(",") if x.strip()] if raw else [] + block = re.search(rf"^{name}:\s*\n((?: - .*\n?)+)", fm, re.MULTILINE) + if block: + return [ln.removeprefix(" - ").strip() for ln in block.group(1).splitlines() if ln.strip()] + return [] + + slug = scalar("slug") or path.stem + priority_raw = scalar("priority") + priority = int(priority_raw) if priority_raw.isdigit() else 3 + return Leaf(slug, priority, list_field("related"), list_field("categories")) + + +def load_leaves(knowledge_dir: pathlib.Path) -> dict[str, Leaf]: + leaves: dict[str, Leaf] = {} + for path in sorted(knowledge_dir.glob("*.md")): + if path.stem == "index": + continue + leaf = parse_leaf(path) + if leaf is not None: + leaves[leaf.slug] = leaf + return leaves + + +def read_jsonl(path: pathlib.Path) -> list[dict]: + if not path.exists(): + return [] + rows: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue + return rows + + +def read_watermark(stats_dir: pathlib.Path) -> str: + wm = stats_dir / "last_reviewed" + if not wm.exists(): + return "" + return wm.read_text(encoding="utf-8").strip() + + +def slug_of(path: str) -> str: + return pathlib.PurePath(path).stem + + +def build_report(knowledge_dir: pathlib.Path, stats_dir: pathlib.Path, domain: str) -> dict: + leaves = load_leaves(knowledge_dir) + leaf_slugs = set(leaves) + fetches = read_jsonl(stats_dir / "fetches.jsonl") + gaps = read_jsonl(stats_dir / "gaps.jsonl") + watermark = read_watermark(stats_dir) + + pending_gaps = [g for g in gaps if g.get("ts", "") > watermark] + + fetch_counts: Counter[str] = Counter() + session_leaves: dict[str, set[str]] = defaultdict(set) + for row in fetches: + slug = slug_of(row.get("path", "")) + if slug not in leaf_slugs: + continue + fetch_counts[slug] += 1 + sid = row.get("session_id", "") + if sid: + session_leaves[sid].add(slug) + + pair_sessions: Counter[tuple[str, str]] = Counter() + for slugs in session_leaves.values(): + for a, b in combinations(sorted(slugs), 2): + pair_sessions[(a, b)] += 1 + + missing_links = [] + for (a, b), n in pair_sessions.most_common(): + if n < MIN_COFETCH_SESSIONS: + continue + if b in leaves[a].related or a in leaves[b].related: + continue + missing_links.append({"a": a, "b": b, "sessions": n}) + + cold_demote = [] + cold_keep = [] + for slug, leaf in sorted(leaves.items(), key=lambda kv: (kv[1].priority, kv[0])): + if fetch_counts.get(slug, 0) > 0: + continue + record = {"slug": slug, "priority": leaf.priority} + if leaf.priority <= COLD_KEEP_PRIORITY: + cold_keep.append(record) + else: + cold_demote.append(record) + + hot_low_priority = [ + {"slug": slug, "priority": leaves[slug].priority, "fetches": count} + for slug, count in fetch_counts.most_common() + if count >= HOT_FETCH_MIN and leaves[slug].priority >= 4 + ] + + return { + "domain": domain, + "knowledge_dir": str(knowledge_dir), + "stats_dir": str(stats_dir), + "watermark": watermark or "(never reviewed — all telemetry is pending)", + "totals": { + "leaves": len(leaves), + "fetch_events": len(fetches), + "sessions": len(session_leaves), + "gap_events": len(gaps), + "pending_gaps": len(pending_gaps), + }, + "gap_candidates": pending_gaps, + "missing_links": missing_links, + "cold_demote": cold_demote, + "cold_keep": cold_keep, + "hot_low_priority": hot_low_priority, + } + + +def cap_list(items: list[str], limit: int = 15) -> list[str]: + if len(items) <= limit: + return items + return items[:limit] + [f"- …and {len(items) - limit} more"] + + +def render_markdown(report: dict) -> str: + t = report["totals"] + thin = t["fetch_events"] < 30 + lines = [ + f"# Knowledge Reflection — {report['domain']}", + "", + f"Knowledge dir: {report['knowledge_dir']}", + f"Watermark: {report['watermark']}", + f"Leaves: {t['leaves']} · Fetch events: {t['fetch_events']} · " + f"Sessions: {t['sessions']} · Gaps: {t['gap_events']} " + f"({t['pending_gaps']} pending)", + "", + ] + + lines += ["## New-leaf candidates (recorded gaps)", ""] + if report["gap_candidates"]: + for g in report["gap_candidates"]: + task = f" — task: {g['task']}" if g.get("task") else "" + lines.append(f"- {g.get('note', '(no note)')}{task} · {g.get('ts', '')}") + else: + lines.append("- (none pending)") + lines.append("") + + lines += ["## Missing `related:` links (co-fetched, not linked)", ""] + if report["missing_links"]: + for link in report["missing_links"]: + lines.append( + f"- `{link['a']}` ↔ `{link['b']}` — co-fetched in {link['sessions']} sessions" + ) + else: + lines.append("- (none — pairs need session_id on fetch events and ≥2 shared sessions)") + lines.append("") + + lines += ["## Priority signals (hot but low-priority — weak proxy, you decide)", ""] + if report["hot_low_priority"]: + for h in report["hot_low_priority"]: + lines.append(f"- `{h['slug']}` (p{h['priority']}) — fetched {h['fetches']}×; consider bump") + else: + lines.append("- (none)") + lines.append("") + + lines += ["## Cold leaves — demote/delete candidates (p3-5, never fetched)", ""] + if thin: + lines.append(f"- (skipped — only {t['fetch_events']} fetch events; cold lists need a fuller log to mean anything)") + elif report["cold_demote"]: + lines += cap_list([f"- `{c['slug']}` (p{c['priority']})" for c in report["cold_demote"]]) + else: + lines.append("- (none)") + lines.append("") + + keep = report["cold_keep"] + lines += ["## Cold but foundational — keep (p1-2, never fetched)", ""] + if thin: + lines.append(f"- (skipped — thin log; {len(keep)} p1-2 leaves currently unfetched)") + elif keep: + lines.append(f"{len(keep)} foundational leaves are cold. Cold ≠ useless — do not demote on fetch count:") + lines += cap_list([f"- `{c['slug']}` (p{c['priority']})" for c in keep]) + else: + lines.append("- (none)") + lines.append("") + + return "\n".join(lines) + + +def detect_domain(knowledge_dir: pathlib.Path) -> str: + name = knowledge_dir.name + if name not in {"code", "write"}: + print(f"FAIL: knowledge-dir basename must be 'code' or 'write'; got '{name}'", file=sys.stderr) + sys.exit(1) + return name + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + default_kb = pathlib.Path(__file__).resolve().parent.parent / "knowledge" / "code" + parser.add_argument("--knowledge-dir", type=pathlib.Path, default=default_kb) + parser.add_argument("--stats-dir", type=pathlib.Path, default=None) + parser.add_argument("--json", action="store_true") + parser.add_argument("--mark-reviewed", action="store_true") + args = parser.parse_args() + + kb = args.knowledge_dir.resolve() + if not kb.is_dir(): + print(f"FAIL: knowledge dir not found: {kb}", file=sys.stderr) + return 1 + domain = detect_domain(kb) + stats_dir = args.stats_dir.resolve() if args.stats_dir else kb / ".stats" + + if args.mark_reviewed: + stats_dir.mkdir(parents=True, exist_ok=True) + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + (stats_dir / "last_reviewed").write_text(now + "\n", encoding="utf-8") + print(f"[{domain}] watermark advanced → {now}") + return 0 + + report = build_report(kb, stats_dir, domain) + if args.json: + print(json.dumps(report, indent=2, ensure_ascii=False)) + else: + print(render_markdown(report)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/dot_agents/skills/code/SKILL.md b/src/dot_agents/skills/code/SKILL.md index be9e1ca..02f8679 100644 --- a/src/dot_agents/skills/code/SKILL.md +++ b/src/dot_agents/skills/code/SKILL.md @@ -41,6 +41,16 @@ If the task is trivial, skip the fetch entirely. Do not narrate. Do not explain. 5. **Read leaf files fully** before decisions. Follow `related:` fields when adjacent entries would strengthen the reasoning. 6. **Re-fetch as task shape shifts** — different subtasks need different entries. Knowledge is cheap to re-read. +## Knowledge Gaps (record misses) + +If you traverse the KB for a substantive task and no leaf adequately covers it, record the gap so `knowledge-reflect` can later turn it into a new-leaf candidate: + +```bash +python3 ~/.agents/scripts/kb_gap.py --domain code --note "" --task "" +``` + +Record a gap only for a genuine miss on substantive work — not when an adequate leaf exists, and not for trivial tasks you correctly skipped. One line per distinct gap. This is the signal that grows the corpus; the miss is worth more than the hit. + ## Audit Trail (MANDATORY) Before each Read of a knowledge file, state in one line WHAT you're fetching and WHY. Use the exact `KB:` prefix so the user can scan for these lines. diff --git a/src/dot_agents/skills/knowledge-reflect/SKILL.md b/src/dot_agents/skills/knowledge-reflect/SKILL.md new file mode 100644 index 0000000..2d5579f --- /dev/null +++ b/src/dot_agents/skills/knowledge-reflect/SKILL.md @@ -0,0 +1,111 @@ +--- +name: knowledge-reflect +description: Turn knowledge-base fetch/gap telemetry into reviewed curation edits — new-leaf candidates from recorded gaps, missing `related:` links from co-fetches, and priority/cold signals. Use when the SessionStart nudge flags pending curation, or when the user asks to reflect on / curate / prune the knowledge base from usage data. Proposes; the user approves every write. +--- + +# Knowledge Reflect + +## Purpose + +Closes the knowledge-base loop. The `code`/`write` skills generate trajectories +(fetches are logged; misses are recorded as gaps); this skill is the **Reflector + +Curator**: it reads that telemetry, proposes bounded curation edits, and — only +on your approval — applies them. It never rewrites the corpus on its own. + +Complements the family: +- `knowledge-stats` — raw fetch counts, read-only. [[knowledge-stats]] +- `knowledge-audit` — static structural health, read-only. [[knowledge-audit]] +- `knowledge-add` — writes a single new leaf. [[knowledge-add]] +- **`knowledge-reflect`** — usage-driven curation across the corpus, gated on approval. + +## When to Use + +- The SessionStart nudge printed "📚 Knowledge base — pending curation". +- User asks to "reflect on / curate / prune / review usage of" a KB. +- Periodically, to convert accumulated gaps and co-fetch patterns into edits. + +## Inputs + +Argument: `[domain]` — `code`, `write`, or omitted (reflect both, one section each). + +Telemetry lives at runtime under `~/.agents/knowledge//.stats/`: +`fetches.jsonl`, `gaps.jsonl`, `last_reviewed`. If a domain has no telemetry, +say so and skip it. + +## Flow + +1. **Mine.** For each domain, run: + ```bash + python3 ~/.agents/scripts/kb_reflect.py --knowledge-dir ~/.agents/knowledge/ + ``` + It reports five candidate buckets since the last-reviewed watermark. Present + the report; do not edit anything yet. + +2. **Walk candidates with the user, one bucket at a time.** For each, get an + explicit decision before touching a file: + + - **New-leaf candidates (recorded gaps)** → for each gap the user wants to + fill, hand off to `knowledge-add` (`/knowledge-add `), + citing the gap note as the source need. Skip gaps that are stale or already + covered — say why. + - **Missing `related:` links (co-fetched, not linked)** → for approved pairs, + add each slug to the other leaf's `related:` list. These are the cheapest, + highest-signal edits — two leaves repeatedly read together should point at + each other. + - **Priority signals (hot but low-priority)** → propose a bump, but flag + explicitly that fetch count is a weak proxy (Goodhart). The user decides. + - **Cold demote/delete (p3-5, never fetched)** → only actionable once the log + is substantial (the script suppresses this bucket on a thin log). Discuss + demotion or deletion per leaf; never batch. + - **Cold but foundational (p1-2, never fetched)** → informational only. Do + **not** demote on fetch count — foundational entries are often cold because + they are internalized, not useless. + +3. **Apply approved edits.** For any leaf whose `related:` or `priority` + frontmatter changed, or any leaf deleted, regenerate and validate: + ```bash + python3 ~/.agents/scripts/gen_mocs.py --knowledge-dir ~/.agents/knowledge/ + python3 ~/.agents/scripts/validate_kb.py --knowledge-dir ~/.agents/knowledge/ + ``` + Fix any validation errors before proceeding. (New leaves added via + `knowledge-add` already run this pair.) + +4. **Advance the watermark** so the nudge resets — even if you accepted nothing, + the review still happened: + ```bash + python3 ~/.agents/scripts/kb_reflect.py --knowledge-dir ~/.agents/knowledge/ --mark-reviewed + ``` + +5. **Contribution scope.** Steps 2-3 only touched `~/.agents/` on this machine. + For universal edits, sync into the chezmoi source and push (same mechanism as + `knowledge-add` step 8: `chezmoi $CFG add` new files, `re-add` regenerated + MOCs and edited leaves, then commit + push from the source repo). Leave + machine-specific edits local. + +6. **Report** what was proposed, what you applied, the new watermark, and the + contribution scope chosen. + +## Guardrails + +Self-improving loops optimize whatever signal they are given, so this one keeps a +human in the decision seat: + +- **Approve every write.** This skill proposes; the user disposes. No unattended + curation. +- **Fetch count is a weak proxy.** Never auto-adjust priority from counts. See + [[goodharts-law]] — the moment a metric becomes a target it stops measuring + what you cared about. +- **Cold ≠ useless.** Never demote or delete a foundational (p1-2) leaf because + it is unfetched. Protect corpus diversity. +- **Bounded edits only.** One leaf, one field at a time. No sweeping rewrites, + no recategorization without the user asking. +- **Read-mostly.** Mining and reporting are safe to run anytime; only steps 2-3 + mutate, and only with approval. + +## Rules + +- Always pass `--knowledge-dir ~/.agents/knowledge/` to every script. +- Never cross domains: a `code` reflection never touches `write` leaves. +- Never skip the regen + validate pair after a frontmatter or file change. +- Always advance the watermark at the end of a review, or the nudge will keep + firing on already-reviewed telemetry. diff --git a/src/dot_agents/skills/knowledge-stats/SKILL.md b/src/dot_agents/skills/knowledge-stats/SKILL.md index 1ae4d63..cbf4123 100644 --- a/src/dot_agents/skills/knowledge-stats/SKILL.md +++ b/src/dot_agents/skills/knowledge-stats/SKILL.md @@ -9,6 +9,8 @@ description: Report fetch frequency stats for a domain-scoped knowledge base fro Aggregates per-domain `~/.agents/knowledge//.stats/fetches.jsonl` (written by `log-knowledge-fetch.sh` PostToolUse hook on every Read of a `~/.agents/knowledge//*.md` file). Reports fetch counts split by leaf vs MOC. Cumulative, all-time. Read-only — never edits priorities. +To act on this telemetry — turn recorded gaps into new leaves, co-fetches into `related:` links, and counts into reviewed priority changes — use `knowledge-reflect` (proposes edits, you approve). This skill is the raw-count view; `knowledge-reflect` is the curation loop. + ## When to Use - User asks for knowledge fetch stats / metrics / "what gets fetched most" — ask which domain if unclear diff --git a/src/dot_agents/skills/write/SKILL.md b/src/dot_agents/skills/write/SKILL.md index 1e24ccf..3e20256 100644 --- a/src/dot_agents/skills/write/SKILL.md +++ b/src/dot_agents/skills/write/SKILL.md @@ -32,6 +32,16 @@ If unclear, ask which intent in one short message before proceeding. 4. Read leaf files fully before drafting, critiquing, or revising. 5. Re-fetch as the task shape shifts. +## Knowledge Gaps (record misses) + +If you traverse the KB for a substantive writing task and no leaf adequately covers it, record the gap so `knowledge-reflect` can later turn it into a new-leaf candidate: + +```bash +python3 ~/.agents/scripts/kb_gap.py --domain write --note "" --task "" +``` + +Record a gap only for a genuine miss on substantive work — not when an adequate leaf exists, and not for trivial tasks. One line per distinct gap. + ## Audit Trail Before each read of a writing-knowledge file, state one line with the `WB:` diff --git a/src/dot_claude/commands/knowledge-reflect.md b/src/dot_claude/commands/knowledge-reflect.md new file mode 100644 index 0000000..bc5db87 --- /dev/null +++ b/src/dot_claude/commands/knowledge-reflect.md @@ -0,0 +1,62 @@ +--- +description: Turn knowledge-base fetch/gap telemetry into reviewed curation edits (new leaves, related-links, priority signals). Proposes; you approve every write. +argument-hint: "[domain] — code, write, or omit for both" +--- + +# /knowledge-reflect $ARGUMENTS + +Close the knowledge-base loop: read usage telemetry, propose bounded curation +edits, apply only what the user approves. This is the **Reflector + Curator** for +the KB. The `code`/`write` skills produce the trajectories (fetches logged, misses +recorded as gaps); you turn them into edits. + +Argument `$ARGUMENTS` is an optional domain: `code`, `write`, or omitted (reflect +both, one section each). Telemetry lives at `~/.agents/knowledge//.stats/` +(`fetches.jsonl`, `gaps.jsonl`, `last_reviewed`). If a domain has none, say so and +skip it. + +## Flow + +1. **Mine.** For each domain: + ```bash + python3 ~/.agents/scripts/kb_reflect.py --knowledge-dir ~/.agents/knowledge/ + ``` + Present the report's five buckets. Edit nothing yet. + +2. **Walk candidates with the user, one bucket at a time** — explicit decision before any file changes: + - **New-leaf candidates (recorded gaps)** → hand approved ones to `/knowledge-add `, citing the gap note. Skip stale/already-covered gaps and say why. + - **Missing `related:` links (co-fetched, not linked)** → for approved pairs, add each slug to the other leaf's `related:`. Cheapest, highest-signal edit. + - **Priority signals (hot but low-priority)** → propose a bump; flag that fetch count is a weak proxy (Goodhart). User decides. + - **Cold demote/delete (p3-5, never fetched)** → only when the log is substantial (script suppresses on thin logs). Per-leaf, never batched. + - **Cold but foundational (p1-2, never fetched)** → informational only. Do NOT demote on fetch count. + +3. **Apply approved edits**, then for any changed/deleted leaf regenerate + validate: + ```bash + python3 ~/.agents/scripts/gen_mocs.py --knowledge-dir ~/.agents/knowledge/ + python3 ~/.agents/scripts/validate_kb.py --knowledge-dir ~/.agents/knowledge/ + ``` + Fix errors before continuing. (New leaves via `/knowledge-add` already ran this pair.) + +4. **Advance the watermark** so the nudge resets — even if nothing was accepted: + ```bash + python3 ~/.agents/scripts/kb_reflect.py --knowledge-dir ~/.agents/knowledge/ --mark-reviewed + ``` + +5. **Contribution scope.** Edits landed only in `~/.agents/` on this machine. For + universal edits, sync into the chezmoi source and push (same mechanism as + `/knowledge-add`: `chezmoi $CFG add` new files, `re-add` regenerated MOCs and + edited leaves, commit + push from the source repo). Leave machine-specific edits local. + +6. **Report** proposals, applied edits, the new watermark, and the contribution scope. + +## Guardrails + +- **Approve every write.** This command proposes; the user disposes. +- **Fetch count is a weak proxy.** Never auto-adjust priority from counts. +- **Cold ≠ useless.** Never demote/delete a foundational (p1-2) leaf for being unfetched. +- **Bounded edits only.** One leaf, one field at a time. No sweeping rewrites or recategorization unless asked. +- **Never cross domains**, and never skip regen + validate after a change. + +## Task + +$ARGUMENTS diff --git a/src/dot_claude/commands/write.md b/src/dot_claude/commands/write.md index c6fdfea..3f89585 100644 --- a/src/dot_claude/commands/write.md +++ b/src/dot_claude/commands/write.md @@ -36,6 +36,16 @@ If unclear, ask which intent in one short message before proceeding. 4. **Read leaf files fully** before writing/critiquing. 5. **Re-fetch** as the task shape shifts. +## Knowledge Gaps (record misses) + +If you traverse the KB for a substantive task and no leaf adequately covers it, record the gap so `/knowledge-reflect` can later turn it into a new-leaf candidate: + +```bash +python3 ~/.agents/scripts/kb_gap.py --domain write --note "" --task "" +``` + +Record a gap only for a genuine miss on substantive work — not when an adequate leaf exists, and not for trivial tasks. One line per distinct gap. + ## Audit Trail (MANDATORY) Before each Read of a writing-knowledge file, state in one line WHAT you're fetching and WHY. Use the `WB:` prefix (writing-base) so the user can scan for these lines. diff --git a/src/dot_claude/hooks/executable_kb-reflect-nudge.sh b/src/dot_claude/hooks/executable_kb-reflect-nudge.sh new file mode 100644 index 0000000..d717da8 --- /dev/null +++ b/src/dot_claude/hooks/executable_kb-reflect-nudge.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +cat >/dev/null 2>&1 + +gap_threshold=3 +stale_days=14 + +now_epoch=$(date +%s) +lines="" + +for domain in code write; do + stats_dir="$HOME/.agents/knowledge/$domain/.stats" + gaps="$stats_dir/gaps.jsonl" + watermark_file="$stats_dir/last_reviewed" + [ -f "$gaps" ] || continue + + watermark="" + [ -s "$watermark_file" ] && watermark=$(tr -d '[:space:]' < "$watermark_file") + + pending=$(jq -rR --arg w "$watermark" 'fromjson? | select(.ts > $w) | .ts' "$gaps" 2>/dev/null | grep -c .) + [ "$pending" -gt 0 ] || continue + + if [ -n "$watermark" ]; then + wm_epoch=$(date -d "$watermark" +%s 2>/dev/null || echo "$now_epoch") + days_since=$(( (now_epoch - wm_epoch) / 86400 )) + age="${days_since}d since last review" + else + days_since=99999 + age="never reviewed" + fi + + if [ "$pending" -ge "$gap_threshold" ] || [ "$days_since" -ge "$stale_days" ]; then + noun="gap notes" + [ "$pending" -eq 1 ] && noun="gap note" + lines="${lines}- ${domain}: ${pending} ${noun} pending (${age})"$'\n' + fi +done + +[ -n "$lines" ] || exit 0 + +printf '📚 Knowledge base — pending curation:\n%sRun /knowledge-reflect to review. Recorded misses become new-leaf candidates; nothing changes without your approval.\n' "$lines" +exit 0 diff --git a/src/dot_claude/hooks/executable_log-knowledge-fetch.sh b/src/dot_claude/hooks/executable_log-knowledge-fetch.sh index 4d3d932..00cbcd1 100644 --- a/src/dot_claude/hooks/executable_log-knowledge-fetch.sh +++ b/src/dot_claude/hooks/executable_log-knowledge-fetch.sh @@ -16,6 +16,15 @@ stats_dir="$HOME/.agents/knowledge/$domain/.stats" mkdir -p "$stats_dir" ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) -jq -nc --arg ts "$ts" --arg path "$file_path" --arg domain "$domain" '{ts: $ts, path: $path, domain: $domain}' >> "$stats_dir/fetches.jsonl" +session_id=$(echo "$input" | jq -r '.session_id // empty') +cwd=$(echo "$input" | jq -r '.cwd // empty') +jq -nc \ + --arg ts "$ts" \ + --arg path "$file_path" \ + --arg domain "$domain" \ + --arg session_id "$session_id" \ + --arg cwd "$cwd" \ + '{ts: $ts, path: $path, domain: $domain, session_id: $session_id, cwd: $cwd}' \ + >> "$stats_dir/fetches.jsonl" exit 0 diff --git a/src/dot_claude/settings.json.tmpl b/src/dot_claude/settings.json.tmpl index dcca0b4..c05cf40 100644 --- a/src/dot_claude/settings.json.tmpl +++ b/src/dot_claude/settings.json.tmpl @@ -19,7 +19,14 @@ }, "hooks": { "SessionStart": [ -{{- if .work }} + { + "hooks": [ + { + "type": "command", + "command": "bash {{ .chezmoi.homeDir }}/.claude/hooks/kb-reflect-nudge.sh" + } + ] + }{{- if .work }}, { "hooks": [ { @@ -27,8 +34,7 @@ "command": "bash {{ .chezmoi.homeDir }}/knowledge/.claude/hooks/session-orient.sh" } ] - } -{{- end }} + }{{- end }} ], "PostToolUse": [ { From abe8561a68bd34b24a22c5018cd6f207d315af1b Mon Sep 17 00:00:00 2001 From: Ryan Sheppard Date: Wed, 8 Jul 2026 10:45:27 -0400 Subject: [PATCH 2/2] Record KB gaps from brainstorming skill too brainstorming independently traverses the code KB with the same protocol as the code skill, so genuine misses during a brainstorm were going unrecorded. The code-* spec/review skills don't fetch the KB directly (they rely on the auto-triggering code skill) and are intentionally left unchanged. --- src/dot_agents/skills/brainstorming/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/dot_agents/skills/brainstorming/SKILL.md b/src/dot_agents/skills/brainstorming/SKILL.md index 399b043..2a2f8cd 100644 --- a/src/dot_agents/skills/brainstorming/SKILL.md +++ b/src/dot_agents/skills/brainstorming/SKILL.md @@ -25,6 +25,14 @@ Entry points: Read each knowledge file via the `Read` tool — the PostToolUse hook auto-logs the fetch to `~/.agents/knowledge/code/.stats/fetches.jsonl`. Don't bypass with `cat`/`grep`; the stats only increment on `Read`. +If you traverse the KB during a brainstorm and no leaf covers the decision at hand, record the gap so `knowledge-reflect` can turn it into a new-leaf candidate — same convention as the `code` skill: + +```bash +python3 ~/.agents/scripts/kb_gap.py --domain code --note "" --task "" +``` + +Record only genuine misses on substantive decisions, not when an adequate leaf exists. + ### Audit trail (MANDATORY) Before every Read of a knowledge file, emit one line with the `KB:` prefix stating WHAT and WHY in terms of THIS brainstorm. Same convention as the `code` skill.