diff --git a/graphify/detect.py b/graphify/detect.py index 4cb123104c..48061e02d5 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -10,10 +10,16 @@ import time import unicodedata from concurrent.futures import ThreadPoolExecutor +import contextlib +from dataclasses import dataclass, field +from datetime import datetime, timezone from enum import Enum +import errno from functools import lru_cache from pathlib import Path -from typing import Callable +import sys +from typing import Callable, Any +import uuid from graphify.google_workspace import ( GOOGLE_WORKSPACE_EXTENSIONS, @@ -2324,3 +2330,688 @@ def detect_incremental( full["deleted_files"] = deleted_files full["excluded_files"] = excluded_files return full + + +class GraphState(str, Enum): + ABSENT = "ABSENT" + BUILDING = "BUILDING" + INCOMPLETE = "INCOMPLETE" + UNVERIFIABLE = "UNVERIFIABLE" + STALE = "STALE" + FRESH = "FRESH" + + +class StalenessReason(str, Enum): + HEAD_MISMATCH = "HEAD_MISMATCH" + DIRTY_WORKTREE = "DIRTY_WORKTREE" + + +@dataclass(frozen=True) +class GraphStateResult: + state: GraphState + graph_path: Path | None = None + built_at_commit: str | None = None + current_head: str | None = None + staleness_reasons: tuple[StalenessReason, ...] = () + intermediate_artifacts: tuple[str, ...] = () + active_build: bool = False + unverifiable_reason: str | None = None + dirty_files: tuple[str, ...] = () + active_pid: int | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "state": self.state.value, + "graph_path": str(self.graph_path) if self.graph_path else None, + "built_at_commit": self.built_at_commit, + "current_head": self.current_head, + "staleness_reasons": [r.value for r in self.staleness_reasons], + "intermediate_artifacts": list(self.intermediate_artifacts), + "active_build": self.active_build, + "unverifiable_reason": self.unverifiable_reason, + "dirty_files": list(self.dirty_files), + "active_pid": self.active_pid, + } + + +class LockResult(str, Enum): + ACQUIRED = "ACQUIRED" + ALREADY_ACTIVE = "ALREADY_ACTIVE" + RECLAIMED_STALE = "RECLAIMED_STALE" + + +@dataclass(frozen=True) +class BuildLockStatus: + result: LockResult + active_pid: int | None = None + lock_path: Path | None = None + token: str | None = None + details: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "result": self.result.value, + "active_pid": self.active_pid, + "lock_path": str(self.lock_path) if self.lock_path else None, + "token": self.token, + "details": self.details, + } + + +@dataclass +class _LockFileContent: + pid: int + token: str | None = None + create_time: float | None = None + acquired_at: str | None = None + raw_lines: list[str] = field(default_factory=list) + + +EPHEMERAL_ARTIFACT_PATTERNS = ( + ".graphify_extract.json", + ".graphify_ast.json", + ".graphify_semantic.json", + ".graphify_chunk_*.json", + ".graphify_detect.json", + ".graphify_analysis.json", + ".graphify_cached.json", + ".graphify_uncached.txt", + ".graphify_semantic_new.json", +) + +PERSISTENT_SIDECAR_FILENAMES = frozenset({ + ".graphify_labels.json", + ".graphify_labels.json.sig", + ".graphify_python", + ".graphify_root", + "manifest.json", + "cost.json", + "graph.html", + ".graphify_version", + "graph.json", + "GRAPH_REPORT.md", + ".rebuild.lock", + ".rebuild.lock.mutex", + ".graphify_build_token", +}) + + +def _resolve_out_dir(target_path: Path | str) -> Path: + p = Path(target_path) + if p.name == GRAPHIFY_OUT: + return p + return p / GRAPHIFY_OUT + + +def _get_process_create_time(pid: int) -> float | None: + """Return process creation time as a timestamp, or None if unavailable.""" + if pid <= 0: + return None + if sys.platform == "win32": + try: + import ctypes + from ctypes import wintypes + kernel32 = ctypes.windll.kernel32 + h = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION + if not h: + return None + try: + class FILETIME(ctypes.Structure): + _fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)] + creation, exit_t, kernel_t, user_t = FILETIME(), FILETIME(), FILETIME(), FILETIME() + if kernel32.GetProcessTimes(h, ctypes.byref(creation), ctypes.byref(exit_t), ctypes.byref(kernel_t), ctypes.byref(user_t)): + ft = (creation.dwHighDateTime << 32) + creation.dwLowDateTime + if ft == 0: + return None + return (ft - 116444736000000000) / 10000000.0 + return None + finally: + kernel32.CloseHandle(h) + except Exception: + return None + elif sys.platform.startswith("linux"): + try: + stat_parts = Path(f"/proc/{pid}/stat").read_text().split() + start_ticks = int(stat_parts[21]) + clk_tck = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100 + btime = 0.0 + for line in Path("/proc/stat").read_text().splitlines(): + if line.startswith("btime "): + btime = float(line.split()[1]) + break + return btime + (start_ticks / clk_tck) + except Exception: + return None + else: + # macOS / BSD fallback via ps -p -o lstart= in C locale + try: + env = dict(os.environ, LC_ALL="C") + out = subprocess.check_output( + ["ps", "-p", str(pid), "-o", "lstart="], + text=True, + env=env, + stderr=subprocess.DEVNULL, + ).strip() + if out: + import time as _time + parsed = _time.strptime(out) + return _time.mktime(parsed) + except Exception: + pass + return None + + +_LOCK_CONTENTION_ERRNOS = frozenset({ + errno.EACCES, + errno.EAGAIN, + errno.EWOULDBLOCK, + getattr(errno, "EDEADLK", 36), +}) + + +@contextlib.contextmanager +def _mutex_lock(out_dir: Path, *, timeout_s: float = 2.0): + """Acquire a short-lived OS advisory lock on .rebuild.lock.mutex.""" + out_dir.mkdir(parents=True, exist_ok=True) + mutex_path = out_dir / ".rebuild.lock.mutex" + fh = open(mutex_path, "a+b") + start = time.monotonic() + acquired = False + try: + while True: + try: + if sys.platform == "win32": + import msvcrt + fh.seek(0) + msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + break + except (BlockingIOError, PermissionError): + if time.monotonic() - start > timeout_s: + raise TimeoutError(f"Timed out after {timeout_s}s waiting for {mutex_path}") + time.sleep(0.025) + except OSError as e: + if e.errno in _LOCK_CONTENTION_ERRNOS: + if time.monotonic() - start > timeout_s: + raise TimeoutError(f"Timed out after {timeout_s}s waiting for {mutex_path}") + time.sleep(0.025) + else: + raise + yield + finally: + if acquired: + try: + if sys.platform == "win32": + import msvcrt + fh.seek(0) + msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(fh.fileno(), fcntl.LOCK_UN) + except OSError: + pass + fh.close() + + +def _parse_lock_file(lock_path: Path) -> _LockFileContent | None: + try: + if lock_path.is_symlink(): + return None + text = lock_path.read_text(encoding="utf-8").strip() + if not text: + return None + lines = text.splitlines() + pid = int(lines[0].strip()) + token = None + create_time = None + acquired_at = None + for line in lines[1:]: + line_s = line.strip() + if line_s.startswith("token="): + token = line_s.split("=", 1)[1].strip() or None + elif line_s.startswith("create_time="): + try: + create_time = float(line_s.split("=", 1)[1].strip()) + except ValueError: + pass + elif line_s.startswith("acquired_at="): + acquired_at = line_s.split("=", 1)[1].strip() or None + return _LockFileContent(pid=pid, token=token, create_time=create_time, acquired_at=acquired_at, raw_lines=lines) + except Exception: + return None + + +def _safe_write_file(dest_path: Path, content: str) -> None: + """Atomically write *content* to *dest_path* without following symlinks.""" + parent = dest_path.parent + parent.mkdir(parents=True, exist_ok=True) + tmp_path = parent / f".{dest_path.name}.{uuid.uuid4().hex}.tmp" + try: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(str(tmp_path), flags, 0o600) + with open(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(str(tmp_path), str(dest_path)) + finally: + if tmp_path.exists(): + try: + tmp_path.unlink(missing_ok=True) + except Exception: + pass + + +def _write_lock_file(lock_path: Path, pid: int, token: str, create_time: float | None = None) -> None: + now_iso = datetime.now(timezone.utc).isoformat() + lines = [f"{pid}", f"token={token}"] + if create_time is not None: + lines.append(f"create_time={create_time:.6f}") + lines.append(f"acquired_at={now_iso}") + _safe_write_file(lock_path, "\n".join(lines) + "\n") + + +def acquire_build_lock( + target_path: Path | str = ".", + *, + pid: int | None = None, +) -> BuildLockStatus: + """Atomically acquire or reclaim the build lock for *target_path*.""" + out_dir = _resolve_out_dir(target_path) + lock_path = out_dir / ".rebuild.lock" + token_file = out_dir / ".graphify_build_token" + my_pid = pid if pid is not None else os.getpid() + my_create_time = _get_process_create_time(my_pid) + + with _mutex_lock(out_dir): + if lock_path.exists(): + lock_content = _parse_lock_file(lock_path) + if lock_content is None: + # Malformed, empty, or symlinked lock file + new_token = str(uuid.uuid4()) + _write_lock_file(lock_path, my_pid, new_token, my_create_time) + _safe_write_file(token_file, new_token) + return BuildLockStatus( + result=LockResult.RECLAIMED_STALE, + active_pid=my_pid, + lock_path=lock_path, + token=new_token, + details="Reclaimed corrupt or empty lock file", + ) + + existing_pid = lock_content.pid + if _is_pid_alive(existing_pid): + if lock_content.create_time is not None: + current_create_time = _get_process_create_time(existing_pid) + if current_create_time is not None and abs(current_create_time - lock_content.create_time) > 2.0: + # PID was recycled by the OS + new_token = str(uuid.uuid4()) + _write_lock_file(lock_path, my_pid, new_token, my_create_time) + _safe_write_file(token_file, new_token) + return BuildLockStatus( + result=LockResult.RECLAIMED_STALE, + active_pid=my_pid, + lock_path=lock_path, + token=new_token, + details=f"Reclaimed recycled PID {existing_pid}", + ) + # Active owner is alive and verified + return BuildLockStatus( + result=LockResult.ALREADY_ACTIVE, + active_pid=existing_pid, + lock_path=lock_path, + token=None, + details=f"Active build owned by PID {existing_pid}", + ) + else: + # Dead PID + new_token = str(uuid.uuid4()) + _write_lock_file(lock_path, my_pid, new_token, my_create_time) + _safe_write_file(token_file, new_token) + return BuildLockStatus( + result=LockResult.RECLAIMED_STALE, + active_pid=my_pid, + lock_path=lock_path, + token=new_token, + details=f"Reclaimed dead PID {existing_pid}", + ) + else: + # Fresh acquisition + new_token = str(uuid.uuid4()) + _write_lock_file(lock_path, my_pid, new_token, my_create_time) + _safe_write_file(token_file, new_token) + return BuildLockStatus( + result=LockResult.ACQUIRED, + active_pid=my_pid, + lock_path=lock_path, + token=new_token, + details="Acquired new build lock", + ) + + +def release_build_lock( + target_path: Path | str = ".", + *, + token: str | None = None, +) -> bool: + """Safely release the build lock for *target_path* if token matches.""" + out_dir = _resolve_out_dir(target_path) + lock_path = out_dir / ".rebuild.lock" + token_file = out_dir / ".graphify_build_token" + + expected_token = token + + with _mutex_lock(out_dir): + if not lock_path.exists(): + if expected_token is not None and token_file.exists(): + try: + if token_file.read_text(encoding="utf-8").strip() == expected_token: + token_file.unlink(missing_ok=True) + except Exception: + pass + elif expected_token is None and token_file.exists(): + token_file.unlink(missing_ok=True) + return False + + lock_content = _parse_lock_file(lock_path) + if lock_content is None: + return False + + if expected_token is not None and lock_content.token == expected_token: + lock_path.unlink(missing_ok=True) + if token_file.exists(): + try: + if token_file.read_text(encoding="utf-8").strip() == expected_token: + token_file.unlink(missing_ok=True) + except Exception: + token_file.unlink(missing_ok=True) + return True + elif expected_token is None and lock_content.token is None and lock_content.pid == os.getpid(): + lock_path.unlink(missing_ok=True) + token_file.unlink(missing_ok=True) + return True + else: + return False + + +def _is_pid_alive(pid: int) -> bool: + """Check if a process with *pid* is currently running.""" + if pid <= 0: + return False + try: + if os.name == "nt": + import ctypes + kernel32 = ctypes.windll.kernel32 + # SYNCHRONIZE (0x00100000) | PROCESS_QUERY_LIMITED_INFORMATION (0x1000) + handle = kernel32.OpenProcess(0x00101000, False, pid) + if not handle: + handle = kernel32.OpenProcess(0x1000, False, pid) + if not handle: + handle = kernel32.OpenProcess(0x0400, False, pid) + if not handle: + err = kernel32.GetLastError() + return err == 5 # ERROR_ACCESS_DENIED means process is running + try: + wait_res = kernel32.WaitForSingleObject(handle, 0) + if wait_res == 0: # WAIT_OBJECT_0: process terminated + return False + if wait_res == 258: # WAIT_TIMEOUT (0x102): process still active + return True + exit_code = ctypes.c_ulong() + if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return exit_code.value == 259 + return False + finally: + kernel32.CloseHandle(handle) + else: + os.kill(pid, 0) + return True + except PermissionError: + return True + except (ProcessLookupError, OSError): + return False + except Exception: + return False + + +def _find_intermediate_artifacts(out_dir: Path) -> list[str]: + """Return sorted list of ephemeral artifact filenames present in *out_dir*.""" + if not out_dir.is_dir(): + return [] + found = set() + for item in out_dir.iterdir(): + name = item.name + if name in PERSISTENT_SIDECAR_FILENAMES: + continue + for pattern in EPHEMERAL_ARTIFACT_PATTERNS: + if fnmatch.fnmatch(name, pattern): + found.add(name) + break + return sorted(found) + + +def _parse_porcelain_z(raw_bytes: bytes) -> list[tuple[str, str, str | None]]: + """Parse git status --porcelain -z byte stream.""" + entries = [] + i = 0 + n = len(raw_bytes) + while i < n: + if i + 3 > n: + break + status = raw_bytes[i:i+2].decode("latin1", errors="replace") + if raw_bytes[i+2:i+3] != b" ": + break + path_start = i + 3 + nul_idx = raw_bytes.find(b"\x00", path_start) + if nul_idx == -1: + break + path_str = raw_bytes[path_start:nul_idx].decode("utf-8", errors="replace") + i = nul_idx + 1 + orig_path = None + if status[0] in ("R", "C") or status[1] in ("R", "C"): + nul_idx2 = raw_bytes.find(b"\x00", i) + if nul_idx2 != -1: + orig_path = raw_bytes[i:nul_idx2].decode("utf-8", errors="replace") + i = nul_idx2 + 1 + entries.append((status, path_str, orig_path)) + return entries + + +def _inspect_git_dirty(target_path: Path, repo_root: Path) -> list[str]: + """Return list of relative file paths within *target_path* with uncommitted changes.""" + try: + r = subprocess.run( + ["git", "-C", str(repo_root), "status", "--porcelain", "-z", "-uall"], + capture_output=True, + timeout=10, + ) + if r.returncode != 0: + return [] + except Exception: + return [] + + entries = _parse_porcelain_z(r.stdout) + if not entries: + return [] + + target_resolved = target_path.resolve() + out_dir_resolved = (target_resolved / GRAPHIFY_OUT).resolve() + dirty_files = [] + ignore_patterns = _load_graphifyignore(target_resolved, gitignore=True) + supported_exts = ( + CODE_EXTENSIONS + | DOC_EXTENSIONS + | PAPER_EXTENSIONS + | IMAGE_EXTENSIONS + | VIDEO_EXTENSIONS + | OFFICE_EXTENSIONS + ) + + for status, path_str, orig_path in entries: + candidate_paths = [repo_root / path_str] + if orig_path: + candidate_paths.append(repo_root / orig_path) + + for p in candidate_paths: + try: + p_resolved = p.resolve() + rel_target = p_resolved.relative_to(target_resolved) + except ValueError: + continue + + # Exclude graphify-out output directory + try: + p_resolved.relative_to(out_dir_resolved) + continue + except ValueError: + pass + + rel_str = str(rel_target).replace(os.sep, "/") + + suffix = p_resolved.suffix.lower() or Path(path_str).suffix.lower() + if suffix not in supported_exts: + if not (p_resolved.exists() and classify_file(p_resolved) is not None): + continue + if _is_ignored(p_resolved, target_resolved, ignore_patterns): + continue + if rel_str not in dirty_files: + dirty_files.append(rel_str) + + return sorted(dirty_files) + + +def inspect_graph_state( + target_path: Path | str = ".", + *, + current_token: str | None = None, +) -> GraphStateResult: + """Inspect the state of graphify-out/ for *target_path*. + + Precedence: + 1. graph.json missing -> ABSENT + 2. active .rebuild.lock PID is alive (and not matching *current_token*) -> BUILDING + 3. ephemeral intermediate artifacts exist -> INCOMPLETE + 4. freshness cannot be established -> UNVERIFIABLE + 5. HEAD mismatch and/or relevant dirty working tree -> STALE + 6. otherwise -> FRESH + """ + target = Path(target_path) + out_dir = _resolve_out_dir(target) + graph_path = out_dir / "graph.json" + + if not graph_path.exists() or not graph_path.is_file(): + return GraphStateResult(state=GraphState.ABSENT, graph_path=graph_path) + + # 1. Check for active build via .rebuild.lock + lock_file = out_dir / ".rebuild.lock" + active_build = False + active_pid = None + if lock_file.exists(): + lock_content = _parse_lock_file(lock_file) + if lock_content is not None: + # If current_token is provided and matches, the caller owns this lock + if current_token is not None and lock_content.token == current_token: + active_build = False + else: + active_pid = lock_content.pid + if _is_pid_alive(lock_content.pid): + if lock_content.create_time is not None: + current_create_time = _get_process_create_time(lock_content.pid) + if current_create_time is None or abs(current_create_time - lock_content.create_time) <= 2.0: + active_build = True + else: + active_build = True + + if active_build: + return GraphStateResult( + state=GraphState.BUILDING, + graph_path=graph_path, + active_build=True, + active_pid=active_pid, + ) + + # 2. Check for intermediate artifacts (abandoned/interrupted build) + intermediates = _find_intermediate_artifacts(out_dir) + if intermediates: + return GraphStateResult( + state=GraphState.INCOMPLETE, + graph_path=graph_path, + intermediate_artifacts=tuple(intermediates), + active_build=False, + active_pid=active_pid, + ) + + # 3. Read graph.json metadata for built_at_commit + built_at_commit = None + try: + graph_data = json.loads(graph_path.read_text(encoding="utf-8")) + if isinstance(graph_data, dict): + built_at_commit = graph_data.get("built_at_commit") + except Exception: + pass + + # 4. Resolve git repository and HEAD + from graphify.export import _git_head + current_head = _git_head(cwd=target) + + if not built_at_commit: + return GraphStateResult( + state=GraphState.UNVERIFIABLE, + graph_path=graph_path, + built_at_commit=built_at_commit, + current_head=current_head, + unverifiable_reason="missing_built_at_commit" if current_head else "not_a_git_repo", + ) + + if not current_head: + return GraphStateResult( + state=GraphState.UNVERIFIABLE, + graph_path=graph_path, + built_at_commit=built_at_commit, + current_head=None, + unverifiable_reason="not_a_git_repo", + ) + + # Find repo root for status inspection + repo_root = None + try: + r = subprocess.run( + ["git", "-C", str(target), "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=3, + ) + if r.returncode == 0 and r.stdout.strip(): + repo_root = Path(r.stdout.strip()) + except Exception: + repo_root = None + + if not repo_root: + repo_root = target.resolve() + + # 5. Check staleness (HEAD mismatch and/or dirty working tree) + staleness_reasons: list[StalenessReason] = [] + if built_at_commit != current_head: + staleness_reasons.append(StalenessReason.HEAD_MISMATCH) + + dirty_files = _inspect_git_dirty(target, repo_root) + if dirty_files: + staleness_reasons.append(StalenessReason.DIRTY_WORKTREE) + + if staleness_reasons: + return GraphStateResult( + state=GraphState.STALE, + graph_path=graph_path, + built_at_commit=built_at_commit, + current_head=current_head, + staleness_reasons=tuple(staleness_reasons), + dirty_files=tuple(dirty_files), + ) + + return GraphStateResult( + state=GraphState.FRESH, + graph_path=graph_path, + built_at_commit=built_at_commit, + current_head=current_head, + ) diff --git a/graphify/skill-agents.md b/graphify/skill-agents.md index 190827d9ac..bfe35e5c5c 100644 --- a/graphify/skill-agents.md +++ b/graphify/skill-agents.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -612,6 +674,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -620,7 +685,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-amp.md b/graphify/skill-amp.md index 190827d9ac..bfe35e5c5c 100644 --- a/graphify/skill-amp.md +++ b/graphify/skill-amp.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -612,6 +674,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -620,7 +685,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md index abd2811d23..4561604060 100644 --- a/graphify/skill-claw.md +++ b/graphify/skill-claw.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md index af3f723c78..0c87322775 100644 --- a/graphify/skill-codex.md +++ b/graphify/skill-codex.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -612,6 +674,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -620,7 +685,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-copilot.md b/graphify/skill-copilot.md index abd2811d23..4561604060 100644 --- a/graphify/skill-copilot.md +++ b/graphify/skill-copilot.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md index fd148d485d..55cf87c656 100644 --- a/graphify/skill-droid.md +++ b/graphify/skill-droid.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -612,6 +674,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -620,7 +685,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-kilo.md b/graphify/skill-kilo.md index 3e70b050a4..a862bc7211 100644 --- a/graphify/skill-kilo.md +++ b/graphify/skill-kilo.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-kiro.md b/graphify/skill-kiro.md index abd2811d23..4561604060 100644 --- a/graphify/skill-kiro.md +++ b/graphify/skill-kiro.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md index 91ced60675..94a32df4cb 100644 --- a/graphify/skill-opencode.md +++ b/graphify/skill-opencode.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -607,6 +669,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -615,7 +680,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-pi.md b/graphify/skill-pi.md index abd2811d23..4561604060 100644 --- a/graphify/skill-pi.md +++ b/graphify/skill-pi.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md index 050667bc20..d0a08e91a9 100644 --- a/graphify/skill-trae.md +++ b/graphify/skill-trae.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -613,6 +675,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -621,7 +686,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md index 20c7c0835c..38a5161d97 100644 --- a/graphify/skill-vscode.md +++ b/graphify/skill-vscode.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -611,6 +673,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -619,7 +684,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index d631821ec3..0ac4ff96bd 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -50,7 +50,41 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```powershell +if (-not (Test-Path graphify-out\.graphify_python)) { + $GRAPHIFY_PYTHON = $null + $graphifyCmd = Get-Command graphify -ErrorAction SilentlyContinue + if ($graphifyCmd) { + # The interpreter that owns the graphify entry point sits next to it + # (\Scripts\python.exe for uv tool, pipx, and venv installs). + $py = Join-Path (Split-Path $graphifyCmd.Source) "python.exe" + if (Test-Path $py) { $GRAPHIFY_PYTHON = $py } + } + if (-not $GRAPHIFY_PYTHON) { $GRAPHIFY_PYTHON = "python" } + New-Item -ItemType Directory -Force -Path graphify-out | Out-Null + & $GRAPHIFY_PYTHON -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +} +``` + +```powershell +@' +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +'@ | & (Get-Content graphify-out\.graphify_python) - +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -128,19 +162,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```powershell @' -import json -from graphify.detect import detect +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") -print(f'Detected {result["total_files"]} files') +print(f'Detected {result["total_files"]} files (build token: {status.token})') '@ | & (Get-Content graphify-out\.graphify_python) - ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -166,6 +219,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```powershell +@' +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +'@ | & (Get-Content graphify-out\.graphify_python) - +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -637,6 +701,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding="utf-8") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost["total_input_tokens"]:,} input, {cost["total_output_tokens"]:,} output ({len(cost["runs"])} runs)') '@ | & (Get-Content graphify-out\.graphify_python) - @@ -645,7 +712,7 @@ Get-ChildItem graphify-out -Filter '.graphify_chunk_*.json' -File -ErrorAction S Remove-Item -Force -ErrorAction SilentlyContinue graphify-out\.needs_update ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/skill.md b/graphify/skill.md index abd2811d23..4561604060 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/graphify/watch.py b/graphify/watch.py index 69986ea86a..7c15ca86f3 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -160,59 +160,27 @@ def _rebuild_lock(out_dir: Path, *, blocking: bool = False): """Per-repo advisory lock around a rebuild. Yields True if acquired, False if another rebuild is already running and - ``blocking`` is False. Uses fcntl.flock so the lock is released - automatically if the process is killed (no stale-lock cleanup needed). + ``blocking`` is False. Uses the unified Graphify build-lock protocol. - While the lock is held, ``.rebuild.lock`` contains the owning PID followed - by a newline so external pollers (publish scripts, etc.) can read it. - On successful release the file is unlinked so downstream tooling that - waits for the lock to clear by polling for its absence unblocks promptly. - - Falls back to a no-op yield(True) on platforms without fcntl (Windows). + While the lock is held, ``.rebuild.lock`` contains the owning PID on line 1 + so external pollers and inspection can read it. On release the file is + unlinked so downstream tooling unblocks promptly. """ + from graphify.detect import acquire_build_lock, release_build_lock, LockResult + target_path = out_dir.parent if out_dir.name == "graphify-out" else out_dir + status = acquire_build_lock(target_path) + acquired = status.result in (LockResult.ACQUIRED, LockResult.RECLAIMED_STALE) + if not acquired and blocking: + start = time.monotonic() + while not acquired and (time.monotonic() - start < 30.0): + time.sleep(0.05) + status = acquire_build_lock(target_path) + acquired = status.result in (LockResult.ACQUIRED, LockResult.RECLAIMED_STALE) try: - import fcntl - except ImportError: - yield True - return - - out_dir.mkdir(parents=True, exist_ok=True) - lock_path = out_dir / ".rebuild.lock" - # "a+" creates the file if missing without truncating an existing holder's - # PID payload — important because another process may have already written - # its PID before we attempt the flock. - fh = open(lock_path, "a+", encoding="utf-8") - acquired = False - try: - flags = fcntl.LOCK_EX if blocking else (fcntl.LOCK_EX | fcntl.LOCK_NB) - try: - fcntl.flock(fh.fileno(), flags) - except BlockingIOError: - yield False - return - acquired = True - # Replace any prior owner's PID with ours so external readers see a - # single parseable line, not a digit-concatenation across rebuilds. - try: - fh.seek(0) - fh.truncate() - fh.write(f"{os.getpid()}\n") - fh.flush() - except OSError: - pass - yield True + yield acquired finally: - if acquired: - try: - fcntl.flock(fh.fileno(), fcntl.LOCK_UN) - except OSError: - pass - fh.close() - # Signal "rebuild done" by removing the lock file. Only the holder - # unlinks; a non-acquiring caller leaves the existing lock in place. - if acquired: - with contextlib.suppress(OSError): - lock_path.unlink() + if acquired and status.token: + release_build_lock(target_path, token=status.token) def _apply_resource_limits() -> None: diff --git a/tests/test_graph_state.py b/tests/test_graph_state.py new file mode 100644 index 0000000000..9bd73717b8 --- /dev/null +++ b/tests/test_graph_state.py @@ -0,0 +1,1145 @@ +"""Tests for Graph State Inspection Layer (Issue #2841 - Phase 3).""" +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +import pytest + +from graphify.detect import ( + GraphState, + StalenessReason, + GraphStateResult, + LockResult, + BuildLockStatus, + acquire_build_lock, + release_build_lock, + inspect_graph_state, + _mutex_lock, +) + + +def _init_git_repo(path: Path) -> str: + """Initialize a git repo at path, commit an initial file, and return HEAD SHA.""" + subprocess.run(["git", "init"], cwd=str(path), check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=str(path), check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=str(path), check=True, capture_output=True) + subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=str(path), check=True, capture_output=True) + + (path / "main.py").write_text("print('hello')\n", encoding="utf-8") + subprocess.run(["git", "add", "main.py"], cwd=str(path), check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "initial commit"], cwd=str(path), check=True, capture_output=True) + + r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(path), check=True, capture_output=True, text=True) + return r.stdout.strip() + + +def _write_graph(path: Path, built_at_commit: str | None = None) -> Path: + out = path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + graph_file = out / "graph.json" + data = {"nodes": [], "links": []} + if built_at_commit is not None: + data["built_at_commit"] = built_at_commit + graph_file.write_text(json.dumps(data), encoding="utf-8") + return graph_file + + +# ── 1. Basic State Tests ────────────────────────────────────────────────── + +def test_state_absent_when_no_graph_json(tmp_path): + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.ABSENT + assert res.graph_path == tmp_path / "graphify-out" / "graph.json" + assert res.to_dict()["state"] == "ABSENT" + + +def test_state_fresh_when_clean_and_head_matches(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.FRESH + assert res.built_at_commit == head + assert res.current_head == head + assert res.staleness_reasons == () + assert res.dirty_files == () + + +def test_state_stale_on_head_mismatch(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit="0" * 40) + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.STALE + assert res.staleness_reasons == (StalenessReason.HEAD_MISMATCH,) + assert res.built_at_commit == "0" * 40 + assert res.current_head == head + + +def test_state_unverifiable_on_missing_built_at_commit(tmp_path): + _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=None) + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.UNVERIFIABLE + assert res.unverifiable_reason == "missing_built_at_commit" + assert res.built_at_commit is None + + +def test_state_unverifiable_on_non_git_corpus(tmp_path): + # Directory without git repo + _write_graph(tmp_path, built_at_commit="a" * 40) + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.UNVERIFIABLE + assert res.unverifiable_reason == "not_a_git_repo" + assert res.current_head is None + + +# ── 2. Dirty Working Tree Tests ────────────────────────────────────────── + +def test_state_stale_on_staged_modification(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + (tmp_path / "main.py").write_text("print('staged edit')\n", encoding="utf-8") + subprocess.run(["git", "add", "main.py"], cwd=str(tmp_path), check=True, capture_output=True) + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.STALE + assert res.staleness_reasons == (StalenessReason.DIRTY_WORKTREE,) + assert "main.py" in res.dirty_files + + +def test_state_stale_on_unstaged_modification(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + (tmp_path / "main.py").write_text("print('unstaged edit')\n", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.STALE + assert res.staleness_reasons == (StalenessReason.DIRTY_WORKTREE,) + assert "main.py" in res.dirty_files + + +def test_state_stale_on_tracked_deletion(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + (tmp_path / "main.py").unlink() + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.STALE + assert res.staleness_reasons == (StalenessReason.DIRTY_WORKTREE,) + assert "main.py" in res.dirty_files + + +def test_state_stale_on_tracked_rename(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + subprocess.run(["git", "mv", "main.py", "renamed.py"], cwd=str(tmp_path), check=True, capture_output=True) + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.STALE + assert res.staleness_reasons == (StalenessReason.DIRTY_WORKTREE,) + assert any("renamed.py" in f or "main.py" in f for f in res.dirty_files) + + +def test_state_stale_on_supported_untracked_file(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + (tmp_path / "new_module.py").write_text("def foo(): pass\n", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.STALE + assert res.staleness_reasons == (StalenessReason.DIRTY_WORKTREE,) + assert "new_module.py" in res.dirty_files + + +def test_state_fresh_ignores_unsupported_untracked_file(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + (tmp_path / "scratch.log").write_text("log data\n", encoding="utf-8") + (tmp_path / "temp.tmp").write_text("temp data\n", encoding="utf-8") + (tmp_path / ".DS_Store").write_text("junk\n", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.FRESH + assert res.staleness_reasons == () + assert res.dirty_files == () + + +def test_state_fresh_ignores_graphifyignored_untracked_file(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + (tmp_path / ".graphifyignore").write_text("vendor/\n", encoding="utf-8") + vendor_dir = tmp_path / "vendor" + vendor_dir.mkdir() + (vendor_dir / "lib.py").write_text("code\n", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.FRESH + assert res.staleness_reasons == () + + +def test_state_stale_on_head_mismatch_and_dirty_tree(tmp_path): + _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit="0" * 40) + + (tmp_path / "main.py").write_text("edit\n", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.STALE + assert StalenessReason.HEAD_MISMATCH in res.staleness_reasons + assert StalenessReason.DIRTY_WORKTREE in res.staleness_reasons + assert "main.py" in res.dirty_files + + +# ── 3. Path Boundaries Tests ───────────────────────────────────────────── + +def test_path_boundary_filtering_subdirectory_target(tmp_path): + head = _init_git_repo(tmp_path) + + # Structure: + # services/auth/ + # services/auth2/ + # services/billing/ + auth_dir = tmp_path / "services" / "auth" + auth2_dir = tmp_path / "services" / "auth2" + billing_dir = tmp_path / "services" / "billing" + + auth_dir.mkdir(parents=True) + auth2_dir.mkdir(parents=True) + billing_dir.mkdir(parents=True) + + (auth_dir / "auth.py").write_text("# auth\n", encoding="utf-8") + (auth2_dir / "auth2.py").write_text("# auth2\n", encoding="utf-8") + (billing_dir / "billing.py").write_text("# billing\n", encoding="utf-8") + + subprocess.run(["git", "add", "."], cwd=str(tmp_path), check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "add services"], cwd=str(tmp_path), check=True, capture_output=True) + r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(tmp_path), check=True, capture_output=True, text=True) + new_head = r.stdout.strip() + + _write_graph(auth_dir, built_at_commit=new_head) + + # Modify auth2 and billing (outside auth_dir) + (auth2_dir / "auth2.py").write_text("# auth2 modified\n", encoding="utf-8") + (billing_dir / "billing.py").write_text("# billing modified\n", encoding="utf-8") + + res = inspect_graph_state(auth_dir) + assert res.state == GraphState.FRESH + assert res.staleness_reasons == () + assert res.dirty_files == () + + +def test_path_boundary_filtering_nested_target(tmp_path): + head = _init_git_repo(tmp_path) + + auth_dir = tmp_path / "services" / "auth" + sub_dir = auth_dir / "sub" + sub_dir.mkdir(parents=True) + + (sub_dir / "bar.ts").write_text("export const x = 1;\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=str(tmp_path), check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "add sub"], cwd=str(tmp_path), check=True, capture_output=True) + r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(tmp_path), check=True, capture_output=True, text=True) + new_head = r.stdout.strip() + + _write_graph(auth_dir, built_at_commit=new_head) + + # Modify nested file + (sub_dir / "bar.ts").write_text("export const x = 2;\n", encoding="utf-8") + + res = inspect_graph_state(auth_dir) + assert res.state == GraphState.STALE + assert res.staleness_reasons == (StalenessReason.DIRTY_WORKTREE,) + assert any("sub/bar.ts" in f or "bar.ts" in f for f in res.dirty_files) + + +# ── 4. Intermediate State Tests ────────────────────────────────────────── + +def test_state_incomplete_on_intermediate_artifact(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + # Add leftover .graphify_extract.json + out = tmp_path / "graphify-out" + (out / ".graphify_extract.json").write_text("{}", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.INCOMPLETE + assert ".graphify_extract.json" in res.intermediate_artifacts + assert res.active_build is False + + +def test_state_incomplete_multiple_artifacts_reported(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + out = tmp_path / "graphify-out" + (out / ".graphify_ast.json").write_text("{}", encoding="utf-8") + (out / ".graphify_chunk_00.json").write_text("{}", encoding="utf-8") + (out / ".graphify_detect.json").write_text("{}", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.INCOMPLETE + assert ".graphify_ast.json" in res.intermediate_artifacts + assert ".graphify_chunk_00.json" in res.intermediate_artifacts + assert ".graphify_detect.json" in res.intermediate_artifacts + + +def test_state_persistent_sidecars_do_not_trigger_incomplete(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + out = tmp_path / "graphify-out" + (out / ".graphify_labels.json").write_text("{}", encoding="utf-8") + (out / ".graphify_labels.json.sig").write_text("{}", encoding="utf-8") + (out / ".graphify_python").write_text("/usr/bin/python\n", encoding="utf-8") + (out / ".graphify_root").write_text(".\n", encoding="utf-8") + (out / "manifest.json").write_text("{}", encoding="utf-8") + (out / "cost.json").write_text("{}", encoding="utf-8") + (out / "graph.html").write_text("", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.FRESH + assert res.intermediate_artifacts == () + + +def test_state_building_when_active_lock_pid_alive(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + out = tmp_path / "graphify-out" + # Write current process PID (definitely alive) + (out / ".rebuild.lock").write_text(f"{os.getpid()}\n", encoding="utf-8") + (out / ".graphify_extract.json").write_text("{}", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.BUILDING + assert res.active_build is True + assert res.active_pid == os.getpid() + + +def test_state_incomplete_when_lock_pid_dead(tmp_path, monkeypatch): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + out = tmp_path / "graphify-out" + # Write arbitrary PID and mock _is_pid_alive to False + (out / ".rebuild.lock").write_text("99999999\n", encoding="utf-8") + (out / ".graphify_ast.json").write_text("{}", encoding="utf-8") + + from graphify import detect + monkeypatch.setattr(detect, "_is_pid_alive", lambda pid: False) + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.INCOMPLETE + assert res.active_build is False + assert ".graphify_ast.json" in res.intermediate_artifacts + + +def test_precedence_incomplete_over_stale_commit(tmp_path): + _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit="0" * 40) + + out = tmp_path / "graphify-out" + (out / ".graphify_extract.json").write_text("{}", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + # Must be INCOMPLETE, not STALE + assert res.state == GraphState.INCOMPLETE + assert ".graphify_extract.json" in res.intermediate_artifacts + + +# ── 5. Serialization & Dict Details ────────────────────────────────────── + +def test_result_to_dict_structure(tmp_path): + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + (tmp_path / "main.py").write_text("edit\n", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + d = res.to_dict() + assert d["state"] == "STALE" + assert d["built_at_commit"] == head + assert d["current_head"] == head + assert "DIRTY_WORKTREE" in d["staleness_reasons"] + assert "main.py" in d["dirty_files"] + assert d["intermediate_artifacts"] == [] + assert d["active_build"] is False + assert d["graph_path"] == str(tmp_path / "graphify-out" / "graph.json") + + +# ── 6. Legacy / Missing .graphify_python Tests ──────────────────────────── + +def test_legacy_graph_missing_graphify_python_returns_fresh(tmp_path): + """A legacy graph with clean tree and matching HEAD works without .graphify_python.""" + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + # Ensure .graphify_python does NOT exist + assert not (tmp_path / "graphify-out" / ".graphify_python").exists() + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.FRESH + assert res.built_at_commit == head + assert res.current_head == head + + +def test_legacy_graph_missing_graphify_python_returns_stale_on_diverged_head(tmp_path): + """A legacy graph with diverged HEAD detects STALE without .graphify_python.""" + _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit="old" + "0" * 37) + assert not (tmp_path / "graphify-out" / ".graphify_python").exists() + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.STALE + assert res.staleness_reasons == (StalenessReason.HEAD_MISMATCH,) + + +def test_legacy_graph_missing_graphify_python_returns_incomplete_on_artifacts(tmp_path): + """A legacy graph with leftover artifacts detects INCOMPLETE without .graphify_python.""" + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + (tmp_path / "graphify-out" / ".graphify_ast.json").write_text("{}", encoding="utf-8") + assert not (tmp_path / "graphify-out" / ".graphify_python").exists() + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.INCOMPLETE + assert ".graphify_ast.json" in res.intermediate_artifacts + + +def test_legacy_graph_missing_graphify_python_returns_unverifiable_on_non_git(tmp_path): + """A legacy graph in non-git repo detects UNVERIFIABLE without .graphify_python.""" + _write_graph(tmp_path, built_at_commit="abc" * 13 + "a") + assert not (tmp_path / "graphify-out" / ".graphify_python").exists() + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.UNVERIFIABLE + assert res.unverifiable_reason == "not_a_git_repo" + + +# ── 7. Phase 4B: Build Lock Lifecycle Tests ────────────────────────────── + +def test_acquire_lock_fresh(tmp_path): + """Acquiring lock on fresh directory returns ACQUIRED and creates .rebuild.lock.""" + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.ACQUIRED + assert res.token is not None + assert res.active_pid == os.getpid() + + lock_file = tmp_path / "graphify-out" / ".rebuild.lock" + assert lock_file.exists() + lines = lock_file.read_text(encoding="utf-8").splitlines() + assert lines[0] == str(os.getpid()) + assert f"token={res.token}" in lines + assert (tmp_path / "graphify-out" / ".graphify_build_token").read_text(encoding="utf-8") == res.token + + +def test_acquire_lock_active_contender(tmp_path): + """Acquiring lock when another live process owns it returns ALREADY_ACTIVE.""" + first = acquire_build_lock(tmp_path) + assert first.result == LockResult.ACQUIRED + + # Second contender attempts to acquire + second = acquire_build_lock(tmp_path, pid=999999) + assert second.result == LockResult.ALREADY_ACTIVE + assert second.active_pid == os.getpid() + assert second.token is None + + +def test_acquire_lock_dead_pid_reclaimed(tmp_path, monkeypatch): + """Acquiring lock when owner PID is dead returns RECLAIMED_STALE.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + # Write dead PID lock + (out / ".rebuild.lock").write_text("99999999\ntoken=dead-token\n", encoding="utf-8") + + from graphify import detect + monkeypatch.setattr(detect, "_is_pid_alive", lambda pid: False) + + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.RECLAIMED_STALE + assert res.token != "dead-token" + assert res.active_pid == os.getpid() + + +def test_acquire_lock_pid_reuse_detected(tmp_path, monkeypatch): + """Acquiring lock when PID is alive but create_time does not match returns RECLAIMED_STALE.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + (out / ".rebuild.lock").write_text(f"{os.getpid()}\ntoken=old-token\ncreate_time=1000.0\n", encoding="utf-8") + + from graphify import detect + monkeypatch.setattr(detect, "_is_pid_alive", lambda pid: True) + monkeypatch.setattr(detect, "_get_process_create_time", lambda pid: 2000.0) + + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.RECLAIMED_STALE + assert res.token != "old-token" + + +def test_concurrent_lock_contention(tmp_path, monkeypatch): + """Concurrent threads contending for a fresh lock result in exactly 1 ACQUIRED.""" + from graphify import detect + monkeypatch.setattr(detect, "_is_pid_alive", lambda pid: True) + monkeypatch.setattr(detect, "_get_process_create_time", lambda pid: 1000.0) + + from concurrent.futures import ThreadPoolExecutor + outcomes = [] + + def try_acquire(pid): + return acquire_build_lock(tmp_path, pid=pid) + + with ThreadPoolExecutor(max_workers=8) as ex: + futures = [ex.submit(try_acquire, 10000 + i) for i in range(8)] + for f in futures: + outcomes.append(f.result().result) + + assert outcomes.count(LockResult.ACQUIRED) == 1 + assert outcomes.count(LockResult.ALREADY_ACTIVE) == 7 + + +def test_concurrent_stale_takeover_race(tmp_path, monkeypatch): + """Concurrent threads contending for a dead lock result in exactly 1 RECLAIMED_STALE.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + (out / ".rebuild.lock").write_text("999999\ntoken=stale-1\n", encoding="utf-8") + + from graphify import detect + # First time checking 999999 -> dead; once new PID is written -> alive + def mock_alive(pid): + return pid != 999999 + + monkeypatch.setattr(detect, "_is_pid_alive", mock_alive) + monkeypatch.setattr(detect, "_get_process_create_time", lambda pid: 1000.0) + + from concurrent.futures import ThreadPoolExecutor + outcomes = [] + + def try_acquire(pid): + return acquire_build_lock(tmp_path, pid=pid) + + with ThreadPoolExecutor(max_workers=8) as ex: + futures = [ex.submit(try_acquire, 20000 + i) for i in range(8)] + for f in futures: + outcomes.append(f.result().result) + + assert outcomes.count(LockResult.RECLAIMED_STALE) == 1 + assert outcomes.count(LockResult.ALREADY_ACTIVE) == 7 + + +def test_release_matching_token(tmp_path): + """Release with matching token unlinks .rebuild.lock and removes build token.""" + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.ACQUIRED + lock_file = tmp_path / "graphify-out" / ".rebuild.lock" + token_file = tmp_path / "graphify-out" / ".graphify_build_token" + assert lock_file.exists() + assert token_file.exists() + + released = release_build_lock(tmp_path, token=res.token) + assert released is True + assert not lock_file.exists() + assert not token_file.exists() + + +def test_release_wrong_token_rejected(tmp_path): + """Release with wrong token does not unlink .rebuild.lock.""" + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.ACQUIRED + lock_file = tmp_path / "graphify-out" / ".rebuild.lock" + + released = release_build_lock(tmp_path, token="wrong-token") + assert released is False + assert lock_file.exists() + + +def test_release_superseded_token_does_not_delete_new_owner(tmp_path): + """A stale owner releasing an old token does not delete a newer owner's lock.""" + first = acquire_build_lock(tmp_path) + old_token = first.token + + # Simulate takeover by writing new owner lock + out = tmp_path / "graphify-out" + (out / ".rebuild.lock").write_text(f"{os.getpid()}\ntoken=new-token-123\n", encoding="utf-8") + + # Old owner attempts to release with old token + released = release_build_lock(tmp_path, token=old_token) + assert released is False + # Newer owner's lock must remain untouched + assert (out / ".rebuild.lock").exists() + assert "token=new-token-123" in (out / ".rebuild.lock").read_text(encoding="utf-8") + + +def test_legacy_pid_only_live_lock(tmp_path): + """Legacy lock with single PID line of current process reports ALREADY_ACTIVE.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + (out / ".rebuild.lock").write_text(f"{os.getpid()}\n", encoding="utf-8") + + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.ALREADY_ACTIVE + assert res.active_pid == os.getpid() + + +def test_legacy_pid_only_dead_lock_reclaimed(tmp_path, monkeypatch): + """Legacy lock with single dead PID line is safely reclaimed.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + (out / ".rebuild.lock").write_text("99999999\n", encoding="utf-8") + + from graphify import detect + monkeypatch.setattr(detect, "_is_pid_alive", lambda pid: False) + + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.RECLAIMED_STALE + + +def test_acquire_lock_reclaims_empty_file(tmp_path): + """An empty lock file is safely reclaimed.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + (out / ".rebuild.lock").write_text("", encoding="utf-8") + + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.RECLAIMED_STALE + + +def test_acquire_lock_reclaims_invalid_pid(tmp_path): + """A lock file with invalid non-numeric PID is safely reclaimed.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + (out / ".rebuild.lock").write_text("not-a-number\n", encoding="utf-8") + + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.RECLAIMED_STALE + + +def test_acquire_lock_reclaims_truncated_metadata(tmp_path, monkeypatch): + """A lock file with invalid create_time or truncated lines is safely handled.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + (out / ".rebuild.lock").write_text("99999999\ntoken=\ncreate_time=bad\n", encoding="utf-8") + + from graphify import detect + monkeypatch.setattr(detect, "_is_pid_alive", lambda pid: False) + + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.RECLAIMED_STALE + + +def test_mutex_unwedges_after_exception(tmp_path): + """An exception inside _mutex_lock releases the lock immediately.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + + with pytest.raises(RuntimeError): + with _mutex_lock(out): + raise RuntimeError("crash inside critical section") + + # Next attempt must succeed immediately + res = acquire_build_lock(tmp_path) + assert res.result == LockResult.ACQUIRED + + +def test_reinspect_protocol_aborts_if_fresh(tmp_path): + """Acquiring lock followed by re-inspection seeing FRESH avoids rebuilding.""" + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + # Initial state is FRESH + initial_res = inspect_graph_state(tmp_path) + assert initial_res.state == GraphState.FRESH + + # Acquire lock + status = acquire_build_lock(tmp_path) + assert status.result == LockResult.ACQUIRED + + # External inspector (no token) sees BUILDING + external_res = inspect_graph_state(tmp_path) + assert external_res.state == GraphState.BUILDING + + # Re-inspecting with caller's token sees underlying FRESH state + re_res = inspect_graph_state(tmp_path, current_token=status.token) + assert re_res.state == GraphState.FRESH + + # Release lock + release_build_lock(tmp_path, token=status.token) + + +# ── 8. Phase 4B Verification Gate Tests ────────────────────────────────── + +def test_current_token_owner_inspecting_itself_is_not_building(tmp_path): + """Case A: Owner inspecting with current_token evaluates underlying graph state.""" + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + status = acquire_build_lock(tmp_path) + assert status.result == LockResult.ACQUIRED + + res = inspect_graph_state(tmp_path, current_token=status.token) + assert res.state == GraphState.FRESH + assert res.active_build is False + + +def test_current_token_other_process_sees_building(tmp_path): + """Case B: External process without token sees BUILDING.""" + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + status = acquire_build_lock(tmp_path) + assert status.result == LockResult.ACQUIRED + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.BUILDING + assert res.active_build is True + assert res.active_pid == os.getpid() + + +def test_current_token_superseded_owner_sees_building(tmp_path): + """Case C: Superseded owner with old token sees BUILDING owned by new owner.""" + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + (out / ".rebuild.lock").write_text(f"{os.getpid()}\ntoken=token-B\n", encoding="utf-8") + + # Caller passes old token-A + res = inspect_graph_state(tmp_path, current_token="token-A") + assert res.state == GraphState.BUILDING + assert res.active_build is True + assert res.active_pid == os.getpid() + + +def test_persistent_sidecars_lock_and_mutex_do_not_trigger_incomplete(tmp_path): + """The presence of .rebuild.lock.mutex and .graphify_build_token does not trigger INCOMPLETE.""" + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + out = tmp_path / "graphify-out" + (out / ".rebuild.lock.mutex").write_text("", encoding="utf-8") + (out / ".graphify_build_token").write_text("tok-123", encoding="utf-8") + + res = inspect_graph_state(tmp_path) + assert res.state == GraphState.FRESH + assert res.intermediate_artifacts == () + + +def test_watch_rebuild_lock_lifecycle_normal(tmp_path): + """_rebuild_lock context manager creates lock and removes it upon clean exit.""" + from graphify.watch import _rebuild_lock + out = tmp_path / "graphify-out" + lock_file = out / ".rebuild.lock" + + with _rebuild_lock(out) as acquired: + assert acquired is True + assert lock_file.exists() + lines = lock_file.read_text(encoding="utf-8").splitlines() + assert lines[0] == str(os.getpid()) + + assert not lock_file.exists() + + +def test_watch_rebuild_lock_lifecycle_exception(tmp_path): + """_rebuild_lock context manager removes lock even when an exception is raised.""" + from graphify.watch import _rebuild_lock + out = tmp_path / "graphify-out" + lock_file = out / ".rebuild.lock" + + with pytest.raises(ValueError): + with _rebuild_lock(out) as acquired: + assert acquired is True + assert lock_file.exists() + raise ValueError("boom") + + assert not lock_file.exists() + + +def test_watch_rebuild_lock_non_blocking_does_not_clobber_holder(tmp_path): + """A non-blocking second caller to _rebuild_lock does not clobber the primary holder.""" + from graphify.watch import _rebuild_lock + out = tmp_path / "graphify-out" + lock_file = out / ".rebuild.lock" + + with _rebuild_lock(out) as outer: + assert outer is True + held_content = lock_file.read_text(encoding="utf-8") + + with _rebuild_lock(out, blocking=False) as inner: + assert inner is False + # Holder lock file unchanged + assert lock_file.read_text(encoding="utf-8") == held_content + + # Outer holder still intact + assert lock_file.exists() + + assert not lock_file.exists() + + +def test_is_pid_alive_permission_error_treated_as_alive(monkeypatch): + """PermissionError from os.kill(pid, 0) means the process is alive under another user.""" + from graphify import detect + def mock_kill(pid, sig): + raise PermissionError("Access denied") + + monkeypatch.setattr(os, "kill", mock_kill) + if os.name == "nt": + monkeypatch.setattr(os, "name", "posix") + assert detect._is_pid_alive(12345) is True + + +def test_is_pid_alive_windows_access_denied_treated_as_alive(monkeypatch): + """ERROR_ACCESS_DENIED (5) on Windows OpenProcess means the process is alive.""" + import sys + from unittest.mock import MagicMock + from graphify import detect + + mock_kernel32 = MagicMock() + mock_kernel32.OpenProcess.return_value = 0 + mock_kernel32.GetLastError.return_value = 5 + mock_ctypes = MagicMock() + mock_ctypes.windll.kernel32 = mock_kernel32 + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setitem(sys.modules, "ctypes", mock_ctypes) + assert detect._is_pid_alive(12345) is True + + +def test_release_mismatched_token_does_not_delete_other_owner_token_file(tmp_path): + """A stale owner calling release with an old token must NOT delete active owner's token file.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + lock_file = out / ".rebuild.lock" + token_file = out / ".graphify_build_token" + + # Active owner B writes its lock and token + lock_file.write_text(f"{os.getpid()}\ntoken=token_B\n", encoding="utf-8") + token_file.write_text("token_B", encoding="utf-8") + + # Stale owner A tries to release token_A + released = release_build_lock(tmp_path, token="token_A") + assert released is False + + # BOTH .rebuild.lock and .graphify_build_token must remain owned by token_B + assert lock_file.exists() + assert "token=token_B" in lock_file.read_text(encoding="utf-8") + assert token_file.exists() + assert token_file.read_text(encoding="utf-8").strip() == "token_B" + + +def test_release_matching_token_unlinks_both_files(tmp_path): + """A matching owner calling release removes both .rebuild.lock and .graphify_build_token.""" + status = acquire_build_lock(tmp_path) + assert status.result == LockResult.ACQUIRED + out = tmp_path / "graphify-out" + assert (out / ".rebuild.lock").exists() + assert (out / ".graphify_build_token").exists() + + released = release_build_lock(tmp_path, token=status.token) + assert released is True + assert not (out / ".rebuild.lock").exists() + assert not (out / ".graphify_build_token").exists() + + +def test_step2_fresh_reinspection_releases_lock(tmp_path): + """Step 2 protocol: acquiring lock followed by FRESH re-inspection releases lock before exit.""" + head = _init_git_repo(tmp_path) + _write_graph(tmp_path, built_at_commit=head) + + out = tmp_path / "graphify-out" + status = acquire_build_lock(tmp_path) + assert status.result == LockResult.ACQUIRED + assert (out / ".rebuild.lock").exists() + + re_state = inspect_graph_state(tmp_path, current_token=status.token) + assert re_state.state == GraphState.FRESH + + # Step 2 releases lock when FRESH + released = release_build_lock(tmp_path, token=status.token) + assert released is True + assert not (out / ".rebuild.lock").exists() + assert not (out / ".graphify_build_token").exists() + + +def test_step2_total_files_zero_releases_lock(tmp_path): + """Step 2 protocol: detect() with 0 files releases lock.""" + out = tmp_path / "graphify-out" + status = acquire_build_lock(tmp_path) + assert status.result == LockResult.ACQUIRED + assert (out / ".rebuild.lock").exists() + + from graphify.detect import detect + res = detect(tmp_path) + if res["total_files"] == 0: + release_build_lock(tmp_path, token=status.token) + + assert not (out / ".rebuild.lock").exists() + assert not (out / ".graphify_build_token").exists() + + +def test_release_without_token_rejected_on_token_lock(tmp_path): + """Calling release_build_lock() without a token on a token-protected lock returns False.""" + status = acquire_build_lock(tmp_path) + assert status.result == LockResult.ACQUIRED + out = tmp_path / "graphify-out" + assert (out / ".rebuild.lock").exists() + + # Attempt to release without token + released = release_build_lock(tmp_path) + assert released is False + # Lock and token must remain intact + assert (out / ".rebuild.lock").exists() + assert (out / ".graphify_build_token").exists() + + # Releasing with correct token succeeds + released_with_token = release_build_lock(tmp_path, token=status.token) + assert released_with_token is True + assert not (out / ".rebuild.lock").exists() + assert not (out / ".graphify_build_token").exists() + + +def test_superseded_owner_no_token_release_does_not_delete_new_owner(tmp_path): + """If owner A is superseded by owner B, A calling release_build_lock() with no token or token_A cannot delete B.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + lock_file = out / ".rebuild.lock" + token_file = out / ".graphify_build_token" + + # Owner B holds token_B + lock_file.write_text(f"{os.getpid()}\ntoken=token_B\n", encoding="utf-8") + token_file.write_text("token_B", encoding="utf-8") + + # Superseded owner A calls release_build_lock with no token + released = release_build_lock(tmp_path) + assert released is False + assert lock_file.exists() + assert token_file.exists() + assert "token=token_B" in lock_file.read_text(encoding="utf-8") + assert token_file.read_text(encoding="utf-8").strip() == "token_B" + + # Superseded owner A calls release_build_lock with token_A + released_token_a = release_build_lock(tmp_path, token="token_A") + assert released_token_a is False + assert lock_file.exists() + assert token_file.exists() + assert "token=token_B" in lock_file.read_text(encoding="utf-8") + assert token_file.read_text(encoding="utf-8").strip() == "token_B" + + +def test_takeover_and_aborted_session_retained_token_cleanup_safety(tmp_path): + """Owner A acquires token_A, B takes over with token_B; A's abort cleanup with token_A returns False and leaves B intact.""" + out = tmp_path / "graphify-out" + lock_file = out / ".rebuild.lock" + token_file = out / ".graphify_build_token" + + # Step 1: Owner A acquires lock + status_a = acquire_build_lock(tmp_path) + assert status_a.result == LockResult.ACQUIRED + token_a = status_a.token + assert token_file.read_text(encoding="utf-8").strip() == token_a + + # Step 2: Simulate A dying/stalling and B taking over + # Write a stale lock file with an exited PID to simulate takeover conditions + lock_file.write_text(f"999999\ntoken={token_a}\n", encoding="utf-8") + status_b = acquire_build_lock(tmp_path) + assert status_b.result == LockResult.RECLAIMED_STALE + token_b = status_b.token + assert token_b != token_a + assert token_file.read_text(encoding="utf-8").strip() == token_b + + # Step 3: Owner A resumes its abort cleanup using its retained token_a + released_a = release_build_lock(tmp_path, token=token_a) + assert released_a is False + + # Verify B's lock and token remain intact + assert lock_file.exists() + assert f"token={token_b}" in lock_file.read_text(encoding="utf-8") + assert token_file.exists() + assert token_file.read_text(encoding="utf-8").strip() == token_b + + # Step 4: Owner B completes and releases with its token_b + released_b = release_build_lock(tmp_path, token=token_b) + assert released_b is True + assert not lock_file.exists() + assert not token_file.exists() + + +# ── 15. Advisory Findings Regression Tests ───────────────────────────────── + +def test_acquire_build_lock_unlinks_symlink_and_does_not_overwrite_target(tmp_path): + """A repo-controlled symlink at .rebuild.lock must be replaced and not overwrite target.""" + out = tmp_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + sensitive_file = tmp_path / "sensitive.txt" + sensitive_file.write_text("TOP_SECRET", encoding="utf-8") + + lock_file = out / ".rebuild.lock" + try: + lock_file.symlink_to(sensitive_file) + except OSError: + pytest.skip("Symlinks not supported on this platform/privilege level") + + status = acquire_build_lock(tmp_path) + assert status.result == LockResult.RECLAIMED_STALE + # Target file must be untouched + assert sensitive_file.read_text(encoding="utf-8") == "TOP_SECRET" + # Lock file must now be a regular file, not a symlink + assert not lock_file.is_symlink() + assert lock_file.is_file() + + +def test_inspect_graph_state_detects_dirty_extensionless_shebang(tmp_path): + """An extensionless shebang script modified in working tree must flag STALE / DIRTY_WORKTREE.""" + head = _init_git_repo(tmp_path) + + # Add extensionless shebang script to repo + script = tmp_path / "cli_tool" + script.write_text("#!/usr/bin/env python\ndef main(): pass\n", encoding="utf-8") + subprocess.run(["git", "add", "cli_tool"], cwd=str(tmp_path), check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "add cli_tool"], cwd=str(tmp_path), check=True, capture_output=True) + new_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(tmp_path), check=True, capture_output=True, text=True).stdout.strip() + + _write_graph(tmp_path, built_at_commit=new_head) + + # Initial state should be FRESH + res_clean = inspect_graph_state(tmp_path) + assert res_clean.state == GraphState.FRESH + + # Modify extensionless shebang script in working tree + script.write_text("#!/usr/bin/env python\ndef main(): print('modified')\n", encoding="utf-8") + + res_dirty = inspect_graph_state(tmp_path) + assert res_dirty.state == GraphState.STALE + assert StalenessReason.DIRTY_WORKTREE in res_dirty.staleness_reasons + assert "cli_tool" in res_dirty.dirty_files + + +def test_is_pid_alive_windows_active_process_treated_as_alive(monkeypatch): + """1. Active running process (WAIT_TIMEOUT = 258) is ALIVE.""" + import sys + from unittest.mock import MagicMock + from graphify import detect + + mock_kernel32 = MagicMock() + mock_kernel32.OpenProcess.return_value = 9999 + mock_kernel32.WaitForSingleObject.return_value = 258 # WAIT_TIMEOUT -> active + mock_ctypes = MagicMock() + mock_ctypes.windll.kernel32 = mock_kernel32 + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setitem(sys.modules, "ctypes", mock_ctypes) + + assert detect._is_pid_alive(12345) is True + mock_kernel32.CloseHandle.assert_called_once_with(9999) + + +def test_is_pid_alive_windows_signaled_process_treated_as_dead(monkeypatch): + """2. Signaled/terminated process (WAIT_OBJECT_0 = 0) is DEAD.""" + import sys + from unittest.mock import MagicMock + from graphify import detect + + mock_kernel32 = MagicMock() + mock_kernel32.OpenProcess.return_value = 9999 + mock_kernel32.WaitForSingleObject.return_value = 0 # WAIT_OBJECT_0 -> terminated + mock_ctypes = MagicMock() + mock_ctypes.windll.kernel32 = mock_kernel32 + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setitem(sys.modules, "ctypes", mock_ctypes) + + assert detect._is_pid_alive(12345) is False + mock_kernel32.CloseHandle.assert_called_once_with(9999) + + +def test_is_pid_alive_windows_exit_code_259_with_signaled_handle_treated_as_dead(monkeypatch): + """3. Terminated process whose exit code happens to be 259 (STILL_ACTIVE) is DEAD.""" + import sys + from unittest.mock import MagicMock + from graphify import detect + + mock_kernel32 = MagicMock() + mock_kernel32.OpenProcess.return_value = 9999 + mock_kernel32.WaitForSingleObject.return_value = 0 # WAIT_OBJECT_0 -> terminated + mock_ctypes = MagicMock() + mock_ctypes.windll.kernel32 = mock_kernel32 + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setitem(sys.modules, "ctypes", mock_ctypes) + + assert detect._is_pid_alive(12345) is False + mock_kernel32.CloseHandle.assert_called_once_with(9999) + + +def test_is_pid_alive_windows_access_denied_treated_as_alive_case4(monkeypatch): + """4. ERROR_ACCESS_DENIED (5) when OpenProcess fails means PID exists / ALIVE.""" + import sys + from unittest.mock import MagicMock + from graphify import detect + + mock_kernel32 = MagicMock() + mock_kernel32.OpenProcess.return_value = 0 + mock_kernel32.GetLastError.return_value = 5 # ERROR_ACCESS_DENIED + mock_ctypes = MagicMock() + mock_ctypes.windll.kernel32 = mock_kernel32 + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setitem(sys.modules, "ctypes", mock_ctypes) + + assert detect._is_pid_alive(12345) is True + + +def test_is_pid_alive_windows_nonexistent_pid_treated_as_dead(monkeypatch): + """5. Nonexistent PID (ERROR_INVALID_PARAMETER = 87) is DEAD.""" + import sys + from unittest.mock import MagicMock + from graphify import detect + + mock_kernel32 = MagicMock() + mock_kernel32.OpenProcess.return_value = 0 + mock_kernel32.GetLastError.return_value = 87 # ERROR_INVALID_PARAMETER + mock_ctypes = MagicMock() + mock_ctypes.windll.kernel32 = mock_kernel32 + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setitem(sys.modules, "ctypes", mock_ctypes) + + assert detect._is_pid_alive(12345) is False + + +def test_parse_porcelain_z_all_record_types(): + """_parse_porcelain_z handles all porcelain-z entry types including renames and copies.""" + from graphify.detect import _parse_porcelain_z + + raw = ( + b" M modified.py\x00" + b"A staged.py\x00" + b"MM both_mod.py\x00" + b"?? untracked.py\x00" + b"R new_path.py\x00old_path.py\x00" + b"RM renamed_mod.py\x00orig_mod.py\x00" + b"C copy_dst.py\x00copy_src.py\x00" + b"CM copy_dst_mod.py\x00copy_src_mod.py\x00" + b" M path with spaces/my file.py\x00" + ) + entries = _parse_porcelain_z(raw) + assert len(entries) == 9 + assert entries[0] == (" M", "modified.py", None) + assert entries[1] == ("A ", "staged.py", None) + assert entries[2] == ("MM", "both_mod.py", None) + assert entries[3] == ("??", "untracked.py", None) + assert entries[4] == ("R ", "new_path.py", "old_path.py") + assert entries[5] == ("RM", "renamed_mod.py", "orig_mod.py") + assert entries[6] == ("C ", "copy_dst.py", "copy_src.py") + assert entries[7] == ("CM", "copy_dst_mod.py", "copy_src_mod.py") + assert entries[8] == (" M", "path with spaces/my file.py", None) diff --git a/tests/test_install.py b/tests/test_install.py index 7e74487cb2..f463634512 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -271,7 +271,7 @@ def test_codex_skill_uses_graphify_with_existing_graph(): fast-path block, which jumps straight to the query flow when a graph exists. """ import graphify - skill = (Path(graphify.__file__).parent / "skill-codex.md").read_text() + skill = (Path(graphify.__file__).parent / "skill-codex.md").read_text(encoding="utf-8") assert "Fast path — existing graph" in skill assert "skip Steps 1–5 entirely and jump straight to `## For /graphify query`" in skill assert "graphify query" in skill diff --git a/tests/test_skillgen.py b/tests/test_skillgen.py index cf116869f2..bdc0456089 100644 --- a/tests/test_skillgen.py +++ b/tests/test_skillgen.py @@ -430,7 +430,7 @@ def test_windows_python_step_bodies_match_posix_verbatim(): bodies = [] current = None for line in claude_core.splitlines(): - if line == gen._PY_INVOKE_POSIX: + if line in (gen._PY_INVOKE_POSIX, gen._PY_INVOKE_POSIX_QUOTED): current = [] elif current is not None and line == '"': bodies.append("\n".join(gen._unescape_bash_dq(l) for l in current)) @@ -1093,3 +1093,47 @@ def test_semantic_cache_calls_pass_prompt_file_for_every_split_host(): ) # The placeholder is inert unless the body tells the agent what to substitute. assert "SPEC_PATH below is the **absolute** path" in a.content, a.path + + +def test_fast_path_graph_state_inspection_rendered_across_all_split_hosts(): + """All split-host skill variants include the inspect_graph_state fast-path policy.""" + platforms = gen.load_platforms() + for key, p in platforms.items(): + if p.bucket != "split": + continue + arts = gen.render(p) + core_art = next(a for a in arts if a.path == p.skill_dst) + content = core_art.content + assert "Fast path — existing graph" in content, f"{p.skill_dst} missing fast path section" + assert "inspect_graph_state" in content, f"{p.skill_dst} missing inspect_graph_state call" + assert "FRESH" in content, f"{p.skill_dst} missing FRESH state guidance" + assert "STALE" in content, f"{p.skill_dst} missing STALE state guidance" + assert "INCOMPLETE" in content, f"{p.skill_dst} missing INCOMPLETE state guidance" + assert "BUILDING" in content, f"{p.skill_dst} missing BUILDING state guidance" + assert "UNVERIFIABLE" in content, f"{p.skill_dst} missing UNVERIFIABLE state guidance" + assert "ABSENT" in content, f"{p.skill_dst} missing ABSENT state guidance" + + +def test_fast_path_powershell_translation_for_windows(): + """Windows platform skill renders valid PowerShell for inspect_graph_state call.""" + platforms = gen.load_platforms() + arts = gen.render(platforms["windows"]) + core_art = next(a for a in arts if a.path == "graphify/skill-windows.md") + content = core_art.content + assert "'@ | & (Get-Content graphify-out\\.graphify_python) -" in content + assert "inspect_graph_state" in content + assert "$(cat " not in content + assert '"$(cat ' not in content + + +def test_posix_skills_use_quoted_interpreter_invocation(): + """POSIX platform skills use quoted \"$(cat ...)\" for fast path, step 2, and abort cleanup.""" + platforms = gen.load_platforms() + for key in ("claude", "amp", "agents", "codex", "opencode"): + arts = gen.render(platforms[key]) + core_art = next(a for a in arts if a.path == platforms[key].skill_dst) + content = core_art.content + assert '"$(cat graphify-out/.graphify_python)" -c "' in content, f"{key} missing quoted python invocation" + assert "release_build_lock" in content + assert "re_state.state == GraphState.FRESH" in content + assert "release_build_lock('.', token=status.token)" in content diff --git a/tests/test_watch.py b/tests/test_watch.py index 82526e4a1e..4d11e459ff 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -182,7 +182,6 @@ def mock_import(name, *args, **kwargs): # --- _rebuild_lock (GH-858) --- -@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)") def test_rebuild_lock_writes_pid_with_newline(tmp_path): out = tmp_path / "graphify-out" lock_path = out / ".rebuild.lock" @@ -190,10 +189,13 @@ def test_rebuild_lock_writes_pid_with_newline(tmp_path): assert got is True assert lock_path.exists() contents = lock_path.read_text(encoding="utf-8") - assert contents == f"{os.getpid()}\n", contents + lines = contents.splitlines() + assert len(lines) >= 1 + assert lines[0] == str(os.getpid()) + assert contents.startswith(f"{os.getpid()}\n") + assert any(line.startswith("token=") for line in lines[1:]) -@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)") def test_rebuild_lock_removed_after_release(tmp_path): """GH-858: lock file must be unlinked once the rebuild completes so downstream waiters that poll for its absence unblock promptly.""" @@ -204,17 +206,20 @@ def test_rebuild_lock_removed_after_release(tmp_path): assert not lock_path.exists(), "lock file should be unlinked after release" -@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)") def test_rebuild_lock_does_not_accumulate_pids_across_runs(tmp_path): - """GH-858: each acquisition truncates and rewrites the PID line rather - than appending, so the file never grows into a digit-concatenation.""" + """GH-858: each acquisition rewrites the lock metadata cleanly rather + than appending, so the file never accumulates multiple PID lines.""" out = tmp_path / "graphify-out" lock_path = out / ".rebuild.lock" - expected = f"{os.getpid()}\n" for _ in range(5): with _rebuild_lock(out) as got: assert got is True - assert lock_path.read_text(encoding="utf-8") == expected + contents = lock_path.read_text(encoding="utf-8") + lines = contents.splitlines() + assert lines[0] == str(os.getpid()) + numeric_lines = [line for line in lines if line.strip().isdigit()] + assert len(numeric_lines) == 1 + assert any(line.startswith("token=") for line in lines[1:]) assert not lock_path.exists() @@ -1010,7 +1015,6 @@ def labels(d): assert "bar()" in labels(healed), "surviving symbol must be kept throughout" -@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)") def test_rebuild_lock_non_blocking_does_not_clobber_holder(tmp_path): """GH-858: a non-blocking caller that fails to acquire the lock must not truncate the holder's PID payload.""" diff --git a/tools/skillgen/expected/graphify__skill-agents.md b/tools/skillgen/expected/graphify__skill-agents.md index 190827d9ac..bfe35e5c5c 100644 --- a/tools/skillgen/expected/graphify__skill-agents.md +++ b/tools/skillgen/expected/graphify__skill-agents.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -612,6 +674,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -620,7 +685,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-amp.md b/tools/skillgen/expected/graphify__skill-amp.md index 190827d9ac..bfe35e5c5c 100644 --- a/tools/skillgen/expected/graphify__skill-amp.md +++ b/tools/skillgen/expected/graphify__skill-amp.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -612,6 +674,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -620,7 +685,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-claw.md b/tools/skillgen/expected/graphify__skill-claw.md index abd2811d23..4561604060 100644 --- a/tools/skillgen/expected/graphify__skill-claw.md +++ b/tools/skillgen/expected/graphify__skill-claw.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-codex.md b/tools/skillgen/expected/graphify__skill-codex.md index af3f723c78..0c87322775 100644 --- a/tools/skillgen/expected/graphify__skill-codex.md +++ b/tools/skillgen/expected/graphify__skill-codex.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -612,6 +674,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -620,7 +685,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-copilot.md b/tools/skillgen/expected/graphify__skill-copilot.md index abd2811d23..4561604060 100644 --- a/tools/skillgen/expected/graphify__skill-copilot.md +++ b/tools/skillgen/expected/graphify__skill-copilot.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-droid.md b/tools/skillgen/expected/graphify__skill-droid.md index fd148d485d..55cf87c656 100644 --- a/tools/skillgen/expected/graphify__skill-droid.md +++ b/tools/skillgen/expected/graphify__skill-droid.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -612,6 +674,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -620,7 +685,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-kilo.md b/tools/skillgen/expected/graphify__skill-kilo.md index 3e70b050a4..a862bc7211 100644 --- a/tools/skillgen/expected/graphify__skill-kilo.md +++ b/tools/skillgen/expected/graphify__skill-kilo.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-kiro.md b/tools/skillgen/expected/graphify__skill-kiro.md index abd2811d23..4561604060 100644 --- a/tools/skillgen/expected/graphify__skill-kiro.md +++ b/tools/skillgen/expected/graphify__skill-kiro.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-opencode.md b/tools/skillgen/expected/graphify__skill-opencode.md index 91ced60675..94a32df4cb 100644 --- a/tools/skillgen/expected/graphify__skill-opencode.md +++ b/tools/skillgen/expected/graphify__skill-opencode.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -607,6 +669,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -615,7 +680,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-pi.md b/tools/skillgen/expected/graphify__skill-pi.md index abd2811d23..4561604060 100644 --- a/tools/skillgen/expected/graphify__skill-pi.md +++ b/tools/skillgen/expected/graphify__skill-pi.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-trae.md b/tools/skillgen/expected/graphify__skill-trae.md index 050667bc20..d0a08e91a9 100644 --- a/tools/skillgen/expected/graphify__skill-trae.md +++ b/tools/skillgen/expected/graphify__skill-trae.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -613,6 +675,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -621,7 +686,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-vscode.md b/tools/skillgen/expected/graphify__skill-vscode.md index 20c7c0835c..38a5161d97 100644 --- a/tools/skillgen/expected/graphify__skill-vscode.md +++ b/tools/skillgen/expected/graphify__skill-vscode.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -611,6 +673,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -619,7 +684,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill-windows.md b/tools/skillgen/expected/graphify__skill-windows.md index d631821ec3..0ac4ff96bd 100644 --- a/tools/skillgen/expected/graphify__skill-windows.md +++ b/tools/skillgen/expected/graphify__skill-windows.md @@ -50,7 +50,41 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```powershell +if (-not (Test-Path graphify-out\.graphify_python)) { + $GRAPHIFY_PYTHON = $null + $graphifyCmd = Get-Command graphify -ErrorAction SilentlyContinue + if ($graphifyCmd) { + # The interpreter that owns the graphify entry point sits next to it + # (\Scripts\python.exe for uv tool, pipx, and venv installs). + $py = Join-Path (Split-Path $graphifyCmd.Source) "python.exe" + if (Test-Path $py) { $GRAPHIFY_PYTHON = $py } + } + if (-not $GRAPHIFY_PYTHON) { $GRAPHIFY_PYTHON = "python" } + New-Item -ItemType Directory -Force -Path graphify-out | Out-Null + & $GRAPHIFY_PYTHON -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +} +``` + +```powershell +@' +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +'@ | & (Get-Content graphify-out\.graphify_python) - +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -128,19 +162,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```powershell @' -import json -from graphify.detect import detect +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") -print(f'Detected {result["total_files"]} files') +print(f'Detected {result["total_files"]} files (build token: {status.token})') '@ | & (Get-Content graphify-out\.graphify_python) - ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -166,6 +219,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```powershell +@' +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +'@ | & (Get-Content graphify-out\.graphify_python) - +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -637,6 +701,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding="utf-8") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost["total_input_tokens"]:,} input, {cost["total_output_tokens"]:,} output ({len(cost["runs"])} runs)') '@ | & (Get-Content graphify-out\.graphify_python) - @@ -645,7 +712,7 @@ Get-ChildItem graphify-out -Filter '.graphify_chunk_*.json' -File -ErrorAction S Remove-Item -Force -ErrorAction SilentlyContinue graphify-out\.needs_update ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/expected/graphify__skill.md b/tools/skillgen/expected/graphify__skill.md index abd2811d23..4561604060 100644 --- a/tools/skillgen/expected/graphify__skill.md +++ b/tools/skillgen/expected/graphify__skill.md @@ -50,7 +50,39 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + GRAPHIFY_BIN=$(which graphify 2>/dev/null) + if [ -n "$GRAPHIFY_BIN" ]; then + PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac + else + PYTHON="python3" + fi + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -106,19 +138,38 @@ If the import succeeds, print nothing and move straight to Step 2. ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -144,6 +195,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -615,6 +677,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -623,7 +688,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/fragments/core/core.md b/tools/skillgen/fragments/core/core.md index c527a12563..b8ca83a343 100644 --- a/tools/skillgen/fragments/core/core.md +++ b/tools/skillgen/fragments/core/core.md @@ -47,7 +47,27 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. If `graphify-out/graph.json` exists, resolve the Python interpreter if missing, then inspect the graph state: + +@@INTERP_GUARD@@ + +```bash +"$(cat graphify-out/.graphify_python)" -c " +import json +from graphify.detect import inspect_graph_state +res = inspect_graph_state('.') +print(json.dumps(res.to_dict())) +" +``` + +Act based on the returned `state`: + +* **`FRESH`**: The graph is clean and matches git HEAD. If the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +* **`STALE`**: The graph was built from an older commit or the working tree contains uncommitted changes (`HEAD_MISMATCH` and/or `DIRTY_WORKTREE`). **Do NOT silently take the query fast path.** Surface that the existing graph is stale (report `built_at_commit`, current HEAD, and the reason: different commit or uncommitted modifications). Recommend updating the graph with `/graphify --update` (or a full rebuild `/graphify ` if major structural changes occurred). Do not automatically execute `--update`. +* **`INCOMPLETE`**: Intermediate artifacts (e.g. `.graphify_extract.json`, `.graphify_ast.json`, `.graphify_chunk_*.json`) were left behind by a previous interrupted or crashed build. **Do NOT query the existing graph.** Warn the user that intermediate artifacts were detected and recommend running a fresh full rebuild (`/graphify `). Do not recommend `--update` as the primary recovery mechanism, and do not automatically delete the artifacts. +* **`BUILDING`**: Another Graphify build process is currently running (active PID in `.rebuild.lock`). **Do NOT start another build or query the graph.** Inform the user that another Graphify rebuild is currently active. +* **`UNVERIFIABLE`**: The graph exists but freshness cannot be established (e.g. not a git repository or missing `built_at_commit`). Print a concise provenance warning explaining why freshness could not be confirmed. To preserve compatibility, if the user asked a natural-language question, you may proceed to `## For /graphify query` with that notice, but do not claim the graph is fresh. +* **`ABSENT`**: `graphify-out/graph.json` does not exist. Proceed with the normal full build workflow (Step 1 below). If no path was given, use `.` (current directory). Do not ask the user for a path. @@ -65,19 +85,38 @@ Only when the path is one or more `https://github.com/...` URLs, or several loca ### Step 2 - Detect files +Before starting the build, acquire the build lock, re-inspect the graph state, and detect files: + ```bash -$(cat graphify-out/.graphify_python) -c " -import json -from graphify.detect import detect +"$(cat graphify-out/.graphify_python)" -c " +import sys, json +from graphify.detect import acquire_build_lock, inspect_graph_state, release_build_lock, detect, LockResult, GraphState from pathlib import Path + +status = acquire_build_lock('.') +if status.result == LockResult.ALREADY_ACTIVE: + print(f'Another Graphify build is currently active (PID {status.active_pid}). Halting.') + sys.exit(1) +re_state = inspect_graph_state('.', current_token=status.token) +if re_state.state == GraphState.FRESH: + release_build_lock('.', token=status.token) + print('Graph is already fresh. Skipping build.') + sys.exit(2) + result = detect(Path('INPUT_PATH')) +if result['total_files'] == 0: + release_build_lock('.', token=status.token) # Write the sidecar from Python, not a shell redirect, so the same block renders # on PowerShell hosts without console-encoding drift (#2528). Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\") -print(f'Detected {result[\"total_files\"]} files') +print(f'Detected {result[\"total_files\"]} files (build token: {status.token})') " ``` +If another build is active or the graph is already fresh, stop and do not proceed with the build. + +**Session token retention:** Retain the `build token: ` printed by Step 2. You MUST pass this exact token string into `release_build_lock('.', token='')` in Step 9 or upon abort. Never read `.graphify_build_token` from disk during cleanup, as a newer build could have superseded a stalled session. + Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` @@ -103,6 +142,17 @@ Then act on it: - Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not. +> **Lock release on abort:** If the build fails, is cancelled, or aborts at any point before Step 9, release the build lock using the exact session token retained from Step 2: + +```bash +"$(cat graphify-out/.graphify_python)" -c " +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') +" +``` + +Replace `BUILD_TOKEN` with the exact token printed in Step 2. + ### Step 2.5 - Video and audio (only if video files detected) Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. @@ -550,6 +600,9 @@ cost['total_input_tokens'] += input_tok cost['total_output_tokens'] += output_tok cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\") +from graphify.detect import release_build_lock +release_build_lock('.', token='BUILD_TOKEN') + print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " @@ -558,7 +611,7 @@ find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null rm -f graphify-out/.needs_update 2>/dev/null || true ``` -Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. +Replace `BUILD_TOKEN` with the exact session token retained from Step 2, and `INPUT_PATH` with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root. Tell the user (omit the obsidian line unless --obsidian was given): ``` diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py index 09e19ede00..7e3d17a592 100644 --- a/tools/skillgen/gen.py +++ b/tools/skillgen/gen.py @@ -382,6 +382,7 @@ def _render_frontmatter(platform: Platform) -> str: # belt-and-braces post-check on the final body. _PY_INVOKE_POSIX = '$(cat graphify-out/.graphify_python) -c "' +_PY_INVOKE_POSIX_QUOTED = '"$(cat graphify-out/.graphify_python)" -c "' _PY_INVOKE_PS_OPEN = "@'" _PY_INVOKE_PS_CLOSE = "'@ | & (Get-Content graphify-out\\.graphify_python) -" _MKDIR_POSIX = "mkdir -p graphify-out" @@ -393,7 +394,7 @@ def _render_frontmatter(platform: Platform) -> str: ) # Bash-only tokens that must never survive in a powershell-shell render. -_POWERSHELL_BANNED_TOKENS = ("$(cat ", "rm -f ", "2>/dev/null", "```bash") +_POWERSHELL_BANNED_TOKENS = ("$(cat ", '"$(cat ', "rm -f ", "2>/dev/null", "```bash") def _unescape_bash_dq(line: str) -> str: @@ -439,7 +440,7 @@ def _translate_bash_block(lines: list[str]) -> list[str]: in_py = False else: out.append(_unescape_bash_dq(line)) - elif line == _PY_INVOKE_POSIX: + elif line in (_PY_INVOKE_POSIX, _PY_INVOKE_POSIX_QUOTED): out.append(_PY_INVOKE_PS_OPEN) in_py = True elif line == _MKDIR_POSIX: