Files
2026-07-13 12:35:03 +08:00

210 lines
6.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
ArcKit AfterTool Hook for Gemini CLI -- Auto-update docs/manifest.json.
Fires after any write_file tool call. If the written file is an ARC-*.md
under projects/, the hook incrementally updates docs/manifest.json so it
stays current without requiring a full /arckit:pages re-run.
Python equivalent of arckit-claude/hooks/update-manifest.mjs.
Hook Type: AfterTool
Input (stdin): JSON with tool_name, tool_input (file_path, content), cwd
Output (stdout): none (AfterTool hooks are silent)
Exit codes: 0 always
"""
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(__file__))
from hook_utils import (
parse_hook_input, is_file, find_repo_root,
DOC_TYPES, SUBDIR_MAP, SUBDIR_TO_KEY, COMPOUND_TYPES, ARC_PATTERN,
)
def extract_doc_type(filename):
"""Extract document type code from ARC filename."""
m = re.match(r"^ARC-\d{3}-(.+)-v\d+(\.\d+)?\.md$", filename)
if not m:
return None
rest = m.group(1)
# Try compound types first (longest match)
for code in COMPOUND_TYPES:
if rest.startswith(code):
return code
# Strip trailing -NNN for multi-instance types
rest = re.sub(r"-\d{3}$", "", rest)
return rest
def extract_doc_id(filename):
"""Extract document ID (filename without .md)."""
return filename.rsplit(".md", 1)[0] if filename.endswith(".md") else filename
def base_id(document_id):
"""Strip version to get base ID for dedup: ARC-001-REQ-v1.0 -> ARC-001-REQ."""
return re.sub(r"-v\d+(\.\d+)?$", "", document_id)
def extract_first_heading(content):
"""Extract first # heading from markdown content."""
if not content:
return None
for line in content.split("\n", 20)[:20]:
m = re.match(r"^#\s+(.+)", line)
if m:
return m.group(1).strip()
return None
# --- Main ---
data = parse_hook_input()
if not data:
sys.exit(0)
file_path = data.get("tool_input", {}).get("file_path", "")
file_content = data.get("tool_input", {}).get("content", "")
cwd = data.get("cwd", os.getcwd())
# Guard: must be an ARC file under projects/
if "/projects/" not in file_path:
sys.exit(0)
filename = os.path.basename(file_path)
if not ARC_PATTERN.match(filename):
sys.exit(0)
# Guard: repo must have docs/manifest.json
repo_root = find_repo_root(cwd)
if not repo_root:
sys.exit(0)
manifest_path = os.path.join(repo_root, "docs", "manifest.json")
if not is_file(manifest_path):
sys.exit(0)
# Parse manifest
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
except (OSError, json.JSONDecodeError):
sys.exit(0)
# Extract file metadata
doc_type = extract_doc_type(filename)
meta = DOC_TYPES.get(doc_type, {"category": "Other", "name": doc_type or "Unknown"})
document_id = extract_doc_id(filename)
new_base_id = base_id(document_id)
# Determine project dir and subdirectory from path
# Path: .../projects/{NNN-name}/[subdir/]ARC-*.md
after_projects = file_path.split("/projects/", 1)[1]
parts = after_projects.split("/")
project_dir_name = parts[0] # "001-foo" or "000-global"
# Determine if file is in a subdirectory
subdir_name = None
if len(parts) == 3:
# projects/001-foo/decisions/ARC-*.md
subdir_name = parts[1]
# Build the relative path for manifest
rel_path = f"projects/{after_projects}"
# Determine title: for multi-instance types in subdirs, use first heading
title = meta["name"]
if subdir_name and file_content:
heading = extract_first_heading(file_content)
if heading:
title = heading
# Build the new entry
new_entry = {"path": rel_path, "title": title, "documentId": document_id}
# Handle 000-global
if project_dir_name == "000-global":
new_entry["category"] = meta["category"]
if not isinstance(manifest.get("global"), list):
manifest["global"] = []
# Dedup: remove any existing entry with same base ID
manifest["global"] = [
e for e in manifest["global"]
if base_id(e.get("documentId", "")) != new_base_id
]
manifest["global"].append(new_entry)
# Update defaultDocument if this is a PRIN doc
if doc_type == "PRIN":
for d in manifest["global"]:
if d.get("documentId") and "PRIN" in d["documentId"]:
d["isDefault"] = True
manifest["defaultDocument"] = d["path"]
break
# Update timestamp and write
from datetime import datetime, timezone
manifest["generated"] = datetime.now(timezone.utc).isoformat()
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
sys.exit(0)
# Handle numbered project
if not isinstance(manifest.get("projects"), list):
manifest["projects"] = []
# Find existing project or create new one
project = None
for p in manifest["projects"]:
if p.get("id") == project_dir_name:
project = p
break
if project is None:
# Derive display name: "001-fuel-prices" -> "Fuel Prices"
display_name = re.sub(r"^\d{3}-", "", project_dir_name)
display_name = " ".join(
word.capitalize() for word in display_name.split("-")
)
project = {
"id": project_dir_name,
"name": display_name,
"documents": [],
}
manifest["projects"].append(project)
# Determine target array key
target_key = "documents"
if subdir_name and subdir_name in SUBDIR_TO_KEY:
target_key = SUBDIR_TO_KEY[subdir_name]
# Ensure target array exists
if not isinstance(project.get(target_key), list):
project[target_key] = []
# For root documents, include category
if target_key == "documents":
new_entry["category"] = meta["category"]
# Dedup: remove any existing entry with same base ID
project[target_key] = [
e for e in project[target_key]
if base_id(e.get("documentId", "")) != new_base_id
]
project[target_key].append(new_entry)
# Update timestamp and write
from datetime import datetime, timezone
manifest["generated"] = datetime.now(timezone.utc).isoformat()
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
sys.exit(0)