Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Claude Code `/debt-ops:<name>` · Codex `$<name>`:
- **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.

</details>

Expand Down
9 changes: 9 additions & 0 deletions claude-code/hooks/drop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions claude-code/hooks/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions claude-code/hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions claude-code/hooks/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Expand Down
35 changes: 35 additions & 0 deletions claude-code/skills/disable/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 104 additions & 0 deletions claude-code/skills/disable/scripts/toggle.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions codex/hooks/drop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions codex/hooks/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions codex/hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions codex/hooks/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions codex/skills/disable/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading