186 lines
5.9 KiB
Python
Executable File
186 lines
5.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
ArcKit BeforeTool Hook for Gemini CLI -- ARC Filename Validation.
|
|
|
|
Intercepts write_file tool calls targeting ARC-* files under projects/
|
|
and auto-corrects filenames to match the ArcKit naming convention
|
|
(ARC-{PID}-{TYPE}[-{SEQ}]-v{VER}.md).
|
|
|
|
Python equivalent of arckit-claude/hooks/validate-arc-filename.mjs.
|
|
|
|
Corrections applied:
|
|
- Zero-pads project ID to 3 digits (1 -> 001)
|
|
- Normalizes version format (v1 -> v1.0)
|
|
- Corrects project ID to match directory number (ARC-999 in 001-foo/ -> ARC-001)
|
|
- Moves multi-instance types to correct subdirectory (ADR -> decisions/)
|
|
- Assigns next sequence number for multi-instance types missing one
|
|
- Creates subdirectories as needed (mkdir -p equivalent)
|
|
|
|
Hook Type: BeforeTool
|
|
Input (stdin): JSON with tool_name, tool_input, cwd, etc.
|
|
Output (stdout): JSON with updatedInput if path changed, or nothing
|
|
Exit codes: 0 = allow, 2 = block (invalid type code)
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from hook_utils import (
|
|
parse_hook_input, output_json, output_block,
|
|
KNOWN_TYPES, MULTI_INSTANCE_TYPES, SUBDIR_MAP,
|
|
)
|
|
|
|
data = parse_hook_input()
|
|
if not data:
|
|
sys.exit(0)
|
|
|
|
tool_input = data.get("tool_input", {})
|
|
file_path = tool_input.get("file_path", "")
|
|
if not file_path:
|
|
sys.exit(0)
|
|
|
|
# Resolve relative paths using cwd
|
|
if not file_path.startswith("/"):
|
|
cwd = data.get("cwd", "")
|
|
if cwd:
|
|
file_path = os.path.join(cwd, file_path)
|
|
|
|
filename = os.path.basename(file_path)
|
|
dirpath = os.path.dirname(file_path)
|
|
|
|
# Early exit: only process ARC-*.md files under a projects/ directory
|
|
if "/projects/" not in file_path:
|
|
sys.exit(0)
|
|
if not filename.startswith("ARC-"):
|
|
sys.exit(0)
|
|
if not filename.endswith(".md"):
|
|
sys.exit(0)
|
|
|
|
# --- Extract project directory info ---
|
|
# Path format: .../projects/{NNN-name}/[subdir/]ARC-*.md
|
|
after_projects = file_path.split("projects/", 1)[1]
|
|
project_dir_name = after_projects.split("/")[0]
|
|
projects_base = file_path.split("projects/", 1)[0] + "projects"
|
|
project_dir = os.path.join(projects_base, project_dir_name)
|
|
|
|
# Extract project number from directory name
|
|
dir_project_num = ""
|
|
dir_match = re.match(r"^(\d+)-", project_dir_name)
|
|
if dir_match:
|
|
dir_project_num = dir_match.group(1)
|
|
|
|
# --- Parse ARC filename ---
|
|
# Patterns: ARC-001-REQ-v1.0.md, ARC-001-ADR-001-v1.0.md, ARC-001-SECD-MOD-v1.0.md
|
|
core = filename[4:] # Strip "ARC-"
|
|
core = core[:-3] # Strip ".md"
|
|
|
|
# Extract version: match last -vN.N or -vN
|
|
vm = re.match(r"^(.+)-v(\d+\.?\d*)$", core)
|
|
if not vm:
|
|
# Can't parse version - not a standard ARC filename, pass through
|
|
sys.exit(0)
|
|
pre_version = vm.group(1)
|
|
raw_version = vm.group(2)
|
|
|
|
# Extract project ID (first numeric segment)
|
|
pm = re.match(r"^(\d+)-(.+)$", pre_version)
|
|
if not pm:
|
|
sys.exit(0)
|
|
raw_project_id = pm.group(1)
|
|
type_and_seq = pm.group(2)
|
|
|
|
# --- Determine doc type code and optional sequence number ---
|
|
doc_type = ""
|
|
seq_num = ""
|
|
|
|
tm = re.match(r"^(.+)-(\d{3})$", type_and_seq)
|
|
if tm:
|
|
potential_type = tm.group(1)
|
|
potential_seq = tm.group(2)
|
|
if potential_type in MULTI_INSTANCE_TYPES:
|
|
doc_type = potential_type
|
|
seq_num = potential_seq
|
|
else:
|
|
doc_type = type_and_seq
|
|
else:
|
|
doc_type = type_and_seq
|
|
|
|
# --- Validate doc type code ---
|
|
if doc_type not in KNOWN_TYPES:
|
|
valid_list = " ".join(sorted(KNOWN_TYPES))
|
|
output_block(
|
|
f"ArcKit: Unknown document type code '{doc_type}'. Valid codes: {valid_list}"
|
|
)
|
|
|
|
# --- Normalize project ID (3-digit zero-padded) ---
|
|
if dir_project_num:
|
|
pid_clean = int(dir_project_num.lstrip("0") or "0")
|
|
else:
|
|
pid_clean = int(raw_project_id.lstrip("0") or "0")
|
|
padded_pid = str(pid_clean).zfill(3)
|
|
|
|
# --- Normalize version (ensure N.N format) ---
|
|
if re.match(r"^\d+$", raw_version):
|
|
norm_version = f"{raw_version}.0"
|
|
else:
|
|
norm_version = raw_version
|
|
|
|
# --- Route to correct directory and filename ---
|
|
corrected_path = None
|
|
if doc_type in MULTI_INSTANCE_TYPES:
|
|
# Multi-instance types: route to subdirectory with sequence number
|
|
required_subdir = SUBDIR_MAP.get(doc_type, "")
|
|
target_dir = os.path.join(project_dir, required_subdir)
|
|
|
|
if not seq_num:
|
|
# Scan directory and assign next available sequence number
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
last_num = 0
|
|
|
|
try:
|
|
escaped_type = re.escape(doc_type)
|
|
pattern = re.compile(
|
|
rf"ARC-{padded_pid}-{escaped_type}-(\d+)-"
|
|
)
|
|
for fname in os.listdir(target_dir):
|
|
if not fname.endswith(".md"):
|
|
continue
|
|
nm = pattern.match(fname)
|
|
if nm:
|
|
num = int(nm.group(1))
|
|
if num > last_num:
|
|
last_num = num
|
|
except OSError:
|
|
pass
|
|
|
|
seq_num = str(last_num + 1).zfill(3)
|
|
else:
|
|
# Keep provided sequence number, ensure directory exists
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
|
|
corrected_filename = f"ARC-{padded_pid}-{doc_type}-{seq_num}-v{norm_version}.md"
|
|
corrected_path = os.path.join(target_dir, corrected_filename)
|
|
elif doc_type in SUBDIR_MAP:
|
|
# Single-instance type with required subdirectory (e.g. RSCH -> research/)
|
|
required_subdir = SUBDIR_MAP[doc_type]
|
|
target_dir = os.path.join(project_dir, required_subdir)
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
corrected_filename = f"ARC-{padded_pid}-{doc_type}-v{norm_version}.md"
|
|
corrected_path = os.path.join(target_dir, corrected_filename)
|
|
else:
|
|
# Single-instance type in project root
|
|
corrected_filename = f"ARC-{padded_pid}-{doc_type}-v{norm_version}.md"
|
|
corrected_path = os.path.join(dirpath, corrected_filename)
|
|
|
|
# --- Compare and output ---
|
|
if corrected_path == file_path:
|
|
sys.exit(0)
|
|
|
|
# Return updatedInput with corrected file_path (preserves original content)
|
|
updated_input = dict(tool_input)
|
|
updated_input["file_path"] = corrected_path
|
|
output_json({"updatedInput": updated_input})
|
|
sys.exit(0)
|