backup materials and knowledge-base docs

This commit is contained in:
admin
2026-05-30 16:22:29 +08:00
commit 93e50e8fce
3024 changed files with 2994945 additions and 0 deletions

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()