253 lines
9.2 KiB
Python
253 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""wiki-mode.py — read + route helper for v1.8 methodology modes.
|
|
|
|
Single source of truth for "which mode is this vault in" and "where should
|
|
new content of type X be filed under mode Y." Consumed by:
|
|
|
|
- skills/wiki-ingest/SKILL.md (where to file new source/entity/concept pages)
|
|
- skills/save/SKILL.md (where to file session notes)
|
|
- skills/autoresearch/SKILL.md (where to file research output)
|
|
- bin/setup-mode.sh (writes .vault-meta/mode.json)
|
|
|
|
If `.vault-meta/mode.json` is absent → mode = "generic" → behavior identical
|
|
to v1.7. No skill needs to special-case the missing-config path.
|
|
|
|
CLI:
|
|
wiki-mode.py get # print current mode (default: generic)
|
|
wiki-mode.py config # print full config JSON
|
|
wiki-mode.py route TYPE NAME # print suggested path for new content
|
|
# TYPE: source|entity|concept|session|research
|
|
wiki-mode.py set MODE # write mode (lyt|para|zettelkasten|generic)
|
|
wiki-mode.py id # mint a Zettelkasten ID (timestamp)
|
|
wiki-mode.py templates # list per-mode template files
|
|
|
|
Exit codes:
|
|
0 — success
|
|
2 — usage error
|
|
3 — invalid mode string
|
|
4 — invalid content type
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
VAULT_ROOT = Path(__file__).resolve().parent.parent
|
|
META_DIR = VAULT_ROOT / ".vault-meta"
|
|
MODE_PATH = META_DIR / "mode.json"
|
|
|
|
VALID_MODES = ("generic", "lyt", "para", "zettelkasten")
|
|
VALID_TYPES = ("source", "entity", "concept", "session", "research")
|
|
|
|
DEFAULT_CONFIG = {
|
|
"schema_version": 1,
|
|
"mode": "generic",
|
|
"configured_at": None,
|
|
"config": {
|
|
"lyt": {
|
|
"moc_folder": "wiki/mocs/",
|
|
"notes_folder": "wiki/notes/",
|
|
},
|
|
"para": {
|
|
"projects_folder": "wiki/projects/",
|
|
"areas_folder": "wiki/areas/",
|
|
"resources_folder": "wiki/resources/",
|
|
"archives_folder": "wiki/archives/",
|
|
},
|
|
"zettelkasten": {
|
|
"id_format": "YYYYMMDDHHMMSSffffff",
|
|
"no_folders": True,
|
|
"root_folder": "wiki/",
|
|
},
|
|
"generic": {
|
|
"sources_folder": "wiki/sources/",
|
|
"entities_folder": "wiki/entities/",
|
|
"concepts_folder": "wiki/concepts/",
|
|
"sessions_folder": "wiki/sessions/",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def load_config():
|
|
"""Return parsed mode.json, or DEFAULT_CONFIG with mode='generic' if absent."""
|
|
if not MODE_PATH.is_file():
|
|
return dict(DEFAULT_CONFIG)
|
|
try:
|
|
loaded = json.loads(MODE_PATH.read_text(encoding="utf-8"))
|
|
# Merge with defaults so partially-configured files still work
|
|
merged = dict(DEFAULT_CONFIG)
|
|
merged["mode"] = loaded.get("mode", "generic")
|
|
merged["configured_at"] = loaded.get("configured_at")
|
|
loaded_config = loaded.get("config", {})
|
|
for k, v in loaded_config.items():
|
|
if k in merged["config"] and isinstance(v, dict):
|
|
merged["config"][k].update(v)
|
|
return merged
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
print(f"ERR: cannot parse {MODE_PATH}: {e}", file=sys.stderr)
|
|
print(" Falling back to mode=generic. Re-run `bash bin/setup-mode.sh` to fix.",
|
|
file=sys.stderr)
|
|
return dict(DEFAULT_CONFIG)
|
|
|
|
|
|
def save_config(cfg):
|
|
META_DIR.mkdir(parents=True, exist_ok=True)
|
|
payload = json.dumps(cfg, indent=2, ensure_ascii=False) + "\n"
|
|
fd, tmp_path = tempfile.mkstemp(prefix="mode.", suffix=".tmp", dir=str(META_DIR))
|
|
try:
|
|
with open(fd, "w", encoding="utf-8") as fh:
|
|
fh.write(payload)
|
|
Path(tmp_path).replace(MODE_PATH)
|
|
except Exception:
|
|
try:
|
|
Path(tmp_path).unlink()
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def slugify(name):
|
|
"""Filesystem-safe slug; matches the convention used by the existing skills.
|
|
Any run of non-word, non-hyphen characters becomes a single hyphen so that
|
|
'v1.8 launch! prep?' → 'v1-8-launch-prep' (not 'v18launchprep').
|
|
Unicode word characters (CJK, accented Latin, Cyrillic, etc.) are preserved.
|
|
"""
|
|
s = re.sub(r"[^\w\-]+", "-", name, flags=re.UNICODE)
|
|
s = re.sub(r"-+", "-", s).strip("-")
|
|
return s or "untitled"
|
|
|
|
|
|
def safe_name(name):
|
|
"""Sanitize a name that intentionally preserves case + spaces (entity/concept).
|
|
Strips path separators, null bytes, control characters, and leading dots or
|
|
hyphens so the returned string cannot escape its parent directory or be
|
|
interpreted as a hidden file or flag. Spaces and case are preserved.
|
|
"""
|
|
cleaned = re.sub(r"[/\\\x00-\x1f]+", "", name)
|
|
cleaned = cleaned.lstrip(".-")
|
|
return cleaned or "untitled"
|
|
|
|
|
|
def mint_zettel_id():
|
|
"""YYYYMMDDHHMMSSffffff in UTC (microsecond resolution).
|
|
Stable across timezones; lexicographically sortable; collision-resistant
|
|
against rapid back-to-back calls in the same second. Microsecond suffix
|
|
closes the v1.8.0 verifier LOW (two rapid mint calls produced the same
|
|
14-digit ID and would have generated colliding filenames).
|
|
"""
|
|
return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
|
|
|
|
|
|
def route_path(mode, content_type, name, cfg):
|
|
"""Return the suggested vault-relative path for new content under `mode`."""
|
|
if content_type not in VALID_TYPES:
|
|
raise SystemExit(4)
|
|
slug = slugify(name)
|
|
|
|
raw = safe_name(name) # case + spaces preserved, but path-traversal stripped
|
|
|
|
if mode == "generic":
|
|
g = cfg["config"]["generic"]
|
|
mapping = {
|
|
"source": g["sources_folder"] + slug + ".md",
|
|
"entity": g["entities_folder"] + raw + ".md", # preserve capitalization for entities
|
|
"concept": g["concepts_folder"] + raw + ".md",
|
|
"session": g["sessions_folder"] + slug + ".md",
|
|
"research": g["concepts_folder"] + raw + ".md",
|
|
}
|
|
return mapping[content_type]
|
|
|
|
if mode == "lyt":
|
|
notes = cfg["config"]["lyt"]["notes_folder"]
|
|
# All atomic notes flat in wiki/notes/; routing is the same regardless of type
|
|
return notes + slug + ".md"
|
|
|
|
if mode == "para":
|
|
p = cfg["config"]["para"]
|
|
mapping = {
|
|
# New sources land in resources/<topic>/ (we use a generic 'incoming' bucket;
|
|
# the user will sort into specific topics via their own workflow)
|
|
"source": p["resources_folder"] + "incoming/" + slug + ".md",
|
|
"entity": p["resources_folder"] + "people/" + raw + ".md",
|
|
"concept": p["resources_folder"] + "concepts/" + raw + ".md",
|
|
# Session notes land in projects/inbox/; user reroutes to specific projects
|
|
"session": p["projects_folder"] + "inbox/" + slug + ".md",
|
|
"research": p["resources_folder"] + slug + "/" + slug + ".md",
|
|
}
|
|
return mapping[content_type]
|
|
|
|
if mode == "zettelkasten":
|
|
z = cfg["config"]["zettelkasten"]
|
|
zid = mint_zettel_id()
|
|
return z["root_folder"] + f"{zid}-{slug}.md"
|
|
|
|
raise SystemExit(3)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Methodology-mode router for v1.8 Compound Vault.")
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
sub.add_parser("get", help="Print current mode")
|
|
sub.add_parser("config", help="Print full config JSON")
|
|
|
|
sp_route = sub.add_parser("route", help="Print suggested vault path for new content")
|
|
sp_route.add_argument("type", choices=VALID_TYPES)
|
|
sp_route.add_argument("name", help="Content name (will be slugified for filenames)")
|
|
sp_route.add_argument("--mode", choices=VALID_MODES, default=None,
|
|
help="Preview routing under MODE without writing mode.json (default: use current vault mode)")
|
|
|
|
sp_set = sub.add_parser("set", help="Write a mode to .vault-meta/mode.json")
|
|
sp_set.add_argument("mode", choices=VALID_MODES)
|
|
|
|
sub.add_parser("id", help="Mint a Zettelkasten ID (timestamp)")
|
|
sub.add_parser("templates", help="List per-mode template files")
|
|
|
|
args = parser.parse_args()
|
|
cfg = load_config()
|
|
|
|
if args.cmd == "get":
|
|
print(cfg["mode"])
|
|
return 0
|
|
|
|
if args.cmd == "config":
|
|
print(json.dumps(cfg, indent=2, ensure_ascii=False))
|
|
return 0
|
|
|
|
if args.cmd == "route":
|
|
active_mode = args.mode if args.mode else cfg["mode"]
|
|
path = route_path(active_mode, args.type, args.name, cfg)
|
|
print(path)
|
|
return 0
|
|
|
|
if args.cmd == "set":
|
|
cfg["mode"] = args.mode
|
|
cfg["configured_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
save_config(cfg)
|
|
print(f"mode set: {args.mode}")
|
|
return 0
|
|
|
|
if args.cmd == "id":
|
|
print(mint_zettel_id())
|
|
return 0
|
|
|
|
if args.cmd == "templates":
|
|
templates_dir = VAULT_ROOT / "skills" / "wiki-mode" / "templates"
|
|
if not templates_dir.is_dir():
|
|
print(f"ERR: templates dir missing: {templates_dir}", file=sys.stderr)
|
|
return 2
|
|
for f in sorted(templates_dir.rglob("*.md")):
|
|
print(str(f.relative_to(VAULT_ROOT)))
|
|
return 0
|
|
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|