first commit
This commit is contained in:
@@ -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`
|
||||
@@ -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}/
|
||||
```
|
||||
@@ -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/`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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()
|
||||
831
文档润色流和知识库构建流/claude-scholar/skills/obsidian-project-kb-core/scripts/kb_common.py
Executable file
831
文档润色流和知识库构建流/claude-scholar/skills/obsidian-project-kb-core/scripts/kb_common.py
Executable 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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
190
文档润色流和知识库构建流/claude-scholar/skills/obsidian-project-kb-core/scripts/kb_lint.py
Executable file
190
文档润色流和知识库构建流/claude-scholar/skills/obsidian-project-kb-core/scripts/kb_lint.py
Executable 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user