Obsidian Project KB Core This is the main workflow authority for project-scoped Obsidian knowledge maintenance. Default project root: Default structure: Core rules - Keep all durable project knowledge inside the current . - Keep repo-local only as the runtime binding layer. - is the only visible project registry. - is a human navigation note, not a registry mirror. - is a derived-artifact area; do not generate non-essential canvases by default. - is the default subdirectory for round and batch experiment reports. Responsibilities - detect and bind the current repo to a project root - bootstra…

, content):\n content = re.sub(\n r'(?ms)^## Auto Index\\s*\\n.*?(?=^## |\\Z)',\n auto_section,\n content,\n count=1,\n ).rstrip() + '\\n'\n else:\n content = content.rstrip() + '\\n\\n' + auto_section\n\n write_text(index_path, content.rstrip() + '\\n')\n\n\ndef ensure_today_daily(project_root: Path, project_slug: str, force: bool = False) -> Path:\n path = project_root / 'Daily' / f'{today_str()}.md'\n if not path.exists() or force:\n context = scaffold_context(project_slug, titleize_slug(project_slug))\n write_text(path, render_template('notes/daily.md', context))\n return path\n\n\ndef prepend_recent_change(project_root: Path, message: str) -> None:\n hub = project_root / '00-Hub.md'\n content = read_text(hub)\n marker = '## Recent Changes\\n'\n if marker not in content:\n content = content.rstrip() + f'\\n\\n{marker}- {message}\\n'\n else:\n head, tail = content.split(marker, 1)\n existing_lines = [line for line in tail.splitlines() if line.startswith('- ')]\n merged = [f'- {message}', *[line for line in existing_lines if line != f'- {message}']]\n content = head + marker + '\\n'.join(merged[:8]) + '\\n'\n write_text(hub, content)\n\n\ndef update_project_memory(repo_root: Path, project_id: str, project_root: Path, hub_note: str, note_language: str, summary: str | None = None) -> None:\n path = project_memory_path(repo_root, project_id)\n content = read_text(path, f'---\\nproject_id: {project_id}\\nproject_slug: {project_id}\\nrepo_root: {repo_root}\\nvault_root: {project_root}\\nhub_note: {hub_note}\\nlanguage: {note_language}\\nstatus: active\\nauto_sync: true\\n---\\n\\n# Project Memory: {project_id}\\n')\n content = set_frontmatter_value(content, 'last_sync_at', now_iso())\n content = set_frontmatter_value(content, 'vault_root', str(project_root))\n content = set_frontmatter_value(content, 'hub_note', hub_note)\n if summary:\n block = f'## Recent Sync Summary\\n- {summary}\\n'\n if '## Recent Sync Summary\\n' in content:\n content = re.sub(r'## Recent Sync Summary\\n(?:- .*\\n?)*', block, content, count=1)\n else:\n content = content.rstrip() + '\\n\\n' + block\n write_text(path, content)\n\n\ndef list_kind_notes(project_root: Path, kind: str) -> list[Path]:\n rel = NOTE_KIND_TO_FOLDER.get(kind)\n if not rel:\n return []\n base = project_root / rel\n if not base.exists():\n return []\n suffixes = {'.md'} if kind != 'map' else {'.md', '.canvas'}\n return sorted([p for p in base.rglob('*') if p.is_file() and p.suffix in suffixes])\n\n\ndef project_note_ref(path: Path, project_root: Path) -> str:\n rel = path.relative_to(project_root).as_posix()\n return rel[:-3] if rel.endswith('.md') else rel\n\n\ndef search_note_candidates(project_root: Path, kind: str, query: str, limit: int = 5) -> list[Path]:\n notes = list_kind_notes(project_root, kind)\n if not notes:\n return []\n raw_query = query.strip()\n exact = project_root / raw_query\n if exact.exists():\n return [exact]\n if raw_query.endswith('.md'):\n exact_md = project_root / raw_query\n if exact_md.exists():\n return [exact_md]\n query_norm = normalize_note_token(raw_query[:-3] if raw_query.endswith('.md') else raw_query)\n query_tokens = token_set(raw_query)\n scored: list[tuple[tuple[int, int, int], Path]] = []\n for note in notes:\n ref = project_note_ref(note, project_root)\n ref_norm = normalize_note_token(ref)\n note_norm = normalize_note_token(note.stem)\n score = None\n if ref == raw_query or note.stem == raw_query:\n score = (0, len(ref), len(note.stem))\n elif ref_norm == query_norm or note_norm == query_norm:\n score = (1, len(ref_norm), len(note_norm))\n elif query_norm and query_norm in ref_norm:\n score = (2, len(ref_norm), len(note_norm))\n elif query_tokens & token_set(ref):\n score = (3, -len(query_tokens & token_set(ref)), len(ref_norm))\n if score is not None:\n scored.append((score, note))\n scored.sort(key=lambda item: (item[0], project_note_ref(item[1], project_root)))\n return [item[1] for item in scored[:limit]]\n\n\ndef resolve_project_note(project_root: Path, note: str) -> Path:\n candidate = (project_root / note).resolve()\n if candidate.exists():\n return candidate\n if not note.endswith('.md'):\n candidate_md = (project_root / f'{note}.md').resolve()\n if candidate_md.exists():\n return candidate_md\n raise SystemExit(f'Note not found: {note}')\n\n\ndef replace_wikilinks(content: str, old_rel: str, new_rel: str | None = None) -> str:\n old_variants = {old_rel, old_rel[:-3] if old_rel.endswith('.md') else old_rel}\n new_target = None if new_rel is None else (new_rel[:-3] if new_rel.endswith('.md') else new_rel)\n\n def repl(match: re.Match[str]) -> str:\n inner = match.group(1)\n target, *rest = inner.split('|', 1)\n target_no_heading = target.split('#', 1)[0]\n if target_no_heading not in old_variants:\n return match.group(0)\n if new_target is None:\n return match.group(0)\n replaced = target.replace(target_no_heading, new_target, 1)\n if rest:\n return f'[[{replaced}|{rest[0]}]]'\n return f'[[{replaced}]]'\n\n return re.sub(r'\\[\\[([^\\]]+)\\]\\]', repl, content)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":31366,"content_sha256":"6f1e42b559c8556375bb20afa217a7ad3eb2620b465766d9aab8de6266642b2b"},{"filename":"scripts/kb_index_check.py","content":"#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport re\nimport sys\nfrom pathlib import Path\n\nSCRIPT_DIR = Path(__file__).resolve().parent\nif str(SCRIPT_DIR) not in sys.path:\n sys.path.insert(0, str(SCRIPT_DIR))\n\nimport kb_common as common # type: ignore\n\nINDEX_LINK_RE = re.compile(r'\\[\\[([^\\]|#]+)')\n\n\ndef parse_args() -> argparse.Namespace:\n parser = argparse.ArgumentParser(description='Check 02-Index coverage for active canonical notes.')\n parser.add_argument('--cwd', default='.')\n parser.add_argument('--project-id', default='')\n return parser.parse_args()\n\n\ndef main() -> None:\n args = parse_args()\n repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())\n binding = common.resolve_binding(repo_root, args.project_id or None)\n rows = common.parse_registry_md(common.registry_path(binding.project_root))\n index_path = binding.project_root / '02-Index.md'\n index_text = common.read_text(index_path)\n index_links = {match.strip() for match in INDEX_LINK_RE.findall(index_text)}\n\n missing: list[str] = []\n for section in ['Sources', 'Knowledge', 'Experiments', 'Results', 'Writing', 'Maps']:\n for row in rows.get(section, []):\n if row.get('Status') == 'archived':\n continue\n path = row.get('Path', '').strip()\n if not path.startswith('[['):\n continue\n target = path[2:-2]\n if target not in index_links:\n missing.append(target)\n\n payload = {\n 'project_id': binding.project_id,\n 'project_root': str(binding.project_root),\n 'missing_index_entries': sorted(missing),\n 'missing_count': len(missing),\n }\n print(json.dumps(payload, ensure_ascii=False, indent=2))\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":1845,"content_sha256":"fb7c2673bb6e654feb90aa2a9210e233740c8e9baf6220ff27452169fa597aee"},{"filename":"scripts/kb_link_check.py","content":"#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport re\nimport sys\nfrom pathlib import Path\n\nSCRIPT_DIR = Path(__file__).resolve().parent\nif str(SCRIPT_DIR) not in sys.path:\n sys.path.insert(0, str(SCRIPT_DIR))\n\nimport kb_common as common # type: ignore\n\nWIKILINK_RE = re.compile(r'\\[\\[([^\\]]+)\\]\\]')\n\n\ndef parse_args() -> argparse.Namespace:\n parser = argparse.ArgumentParser(description='Check wikilinks inside the bound project KB.')\n parser.add_argument('--cwd', default='.')\n parser.add_argument('--project-id', default='')\n return parser.parse_args()\n\n\ndef normalize_target(target: str) -> str:\n target = target.split('|', 1)[0].split('#', 1)[0].strip()\n for suffix in ('.md', '.canvas'):\n if target.endswith(suffix):\n target = target[:-len(suffix)]\n break\n return target\n\n\ndef build_targets(project_root: Path) -> tuple[set[str], dict[str, list[str]]]:\n exact: set[str] = set()\n stems: dict[str, list[str]] = {}\n for suffix in ('*.md', '*.canvas'):\n for path in project_root.rglob(suffix):\n rel = str(path.relative_to(project_root)).replace(os.sep, '/')\n note_ref = rel[:-len(path.suffix)]\n exact.add(note_ref)\n stems.setdefault(path.stem, []).append(note_ref)\n return exact, stems\n\n\ndef main() -> None:\n args = parse_args()\n repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())\n binding = common.resolve_binding(repo_root, args.project_id or None)\n exact, stems = build_targets(binding.project_root)\n broken: list[dict[str, str]] = []\n\n for path in sorted(binding.project_root.rglob('*.md')):\n rel = str(path.relative_to(binding.project_root)).replace(os.sep, '/')\n if rel.startswith('_system/'):\n continue\n text = path.read_text(encoding='utf-8')\n for raw in WIKILINK_RE.findall(text):\n target = normalize_target(raw)\n if not target or target.startswith('#'):\n continue\n if target in exact:\n continue\n basename = Path(target).name\n if basename in stems and len(stems[basename]) == 1:\n continue\n broken.append({'file': rel, 'target': target})\n\n payload = {\n 'project_id': binding.project_id,\n 'project_root': str(binding.project_root),\n 'broken_links': broken,\n 'broken_count': len(broken),\n }\n print(json.dumps(payload, ensure_ascii=False, indent=2))\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2593,"content_sha256":"c9ce792738c59e9221e1bd3d19ff89da8bdb74ff706baee247a6e2a75202151e"},{"filename":"scripts/kb_lint.py","content":"#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nSCRIPT_DIR = Path(__file__).resolve().parent\nif str(SCRIPT_DIR) not in sys.path:\n sys.path.insert(0, str(SCRIPT_DIR))\n\nimport kb_common as common # type: ignore\n\nWIKILINK_RE = re.compile(r'\\[\\[([^\\]|#]+)')\nSOURCE_LINK_RE = re.compile(r'\\[\\[(Sources/[^\\]|#]+)')\nEXPERIMENT_LINK_RE = re.compile(r'\\[\\[(Experiments/[^\\]|#]+)')\nRESULT_LINK_RE = re.compile(r'\\[\\[(Results/[^\\]|#]+)')\nARCHIVED_RESULT_LINK_RE = re.compile(r'\\[\\[(Archive/Results/[^\\]|#]+)')\n\n\ndef parse_args() -> argparse.Namespace:\n parser = argparse.ArgumentParser(description='Aggregate KB lint checks and write _system/lint-report.md.')\n parser.add_argument('--cwd', default='.')\n parser.add_argument('--project-id', default='')\n return parser.parse_args()\n\n\ndef run_json(script_name: str, cwd: Path, project_id: str) -> dict:\n cmd = ['python3', str(SCRIPT_DIR / script_name), '--cwd', str(cwd)]\n if project_id:\n cmd.extend(['--project-id', project_id])\n out = subprocess.check_output(cmd, text=True)\n return json.loads(out)\n\n\ndef note_refs_in_file(path: Path) -> set[str]:\n text = path.read_text(encoding='utf-8')\n return {match.strip() for match in WIKILINK_RE.findall(text)}\n\n\ndef main() -> None:\n args = parse_args()\n repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())\n binding = common.resolve_binding(repo_root, args.project_id or None)\n\n registry_check = run_json('kb_registry_check.py', repo_root, binding.project_id)\n link_check = run_json('kb_link_check.py', repo_root, binding.project_id)\n index_check = run_json('kb_index_check.py', repo_root, binding.project_id)\n canvas_check = run_json('kb_canvas_check.py', repo_root, binding.project_id)\n\n rows = common.parse_registry_md(common.registry_path(binding.project_root))\n knowledge_without_sources: list[str] = []\n for row in rows.get('Knowledge', []):\n target = row.get('Path', '')\n if not target.startswith('[['):\n continue\n note_ref = target[2:-2]\n note_path = binding.project_root / f'{note_ref}.md'\n if note_path.exists():\n text = note_path.read_text(encoding='utf-8')\n if not SOURCE_LINK_RE.search(text):\n knowledge_without_sources.append(note_ref)\n\n experiments_without_results: list[str] = []\n experiments_with_only_archived_results: list[str] = []\n for row in rows.get('Experiments', []):\n target = row.get('Path', '')\n if not target.startswith('[['):\n continue\n note_ref = target[2:-2]\n note_path = binding.project_root / f'{note_ref}.md'\n if note_path.exists():\n text = note_path.read_text(encoding='utf-8')\n has_active_result = bool(RESULT_LINK_RE.search(text))\n has_archived_result = bool(ARCHIVED_RESULT_LINK_RE.search(text))\n if not has_active_result and not has_archived_result:\n experiments_without_results.append(note_ref)\n elif has_archived_result and not has_active_result:\n experiments_with_only_archived_results.append(note_ref)\n\n result_refs = {row.get('Path', '')[2:-2] for row in rows.get('Results', []) if row.get('Path', '').startswith('[[')}\n results_without_experiments: list[str] = []\n for note_ref in sorted(result_refs):\n note_path = binding.project_root / f'{note_ref}.md'\n if note_path.exists():\n text = note_path.read_text(encoding='utf-8')\n if not EXPERIMENT_LINK_RE.search(text):\n results_without_experiments.append(note_ref)\n\n archived_refs = {row.get('Archived Path', '')[2:-2] for row in rows.get('Archive', []) if row.get('Archived Path', '').startswith('[[')}\n active_notes_referencing_archived_notes: list[str] = []\n active_md_files = []\n for p in binding.project_root.rglob('*.md'):\n rel = str(p.relative_to(binding.project_root)).replace(os.sep, '/')\n if rel.startswith('Archive/') or rel.startswith('_system/'):\n continue\n active_md_files.append(p)\n for path in active_md_files:\n refs = note_refs_in_file(path)\n shared = sorted(ref for ref in refs if ref in archived_refs)\n for ref in shared:\n active_notes_referencing_archived_notes.append(f'{path.relative_to(binding.project_root).as_posix()} -> {ref}')\n\n daily_candidates: list[str] = []\n for daily_path in sorted((binding.project_root / 'Daily').glob('*.md')):\n text = daily_path.read_text(encoding='utf-8')\n if 'TODO' in text or 'Open Questions' in text or 'Promote' in text:\n daily_candidates.append(daily_path.relative_to(binding.project_root).as_posix())\n\n summary_rows = [\n ('Broken links', 'pass' if link_check['broken_count'] == 0 else 'fail', link_check['broken_count']),\n ('Missing registry entries', 'pass' if not registry_check['missing_registry_entries'] else 'fail', len(registry_check['missing_registry_entries'])),\n ('Dangling registry entries', 'pass' if not registry_check['dangling_registry_entries'] else 'fail', len(registry_check['dangling_registry_entries'])),\n ('Missing index entries', 'pass' if index_check['missing_count'] == 0 else 'warn', index_check['missing_count']),\n ('Canvas issues', 'pass' if canvas_check['issue_count'] == 0 else 'warn', canvas_check['issue_count']),\n ('Knowledge without sources', 'pass' if not knowledge_without_sources else 'warn', len(knowledge_without_sources)),\n ('Experiments without results', 'pass' if not experiments_without_results else 'warn', len(experiments_without_results)),\n ('Experiments with only archived results', 'pass' if not experiments_with_only_archived_results else 'warn', len(experiments_with_only_archived_results)),\n ('Results without experiments', 'pass' if not results_without_experiments else 'warn', len(results_without_experiments)),\n ('Daily promotion candidates', 'pass' if not daily_candidates else 'warn', len(daily_candidates)),\n ('Active notes referencing archived notes', 'pass' if not active_notes_referencing_archived_notes else 'warn', len(active_notes_referencing_archived_notes)),\n ]\n\n lines = ['# Lint Report', '', f'Last checked: {common.now_iso()}', '', '## Summary', '', '| Check | Status | Count |', '|---|---|---|']\n lines.extend(f'| {name} | {status} | {count} |' for name, status, count in summary_rows)\n lines.extend(['', '## Issues', ''])\n\n def add_issue_block(title: str, items: list[str]) -> None:\n lines.append(f'### {title}')\n if items:\n lines.extend(f'- {item}' for item in items)\n else:\n lines.append('- None.')\n lines.append('')\n\n add_issue_block('Missing Registry Entries', registry_check['missing_registry_entries'])\n add_issue_block('Dangling Registry Entries', registry_check['dangling_registry_entries'])\n add_issue_block('Broken Wikilinks', [f\"{item['file']} -> {item['target']}\" for item in link_check['broken_links']])\n add_issue_block('Missing Index Entries', index_check['missing_index_entries'])\n add_issue_block('Canvas Issues', [f\"{item['file']} -> {item['issue']}\" for item in canvas_check['canvas_issues']])\n add_issue_block('Knowledge Notes Without Sources', knowledge_without_sources)\n add_issue_block('Experiments Without Results', experiments_without_results)\n add_issue_block('Experiments With Only Archived Results', experiments_with_only_archived_results)\n add_issue_block('Results Without Experiments', results_without_experiments)\n add_issue_block('Daily Promotion Candidates', daily_candidates)\n add_issue_block('Active Notes Referencing Archived Notes', active_notes_referencing_archived_notes)\n\n lines.extend(['## Recommended Fixes', ''])\n fixes: list[str] = []\n if registry_check['missing_registry_entries']:\n fixes.append('Add registry rows for all missing canonical notes.')\n if link_check['broken_count']:\n fixes.append('Repair or remove broken wikilinks before the next sync.')\n if index_check['missing_count']:\n fixes.append('Update 02-Index.md so active canonical notes remain navigable.')\n if experiments_with_only_archived_results:\n fixes.append('Decide whether archived result links should stay historical or be promoted back into active Results.')\n if daily_candidates:\n fixes.append('Promote durable content from Daily into Knowledge, Experiments, Results, or Writing.')\n if not fixes:\n fixes.append('No action required.')\n lines.extend(f'- {fix}' for fix in fixes)\n lines.append('')\n\n report_path = binding.project_root / '_system' / 'lint-report.md'\n common.write_text(report_path, '\\n'.join(lines))\n\n payload = {\n 'project_id': binding.project_id,\n 'project_root': str(binding.project_root),\n 'lint_report': str(report_path),\n 'broken_links': link_check['broken_count'],\n 'missing_registry_entries': len(registry_check['missing_registry_entries']),\n 'missing_index_entries': index_check['missing_count'],\n 'knowledge_without_sources': len(knowledge_without_sources),\n 'experiments_without_results': len(experiments_without_results),\n 'experiments_with_only_archived_results': len(experiments_with_only_archived_results),\n 'results_without_experiments': len(results_without_experiments),\n 'daily_promotion_candidates': len(daily_candidates),\n 'active_notes_referencing_archived_notes': len(active_notes_referencing_archived_notes),\n }\n print(json.dumps(payload, ensure_ascii=False, indent=2))\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9813,"content_sha256":"46fbd9f1e7cbfa3dd7748bbdd466509fcd61bade5aa473cee1cf649cb573fd6b"},{"filename":"scripts/kb_registry_check.py","content":"#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\nfrom pathlib import Path\n\nSCRIPT_DIR = Path(__file__).resolve().parent\nif str(SCRIPT_DIR) not in sys.path:\n sys.path.insert(0, str(SCRIPT_DIR))\n\nimport kb_common as common # type: ignore\n\n\ndef parse_args() -> argparse.Namespace:\n parser = argparse.ArgumentParser(description='Check registry coverage for the bound project KB.')\n parser.add_argument('--cwd', default='.')\n parser.add_argument('--project-id', default='')\n return parser.parse_args()\n\n\ndef main() -> None:\n args = parse_args()\n repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())\n binding = common.resolve_binding(repo_root, args.project_id or None)\n rels = common.scan_canonical_relpaths(binding.project_root)\n rows = common.parse_registry_md(common.registry_path(binding.project_root))\n\n active_entries: list[tuple[str, str]] = []\n ids: dict[str, list[str]] = {}\n for section, entries in rows.items():\n if section == 'Archive':\n continue\n for row in entries:\n link = row.get('Path', '')\n active_entries.append((section, link))\n rid = row.get('ID', '')\n if rid:\n ids.setdefault(rid, []).append(link)\n\n desired = {common.wikilink(rel) for rel in rels}\n registered = {link for _, link in active_entries if link}\n missing = sorted(rel for rel in rels if common.wikilink(rel) not in registered)\n dangling = sorted(link for link in registered if link not in desired)\n duplicate_ids = {rid: paths for rid, paths in ids.items() if len(paths) > 1}\n\n payload = {\n 'project_id': binding.project_id,\n 'project_root': str(binding.project_root),\n 'canonical_count': len(rels),\n 'registered_count': len(registered),\n 'missing_registry_entries': missing,\n 'dangling_registry_entries': dangling,\n 'duplicate_ids': duplicate_ids,\n 'coverage_ok': not missing and not dangling and not duplicate_ids,\n }\n print(json.dumps(payload, ensure_ascii=False, indent=2))\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2180,"content_sha256":"f9cf32db691e958b53c4e11818b1e4831087c479f3db2c093e015ed1949af68b"},{"filename":"scripts/kb_scaffold.py","content":"#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\nfrom pathlib import Path\n\nSCRIPT_DIR = Path(__file__).resolve().parent\nif str(SCRIPT_DIR) not in sys.path:\n sys.path.insert(0, str(SCRIPT_DIR))\n\nimport kb_common as common # type: ignore\n\n\ndef parse_args() -> argparse.Namespace:\n parser = argparse.ArgumentParser(description='Scaffold a project KB skeleton.')\n parser.add_argument('--cwd', default='.')\n parser.add_argument('--project-id', default='')\n parser.add_argument('--vault-path', default='')\n parser.add_argument('--project-name', default='')\n parser.add_argument('--force', action='store_true')\n parser.add_argument('--note-language', default='en')\n return parser.parse_args()\n\n\ndef main() -> None:\n args = parse_args()\n repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())\n if args.vault_path:\n result = common.bootstrap_binding(\n repo_root,\n Path(args.vault_path),\n project_name=args.project_name or None,\n force=args.force,\n note_language=args.note_language,\n )\n else:\n binding = common.resolve_binding(repo_root, args.project_id or None)\n common.ensure_project_scaffold(binding.project_root, binding.project_slug, common.titleize_slug(binding.project_slug), force=args.force)\n result = {\n 'project_id': binding.project_id,\n 'project_root': str(binding.project_root),\n 'scaffold_refreshed': True,\n }\n print(json.dumps(result, ensure_ascii=False, indent=2))\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":1661,"content_sha256":"318f74b857bf64158023e01e0d10a65ffee361fc1735751e80761301162d4e83"},{"filename":"scripts/project_kb.py","content":"#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport re\nimport shutil\nimport sys\nfrom pathlib import Path\nfrom typing import Any\n\nSCRIPT_DIR = Path(__file__).resolve().parent\nif str(SCRIPT_DIR) not in sys.path:\n sys.path.insert(0, str(SCRIPT_DIR))\n\nimport kb_common as common # type: ignore\n\n\ndef parse_args() -> argparse.Namespace:\n parser = argparse.ArgumentParser(description='Project-scoped Obsidian KB helper.')\n subparsers = parser.add_subparsers(dest='command', required=True)\n\n detect = subparsers.add_parser('detect', help='Detect repo binding and candidate status.')\n detect.add_argument('--cwd', default='.')\n\n bootstrap = subparsers.add_parser('bootstrap', help='Create or rebuild a bound project KB.')\n bootstrap.add_argument('--cwd', default='.')\n bootstrap.add_argument('--vault-path', default='')\n bootstrap.add_argument('--project-name', default='')\n bootstrap.add_argument('--project-id', default='')\n bootstrap.add_argument('--note-language', default='en')\n bootstrap.add_argument('--force', action='store_true')\n\n sync = subparsers.add_parser('sync', help='Refresh scaffold, registry, index, daily note, and runtime binding summary.')\n sync.add_argument('--cwd', default='.')\n sync.add_argument('--project-id', default='')\n sync.add_argument('--scope', default='auto')\n\n status = subparsers.add_parser('status', help='Return a compact project KB status summary.')\n status.add_argument('--cwd', default='.')\n status.add_argument('--project-id', default='')\n\n lifecycle = subparsers.add_parser('lifecycle', help='Manage project-level lifecycle state.')\n lifecycle.add_argument('--cwd', default='.')\n lifecycle.add_argument('--project-id', default='')\n lifecycle.add_argument('--mode', choices=['detach', 'archive', 'purge', 'rebuild'], required=True)\n lifecycle.add_argument('--vault-path', default='')\n lifecycle.add_argument('--force', action='store_true')\n\n query = subparsers.add_parser('query-context', help='Return note context candidates for the current project.')\n query.add_argument('--cwd', default='.')\n query.add_argument('--project-id', default='')\n query.add_argument('--kind', default='broad')\n query.add_argument('--query', default='')\n\n find = subparsers.add_parser('find-canonical-note', help='Find canonical notes by kind and query.')\n find.add_argument('--cwd', default='.')\n find.add_argument('--project-id', default='')\n find.add_argument('--kind', required=True)\n find.add_argument('--query', required=True)\n find.add_argument('--limit', type=int, default=5)\n\n note = subparsers.add_parser('note-lifecycle', help='Manage a single canonical note.')\n note.add_argument('--cwd', default='.')\n note.add_argument('--project-id', default='')\n note.add_argument('--mode', choices=['archive', 'purge', 'rename'], required=True)\n note.add_argument('--note', required=True)\n note.add_argument('--dest', default='')\n note.add_argument('--reason', default='manual')\n\n return parser.parse_args()\n\n\ndef repo_root_from(cwd: str) -> Path:\n return common.find_repo_root(Path(cwd).expanduser().resolve())\n\n\ndef registry_links(rows: dict[str, list[dict[str, str]]]) -> dict[str, str]:\n links: dict[str, str] = {}\n for section, entries in rows.items():\n if section == 'Archive':\n continue\n for row in entries:\n path = row.get('Path', '')\n if path:\n links[path] = section\n return links\n\n\ndef sync_registry(project_root: Path) -> dict[str, Any]:\n rels = common.scan_canonical_relpaths(project_root)\n rows = common.parse_registry_md(common.registry_path(project_root))\n existing = registry_links(rows)\n desired = {common.wikilink(rel): rel for rel in rels}\n\n added: list[str] = []\n removed: list[str] = []\n for rel in rels:\n result = common.registry_add_or_update(project_root, rel)\n if result.get('updated') and common.wikilink(rel) not in existing:\n added.append(rel)\n\n rows = common.parse_registry_md(common.registry_path(project_root))\n for section in list(rows):\n if section == 'Archive':\n continue\n keep: list[dict[str, str]] = []\n for row in rows[section]:\n link = row.get('Path', '')\n if link in desired:\n keep.append(row)\n else:\n removed.append(link)\n rows[section] = keep\n common.write_registry(project_root, rows)\n return {'canonical_paths': rels, 'added': added, 'removed': removed}\n\n\ndef update_hub_link_block(binding: common.Binding) -> None:\n hub_path = binding.project_root / '00-Hub.md'\n content = common.read_text(hub_path)\n required_lines = [\n '- [[01-Plan]]',\n '- [[02-Index]]',\n '- [[_system/registry]]',\n f'- [[Daily/{common.today_str()}]]',\n ]\n marker = '## Important Links\\n'\n if marker not in content:\n content = content.rstrip() + '\\n\\n' + marker + '\\n'.join(required_lines) + '\\n'\n else:\n head, tail = content.split(marker, 1)\n rest_lines = tail.splitlines()\n collected: list[str] = []\n skipping = True\n idx = 0\n while idx \u003c len(rest_lines):\n line = rest_lines[idx]\n if skipping and (line.startswith('- ') or not line.strip()):\n idx += 1\n continue\n skipping = False\n collected = rest_lines[idx:]\n break\n content = head + marker + '\\n'.join(required_lines) + '\\n\\n' + '\\n'.join(collected).lstrip('\\n')\n common.write_text(hub_path, content.rstrip() + '\\n')\n\n\ndef run_sync(binding: common.Binding, scope: str = 'auto') -> dict[str, Any]:\n common.ensure_project_scaffold(binding.project_root, binding.project_slug, common.titleize_slug(binding.project_slug))\n migrated = common.maybe_migrate_old_layout(binding.project_root)\n daily_path = common.ensure_today_daily(binding.project_root, binding.project_slug)\n registry_result = sync_registry(binding.project_root)\n common.update_index(binding.project_root)\n update_hub_link_block(binding)\n summary = f'scope={scope}; canonical={len(registry_result[\"canonical_paths\"])}; added={len(registry_result[\"added\"])}; migrated={len(migrated)}'\n common.update_project_memory(\n binding.repo_root,\n binding.project_id,\n binding.project_root,\n common.relative_note_path(binding.project_root / '00-Hub.md', binding.vault_path),\n binding.note_language,\n summary=summary,\n )\n common.prepend_recent_change(binding.project_root, f'{common.now_iso()}: sync refreshed scaffold, registry, index, and daily note ({scope}).')\n return {\n 'project_id': binding.project_id,\n 'project_root': str(binding.project_root),\n 'daily_note': str(daily_path),\n 'migrated_paths': migrated,\n 'registry_added': registry_result['added'],\n 'registry_removed': registry_result['removed'],\n 'canonical_count': len(registry_result['canonical_paths']),\n 'scope': scope,\n }\n\n\ndef load_binding(repo_root: Path, project_id: str | None) -> common.Binding:\n return common.resolve_binding(repo_root, project_id or None)\n\n\ndef update_binding_entry(repo_root: Path, project_id: str, **updates: Any) -> dict[str, Any]:\n reg_path = common.binding_registry_path(repo_root)\n registry = common.load_binding_registry(reg_path)\n entry = (registry.get('projects') or {}).get(project_id)\n if entry is None:\n raise SystemExit(f'Project {project_id!r} not found in binding registry')\n entry.update(updates)\n entry['updated_at'] = common.now_iso()\n common.save_binding_registry(reg_path, registry)\n return entry\n\n\ndef archive_project(binding: common.Binding) -> dict[str, Any]:\n archive_root = binding.vault_path / 'Research' / '_archived'\n archive_root.mkdir(parents=True, exist_ok=True)\n dest = archive_root / f'{binding.project_slug}-{common.today_str()}'\n counter = 1\n while dest.exists():\n counter += 1\n dest = archive_root / f'{binding.project_slug}-{common.today_str()}-{counter}'\n shutil.move(str(binding.project_root), str(dest))\n entry = update_binding_entry(\n binding.repo_root,\n binding.project_id,\n vault_root=str(dest),\n hub_note=common.relative_note_path(dest / '00-Hub.md', binding.vault_path),\n status='archived',\n auto_sync=False,\n )\n common.update_project_memory(\n binding.repo_root,\n binding.project_id,\n dest,\n entry['hub_note'],\n binding.note_language,\n summary=f'project archived to {dest}',\n )\n return {'project_id': binding.project_id, 'archived_to': str(dest)}\n\n\ndef purge_project(binding: common.Binding) -> dict[str, Any]:\n reg_path = common.binding_registry_path(binding.repo_root)\n registry = common.load_binding_registry(reg_path)\n registry.setdefault('projects', {}).pop(binding.project_id, None)\n common.save_binding_registry(reg_path, registry)\n memory_path = common.project_memory_path(binding.repo_root, binding.project_id)\n if memory_path.exists():\n memory_path.unlink()\n if binding.project_root.exists():\n shutil.rmtree(binding.project_root)\n return {\n 'project_id': binding.project_id,\n 'purged_project_root': str(binding.project_root),\n 'removed_memory': str(memory_path),\n }\n\n\ndef detach_project(binding: common.Binding) -> dict[str, Any]:\n entry = update_binding_entry(binding.repo_root, binding.project_id, status='detached', auto_sync=False)\n common.update_project_memory(\n binding.repo_root,\n binding.project_id,\n binding.project_root,\n entry['hub_note'],\n binding.note_language,\n summary='project detached; vault content preserved',\n )\n return {'project_id': binding.project_id, 'status': 'detached', 'project_root': str(binding.project_root)}\n\n\ndef refresh_or_rebuild(repo_root: Path, project_id: str | None, vault_path: str, force: bool) -> dict[str, Any]:\n if project_id:\n binding = load_binding(repo_root, project_id)\n target_vault = Path(vault_path).expanduser().resolve() if vault_path else binding.vault_path\n result = common.bootstrap_binding(repo_root, target_vault, project_name=binding.project_slug, force=True, note_language=binding.note_language)\n result['rebuild'] = True\n return result\n target_vault = Path(vault_path or os.environ.get('OBSIDIAN_VAULT_PATH', '')).expanduser()\n if not str(target_vault):\n raise SystemExit('Rebuild requires --vault-path or OBSIDIAN_VAULT_PATH')\n return common.bootstrap_binding(repo_root, target_vault, force=force)\n\n\ndef broad_context(binding: common.Binding) -> dict[str, Any]:\n daily_path = binding.project_root / 'Daily' / f'{common.today_str()}.md'\n payload = {\n 'project_id': binding.project_id,\n 'project_root': str(binding.project_root),\n 'hub': str(binding.project_root / '00-Hub.md'),\n 'plan': str(binding.project_root / '01-Plan.md'),\n 'index': str(binding.project_root / '02-Index.md'),\n 'daily': str(daily_path) if daily_path.exists() else '',\n }\n return payload\n\n\ndef query_context(binding: common.Binding, kind: str, query: str) -> dict[str, Any]:\n if kind == 'broad':\n return broad_context(binding)\n candidates = common.search_note_candidates(binding.project_root, kind, query or kind, limit=8)\n return {\n 'project_id': binding.project_id,\n 'kind': kind,\n 'query': query,\n 'candidates': [str(path) for path in candidates],\n }\n\n\ndef replace_links_in_text(content: str, old_rel: str, new_rel: str | None) -> str:\n if new_rel is not None:\n return common.replace_wikilinks(content, old_rel, new_rel)\n\n old_variants = {old_rel, old_rel[:-3] if old_rel.endswith('.md') else old_rel}\n old_title = Path(old_rel).stem.replace('-', ' ')\n\n def repl(match: Any) -> str:\n inner = match.group(1)\n target, *rest = inner.split('|', 1)\n target_no_heading = target.split('#', 1)[0]\n if target_no_heading not in old_variants:\n return match.group(0)\n if rest:\n return rest[0]\n return old_title\n\n import re\n return re.sub(r'\\[\\[([^\\]]+)\\]\\]', repl, content)\n\n\ndef rewrite_project_references(project_root: Path, old_rel: str, new_rel: str | None) -> list[str]:\n touched: list[str] = []\n for path in sorted(project_root.rglob('*')):\n if path.is_dir():\n continue\n rel = str(path.relative_to(project_root)).replace(os.sep, '/')\n if rel == '02-Index.md' or rel.startswith('_system/'):\n continue\n if path.suffix == '.md':\n before = common.read_text(path)\n after = replace_links_in_text(before, old_rel, new_rel)\n if after != before:\n common.write_text(path, after)\n touched.append(str(path))\n elif path.suffix == '.canvas':\n before = common.read_text(path)\n marker_old = old_rel.replace('\\\\', '/')\n marker_new = '' if new_rel is None else new_rel.replace('\\\\', '/')\n after = before.replace(marker_old, marker_new) if marker_old in before else before\n if after != before:\n common.write_text(path, after)\n touched.append(str(path))\n return touched\n\n\ndef ensure_parent(path: Path) -> None:\n path.parent.mkdir(parents=True, exist_ok=True)\n\n\ndef note_lifecycle(binding: common.Binding, mode: str, note: str, dest: str = '', reason: str = 'manual') -> dict[str, Any]:\n source = common.resolve_project_note(binding.project_root, note)\n source_rel = str(source.relative_to(binding.project_root)).replace(os.sep, '/')\n\n if mode == 'rename':\n if not dest:\n raise SystemExit('--dest is required for rename')\n target = (binding.project_root / dest).resolve()\n ensure_parent(target)\n shutil.move(str(source), str(target))\n target_rel = str(target.relative_to(binding.project_root)).replace(os.sep, '/')\n rewritten = rewrite_project_references(binding.project_root, source_rel, target_rel)\n common.registry_add_or_update(binding.project_root, target_rel)\n common.registry_remove_path(binding.project_root, source_rel, reason='renamed', record_archive=False)\n sync_registry(binding.project_root)\n common.update_index(binding.project_root)\n source_label = Path(source_rel).stem.replace('-', ' ')\n target_link = target_rel[:-3] if target_rel.endswith('.md') else target_rel\n common.prepend_recent_change(binding.project_root, f'{common.now_iso()}: renamed {source_label} -> [[{target_link}]].')\n return {'mode': mode, 'from': source_rel, 'to': target_rel, 'rewritten_paths': rewritten}\n\n if mode == 'archive':\n archived = binding.project_root / 'Archive' / source_rel\n ensure_parent(archived)\n shutil.move(str(source), str(archived))\n archived_rel = str(archived.relative_to(binding.project_root)).replace(os.sep, '/')\n rewritten = rewrite_project_references(binding.project_root, source_rel, archived_rel)\n common.registry_archive(binding.project_root, source_rel, archived_rel, reason=reason)\n common.update_index(binding.project_root)\n common.prepend_recent_change(binding.project_root, f'{common.now_iso()}: archived [[{source_rel[:-3] if source_rel.endswith(\".md\") else source_rel}]].')\n return {'mode': mode, 'from': source_rel, 'to': archived_rel, 'rewritten_paths': rewritten}\n\n if mode == 'purge':\n source.unlink()\n rewritten = rewrite_project_references(binding.project_root, source_rel, None)\n common.registry_remove_path(binding.project_root, source_rel, reason=reason)\n common.update_index(binding.project_root)\n common.prepend_recent_change(binding.project_root, f'{common.now_iso()}: purged {source_rel}.')\n return {'mode': mode, 'purged': source_rel, 'rewritten_paths': rewritten}\n\n raise SystemExit(f'Unsupported note lifecycle mode: {mode}')\n\n\ndef project_status(binding: common.Binding) -> dict[str, Any]:\n rows = common.parse_registry_md(common.registry_path(binding.project_root))\n daily_path = binding.project_root / 'Daily' / f'{common.today_str()}.md'\n lint_path = binding.project_root / '_system' / 'lint-report.md'\n archived_refs = {row.get('Archived Path', '')[2:-2] for row in rows.get('Archive', []) if row.get('Archived Path', '').startswith('[[')}\n active_notes_referencing_archived_notes = 0\n experiments_with_only_archived_results = 0\n for path in sorted(binding.project_root.rglob('*.md')):\n rel = str(path.relative_to(binding.project_root)).replace(os.sep, '/')\n if rel.startswith('Archive/') or rel.startswith('_system/'):\n continue\n refs = {match.strip() for match in re.findall(r'\\[\\[([^\\]|#]+)', path.read_text(encoding='utf-8'))}\n archived_hits = refs & archived_refs\n if archived_hits:\n active_notes_referencing_archived_notes += len(archived_hits)\n if rel.startswith('Experiments/'):\n has_active_result = bool(re.search(r'\\[\\[(Results/[^\\]|#]+)', path.read_text(encoding='utf-8')))\n has_archived_result = bool(re.search(r'\\[\\[(Archive/Results/[^\\]|#]+)', path.read_text(encoding='utf-8')))\n if has_archived_result and not has_active_result:\n experiments_with_only_archived_results += 1\n return {\n 'project_id': binding.project_id,\n 'project_root': str(binding.project_root),\n 'status': binding.status,\n 'auto_sync': binding.auto_sync,\n 'sources': len(rows.get('Sources', [])),\n 'knowledge': len(rows.get('Knowledge', [])),\n 'experiments': len(rows.get('Experiments', [])),\n 'results': len(rows.get('Results', [])),\n 'writing': len(rows.get('Writing', [])),\n 'maps': len(rows.get('Maps', [])),\n 'archive': len(rows.get('Archive', [])),\n 'experiments_with_only_archived_results': experiments_with_only_archived_results,\n 'active_notes_referencing_archived_notes': active_notes_referencing_archived_notes,\n 'daily_note': str(daily_path) if daily_path.exists() else '',\n 'lint_report': str(lint_path) if lint_path.exists() else '',\n }\n\n\ndef main() -> None:\n args = parse_args()\n repo_root = repo_root_from(getattr(args, 'cwd', '.'))\n\n if args.command == 'detect':\n print(json.dumps(common.detect(repo_root), ensure_ascii=False, indent=2))\n return\n\n if args.command == 'bootstrap':\n vault_arg = args.vault_path or os.environ.get('OBSIDIAN_VAULT_PATH', '')\n if not vault_arg:\n raise SystemExit('bootstrap requires --vault-path or OBSIDIAN_VAULT_PATH')\n result = common.bootstrap_binding(\n repo_root,\n Path(vault_arg),\n project_name=args.project_name or None,\n force=args.force,\n note_language=args.note_language,\n )\n print(json.dumps(result, ensure_ascii=False, indent=2))\n return\n\n binding = load_binding(repo_root, getattr(args, 'project_id', '') or None)\n\n if args.command == 'sync':\n print(json.dumps(run_sync(binding, scope=args.scope), ensure_ascii=False, indent=2))\n return\n\n if args.command == 'status':\n print(json.dumps(project_status(binding), ensure_ascii=False, indent=2))\n return\n\n if args.command == 'lifecycle':\n if args.mode == 'detach':\n result = detach_project(binding)\n elif args.mode == 'archive':\n result = archive_project(binding)\n elif args.mode == 'purge':\n result = purge_project(binding)\n elif args.mode == 'rebuild':\n result = refresh_or_rebuild(repo_root, binding.project_id, args.vault_path, args.force)\n else:\n raise SystemExit(f'Unsupported lifecycle mode: {args.mode}')\n print(json.dumps(result, ensure_ascii=False, indent=2))\n return\n\n if args.command == 'query-context':\n print(json.dumps(query_context(binding, args.kind, args.query), ensure_ascii=False, indent=2))\n return\n\n if args.command == 'find-canonical-note':\n candidates = common.search_note_candidates(binding.project_root, args.kind, args.query, limit=args.limit)\n payload = {\n 'project_id': binding.project_id,\n 'kind': args.kind,\n 'query': args.query,\n 'candidates': [str(path.relative_to(binding.project_root)).replace(os.sep, '/') for path in candidates],\n }\n print(json.dumps(payload, ensure_ascii=False, indent=2))\n return\n\n if args.command == 'note-lifecycle':\n print(json.dumps(note_lifecycle(binding, args.mode, args.note, args.dest, args.reason), ensure_ascii=False, indent=2))\n return\n\n raise SystemExit(f'Unhandled command: {args.command}')\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":21178,"content_sha256":"c62e872f2aefea74bdf2f98590c9f6544f39e02743fd49c5bd55bc4568ddafad"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Obsidian Project KB Core","type":"text"}]},{"type":"paragraph","content":[{"text":"This is the ","type":"text"},{"text":"main workflow authority","type":"text","marks":[{"type":"strong"}]},{"text":" for project-scoped Obsidian knowledge maintenance.","type":"text"}]},{"type":"paragraph","content":[{"text":"Default project root:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"Research/{project-slug}/","type":"text"}]},{"type":"paragraph","content":[{"text":"Default structure:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"00-Hub.md\n01-Plan.md\n02-Index.md\nSources/\nKnowledge/\nExperiments/\nResults/\n Reports/\nWriting/\nDaily/\nMaps/\nArchive/\n_system/","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Core rules","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep all durable project knowledge inside the current ","type":"text"},{"text":"Research/{project-slug}/","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep repo-local ","type":"text"},{"text":".claude/project-memory/*","type":"text","marks":[{"type":"code_inline"}]},{"text":" only as the runtime binding layer.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"_system/registry.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the only visible project registry.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"02-Index.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a human navigation note, not a registry mirror.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Maps/","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a derived-artifact area; do not generate non-essential canvases by default.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Results/Reports/","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the default subdirectory for round and batch experiment reports.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Responsibilities","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"detect and bind the current repo to a project root","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"bootstrap the project skeleton","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"route notes into ","type":"text"},{"text":"Sources / Knowledge / Experiments / Results / Results/Reports / Writing / Daily / Maps / Archive","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"update ","type":"text"},{"text":"00-Hub.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"01-Plan.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"02-Index.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"update ","type":"text"},{"text":"_system/registry.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"_system/schema.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"_system/lint-report.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"handle note lifecycle actions: create, update, rename, archive, purge, promote, and link repair","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"run deterministic health checks through helper scripts","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Deterministic helpers","type":"text"}]},{"type":"paragraph","content":[{"text":"Use the scripts under ","type":"text"},{"text":"scripts/","type":"text","marks":[{"type":"code_inline"}]},{"text":" for:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scaffold","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"registry consistency","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"link checks","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"index checks","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"canvas checks","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"lint aggregation","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Use agents for:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"note routing","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"daily promotion decisions","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"source vs knowledge judgment","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"stable-result judgment","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"semantic Hub and Index updates","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Read next","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/DIRECTORY-SCHEMA.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/HUB-PLAN-INDEX.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/REGISTRY.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/DAILY-PROMOTION.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/LIFECYCLE.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/LINT.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/BINDING-LAYER.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"obsidian-project-kb-core","author":"@skillopedia","source":{"stars":4138,"repo_name":"claude-scholar","origin_url":"https://github.com/galaxy-dawn/claude-scholar/blob/HEAD/skills/obsidian-project-kb-core/SKILL.md","repo_owner":"galaxy-dawn","body_sha256":"4bf1be4918b0304767a9d4620434c943f32bcd506cafcd9aa7cc3224b9947ffa","cluster_key":"c6c97432d0674e1fc41aaf55fb09c1086606818eec2fc6e523ba425174c3f3fd","clean_bundle":{"format":"clean-skill-bundle-v1","source":"galaxy-dawn/claude-scholar/skills/obsidian-project-kb-core/SKILL.md","attachments":[{"id":"d8387f69-70cf-539c-8a3f-24ad00f9ba41","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d8387f69-70cf-539c-8a3f-24ad00f9ba41/attachment.md","path":"references/BINDING-LAYER.md","size":482,"sha256":"abf9ef97305eb03cca260d6afd4baed8778f56298fca802962d24ef5b0cd627b","contentType":"text/markdown; charset=utf-8"},{"id":"f3bee598-dc79-564b-ab7d-479a023f4ec4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f3bee598-dc79-564b-ab7d-479a023f4ec4/attachment.md","path":"references/DAILY-PROMOTION.md","size":563,"sha256":"938c26726396fc4f6e00cad9b64686c2033574b57666b1860dc438f0acbceb51","contentType":"text/markdown; charset=utf-8"},{"id":"a7d84b00-182f-53a4-a207-ea1d65cb55b7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a7d84b00-182f-53a4-a207-ea1d65cb55b7/attachment.md","path":"references/DIRECTORY-SCHEMA.md","size":753,"sha256":"9a15487a6f95f46848806f89e5aafb701c6eaf76a5346c15cc12513baf28ce44","contentType":"text/markdown; charset=utf-8"},{"id":"4695970a-e8ec-5664-8ba1-85f8262cd8c3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4695970a-e8ec-5664-8ba1-85f8262cd8c3/attachment.md","path":"references/HUB-PLAN-INDEX.md","size":1042,"sha256":"65362c9ebd89d08e12ee0c5045f002917421b61c8fbe9dd7cfae819bf47e67b3","contentType":"text/markdown; charset=utf-8"},{"id":"ea469b80-6409-5e87-a614-315e4017285f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ea469b80-6409-5e87-a614-315e4017285f/attachment.md","path":"references/LIFECYCLE.md","size":927,"sha256":"bdd2352008f088d2e0a91de6088ae853c8e17b6e5ca704133eb572865b8a1658","contentType":"text/markdown; charset=utf-8"},{"id":"86abd7af-963e-52bc-8a02-b9a02149d390","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/86abd7af-963e-52bc-8a02-b9a02149d390/attachment.md","path":"references/LINT.md","size":433,"sha256":"c7377e6edf7220d7852592871a70a3beceb91ad25367f1d5cc62bc94d91d58e2","contentType":"text/markdown; charset=utf-8"},{"id":"c75a99dd-9dc9-570a-87cd-4014bef688b9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c75a99dd-9dc9-570a-87cd-4014bef688b9/attachment.md","path":"references/REGISTRY.md","size":857,"sha256":"57d949e379af27710c18d1dcc2c75e174a9012413046200a998340795aa7d2d0","contentType":"text/markdown; charset=utf-8"},{"id":"cdaf6374-0dcb-506b-b739-2296a6dc2883","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cdaf6374-0dcb-506b-b739-2296a6dc2883/attachment.py","path":"scripts/kb_canvas_check.py","size":1926,"sha256":"b5af59701c390cf9cac68ec4ec3c0028db2177a519b627863816b20abb94f89b","contentType":"text/x-python; charset=utf-8"},{"id":"a2269e01-076b-5889-ba08-1ac3c7b6de3e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a2269e01-076b-5889-ba08-1ac3c7b6de3e/attachment.py","path":"scripts/kb_common.py","size":31366,"sha256":"6f1e42b559c8556375bb20afa217a7ad3eb2620b465766d9aab8de6266642b2b","contentType":"text/x-python; charset=utf-8"},{"id":"19289cff-9f5c-5cf9-a73f-f50508951590","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/19289cff-9f5c-5cf9-a73f-f50508951590/attachment.py","path":"scripts/kb_index_check.py","size":1845,"sha256":"fb7c2673bb6e654feb90aa2a9210e233740c8e9baf6220ff27452169fa597aee","contentType":"text/x-python; charset=utf-8"},{"id":"38d0110d-653a-5b52-9459-23efffe897ab","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/38d0110d-653a-5b52-9459-23efffe897ab/attachment.py","path":"scripts/kb_link_check.py","size":2593,"sha256":"c9ce792738c59e9221e1bd3d19ff89da8bdb74ff706baee247a6e2a75202151e","contentType":"text/x-python; charset=utf-8"},{"id":"e7f4663d-da78-5195-a8a1-53e601b49166","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e7f4663d-da78-5195-a8a1-53e601b49166/attachment.py","path":"scripts/kb_lint.py","size":9813,"sha256":"46fbd9f1e7cbfa3dd7748bbdd466509fcd61bade5aa473cee1cf649cb573fd6b","contentType":"text/x-python; charset=utf-8"},{"id":"2800ce1b-f267-549f-9fb0-8c8ff9b1eb02","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2800ce1b-f267-549f-9fb0-8c8ff9b1eb02/attachment.py","path":"scripts/kb_registry_check.py","size":2180,"sha256":"f9cf32db691e958b53c4e11818b1e4831087c479f3db2c093e015ed1949af68b","contentType":"text/x-python; charset=utf-8"},{"id":"de88786c-b465-5759-8d5a-9ff5f363654e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/de88786c-b465-5759-8d5a-9ff5f363654e/attachment.py","path":"scripts/kb_scaffold.py","size":1661,"sha256":"318f74b857bf64158023e01e0d10a65ffee361fc1735751e80761301162d4e83","contentType":"text/x-python; charset=utf-8"},{"id":"bd5c3753-2841-50fc-8c57-6127e19b8a0d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bd5c3753-2841-50fc-8c57-6127e19b8a0d/attachment.py","path":"scripts/project_kb.py","size":21178,"sha256":"c62e872f2aefea74bdf2f98590c9f6544f39e02743fd49c5bd55bc4568ddafad","contentType":"text/x-python; charset=utf-8"}],"bundle_sha256":"9aa9290365d633ea0704403d7453eb27005375245150ad22136f649b0f75b54f","attachment_count":15,"text_attachments":15,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/obsidian-project-kb-core/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"education-research","category_label":"Education"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"education-research","import_tag":"clean-skills-v1","description":"Use this as the main Claude Scholar skill for a vault-first, project-scoped Obsidian research knowledge base rooted at Research/{project-slug}/. It owns bootstrap, routing, daily logging, hub/plan/index maintenance, registry updates, lifecycle actions, and lint orchestration."}},"renderedAt":1782981253699}

Obsidian Project KB Core This is the main workflow authority for project-scoped Obsidian knowledge maintenance. Default project root: Default structure: Core rules - Keep all durable project knowledge inside the current . - Keep repo-local only as the runtime binding layer. - is the only visible project registry. - is a human navigation note, not a registry mirror. - is a derived-artifact area; do not generate non-essential canvases by default. - is the default subdirectory for round and batch experiment reports. Responsibilities - detect and bind the current repo to a project root - bootstra…