258 lines
9.5 KiB
Python
Executable File
258 lines
9.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
ArcKit Hook Utilities -- Shared module for all Gemini CLI hooks.
|
|
|
|
Python equivalent of arckit-claude/hooks/hook-utils.mjs.
|
|
Provides common helpers for stdin parsing, repo discovery, doc-type
|
|
metadata, and JSON output formatting.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
# ── Document Type Codes (mirrors arckit-claude/config/doc-types.mjs) ──
|
|
|
|
DOC_TYPES = {
|
|
# Discovery
|
|
"REQ": {"name": "Requirements", "category": "Discovery"},
|
|
"STKE": {"name": "Stakeholder Analysis", "category": "Discovery"},
|
|
"RSCH": {"name": "Research Findings", "category": "Discovery"},
|
|
"DSCT": {"name": "Data Source Discovery", "category": "Discovery"},
|
|
# Planning
|
|
"SOBC": {"name": "Strategic Outline Business Case", "category": "Planning"},
|
|
"PLAN": {"name": "Project Plan", "category": "Planning"},
|
|
"ROAD": {"name": "Roadmap", "category": "Planning"},
|
|
"STRAT": {"name": "Architecture Strategy", "category": "Planning"},
|
|
"BKLG": {"name": "Product Backlog", "category": "Planning"},
|
|
# Architecture
|
|
"PRIN": {"name": "Architecture Principles", "category": "Architecture"},
|
|
"HLDR": {"name": "High-Level Design Review", "category": "Architecture"},
|
|
"DLDR": {"name": "Detailed Design Review", "category": "Architecture"},
|
|
"DATA": {"name": "Data Model", "category": "Architecture"},
|
|
"WARD": {"name": "Wardley Map", "category": "Architecture"},
|
|
"DIAG": {"name": "Architecture Diagrams", "category": "Architecture"},
|
|
"DFD": {"name": "Data Flow Diagram", "category": "Architecture"},
|
|
"ADR": {"name": "Architecture Decision Records", "category": "Architecture"},
|
|
"PLAT": {"name": "Platform Design", "category": "Architecture"},
|
|
# Governance
|
|
"RISK": {"name": "Risk Register", "category": "Governance"},
|
|
"TRAC": {"name": "Traceability Matrix", "category": "Governance"},
|
|
"PRIN-COMP": {"name": "Principles Compliance", "category": "Governance"},
|
|
"CONF": {"name": "Conformance Assessment", "category": "Governance"},
|
|
"PRES": {"name": "Presentation", "category": "Governance"},
|
|
"ANAL": {"name": "Analysis Report", "category": "Governance"},
|
|
"GAPS": {"name": "Gap Analysis", "category": "Governance"},
|
|
# Compliance
|
|
"TCOP": {"name": "TCoP Assessment", "category": "Compliance"},
|
|
"SECD": {"name": "Secure by Design", "category": "Compliance"},
|
|
"SECD-MOD": {"name": "MOD Secure by Design", "category": "Compliance"},
|
|
"AIPB": {"name": "AI Playbook Assessment", "category": "Compliance"},
|
|
"ATRS": {"name": "ATRS Record", "category": "Compliance"},
|
|
"DPIA": {"name": "Data Protection Impact Assessment", "category": "Compliance"},
|
|
"JSP936": {"name": "JSP 936 Assessment", "category": "Compliance"},
|
|
"SVCASS": {"name": "Service Assessment", "category": "Compliance"},
|
|
# Operations
|
|
"SNOW": {"name": "ServiceNow Design", "category": "Operations"},
|
|
"DEVOPS": {"name": "DevOps Strategy", "category": "Operations"},
|
|
"MLOPS": {"name": "MLOps Strategy", "category": "Operations"},
|
|
"FINOPS": {"name": "FinOps Strategy", "category": "Operations"},
|
|
"OPS": {"name": "Operational Readiness", "category": "Operations"},
|
|
# Procurement
|
|
"SOW": {"name": "Statement of Work", "category": "Procurement"},
|
|
"EVAL": {"name": "Evaluation Criteria", "category": "Procurement"},
|
|
"DOS": {"name": "DOS Requirements", "category": "Procurement"},
|
|
"GCLD": {"name": "G-Cloud Search", "category": "Procurement"},
|
|
"GCLC": {"name": "G-Cloud Clarifications", "category": "Procurement"},
|
|
"DMC": {"name": "Data Mesh Contract", "category": "Procurement"},
|
|
"VEND": {"name": "Vendor Evaluation", "category": "Procurement"},
|
|
# Research
|
|
"AWRS": {"name": "AWS Research", "category": "Research"},
|
|
"AZRS": {"name": "Azure Research", "category": "Research"},
|
|
"GCRS": {"name": "GCP Research", "category": "Research"},
|
|
# Other
|
|
"STORY": {"name": "Project Story", "category": "Other"},
|
|
}
|
|
|
|
# Set of all valid type codes
|
|
KNOWN_TYPES = set(DOC_TYPES.keys())
|
|
|
|
# Multi-instance types requiring sequence numbers (ADR-001, DIAG-002, etc.)
|
|
MULTI_INSTANCE_TYPES = {
|
|
"ADR", "DIAG", "DFD", "WARD", "DMC",
|
|
"RSCH", "AWRS", "AZRS", "GCRS", "DSCT",
|
|
}
|
|
|
|
# Type code -> required subdirectory
|
|
SUBDIR_MAP = {
|
|
"ADR": "decisions",
|
|
"DIAG": "diagrams",
|
|
"DFD": "diagrams",
|
|
"WARD": "wardley-maps",
|
|
"DMC": "data-contracts",
|
|
"RSCH": "research",
|
|
"AWRS": "research",
|
|
"AZRS": "research",
|
|
"GCRS": "research",
|
|
"DSCT": "research",
|
|
}
|
|
|
|
# Compound types (contain hyphens) -- checked first during extraction
|
|
COMPOUND_TYPES = [k for k in DOC_TYPES if "-" in k]
|
|
|
|
# Regex for ARC filenames: ARC-NNN-TYPE[-SEQ]-vN.N.md
|
|
ARC_PATTERN = re.compile(r"^ARC-\d{3}-.+-v\d+(\.\d+)?\.md$")
|
|
|
|
# Subdirectory name -> manifest array key
|
|
SUBDIR_TO_KEY = {}
|
|
for _dir in set(SUBDIR_MAP.values()):
|
|
# Convert kebab-case to camelCase: "wardley-maps" -> "wardleyMaps"
|
|
_key = re.sub(r"-([a-z])", lambda m: m.group(1).upper(), _dir)
|
|
SUBDIR_TO_KEY[_dir] = _key
|
|
SUBDIR_TO_KEY["reviews"] = "reviews"
|
|
|
|
# Subdirectories to scan for artifacts
|
|
ARTIFACT_SUBDIRS = list(set(SUBDIR_MAP.values())) + ["reviews"]
|
|
|
|
|
|
# ── File System Helpers ──
|
|
|
|
def is_dir(path):
|
|
"""Check if path is a directory."""
|
|
return os.path.isdir(path)
|
|
|
|
|
|
def is_file(path):
|
|
"""Check if path is a file."""
|
|
return os.path.isfile(path)
|
|
|
|
|
|
def read_text(path):
|
|
"""Read file contents as text, return None on failure."""
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
except (OSError, IOError):
|
|
return None
|
|
|
|
|
|
def list_dir(path):
|
|
"""List directory contents sorted, return empty list on failure."""
|
|
try:
|
|
return sorted(os.listdir(path))
|
|
except (OSError, IOError):
|
|
return []
|
|
|
|
|
|
def list_files_recursive(root_dir):
|
|
"""List files below root_dir with stable slash-separated relative paths."""
|
|
files = []
|
|
|
|
def walk(current_dir, parts):
|
|
for entry in list_dir(current_dir):
|
|
full_path = os.path.join(current_dir, entry)
|
|
next_parts = parts + [entry]
|
|
if is_dir(full_path):
|
|
walk(full_path, next_parts)
|
|
elif is_file(full_path):
|
|
files.append({
|
|
"name": entry,
|
|
"path": full_path,
|
|
"relative_path": "/".join(next_parts),
|
|
})
|
|
|
|
if is_dir(root_dir):
|
|
walk(root_dir, [])
|
|
return files
|
|
|
|
|
|
def mtime_ms(path):
|
|
"""Get file modification time in milliseconds, 0 on failure."""
|
|
try:
|
|
return os.stat(path).st_mtime * 1000
|
|
except (OSError, IOError):
|
|
return 0
|
|
|
|
|
|
# ── Repository Discovery ──
|
|
|
|
def find_repo_root(cwd):
|
|
"""Walk up from cwd until a directory containing projects/ is found."""
|
|
current = os.path.abspath(cwd)
|
|
while True:
|
|
if is_dir(os.path.join(current, "projects")):
|
|
return current
|
|
parent = os.path.dirname(current)
|
|
if parent == current:
|
|
break
|
|
current = parent
|
|
return None
|
|
|
|
|
|
# ── Doc Type Extraction ──
|
|
|
|
def extract_doc_type(filename):
|
|
"""Extract the document type code from an 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 (e.g. SECD-MOD, PRIN-COMP)
|
|
for code in COMPOUND_TYPES:
|
|
if rest.startswith(code):
|
|
return code
|
|
|
|
# Strip trailing -NNN for multi-instance types (ADR-001, DIAG-002)
|
|
rest = re.sub(r"-\d{3}$", "", rest)
|
|
return rest
|
|
|
|
|
|
def extract_version(filename):
|
|
"""Extract version string from ARC filename."""
|
|
m = re.search(r"-v(\d+(?:\.\d+)?)\.md$", filename)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def doc_type_name(code):
|
|
"""Get display name for a doc type code."""
|
|
entry = DOC_TYPES.get(code)
|
|
return entry["name"] if entry else code
|
|
|
|
|
|
# ── Hook Input/Output ──
|
|
|
|
def parse_hook_input():
|
|
"""Read JSON from stdin. Return empty dict on failure."""
|
|
try:
|
|
raw = sys.stdin.read()
|
|
except Exception:
|
|
return {}
|
|
if not raw or not raw.strip():
|
|
return {}
|
|
try:
|
|
return json.loads(raw)
|
|
except (json.JSONDecodeError, ValueError):
|
|
return {}
|
|
|
|
|
|
def output_json(data):
|
|
"""Write JSON to stdout."""
|
|
print(json.dumps(data))
|
|
|
|
|
|
def output_context(text):
|
|
"""Output additionalContext via hookSpecificOutput."""
|
|
output_json({
|
|
"hookSpecificOutput": {
|
|
"additionalContext": text,
|
|
}
|
|
})
|
|
|
|
|
|
def output_block(reason):
|
|
"""Write reason to stderr and exit with code 2 (block)."""
|
|
sys.stderr.write(reason + "\n")
|
|
sys.exit(2)
|