diff --git a/hooks/posttool-rename-sweep.py b/hooks/posttool-rename-sweep.py new file mode 100644 index 0000000..a9446e0 --- /dev/null +++ b/hooks/posttool-rename-sweep.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# hook-version: 1.0.0 +""" +PostToolUse Hook: Post-Rename Reference Sweep + +After a `git mv` command, scans .py/.json/.md/.yaml files for stale +references to the old filename stem and prints a warning listing them. + +Design Principles: +- SILENT by default (only speaks when stale references are found) +- Non-blocking (informational only — always exits 0) +- Fast exit path for non-matching commands (<50ms requirement) + +ADR: adr/129-post-rename-reference-sweep.md +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "lib")) +from stdin_timeout import read_stdin + + +def extract_git_mv_paths(command: str): + """Extract (old_path, new_path) from a git mv command string. + + Handles: + - Simple: git mv old new + - With flags: git mv -f old new, git mv --force old new + - Quoted: git mv "old path" "new path" + - Chained: git mv old new && git add . + + Returns None if the pattern is not found. + """ + # Skip optional flags (-f, --force, -k, -n, -v, etc.) before positional args + # Then match either quoted or unquoted path arguments + # Unquoted paths stop at whitespace, semicolons, pipes, and ampersands + pattern = r"git\s+mv\s+(?:-\S+\s+)*(?:(['\"])(.+?)\1|([^\s;&|]+))\s+(?:(['\"])(.+?)\4|([^\s;&|]+))" + match = re.search(pattern, command) + if not match: + return None + # Groups: 1=old_quote, 2=old_quoted_path, 3=old_unquoted_path, + # 4=new_quote, 5=new_quoted_path, 6=new_unquoted_path + old_path = match.group(2) or match.group(3) + new_path = match.group(5) or match.group(6) + return old_path, new_path + + +def grep_for_stem(stem: str, search_root: str) -> list: + """Run grep for stem across .py/.json/.md/.yaml files. + + Returns a list of "file:line: text" strings (may be empty). + Excludes .git/ and __pycache__/. + """ + try: + result = subprocess.run( + [ + "grep", + "-rnF", + "--include=*.py", + "--include=*.json", + "--include=*.md", + "--include=*.yaml", + "--include=*.yml", + "--exclude-dir=.git", + "--exclude-dir=__pycache__", + stem, + search_root, + ], + capture_output=True, + text=True, + timeout=3, + ) + if result.returncode not in (0, 1): + # returncode 1 means no matches (normal), >1 is an error + return [] + lines = [l for l in result.stdout.splitlines() if l.strip()] + return lines + except subprocess.TimeoutExpired: + return [] + except Exception: + return [] + + +def main(): + try: + raw = read_stdin(timeout=5) + if not raw: + return + + try: + data = json.loads(raw) + except json.JSONDecodeError: + return + + tool_name = data.get("tool_name", "") + if tool_name != "Bash": + return + + # Skip failed git mv commands — no rename happened + tool_result = data.get("tool_result", {}) + if tool_result.get("is_error", False): + return + + tool_input = data.get("tool_input", {}) + command = tool_input.get("command", "") + + if "git mv" not in command: + return + + paths = extract_git_mv_paths(command) + if not paths: + return + + old_path, new_path = paths + + # Extract stem: strip directory and extension + stem = Path(old_path).stem + if not stem or len(stem) < 3: + return + + # Search from the repo root if possible, else cwd + search_root = os.getcwd() + + matches = grep_for_stem(stem, search_root) + if not matches: + return + + # Filter out matches in the renamed file itself (new_path) + new_path_abs = str(Path(new_path).resolve()) + filtered = [] + for line in matches: + match_file = line.split(":")[0] + try: + if Path(match_file).resolve() == Path(new_path_abs): + continue + except Exception: + pass + filtered.append(line) + + if not filtered: + return + + max_shown = 20 + print(f'[rename-sweep] Stale references to "{stem}" found after git mv:') + for line in filtered[:max_shown]: + # Make path relative to search_root for readability + try: + rel = os.path.relpath(line.split(":")[0], search_root) + rest = ":".join(line.split(":")[1:]) + print(f" {rel}:{rest}") + except Exception: + print(f" {line}") + if len(filtered) > max_shown: + print(f" ... and {len(filtered) - max_shown} more") + print("Consider updating these references before committing.") + + except Exception as e: + if os.environ.get("CLAUDE_HOOKS_DEBUG"): + import traceback + + print(f"[rename-sweep] HOOK-ERROR: {type(e).__name__}: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + finally: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/hooks/tests/test_posttool_rename_sweep.py b/hooks/tests/test_posttool_rename_sweep.py new file mode 100644 index 0000000..ce9cccd --- /dev/null +++ b/hooks/tests/test_posttool_rename_sweep.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +Tests for the posttool-rename-sweep hook. + +Run with: python3 -m pytest hooks/tests/test_posttool_rename_sweep.py -v +""" + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Import the module under test +# --------------------------------------------------------------------------- + +HOOK_PATH = Path(__file__).parent.parent / "posttool-rename-sweep.py" + +spec = importlib.util.spec_from_file_location("posttool_rename_sweep", HOOK_PATH) +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def run_hook(event: dict) -> tuple[str, str, int]: + """Run the hook with given event and return (stdout, stderr, exit_code).""" + result = subprocess.run( + [sys.executable, str(HOOK_PATH)], + input=json.dumps(event), + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout, result.stderr, result.returncode + + +# --------------------------------------------------------------------------- +# extract_git_mv_paths tests +# --------------------------------------------------------------------------- + + +class TestExtractGitMvPaths: + """Test regex parsing of git mv commands.""" + + def test_simple(self): + assert mod.extract_git_mv_paths("git mv old.py new.py") == ("old.py", "new.py") + + def test_with_directories(self): + assert mod.extract_git_mv_paths("git mv hooks/old.py hooks/new.py") == ( + "hooks/old.py", + "hooks/new.py", + ) + + def test_chained_with_and(self): + assert mod.extract_git_mv_paths("git mv old.py new.py && git add .") == ( + "old.py", + "new.py", + ) + + def test_chained_with_semicolon(self): + assert mod.extract_git_mv_paths("git mv old.py new.py; echo done") == ( + "old.py", + "new.py", + ) + + def test_chained_with_pipe(self): + assert mod.extract_git_mv_paths("git mv old.py new.py | cat") == ( + "old.py", + "new.py", + ) + + def test_flag_f(self): + assert mod.extract_git_mv_paths("git mv -f old.py new.py") == ( + "old.py", + "new.py", + ) + + def test_flag_force(self): + assert mod.extract_git_mv_paths("git mv --force old.py new.py") == ( + "old.py", + "new.py", + ) + + def test_double_quoted_paths(self): + assert mod.extract_git_mv_paths('git mv "old file.py" "new file.py"') == ( + "old file.py", + "new file.py", + ) + + def test_single_quoted_paths(self): + assert mod.extract_git_mv_paths("git mv 'old file.py' 'new file.py'") == ( + "old file.py", + "new file.py", + ) + + def test_no_git_mv(self): + assert mod.extract_git_mv_paths("echo hello") is None + + def test_incomplete_git_mv(self): + assert mod.extract_git_mv_paths("git mv") is None + + def test_git_mv_with_only_one_arg(self): + # git mv with only source, no dest — should not match + assert mod.extract_git_mv_paths("git mv old.py") is None + + +# --------------------------------------------------------------------------- +# Tool name and error filtering +# --------------------------------------------------------------------------- + + +class TestToolFiltering: + """Test that the hook correctly filters by tool name and error state.""" + + def test_non_bash_tool_silent(self): + """Non-Bash tools produce no output.""" + event = { + "tool_name": "Edit", + "tool_input": {"command": "git mv old.py new.py"}, + "tool_result": {}, + } + stdout, stderr, rc = run_hook(event) + assert rc == 0 + assert stdout == "" + + def test_failed_git_mv_silent(self): + """Failed git mv commands (is_error=True) produce no output.""" + event = { + "tool_name": "Bash", + "tool_input": {"command": "git mv nonexistent.py dest.py"}, + "tool_result": {"is_error": True}, + } + stdout, stderr, rc = run_hook(event) + assert rc == 0 + assert stdout == "" + + def test_no_git_mv_in_command_silent(self): + """Bash commands without git mv produce no output.""" + event = { + "tool_name": "Bash", + "tool_input": {"command": "ls -la"}, + "tool_result": {}, + } + stdout, stderr, rc = run_hook(event) + assert rc == 0 + assert stdout == "" + + def test_short_stem_silent(self): + """Stems under 3 characters are skipped to avoid noisy results.""" + event = { + "tool_name": "Bash", + "tool_input": {"command": "git mv a.py b.py"}, + "tool_result": {}, + } + stdout, stderr, rc = run_hook(event) + assert rc == 0 + assert stdout == "" + + +# --------------------------------------------------------------------------- +# Always exits 0 +# --------------------------------------------------------------------------- + + +class TestExitCode: + """Hook must always exit 0 (non-blocking).""" + + def test_empty_stdin(self): + result = subprocess.run( + [sys.executable, str(HOOK_PATH)], + input="", + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0 + + def test_invalid_json(self): + result = subprocess.run( + [sys.executable, str(HOOK_PATH)], + input="not json", + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0 + + def test_valid_bash_no_git_mv(self): + event = { + "tool_name": "Bash", + "tool_input": {"command": "echo hello"}, + "tool_result": {}, + } + stdout, stderr, rc = run_hook(event) + assert rc == 0 diff --git a/pipelines/agent-upgrade/SKILL.md b/pipelines/agent-upgrade/SKILL.md index 7c445ca..2946f47 100644 --- a/pipelines/agent-upgrade/SKILL.md +++ b/pipelines/agent-upgrade/SKILL.md @@ -235,7 +235,20 @@ Proceed with implementation? (or specify which items to include/exclude) **For peer inconsistencies**: - Align to the majority pattern observed across peers. If peers themselves are inconsistent, align to the most recent or highest-scoring peer. -**Step 3**: Do NOT change any of the following without explicit user direction because domain logic changes require deliberate user decision, not opportunistic bundling: +**Step 3**: Run positive framing validation on the modified agent or skill. + +After applying all approved improvements, invoke `joy-check --mode instruction` on the +target file. This ensures that new or modified content uses positive framing per ADR-127. +Positive framing produces instructions agents act on rather than rules they work around. + +If joy-check flags lines in the NEW content (content that was just added/modified): +- Fix those lines as part of this same implementation pass +- Do not flag pre-existing content that was not part of the upgrade scope + +If joy-check flags lines in EXISTING content (not modified by this upgrade): +- Note them in the upgrade report but do not fix them (out of scope for this upgrade) + +**Step 4**: Do NOT change any of the following without explicit user direction because domain logic changes require deliberate user decision, not opportunistic bundling: - Routing triggers (`triggers:` frontmatter field) - Domain coverage statements - Core methodology or phase structure (for skills) diff --git a/pipelines/skill-creation-pipeline/SKILL.md b/pipelines/skill-creation-pipeline/SKILL.md index 93390b7..a10ad02 100644 --- a/pipelines/skill-creation-pipeline/SKILL.md +++ b/pipelines/skill-creation-pipeline/SKILL.md @@ -247,6 +247,7 @@ Breakdown: Reference Files: [N]/10 Error Handling: [N]/15 Content Depth: [N]/30 + Joy-Check: [pending — run Step 3] [If grade A or B:] Gate passed. Proceeding to INTEGRATE. @@ -257,7 +258,25 @@ Breakdown: Iterations remaining: [3 | 2 | 1] ``` -**Step 3**: If grade C or below: +**Step 3**: Run positive framing validation on the generated skill. + +Invoke `joy-check --mode instruction` on `skills/{name}/SKILL.md`. This validates that +the skill's instructions use positive framing (action-based) rather than prohibition-based +language (NEVER, do NOT, FORBIDDEN) per ADR-127. Positive framing makes instructions more +actionable and easier for agents to internalize — prohibitions tell the agent what to avoid +but not what to do instead. + +After running, update the Joy-Check line in the Step 2 report from `[pending]` to +`[PASS]` or `[N lines flagged]`. + +Joy-check is advisory, not blocking — flagged lines do not prevent proceeding to INTEGRATE +when the quality score is ≥75. However, flagged lines should be addressed in the same +iteration as any quality fixes to avoid accumulating framing debt: +- List the specific lines flagged +- If returning to SCAFFOLD for a quality score failure (Step 4), include joy-check fixes + in the same pass — this counts toward the same 3-iteration limit + +**Step 4**: If grade C or below: - List the specific sections that are weak or missing - Return to Phase 3 with explicit instructions to fix those sections - Re-run Phase 4 after the fix diff --git a/skills/pause-work/SKILL.md b/skills/pause-work/SKILL.md index e202ebc..4f82136 100644 --- a/skills/pause-work/SKILL.md +++ b/skills/pause-work/SKILL.md @@ -154,13 +154,79 @@ Capture the session's mental model — the reasoning context that is NOT capture **GATE**: All handoff fields populated with specific, actionable content. No vague entries like "continue work" or "finish implementation." -### Phase 3: WRITE +### Phase 3: EXTRACT LEARNINGS + +**Goal**: Query session learnings from learning.db, filter for architectural decisions that warrant ADRs, and draft ADR skeletons for each candidate. This phase runs before WRITE so that ADR data is available for inclusion in both handoff files — passing extracted data downstream is cheaper than appending to files after the fact. + +**Step 1: Query session learnings** + +```bash +python3 ~/.claude/scripts/learning-db.py query --format json --limit 20 +``` + +`learning-db.py` has no `--since` flag, so query recent entries and filter by recency. Use the `created_at` field in the JSON output to identify entries recorded during this session — the most recent entries are the ones this session produced. + +**Step 2: Filter for ADR candidates** + +Apply this heuristic to determine which learnings describe architectural decisions vs. incidental tips: + +| Learning pattern | ADR candidate? | +|-----------------|----------------| +| "After X, always do Y" | Yes — process decision | +| "X depends on Y" | Yes — contract/coupling | +| "Use A instead of B because C" | Yes — architectural choice | +| "X is faster than Y" | Maybe — only if it changes approach | +| "Use --flag for better output" | No — tip, not decision | + +Keep only entries that describe process changes, tooling contracts, or architectural choices. Tips and incidental observations don't warrant ADRs because they don't reflect decisions that constrain future work — capturing them as ADRs would dilute the ADR corpus and create noise in architecture documentation. + +**Step 3: Draft ADR skeletons** (only if candidates found) + +Get the next safe ADR number once, then increment for subsequent candidates: +```bash +python3 ~/.claude/scripts/adr-query.py next-number 2>/dev/null || echo "manual" +``` + +Call `next-number` once for the first candidate. For additional candidates, increment the number manually (e.g., if first returns 132, use 133 for the second) because the script checks existing files on disk and the first skeleton has not been committed yet. + +If `adr-query.py` returns "manual", use placeholder numbers and note that the user should assign them before merging. + +Draft to `adr/{number}-{slug}.md`: + +```markdown +# ADR-{number}: {Title from learning} + +**Status**: Proposed +**Date**: {today} +**Source**: Auto-extracted from session learning (confidence: {confidence}) + +## Context +{Context derived from the learning entry} + +## Decision +{Decision derived from the learning pattern} + +## Validation Criteria +- [ ] {Criterion derived from the decision} +``` + +Write the file to disk so it is visible in the next session even if Phase 4 fails. + +**Step 4: Pass ADR data to Phase 4** + +Construct a `drafted_adrs` list in memory for use during the WRITE phase: +- If candidates were found: list of `{"number": N, "path": "adr/N-slug.md", "title": "..."}` entries +- If no candidates found: empty list — skip silently, no empty sections in output files + +**GATE**: learning.db queried. Candidates filtered using the decision-vs-tip heuristic. ADR skeleton files written to disk for any candidates found. `drafted_adrs` data available for Phase 4. + +### Phase 4: WRITE **Goal**: Write both handoff files to the project root. This skill only creates files — it only creates files and leaves existing code and git state untouched because it must be safe to invoke repeatedly without side effects. **Step 1: Write HANDOFF.json** -Write to `{project_root}/HANDOFF.json` with UTC ISO 8601 timestamps for unambiguous parsing across time zones and system clocks: +Write to `{project_root}/HANDOFF.json` with UTC ISO 8601 timestamps for unambiguous parsing across time zones and system clocks. Include the `drafted_adrs` field from Phase 3 — omit the field entirely (not null, not `[]`) if no ADRs were drafted, so `/resume-work` can detect absence reliably: ```json { @@ -191,13 +257,16 @@ Write to `{project_root}/HANDOFF.json` with UTC ISO 8601 timestamps for unambigu "base_branch": "main", "false_completions": [ "" + ], + "drafted_adrs": [ + {"number": "", "path": "adr/-.md", "title": ""} ] } ``` **Step 2: Write .continue-here.md** -Write to `{project_root}/.continue-here.md` because humans need prose-form state before committing to `/resume-work`: +Write to `{project_root}/.continue-here.md` because humans need prose-form state before committing to `/resume-work`. Include the ADR section only if `drafted_adrs` is non-empty — an empty section wastes the reader's attention and signals noise: ```markdown # Continue Here @@ -223,8 +292,13 @@ Write to `{project_root}/.continue-here.md` because humans need prose-form state ## Uncommitted work - [file1 — brief description of changes] - [file2 — brief description of changes] + +## ADRs Drafted from Session Learnings +- [ADR-N: Title — adr/N-slug.md] ``` +Omit the "ADRs Drafted from Session Learnings" section entirely when `drafted_adrs` is empty. + **Step 3: Suggest WIP commit if needed** If there are uncommitted changes (from Phase 1 Step 3), display a warning because uncommitted work can be lost if the worktree is cleaned up. However, let the user decide whether to commit because auto-committing removes the user's ability to decide — changes may be experimental, broken, or intentionally staged for review. @@ -250,7 +324,7 @@ git commit -m "chore: session handoff artifacts" **GATE**: Both files written to project root. User notified of uncommitted work if any. -### Phase 4: CONFIRM +### Phase 5: CONFIRM **Goal**: Display summary and confirm handoff was captured. Skip this phase if `--quiet` flag was provided (for automated/scripted usage). @@ -270,6 +344,7 @@ Display the handoff summary: Blockers: N Uncommitted files: N False completions: N placeholder(s) found + ADRs drafted: N Next action: <brief next_action summary> @@ -287,6 +362,10 @@ Display the handoff summary: **Cause**: No commits on current branch, no task_plan.md, no uncommitted changes — nothing to hand off **Solution**: If the session genuinely did no work, there is nothing to hand off. Inform the user: "No work detected to hand off. If you made changes that aren't committed or tracked, describe what you were working on and I'll create the handoff manually." +### Error: learning-db.py query fails +**Cause**: `learning-db.py query` exits non-zero — database not initialized, script missing, or corrupted db file +**Solution**: Skip Phase 3 silently and proceed to Phase 4 with an empty `drafted_adrs` list. The handoff files are the primary deliverable; ADR extraction is a best-effort enhancement. Log a single line in context_notes: "learning-db.py unavailable — ADR extraction skipped." + ### Error: HANDOFF.json Already Exists **Cause**: A previous `/pause` created handoff files that were not yet consumed by `/resume-work` **Solution**: Warn the user that stale handoff files exist. Offer to overwrite (default) or append. Overwriting is almost always correct — stale handoffs from abandoned sessions should not block new ones.