diff --git a/CLAUDE.md b/CLAUDE.md index e11e2d6..d0c5de3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,16 +31,17 @@ deltas below** (don't flatten them). - **Helper scripts** — every adapter co-locates each script with its invoker: hook scripts in `hooks/` (next to `hooks.json`), skill scripts in the skill's own - `scripts/`. `claude-code` is the reference (it has all six). Copies: + `scripts/`. `claude-code` is the reference (it has all seven). Copies: - `register.py` (add skill) → `claude-code/skills/add/scripts/`, `codex/skills/add/scripts/`, `copilot/skills/debt-ops-add/scripts/`, `skills/debt-ops-add/scripts/` - `review.py` (review skill) → `claude-code/skills/review/scripts/`, `codex/skills/review/scripts/`, `copilot/skills/debt-ops-review/scripts/`, `skills/debt-ops-review/scripts/` + - `toggle.py` (disable skill) → `claude-code/skills/disable/scripts/`, `codex/skills/disable/scripts/`, `copilot/skills/debt-ops-disable/scripts/`, `skills/debt-ops-disable/scripts/` (claude resolves the cache like `register.py`; the rest use `cache_base`) - `feedback.py`, `session-start.py` (hooks) → `claude-code/hooks/`, `codex/hooks/`, `copilot/hooks/` - `stop.py` (hook) → `claude-code/hooks/`, `codex/hooks/`, `copilot/hooks/` (copilot on `agentStop`) - `drop.py` (hook) → `claude-code/hooks/`, `codex/hooks/` (no copilot — `userPromptSubmitted` output isn't processed) - **Within-script helpers** repeated across most of the above — change one, change - all: `git_toplevel`, `repo_hash`, `cache_base`, `read_registry_dir`, `log_metric`, - `letter_for`, `parse_frontmatter`, `days_since`. -- **Skills (`SKILL.md`)** — `add`, `review`, `metrics`, `init` each live in + all: `git_toplevel`, `repo_hash`, `repo_disabled`, `cache_base`, `read_registry_dir`, + `log_metric`, `letter_for`, `parse_frontmatter`, `days_since`. +- **Skills (`SKILL.md`)** — `add`, `review`, `metrics`, `init`, `disable` each live in `claude-code/skills/`, `codex/skills/`, `copilot/skills/`, and top-level `skills/`. Dir names differ by namespace (see deltas): bare (`add`) under the namespaced plugins, `debt-ops-`-prefixed under copilot + portable. diff --git a/README.md b/README.md index dd1f0a1..9476f8b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Claude Code `/debt-ops:` · Codex `$`: - **review**: audit and rank the registry, then walk paydown. - **init** *(opt-in)*: write the disciplines into `CLAUDE.md`/`AGENTS.md` so the team shares them. - **metrics**: read-only health summary of the registry. +- **disable**: turn debt-ops off for the current repo (run again to re-enable). Flips a per-repo marker in the plugin cache — nothing is written to your working tree. Per-machine, not committed. diff --git a/claude-code/hooks/drop.py b/claude-code/hooks/drop.py index 6f3829b..950d2e9 100755 --- a/claude-code/hooks/drop.py +++ b/claude-code/hooks/drop.py @@ -59,6 +59,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + def emit_block(reason): payload = { "decision": "block", @@ -140,6 +145,10 @@ def main(): cache_base = Path(os.environ.get("CLAUDE_PLUGIN_DATA") or (Path.home() / ".cache" / "debt-ops")) cache_dir = cache_base / "cache" / repo_hash(toplevel) + # Idle out if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + # Look in both files: current-turn.txt (just-finished turn, not yet rotated) # and last-batch.txt (turn before that). Merge with current-turn winning. current = read_batch(cache_dir / "current-turn.txt") diff --git a/claude-code/hooks/feedback.py b/claude-code/hooks/feedback.py index c280836..5c8d138 100755 --- a/claude-code/hooks/feedback.py +++ b/claude-code/hooks/feedback.py @@ -56,6 +56,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + # Pulls the just-edited file path out of the hook's stdin JSON payload. def changed_file_from_stdin(): try: @@ -228,6 +233,10 @@ def main(): cache_base = Path(os.environ.get("CLAUDE_PLUGIN_DATA") or (Path.home() / ".cache" / "debt-ops")) cache_dir = cache_base / "cache" / repo_hash(toplevel) + # Idle out if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + changed = changed_file_from_stdin() changed_files = [changed] if changed else [] env = os.environ.copy() diff --git a/claude-code/hooks/session-start.py b/claude-code/hooks/session-start.py index 4b96d50..3c69afe 100755 --- a/claude-code/hooks/session-start.py +++ b/claude-code/hooks/session-start.py @@ -62,6 +62,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + def manifest_hash(toplevel): paths = [toplevel / n for n in MANIFEST_FILES if (toplevel / n).is_file()] if not paths: @@ -301,6 +306,11 @@ def main(): cache_base = Path(os.environ.get("CLAUDE_PLUGIN_DATA") or (Path.home() / ".cache" / "debt-ops")) cache_dir = cache_base / "cache" / repo_hash(toplevel) + + # Idle out silently if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + stateless = False try: cache_dir.mkdir(parents=True, exist_ok=True) diff --git a/claude-code/hooks/stop.py b/claude-code/hooks/stop.py index 36bc9b7..7f8b2d8 100644 --- a/claude-code/hooks/stop.py +++ b/claude-code/hooks/stop.py @@ -65,6 +65,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + # Debug log path — only when DEBT_OPS_DEBUG=1 is set in the environment. def debug_path(cache_dir): if not os.environ.get(DEBUG_ENV): @@ -331,6 +336,11 @@ def main(): cache_base = Path(os.environ.get("CLAUDE_PLUGIN_DATA") or (Path.home() / ".cache" / "debt-ops")) cache_dir = cache_base / "cache" / repo_hash(toplevel) + + # Idle out if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + dpath = debug_path(cache_dir) state_path = cache_dir / "stop-state" session_blocks_path = cache_dir / "session-blocks" diff --git a/claude-code/skills/disable/SKILL.md b/claude-code/skills/disable/SKILL.md new file mode 100644 index 0000000..2465ef8 --- /dev/null +++ b/claude-code/skills/disable/SKILL.md @@ -0,0 +1,35 @@ +--- +name: disable +description: Turn debt-ops off (or back on) for the current repo. Run ONLY when the user explicitly asks to disable, turn off, silence, or re-enable debt-ops here — never auto-invoke. Flips a per-repo marker in the plugin cache; nothing is written to the working tree. +disable-model-invocation: true +allowed-tools: Bash(python3 *) +# Hidden from `npx skills` discovery — this copy ships in the Claude Code plugin (uses ${CLAUDE_PLUGIN_ROOT}); the portable skills/ copy is the one for the skills CLI. +metadata: + internal: true +--- + +# /debt-ops:disable — turn debt-ops off for this repo + +Call `toggle.py` via Bash. It flips a `disabled` marker in this repo's plugin +cache dir (ADR 0020). While the marker is present, every hook idles silently — +no session inject, no write-time feedback, no stop nudge. Nothing lands in the +working tree, so it never shows up in `git status`. + +The marker is per-machine, not committed — it disables debt-ops for *you* in +*this* repo. The script's stdout IS the confirmation; add no commentary. + +## The call + +```bash +# Toggle (or pass `disable` / `enable` to force a direction): +python3 ${CLAUDE_PLUGIN_ROOT}/skills/disable/scripts/toggle.py disable +python3 ${CLAUDE_PLUGIN_ROOT}/skills/disable/scripts/toggle.py enable +``` + +Match the user's intent: pass `disable` when they ask to turn it off, `enable` +when they ask to turn it back on. Omit the argument only when they say "toggle." + +## Don't + +- Don't run this as part of normal work — only on an explicit request. +- Don't echo or paraphrase the script's output; the Bash result is already visible. diff --git a/claude-code/skills/disable/scripts/toggle.py b/claude-code/skills/disable/scripts/toggle.py new file mode 100644 index 0000000..901f49f --- /dev/null +++ b/claude-code/skills/disable/scripts/toggle.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""debt-ops disable toggle: flip the per-repo disable sentinel (ADR 0020). + +Writes (or removes) a `disabled` marker in this repo's plugin cache dir. While +the marker is present every hook idles silently — no session inject, no +write-time feedback, no stop nudge. Nothing is written to the working tree. + +With no argument it toggles; pass `disable` or `enable` to force a direction. +""" + +import argparse +import hashlib +import os +import subprocess +import sys +from pathlib import Path + + +def git_toplevel(): + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=True, timeout=2, + ) + s = out.stdout.strip() + return Path(s) if s else None + except (subprocess.SubprocessError, FileNotFoundError): + return None + + +def repo_hash(toplevel): + return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] + + +# Locate this repo's plugin cache — same resolution order as register.py. +# CLAUDE_PLUGIN_DATA is set for hook subprocesses but NOT for skill Bash (where +# this runs), so fall back to the standard plugin-data dir, then the legacy +# cache path. The sentinel must land where the hooks read it. +def resolve_cache_dir(toplevel): + h = repo_hash(toplevel) + candidates = [] + pd = os.environ.get("CLAUDE_PLUGIN_DATA") + if pd: + candidates.append(Path(pd) / "cache" / h) + candidates += sorted( + (Path.home() / ".claude" / "plugins" / "data").glob(f"debt-ops*/cache/{h}") + ) + candidates.append(Path.home() / ".cache" / "debt-ops" / "cache" / h) + for c in candidates: + if c.is_dir(): + return c + return candidates[0] + + +def parse_args(): + p = argparse.ArgumentParser(description="Toggle debt-ops for this repo.") + p.add_argument("action", nargs="?", choices=["disable", "enable"], + help="force a direction; omit to toggle") + return p.parse_args() + + +def main(): + args = parse_args() + + toplevel = git_toplevel() + if toplevel is None: + sys.stderr.write("debt-ops: not in a git repo\n") + return 2 + + cache_dir = resolve_cache_dir(toplevel) + sentinel = cache_dir / "disabled" + currently_disabled = sentinel.is_file() + + if args.action == "disable": + want_disabled = True + elif args.action == "enable": + want_disabled = False + else: + want_disabled = not currently_disabled + + if want_disabled: + try: + cache_dir.mkdir(parents=True, exist_ok=True) + sentinel.write_text("", encoding="utf-8") + except OSError as e: + sys.stderr.write(f"debt-ops: could not disable: {e}\n") + return 1 + sys.stdout.write("debt-ops disabled for this repo (run again to re-enable).\n") + else: + try: + sentinel.unlink(missing_ok=True) + except OSError as e: + sys.stderr.write(f"debt-ops: could not re-enable: {e}\n") + return 1 + sys.stdout.write("debt-ops re-enabled for this repo.\n") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + sys.stderr.write(f"debt-ops disable: {e}\n") + sys.exit(1) diff --git a/codex/hooks/drop.py b/codex/hooks/drop.py index 7fb5744..382c0f9 100644 --- a/codex/hooks/drop.py +++ b/codex/hooks/drop.py @@ -67,6 +67,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + def emit_block(reason): payload = { "decision": "block", @@ -147,6 +152,10 @@ def main(): cache_dir = cache_base() / "cache" / repo_hash(toplevel) + # Idle out if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + # Look in both files: current-turn.txt (just-finished turn, not yet rotated) # and last-batch.txt (turn before that). Merge with current-turn winning. current = read_batch(cache_dir / "current-turn.txt") diff --git a/codex/hooks/feedback.py b/codex/hooks/feedback.py index 48b4d1d..8ea8085 100644 --- a/codex/hooks/feedback.py +++ b/codex/hooks/feedback.py @@ -72,6 +72,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + # Pull the V4A patch text out of an apply_patch tool_input. Codex may place it # in any string field (or hand tool_input as a raw string), so we sniff for the # envelope markers rather than guessing the field name. @@ -287,6 +292,10 @@ def main(): cache_dir = cache_base() / "cache" / repo_hash(toplevel) + # Idle out if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + changed_files = changed_files_from_stdin() changed = " ".join(changed_files) env = os.environ.copy() diff --git a/codex/hooks/session-start.py b/codex/hooks/session-start.py index 03670da..e487866 100644 --- a/codex/hooks/session-start.py +++ b/codex/hooks/session-start.py @@ -73,6 +73,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + def manifest_hash(toplevel): paths = [toplevel / n for n in MANIFEST_FILES if (toplevel / n).is_file()] if not paths: @@ -311,6 +316,11 @@ def main(): return 0 cache_dir = cache_base() / "cache" / repo_hash(toplevel) + + # Idle out silently if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + stateless = False try: cache_dir.mkdir(parents=True, exist_ok=True) diff --git a/codex/hooks/stop.py b/codex/hooks/stop.py index 15378d0..e8e093f 100644 --- a/codex/hooks/stop.py +++ b/codex/hooks/stop.py @@ -70,6 +70,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + # Debug log path — only when DEBT_OPS_DEBUG=1 is set in the environment. def debug_path(cache_dir): if not os.environ.get(DEBUG_ENV): @@ -335,6 +340,11 @@ def main(): return 0 cache_dir = cache_base() / "cache" / repo_hash(toplevel) + + # Idle out if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + dpath = debug_path(cache_dir) state_path = cache_dir / "stop-state" session_blocks_path = cache_dir / "session-blocks" diff --git a/codex/skills/disable/SKILL.md b/codex/skills/disable/SKILL.md new file mode 100644 index 0000000..6f1df1d --- /dev/null +++ b/codex/skills/disable/SKILL.md @@ -0,0 +1,30 @@ +--- +name: disable +description: Turn debt-ops off (or back on) for the current repo. Run ONLY when the user explicitly asks to disable, turn off, silence, or re-enable debt-ops here — never auto-invoke. Flips a per-repo marker in the plugin cache; nothing is written to the working tree. +--- + +# disable — turn debt-ops off for this repo + +Call `toggle.py` via Bash. It flips a `disabled` marker in this repo's plugin +cache dir (ADR 0020). While the marker is present, every hook idles silently — +no session inject, no write-time feedback, no stop nudge. Nothing lands in the +working tree, so it never shows up in `git status`. + +The marker is per-machine, not committed — it disables debt-ops for *you* in +*this* repo. The script's stdout IS the confirmation; add no commentary. + +## The call + +```bash +# Toggle (or pass `disable` / `enable` to force a direction): +python3 scripts/toggle.py disable +python3 scripts/toggle.py enable +``` + +Match the user's intent: pass `disable` when they ask to turn it off, `enable` +when they ask to turn it back on. Omit the argument only when they say "toggle." + +## Don't + +- Don't run this as part of normal work — only on an explicit request. +- Don't echo or paraphrase the script's output; the Bash result is already visible. diff --git a/codex/skills/disable/scripts/toggle.py b/codex/skills/disable/scripts/toggle.py new file mode 100644 index 0000000..3e7bf56 --- /dev/null +++ b/codex/skills/disable/scripts/toggle.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""debt-ops disable toggle: flip the per-repo disable sentinel (ADR 0020). + +Writes (or removes) a `disabled` marker in this repo's plugin cache dir. While +the marker is present every hook idles silently — no session inject, no +write-time feedback, no stop nudge. Nothing is written to the working tree. + +With no argument it toggles; pass `disable` or `enable` to force a direction. +""" + +import argparse +import hashlib +import os +import subprocess +import sys +from pathlib import Path + + +def git_toplevel(): + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=True, timeout=2, + ) + s = out.stdout.strip() + return Path(s) if s else None + except (subprocess.SubprocessError, FileNotFoundError): + return None + + +def repo_hash(toplevel): + return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] + + +# Single deterministic cache base, shared with the hooks (override DEBT_OPS_CACHE). +def cache_base(): + override = os.environ.get("DEBT_OPS_CACHE") + return Path(override) if override else (Path.home() / ".cache" / "debt-ops") + + +def parse_args(): + p = argparse.ArgumentParser(description="Toggle debt-ops for this repo.") + p.add_argument("action", nargs="?", choices=["disable", "enable"], + help="force a direction; omit to toggle") + return p.parse_args() + + +def main(): + args = parse_args() + + toplevel = git_toplevel() + if toplevel is None: + sys.stderr.write("debt-ops: not in a git repo\n") + return 2 + + cache_dir = cache_base() / "cache" / repo_hash(toplevel) + sentinel = cache_dir / "disabled" + currently_disabled = sentinel.is_file() + + if args.action == "disable": + want_disabled = True + elif args.action == "enable": + want_disabled = False + else: + want_disabled = not currently_disabled + + if want_disabled: + try: + cache_dir.mkdir(parents=True, exist_ok=True) + sentinel.write_text("", encoding="utf-8") + except OSError as e: + sys.stderr.write(f"debt-ops: could not disable: {e}\n") + return 1 + sys.stdout.write("debt-ops disabled for this repo (run again to re-enable).\n") + else: + try: + sentinel.unlink(missing_ok=True) + except OSError as e: + sys.stderr.write(f"debt-ops: could not re-enable: {e}\n") + return 1 + sys.stdout.write("debt-ops re-enabled for this repo.\n") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + sys.stderr.write(f"debt-ops disable: {e}\n") + sys.exit(1) diff --git a/copilot/hooks/feedback.py b/copilot/hooks/feedback.py index f3773c2..731aafb 100644 --- a/copilot/hooks/feedback.py +++ b/copilot/hooks/feedback.py @@ -93,6 +93,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + # Single deterministic cache base, shared with the skills (override DEBT_OPS_CACHE). def cache_base(): override = os.environ.get("DEBT_OPS_CACHE") @@ -313,6 +318,10 @@ def main(): cache_dir = cache_base() / "cache" / repo_hash(toplevel) + # Idle out if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + tool_name, changed = changed_file(data) # Copilot fires postToolUse on every tool; only edits concern us. if not changed: diff --git a/copilot/hooks/session-start.py b/copilot/hooks/session-start.py index ae9c914..68589ff 100644 --- a/copilot/hooks/session-start.py +++ b/copilot/hooks/session-start.py @@ -51,6 +51,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + def cache_base(): override = os.environ.get("DEBT_OPS_CACHE") return Path(override) if override else (Path.home() / ".cache" / "debt-ops") @@ -172,6 +177,11 @@ def main(): return 0 cache_dir = cache_base() / "cache" / repo_hash(toplevel) + + # Idle out silently if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + try: cache_dir.mkdir(parents=True, exist_ok=True) except OSError: diff --git a/copilot/hooks/stop.py b/copilot/hooks/stop.py index 873e6d2..73549f2 100644 --- a/copilot/hooks/stop.py +++ b/copilot/hooks/stop.py @@ -72,6 +72,11 @@ def repo_hash(toplevel): return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] +# True if this repo has a debt-ops disable sentinel in its cache dir (ADR 0020). +def repo_disabled(cache_dir): + return (cache_dir / "disabled").is_file() + + # Debug log path — only when DEBT_OPS_DEBUG=1 is set in the environment. def debug_path(cache_dir): if not os.environ.get(DEBUG_ENV): @@ -355,6 +360,11 @@ def main(): return 0 cache_dir = cache_base() / "cache" / repo_hash(toplevel) + + # Idle out if debt-ops is disabled for this repo (ADR 0020). + if repo_disabled(cache_dir): + return 0 + dpath = debug_path(cache_dir) state_path = cache_dir / "stop-state" session_blocks_path = cache_dir / "session-blocks" diff --git a/copilot/skills/debt-ops-disable/SKILL.md b/copilot/skills/debt-ops-disable/SKILL.md new file mode 100644 index 0000000..c58c039 --- /dev/null +++ b/copilot/skills/debt-ops-disable/SKILL.md @@ -0,0 +1,28 @@ +--- +name: debt-ops-disable +description: Turn debt-ops off (or back on) for the current repo. Run ONLY when the user explicitly asks to disable, turn off, silence, or re-enable debt-ops here — never auto-invoke. Flips a per-repo marker in the plugin cache; nothing is written to the working tree. +--- + +# debt-ops-disable — turn debt-ops off for this repo + +**Run only on explicit user request** ("disable debt-ops here", "turn debt-ops +off", "re-enable debt-ops"). Do not invoke this as part of normal work. + +Call `toggle.py` via Bash. It flips a `disabled` marker in this repo's plugin +cache dir (ADR 0020). While the marker is present, every hook idles silently — +no write-time feedback, no agentStop nudge. Nothing lands in the working tree, +so it never shows up in `git status`. + +The marker is per-machine, not committed — it disables debt-ops for *you* in +*this* repo. The script's stdout IS the confirmation; add no commentary. + +## The call + +```bash +# Toggle (or pass `disable` / `enable` to force a direction): +python3 scripts/toggle.py disable +python3 scripts/toggle.py enable +``` + +Match the user's intent: pass `disable` when they ask to turn it off, `enable` +when they ask to turn it back on. Omit the argument only when they say "toggle." diff --git a/copilot/skills/debt-ops-disable/scripts/toggle.py b/copilot/skills/debt-ops-disable/scripts/toggle.py new file mode 100644 index 0000000..3e7bf56 --- /dev/null +++ b/copilot/skills/debt-ops-disable/scripts/toggle.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""debt-ops disable toggle: flip the per-repo disable sentinel (ADR 0020). + +Writes (or removes) a `disabled` marker in this repo's plugin cache dir. While +the marker is present every hook idles silently — no session inject, no +write-time feedback, no stop nudge. Nothing is written to the working tree. + +With no argument it toggles; pass `disable` or `enable` to force a direction. +""" + +import argparse +import hashlib +import os +import subprocess +import sys +from pathlib import Path + + +def git_toplevel(): + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=True, timeout=2, + ) + s = out.stdout.strip() + return Path(s) if s else None + except (subprocess.SubprocessError, FileNotFoundError): + return None + + +def repo_hash(toplevel): + return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] + + +# Single deterministic cache base, shared with the hooks (override DEBT_OPS_CACHE). +def cache_base(): + override = os.environ.get("DEBT_OPS_CACHE") + return Path(override) if override else (Path.home() / ".cache" / "debt-ops") + + +def parse_args(): + p = argparse.ArgumentParser(description="Toggle debt-ops for this repo.") + p.add_argument("action", nargs="?", choices=["disable", "enable"], + help="force a direction; omit to toggle") + return p.parse_args() + + +def main(): + args = parse_args() + + toplevel = git_toplevel() + if toplevel is None: + sys.stderr.write("debt-ops: not in a git repo\n") + return 2 + + cache_dir = cache_base() / "cache" / repo_hash(toplevel) + sentinel = cache_dir / "disabled" + currently_disabled = sentinel.is_file() + + if args.action == "disable": + want_disabled = True + elif args.action == "enable": + want_disabled = False + else: + want_disabled = not currently_disabled + + if want_disabled: + try: + cache_dir.mkdir(parents=True, exist_ok=True) + sentinel.write_text("", encoding="utf-8") + except OSError as e: + sys.stderr.write(f"debt-ops: could not disable: {e}\n") + return 1 + sys.stdout.write("debt-ops disabled for this repo (run again to re-enable).\n") + else: + try: + sentinel.unlink(missing_ok=True) + except OSError as e: + sys.stderr.write(f"debt-ops: could not re-enable: {e}\n") + return 1 + sys.stdout.write("debt-ops re-enabled for this repo.\n") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + sys.stderr.write(f"debt-ops disable: {e}\n") + sys.exit(1) diff --git a/docs/adr/0020-per-repo-disable-via-cache-sentinel.md b/docs/adr/0020-per-repo-disable-via-cache-sentinel.md new file mode 100644 index 0000000..eff89a0 --- /dev/null +++ b/docs/adr/0020-per-repo-disable-via-cache-sentinel.md @@ -0,0 +1,60 @@ +# 0020 — Disable debt-ops per-repo via a cache sentinel, not a repo file + +**Date:** 2026-06-10 +**Status:** Accepted + +## Context + +There was no way to turn debt-ops off for a single repo. The hooks fire on +every session, edit, and stop, and the only escape was uninstalling the plugin +globally — too blunt when you want it on everywhere except one repo (a vendored +tree, a throwaway spike, a repo whose owner just doesn't want it). + +We needed an on/off switch that is per-repo, easy to flip, and survives across +sessions. The open question was *where the off-state lives*. + +## Decision + +Store the disable flag as a sentinel file `disabled` inside the plugin's +existing per-repo cache dir (`/cache//disabled`) — the same +place `registry-dir`, `feedback.list`, and the turn batch already live. Every +hook already resolves `git_toplevel()` → `cache_dir`; we add one +`repo_disabled(cache_dir)` check right there and idle silently if the sentinel +exists (the same clean exit as "not a git repo"). + +Flipping it is the `/debt-ops:disable` skill (`$debt-ops-disable` on +non-Claude), backed by a small `toggle.py` that resolves the cache the same way +`register.py` does and creates or removes the sentinel, printing the resulting +state. Run it again to re-enable. The skill never auto-invokes — it only runs +on explicit request. + +## What this means for you + +- Disable a repo: run `/debt-ops:disable` (Claude) / `$debt-ops-disable` + (Codex/Copilot/portable). Run it again to re-enable. The hooks go quiet + immediately — no session inject, no write-time feedback, no stop nudge. +- Nothing is written to your working tree. The flag lives in the plugin's + cache, so it never shows up in `git status` or a diff. +- Because the cache is per-machine, the off-state is **per developer machine**, + not shared with the team. Each person who wants it off in a repo flips it + themselves. (See the trade-off below.) +- The explicitly-invoked skills still work while disabled — disabling targets + the automatic hook behaviors, which is what's intrusive. + +## Alternatives we ruled out + +- **A committed repo file** (`.debt-ops-disabled`, a config key). Would make the + decision team-wide and version-controlled, but it puts a file in the user's + tree — the one thing we were asked to avoid. The cache sentinel keeps the + working tree clean at the cost of being per-machine. +- **An env var** (`DEBT_OPS_DISABLE=1`). Easy, but it's per-shell, not + per-repo, and not persistent — you'd forget it's set. It doesn't answer "off + for *this repo*." +- **Claude Code's native per-project plugin disable** (`.claude/settings.json`). + Claude-only (not Codex/Copilot/portable) and still a committed repo file. + +## When to revisit + +If users ask for a *shared*, team-wide per-repo disable, add a committed-file +mechanism alongside this one (the hook check can honor either) — at that point +the working-tree-cleanliness trade-off is worth reopening. diff --git a/skills/debt-ops-disable/SKILL.md b/skills/debt-ops-disable/SKILL.md new file mode 100644 index 0000000..6a6dadc --- /dev/null +++ b/skills/debt-ops-disable/SKILL.md @@ -0,0 +1,28 @@ +--- +name: debt-ops-disable +description: Turn debt-ops off (or back on) for the current repo. Run ONLY when the user explicitly asks to disable, turn off, silence, or re-enable debt-ops here — never auto-invoke. Flips a per-repo marker in the plugin cache; nothing is written to the working tree. +--- + +# debt-ops-disable — turn debt-ops off for this repo + +**Run only on explicit user request** ("disable debt-ops here", "turn debt-ops +off", "re-enable debt-ops"). Do not invoke this as part of normal work. + +Call `toggle.py` via Bash. It flips a `disabled` marker in this repo's plugin +cache dir (ADR 0020). While the marker is present, every hook idles silently — +no session inject, no write-time feedback, no stop nudge. Nothing lands in the +working tree, so it never shows up in `git status`. + +The marker is per-machine, not committed — it disables debt-ops for *you* in +*this* repo. The script's stdout IS the confirmation; add no commentary. + +## The call + +```bash +# Toggle (or pass `disable` / `enable` to force a direction): +python3 scripts/toggle.py disable +python3 scripts/toggle.py enable +``` + +Match the user's intent: pass `disable` when they ask to turn it off, `enable` +when they ask to turn it back on. Omit the argument only when they say "toggle." diff --git a/skills/debt-ops-disable/scripts/toggle.py b/skills/debt-ops-disable/scripts/toggle.py new file mode 100644 index 0000000..3e7bf56 --- /dev/null +++ b/skills/debt-ops-disable/scripts/toggle.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""debt-ops disable toggle: flip the per-repo disable sentinel (ADR 0020). + +Writes (or removes) a `disabled` marker in this repo's plugin cache dir. While +the marker is present every hook idles silently — no session inject, no +write-time feedback, no stop nudge. Nothing is written to the working tree. + +With no argument it toggles; pass `disable` or `enable` to force a direction. +""" + +import argparse +import hashlib +import os +import subprocess +import sys +from pathlib import Path + + +def git_toplevel(): + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=True, timeout=2, + ) + s = out.stdout.strip() + return Path(s) if s else None + except (subprocess.SubprocessError, FileNotFoundError): + return None + + +def repo_hash(toplevel): + return hashlib.sha1(str(toplevel).encode()).hexdigest()[:12] + + +# Single deterministic cache base, shared with the hooks (override DEBT_OPS_CACHE). +def cache_base(): + override = os.environ.get("DEBT_OPS_CACHE") + return Path(override) if override else (Path.home() / ".cache" / "debt-ops") + + +def parse_args(): + p = argparse.ArgumentParser(description="Toggle debt-ops for this repo.") + p.add_argument("action", nargs="?", choices=["disable", "enable"], + help="force a direction; omit to toggle") + return p.parse_args() + + +def main(): + args = parse_args() + + toplevel = git_toplevel() + if toplevel is None: + sys.stderr.write("debt-ops: not in a git repo\n") + return 2 + + cache_dir = cache_base() / "cache" / repo_hash(toplevel) + sentinel = cache_dir / "disabled" + currently_disabled = sentinel.is_file() + + if args.action == "disable": + want_disabled = True + elif args.action == "enable": + want_disabled = False + else: + want_disabled = not currently_disabled + + if want_disabled: + try: + cache_dir.mkdir(parents=True, exist_ok=True) + sentinel.write_text("", encoding="utf-8") + except OSError as e: + sys.stderr.write(f"debt-ops: could not disable: {e}\n") + return 1 + sys.stdout.write("debt-ops disabled for this repo (run again to re-enable).\n") + else: + try: + sentinel.unlink(missing_ok=True) + except OSError as e: + sys.stderr.write(f"debt-ops: could not re-enable: {e}\n") + return 1 + sys.stdout.write("debt-ops re-enabled for this repo.\n") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + sys.stderr.write(f"debt-ops disable: {e}\n") + sys.exit(1)