first commit

This commit is contained in:
2026-06-11 03:33:14 +08:00
commit 5f555bf342
599 changed files with 142347 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
---
name: obsidian-project-kb-core
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.
---
# Obsidian Project KB Core
This is the **main workflow authority** for project-scoped Obsidian knowledge maintenance.
Default project root:
```text
Research/{project-slug}/
```
Default structure:
```text
00-Hub.md
01-Plan.md
02-Index.md
Sources/
Knowledge/
Experiments/
Results/
Reports/
Writing/
Daily/
Maps/
Archive/
_system/
```
## Core rules
- Keep all durable project knowledge inside the current `Research/{project-slug}/`.
- Keep repo-local `.claude/project-memory/*` only as the runtime binding layer.
- `_system/registry.md` is the only visible project registry.
- `02-Index.md` is a human navigation note, not a registry mirror.
- `Maps/` is a derived-artifact area; do not generate non-essential canvases by default.
- `Results/Reports/` is the default subdirectory for round and batch experiment reports.
## Responsibilities
- detect and bind the current repo to a project root
- bootstrap the project skeleton
- route notes into `Sources / Knowledge / Experiments / Results / Results/Reports / Writing / Daily / Maps / Archive`
- update `00-Hub.md`, `01-Plan.md`, `02-Index.md`
- update `_system/registry.md`, `_system/schema.md`, `_system/lint-report.md`
- handle note lifecycle actions: create, update, rename, archive, purge, promote, and link repair
- run deterministic health checks through helper scripts
## Deterministic helpers
Use the scripts under `scripts/` for:
- scaffold
- registry consistency
- link checks
- index checks
- canvas checks
- lint aggregation
Use agents for:
- note routing
- daily promotion decisions
- source vs knowledge judgment
- stable-result judgment
- semantic Hub and Index updates
## Read next
- `references/DIRECTORY-SCHEMA.md`
- `references/HUB-PLAN-INDEX.md`
- `references/REGISTRY.md`
- `references/DAILY-PROMOTION.md`
- `references/LIFECYCLE.md`
- `references/LINT.md`
- `references/BINDING-LAYER.md`

View File

@@ -0,0 +1,28 @@
# Binding Layer
Repo-local binding metadata remains under:
```text
.claude/project-memory/registry.yaml
.claude/project-memory/<project_id>.md
```
This is the runtime binding layer only.
It may record:
- `project_id`
- `project_slug`
- `repo_roots`
- `vault_root`
- `hub_note`
- `note_language`
- `status`
- `auto_sync`
- recent sync summary
It must not be treated as the project knowledge layer.
The project knowledge layer lives inside:
```text
Research/{project-slug}/
```

View File

@@ -0,0 +1,18 @@
# Daily Promotion Rules
`Daily/` keeps process notes, raw meeting notes, scratch ideas, and temporary observations.
Promote content out of `Daily/` when it becomes:
- a reusable source note
- a durable knowledge note
- an experiment definition
- a stable result
- a writing artifact
Promotion defaults:
- source-centered material -> `Sources/*`
- cross-source synthesis -> `Knowledge/`
- planned or running work -> `Experiments/`
- stable interpreted finding -> `Results/`
- round or batch retrospective -> `Results/Reports/`
- prose deliverable -> `Writing/`

View File

@@ -0,0 +1,46 @@
# Directory Schema
The only default project root is:
```text
Research/{project-slug}/
```
Required files and folders:
```text
00-Hub.md
01-Plan.md
02-Index.md
Sources/
Papers/
Web/
Docs/
Data/
Interviews/
Notes/
Knowledge/
Experiments/
Results/
Reports/
Writing/
Daily/
Maps/
Archive/
_system/
registry.md
schema.md
lint-report.md
```
Defaults:
- `Sources/Papers/` is the only default paper-note directory.
- `Results/Reports/` is the default subdirectory for round or batch experiment reports.
- `Maps/` contains derived artifacts only.
- `Daily/` is preserved by default.
Do not create by default:
- top-level `Papers/`
- `project/KB/`
- project-local JSON or YAML note registries
- `.base` files unless explicitly requested

View File

@@ -0,0 +1,51 @@
# Hub, Plan, and Index
## `00-Hub.md`
Use as the project landing page.
Must contain:
- project summary
- current focus
- current questions
- next actions
- important links
- recent important changes
## `01-Plan.md`
Use for durable plans and active tasks.
Must contain:
- current priorities
- active tasks
- experiment queue
- writing queue when relevant
- current research state when the project has an active research question
Recommended research state block:
```md
## Current Research State
Current question:
Current hypothesis:
Strongest evidence:
Weakest evidence:
Open blockers:
Next experiment:
Do not repeat:
Last decision:
```
Keep this state in `01-Plan.md`. Do not create a separate `.scholar/state.md` for the default Obsidian workflow.
## `02-Index.md`
Use as the human-friendly MOC.
Rules:
- do not mirror the full registry
- list only important and currently active notes
- group links by `Sources`, `Knowledge`, `Experiments`, `Results`, `Writing`, and `Maps`
- keep the note readable as a project directory page

View File

@@ -0,0 +1,19 @@
# Lifecycle Rules
## Project lifecycle
- `detach`: remove or disable repo binding, keep vault project content in place
- `archive`: move the entire project to `Research/_archived/{project-slug}-{date}/` and disable sync
- `purge`: permanently delete binding metadata and the vault project root
## Note lifecycle
- `archive`: move a canonical note into `Research/{project-slug}/Archive/`, repair links, update registry and index
- `rename`: rename or move a canonical note in place, repair links, update registry and index, and do not create archive history
- `purge`: permanently delete a canonical note, repair links, and record the removal in archive history when appropriate
## Defaults
- “remove project knowledge” means `archive`, not `purge`
- note archive history remains visible in `_system/registry.md`
- archived notes may still be referenced for historical context, but they are not active canonical notes

View File

@@ -0,0 +1,15 @@
# Lint Rules
The lint system should check at least:
- missing registry entries
- registry paths that do not exist
- missing index entries
- broken wikilinks
- orphan canonical notes
- duplicate source notes
- experiments without results
- results without experiments
- knowledge notes without sources
- Daily notes with promotable content
- archived notes still referenced by active notes
- canvas nodes that point to missing notes

View File

@@ -0,0 +1,36 @@
# Registry Rules
`_system/registry.md` is the only visible project registry.
## Sections
- Sources
- Knowledge
- Experiments
- Results
- Writing
- Maps
- Archive
## Rules
- Every canonical note must have a registry entry.
- `Daily/` notes are not registered by default.
- Registry entries use stable IDs that do not depend on note titles.
- Archive keeps history; archiving does not delete registry history.
- Paths are rendered as wikilinks.
- The registry is machine-parsed from Markdown tables; do not create a second formal JSON registry.
## ID defaults
- source paper: `paper-001`
- source web: `web-001`
- source doc: `doc-001`
- source data: `data-001`
- source interview: `interview-001`
- source note: `note-001`
- knowledge: `knowledge-001`
- experiment: `exp-001`
- result or report: `result-001`
- writing: `writing-001`
- map: `map-001`

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import kb_common as common # type: ignore
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Validate project canvas files.')
parser.add_argument('--cwd', default='.')
parser.add_argument('--project-id', default='')
return parser.parse_args()
def main() -> None:
args = parse_args()
repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())
binding = common.resolve_binding(repo_root, args.project_id or None)
issues: list[dict[str, str]] = []
for canvas_path in sorted((binding.project_root / 'Maps').glob('*.canvas')):
rel = str(canvas_path.relative_to(binding.project_root)).replace(os.sep, '/')
try:
data = json.loads(canvas_path.read_text(encoding='utf-8'))
except Exception as exc:
issues.append({'file': rel, 'issue': f'invalid-json: {exc}'})
continue
for node in data.get('nodes', []):
if node.get('type') != 'file':
continue
file_ref = str(node.get('file', '')).strip()
if not file_ref:
issues.append({'file': rel, 'issue': 'file-node-missing-path'})
continue
target = binding.vault_path / file_ref
if not target.exists():
issues.append({'file': rel, 'issue': f'missing-target: {file_ref}'})
payload = {
'project_id': binding.project_id,
'project_root': str(binding.project_root),
'canvas_issues': issues,
'issue_count': len(issues),
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,831 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
IGNORE_DIRS = {
'.git', '.hg', '.svn', '.venv', 'venv', 'node_modules', '__pycache__',
'.mypy_cache', '.pytest_cache', '.ruff_cache', '.idea', '.vscode',
'dist', 'build', 'checkpoints', 'checkpoint', 'cache', '.cache',
'temp', 'tmp', '.tmp'
}
SECTION_COLUMNS: dict[str, list[str]] = {
'Sources': ['ID', 'Type', 'Title', 'Path', 'Status', 'Origin', 'Updated'],
'Knowledge': ['ID', 'Type', 'Title', 'Path', 'Status', 'Sources', 'Updated'],
'Experiments': ['ID', 'Title', 'Path', 'Status', 'Related Results', 'Updated'],
'Results': ['ID', 'Title', 'Path', 'Status', 'Related Experiment', 'Sources', 'Updated'],
'Writing': ['ID', 'Title', 'Path', 'Status', 'Related Notes', 'Updated'],
'Maps': ['ID', 'Title', 'Path', 'Status', 'Type', 'Updated'],
'Archive': ['ID', 'Title', 'Old Path', 'Archived Path', 'Reason', 'Archived At'],
}
SECTION_ORDER = list(SECTION_COLUMNS)
SOURCE_TYPES = {
'Papers': 'paper',
'Web': 'web',
'Docs': 'doc',
'Data': 'data',
'Interviews': 'interview',
'Notes': 'note',
}
NOTE_KIND_TO_FOLDER = {
'source': 'Sources',
'paper': 'Sources/Papers',
'knowledge': 'Knowledge',
'experiment': 'Experiments',
'result': 'Results',
'report': 'Results/Reports',
'writing': 'Writing',
'daily': 'Daily',
'map': 'Maps',
}
AUTO_INDEX_BEGIN = '<!-- BEGIN AUTO INDEX -->'
AUTO_INDEX_END = '<!-- END AUTO INDEX -->'
@dataclass(frozen=True)
class Binding:
project_id: str
project_slug: str
repo_root: Path
vault_name: str
vault_path: Path
project_root: Path
hub_note: str
status: str
auto_sync: bool
note_language: str
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
def today_str() -> str:
return datetime.now().strftime('%Y-%m-%d')
def slugify(value: str) -> str:
slug = re.sub(r'[^A-Za-z0-9]+', '-', value).strip('-').lower()
return slug or 'research-project'
def titleize_slug(slug: str) -> str:
return ' '.join(part.capitalize() for part in slug.split('-'))
def normalize_note_token(value: str) -> str:
return re.sub(r'[^a-z0-9]+', '-', value.lower()).strip('-')
def token_set(value: str) -> set[str]:
return {token for token in re.split(r'[^a-z0-9]+', value.lower()) if token}
def find_repo_root(cwd: Path) -> Path:
try:
output = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], cwd=str(cwd), stderr=subprocess.DEVNULL)
return Path(output.decode().strip())
except Exception:
cur = cwd.resolve()
for candidate in [cur, *cur.parents]:
if (candidate / '.git').exists():
return candidate
return cur
def binding_registry_path(repo_root: Path) -> Path:
return repo_root / '.claude' / 'project-memory' / 'registry.yaml'
def project_memory_path(repo_root: Path, project_id: str) -> Path:
return repo_root / '.claude' / 'project-memory' / f'{project_id}.md'
def load_binding_registry(path: Path) -> dict[str, Any]:
if not path.exists():
return {'projects': {}}
raw = path.read_text(encoding='utf-8').strip()
if not raw:
return {'projects': {}}
if raw.startswith('{'):
data = json.loads(raw)
if 'projects' not in data:
data = {'projects': {}}
return data
try:
import yaml # type: ignore
data = yaml.safe_load(raw) or {}
if 'projects' not in data:
data = {'projects': {}}
return data
except Exception:
raise SystemExit(f'Unsupported binding registry format: {path}')
def save_binding_registry(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
try:
import yaml # type: ignore
path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding='utf-8')
except Exception:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
def read_text(path: Path, default: str = '') -> str:
if not path.exists():
return default
return path.read_text(encoding='utf-8')
def write_text(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding='utf-8')
def relative_note_path(target: Path, vault_path: Path) -> str:
return str(target.relative_to(vault_path)).replace(os.sep, '/')
def wikilink(rel_path: str) -> str:
rel = rel_path.replace('\\', '/').strip()
if rel.endswith('.md'):
rel = rel[:-3]
return f'[[{rel}]]'
def parse_frontmatter(text: str) -> dict[str, str]:
if not text.startswith('---\n'):
return {}
try:
_, block, _ = text.split('---\n', 2)
except ValueError:
return {}
data: dict[str, str] = {}
for line in block.splitlines():
if ':' not in line or line.strip().startswith('- '):
continue
key, value = line.split(':', 1)
data[key.strip()] = value.strip().strip('"')
return data
def set_frontmatter_value(text: str, key: str, value: str) -> str:
value_text = str(value)
if not text.startswith('---\n'):
return f'---\n{key}: {value_text}\n---\n\n{text}'.rstrip() + '\n'
try:
_start, block, rest = text.split('---\n', 2)
except ValueError:
return text
lines = block.splitlines()
replaced = False
out: list[str] = []
for line in lines:
if line.startswith(f'{key}:'):
out.append(f'{key}: {value_text}')
replaced = True
else:
out.append(line)
if not replaced:
out.append(f'{key}: {value_text}')
return '---\n' + '\n'.join(out) + '\n---\n' + rest.lstrip('\n')
def project_root_for(vault_path: Path, project_slug: str) -> Path:
return vault_path / 'Research' / project_slug
def resolve_binding(repo_root: Path, project_id: str | None = None) -> Binding:
registry = load_binding_registry(binding_registry_path(repo_root))
projects = registry.get('projects') or {}
if not projects:
raise SystemExit('No registered projects found in .claude/project-memory/registry.yaml')
if project_id is None:
if len(projects) == 1:
project_id = next(iter(projects))
else:
detected = detect(repo_root)
project = detected.get('project') or {}
project_id = project.get('project_id')
if not project_id:
raise SystemExit('Multiple projects registered; pass --project-id')
entry = projects.get(project_id)
if not entry:
raise SystemExit(f'Project {project_id!r} not found in binding registry')
project_root = Path(entry['vault_root']).expanduser().resolve()
vault_path = project_root.parent.parent
return Binding(
project_id=entry.get('project_id', project_id),
project_slug=entry.get('project_slug', project_root.name),
repo_root=repo_root,
vault_name=entry.get('vault_name', vault_path.name),
vault_path=vault_path,
project_root=project_root,
hub_note=entry.get('hub_note', relative_note_path(project_root / '00-Hub.md', vault_path)),
status=entry.get('status', 'active'),
auto_sync=bool(entry.get('auto_sync', True)),
note_language=entry.get('note_language', 'en'),
)
def detect_project_features(repo_root: Path) -> dict[str, Any]:
feature_checks = {
'.git': (repo_root / '.git').exists(),
'README.md': (repo_root / 'README.md').exists(),
'docs/*.md': (repo_root / 'docs').exists(),
'notes/*.md': (repo_root / 'notes').exists(),
'plan/': (repo_root / 'plan').exists(),
'results/': (repo_root / 'results').exists(),
'outputs/': (repo_root / 'outputs').exists(),
'src/': (repo_root / 'src').exists(),
'scripts/': (repo_root / 'scripts').exists(),
}
config_hits = []
for name in ['pyproject.toml', 'requirements.txt', 'environment.yml', 'configs', 'conf', 'Makefile']:
if (repo_root / name).exists():
config_hits.append(name)
score = sum(1 for value in feature_checks.values() if value) + min(len(config_hits), 2)
return {
'score': score,
'matched': [name for name, value in feature_checks.items() if value],
'config_hits': config_hits,
'is_candidate': score >= 3,
}
def detect(repo_root: Path) -> dict[str, Any]:
registry = load_binding_registry(binding_registry_path(repo_root))
features = detect_project_features(repo_root)
project = None
for project_id, entry in (registry.get('projects') or {}).items():
roots = [Path(item).expanduser().resolve() for item in entry.get('repo_roots') or []]
if repo_root.resolve() in roots:
project = {
'project_id': project_id,
'project_slug': entry.get('project_slug', project_id),
'vault_root': entry.get('vault_root', ''),
'status': entry.get('status', 'active'),
'auto_sync': bool(entry.get('auto_sync', True)),
'note_language': entry.get('note_language', 'en'),
}
break
return {
'repo_root': str(repo_root),
'features': features,
'project': project,
'should_bootstrap': bool(features['is_candidate'] and project is None),
}
def template_root() -> Path:
return Path(__file__).resolve().parents[3] / 'templates'
def render_template(rel_path: str, context: dict[str, str]) -> str:
template = (template_root() / rel_path).read_text(encoding='utf-8')
for key, value in context.items():
template = template.replace('{{' + key + '}}', value)
return template
def scaffold_context(project_slug: str, project_title: str) -> dict[str, str]:
ts = now_iso()
return {
'project_slug': project_slug,
'project_title': project_title,
'timestamp': ts,
'today': today_str(),
'title': 'Untitled',
}
def ensure_file_from_template(path: Path, template_rel: str, context: dict[str, str], force: bool = False) -> None:
if path.exists() and not force:
return
write_text(path, render_template(template_rel, context))
def ensure_project_scaffold(project_root: Path, project_slug: str, project_title: str, force: bool = False) -> None:
for rel in [
'Sources/Papers', 'Sources/Web', 'Sources/Docs', 'Sources/Data', 'Sources/Interviews', 'Sources/Notes',
'Knowledge', 'Experiments', 'Results/Reports', 'Writing', 'Daily', 'Maps', 'Archive', '_system'
]:
(project_root / rel).mkdir(parents=True, exist_ok=True)
context = scaffold_context(project_slug, project_title)
ensure_file_from_template(project_root / '00-Hub.md', 'project/00-Hub.md', context, force=force)
ensure_file_from_template(project_root / '01-Plan.md', 'project/01-Plan.md', context, force=force)
ensure_file_from_template(project_root / '02-Index.md', 'project/02-Index.md', context, force=force)
ensure_file_from_template(project_root / 'Daily' / f'{today_str()}.md', 'notes/daily.md', context, force=False)
ensure_file_from_template(project_root / '_system' / 'registry.md', 'project/_system/registry.md', context, force=force)
ensure_file_from_template(project_root / '_system' / 'schema.md', 'project/_system/schema.md', context, force=force)
ensure_file_from_template(project_root / '_system' / 'lint-report.md', 'project/_system/lint-report.md', context, force=force)
def maybe_migrate_old_layout(project_root: Path) -> list[dict[str, str]]:
migrated: list[dict[str, str]] = []
old_papers = project_root / 'Papers'
new_papers = project_root / 'Sources' / 'Papers'
if old_papers.exists():
new_papers.parent.mkdir(parents=True, exist_ok=True)
if new_papers.exists():
for path in sorted(old_papers.glob('*.md')):
target = new_papers / path.name
if not target.exists():
shutil.move(str(path), str(target))
migrated.append({'from': str(path), 'to': str(target)})
if old_papers.is_dir() and not any(old_papers.iterdir()):
old_papers.rmdir()
else:
shutil.move(str(old_papers), str(new_papers))
migrated.append({'from': str(old_papers), 'to': str(new_papers)})
return migrated
def bootstrap_binding(repo_root: Path, vault_path: Path, project_name: str | None = None, force: bool = False, note_language: str = 'en') -> dict[str, Any]:
project_slug = slugify(project_name or repo_root.name)
project_title = project_name or titleize_slug(project_slug)
vault_path = vault_path.expanduser().resolve()
project_root = project_root_for(vault_path, project_slug)
project_root.mkdir(parents=True, exist_ok=True)
ensure_project_scaffold(project_root, project_slug, project_title, force=force)
migrated = maybe_migrate_old_layout(project_root)
reg_path = binding_registry_path(repo_root)
registry = load_binding_registry(reg_path)
registry.setdefault('projects', {})
registry['projects'][project_slug] = {
'project_id': project_slug,
'project_slug': project_slug,
'repo_roots': [str(repo_root.resolve())],
'vault_name': os.environ.get('OBSIDIAN_VAULT_NAME', vault_path.name),
'vault_root': str(project_root),
'hub_note': relative_note_path(project_root / '00-Hub.md', vault_path),
'status': 'active',
'auto_sync': True,
'note_language': note_language,
'updated_at': now_iso(),
}
save_binding_registry(reg_path, registry)
memory = project_memory_path(repo_root, project_slug)
content = f'''---
project_id: {project_slug}
project_slug: {project_slug}
repo_root: {repo_root.resolve()}
vault_root: {project_root}
hub_note: {relative_note_path(project_root / '00-Hub.md', vault_path)}
language: {note_language}
last_sync_at: {now_iso()}
status: active
auto_sync: true
---
# Project Memory: {project_slug}
## Current Focus
- TODO
## Active Tasks
- Review project scaffold
- Fill current sources, experiments, and results
- Keep registry and index updated
## Recent Sync Summary
- Bootstrap completed at {now_iso()}.
'''
write_text(memory, content)
return {
'project_id': project_slug,
'project_slug': project_slug,
'project_root': str(project_root),
'memory_path': str(memory),
'binding_registry': str(reg_path),
'migrated_paths': migrated,
}
def registry_path(project_root: Path) -> Path:
return project_root / '_system' / 'registry.md'
def default_registry_tables() -> dict[str, list[dict[str, str]]]:
return {section: [] for section in SECTION_ORDER}
def parse_table_row(line: str, columns: list[str]) -> dict[str, str] | None:
if not line.startswith('|'):
return None
parts = [part.strip() for part in line.strip().strip('|').split('|')]
if len(parts) != len(columns):
return None
if set(parts) == {'---'}:
return None
return dict(zip(columns, parts))
def parse_registry_md(path: Path) -> dict[str, list[dict[str, str]]]:
if not path.exists():
return default_registry_tables()
content = path.read_text(encoding='utf-8')
rows = default_registry_tables()
current_section = None
columns: list[str] | None = None
for raw_line in content.splitlines():
line = raw_line.rstrip()
if line.startswith('## '):
section = line[3:].strip()
current_section = section if section in SECTION_COLUMNS else None
columns = None
continue
if current_section is None or not line.startswith('|'):
continue
if columns is None:
header = [part.strip() for part in line.strip().strip('|').split('|')]
if header == SECTION_COLUMNS[current_section]:
columns = header
continue
row = parse_table_row(line, columns)
if not row or '---' in ''.join(row.values()):
continue
if current_section == 'Archive':
if row.get('Old Path', '') or row.get('Archived Path', ''):
rows[current_section].append(row)
continue
if row.get(columns[0], ''):
rows[current_section].append(row)
return rows
def render_registry_md(rows: dict[str, list[dict[str, str]]], last_updated: str | None = None) -> str:
lines = ['# Registry', '', f'Last updated: {last_updated or now_iso()}', '']
for section in SECTION_ORDER:
columns = SECTION_COLUMNS[section]
lines.append(f'## {section}')
lines.append('')
lines.append('| ' + ' | '.join(columns) + ' |')
lines.append('|' + '|'.join(['---'] * len(columns)) + '|')
for row in rows.get(section, []):
lines.append('| ' + ' | '.join(row.get(col, '') for col in columns) + ' |')
lines.append('')
return '\n'.join(lines).rstrip() + '\n'
def write_registry(project_root: Path, rows: dict[str, list[dict[str, str]]]) -> None:
write_text(registry_path(project_root), render_registry_md(rows))
def section_and_row_for_relpath(rel_path: str) -> tuple[str, dict[str, str]] | None:
rel = rel_path.replace('\\', '/')
updated = now_iso()
title = Path(rel).stem.replace('-', ' ')
if rel.startswith('Sources/'):
parts = rel.split('/', 2)
source_folder = parts[1] if len(parts) >= 2 else ''
source_type = SOURCE_TYPES.get(source_folder)
if not source_type:
return None
return 'Sources', {
'ID': '', 'Type': source_type, 'Title': title.title(), 'Path': wikilink(rel),
'Status': 'active', 'Origin': source_type, 'Updated': updated,
}
if rel.startswith('Knowledge/'):
return 'Knowledge', {
'ID': '', 'Type': 'knowledge', 'Title': title.title(), 'Path': wikilink(rel),
'Status': 'active', 'Sources': '', 'Updated': updated,
}
if rel.startswith('Experiments/'):
return 'Experiments', {
'ID': '', 'Title': title.title(), 'Path': wikilink(rel), 'Status': 'active',
'Related Results': '', 'Updated': updated,
}
if rel.startswith('Results/'):
return 'Results', {
'ID': '', 'Title': title.title(), 'Path': wikilink(rel), 'Status': 'active',
'Related Experiment': '', 'Sources': '', 'Updated': updated,
}
if rel.startswith('Writing/'):
return 'Writing', {
'ID': '', 'Title': title.title(), 'Path': wikilink(rel), 'Status': 'draft',
'Related Notes': '', 'Updated': updated,
}
if rel.startswith('Maps/'):
return 'Maps', {
'ID': '', 'Title': title.title(), 'Path': wikilink(rel), 'Status': 'active',
'Type': Path(rel).suffix.lstrip('.') or 'map', 'Updated': updated,
}
return None
def next_registry_id(rows: list[dict[str, str]], prefix: str) -> str:
nums = []
for row in rows:
rid = row.get('ID', '')
if rid.startswith(prefix + '-'):
try:
nums.append(int(rid.split('-')[-1]))
except ValueError:
pass
return f'{prefix}-{max(nums, default=0) + 1:03d}'
def prefix_for_section_row(section: str, row: dict[str, str]) -> str:
if section == 'Sources':
return row['Type']
return {
'Knowledge': 'knowledge',
'Experiments': 'exp',
'Results': 'result',
'Writing': 'writing',
'Maps': 'map',
'Archive': 'archive',
}[section]
def registry_add_or_update(project_root: Path, rel_path: str, *, status: str | None = None) -> dict[str, Any]:
payload = section_and_row_for_relpath(rel_path)
if payload is None:
return {'updated': False, 'reason': 'path-not-registrable'}
section, row = payload
rows = parse_registry_md(registry_path(project_root))
existing = None
for item in rows[section]:
if item.get('Path') == wikilink(rel_path):
existing = item
break
if existing is None:
row['ID'] = next_registry_id(rows[section], prefix_for_section_row(section, row))
if status:
row['Status'] = status
rows[section].append(row)
else:
existing['Path'] = row['Path']
existing['Updated'] = row['Updated']
if status is not None:
existing['Status'] = status
write_registry(project_root, rows)
return {'updated': True, 'section': section, 'path': rel_path}
def registry_archive(project_root: Path, old_rel: str, archived_rel: str, reason: str = 'archive') -> dict[str, Any]:
rows = parse_registry_md(registry_path(project_root))
old_link = wikilink(old_rel)
removed_row = None
removed_section = None
for section in SECTION_ORDER:
if section == 'Archive':
continue
for idx, row in enumerate(list(rows[section])):
if row.get('Path') == old_link:
removed_row = row
removed_section = section
rows[section].pop(idx)
break
if removed_row:
break
archive_row = {
'ID': removed_row.get('ID', '') if removed_row else '',
'Title': removed_row.get('Title', Path(old_rel).stem.title()) if removed_row else Path(old_rel).stem.title(),
'Old Path': wikilink(old_rel),
'Archived Path': wikilink(archived_rel),
'Reason': reason,
'Archived At': now_iso(),
}
rows['Archive'].append(archive_row)
write_registry(project_root, rows)
return {'updated': True, 'section': removed_section or 'Archive'}
def registry_remove_path(project_root: Path, rel_path: str, reason: str = 'purge', *, record_archive: bool = True) -> None:
rows = parse_registry_md(registry_path(project_root))
target = wikilink(rel_path)
removed_row = None
for section in SECTION_ORDER:
if section == 'Archive':
continue
keep_rows = []
for row in rows[section]:
if row.get('Path') == target:
removed_row = row
continue
keep_rows.append(row)
rows[section] = keep_rows
if record_archive:
rows['Archive'].append({
'ID': removed_row.get('ID', '') if removed_row else '',
'Title': removed_row.get('Title', Path(rel_path).stem.title()) if removed_row else Path(rel_path).stem.title(),
'Old Path': wikilink(rel_path),
'Archived Path': '',
'Reason': reason,
'Archived At': now_iso(),
})
write_registry(project_root, rows)
def scan_canonical_relpaths(project_root: Path) -> list[str]:
rels: list[str] = []
for base in ['Sources', 'Knowledge', 'Experiments', 'Results', 'Writing', 'Maps']:
path = project_root / base
if not path.exists():
continue
for child in sorted(path.rglob('*')):
if child.is_dir():
continue
if base == 'Maps' and child.suffix not in {'.canvas', '.md'}:
continue
if base != 'Maps' and child.suffix != '.md':
continue
rels.append(str(child.relative_to(project_root)).replace(os.sep, '/'))
return rels
def update_index(project_root: Path) -> None:
rows = parse_registry_md(registry_path(project_root))
auto_lines: list[str] = []
for title, section in [('Sources', 'Sources'), ('Knowledge', 'Knowledge'), ('Experiments', 'Experiments'), ('Results', 'Results'), ('Writing', 'Writing'), ('Maps', 'Maps')]:
auto_lines.append(f'### {title}')
active = [row['Path'] for row in rows.get(section, []) if row.get('Status', '') != 'archived']
if active:
auto_lines.extend(f'- {item}' for item in active)
else:
auto_lines.append('- None yet.')
auto_lines.append('')
managed_note = 'Managed block. Refresh with `/kb-sync` or `/kb-index`. Put hand-written navigation in **Curated Index**, not inside the markers below.'
auto_block = AUTO_INDEX_BEGIN + '\n' + '\n'.join(auto_lines).rstrip() + '\n' + AUTO_INDEX_END
index_path = project_root / '02-Index.md'
content = read_text(index_path)
if not content.strip():
content = '\n'.join([
'---',
'type: project-index',
f'updated: {now_iso()}',
'---',
'',
'# Index',
'',
'## Curated Index',
'- Add human-maintained entry points here.',
'',
'## Auto Index',
'',
managed_note,
auto_block,
'',
]).rstrip() + '\n'
else:
content = set_frontmatter_value(content, 'updated', now_iso())
if AUTO_INDEX_BEGIN in content and AUTO_INDEX_END in content:
content = re.sub(
rf'{re.escape(AUTO_INDEX_BEGIN)}[\s\S]*?{re.escape(AUTO_INDEX_END)}',
auto_block,
content,
count=1,
)
else:
auto_section = '\n'.join([
'## Auto Index',
'',
managed_note,
auto_block,
'',
]).rstrip() + '\n'
if re.search(r'(?m)^## Auto Index\s*$', content):
content = re.sub(
r'(?ms)^## Auto Index\s*\n.*?(?=^## |\Z)',
auto_section,
content,
count=1,
).rstrip() + '\n'
else:
content = content.rstrip() + '\n\n' + auto_section
write_text(index_path, content.rstrip() + '\n')
def ensure_today_daily(project_root: Path, project_slug: str, force: bool = False) -> Path:
path = project_root / 'Daily' / f'{today_str()}.md'
if not path.exists() or force:
context = scaffold_context(project_slug, titleize_slug(project_slug))
write_text(path, render_template('notes/daily.md', context))
return path
def prepend_recent_change(project_root: Path, message: str) -> None:
hub = project_root / '00-Hub.md'
content = read_text(hub)
marker = '## Recent Changes\n'
if marker not in content:
content = content.rstrip() + f'\n\n{marker}- {message}\n'
else:
head, tail = content.split(marker, 1)
existing_lines = [line for line in tail.splitlines() if line.startswith('- ')]
merged = [f'- {message}', *[line for line in existing_lines if line != f'- {message}']]
content = head + marker + '\n'.join(merged[:8]) + '\n'
write_text(hub, content)
def update_project_memory(repo_root: Path, project_id: str, project_root: Path, hub_note: str, note_language: str, summary: str | None = None) -> None:
path = project_memory_path(repo_root, project_id)
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')
content = set_frontmatter_value(content, 'last_sync_at', now_iso())
content = set_frontmatter_value(content, 'vault_root', str(project_root))
content = set_frontmatter_value(content, 'hub_note', hub_note)
if summary:
block = f'## Recent Sync Summary\n- {summary}\n'
if '## Recent Sync Summary\n' in content:
content = re.sub(r'## Recent Sync Summary\n(?:- .*\n?)*', block, content, count=1)
else:
content = content.rstrip() + '\n\n' + block
write_text(path, content)
def list_kind_notes(project_root: Path, kind: str) -> list[Path]:
rel = NOTE_KIND_TO_FOLDER.get(kind)
if not rel:
return []
base = project_root / rel
if not base.exists():
return []
suffixes = {'.md'} if kind != 'map' else {'.md', '.canvas'}
return sorted([p for p in base.rglob('*') if p.is_file() and p.suffix in suffixes])
def project_note_ref(path: Path, project_root: Path) -> str:
rel = path.relative_to(project_root).as_posix()
return rel[:-3] if rel.endswith('.md') else rel
def search_note_candidates(project_root: Path, kind: str, query: str, limit: int = 5) -> list[Path]:
notes = list_kind_notes(project_root, kind)
if not notes:
return []
raw_query = query.strip()
exact = project_root / raw_query
if exact.exists():
return [exact]
if raw_query.endswith('.md'):
exact_md = project_root / raw_query
if exact_md.exists():
return [exact_md]
query_norm = normalize_note_token(raw_query[:-3] if raw_query.endswith('.md') else raw_query)
query_tokens = token_set(raw_query)
scored: list[tuple[tuple[int, int, int], Path]] = []
for note in notes:
ref = project_note_ref(note, project_root)
ref_norm = normalize_note_token(ref)
note_norm = normalize_note_token(note.stem)
score = None
if ref == raw_query or note.stem == raw_query:
score = (0, len(ref), len(note.stem))
elif ref_norm == query_norm or note_norm == query_norm:
score = (1, len(ref_norm), len(note_norm))
elif query_norm and query_norm in ref_norm:
score = (2, len(ref_norm), len(note_norm))
elif query_tokens & token_set(ref):
score = (3, -len(query_tokens & token_set(ref)), len(ref_norm))
if score is not None:
scored.append((score, note))
scored.sort(key=lambda item: (item[0], project_note_ref(item[1], project_root)))
return [item[1] for item in scored[:limit]]
def resolve_project_note(project_root: Path, note: str) -> Path:
candidate = (project_root / note).resolve()
if candidate.exists():
return candidate
if not note.endswith('.md'):
candidate_md = (project_root / f'{note}.md').resolve()
if candidate_md.exists():
return candidate_md
raise SystemExit(f'Note not found: {note}')
def replace_wikilinks(content: str, old_rel: str, new_rel: str | None = None) -> str:
old_variants = {old_rel, old_rel[:-3] if old_rel.endswith('.md') else old_rel}
new_target = None if new_rel is None else (new_rel[:-3] if new_rel.endswith('.md') else new_rel)
def repl(match: re.Match[str]) -> str:
inner = match.group(1)
target, *rest = inner.split('|', 1)
target_no_heading = target.split('#', 1)[0]
if target_no_heading not in old_variants:
return match.group(0)
if new_target is None:
return match.group(0)
replaced = target.replace(target_no_heading, new_target, 1)
if rest:
return f'[[{replaced}|{rest[0]}]]'
return f'[[{replaced}]]'
return re.sub(r'\[\[([^\]]+)\]\]', repl, content)

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import kb_common as common # type: ignore
INDEX_LINK_RE = re.compile(r'\[\[([^\]|#]+)')
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Check 02-Index coverage for active canonical notes.')
parser.add_argument('--cwd', default='.')
parser.add_argument('--project-id', default='')
return parser.parse_args()
def main() -> None:
args = parse_args()
repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())
binding = common.resolve_binding(repo_root, args.project_id or None)
rows = common.parse_registry_md(common.registry_path(binding.project_root))
index_path = binding.project_root / '02-Index.md'
index_text = common.read_text(index_path)
index_links = {match.strip() for match in INDEX_LINK_RE.findall(index_text)}
missing: list[str] = []
for section in ['Sources', 'Knowledge', 'Experiments', 'Results', 'Writing', 'Maps']:
for row in rows.get(section, []):
if row.get('Status') == 'archived':
continue
path = row.get('Path', '').strip()
if not path.startswith('[['):
continue
target = path[2:-2]
if target not in index_links:
missing.append(target)
payload = {
'project_id': binding.project_id,
'project_root': str(binding.project_root),
'missing_index_entries': sorted(missing),
'missing_count': len(missing),
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import kb_common as common # type: ignore
WIKILINK_RE = re.compile(r'\[\[([^\]]+)\]\]')
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Check wikilinks inside the bound project KB.')
parser.add_argument('--cwd', default='.')
parser.add_argument('--project-id', default='')
return parser.parse_args()
def normalize_target(target: str) -> str:
target = target.split('|', 1)[0].split('#', 1)[0].strip()
for suffix in ('.md', '.canvas'):
if target.endswith(suffix):
target = target[:-len(suffix)]
break
return target
def build_targets(project_root: Path) -> tuple[set[str], dict[str, list[str]]]:
exact: set[str] = set()
stems: dict[str, list[str]] = {}
for suffix in ('*.md', '*.canvas'):
for path in project_root.rglob(suffix):
rel = str(path.relative_to(project_root)).replace(os.sep, '/')
note_ref = rel[:-len(path.suffix)]
exact.add(note_ref)
stems.setdefault(path.stem, []).append(note_ref)
return exact, stems
def main() -> None:
args = parse_args()
repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())
binding = common.resolve_binding(repo_root, args.project_id or None)
exact, stems = build_targets(binding.project_root)
broken: list[dict[str, str]] = []
for path in sorted(binding.project_root.rglob('*.md')):
rel = str(path.relative_to(binding.project_root)).replace(os.sep, '/')
if rel.startswith('_system/'):
continue
text = path.read_text(encoding='utf-8')
for raw in WIKILINK_RE.findall(text):
target = normalize_target(raw)
if not target or target.startswith('#'):
continue
if target in exact:
continue
basename = Path(target).name
if basename in stems and len(stems[basename]) == 1:
continue
broken.append({'file': rel, 'target': target})
payload = {
'project_id': binding.project_id,
'project_root': str(binding.project_root),
'broken_links': broken,
'broken_count': len(broken),
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import kb_common as common # type: ignore
WIKILINK_RE = re.compile(r'\[\[([^\]|#]+)')
SOURCE_LINK_RE = re.compile(r'\[\[(Sources/[^\]|#]+)')
EXPERIMENT_LINK_RE = re.compile(r'\[\[(Experiments/[^\]|#]+)')
RESULT_LINK_RE = re.compile(r'\[\[(Results/[^\]|#]+)')
ARCHIVED_RESULT_LINK_RE = re.compile(r'\[\[(Archive/Results/[^\]|#]+)')
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Aggregate KB lint checks and write _system/lint-report.md.')
parser.add_argument('--cwd', default='.')
parser.add_argument('--project-id', default='')
return parser.parse_args()
def run_json(script_name: str, cwd: Path, project_id: str) -> dict:
cmd = ['python3', str(SCRIPT_DIR / script_name), '--cwd', str(cwd)]
if project_id:
cmd.extend(['--project-id', project_id])
out = subprocess.check_output(cmd, text=True)
return json.loads(out)
def note_refs_in_file(path: Path) -> set[str]:
text = path.read_text(encoding='utf-8')
return {match.strip() for match in WIKILINK_RE.findall(text)}
def main() -> None:
args = parse_args()
repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())
binding = common.resolve_binding(repo_root, args.project_id or None)
registry_check = run_json('kb_registry_check.py', repo_root, binding.project_id)
link_check = run_json('kb_link_check.py', repo_root, binding.project_id)
index_check = run_json('kb_index_check.py', repo_root, binding.project_id)
canvas_check = run_json('kb_canvas_check.py', repo_root, binding.project_id)
rows = common.parse_registry_md(common.registry_path(binding.project_root))
knowledge_without_sources: list[str] = []
for row in rows.get('Knowledge', []):
target = row.get('Path', '')
if not target.startswith('[['):
continue
note_ref = target[2:-2]
note_path = binding.project_root / f'{note_ref}.md'
if note_path.exists():
text = note_path.read_text(encoding='utf-8')
if not SOURCE_LINK_RE.search(text):
knowledge_without_sources.append(note_ref)
experiments_without_results: list[str] = []
experiments_with_only_archived_results: list[str] = []
for row in rows.get('Experiments', []):
target = row.get('Path', '')
if not target.startswith('[['):
continue
note_ref = target[2:-2]
note_path = binding.project_root / f'{note_ref}.md'
if note_path.exists():
text = note_path.read_text(encoding='utf-8')
has_active_result = bool(RESULT_LINK_RE.search(text))
has_archived_result = bool(ARCHIVED_RESULT_LINK_RE.search(text))
if not has_active_result and not has_archived_result:
experiments_without_results.append(note_ref)
elif has_archived_result and not has_active_result:
experiments_with_only_archived_results.append(note_ref)
result_refs = {row.get('Path', '')[2:-2] for row in rows.get('Results', []) if row.get('Path', '').startswith('[[')}
results_without_experiments: list[str] = []
for note_ref in sorted(result_refs):
note_path = binding.project_root / f'{note_ref}.md'
if note_path.exists():
text = note_path.read_text(encoding='utf-8')
if not EXPERIMENT_LINK_RE.search(text):
results_without_experiments.append(note_ref)
archived_refs = {row.get('Archived Path', '')[2:-2] for row in rows.get('Archive', []) if row.get('Archived Path', '').startswith('[[')}
active_notes_referencing_archived_notes: list[str] = []
active_md_files = []
for p in binding.project_root.rglob('*.md'):
rel = str(p.relative_to(binding.project_root)).replace(os.sep, '/')
if rel.startswith('Archive/') or rel.startswith('_system/'):
continue
active_md_files.append(p)
for path in active_md_files:
refs = note_refs_in_file(path)
shared = sorted(ref for ref in refs if ref in archived_refs)
for ref in shared:
active_notes_referencing_archived_notes.append(f'{path.relative_to(binding.project_root).as_posix()} -> {ref}')
daily_candidates: list[str] = []
for daily_path in sorted((binding.project_root / 'Daily').glob('*.md')):
text = daily_path.read_text(encoding='utf-8')
if 'TODO' in text or 'Open Questions' in text or 'Promote' in text:
daily_candidates.append(daily_path.relative_to(binding.project_root).as_posix())
summary_rows = [
('Broken links', 'pass' if link_check['broken_count'] == 0 else 'fail', link_check['broken_count']),
('Missing registry entries', 'pass' if not registry_check['missing_registry_entries'] else 'fail', len(registry_check['missing_registry_entries'])),
('Dangling registry entries', 'pass' if not registry_check['dangling_registry_entries'] else 'fail', len(registry_check['dangling_registry_entries'])),
('Missing index entries', 'pass' if index_check['missing_count'] == 0 else 'warn', index_check['missing_count']),
('Canvas issues', 'pass' if canvas_check['issue_count'] == 0 else 'warn', canvas_check['issue_count']),
('Knowledge without sources', 'pass' if not knowledge_without_sources else 'warn', len(knowledge_without_sources)),
('Experiments without results', 'pass' if not experiments_without_results else 'warn', len(experiments_without_results)),
('Experiments with only archived results', 'pass' if not experiments_with_only_archived_results else 'warn', len(experiments_with_only_archived_results)),
('Results without experiments', 'pass' if not results_without_experiments else 'warn', len(results_without_experiments)),
('Daily promotion candidates', 'pass' if not daily_candidates else 'warn', len(daily_candidates)),
('Active notes referencing archived notes', 'pass' if not active_notes_referencing_archived_notes else 'warn', len(active_notes_referencing_archived_notes)),
]
lines = ['# Lint Report', '', f'Last checked: {common.now_iso()}', '', '## Summary', '', '| Check | Status | Count |', '|---|---|---|']
lines.extend(f'| {name} | {status} | {count} |' for name, status, count in summary_rows)
lines.extend(['', '## Issues', ''])
def add_issue_block(title: str, items: list[str]) -> None:
lines.append(f'### {title}')
if items:
lines.extend(f'- {item}' for item in items)
else:
lines.append('- None.')
lines.append('')
add_issue_block('Missing Registry Entries', registry_check['missing_registry_entries'])
add_issue_block('Dangling Registry Entries', registry_check['dangling_registry_entries'])
add_issue_block('Broken Wikilinks', [f"{item['file']} -> {item['target']}" for item in link_check['broken_links']])
add_issue_block('Missing Index Entries', index_check['missing_index_entries'])
add_issue_block('Canvas Issues', [f"{item['file']} -> {item['issue']}" for item in canvas_check['canvas_issues']])
add_issue_block('Knowledge Notes Without Sources', knowledge_without_sources)
add_issue_block('Experiments Without Results', experiments_without_results)
add_issue_block('Experiments With Only Archived Results', experiments_with_only_archived_results)
add_issue_block('Results Without Experiments', results_without_experiments)
add_issue_block('Daily Promotion Candidates', daily_candidates)
add_issue_block('Active Notes Referencing Archived Notes', active_notes_referencing_archived_notes)
lines.extend(['## Recommended Fixes', ''])
fixes: list[str] = []
if registry_check['missing_registry_entries']:
fixes.append('Add registry rows for all missing canonical notes.')
if link_check['broken_count']:
fixes.append('Repair or remove broken wikilinks before the next sync.')
if index_check['missing_count']:
fixes.append('Update 02-Index.md so active canonical notes remain navigable.')
if experiments_with_only_archived_results:
fixes.append('Decide whether archived result links should stay historical or be promoted back into active Results.')
if daily_candidates:
fixes.append('Promote durable content from Daily into Knowledge, Experiments, Results, or Writing.')
if not fixes:
fixes.append('No action required.')
lines.extend(f'- {fix}' for fix in fixes)
lines.append('')
report_path = binding.project_root / '_system' / 'lint-report.md'
common.write_text(report_path, '\n'.join(lines))
payload = {
'project_id': binding.project_id,
'project_root': str(binding.project_root),
'lint_report': str(report_path),
'broken_links': link_check['broken_count'],
'missing_registry_entries': len(registry_check['missing_registry_entries']),
'missing_index_entries': index_check['missing_count'],
'knowledge_without_sources': len(knowledge_without_sources),
'experiments_without_results': len(experiments_without_results),
'experiments_with_only_archived_results': len(experiments_with_only_archived_results),
'results_without_experiments': len(results_without_experiments),
'daily_promotion_candidates': len(daily_candidates),
'active_notes_referencing_archived_notes': len(active_notes_referencing_archived_notes),
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import kb_common as common # type: ignore
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Check registry coverage for the bound project KB.')
parser.add_argument('--cwd', default='.')
parser.add_argument('--project-id', default='')
return parser.parse_args()
def main() -> None:
args = parse_args()
repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())
binding = common.resolve_binding(repo_root, args.project_id or None)
rels = common.scan_canonical_relpaths(binding.project_root)
rows = common.parse_registry_md(common.registry_path(binding.project_root))
active_entries: list[tuple[str, str]] = []
ids: dict[str, list[str]] = {}
for section, entries in rows.items():
if section == 'Archive':
continue
for row in entries:
link = row.get('Path', '')
active_entries.append((section, link))
rid = row.get('ID', '')
if rid:
ids.setdefault(rid, []).append(link)
desired = {common.wikilink(rel) for rel in rels}
registered = {link for _, link in active_entries if link}
missing = sorted(rel for rel in rels if common.wikilink(rel) not in registered)
dangling = sorted(link for link in registered if link not in desired)
duplicate_ids = {rid: paths for rid, paths in ids.items() if len(paths) > 1}
payload = {
'project_id': binding.project_id,
'project_root': str(binding.project_root),
'canonical_count': len(rels),
'registered_count': len(registered),
'missing_registry_entries': missing,
'dangling_registry_entries': dangling,
'duplicate_ids': duplicate_ids,
'coverage_ok': not missing and not dangling and not duplicate_ids,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import kb_common as common # type: ignore
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Scaffold a project KB skeleton.')
parser.add_argument('--cwd', default='.')
parser.add_argument('--project-id', default='')
parser.add_argument('--vault-path', default='')
parser.add_argument('--project-name', default='')
parser.add_argument('--force', action='store_true')
parser.add_argument('--note-language', default='en')
return parser.parse_args()
def main() -> None:
args = parse_args()
repo_root = common.find_repo_root(Path(args.cwd).expanduser().resolve())
if args.vault_path:
result = common.bootstrap_binding(
repo_root,
Path(args.vault_path),
project_name=args.project_name or None,
force=args.force,
note_language=args.note_language,
)
else:
binding = common.resolve_binding(repo_root, args.project_id or None)
common.ensure_project_scaffold(binding.project_root, binding.project_slug, common.titleize_slug(binding.project_slug), force=args.force)
result = {
'project_id': binding.project_id,
'project_root': str(binding.project_root),
'scaffold_refreshed': True,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,492 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sys
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import kb_common as common # type: ignore
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Project-scoped Obsidian KB helper.')
subparsers = parser.add_subparsers(dest='command', required=True)
detect = subparsers.add_parser('detect', help='Detect repo binding and candidate status.')
detect.add_argument('--cwd', default='.')
bootstrap = subparsers.add_parser('bootstrap', help='Create or rebuild a bound project KB.')
bootstrap.add_argument('--cwd', default='.')
bootstrap.add_argument('--vault-path', default='')
bootstrap.add_argument('--project-name', default='')
bootstrap.add_argument('--project-id', default='')
bootstrap.add_argument('--note-language', default='en')
bootstrap.add_argument('--force', action='store_true')
sync = subparsers.add_parser('sync', help='Refresh scaffold, registry, index, daily note, and runtime binding summary.')
sync.add_argument('--cwd', default='.')
sync.add_argument('--project-id', default='')
sync.add_argument('--scope', default='auto')
status = subparsers.add_parser('status', help='Return a compact project KB status summary.')
status.add_argument('--cwd', default='.')
status.add_argument('--project-id', default='')
lifecycle = subparsers.add_parser('lifecycle', help='Manage project-level lifecycle state.')
lifecycle.add_argument('--cwd', default='.')
lifecycle.add_argument('--project-id', default='')
lifecycle.add_argument('--mode', choices=['detach', 'archive', 'purge', 'rebuild'], required=True)
lifecycle.add_argument('--vault-path', default='')
lifecycle.add_argument('--force', action='store_true')
query = subparsers.add_parser('query-context', help='Return note context candidates for the current project.')
query.add_argument('--cwd', default='.')
query.add_argument('--project-id', default='')
query.add_argument('--kind', default='broad')
query.add_argument('--query', default='')
find = subparsers.add_parser('find-canonical-note', help='Find canonical notes by kind and query.')
find.add_argument('--cwd', default='.')
find.add_argument('--project-id', default='')
find.add_argument('--kind', required=True)
find.add_argument('--query', required=True)
find.add_argument('--limit', type=int, default=5)
note = subparsers.add_parser('note-lifecycle', help='Manage a single canonical note.')
note.add_argument('--cwd', default='.')
note.add_argument('--project-id', default='')
note.add_argument('--mode', choices=['archive', 'purge', 'rename'], required=True)
note.add_argument('--note', required=True)
note.add_argument('--dest', default='')
note.add_argument('--reason', default='manual')
return parser.parse_args()
def repo_root_from(cwd: str) -> Path:
return common.find_repo_root(Path(cwd).expanduser().resolve())
def registry_links(rows: dict[str, list[dict[str, str]]]) -> dict[str, str]:
links: dict[str, str] = {}
for section, entries in rows.items():
if section == 'Archive':
continue
for row in entries:
path = row.get('Path', '')
if path:
links[path] = section
return links
def sync_registry(project_root: Path) -> dict[str, Any]:
rels = common.scan_canonical_relpaths(project_root)
rows = common.parse_registry_md(common.registry_path(project_root))
existing = registry_links(rows)
desired = {common.wikilink(rel): rel for rel in rels}
added: list[str] = []
removed: list[str] = []
for rel in rels:
result = common.registry_add_or_update(project_root, rel)
if result.get('updated') and common.wikilink(rel) not in existing:
added.append(rel)
rows = common.parse_registry_md(common.registry_path(project_root))
for section in list(rows):
if section == 'Archive':
continue
keep: list[dict[str, str]] = []
for row in rows[section]:
link = row.get('Path', '')
if link in desired:
keep.append(row)
else:
removed.append(link)
rows[section] = keep
common.write_registry(project_root, rows)
return {'canonical_paths': rels, 'added': added, 'removed': removed}
def update_hub_link_block(binding: common.Binding) -> None:
hub_path = binding.project_root / '00-Hub.md'
content = common.read_text(hub_path)
required_lines = [
'- [[01-Plan]]',
'- [[02-Index]]',
'- [[_system/registry]]',
f'- [[Daily/{common.today_str()}]]',
]
marker = '## Important Links\n'
if marker not in content:
content = content.rstrip() + '\n\n' + marker + '\n'.join(required_lines) + '\n'
else:
head, tail = content.split(marker, 1)
rest_lines = tail.splitlines()
collected: list[str] = []
skipping = True
idx = 0
while idx < len(rest_lines):
line = rest_lines[idx]
if skipping and (line.startswith('- ') or not line.strip()):
idx += 1
continue
skipping = False
collected = rest_lines[idx:]
break
content = head + marker + '\n'.join(required_lines) + '\n\n' + '\n'.join(collected).lstrip('\n')
common.write_text(hub_path, content.rstrip() + '\n')
def run_sync(binding: common.Binding, scope: str = 'auto') -> dict[str, Any]:
common.ensure_project_scaffold(binding.project_root, binding.project_slug, common.titleize_slug(binding.project_slug))
migrated = common.maybe_migrate_old_layout(binding.project_root)
daily_path = common.ensure_today_daily(binding.project_root, binding.project_slug)
registry_result = sync_registry(binding.project_root)
common.update_index(binding.project_root)
update_hub_link_block(binding)
summary = f'scope={scope}; canonical={len(registry_result["canonical_paths"])}; added={len(registry_result["added"])}; migrated={len(migrated)}'
common.update_project_memory(
binding.repo_root,
binding.project_id,
binding.project_root,
common.relative_note_path(binding.project_root / '00-Hub.md', binding.vault_path),
binding.note_language,
summary=summary,
)
common.prepend_recent_change(binding.project_root, f'{common.now_iso()}: sync refreshed scaffold, registry, index, and daily note ({scope}).')
return {
'project_id': binding.project_id,
'project_root': str(binding.project_root),
'daily_note': str(daily_path),
'migrated_paths': migrated,
'registry_added': registry_result['added'],
'registry_removed': registry_result['removed'],
'canonical_count': len(registry_result['canonical_paths']),
'scope': scope,
}
def load_binding(repo_root: Path, project_id: str | None) -> common.Binding:
return common.resolve_binding(repo_root, project_id or None)
def update_binding_entry(repo_root: Path, project_id: str, **updates: Any) -> dict[str, Any]:
reg_path = common.binding_registry_path(repo_root)
registry = common.load_binding_registry(reg_path)
entry = (registry.get('projects') or {}).get(project_id)
if entry is None:
raise SystemExit(f'Project {project_id!r} not found in binding registry')
entry.update(updates)
entry['updated_at'] = common.now_iso()
common.save_binding_registry(reg_path, registry)
return entry
def archive_project(binding: common.Binding) -> dict[str, Any]:
archive_root = binding.vault_path / 'Research' / '_archived'
archive_root.mkdir(parents=True, exist_ok=True)
dest = archive_root / f'{binding.project_slug}-{common.today_str()}'
counter = 1
while dest.exists():
counter += 1
dest = archive_root / f'{binding.project_slug}-{common.today_str()}-{counter}'
shutil.move(str(binding.project_root), str(dest))
entry = update_binding_entry(
binding.repo_root,
binding.project_id,
vault_root=str(dest),
hub_note=common.relative_note_path(dest / '00-Hub.md', binding.vault_path),
status='archived',
auto_sync=False,
)
common.update_project_memory(
binding.repo_root,
binding.project_id,
dest,
entry['hub_note'],
binding.note_language,
summary=f'project archived to {dest}',
)
return {'project_id': binding.project_id, 'archived_to': str(dest)}
def purge_project(binding: common.Binding) -> dict[str, Any]:
reg_path = common.binding_registry_path(binding.repo_root)
registry = common.load_binding_registry(reg_path)
registry.setdefault('projects', {}).pop(binding.project_id, None)
common.save_binding_registry(reg_path, registry)
memory_path = common.project_memory_path(binding.repo_root, binding.project_id)
if memory_path.exists():
memory_path.unlink()
if binding.project_root.exists():
shutil.rmtree(binding.project_root)
return {
'project_id': binding.project_id,
'purged_project_root': str(binding.project_root),
'removed_memory': str(memory_path),
}
def detach_project(binding: common.Binding) -> dict[str, Any]:
entry = update_binding_entry(binding.repo_root, binding.project_id, status='detached', auto_sync=False)
common.update_project_memory(
binding.repo_root,
binding.project_id,
binding.project_root,
entry['hub_note'],
binding.note_language,
summary='project detached; vault content preserved',
)
return {'project_id': binding.project_id, 'status': 'detached', 'project_root': str(binding.project_root)}
def refresh_or_rebuild(repo_root: Path, project_id: str | None, vault_path: str, force: bool) -> dict[str, Any]:
if project_id:
binding = load_binding(repo_root, project_id)
target_vault = Path(vault_path).expanduser().resolve() if vault_path else binding.vault_path
result = common.bootstrap_binding(repo_root, target_vault, project_name=binding.project_slug, force=True, note_language=binding.note_language)
result['rebuild'] = True
return result
target_vault = Path(vault_path or os.environ.get('OBSIDIAN_VAULT_PATH', '')).expanduser()
if not str(target_vault):
raise SystemExit('Rebuild requires --vault-path or OBSIDIAN_VAULT_PATH')
return common.bootstrap_binding(repo_root, target_vault, force=force)
def broad_context(binding: common.Binding) -> dict[str, Any]:
daily_path = binding.project_root / 'Daily' / f'{common.today_str()}.md'
payload = {
'project_id': binding.project_id,
'project_root': str(binding.project_root),
'hub': str(binding.project_root / '00-Hub.md'),
'plan': str(binding.project_root / '01-Plan.md'),
'index': str(binding.project_root / '02-Index.md'),
'daily': str(daily_path) if daily_path.exists() else '',
}
return payload
def query_context(binding: common.Binding, kind: str, query: str) -> dict[str, Any]:
if kind == 'broad':
return broad_context(binding)
candidates = common.search_note_candidates(binding.project_root, kind, query or kind, limit=8)
return {
'project_id': binding.project_id,
'kind': kind,
'query': query,
'candidates': [str(path) for path in candidates],
}
def replace_links_in_text(content: str, old_rel: str, new_rel: str | None) -> str:
if new_rel is not None:
return common.replace_wikilinks(content, old_rel, new_rel)
old_variants = {old_rel, old_rel[:-3] if old_rel.endswith('.md') else old_rel}
old_title = Path(old_rel).stem.replace('-', ' ')
def repl(match: Any) -> str:
inner = match.group(1)
target, *rest = inner.split('|', 1)
target_no_heading = target.split('#', 1)[0]
if target_no_heading not in old_variants:
return match.group(0)
if rest:
return rest[0]
return old_title
import re
return re.sub(r'\[\[([^\]]+)\]\]', repl, content)
def rewrite_project_references(project_root: Path, old_rel: str, new_rel: str | None) -> list[str]:
touched: list[str] = []
for path in sorted(project_root.rglob('*')):
if path.is_dir():
continue
rel = str(path.relative_to(project_root)).replace(os.sep, '/')
if rel == '02-Index.md' or rel.startswith('_system/'):
continue
if path.suffix == '.md':
before = common.read_text(path)
after = replace_links_in_text(before, old_rel, new_rel)
if after != before:
common.write_text(path, after)
touched.append(str(path))
elif path.suffix == '.canvas':
before = common.read_text(path)
marker_old = old_rel.replace('\\', '/')
marker_new = '' if new_rel is None else new_rel.replace('\\', '/')
after = before.replace(marker_old, marker_new) if marker_old in before else before
if after != before:
common.write_text(path, after)
touched.append(str(path))
return touched
def ensure_parent(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def note_lifecycle(binding: common.Binding, mode: str, note: str, dest: str = '', reason: str = 'manual') -> dict[str, Any]:
source = common.resolve_project_note(binding.project_root, note)
source_rel = str(source.relative_to(binding.project_root)).replace(os.sep, '/')
if mode == 'rename':
if not dest:
raise SystemExit('--dest is required for rename')
target = (binding.project_root / dest).resolve()
ensure_parent(target)
shutil.move(str(source), str(target))
target_rel = str(target.relative_to(binding.project_root)).replace(os.sep, '/')
rewritten = rewrite_project_references(binding.project_root, source_rel, target_rel)
common.registry_add_or_update(binding.project_root, target_rel)
common.registry_remove_path(binding.project_root, source_rel, reason='renamed', record_archive=False)
sync_registry(binding.project_root)
common.update_index(binding.project_root)
source_label = Path(source_rel).stem.replace('-', ' ')
target_link = target_rel[:-3] if target_rel.endswith('.md') else target_rel
common.prepend_recent_change(binding.project_root, f'{common.now_iso()}: renamed {source_label} -> [[{target_link}]].')
return {'mode': mode, 'from': source_rel, 'to': target_rel, 'rewritten_paths': rewritten}
if mode == 'archive':
archived = binding.project_root / 'Archive' / source_rel
ensure_parent(archived)
shutil.move(str(source), str(archived))
archived_rel = str(archived.relative_to(binding.project_root)).replace(os.sep, '/')
rewritten = rewrite_project_references(binding.project_root, source_rel, archived_rel)
common.registry_archive(binding.project_root, source_rel, archived_rel, reason=reason)
common.update_index(binding.project_root)
common.prepend_recent_change(binding.project_root, f'{common.now_iso()}: archived [[{source_rel[:-3] if source_rel.endswith(".md") else source_rel}]].')
return {'mode': mode, 'from': source_rel, 'to': archived_rel, 'rewritten_paths': rewritten}
if mode == 'purge':
source.unlink()
rewritten = rewrite_project_references(binding.project_root, source_rel, None)
common.registry_remove_path(binding.project_root, source_rel, reason=reason)
common.update_index(binding.project_root)
common.prepend_recent_change(binding.project_root, f'{common.now_iso()}: purged {source_rel}.')
return {'mode': mode, 'purged': source_rel, 'rewritten_paths': rewritten}
raise SystemExit(f'Unsupported note lifecycle mode: {mode}')
def project_status(binding: common.Binding) -> dict[str, Any]:
rows = common.parse_registry_md(common.registry_path(binding.project_root))
daily_path = binding.project_root / 'Daily' / f'{common.today_str()}.md'
lint_path = binding.project_root / '_system' / 'lint-report.md'
archived_refs = {row.get('Archived Path', '')[2:-2] for row in rows.get('Archive', []) if row.get('Archived Path', '').startswith('[[')}
active_notes_referencing_archived_notes = 0
experiments_with_only_archived_results = 0
for path in sorted(binding.project_root.rglob('*.md')):
rel = str(path.relative_to(binding.project_root)).replace(os.sep, '/')
if rel.startswith('Archive/') or rel.startswith('_system/'):
continue
refs = {match.strip() for match in re.findall(r'\[\[([^\]|#]+)', path.read_text(encoding='utf-8'))}
archived_hits = refs & archived_refs
if archived_hits:
active_notes_referencing_archived_notes += len(archived_hits)
if rel.startswith('Experiments/'):
has_active_result = bool(re.search(r'\[\[(Results/[^\]|#]+)', path.read_text(encoding='utf-8')))
has_archived_result = bool(re.search(r'\[\[(Archive/Results/[^\]|#]+)', path.read_text(encoding='utf-8')))
if has_archived_result and not has_active_result:
experiments_with_only_archived_results += 1
return {
'project_id': binding.project_id,
'project_root': str(binding.project_root),
'status': binding.status,
'auto_sync': binding.auto_sync,
'sources': len(rows.get('Sources', [])),
'knowledge': len(rows.get('Knowledge', [])),
'experiments': len(rows.get('Experiments', [])),
'results': len(rows.get('Results', [])),
'writing': len(rows.get('Writing', [])),
'maps': len(rows.get('Maps', [])),
'archive': len(rows.get('Archive', [])),
'experiments_with_only_archived_results': experiments_with_only_archived_results,
'active_notes_referencing_archived_notes': active_notes_referencing_archived_notes,
'daily_note': str(daily_path) if daily_path.exists() else '',
'lint_report': str(lint_path) if lint_path.exists() else '',
}
def main() -> None:
args = parse_args()
repo_root = repo_root_from(getattr(args, 'cwd', '.'))
if args.command == 'detect':
print(json.dumps(common.detect(repo_root), ensure_ascii=False, indent=2))
return
if args.command == 'bootstrap':
vault_arg = args.vault_path or os.environ.get('OBSIDIAN_VAULT_PATH', '')
if not vault_arg:
raise SystemExit('bootstrap requires --vault-path or OBSIDIAN_VAULT_PATH')
result = common.bootstrap_binding(
repo_root,
Path(vault_arg),
project_name=args.project_name or None,
force=args.force,
note_language=args.note_language,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return
binding = load_binding(repo_root, getattr(args, 'project_id', '') or None)
if args.command == 'sync':
print(json.dumps(run_sync(binding, scope=args.scope), ensure_ascii=False, indent=2))
return
if args.command == 'status':
print(json.dumps(project_status(binding), ensure_ascii=False, indent=2))
return
if args.command == 'lifecycle':
if args.mode == 'detach':
result = detach_project(binding)
elif args.mode == 'archive':
result = archive_project(binding)
elif args.mode == 'purge':
result = purge_project(binding)
elif args.mode == 'rebuild':
result = refresh_or_rebuild(repo_root, binding.project_id, args.vault_path, args.force)
else:
raise SystemExit(f'Unsupported lifecycle mode: {args.mode}')
print(json.dumps(result, ensure_ascii=False, indent=2))
return
if args.command == 'query-context':
print(json.dumps(query_context(binding, args.kind, args.query), ensure_ascii=False, indent=2))
return
if args.command == 'find-canonical-note':
candidates = common.search_note_candidates(binding.project_root, args.kind, args.query, limit=args.limit)
payload = {
'project_id': binding.project_id,
'kind': args.kind,
'query': args.query,
'candidates': [str(path.relative_to(binding.project_root)).replace(os.sep, '/') for path in candidates],
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
return
if args.command == 'note-lifecycle':
print(json.dumps(note_lifecycle(binding, args.mode, args.note, args.dest, args.reason), ensure_ascii=False, indent=2))
return
raise SystemExit(f'Unhandled command: {args.command}')
if __name__ == '__main__':
main()