diff --git a/.githooks/pre-push b/.githooks/pre-push index 64b50aab..758df5b9 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -18,6 +18,7 @@ cd "$repo_root" # and it is not a valid PR head (see git.md "PR head branch name convention"). # stdin is the pre-push protocol: ` ` lines. LOCAL_PUSH_ATTEMPTED=0 +HAS_REAL_PUSH=0 LOCAL_STDIN="" while IFS= read -r line; do LOCAL_STDIN="${LOCAL_STDIN}${line} @@ -25,7 +26,19 @@ while IFS= read -r line; do # shellcheck disable=SC2086 set -- $line local_ref="${1:-}" + local_sha="${2:-}" remote_ref="${3:-}" + remote_sha="${4:-}" + + # Skip branch deletions with `continue` (skip THIS ref only). `exit 0` here would + # abort the whole hook on the first delete ref, bypassing the 'local'-guard for any + # non-delete ref in the same push (a mixed delete + 'local' update). Heavy CI is still + # skipped for a delete-only push via the HAS_REAL_PUSH check after the loop. + if [ "$local_sha" = "0000000000000000000000000000000000000000" ] || [ "$local_sha" = "(delete)" ]; then + continue + fi + HAS_REAL_PUSH=1 + if [ "$local_ref" = "refs/heads/local" ] || [ "$remote_ref" = "refs/heads/local" ]; then LOCAL_PUSH_ATTEMPTED=1 fi @@ -52,6 +65,11 @@ fi # contract for future hook chaining.) printf '%s' "$LOCAL_STDIN" +# Delete-only push (every ref was a deletion): nothing to test — skip heavy CI. +if [ "$HAS_REAL_PUSH" = "0" ]; then + exit 0 +fi + echo "Running pre-push checks (CI parity)..." if ! command -v bats >/dev/null 2>&1; then diff --git a/.github/workflows/auto-ready.yml b/.github/workflows/auto-ready.yml index d20855d3..0604ba05 100644 --- a/.github/workflows/auto-ready.yml +++ b/.github/workflows/auto-ready.yml @@ -1,15 +1,27 @@ name: auto-ready -# Automatically converts draft PRs targeting next-feat / next-fix to ready_for_review -# once required checks (branch-tag-adjudication, Test) pass. +# Converts draft PRs *targeting* next-feat / next-fix to ready_for_review once +# branch-tag-adjudication passes. +# +# The `branches:` filter of a workflow_run trigger matches the HEAD branch of +# the run that fired, not the base of its PR. A PR into next-feat/next-fix runs +# its checks on the feature branch (fix/…, feat/…), so filtering on +# [next-feat, next-fix] never matched the PRs this workflow exists to serve — +# it only matched promotion PRs (head next-*, base main), which are exactly the +# ones it should leave alone. Every historical run of this workflow was such a +# misfire. +# +# So: no branch filter, and the base is checked in the job instead. The +# workflow_run payload does carry it — pull_requests[0].base.ref. +# +# Note `Test` is deliberately not listed: it triggers on `pull_request: +# branches: [main]`, so it never runs for a PR into next-feat/next-fix and +# waiting on it here would be waiting on something that cannot arrive. on: workflow_run: - workflows: ["branch-tag-adjudication", "Test"] + workflows: ["branch-tag-adjudication"] types: [completed] - branches: - - next-feat - - next-fix permissions: pull-requests: write @@ -25,6 +37,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + BASE_REF: ${{ github.event.workflow_run.pull_requests[0].base.ref }} run: | set -euo pipefail if [ -z "$PR_NUMBER" ]; then @@ -32,11 +45,30 @@ jobs: exit 0 fi + case "$BASE_REF" in + next-feat|next-fix) ;; + *) + echo "PR #$PR_NUMBER targets '${BASE_REF:-}', not a staging branch — skipping." + exit 0 + ;; + esac + IS_DRAFT=$(gh pr view "$PR_NUMBER" -R "$REPO" --json isDraft --jq '.isDraft') - if [ "$IS_DRAFT" = "true" ]; then - echo "PR #$PR_NUMBER is draft. Converting to ready for review..." - gh pr ready "$PR_NUMBER" -R "$REPO" + if [ "$IS_DRAFT" != "true" ]; then + echo "PR #$PR_NUMBER is already ready for review or not draft." + exit 0 + fi + + echo "PR #$PR_NUMBER (base $BASE_REF) is draft. Converting to ready for review..." + # GITHUB_TOKEN cannot perform markPullRequestReadyForReview even with + # pull-requests: write — it fails with "Resource not accessible by + # integration". Report that plainly instead of failing the run: a red + # X here would say "the checks failed", which is not what happened. + # Until a token that can do it is wired up, the transition is the + # author's to make (github-flow's publish topic runs `gh pr ready`). + if gh pr ready "$PR_NUMBER" -R "$REPO" 2>/tmp/ready.err; then echo "Successfully converted PR #$PR_NUMBER to ready_for_review." else - echo "PR #$PR_NUMBER is already ready for review or not draft." + echo "::warning::Could not convert PR #$PR_NUMBER to ready — $(cat /tmp/ready.err)" + echo "GITHUB_TOKEN lacks markPullRequestReadyForReview. Convert manually or wire a token that can." fi diff --git a/.github/workflows/branch-tag-adjudication.yml b/.github/workflows/branch-tag-adjudication.yml index 2e4a64ce..d1f4516a 100644 --- a/.github/workflows/branch-tag-adjudication.yml +++ b/.github/workflows/branch-tag-adjudication.yml @@ -10,6 +10,9 @@ name: branch-tag-adjudication # earlier PR, possibly not yet cascaded to main). # - next-fix: fix commits allowed. chore commits allowed ONLY when the same PR # also contains a fix commit touching the same skill. feat is always REJECTED. +# - refactor: allowed as a primary tag on BOTH branches. A refactor is a +# behaviour-preserving restructure (e.g. a skill rename) that drives no +# release bump, so it needs no same-skill feat/fix rider context. # - Any commit touching skills/**/*.md with tag chore or docs is REJECTED — # skill md changes must use fix: (or feat: when accompanied by a new topic # file). Skill md = behaviour surface, not documentation. @@ -186,6 +189,10 @@ jobs: feat) # Always OK — this is the intended tag for this branch. ;; + refactor) + # Behaviour-preserving restructure — allowed as a primary + # tag on both staging branches (drives no release bump). + ;; fix|chore) # Allowed when the same PR contains a feat commit for the # same skill (i.e. we are shipping a topic addition and a @@ -199,7 +206,7 @@ jobs: fi ;; *) - echo "::error::next-feat branch: unsupported tag '$TAG' on commit $SHA (skill=$SKILL). Allowed tags: feat (primary), fix/chore (as rider when feat present)." + echo "::error::next-feat branch: unsupported tag '$TAG' on commit $SHA (skill=$SKILL). Allowed tags: feat (primary), refactor, fix/chore (as rider when feat present)." FAIL=1 ;; esac @@ -209,6 +216,10 @@ jobs: fix) # Always OK. ;; + refactor) + # Behaviour-preserving restructure — allowed as a primary + # tag on both staging branches (drives no release bump). + ;; chore) if [ "${PR_SKILL_HAS[${SKILL}__fix]:-0}" != "1" ]; then echo "::error::next-fix branch: commit $SHA (skill=$SKILL, tag=$TAG) rejected. Same-skill fix commit not present in this PR." @@ -220,7 +231,7 @@ jobs: FAIL=1 ;; *) - echo "::error::next-fix branch: unsupported tag '$TAG' on commit $SHA (skill=$SKILL). Allowed tags: fix (primary), chore (as rider when fix present)." + echo "::error::next-fix branch: unsupported tag '$TAG' on commit $SHA (skill=$SKILL). Allowed tags: fix (primary), refactor, chore (as rider when fix present)." FAIL=1 ;; esac @@ -236,6 +247,7 @@ jobs: ::error::One or more commits violate the staging-branch tag policy. Policy summary: next-feat accepts feat (fix/chore only as same-skill riders); next-fix accepts fix (chore only as a same-skill rider; feat never); + refactor is accepted as a primary tag on both branches; skill md changes must be tagged fix: or feat: on either branch. EOF exit 1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4786a96f..48f0fdb0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,3 +59,13 @@ jobs: steps: - uses: actions/checkout@v4 - run: bash scripts/lint-frontmatter.sh + + hooks-json-lint: + name: Verify hooks.json (no duplicates, no ghost paths) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 scripts/verify-hooks-json.py diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 9f7ceeed..3c3e6639 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,26 +1,26 @@ { - "skills/cc-plugin": "0.5.3", + "skills/cc-plugin": "0.6.0", "skills/chezmoi": "0.4.2", "skills/choco": "1.0.4", - "skills/claude-session": "0.8.1", - "skills/claudify": "0.6.1", - "skills/code-workflow": "0.6.3", - "skills/commit-tidy": "0.5.4", - "skills/consolidate": "0.5.4", - "skills/dotfile": "0.5.1", - "skills/fix": "0.3.12", - "skills/fix-plan": "0.8.0", + "skills/claudify": "0.7.0", + "skills/code-workflow": "0.7.0", + "skills/commit-tidy": "0.5.5", + "skills/consolidate": "0.6.0", + "skills/dotfile": "0.6.0", + "skills/fix": "0.4.0", + "skills/fix-plan": "0.9.0", "skills/forge": "0.1.2", - "skills/git-repo": "0.8.1", - "skills/github-flow": "0.8.3", + "skills/git-repo": "0.9.0", + "skills/github-flow": "0.9.0", "skills/harness": "0.1.2", "skills/mcp-config": "0.3.2", - "skills/next": "0.8.1", + "skills/next": "0.9.0", "skills/omz": "0.3.2", "skills/repo": "0.3.2", - "skills/skill-kit": "0.6.3", + "skills/session": "0.8.1", + "skills/skill-kit": "0.7.0", "skills/tdd": "0.3.3", "skills/todowrite": "0.8.1", "skills/web-browser": "0.2.7", - "skills/wip": "0.4.5" + "skills/wip": "0.5.0" } diff --git a/hooks/hooks.json b/hooks/hooks.json index 5cc95117..a47e2cee 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -132,12 +132,20 @@ { "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/skills/consolidate/resources/block-noncompliant-review-comment.sh" + }, + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/skills/consolidate/resources/block-summary-fabricated-claims.sh" } ] }, { "matcher": "Edit", "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/skills/fix-plan/resources/block-direct-checklist-edit.js" + }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/complex-logic-guard.sh" @@ -160,7 +168,7 @@ }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/block-wip-register-before-execute.sh" + "command": "${CLAUDE_PLUGIN_ROOT}/skills/wip/resources/block-wip-register-before-execute.sh" }, { "type": "command", @@ -179,6 +187,10 @@ { "matcher": "Write", "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/skills/fix-plan/resources/block-direct-checklist-edit.js" + }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/staged-protect.sh" @@ -193,7 +205,7 @@ }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/block-wip-register-before-execute.sh" + "command": "${CLAUDE_PLUGIN_ROOT}/skills/wip/resources/block-wip-register-before-execute.sh" }, { "type": "command", @@ -231,7 +243,7 @@ }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/block-cleanup-option-below-context-gate.sh", + "command": "${CLAUDE_PLUGIN_ROOT}/skills/cleanup/resources/block-cleanup-option-below-context-gate.sh", "timeout": 10 }, { @@ -242,6 +254,11 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/block-ask-without-token-measure.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/block-squash-recommend-multi-commit.sh", + "timeout": 15 } ] }, @@ -312,11 +329,11 @@ }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/block-cleanup-without-rag.sh" + "command": "${CLAUDE_PLUGIN_ROOT}/skills/cleanup/resources/block-cleanup-without-rag.sh" }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/block-cleanup-without-claudify.sh" + "command": "${CLAUDE_PLUGIN_ROOT}/skills/cleanup/resources/block-cleanup-without-claudify.sh" }, { "type": "command", @@ -341,7 +358,7 @@ }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/block-cleanup-missing-rename.sh" + "command": "${CLAUDE_PLUGIN_ROOT}/skills/cleanup/resources/block-cleanup-missing-rename.sh" } ] } diff --git a/release-please-config.json b/release-please-config.json index e4ea5b54..1f4abd15 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -82,16 +82,6 @@ } ] }, - "skills/claude-session": { - "package-name": "claude-session", - "component": "claude-session", - "extra-files": [ - { - "type": "generic", - "path": "SKILL.md" - } - ] - }, "skills/claudify": { "package-name": "claudify", "component": "claudify", @@ -242,6 +232,16 @@ } ] }, + "skills/session": { + "package-name": "session", + "component": "session", + "extra-files": [ + { + "type": "generic", + "path": "SKILL.md" + } + ] + }, "skills/skill-kit": { "package-name": "skill-kit", "component": "skill-kit", diff --git a/scripts/verify-hooks-json.py b/scripts/verify-hooks-json.py new file mode 100644 index 00000000..b9147c75 --- /dev/null +++ b/scripts/verify-hooks-json.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +Verify hook registrations in every hooks.json in this repository. + +Two independent failure classes are checked: + +1. Duplicate registration — the same (event, matcher, script-basename) registered + more than once. The hook then fires twice on every trigger. + +2. Ghost registration — a command references a script path that does not exist. + The hook dies with exit 127, which the harness cannot distinguish from + "hook ran and raised no objection": the guard silently enforces nothing while + still appearing in the registration list. This is how relocating a script + without updating its registration disables a guard with no visible signal. + +Ported from es6kr/claude-plugins (PR #23, merged) — that repo hit this exact +gap: a hook-relocation commit moved a script into another skill's resources/ +without updating hooks/hooks.json's registration path, silently disabling the +guard with no signal. es6kr/skills hit the identical class independently +(hooks.json#L43 audit, 2026-08-18) — 3 ghost paths found by manual grep before +this CI check existed. This script is that PR's verify-hooks-json.py, adapted +to this repo (structurally generic already — no es6kr/claude-plugins-specific +assumptions beyond the shared hooks/hooks.json + ${CLAUDE_PLUGIN_ROOT} layout +convention both repos use). +""" + +import json +import re +import sys +from pathlib import Path + +# `${CLAUDE_PLUGIN_ROOT}/a/b.sh`, `"${CLAUDE_PLUGIN_ROOT}/a/b.sh"`, `$CLAUDE_PLUGIN_ROOT/a/b.sh` +# +# The path ends differently depending on whether the reference is quoted, and getting +# that wrong produces a FALSE ghost report rather than a missed one: a truncated path +# does not exist, so a legitimate registration would fail CI. So when an opening quote +# precedes the reference, the path runs to the matching close quote (this is the only +# form that can legally contain spaces); otherwise it ends at whitespace, a quote, or +# a shell metacharacter that terminates the word (`;` `&` `|` `)`). +PLUGIN_ROOT_REF = re.compile( + r'(?P["\'])?\$\{?CLAUDE_PLUGIN_ROOT\}?(?P/.*?)(?(q)(?P=q)|(?=["\'\s;&|)]|$))' +) + + +def extract_script_basename(cmd: str) -> str: + # Match script basename like foo.sh, bar.py, baz.js + m = re.search(r'([a-zA-Z0-9_-]+\.(?:sh|py|js|ts))\b', cmd) + if m: + return m.group(1) + # No script token — an inline shell command. The whole command becomes the + # duplicate key, which is what we want: registering the same inline command twice + # under one (event, matcher) is as much a duplicate as registering a script twice. + return cmd.strip() + + +def extract_plugin_root_paths(cmd: str) -> list: + """Return every ${CLAUDE_PLUGIN_ROOT}-relative path referenced by a command.""" + return [m.group("path").lstrip("/") for m in PLUGIN_ROOT_REF.finditer(cmd)] + + +def plugin_root_for(filepath: Path) -> Path: + """Resolve ${CLAUDE_PLUGIN_ROOT} for a given hooks.json. + + A plugin's hooks live at /hooks/hooks.json, so the plugin root is + always the grandparent — for the repo-root plugin (source "./") that is the + repository root, for plugins/ it is that plugin's directory. + """ + return filepath.parent.parent + + +def iter_commands(data: dict): + """Yield (event, matcher, command) for every registered hook.""" + for event, entries in data.get("hooks", {}).items(): + if not isinstance(entries, list): + continue + for entry in entries: + matcher = entry.get("matcher", "*") + for hook in entry.get("hooks", []): + yield event, matcher, hook.get("command", "") + + +def check_hooks_file(filepath: Path) -> tuple: + """Return (errors, checked_paths, skipped_commands) for one hooks.json.""" + if not filepath.is_file(): + return [], 0, 0 + + try: + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + return [f"Failed to parse JSON in {filepath}: {e}"], 0, 0 + + errors = [] + seen = set() + root = plugin_root_for(filepath) + checked_paths = 0 + skipped_commands = 0 + + for event, matcher, cmd in iter_commands(data): + # 1. duplicate registration + script = extract_script_basename(cmd) + key = (event, matcher, script) + if key in seen: + errors.append( + f"Duplicate hook registration in {filepath}: event='{event}', " + f"matcher='{matcher}', script='{script}' (command: {cmd})" + ) + seen.add(key) + + # 2. ghost registration + rel_paths = extract_plugin_root_paths(cmd) + if not rel_paths: + # Inline shell or an absolute/other-variable path — not resolvable here. + skipped_commands += 1 + continue + for rel in rel_paths: + checked_paths += 1 + if not (root / rel).is_file(): + errors.append( + f"Ghost hook registration in {filepath}: event='{event}', " + f"matcher='{matcher}' points at a missing script " + f"'{rel}' (resolved: {root / rel})" + ) + + return errors, checked_paths, skipped_commands + + +def collect_hooks_files(root: Path) -> list: + files = [root / "hooks" / "hooks.json"] + # Nested plugin/skill hook files. es6kr/skills currently has one hooks.json + # at the repo root (the repo-root plugin owns it), but keep the globs so a + # nested hooks.json under plugins/ or skills/ is covered the + # day one appears — mirrors the source repo's own forward-looking comment. + files.extend(root.glob("plugins/**/hooks/hooks.json")) + files.extend(root.glob("skills/**/hooks/hooks.json")) + return [f for f in files if f.is_file()] + + +def main(): + root = Path(__file__).resolve().parent.parent + + all_errors = [] + total_checked = 0 + total_skipped = 0 + for fp in collect_hooks_files(root): + errs, checked, skipped = check_hooks_file(fp) + all_errors.extend(errs) + total_checked += checked + total_skipped += skipped + + # Report coverage unconditionally, so a check that verified nothing is visible + # rather than reading as a pass. + print( + f"Resolved {total_checked} ${{CLAUDE_PLUGIN_ROOT}} script path(s); " + f"{total_skipped} command(s) had no resolvable path and were skipped." + ) + + if all_errors: + print("❌ Found hook registration problems:") + for err in all_errors: + print(f" - {err}") + sys.exit(1) + + print("✅ No duplicate or ghost hook registrations found in hooks.json files.") + + +if __name__ == "__main__": + main() diff --git a/skills/cc-plugin/CHANGELOG.md b/skills/cc-plugin/CHANGELOG.md index baa68a61..a6f94c86 100644 --- a/skills/cc-plugin/CHANGELOG.md +++ b/skills/cc-plugin/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.6.0](https://github.com/es6kr/skills/compare/cc-plugin-v0.5.3...cc-plugin-v0.6.0) (2026-08-20) + + +### Features + +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + ## [0.5.3](https://github.com/es6kr/skills/compare/cc-plugin-v0.5.2...cc-plugin-v0.5.3) (2026-08-17) diff --git a/skills/claudify/CHANGELOG.md b/skills/claudify/CHANGELOG.md index 7d6e59b3..86ef8d47 100644 --- a/skills/claudify/CHANGELOG.md +++ b/skills/claudify/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.7.0](https://github.com/es6kr/skills/compare/claudify-v0.6.1...claudify-v0.7.0) (2026-08-20) + + +### Features + +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + ## [0.6.1](https://github.com/es6kr/skills/compare/claudify-v0.6.0...claudify-v0.6.1) (2026-08-17) diff --git a/skills/claudify/SKILL.md b/skills/claudify/SKILL.md index cb733055..9b17353c 100644 --- a/skills/claudify/SKILL.md +++ b/skills/claudify/SKILL.md @@ -185,7 +185,7 @@ Skill tool: skill: "project-automation:skill-writer" 1. Manual sync to cache, OR 2. New session to reload -**Auto-sync hook**: `plugin-cache-sync.sh` syncs marketplace to cache on Edit/Write +**No auto-sync hook currently exists** — verified 2026-08-18: no `plugin-cache-sync.sh` is registered in `settings.json`/`settings.local.json`, and no live copy of the script exists outside a Syncthing version-history backup. A marketplace checkout's `installPath` in `~/.claude/plugins/installed_plugins.json` can point at a cache directory that either does not exist on disk (harness apparently falls back to reading the marketplace source directly in that case) or exists but has drifted stale relative to the marketplace checkout — see `hook-kit/audit.md` Step 3-D for the detection procedure. Until that gap is closed, manually verify (`diff` the marketplace source against the cache `installPath`) after editing any plugin's hook scripts, rather than assuming this auto-sync exists. ## Output Guidelines diff --git a/skills/cleanup/rag-store.md b/skills/cleanup/rag-store.md index 583e1fe5..73a855c7 100644 --- a/skills/cleanup/rag-store.md +++ b/skills/cleanup/rag-store.md @@ -154,7 +154,11 @@ A generic `mcp____*-store` MCP call (medium 1) writes one arbitrary text ### Procedure 1. **Bulk-sync Completed section (script)**: run vendor-provided fix_plan → RAG sync script -2. After sync success, delete `## Completed` body (keep empty header) +1.5. **Plane-completion-first gate (HARD STOP — before any deletion)**: this is the *deletion* direction of the same canonical-medium principle that already gates the completion-flip direction (`cleanup/run.md` Step 0 "Plane-indexed item completion order") and the creation direction (`fix-plan/add.md` "Canonical medium gate"). If this workspace has adopted Plane as canonical for the item being deleted (its `fix_plan.md` line carries a `→ Plane ()` index suffix, or the item's project matches a `workspace_profile.py --json` non-empty `plane_host`), do NOT delete the local body yet: + - The local `## Completed` text is the only trace that work happened — RAG-syncing it (step 1) preserves it for *search*, but does not make it *canonical-record complete*. Deleting it before Plane reflects completion leaves the canonical backlog silently behind the actual state, with no local record left to reconcile from. + - Verify each Plane-indexed item's issue state: already complete → proceed to step 2 for that item. Not yet complete / no Plane issue exists at all → complete it (or register via intake if none exists) **before** deleting that item's local text. + - Items with no Plane linkage (this workspace/project doesn't use Plane, or the item was never indexed) skip this gate — proceed directly to step 2. +2. After sync success (and, for any Plane-indexed items, after the 1.5 gate clears), delete `## Completed` body (keep empty header) 3. **Other-section body compression (manual RAG store)**: if compression would lose body content, call `mcp____*-store` first. Include 4-6 metadata keys: `{type: troubleshooting|decision|infra-finding, project: , date: YYYY-MM-DD, category: , source: fix_plan-L, status: archived}`. Only after store success, run Edit to compress ### Don't / Do @@ -168,12 +172,14 @@ A generic `mcp____*-store` MCP call (medium 1) writes one arbitrary text | 14 | Report "RAG obligation done" after 1 script sync then compress other-section `[x]` items | Script parses **Completed section only**. Other-section `[x]` handling + body-loss cases require separate manual RAG store. Report both "script: N + manual: M" counts | | 15 | "If user concludes 'no further action needed', the body can be cleaned up too" reasoning | Conclusion and body preservation are separate. User conclusion = **state decision**; body = **troubleshooting steps / primary source / commit history** with future value. Conclusion = `[x]` processing; body = RAG store then compress | | 16 | Treat oversized `[x]` items as safe to ignore because they're "not in `## Completed`" | The sync script's Completed-only scope is a tooling gap, not a signal that inline `[x]` bloat is fine. At wrap-up, also scan top-level `- [x]` items outside `## Completed` for size — condense the same way | +| 17 | Delete a synced `## Completed` body without checking whether any item is Plane-indexed | RAG sync (step 1) is a search-index write, not a canonical-record write. A Plane-indexed item still needs its Plane issue completed (or intake-registered if missing) before its local text — the only remaining trace of the work — is deleted (step 1.5) | ### Self-Check (every session start / end + every time before fix_plan Edit) 1. Does `fix_plan.md` contain `- [x]` items? — if yes, run sync script 2. If yes, was the sync executed? 3. After sync success, were the items removed from `fix_plan.md`? +3.5. **Before deleting, does any item about to be removed carry a `→ Plane ()` suffix (or match a `plane_host`-configured project)?** — if yes, verify/complete that Plane issue (or register via intake) first; only delete once the 1.5 gate clears for that item 4. **Are you about to Edit fix_plan to compress/merge/remove items?** — if yes, run self-checks 5-7 5. Does the body to be compressed contain sub-bullets (options / verification medium / primary source / user-decision commit SHA / hold work / related plan refs) that would be lost? 6. If yes, did you call `mcp____*-store` **before** the Edit? Include source location (fix_plan-L) + type/project/date/category metadata? diff --git a/skills/cleanup/resources/block-cleanup-missing-rename.sh b/skills/cleanup/resources/block-cleanup-missing-rename.sh index f738bb26..96cca96b 100644 --- a/skills/cleanup/resources/block-cleanup-missing-rename.sh +++ b/skills/cleanup/resources/block-cleanup-missing-rename.sh @@ -33,6 +33,12 @@ fi # more likely to be headed "cleanup complete" / "cleanup pass 2 complete" than # to repeat the literal invocation "/cleanup run", and those natural headings # previously matched nothing here, so the guard never even entered its checks. +# Additive, not override (`:+…|` rather than `:-`). The locale data file is sourced +# first, so with `:-` its value REPLACED everything below and the committed markers +# became dead code on any machine that has the file — `Session Cleanup` here was +# live in the repo and silently absent in practice. Which set wins should not depend +# on whether an untracked file happens to exist. Union keeps the committed baseline +# authoritative and lets the git-ignored file only ADD locale variants. HG_CLEANUP_MARKERS="${HG_CLEANUP_MARKERS:+${HG_CLEANUP_MARKERS}|}(^|[[:space:]])/cleanup|cleanup run|cleanup wrap-up|cleanup complete|cleanup pass|cleanup finished|Session Ended|Session Cleanup|session-end report" HG_SESSION_ID_MARKERS="${HG_SESSION_ID_MARKERS:+${HG_SESSION_ID_MARKERS}|}Session ID:|session[[:space:]]+id:" # Words that, together with a markdown table, mark a response as the completion diff --git a/skills/cleanup/resources/block-cleanup-without-rag.sh b/skills/cleanup/resources/block-cleanup-without-rag.sh index 5884ceb3..1eef4145 100755 --- a/skills/cleanup/resources/block-cleanup-without-rag.sh +++ b/skills/cleanup/resources/block-cleanup-without-rag.sh @@ -18,10 +18,11 @@ # reminder every turn only adds noise the headless agent may fixate on, so pass silently. if [[ "${RALPH_LOOP:-}" == "1" ]]; then exit 0; fi -# Load locale-specific regex patterns from data/. The file is git-ignored so -# the public repo never sees Korean characters. When absent, cleanup detection -# falls back to English-only markers. -HG_DATA_FILE="$(dirname "$0")/../data/hangul-patterns.regex" +# Load locale-specific regex patterns from hook-kit/data/. The file is git-ignored +# so the public repo never sees Korean characters. When absent, cleanup detection +# falls back to English-only markers. The path stays hook-kit-relative because the +# regex data did not move when this guard was relocated into the cleanup skill. +HG_DATA_FILE="$(dirname "$0")/../../hook-kit/data/hangul-patterns.regex" if [ -f "$HG_DATA_FILE" ]; then # shellcheck source=/dev/null . "$HG_DATA_FILE" diff --git a/skills/cleanup/scripts/test-fa-classify-meta.py b/skills/cleanup/scripts/test-fa-classify-meta.py new file mode 100644 index 00000000..f2b2de99 --- /dev/null +++ b/skills/cleanup/scripts/test-fa-classify-meta.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""TDD test for fa-classify.py meta-field parser (Phase 2, backward-compatible). + +Run: uv run python scripts/test-fa-classify-meta.py +Exits non-zero on any assertion failure. No external test framework (the skill +ships no test deps); plain asserts keep it runnable in a fresh shell. +""" +import importlib.util +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +spec = importlib.util.spec_from_file_location( + "fa_classify", os.path.join(HERE, "fa-classify.py") +) +fa = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fa) + +failures = [] + + +def check(name, cond): + if cond: + print(f"ok - {name}") + else: + print(f"FAIL - {name}") + failures.append(name) + + +# 1. parse_section_meta extracts key=value fields +m = fa.parse_section_meta( + "## title\n\nbody" +) +check("meta parsed to dict", m is not None) +check("meta class field", m and m.get("class") == "ask-detour") +check("meta count field", m and m.get("count") == "6") +check("meta last field", m and m.get("last") == "2026-07-06") +check("meta status field", m and m.get("status") == "hook-pending") +check("meta hooks field", m and m.get("hooks") == "block-without-guards-skill.sh") + +# 2. no meta -> None (fallback to heuristic) +check("no meta returns None", fa.parse_section_meta("## title\nbody no meta") is None) + +# 3. meta status classification helpers (deterministic replacement of regex) +check("hook-active is resolved", fa.meta_is_resolved("hook-active") is True) +check("guard-added is resolved", fa.meta_is_resolved("guard-added") is True) +check("fixed is resolved", fa.meta_is_resolved("fixed") is True) +check("rule-covered is resolved", fa.meta_is_resolved("rule-covered") is True) +check("hook-pending not resolved", fa.meta_is_resolved("hook-pending") is False) +check("watch not resolved", fa.meta_is_resolved("watch") is False) + +# 4. analyze() honours meta: a hook-pending section stays blocked (not cold), +# a rule-covered section that is old becomes cold-eligible via meta. +import tempfile + +sample = ( + "# Failed Attempts\n\n" + "## pending-risk\n" + "\n" + "- 3rd occurrence, guard not yet built.\n\n" + "## resolved-old\n" + "\n" + "- rule added long ago.\n\n" + "## legacy-no-meta\n" + "- plain heuristic section (2026-01-01), 5회차 재발.\n" +) +with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f: + f.write(sample) + tmp = f.name +rows = fa.analyze(tmp, cutoff="2026-06-01") +by_title = {r["title"]: r for r in rows} +check("pending-risk blocked (not cold)", by_title["pending-risk"]["cold"] is False) +check("pending-risk via meta", by_title["pending-risk"].get("via_meta") is True) +check("resolved-old is cold (old + resolved via meta)", by_title["resolved-old"]["cold"] is True) +check("legacy-no-meta falls back to heuristic (recur -> blocked)", + by_title["legacy-no-meta"].get("via_meta") in (False, None)) +os.unlink(tmp) + +if failures: + print(f"\n{len(failures)} FAILED") + sys.exit(1) +print("\nall passed") diff --git a/skills/code-workflow/CHANGELOG.md b/skills/code-workflow/CHANGELOG.md index 5eb6bfdd..3a9caa14 100644 --- a/skills/code-workflow/CHANGELOG.md +++ b/skills/code-workflow/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.7.0](https://github.com/es6kr/skills/compare/code-workflow-v0.6.3...code-workflow-v0.7.0) (2026-08-20) + + +### Features + +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + + +### Bug Fixes + +* **code-workflow:** record that the plan-edit trigger duplicates plan-guard ([aba3eeb](https://github.com/es6kr/skills/commit/aba3eeb30a1a9b4cde9235020bcbf01643ae0696)) +* **hook-kit:** stop topic dispatch from resolving into nested worktrees ([e81bb4c](https://github.com/es6kr/skills/commit/e81bb4c2aa6e817318950de8ae53b475dc2999aa)) +* promote next-fix batch (hook path repair, topic-dispatch scoping, conflict diagnosis) ([eb7ecb6](https://github.com/es6kr/skills/commit/eb7ecb61dda9701d78f12dc810781dc7cb687caa)) + ## [0.6.3](https://github.com/es6kr/skills/compare/code-workflow-v0.6.2...code-workflow-v0.6.3) (2026-08-17) diff --git a/skills/code-workflow/SKILL.md b/skills/code-workflow/SKILL.md index 6288a61a..00628be0 100644 --- a/skills/code-workflow/SKILL.md +++ b/skills/code-workflow/SKILL.md @@ -48,7 +48,9 @@ TDD is the default implementation discipline. When breaking down tasks in `task. ### Walkthrough Topic-Based Slug Policy (HARD STOP) -When creating or copying `walkthrough.md` artifacts to `llm-wiki/generated/`, **NEVER use generic date filenames (e.g. `walkthrough-2026-07-22.md`)**. Always name the file using a descriptive topic-based slug matching the core feature, issue, or plan (e.g. `llm-wiki/generated/walkthrough-agent-lifecycle-abstraction.md`). +Write `walkthrough.md` artifacts to the configured `output-dir` (resolved via `WSCFG_ARTIFACTS_PATH`, default `.agents/docs/generated`) — the same destination the Configuration table above already assigns to research and plan files. Do not hardcode a wiki path: a walkthrough is a tool working note, and promoting one to the LLM Wiki goes through `raw-ingest` after the knowledge-sharing ask in [steps.md](./steps.md), never a direct write. + +**NEVER use generic date filenames (e.g. `walkthrough-2026-07-22.md`)**. Always name the file using a descriptive topic-based slug matching the core feature, issue, or plan (e.g. `{output-dir}/walkthrough-agent-lifecycle-abstraction.md`). ### Trade-off Decision Ask (MANDATORY Plan Post-Write HARD STOP) diff --git a/skills/code-workflow/plan-research-search.md b/skills/code-workflow/plan-research-search.md index 54059bf6..9d6c931c 100644 --- a/skills/code-workflow/plan-research-search.md +++ b/skills/code-workflow/plan-research-search.md @@ -42,6 +42,7 @@ Run all of the above items as **separate queries** (no single-prefix matching; m | 7 | Force only the `plan-*` prefix in Glob (e.g., `find -name 'plan-*blueprint*'`) — missing other format files (research-, analysis-, `-drift.md`, `.md`) | Search using keyword matching regardless of prefix: `find -iname '**.md'` or `Glob('**/.ralph/docs/generated/**')`. Use keyword-only matching to include all formats such as `plan-/research-/analysis-/...-drift.md` | | 8 | Skip Read for any Glob/find result by guessing "probably not directly related to this task" | **Mandatory Read for every found file**. No guessing. Determine irrelevance only after reading the body | | 9 | Search only one keyword from the task description (filename/tool name) and assert plan absence on 0 results | **Exhaustively extract** all domain keywords from the task description and run a separate Glob for each. Also search code names from user messages (e.g., `Web-E2E-B`) as separate keywords | +| 10 | Conclude "cannot access this — need an external channel (mail auth, etc.) or ask the user to paste it" before searching fix_plan.md / docs for the domain keyword | Domain keywords apply even without a `#N` or GitHub context — a business/administrative document rewrite request (email, report) is an entry action too. Search fix_plan.md and `docs/generated/` for the subject/topic keyword first; the tracker often already records the correct access path (a different account, a CLI tool, a prior message's content) | ## Self-Check (every time on task entry — including query actions) @@ -52,3 +53,4 @@ Run all of the above items as **separate queries** (no single-prefix matching; m 5. Were all found files Read? — No skip-by-guess if even one file was found 6. Was plan absence explicitly confirmed? (empty results for all keywords = may be cited as primary source) 7. Were unfinished items identified? Were they registered as separate tasks or in the fix_plan.md BLOCKED section? +8. About to say "cannot access this / need an external channel / please paste it"? — Search fix_plan.md and `docs/generated/` for the subject keyword FIRST. This applies to non-GitHub business/administrative requests too (e.g., "rewrite this email"), not just `#N` tasks. diff --git a/skills/code-workflow/steps.md b/skills/code-workflow/steps.md index b7dc6e29..39cdb09e 100644 --- a/skills/code-workflow/steps.md +++ b/skills/code-workflow/steps.md @@ -57,6 +57,18 @@ The core 4-stage procedure (Steps 0-3). Step 4 (Implement) is in [implement.md]( **Prohibited**: Deciding the next action by only looking at task checklist items without reading the research/plan files. A task list is a summary; the plan file contains detailed sequences and constraints. +## Milestone Checklist Sync Discipline (HARD STOP) + +When executing multi-phase workflows (`/code-workflow`, `/fix-plan add --deep`), 3 checklist surfaces exist: +1. `task.md` (active in-session real-time tracker in `brain//task.md`): Used by IDE/CLI to show real-time progress steps. +2. `plan-*.md` (the permanent plan artifact's Layered Roadmap / Progress Checklist): Section 3 of the plan document. +3. `fix_plan.md` / `checklist.md` (the workspace global backlog under `.agents/fix_plan.md`): Global task tracker. + +**Compromise Rule (Milestone-Boundary Sync)**: +- **Micro-steps**: Sub-step execution (tool calls, individual file edits) is tracked solely in `task.md` to prevent tool call churn and token budget waste. +- **Phase & Milestone Boundaries**: At major boundaries (Phase 2 Plan authoring complete, Phase 3 User Review approved, Phase 4 TDD implementation complete, Phase 5 verification complete), the agent MUST synchronize the progress across `plan-*.md` (marking completed phases/items as `[x]`) and `fix_plan.md` (updating task status and audit/completion metadata). +- **Phase-Completion Mandatory Commit Gate (HARD STOP)**: At the completion of each implementation Phase (e.g. Phase 1-B) or independent logical unit, the agent MUST review modified files, analyze commit split discipline via `commit-tidy`, and execute the Pre-Commit Verification Ask (`AskUserQuestion`) to secure user approval and commit changes before moving to the next Phase or suggesting `/next`. +- **Session Wrap-up (`/cleanup`)**: Final reconciliation ensures all 3 surfaces are 100% consistent. **Examples**: - Even if `[ ] PR #278 merge` is in the task list, if the plan states "Phase 1 Unit Test → Phase 2 Proxy Test → ... → PR merge", start from Phase 1 - Simple items with no plan file can be proceeded immediately @@ -71,6 +83,7 @@ Read and understand the relevant code **deeply**, then write findings to `{outpu - **Mandatory Corpus & RAG Pre-Lookup (HARD STOP)**: Before writing the research document, check existing project knowledge stores and memory backends (LLM Wiki, Qdrant vector memory, Serena RAG, KI summaries) to prevent duplicate analysis and ground findings in established patterns. - **Grounded command (optional, abstract contract — same shape as "Research artifact dispatch" below)**: when the caller supplies `--pre-lookup=:`, invoke that skill's topic with the task subject before writing, and embed its output as a `## Prior Knowledge & Context` section at the top of `research-*.md` — the embedded section is the auditable evidence that the pre-lookup actually ran. This generic skill does not name a vendor; the caller wires `:` to whichever project skill owns pre-lookup (e.g., a backlog-lifecycle skill's own pre-lookup topic). - When no `--pre-lookup` flag is supplied — or the specified receiver is unavailable in the caller's environment — the mandate still applies via direct store queries (RAG semantic find / wiki `index.md` read); fail-non-blocking (warning + continue), same policy as "Research artifact dispatch" below. + - **Available-skills domain scan**: also grep the current session's available-skills list for the task's domain keywords (e.g., a task touching hook registration/monitoring should check for skills whose description mentions "trigger", "hook auto-register", etc.), not just recalled-from-memory candidates — a skill that already manages this domain may exist under a name that doesn't obviously match the task. - Do not skim a file and move on at the signature level - Understand existing layers, ORM relationships, and duplicate API presence - **Mandatory exploration of existing test files**: Find related `*.test.*`, `*.spec.*` files and understand what cases are already covered @@ -274,6 +287,9 @@ After writing or updating `plan-*.md`, **AI must actively scan the plan for unde | 4 | Single question with 5+ mixed-axis options | Split into multiple questions (max 4) using `questions` array. Each question = one decision axis | | 5 | Ask "Plan OK? proceed?" without enumerating undecided items | Enumerate each undecided item as its own question option | | 6 | Use "save plan as file vs print in chat" as one of the options | Forbidden by the always-on rule "target-unspecified document artifacts = file save default". Medium is decided by default rule | +| 7 | Call this skill's `steps` topic **and** `vibe-coding`'s `plan-guard` on the same plan edit because both PostToolUse triggers fired | The two triggers are redundant on this axis — both demand the same undecided-item scan. **Satisfying either satisfies the axis**; invoking both doubles the skill load per document edit for no extra check. Honour one and state which | + +**Redundant-trigger note**: editing a `plan-*.md` / `research-*.md` fires two PostToolUse skill-triggers at once — `code-workflow` (pointing here) and `vibe-coding` (pointing at `plan-guard`). Their undecided-item obligations overlap almost entirely: scan the plan body for unresolved markers, convert each into an `AskUserQuestion` axis, write the answers back. Treat them as one obligation with two entry points. Consolidating the triggers themselves is separate work; until then, honouring one and naming it is correct behaviour, not a skipped step. #### Self-check (every time after writing/updating plan file) diff --git a/skills/commit-tidy/CHANGELOG.md b/skills/commit-tidy/CHANGELOG.md index 5199fc5a..a6c4a869 100644 --- a/skills/commit-tidy/CHANGELOG.md +++ b/skills/commit-tidy/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.5.5](https://github.com/es6kr/skills/compare/commit-tidy-v0.5.4...commit-tidy-v0.5.5) (2026-08-20) + + +### Bug Fixes + +* **commit-tidy:** add 5+ files large-scale modification mandatory gate ([2eca0a0](https://github.com/es6kr/skills/commit/2eca0a085295dae3707bd4447f2ec1996023e1bf)) +* **hooks:** correct ghost path for block-wip-register-before-execute.sh ([d5875a2](https://github.com/es6kr/skills/commit/d5875a2e624eeb4a3dce9beba5d96a4365a8f950)) +* promote next-fix batch (hook path repair, topic-dispatch scoping, conflict diagnosis) ([eb7ecb6](https://github.com/es6kr/skills/commit/eb7ecb61dda9701d78f12dc810781dc7cb687caa)) + ## [0.5.4](https://github.com/es6kr/skills/compare/commit-tidy-v0.5.3...commit-tidy-v0.5.4) (2026-08-17) diff --git a/skills/commit-tidy/SKILL.md b/skills/commit-tidy/SKILL.md index e166c7e4..5237b9cb 100644 --- a/skills/commit-tidy/SKILL.md +++ b/skills/commit-tidy/SKILL.md @@ -42,6 +42,10 @@ Analyze staged/unstaged changes and recommend whether to split into multiple com - Reviewing changes that touch many files - Ensuring atomic, reviewable commits +## Mandatory Invocation Gate (HARD STOP) + +- **5+ Files / Large-Scale Modification Gate**: Whenever 5+ files are modified/added/deleted, or a broad cross-directory refactoring, skill renaming, or multi-component edit occurs, invoking `commit-tidy` before committing is **MANDATORY** (`HARD STOP`). Monolithic single-commit attempts without a commit-tidy split & staging review are strictly prohibited. + ## Squash-scan scope (HARD STOP) **A user-named commit range is the minimum scope, never the maximum.** The moment any squash candidate is found — whether self-discovered or pointed at by the user — scan the *entire* unpushed range (`git log --name-only @{u}..HEAD`) grouped by file for the same repeated-single-file pattern before proposing a squash plan. See `staging-discipline.md` "Full-range squash-candidate scan" for the procedure. Presenting a squash plan for only the range the user mentioned, while an identical streak sits elsewhere in the same unpushed history, is a violation — the assistant surfaces the full picture, not just the part the user already knew about. diff --git a/skills/consolidate/CHANGELOG.md b/skills/consolidate/CHANGELOG.md index 218f07b1..6974bc3b 100644 --- a/skills/consolidate/CHANGELOG.md +++ b/skills/consolidate/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [0.6.0](https://github.com/es6kr/skills/compare/consolidate-v0.5.4...consolidate-v0.6.0) (2026-08-20) + + +### Features + +* promote next-feat batch (hook registry schema, self-reference path anchoring) ([0c33ffa](https://github.com/es6kr/skills/commit/0c33ffac99a9237f4530566470dabeaea128c209)) +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + + +### Bug Fixes + +* **consolidate:** refresh the AI Review Summary before the merge ask when findings were fixed ([#348](https://github.com/es6kr/skills/issues/348)) ([9512c9c](https://github.com/es6kr/skills/commit/9512c9cdff7d1a0d2e36a8c70a468e2abca6e977)) +* **hook-kit,fix-plan,consolidate:** resolve review findings on registry fail-fast, add-item safety, and mechanical verification ([72fb280](https://github.com/es6kr/skills/commit/72fb28054ccd858aa56617e8a6a5d1fb5b9d2384)) +* promote next-fix batch (hook path repair, topic-dispatch scoping, conflict diagnosis) ([eb7ecb6](https://github.com/es6kr/skills/commit/eb7ecb61dda9701d78f12dc810781dc7cb687caa)) + ## [0.5.4](https://github.com/es6kr/skills/compare/consolidate-v0.5.3...consolidate-v0.5.4) (2026-08-17) diff --git a/skills/consolidate/SKILL.md b/skills/consolidate/SKILL.md index 265836de..2366acbf 100644 --- a/skills/consolidate/SKILL.md +++ b/skills/consolidate/SKILL.md @@ -3,7 +3,7 @@ name: consolidate depends-on: [git-repo, github-flow, hook-kit, superpowers] metadata: author: es6kr - version: "0.5.4" # x-release-please-version + version: "0.6.0" # x-release-please-version description: | Consolidate and respond to external feedback on PRs/issues. Topics — pr (workflow entrypoint + skip conditions), diff --git a/skills/consolidate/next.md b/skills/consolidate/next.md index a93aca3a..621bd978 100644 --- a/skills/consolidate/next.md +++ b/skills/consolidate/next.md @@ -37,6 +37,23 @@ Immediately after the Status line output, **ask the user for the PR handling dir 4. Is the merge question leading (Q1)? → If findings exist, invert: finding-handling leads 5. Is this a post-hoc review PR? → finding options must NOT include "defer all" (deferring contradicts the PR's purpose). Offer scope choices only (which findings), applied immediately +### Refresh the Summary before the merge ask when findings were fixed this session (HARD STOP) + +**When the finding-handling answer applies fixes this session (a fix commit lands + CI re-passes), the AI Review Summary posted at Step 7 is now stale — it still lists the fixed findings as open/Valid.** Before composing the merge ask, **PATCH the existing Summary comment to reflect resolution** (each fixed finding marked Resolved with the fixing commit SHA), per `post.md` "Single Summary preservation guard" PATCH procedure. A merge recommendation sitting on a Summary that still shows the findings as open is self-contradictory: the reader sees unresolved findings while the option says merge, and the merge-attestation URL points at a pre-fix record. + +This closes the gap between the finding-first fix and the merge decision: "fix → reflected in branch" (Finding-first ordering above) is not complete until the review record the merge attestation points at also reflects the fix. + +| # | Don't | Do | +|---|-------|-----| +| 1 | Apply the fix, reply only in the bot's inline thread, then compose the merge ask with the Step-7 Summary untouched | PATCH the Summary comment to mark the applied findings Resolved (+ fixing SHA) BEFORE the merge ask. An inline-thread reply is not a substitute — the Summary is the consolidated review record | +| 2 | Treat "the fix is documented somewhere on the PR (commit / thread reply)" as "the review record reflects resolution" | The AI Review Summary is the canonical record the merge attestation URL points at. It must state the current finding state, not the pre-fix state | +| 3 | PATCH the Summary only after merging | Refresh precedes the merge ask — the user decides merge against the current review state, not a stale one | + +**Self-check (after a finding-handling answer that applied fixes, before the merge ask)**: +1. Did this session land a fix commit for 1+ findings the Summary lists as open? +2. If yes, did I PATCH the Summary (per `post.md` procedure) to mark those findings Resolved (+ SHA) BEFORE composing the merge ask? +3. Does the merge option's Summary-URL attestation point at a Summary that now reflects the applied fixes (not the pre-fix state)? + ### Routing: next vs wip | Situation | Skill to use | Reason | diff --git a/skills/consolidate/post.md b/skills/consolidate/post.md index 1213b9d6..7390a32d 100644 --- a/skills/consolidate/post.md +++ b/skills/consolidate/post.md @@ -232,6 +232,22 @@ Full conditions: **Resolving deployment-required verification items**: If the Test Plan has "post-deployment verification" items, pre-verify on a legacy/staging environment using the feature branch image. Since this is verifiable without master merge, do not record "post-deployment verification required" as a merge-blocking reason. +### Fact-verification before POST — no fabricated SHA / inflated reviewer count (HARD STOP) + +Every verifiable specific written into a Summary or Internal Code Review is an audit-trail claim a merge is read against. Confirm each against a primary source before POST — never invent precise-looking detail: + +1. **Cited commit SHAs must exist.** For every `commit ` in the body, run `git cat-file -e ^{commit}` (in a checkout of the target repo) or `gh api repos///commits/`. Remove or correct any that 404. +2. **External-reviewer finding counts must reconcile with the source.** A "Copilot: N findings" claim must equal `gh api repos///pulls//comments --jq '[.[] | select(.user.login | test("copilot"; "i"))] | length'` (a case-insensitive contains-match is robust across the inline-comment surface login `Copilot` and the review-author `copilot-pull-request-reviewer[bot]`). Never label an internal-reviewer finding as `copilot`; internal output is sourced `Internal Code Review`. +3. **Line numbers and test counts come from real output, not memory.** If a count is unconfirmable, write a verifiable level ("CI green") instead of a fabricated number. + +| # | Don't | Do | +|---|-------|-----| +| 1 | Cite `commit ` from memory or a plausible guess | Resolve each SHA (`git cat-file -e` / `gh api commits`) before writing it; drop any that 404 | +| 2 | Label a finding `copilot` when Copilot did not raise it, or claim more Copilot findings than exist | Reconcile the count against `pulls//comments`; source internal-only findings as `Internal Code Review` | +| 3 | Invent line numbers or "N/N tests" precision | Copy line numbers and test counts from the actual review/run output; otherwise write "CI green" | + +The `block-summary-fabricated-claims` guard (PreToolUse hook) enforces 1 and 2 mechanically: a consolidate-provenance POST citing a nonexistent SHA, or claiming more Copilot findings than the PR actually has, is denied at source. It fails open on any ambiguity (no `gh` / offline / unreadable body / a SHA on an unfetched branch — use `ALLOW_SUMMARY_FABRICATED_CLAIMS=1` per-command in the last case), so the discipline above is the primary defense and the hook is the backstop. + When unmet, write `Actionable Items PENDING fix.` in the Summary + state the unmet conditions. **Do not ask "shall we merge?"** If all conditions met, evaluate the PR's commit history (`gh pr view NUMBER --commits`) **AND the PR's stated intent (description/body, checklist referenced)** to recommend a merge strategy: diff --git a/skills/consolidate/resources/block-summary-fabricated-claims.sh b/skills/consolidate/resources/block-summary-fabricated-claims.sh new file mode 100755 index 00000000..f72f191b --- /dev/null +++ b/skills/consolidate/resources/block-summary-fabricated-claims.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# PreToolUse hook (cross-platform: Claude Code + Antigravity): +# Block a consolidate AI Review Summary / Internal Code Review POST that cites a +# fabricated commit SHA or an inflated external-reviewer (Copilot) finding count. +# +# Rationale: +# The Summary is the audit trail a merge decision is read against. A cited +# `commit ` that does not exist in the repo, or a "Copilot: N findings" +# claim where the reviewer actually produced fewer, makes that record actively +# misleading — a reader (or a future session) can approve a merge on a +# fabricated basis. This guard verifies the two mechanically-checkable claims +# at POST source. Case history: es6kr/skills PR #346 Summary cited commit +# `4e7ee9e` (nonexistent) and inflated Copilot's 2 findings to "4 findings". +# +# Checks (BOTH fail open on any ambiguity — never false-block): +# 1. SHA existence — every `commit <7-40 hex>` cited must resolve. A local +# `git cat-file -e ^{commit}` HIT confirms existence offline; a miss is +# inconclusive (the sha may be a real un-fetched remote commit), so the +# authoritative check is `gh api repos///commits/`. Only a +# definite 404/422 ("No commit found" / "Not Found") marks a sha fabricated. +# No gh / no owner-repo / network error => that sha is UNKNOWN => skipped. +# 2. Copilot count — if the body states "Copilot ... N findings" and the PR's +# actual Copilot review-comment count is < N, the count is inflated. +# +# Only acts on a consolidate-provenance comment (receiving-code-review / +# requesting-code-review link, or a marker) so ad-hoc text +# is never touched. +# +# Cross-platform I/O contract (mirrors block-noncompliant-review-comment.sh): +# - Claude Code: stdin {tool_name, tool_input.command}; block = exit 2 + stderr. +# - Antigravity: stdin {toolCall.name, toolCall.args...}; block = stdout +# {"decision":"deny","reason":...} (+ exit 0). +# +# Bypass (explicit user override, per-command only — never session-wide): +# ALLOW_SUMMARY_FABRICATED_CLAIMS=1 + +INPUT=$(cat) + +# --- runtime detection + command extraction --- +CLAUDE_TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) +AG_TOOL=$(echo "$INPUT" | jq -r '.toolCall.name // empty' 2>/dev/null) + +RUNTIME="" +COMMAND="" +if [[ -n "$CLAUDE_TOOL" ]]; then + RUNTIME="claude" + [[ "$CLAUDE_TOOL" != "Bash" ]] && exit 0 + COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) +elif [[ -n "$AG_TOOL" ]]; then + RUNTIME="antigravity" + [[ "$AG_TOOL" != "run_command" ]] && exit 0 + COMMAND=$(echo "$INPUT" | jq -r '.toolCall.args.command // .toolCall.args.CommandLine // (.toolCall.args | tostring) // empty' 2>/dev/null) +else + exit 0 +fi +[[ -z "$COMMAND" ]] && exit 0 + +# --- explicit override --- +if [[ "$ALLOW_SUMMARY_FABRICATED_CLAIMS" == "1" ]] || echo "$COMMAND" | grep -qE 'ALLOW_SUMMARY_FABRICATED_CLAIMS=1'; then + exit 0 +fi + +# --- only act on a PR/issue comment or review POST --- +IS_POST="" +echo "$COMMAND" | grep -qE 'gh[[:space:]]+(pr|issue)[[:space:]]+comment' && IS_POST=1 +echo "$COMMAND" | grep -qE 'gh[[:space:]]+pr[[:space:]]+review' && IS_POST=1 +echo "$COMMAND" | grep -qE 'gh[[:space:]]+api[[:space:]].*(issues/[0-9]+/comments|pulls/[0-9]+/(comments|reviews))' && IS_POST=1 +echo "$COMMAND" | grep -qE 'curl[[:space:]].*(issues/[0-9]+/comments|pulls/[0-9]+/(comments|reviews))' && IS_POST=1 +[[ -z "$IS_POST" ]] && exit 0 + +# --- extract the body text (inline --body / --body-file / gh api body=@file / --input) --- +BODY="" +BODY="$(echo "$COMMAND" | grep -oE -- '(--body|-b)[[:space:]]+.*' | head -1)" +BF="$(echo "$COMMAND" | grep -oE -- '--body-file[[:space:]]+[^[:space:]]+' | awk '{print $2}')" +[[ -n "$BF" && -f "$BF" ]] && BODY="$BODY $(cat "$BF" 2>/dev/null)" +AF="$(echo "$COMMAND" | grep -oE -- '(--input[[:space:]]+[^[:space:]]+|body=@[^[:space:]]+)' | sed -E 's/^--input[[:space:]]+//; s/^body=@//')" +[[ -n "$AF" && -f "$AF" ]] && BODY="$BODY $(cat "$AF" 2>/dev/null)" +# Can't inspect the body (heredoc / env var / stdin) => do NOT block. +[[ -z "$BODY" ]] && exit 0 + +# --- only guard consolidate-provenance comments (Summary / Internal Review) --- +echo "$BODY" | grep -qE 'receiving-code-review|requesting-code-review|" + content = ( + "# Fix Plan\n\n" + "## Progress\n\n" + "- [x] 2026-07-07 — domain review\n\n" + "## Completed\n\n" + f"{marker}\n" + "- 2026-07-01 — earlier thing\n\n" + "## REPEAT\n" + ) + self._write(content) + + import subprocess + result = subprocess.run( + [sys.executable, str(SCRIPT_DIR / "cleanup.py"), + "--file", self.fix_plan, "--cutoff", "2020-01-01"], + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + output = self._read() + self.assertIn(marker, output) + # the entries around it still move / stay as before + self.assertIn("domain review", output) + self.assertIn("earlier thing", output) + + def test_trailing_newline_preserved(self): + """Rewriting must not strip the file's final newline. + + The output is assembled with a join, which has no terminator after the + last line; writing that back drops the newline the source had and every + subsequent diff reports the last line as modified. + """ + content = ( + "# Fix Plan\n\n" + "## Progress\n\n" + "- [x] 2026-07-07 — domain review\n\n" + "## Completed\n\n" + "## REPEAT\n" + ) + self._write(content) + + import subprocess + result = subprocess.run( + [sys.executable, str(SCRIPT_DIR / "cleanup.py"), + "--file", self.fix_plan, "--cutoff", "2020-01-01"], + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(self._read().endswith("\n")) + class TestAutoDetectTrackerRoot(unittest.TestCase): """Issue #262: auto-detect must resolve .agents/fix_plan.md, not just diff --git a/skills/fix-plan/scripts/test_plane_priority_mapping.py b/skills/fix-plan/scripts/test_plane_priority_mapping.py new file mode 100644 index 00000000..8828402c --- /dev/null +++ b/skills/fix-plan/scripts/test_plane_priority_mapping.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Unit tests for the fix_plan P0-P3 <-> Plane native priority bidirectional +mapping (2026-08-20 Fable audit, plan-plane-done-state-and-priority-mapping.md +Phase 1). All offline: subprocess.run and urllib.request are mocked. +""" + +import ast +import base64 +import json +import sys +import unittest +from pathlib import Path +from unittest import mock + +SCRIPT_DIR = Path(__file__).parent.resolve() +sys.path.insert(0, str(SCRIPT_DIR)) +sys.path.insert(0, str(SCRIPT_DIR.parent.parent / "plane-backlog" / "scripts")) + +import importlib.util + +_client_spec = importlib.util.spec_from_file_location( + "plane_client", str(SCRIPT_DIR.parent.parent / "plane-backlog" / "scripts" / "plane_client.py") +) +plane_client = importlib.util.module_from_spec(_client_spec) +sys.modules["plane_client"] = plane_client +_client_spec.loader.exec_module(plane_client) + +_issue_spec = importlib.util.spec_from_file_location( + "plane_create_issue", str(SCRIPT_DIR.parent.parent / "plane-backlog" / "scripts" / "plane_create_issue.py") +) +plane_create_issue = importlib.util.module_from_spec(_issue_spec) +sys.modules["plane_create_issue"] = plane_create_issue +_issue_spec.loader.exec_module(plane_create_issue) + + +BASE_PROFILE = { + "plane_host": "https://plane.es6.kr", + "token": "test-token", + "workspace_slug": "es6kr", + "default_project": "proj-id", + "k3s_namespace": "plane-ce", +} + + +class TestNormalizePriority(unittest.TestCase): + def test_case_insensitive_p_tags(self): + for tag, expected in ( + ("P0", "urgent"), ("p0", "urgent"), + ("P1", "high"), ("p1", "high"), + ("P2", "medium"), ("p2", "medium"), + ("P3", "low"), ("p3", "low"), + ): + self.assertEqual(plane_client.normalize_priority(tag), expected) + + def test_native_values_pass_through(self): + for value in ("urgent", "high", "medium", "low", "none"): + self.assertEqual(plane_client.normalize_priority(value), value) + + def test_native_values_case_insensitive(self): + self.assertEqual(plane_client.normalize_priority("URGENT"), "urgent") + + def test_falsy_input_is_none(self): + self.assertEqual(plane_client.normalize_priority(None), "none") + self.assertEqual(plane_client.normalize_priority(""), "none") + + def test_unrecognized_value_raises(self): + with self.assertRaises(ValueError): + plane_client.normalize_priority("P4") + with self.assertRaises(ValueError): + plane_client.normalize_priority("critical") + + def test_priority_to_marker_reverse_mapping(self): + self.assertEqual(plane_client.priority_to_marker("urgent"), "P0") + self.assertEqual(plane_client.priority_to_marker("high"), "P1") + self.assertEqual(plane_client.priority_to_marker("medium"), "P2") + self.assertEqual(plane_client.priority_to_marker("low"), "P3") + + def test_priority_to_marker_none_for_no_priority(self): + self.assertIsNone(plane_client.priority_to_marker("none")) + self.assertIsNone(plane_client.priority_to_marker(None)) + self.assertIsNone(plane_client.priority_to_marker("bogus")) + + +class TestRestApiPriorityInjection(unittest.TestCase): + def _captured_payload(self, priority): + captured = {} + + class _FakeResponse: + def __enter__(self_inner): + return self_inner + + def __exit__(self_inner, *exc): + return False + + def read(self_inner): + return json.dumps({"id": "abc", "sequence_id": 1}).encode("utf-8") + + def _fake_urlopen(req, *a, **kw): + captured["payload"] = json.loads(req.data.decode("utf-8")) + return _FakeResponse() + + with mock.patch.object(plane_create_issue.urllib.request, "urlopen", _fake_urlopen): + plane_create_issue.create_via_rest_api( + dict(BASE_PROFILE), "title", is_intake=False, priority=priority + ) + return captured.get("payload") + + def test_p_tag_normalized_into_payload(self): + payload = self._captured_payload("P0") + self.assertEqual(payload.get("priority"), "urgent") + + def test_native_value_passed_through(self): + payload = self._captured_payload("high") + self.assertEqual(payload.get("priority"), "high") + + def test_omitted_priority_not_in_payload(self): + payload = self._captured_payload(None) + self.assertNotIn("priority", payload) + + def test_invalid_priority_raises_before_network_call(self): + with self.assertRaises(ValueError): + self._captured_payload("P9") + + +@unittest.skip( + "blocked on https://github.com/es6kr/skills/pull/349 (f-string brace " + "escaping fix) promoting from next-fix to main — the K3s fallback " + "template on main still crashes on ANY generation, independent of this " + "priority-injection change. Un-skip once #349 lands on main." +) +class TestK3sFallbackPriorityInjection(unittest.TestCase): + def _generated_script(self, priority): + captured_cmd = {} + + def _fake_run(cmd, **kw): + captured_cmd["cmd"] = cmd + + class _Result: + returncode = 0 + stdout = 'RESULT_JSON:{"success": true, "id": "1", "sequence_id": 1, "title": "t", "url": "u", "intake": false}\n' + stderr = "" + + return _Result() + + with mock.patch.object(plane_create_issue.subprocess, "run", _fake_run): + with mock.patch.object(plane_create_issue.shutil, "which", return_value="/usr/bin/kubectl"): + plane_create_issue.create_via_k3s_fallback( + dict(BASE_PROFILE), "title", is_intake=False, priority=priority + ) + cmd = captured_cmd.get("cmd") + exec_arg = cmd[-1] + b64_start = exec_arg.index("b64decode('") + len("b64decode('") + b64_end = exec_arg.index("'", b64_start) + return base64.b64decode(exec_arg[b64_start:b64_end]).decode("utf-8") + + def test_priority_kwarg_present_when_specified(self): + py_script = self._generated_script("P1") + self.assertIn('priority="high"', py_script) + ast.parse(py_script) + + def test_priority_kwarg_absent_when_unspecified(self): + py_script = self._generated_script(None) + self.assertNotIn("priority=", py_script) + ast.parse(py_script) + + def test_generated_script_valid_python_with_priority(self): + py_script = self._generated_script("urgent") + ast.parse(py_script) # would raise SyntaxError if the kwarg injection broke the call + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/fix-plan/scripts/test_plane_sync.py b/skills/fix-plan/scripts/test_plane_sync.py index e1e9e93a..41833e50 100644 --- a/skills/fix-plan/scripts/test_plane_sync.py +++ b/skills/fix-plan/scripts/test_plane_sync.py @@ -39,6 +39,15 @@ def test_matches_phase3_line(self): self.assertEqual(entry["url_match"]["project"], "11111111-1111-1111-1111-111111111111") self.assertEqual(entry["url_match"]["issue"], "22222222-2222-2222-2222-222222222222") + def test_matches_ascii_arrow_delimiter(self): + # The index-line docstring documents an ASCII "->" delimiter, but real + # data uses the Unicode "→". Accept both so a hand-typed ASCII arrow + # still parses. (CodeRabbit/Copilot review, PR #253.) + ascii_line = PHASE3_LINE.replace("→", "->") + matches = plane_sync.parse_index_lines([ascii_line]) + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0]["match"].group("ident"), "INFRA-6") + def test_ignores_non_plane_lines(self): lines = ["- [ ] plain item, no Plane link", "some prose", ""] self.assertEqual(plane_sync.parse_index_lines(lines), []) @@ -200,6 +209,167 @@ def test_write_succeeds_atomically_no_tmp_file_left(self): self.assertFalse((Path(d) / "fix_plan.md.tmp").exists()) +class TestPriorityMapping(unittest.TestCase): + def test_normalize_priority_marker_tags(self): + self.assertEqual(plane_sync.normalize_priority("P0"), "urgent") + self.assertEqual(plane_sync.normalize_priority("p1"), "high") + self.assertEqual(plane_sync.normalize_priority("P2"), "medium") + self.assertEqual(plane_sync.normalize_priority("p3"), "low") + + def test_normalize_priority_native_values_passthrough(self): + for v in ("urgent", "high", "medium", "low", "none"): + self.assertEqual(plane_sync.normalize_priority(v), v) + + def test_normalize_priority_rejects_unknown(self): + with self.assertRaises(ValueError): + plane_sync.normalize_priority("P9") + + def test_priority_to_marker_reverse(self): + self.assertEqual(plane_sync.priority_to_marker("urgent"), "P0") + self.assertEqual(plane_sync.priority_to_marker("low"), "P3") + self.assertIsNone(plane_sync.priority_to_marker("none")) + + +class TestExtractLocalPriority(unittest.TestCase): + def test_extracts_p_tag_from_blocked_marker(self): + self.assertEqual(plane_sync.extract_local_priority("BLOCKED:P1:external"), "high") + + def test_returns_none_for_plain_open_marker(self): + self.assertIsNone(plane_sync.extract_local_priority(" ")) + + def test_returns_none_for_x_marker(self): + self.assertIsNone(plane_sync.extract_local_priority("x")) + + +class TestDetectPriorityDrift(unittest.TestCase): + """Report-only drift detection (Fable audit §7-3): a first-adoption + priority-sync run stays advisory until a human confirms the direction, + so this must never write to fix_plan.md or Plane.""" + + def _profile(self): + return {"plane_host": "https://plane.example.com", "plane_token": "tok"} + + def test_reports_mismatch(self): + with patch.object( + plane_sync, "fetch_issue_state", + return_value={"state": "s1", "priority": "urgent"}, + ): + drifts = plane_sync.detect_priority_drift([PHASE3_LINE], self._profile()) + self.assertEqual(len(drifts), 1) + self.assertEqual(drifts[0]["local_priority"], "low") # P3 + self.assertEqual(drifts[0]["plane_priority"], "urgent") + + def test_no_drift_when_matching(self): + with patch.object( + plane_sync, "fetch_issue_state", + return_value={"state": "s1", "priority": "low"}, + ): + drifts = plane_sync.detect_priority_drift([PHASE3_LINE], self._profile()) + self.assertEqual(drifts, []) + + def test_skips_lines_without_p_tag(self): + line = PHASE3_LINE.replace("[BLOCKED:P3:external]", "[x]") + with patch.object(plane_sync, "fetch_issue_state") as mock_fetch: + drifts = plane_sync.detect_priority_drift([line], self._profile()) + mock_fetch.assert_not_called() + self.assertEqual(drifts, []) + + def test_api_error_no_drift_reported(self): + with patch.object(plane_sync, "fetch_issue_state", return_value={"error": "timeout"}): + drifts = plane_sync.detect_priority_drift([PHASE3_LINE], self._profile()) + self.assertEqual(drifts, []) + + def test_never_mutates_input_lines(self): + original = [PHASE3_LINE] + with patch.object( + plane_sync, "fetch_issue_state", + return_value={"state": "s1", "priority": "urgent"}, + ): + plane_sync.detect_priority_drift(original, self._profile()) + self.assertEqual(original, [PHASE3_LINE]) + + +class TestDoneStateTransition(unittest.TestCase): + """Plane Issue DELETE Prohibition & Done State Preservation (HARD STOP): + a local [x] item whose linked Plane issue isn't yet `completed` gets a + PATCH to the project's completed-group state -- DELETE is never used.""" + + def _profile(self): + return {"plane_host": "https://plane.example.com", "plane_token": "tok"} + + def test_find_state_id_by_group(self): + states = [ + {"id": "s-todo", "group": "unstarted"}, + {"id": "s-done", "group": "completed"}, + ] + self.assertEqual(plane_sync.find_state_id_by_group(states, "completed"), "s-done") + + def test_find_state_id_by_group_missing(self): + self.assertIsNone( + plane_sync.find_state_id_by_group([{"id": "s1", "group": "started"}], "completed") + ) + + def test_transition_issue_to_done_patches_state(self): + captured = {} + + def fake_request(profile, path, method="GET", data=None): + if path.endswith("states/"): + return {"results": [{"id": "done-id", "group": "completed"}]} + captured["path"] = path + captured["method"] = method + captured["data"] = data + return {"id": "issue1", "state": "done-id"} + + with patch.object(plane_sync, "make_plane_request", side_effect=fake_request): + result = plane_sync.transition_issue_to_done(self._profile(), "ws", "proj1", "issue1") + self.assertEqual(captured["method"], "PATCH") + self.assertEqual(captured["data"], {"state": "done-id"}) + self.assertNotIn("error", result) + + def test_transition_issue_to_done_no_completed_state(self): + with patch.object(plane_sync, "make_plane_request", return_value={"results": []}): + result = plane_sync.transition_issue_to_done(self._profile(), "ws", "proj1", "issue1") + self.assertIn("error", result) + + def test_no_delete_call_anywhere_in_module_source(self): + import inspect + source = inspect.getsource(plane_sync) + self.assertNotIn('method="DELETE"', source) + self.assertNotIn("method='DELETE'", source) + + +class TestComputeLocalToPlaneUpdates(unittest.TestCase): + def _profile(self): + return {"plane_host": "https://plane.example.com", "plane_token": "tok"} + + def test_queues_transition_when_local_done_but_plane_not(self): + completed_line = PHASE3_LINE.replace("[BLOCKED:P3:external]", "[x]") + with patch.multiple( + plane_sync, + fetch_issue_state=lambda *a, **kw: {"state": "s1"}, + fetch_state_group=lambda *a, **kw: {"group": "started"}, + ): + updates = plane_sync.compute_local_to_plane_updates([completed_line], self._profile()) + self.assertEqual(len(updates), 1) + self.assertEqual(updates[0]["ident"], "INFRA-6") + + def test_no_queue_when_plane_already_completed(self): + completed_line = PHASE3_LINE.replace("[BLOCKED:P3:external]", "[x]") + with patch.multiple( + plane_sync, + fetch_issue_state=lambda *a, **kw: {"state": "s1"}, + fetch_state_group=lambda *a, **kw: {"group": "completed"}, + ): + updates = plane_sync.compute_local_to_plane_updates([completed_line], self._profile()) + self.assertEqual(updates, []) + + def test_no_queue_for_non_x_local_marker(self): + with patch.object(plane_sync, "fetch_issue_state") as mock_fetch: + updates = plane_sync.compute_local_to_plane_updates([PHASE3_LINE], self._profile()) + mock_fetch.assert_not_called() + self.assertEqual(updates, []) + + class TestAutoDetectTrackerRoot(unittest.TestCase): """Issue #262: the __main__ fallback (no --fix-plan passed) must resolve .agents/fix_plan.md, not just .ralph/fix_plan.md.""" diff --git a/skills/fix-plan/scripts/workspace_profile.py b/skills/fix-plan/scripts/workspace_profile.py index 8088326a..f2996268 100644 --- a/skills/fix-plan/scripts/workspace_profile.py +++ b/skills/fix-plan/scripts/workspace_profile.py @@ -32,8 +32,14 @@ import argparse from pathlib import Path -# Config file location +# Config file locations, newest first. v2 names roles ("rag", "backlog") and +# carries the vendor in a `kind` field, so swapping a vendor is a one-line +# config edit. v1 named the vendor in the key itself. Consumers of this module +# still read the v1-shaped flat keys, so v2 is translated down to them here — +# a v2 file that reached consumers untranslated would look like an empty +# profile and hand every caller the placeholder defaults. CONFIG_FILE = Path.home() / ".config" / "plane-backlog" / "config.json" +CONFIG_FILE_V2 = Path.home() / ".config" / "agent-workspace" / "config.json" DEFAULT_PROFILE = { "workspace_name": "default", @@ -49,17 +55,112 @@ } +def v2_profile_to_flat(profile: dict, defaults: dict) -> dict: + """Translate one v2 role-shaped profile into the v1 flat keys. + + A role set to kind "none" means "not configured for this workspace", which + must surface as an empty value rather than falling through to + DEFAULT_PROFILE's placeholder (localhost) — otherwise "unconfigured" and + "configured, pointing at localhost" become indistinguishable downstream. + """ + roles = dict(defaults or {}) + roles.update(profile.get("roles") or {}) + flat = {} + + match = profile.get("match") or {} + if match.get("path_components"): + flat["cwd_match"] = match["path_components"] + elif profile.get("cwd_match"): + flat["cwd_match"] = profile["cwd_match"] + + backlog = roles.get("backlog") or {} + if backlog.get("kind", "none") != "none": + flat["plane_host"] = backlog.get("endpoint", "") + flat["plane_token_env"] = backlog.get("token_env", "PLANE_API_KEY") + flat["default_project"] = backlog.get("project", "") + else: + flat["plane_host"] = "" + + rag = roles.get("rag") or {} + if rag.get("kind", "none") != "none": + flat["qdrant_url"] = rag.get("endpoint", "") + for key, value in (rag.get("collections") or {}).items(): + flat["qdrant_%s_collection" % key] = value + else: + flat["qdrant_url"] = "" + + wiki = roles.get("wiki") or {} + if wiki.get("kind") == "git" and wiki.get("path"): + flat["llm_wiki_path"] = wiki["path"] + + checklist = roles.get("checklist") or {} + if checklist.get("kind") == "file" and checklist.get("path"): + parent = str(Path(checklist["path"]).parent) + if parent not in ("", "."): + flat["tracker_root"] = parent + + return flat + + def load_user_config(): - """Load user config from ~/.config/plane-backlog/config.json (holds all real per-workspace values).""" - if CONFIG_FILE.exists(): + """Load the first available user config, translating v2 down to v1 keys. + + v2 wins when present; v1 stays readable for the migration window so a + machine that has not been migrated keeps working unchanged. + """ + for path in (CONFIG_FILE_V2, CONFIG_FILE): + if not path.exists(): + continue try: - with open(CONFIG_FILE, 'r', encoding='utf-8') as f: - return json.load(f) + with open(path, 'r', encoding='utf-8') as f: + cfg = json.load(f) except (json.JSONDecodeError, OSError) as e: - print(f"Warning: failed to parse {CONFIG_FILE}: {e}", file=sys.stderr) + print(f"Warning: failed to parse {path}: {e}", file=sys.stderr) + continue + if not isinstance(cfg, dict): + continue + profiles = cfg.get("profiles") or {} + # v2 is identified by the top-level version, not by a profile carrying + # `roles`: a v2 profile may define only `match`/`defaults`. Keying off + # per-profile `roles` alone would leave such a config untranslated and + # then mangle it through the v1 path in get_profile. + is_v2 = cfg.get("version") == 2 or any( + isinstance(p, dict) and "roles" in p for p in profiles.values() + ) + if is_v2: + defaults = cfg.get("defaults") or {} + cfg = { + "profiles": { + # workspace_name must be carried explicitly: DEFAULT_PROFILE + # already holds "default" for that key, so get_profile's + # setdefault cannot correct it later. + name: dict(v2_profile_to_flat(p, defaults), workspace_name=name) + for name, p in profiles.items() + if isinstance(p, dict) + } + } + return cfg return {} +def token_matches(token: str, parts) -> bool: + """True if `token` matches a contiguous run of path segments in `parts`. + + Tokens are written in two shapes: a bare component ("es6kr") and a path + fragment ("ghq/github.com/es6kr"). Splitting on "/" and comparing segment + sequences handles both. + + Comparing whole segments (rather than substrings) is what keeps a token + like "es6kr" from also matching an unrelated sibling such as + "not-es6kr-workspace" — that property must survive any change here. + """ + seq = [s for s in str(token).split("/") if s] + if not seq: + return False + n = len(seq) + return any(list(parts[i:i + n]) == seq for i in range(len(parts) - n + 1)) + + def detect_workspace(target_path: str = None) -> str: """Detect workspace profile based on env var, explicit path, or cwd match against configured profiles.""" profiles = load_user_config().get("profiles", {}) @@ -70,14 +171,13 @@ def detect_workspace(target_path: str = None) -> str: return env_profile # 2. Check path against each configured profile's cwd_match tokens. - # Match against path components (not a raw substring of the full path string) so a - # token like "es6kr" doesn't also match an unrelated sibling such as "not-es6kr-workspace". + # Tokens may be a bare component or a multi-segment fragment; see token_matches. cwd = Path(target_path or os.getcwd()).resolve() cwd_parts = cwd.parts for name, cfg in profiles.items(): for token in cfg.get("cwd_match", [name]): - if token in cwd_parts: + if token_matches(token, cwd_parts): return name # 3. No match — "default" only. Do NOT fall back to an arbitrary configured profile; diff --git a/skills/fix-plan/sync.md b/skills/fix-plan/sync.md index 3f93d1bb..6f00a2dc 100644 --- a/skills/fix-plan/sync.md +++ b/skills/fix-plan/sync.md @@ -58,13 +58,23 @@ Use the same format as [format.md](./format.md) item state changes: `(YYYY-MM-DD Items just synced to `[x]` are immediate candidates for the next [move](./move.md) cycle. The recommended sequence is `sync` → `move` so the freshly-merged items roll into Completed in the same pass. +### 6. Milestone-Boundary Sync (task.md ↔ plan-*.md ↔ fix_plan.md) + +When executing deep tasks (`/fix-plan add --deep`, `/code-workflow`), real-time sub-step tool calls are tracked in `task.md` to avoid token churn on large files. + +At **major phase boundaries** (Phase 2 Plan authoring, Phase 3 Review disposition, Phase 4 TDD completion, Phase 5 verification), synchronize state across all 3 surfaces: +- `task.md`: Current execution step marked `[x]` +- `plan-*.md`: Section 3 Layered Roadmap / Progress Checklist marked `[x]` +- `fix_plan.md`: Task status updated with model + timestamp metadata `(YYYY-MM-DD, ; completed: YYYY-MM-DD, )` +- `/cleanup`: Final verification ensuring zero sync gap across all 3 files. + ## Secondary-tracker sync cadence When a project mirrors its backlog into a second external tracker (a project-management tool, issue tracker, etc.) alongside GitHub, run that tracker's own sync in the same cadence as this GitHub sync — poll both together rather than letting them drift independently. The fix-plan skill stays vendor-agnostic here too: no tracker name is hardcoded. Dispatch via `--secondary-sync=:` (same caller-supplied receiver pattern as `--archive=:` — see the top-level Configuration table). The caller wires this to whichever skill owns that tracker's sync script (e.g., a project-management-tool skill's own dry-run sync command); this skill only documents the cadence contract. -**Example receiver — `scripts/plane_sync.py`**: parses `- [] [-] -> Plane (<issue URL>)` index lines (the format `plane-backlog`'s Phase-3 migration produces), and maps each issue's `state_detail.group` back onto the fix_plan marker — `completed` -> `[x]`, `cancelled` -> `[BLOCKED:P2:external]`, mirroring this file's own MERGED/CLOSED-without-merge rules above. Non-terminal states and API errors leave the line untouched, same as the GitHub rules table. +**Example receiver — `scripts/plane_sync.py`**: parses `- [<marker>] [<IDENT>-<seq>] <title> -> Plane (<issue URL>)` index lines (the format `plane-backlog`'s Phase-3 migration produces), and maps each issue's `state_detail.group` back onto the fix_plan marker — `completed` -> `[x]`, `cancelled` -> `[BLOCKED:P2:external]`, mirroring this file's own MERGED/CLOSED-without-merge rules above. Non-terminal states and API errors leave the line untouched, same as the GitHub rules table. It also runs the reverse leg (`--push-done`: local `[x]` -> Plane Done) and a report-only P0-P3 priority-drift check — never `DELETE`s a Plane issue. See `plane-backlog/SKILL.md` "Plane Issue DELETE Prohibition & Priority Mapping (HARD STOP)" for the full rule. ### Auto-supplying the secondary-sync receiver from a workspace profile diff --git a/skills/fix-plan/tests/test-warn-fixplan-item-schema.sh b/skills/fix-plan/tests/test-warn-fixplan-item-schema.sh new file mode 100755 index 00000000..6803571a --- /dev/null +++ b/skills/fix-plan/tests/test-warn-fixplan-item-schema.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# Tests for warn-fixplan-item-schema.sh +# +# R1 is the regression under fix: editing ONLY an item's header line leaves the +# item's Why/How outside the edit window, and the anchor exemption (keyed on an +# exact old_string header match) cannot see it because the header text itself +# changed. The advisory then reports Why/How as missing while both exist on disk. +# +# T2-T10 are the nets that must keep passing: a genuinely incomplete item still +# has to be flagged, and the scope carve-outs ([x] / [BLOCKED] / non-tracker file +# / non-edit tool / insert-before-anchor) must stay silent. + +HOOK="$(cd "$(dirname "$0")/../resources" && pwd)/warn-fixplan-item-schema.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# Each case gets its own directory so the fixture can keep the literal name the +# hook's path filter accepts ("*/fix_plan.md"). Disambiguating by filename +# instead (fix_plan_t2.md) silently drops the case out of scope, which reads as +# a pass for every case that expects silence. +mkdir -p "$TMP"/t2 "$TMP"/t3 "$TMP"/t4 "$TMP"/t5 "$TMP"/t6 "$TMP"/t7 "$TMP"/t8 "$TMP"/t9 "$TMP"/t10 + +pass=0 +fail=0 +RC=0 +ERR="" + +run_hook() { + ERR="$(printf '%s' "$1" | bash "$HOOK" 2>&1 >/dev/null)" + RC=$? +} + +mk_edit() { # file_path old_string new_string + jq -n --arg fp "$1" --arg o "$2" --arg n "$3" \ + '{tool_name:"Edit", tool_input:{file_path:$fp, old_string:$o, new_string:$n}}' +} + +mk_write() { # file_path content + jq -n --arg fp "$1" --arg c "$2" \ + '{tool_name:"Write", tool_input:{file_path:$fp, content:$c}}' +} + +check() { # name expected_rc + if [ "$2" = "$RC" ]; then + pass=$((pass + 1)) + printf 'ok %s\n' "$1" + else + fail=$((fail + 1)) + printf 'FAIL %s (expected rc=%s, got rc=%s)\n' "$1" "$2" "$RC" + printf '%s\n' "$ERR" | sed 's/^/ /' + fi +} + +# --------------------------------------------------------------------------- +# R1 — header-only edit on an item whose Why/How live below the edit window +# --------------------------------------------------------------------------- +FP="$TMP/fix_plan.md" +cat >"$FP" <<'EOF' +# fix_plan + +## Progress + +- [ ] Fix the login redirect loop on Safari + - **Why**: Users bounce between /login and /callback after SSO. + - **How to apply**: Add a state check in the callback handler; verify with an E2E run. + +- [ ] Add retry to the export job + - **Why**: Transient S3 timeouts fail the nightly export. + - **How to apply**: Wrap the upload in a 3-attempt backoff; assert in unit tests. +EOF +run_hook "$(mk_edit "$FP" \ + '- [ ] Fix the login redirect loop' \ + '- [ ] Fix the login redirect loop on Safari')" +check "R1 header-only edit does not report existing Why/How as missing" 0 + +# --------------------------------------------------------------------------- +# T2 — a genuinely incomplete item is still flagged +# --------------------------------------------------------------------------- +FP="$TMP/t2/fix_plan.md" +cat >"$FP" <<'EOF' +# fix_plan + +- [ ] Do a thing without rationale + +- [ ] Complete item + - **Why**: reason recorded. + - **How to apply**: procedure recorded. +EOF +run_hook "$(mk_edit "$FP" '## Progress' '- [ ] Do a thing without rationale')" +check "T2 item missing Why/How is flagged" 2 + +# --------------------------------------------------------------------------- +# T3 — a complete item authored via Write stays silent +# --------------------------------------------------------------------------- +FP="$TMP/t3/fix_plan.md" +CONTENT='- [ ] Ship the audit log viewer + - **Why**: Support cannot answer "who changed this" without shell access. + - **How to apply**: Add a read-only view over the events table; cover with an integration test.' +printf '%s\n' "$CONTENT" >"$FP" +run_hook "$(mk_write "$FP" "$CONTENT")" +check "T3 complete item stays silent" 0 + +# --------------------------------------------------------------------------- +# T4 — completed [x] items belong to the bloat hook +# --------------------------------------------------------------------------- +FP="$TMP/t4/fix_plan.md" +CONTENT='- [x] Ship the dashboard filter + - Landed in the March release.' +printf '%s\n' "$CONTENT" >"$FP" +run_hook "$(mk_write "$FP" "$CONTENT")" +check "T4 completed [x] item stays silent" 0 + +# --------------------------------------------------------------------------- +# T5 — BLOCKED items carry a trigger, not Why/How +# --------------------------------------------------------------------------- +FP="$TMP/t5/fix_plan.md" +CONTENT='- [BLOCKED:P2:external] Rotate the staging credentials + - **trigger: ops team confirms the rotation window**' +printf '%s\n' "$CONTENT" >"$FP" +run_hook "$(mk_write "$FP" "$CONTENT")" +check "T5 BLOCKED item stays silent" 0 + +# --------------------------------------------------------------------------- +# T6 — non-tracker files are out of scope +# --------------------------------------------------------------------------- +FP="$TMP/t6/notes.md" +CONTENT='- [ ] Do a thing without rationale' +printf '%s\n' "$CONTENT" >"$FP" +run_hook "$(mk_write "$FP" "$CONTENT")" +check "T6 non-tracker file is out of scope" 0 + +# --------------------------------------------------------------------------- +# T7 — inserting before an existing item must not flag the trailing anchor +# --------------------------------------------------------------------------- +FP="$TMP/t7/fix_plan.md" +cat >"$FP" <<'EOF' +# fix_plan + +- [ ] Newly inserted item + - **Why**: fresh motivation. + - **How to apply**: fresh procedure. + +- [ ] Pre-existing item + - **Why**: older motivation. + - **How to apply**: older procedure. +EOF +run_hook "$(mk_edit "$FP" \ + '- [ ] Pre-existing item' \ + '- [ ] Newly inserted item + - **Why**: fresh motivation. + - **How to apply**: fresh procedure. + +- [ ] Pre-existing item')" +check "T7 insert-before-anchor stays silent" 0 + +# --------------------------------------------------------------------------- +# T8 — an over-budget body is still flagged +# --------------------------------------------------------------------------- +FP="$TMP/t8/fix_plan.md" +CONTENT='- [ ] Migrate the billing pipeline + - **Why**: the legacy cron drifts from the ledger. + - **How to apply**: staged cutover. + - extra line 1 + - extra line 2 + - extra line 3 + - extra line 4 + - extra line 5 + - extra line 6' +printf '%s\n' "$CONTENT" >"$FP" +run_hook "$(mk_write "$FP" "$CONTENT")" +check "T8 over-budget item is flagged" 2 + +# --------------------------------------------------------------------------- +# T9 — the blank separator between items is not body content +# --------------------------------------------------------------------------- +FP="$TMP/t9/fix_plan.md" +cat >"$FP" <<'EOF' +- [ ] Trim the report generator + - **Why**: the monthly PDF takes nine minutes to render. + - **How to apply**: stream the rows instead of buffering. + - note one + - note two + - note three + +- [ ] Unrelated later item + - **Why**: unrelated. + - **How to apply**: unrelated. +EOF +run_hook "$(mk_edit "$FP" '- [ ] Trim the report generator' \ + '- [ ] Trim the report generator + - **Why**: the monthly PDF takes nine minutes to render. + - **How to apply**: stream the rows instead of buffering. + - note one + - note two + - note three')" +check "T9 trailing blank separator is not counted against the budget" 0 + +# --------------------------------------------------------------------------- +# T10 — only Edit/Write/MultiEdit are inspected +# --------------------------------------------------------------------------- +FP="$TMP/t10/fix_plan.md" +printf '%s\n' '- [ ] Do a thing without rationale' >"$FP" +run_hook "$(jq -n --arg fp "$FP" '{tool_name:"Bash", tool_input:{file_path:$fp, content:"- [ ] Do a thing without rationale"}}')" +check "T10 non-edit tool is ignored" 0 + +printf '\n%s passed, %s failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/skills/fix-plan/tests/test_workspace_profile.py b/skills/fix-plan/tests/test_workspace_profile.py new file mode 100644 index 00000000..0ad3f319 --- /dev/null +++ b/skills/fix-plan/tests/test_workspace_profile.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Tests for workspace_profile.detect_workspace path matching. + +Regression under test: `cwd_match` tokens are written as path fragments +("ghq/github.com/<org>"), but the matcher compared them against single path +components. No multi-segment token could ever match, so every workspace fell +through to "default" — meaning localhost endpoints and the wrong collections +for every consumer, silently. + +The fix must stay segment-exact so a token still cannot match a longer +component that merely contains it. + +Run: python3 skills/fix-plan/tests/test_workspace_profile.py +""" + +import json +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +import workspace_profile # noqa: E402 + +PASS = 0 +FAIL = 0 + + +def check(name, expected, actual): + global PASS, FAIL + if expected == actual: + PASS += 1 + print(f"PASS {name}") + else: + FAIL += 1 + print(f"FAIL {name}\n expected=[{expected}]\n actual =[{actual}]") + + +CONFIG = { + "profiles": { + "wsMulti": { + "cwd_match": ["ghq/github.com/wsMulti", "WSMULTI"], + "qdrant_url": "http://example.invalid:30333", + "workspace_name": "wsMulti", + }, + "wsSingle": { + "cwd_match": ["wsSingle"], + "workspace_name": "wsSingle", + }, + } +} + +tmp = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") +json.dump(CONFIG, tmp) +tmp.close() +workspace_profile.CONFIG_FILE = Path(tmp.name) +# Point the v2 location at a path that cannot exist so the v1 cases below are +# not silently served by whatever real v2 config happens to be on this machine. +setattr(workspace_profile, "CONFIG_FILE_V2", Path(tmp.name + ".absent-v2")) + +check( + "T1 multi-segment cwd_match matches", + "wsMulti", + workspace_profile.detect_workspace("/Users/x/ghq/github.com/wsMulti/repo"), +) +check( + "T2 single-component token still matches", + "wsSingle", + workspace_profile.detect_workspace("/Users/x/ghq/github.com/wsSingle/repo"), +) +check( + "T3 substring-only component does not match", + "default", + workspace_profile.detect_workspace("/Users/x/ghq/github.com/not-wsSingle-scratch"), +) +check( + "T4 unrelated path falls back to default", + "default", + workspace_profile.detect_workspace("/Users/x/somewhere/else"), +) +check( + "T5 matched profile resolves real endpoint (not the localhost default)", + "http://example.invalid:30333", + workspace_profile.get_profile( + target_path="/Users/x/ghq/github.com/wsMulti/repo" + )["qdrant_url"], +) + +# --- v2 schema (roles + kind) ----------------------------------------- +# The v2 config names roles rather than vendors, so a vendor swap is a +# one-line edit. Consumers here still read the flat vendor-named keys, so +# v2 must be translated back down to them — otherwise adopting v2 silently +# hands every consumer the placeholder defaults. +CONFIG_V2 = { + "version": 2, + "defaults": { + "backlog": {"kind": "none"}, + "checklist": {"kind": "file", "path": ".agents/fix_plan.md"}, + "rag": {"kind": "none"}, + "wiki": {"kind": "none"}, + }, + "profiles": { + "wsV2": { + "match": {"path_components": ["ghq/github.com/wsV2"]}, + "roles": { + "backlog": { + "kind": "plane", + "endpoint": "https://plane.v2.invalid", + "project": "proj-v2", + "token_env": "V2_TOKEN", + }, + "checklist": {"kind": "file", "path": ".agents/fix_plan.md"}, + "rag": { + "kind": "qdrant", + "endpoint": "http://v2.invalid:30333", + "collections": { + "memory": "v2-memory", + "task": "v2-task", + "wiki": "v2-wiki", + }, + }, + "wiki": {"kind": "git", "path": "/tmp/wsV2/llm-wiki"}, + }, + }, + "wsV2NoRag": { + "match": {"path_components": ["wsV2NoRag"]}, + "roles": {"rag": {"kind": "none"}}, + }, + }, +} + +tmp2 = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") +json.dump(CONFIG_V2, tmp2) +tmp2.close() +setattr(workspace_profile, "CONFIG_FILE_V2", Path(tmp2.name)) + +V2_PATH = "/Users/x/ghq/github.com/wsV2/repo" +check( + "T6 v2 config takes precedence over v1", + "wsV2", + workspace_profile.detect_workspace(V2_PATH), +) +p = workspace_profile.get_profile(target_path=V2_PATH) +check("T7 v2 rag endpoint -> qdrant_url", "http://v2.invalid:30333", p["qdrant_url"]) +check("T8 v2 collections -> flat keys", "v2-wiki", p["qdrant_wiki_collection"]) +check("T9 v2 backlog -> plane_host", "https://plane.v2.invalid", p["plane_host"]) +check("T10 v2 backlog token_env -> plane_token_env", "V2_TOKEN", p["plane_token_env"]) +check("T11 v2 wiki path -> llm_wiki_path", "/tmp/wsV2/llm-wiki", p["llm_wiki_path"]) +check("T12 v2 checklist path -> tracker_root", ".agents", p["tracker_root"]) + +# DEFAULT_PROFILE already carries workspace_name="default", so a setdefault +# cannot correct it. v1 configs hid this by naming workspace_name explicitly +# in every profile; a translated v2 profile must supply it too, or the report +# claims "default" while serving a matched profile's real endpoints. +check("T15 v2 profile reports its own workspace_name", "wsV2", p["workspace_name"]) + +# kind "none" must not fabricate an endpoint — it means "not configured". +pn = workspace_profile.get_profile(target_path="/Users/x/wsV2NoRag/repo") +check("T13 v2 kind=none leaves qdrant_url unset", "", pn["qdrant_url"]) + +# --- v2 config whose only profile omits `roles` (top-level defaults only) --- +# Regression: v2 was detected per-profile via `roles` presence. When no profile +# carries `roles`, the whole config was read as v1 and translated through +# v1_to_roles, replacing every top-level default receiver with the v1 +# placeholder. version==2 alone must translate it. +CONFIG_V2_BARE = { + "version": 2, + "defaults": { + "backlog": { + "kind": "plane", + "endpoint": "https://plane.bare.invalid", + "project": "bare-proj", + "token_env": "BARE_TOKEN", + }, + "checklist": {"kind": "file", "path": ".bare/fix_plan.md"}, + "rag": {"kind": "none"}, + "wiki": {"kind": "none"}, + }, + "profiles": {"wsBare": {"match": {"path_components": ["wsBare"]}}}, +} +tmp3 = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") +json.dump(CONFIG_V2_BARE, tmp3) +tmp3.close() +setattr(workspace_profile, "CONFIG_FILE_V2", Path(tmp3.name)) +pb = workspace_profile.get_profile(target_path="/tmp/wsBare/repo") +check("T16 no-roles v2 keeps default backlog", "https://plane.bare.invalid", pb["plane_host"]) +check("T17 no-roles v2 keeps default tracker", ".bare", pb["tracker_root"]) + +# v1 remains readable when no v2 file is present (migration window). +setattr(workspace_profile, "CONFIG_FILE_V2", Path(tmp2.name + ".absent")) +check( + "T14 falls back to v1 when v2 absent", + "wsMulti", + workspace_profile.detect_workspace("/Users/x/ghq/github.com/wsMulti/repo"), +) + +print("---") +print(f"pass={PASS} fail={FAIL}") +sys.exit(1 if FAIL else 0) diff --git a/skills/fix/CHANGELOG.md b/skills/fix/CHANGELOG.md index 505b78d1..e27833a7 100644 --- a/skills/fix/CHANGELOG.md +++ b/skills/fix/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.4.0](https://github.com/es6kr/skills/compare/fix-v0.3.12...fix-v0.4.0) (2026-08-20) + + +### Features + +* **fa:** add lightweight FA-record entry point ([8860a0c](https://github.com/es6kr/skills/commit/8860a0c98de74b909d04ee0d756d77234ab6a496)) +* promote next-feat batch (hook registry schema, self-reference path anchoring) ([0c33ffa](https://github.com/es6kr/skills/commit/0c33ffac99a9237f4530566470dabeaea128c209)) +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + + +### Bug Fixes + +* **fix:** correct escalation-matrix stage numbering + point to /fa ([fde51ea](https://github.com/es6kr/skills/commit/fde51eaf488d83a5e05aad31d31c713e87b57c48)) +* **hook-kit:** repair hook paths broken by the within-marketplace relocation ([#340](https://github.com/es6kr/skills/issues/340)) ([0d9b701](https://github.com/es6kr/skills/commit/0d9b70181015e4822c7cf5cd2fe122d5af708d26)) +* promote next-fix batch (hook path repair, topic-dispatch scoping, conflict diagnosis) ([eb7ecb6](https://github.com/es6kr/skills/commit/eb7ecb61dda9701d78f12dc810781dc7cb687caa)) + ## [0.3.12](https://github.com/es6kr/skills/compare/fix-v0.3.11...fix-v0.3.12) (2026-08-17) diff --git a/skills/fix/SKILL.md b/skills/fix/SKILL.md index 577036fb..b5460733 100644 --- a/skills/fix/SKILL.md +++ b/skills/fix/SKILL.md @@ -12,9 +12,12 @@ description: | Use when "fix:", "fix this", "correct", "why not", "why missing", "behavior fix" is mentioned. --- -# Fix: Behavior Correction Skill +# Fix: Behavior Correction & Work-Resume Skill + +Activated when user gives feedback with "fix:" prefix. Finds the root cause of the mistake, improves the relevant prompt (skill/rule/agent/memory/CLAUDE.md/hook), and **seamlessly resumes and completes the interrupted original work (`Fix -> Resume` Complete Workflow)**. + +> 💡 **Core Identity of `/fix`**: `/fix` is NOT a tool that only patches rules and stops. The primary purpose of this skill is the **complete two-phase flow: `Fix (5-Why & Prompt Improvement) ➔ Resume (Complete the original interrupted deliverable & hand over to next work)`**. Stopping after rule modification without fully executing and delivering the original work is a fundamental failure of this skill. -Activated when user gives feedback with "fix:" prefix. Finds the root cause of the mistake, improves the relevant prompt (skill/rule/agent/memory/CLAUDE.md/hook), and fixes the current issue. ## Trigger @@ -152,6 +155,8 @@ Do NOT use file-existence checks to detect the environment — both `.gemini/` a | 1 | Skip `/fix` step-by-step procedure when the correction seems simple or obvious | Always execute Step 0 (TodoWrite/task.md) first, followed sequentially by Step 1 (5-Why), Step 1.5, Step 2, Step 3, and Step 4 | | 2 | Directly modify files or execute commands on a `/fix` trigger before initializing the task checklist | Ensure `task.md` or `TodoWrite` is initialized as the very first tool call in the turn | +**Zero-content abusive invocation exception (HARD STOP)**: the "no trivial exception" rule above assumes the `/fix` arguments contain *some* identifiable behavior-correction content, however terse. When the arguments are **pure abuse with zero identifiable target** (e.g. a single insult with no instruction, no described mistake, no reference to prior turns), the full Step 0-4 procedure does not apply — there is nothing to run 5-Why analysis against. In this case: do not fabricate a target by guessing, and do not run TodoWrite/5-Why against an empty premise. Instead, briefly name the pattern (repeated content-free abusive messages) and set a boundary — invoke `AskUserQuestion` or plain text asking what specifically should be corrected, if the pattern is a first occurrence in the conversation. If abusive messages continue to recur with no content across multiple turns despite this, that is a candidate for `EndConversation` per its own tool guidance (sustained abuse, explicit prior warning required) — this exception does not itself authorize ending the conversation. The moment any subsequent `/fix` invocation in the same conversation contains identifiable content (even mixed with continued abusive language), the full Step 0-4 procedure resumes as normal — this exception is scoped to content-free invocations only, not to the presence of any abusive language. + **Recurrence pre-check (first step of Step 1) — MANDATORY 2-stage**: **Stage 0 — RAG semantic search (if RAG receiver available)**: @@ -184,22 +189,26 @@ The following general principles apply before entering the fix procedure. If you **If Why analysis identifies the above rules as root cause, the rules themselves do not need to be modified** — go deeper with Why 4-5 to ask "why was that rule ignored in the fix flow?". If the answer is "fix procedure forces a reactive flow" or "existing rules aren't applied automatically", do not trap the rule inside the fix skill — record it as a recurrence in failed-attempts.md (next candidate for hook automation). -Don't stop at the direct cause. Dig at least **3 levels deep**: +Don't stop at the direct cause. Dig **5 levels deep** to bridge cause analysis directly into Resume execution: ``` -Why 1: What went wrong? (symptom — the immediate mistake) -Why 2: Why did I make that decision? (judgment — missing knowledge/rule) -Why 3: Why was that knowledge/rule missing? (structural — skill/rule gap) +Why 1 (Symptom): What went wrong? (the immediate mistake) +Why 2 (Judgment): Why did I make that decision? (missing knowledge / flawed assumption) +Why 3 (Structural): What rule/prompt must be fixed to prevent recurrence? (target skill/rule/hook) +Why 4 (Interrupted Work): What was the original user request / deliverable that was interrupted by this failure? +Why 5 (Next Resume Action): What concrete actions must be executed NEXT to completely finish and deliver that original work? ``` -- Fixing only Why 1 = patching a symptom. It recurs in a different form. -- Why 2-3 reveal **structural causes** (platform ignorance, DRY violation, etc.) — these go into rules/skills. +- Why 1~3 identify **what to fix in prompts/rules/skills**. +- Why 4~5 identify **what to do next to resume and finish the original deliverable**. - Search for the responsible **skill/rule/hook** files (Grep/Glob) **Completion gate — do NOT proceed to Step 1.5 until ALL of these are true:** -1. Each issue has **Why 1, Why 2, Why 3, Why 4, Why 5** written out explicitly — stopping at Why 3 fails the gate. Why 4 = "why it wasn't followed (procedural/structural defect)", Why 5 = "where that defect originates (skill flow, missing automation, etc.)" -2. Why 5 identifies a **specific target** (skill, rule, hook, agent prompt, project config, etc.) to fix -3. No AskUserQuestion or implementation actions during Step 1 — analysis only +1. Each issue has **Why 1, Why 2, Why 3, Why 4, Why 5** written out explicitly — stopping at Why 3 fails the gate. Why 1~3 define the root cause & prompt fix, and Why 4~5 define the exact original work and the immediate next resume actions. +2. Why 3 identifies a **specific target** (skill, rule, hook, agent prompt, project config, etc.) to fix. +3. Why 5 identifies the **concrete sequence of actions** to be executed in Step 3 Resume. +4. No AskUserQuestion or implementation actions during Step 1 — analysis only. + ### 1.5. Action plan per Why (MANDATORY — required before entering Step 2) @@ -210,16 +219,14 @@ Why 3: Why was that knowledge/rule missing? (structural — skill/rule gap) ```text | Why | Target file : spot | Action | |-----|--------------------|--------| -| Why 1 | (current issue — resolved in Step 3) | Step 3 Resume | -| Why 2 | <rule-file> : staging section | Add rule: "Forbid alternative command selection on failure" | -| Why 3 | epic-bundle.md : Step 2 group line | by-theme → by-source-PR | -| Why 3 | epic-bundle.md : body template | per-PR section header | -| Why 3 | epic-bundle.md : Don't/Do table | add row | -| Why 3 | epic-bundle.md : self-check | add item | -| Why 5 | fix/SKILL.md : Step 2 Checkpoint | Add gate item | +| Why 1 | (current issue — symptom) | Root cause understanding | +| Why 2 | <rule-file> : judgment section | Add judgment rule | +| Why 3 | <skill/rule> : procedure/gate | Add structural gate & prompt fix | +| Why 4 | (interrupted original work) | Identify original deliverable scope | +| Why 5 | (target deliverable/tool) | Step 3 Resume: execute concrete next actions to finish original work | ``` -**Multi-spot enumeration rule (HARD STOP)**: a single Why often maps to **multiple spots inside one file** (procedure step + output template + Don't/Do table + self-check). Enumerate **one row per spot**, not one row per file. "Target file : spot" granularity is mandatory — a single `epic-bundle.md` row that hides 4 spots is what produces "fixed the output but missed the procedure" partial corrections. +**Multi-spot enumeration & Resume linkage rule (HARD STOP)**: A single Why often maps to multiple spots. Enumerate one row per spot. **Why 4~5 MUST produce explicit action rows detailing the concrete Step 3 Resume actions** (e.g. running scripts, completing report tables, issuing final Ask/Next). Omitting Resume action rows from the Action Plan table is strictly forbidden. **Action types**: - Edit/Write → execute in Step 2 diff --git a/skills/fix/resources/fix-and-ambiguity-guard.sh b/skills/fix/resources/fix-and-ambiguity-guard.sh index 4cc96075..8267ded9 100755 --- a/skills/fix/resources/fix-and-ambiguity-guard.sh +++ b/skills/fix/resources/fix-and-ambiguity-guard.sh @@ -10,9 +10,11 @@ PROMPT="$(echo "$INPUT" | python3 -c "import sys, json; d = json.load(sys.stdin) [[ -z "$PROMPT" ]] && exit 0 -# Locale detection patterns live in git-ignored data/ (Korean + English). The hook -# carries English-only fallbacks so the PUBLIC copy works without the data file. -HG_DATA_FILE="$(dirname "$0")/../data/hangul-patterns.regex" +# Locale detection patterns live in git-ignored hook-kit/data/ (Korean + English). +# The hook carries English-only fallbacks so the PUBLIC copy works without the data +# file. The path stays hook-kit-relative because the regex data did not move when +# this guard was relocated into the fix skill. +HG_DATA_FILE="$(dirname "$0")/../../hook-kit/data/hangul-patterns.regex" [ -f "$HG_DATA_FILE" ] && . "$HG_DATA_FILE" FIX_AMBIGUITY_OPTION_VERB="${FIX_AMBIGUITY_OPTION_VERB:-(handle|process|deal[[:space:]]with|block|hold|defer|address)[[:space:]]+([0-9]+,[0-9]+(,[0-9]+)*|option[[:space:]]*[0-9]+|item[[:space:]]*[0-9]+)}" FIX_CLAIM_PHRASING="${FIX_CLAIM_PHRASING:-(did[[:space:]]?n.?t|does[[:space:]]?n.?t|not)[[:space:]]+work|is[[:space:]]?n.?t[[:space:]]+working|why[[:space:]]+(not|again|keep|do you|did)}" diff --git a/skills/fix/scripts/detect-agent-env.sh b/skills/fix/scripts/detect-agent-env.sh new file mode 100644 index 00000000..92583ec1 --- /dev/null +++ b/skills/fix/scripts/detect-agent-env.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# detect-agent-env.sh — Detect which AI agent environment is currently running +# Usage: bash detect-agent-env.sh +# Output line 1: "claude-code" | "antigravity" | "antigravity-agent" | "antigravity-ide" | "cursor" | "vscode" | "unknown" +# Output lines 2+: routing table (RULES_FILE, SETTINGS_FILE, SHARED_RULES_DIR) + +detect_env() { + # --- 1. Agent-specific env vars (Top priority: identify agent) --- + # Claude Code injects its own environment variable regardless of IDE + if [[ -n "${CLAUDE_CODE:-}" ]]; then + echo "claude-code" + return + fi + + # Antigravity agent (injects ANTIGRAVITY_AGENT=1 on Windows) + if [[ "${ANTIGRAVITY_AGENT:-}" == "1" ]]; then + echo "antigravity-agent" + return + fi + + # --- 2. macOS IDE fallback: __CFBundleIdentifier --- + # Only infer from IDE when agent-specific env vars are absent + # Note: Claude Code running inside Antigravity IDE is caught above by CLAUDE_CODE + case "${__CFBundleIdentifier:-}" in + com.google.antigravity) echo "antigravity"; return ;; + com.google.antigravity-ide) echo "antigravity-ide"; return ;; + com.todesktop.230313mzl4w4u92) echo "cursor"; return ;; + com.microsoft.VSCode) echo "vscode"; return ;; + esac + + echo "unknown" +} + +ENV=$(detect_env) +echo "$ENV" + +# Emit routing table for the caller +case "$ENV" in + antigravity|antigravity-agent|antigravity-ide) + echo "RULES_FILE=GEMINI.md" + echo "SETTINGS_FILE=$HOME/.gemini/config/config.json" + echo "SHARED_RULES_DIR=$HOME/.agents/rules (READ-ONLY)" + ;; + claude-code) + echo "RULES_FILE=CLAUDE.md" + echo "SETTINGS_FILE=$HOME/.claude/settings.json" + echo "SHARED_RULES_DIR=$HOME/.claude/rules (WRITABLE)" + ;; + cursor|vscode) + echo "RULES_FILE=CLAUDE.md" + echo "SETTINGS_FILE=$HOME/.claude/settings.json" + echo "SHARED_RULES_DIR=$HOME/.claude/rules (WRITABLE)" + ;; + unknown) + echo "RULES_FILE=unknown" + echo "SETTINGS_FILE=unknown" + echo "SHARED_RULES_DIR=unknown" + ;; +esac diff --git a/skills/fix/step4-wrapup.md b/skills/fix/step4-wrapup.md index 18d4df5d..01258c05 100644 --- a/skills/fix/step4-wrapup.md +++ b/skills/fix/step4-wrapup.md @@ -16,6 +16,7 @@ If /fix has been invoked 2+ times in this session, **before** marking fix-* task 3. **Register outstanding work as new tasks** (without the fix-* prefix) 5. **Suggest Next Actions (Only When ALL Tasks Completed — HARD STOP)**: Completing active tasks is top priority. Present active task decision/confirmation (`AskUserQuestion`) FIRST if open questions exist. Invoke `next` / `AskUserQuestion` for next actions ONLY when ALL registered tasks in `task.md` or `TaskList` are 100% completed (`[x]`). If uncompleted tasks (`pending` or `in_progress`) remain or active task asks exist, DO NOT invoke `next` or present next action options — directly proceed to complete the task or ask the active decision item in the same turn. 6. **Call `Skill("next")` after the wrap-up report when the batch is complete (HARD STOP — the mirror of step 5)**: step 5 forbids a premature `next`; this step forbids the omitted one. When every registered task IS completed and the turn is ending on the wrap-up report, the same turn MUST include a `Skill("next")` call (its own gates then decide whether an ask follows). A **mid-turn AskUserQuestion on another axis** (push confirmation, trade-off answer, option selection) does NOT substitute for this call. Do not rely on the Stop-hook safety net: on a continuation chain (a turn resumed from an earlier Stop-hook block), the harness suppresses every later stop's hooks (`stop_hook_active` loop prevention), so long chained /fix turns are precisely where only the explicit call fires (next-invocation family recurrence evidence). + **Context-gate precheck (HARD STOP — run immediately BEFORE the `Skill("next")` call)**: re-measure live context usage via the injection script first. A below-threshold reading from earlier in the turn/chain is **stale-LOW** and proves nothing — a full /fix flow can grow usage tens of points past the old number. If the live reading is at/above the session model's threshold (or the script emits a `CLEANUP-GATE` directive), lead the turn-final ask with a **Recommended cleanup/retrospective option citing the live percentage** — next-action candidate discovery becomes secondary and may be skipped (recommend cleanup instead of composing next-action candidates). ```text diff --git a/skills/git-repo/CHANGELOG.md b/skills/git-repo/CHANGELOG.md index b9dbfd96..76d4320d 100644 --- a/skills/git-repo/CHANGELOG.md +++ b/skills/git-repo/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.9.0](https://github.com/es6kr/skills/compare/git-repo-v0.8.1...git-repo-v0.9.0) (2026-08-20) + + +### Features + +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + + +### Bug Fixes + +* **git-repo:** document conflict root-cause diagnosis (staleness vs divergence) ([f034168](https://github.com/es6kr/skills/commit/f0341685d6bf07c9d16e9375c93c22fea88b454f)) +* **git-repo:** document conflict root-cause diagnosis (staleness vs divergence) ([3f8e492](https://github.com/es6kr/skills/commit/3f8e4924bd8a656ea3fc1eeb573c9a629fea80cd)) +* promote next-fix batch (hook path repair, topic-dispatch scoping, conflict diagnosis) ([eb7ecb6](https://github.com/es6kr/skills/commit/eb7ecb61dda9701d78f12dc810781dc7cb687caa)) + ## [0.8.1](https://github.com/es6kr/skills/compare/git-repo-v0.8.0...git-repo-v0.8.1) (2026-08-17) diff --git a/skills/git-repo/SKILL.md b/skills/git-repo/SKILL.md index b4c2058f..a990978b 100644 --- a/skills/git-repo/SKILL.md +++ b/skills/git-repo/SKILL.md @@ -225,6 +225,7 @@ Key features: 1. **Repository migration**: Migrate to ghq structure with `to-ghq` topic (or `to-bare` for the inverse) 2. **SourceGit update**: Register new paths with `sourcegit` topic 3. **Batch inspection**: Clean up uncommitted/unpushed changes with `patrol` topic +4. **Baseline .gitignore hygiene**: Ensure standard ignore rules (`.worktrees/`, `.DS_Store`, `__pycache__/`, `*.bak`, `*.tmp`) in `.gitignore` or `.git/info/exclude` to isolate worktrees and suppress runtime/editor clutter ## Scripts diff --git a/skills/git-repo/clone.md b/skills/git-repo/clone.md index 755517f1..83b24cf1 100644 --- a/skills/git-repo/clone.md +++ b/skills/git-repo/clone.md @@ -75,6 +75,22 @@ Parse cloned path from output: - Extract path from `clone https://... -> /path/to/repo` format +### Step 1.5: Repository `.gitignore` Hygiene Baseline Check + +Verify that the cloned repository has standard baseline ignores configured (or add to `.git/info/exclude` / `.gitignore`): + +- `.worktrees/` (Worktree isolation) +- `.DS_Store` (OS junk) +- `__pycache__/` (Python bytecode cache) +- `*.bak` (Local backup files) +- `*.tmp` (Temporary files) + +```bash +# Verify baseline ignore patterns +cd /path/to/repo +grep -q "^\.worktrees/" .gitignore 2>/dev/null || echo -e "\n# Worktrees & Runtime\n.worktrees/\n.DS_Store\n__pycache__/\n*.bak\n*.tmp" >> .gitignore +``` + ### Step 2: Parse Repository Info Extract host/group/repo from URL: diff --git a/skills/git-repo/conflict-dry-run.md b/skills/git-repo/conflict-dry-run.md index 8bcd7914..b511584d 100644 --- a/skills/git-repo/conflict-dry-run.md +++ b/skills/git-repo/conflict-dry-run.md @@ -71,6 +71,26 @@ If a safety hook blocks `git merge --abort` (treating it as "discarding in-progr | 2 | Trust `git merge-tree <base> <a> <b>` (legacy 2-tree form) with the wrong base argument (e.g. passing one of the branches itself as base) | Compute the actual merge-base first (`git merge-base <a> <b>`) and pass that, or just do a real `git merge --no-commit` in an isolated worktree — it's authoritative where `merge-tree` misuse is not | | 3 | Interpret a platform's `mergeable: false` / `mergeable_state: "dirty"` API field as possibly stale without checking | Reproduce locally via this dry-run procedure before concluding the platform's mergeability computation is wrong | | 4 | Leave the scratch worktree registered after the test | Remove it (`git worktree remove --force`) unless a safety hook blocks the abort step — in that case leaving it is harmless (see Step 3) | +| 5 | With `git merge-tree --write-tree <branch1> <branch2>` (no explicit base), read the printed unmerged stages by intuition | Stage numbers are fixed regardless of which side "looks like" base: **1 = merge-base, 2 = branch1 (ours), 3 = branch2 (theirs)**. Mislabeling these inverts which side's content you think is which — verify by diffing each stage's blob against the actual ref tips, don't assume from context | + +## Diagnosing why a conflict exists (base staleness vs. genuine divergence) + +A conflict against an accumulation branch has two structurally different causes that call for different fixes: + +- **Base staleness**: the target base is behind another branch it should track — switching base or rebasing resolves it trivially +- **Genuine parallel divergence**: an equivalent fix already landed on the target via a different commit, so the conflicting commit's content is now partially or fully redundant — switching base changes nothing (the same conflict reproduces against any branch containing that equivalent fix) + +Before assuming staleness, check whether the conflict is redundant, not just stale: + +```bash +# Does the target already contain equivalent content for the touched files? +git diff origin/<candidate-base-A> origin/<candidate-base-B> -- <touched-paths> # empty = both bases identical here, switching base won't help + +# Is the conflicting commit's content already upstream (a "straggler")? +git cherry -v origin/<base> <branch> # '-' prefix = already upstream (delta 0), '+' = real delta +``` + +`git cherry -v` showing `+` (real delta) does not rule out redundancy at the *file* level — a commit can carry one hunk that's genuinely new and another that's fully superseded by a separate, already-merged commit. When that's the case, resolving via `git rebase <target>` and taking the target's version for the superseded hunk often makes the redundant sub-commit collapse to empty (git drops it automatically) while the genuinely new content survives untouched — a cleaner outcome than manually splicing the two versions. ## Related diff --git a/skills/git-repo/to-bare.md b/skills/git-repo/to-bare.md index 38abd2b6..eb74a2da 100644 --- a/skills/git-repo/to-bare.md +++ b/skills/git-repo/to-bare.md @@ -64,5 +64,5 @@ git -C <target-path> branch -a # remote branches reachable via ## Notes -- If `<target-path>` is inside another repo's working tree (e.g. `~/.agents/.claude/worktrees/`), confirm that location is gitignored there so the foreign worktree does not pollute that repo's status. +- If `<target-path>` is inside another repo's working tree (e.g. `~/.agents/.claude/worktrees/` or `<repo>/.worktrees/`), confirm that location is gitignored there (`.worktrees/` in `.gitignore` along with baseline hygiene patterns: `__pycache__/`, `.DS_Store`, `*.bak`, `*.tmp`) so the foreign worktree does not pollute that repo's status. - The bare's default branch can be checked out by exactly one worktree; the bare itself has no checkout, so the branch is free. diff --git a/skills/git-repo/to-ghq.md b/skills/git-repo/to-ghq.md index cb2fe158..7102968d 100644 --- a/skills/git-repo/to-ghq.md +++ b/skills/git-repo/to-ghq.md @@ -56,6 +56,9 @@ scripts/repo-to-ghq.sh --no-symlink ~/ghq/local/archive/<name> cd ~/ghq/host/group/repo git status git log --oneline -1 + +# Verify baseline .gitignore hygiene (.worktrees/, .DS_Store, __pycache__/, *.bak, *.tmp) +grep -q "^\.worktrees/" .gitignore 2>/dev/null || echo -e "\n# Worktrees & Runtime\n.worktrees/\n.DS_Store\n__pycache__/\n*.bak\n*.tmp" >> .gitignore ``` ### Step 4: Update SourceGit @@ -192,5 +195,5 @@ The `repo-to-ghq.sh` script: ## Output ``` -Repository moved to '/Users/es6kr/ghq/github.com/org/repo'. +Repository moved to '~/ghq/github.com/org/repo'. ``` diff --git a/skills/git-repo/worktree.md b/skills/git-repo/worktree.md index 710ee313..aebd0742 100644 --- a/skills/git-repo/worktree.md +++ b/skills/git-repo/worktree.md @@ -74,18 +74,31 @@ A worktree is **repurposable** when both checks are empty (fully pushed + clean) ### 3. Decision — reuse or create +**Cost gate first (HARD STOP).** What reuse buys back is a **dependency install / build cache**, not the worktree directory. Where no dependency manifest exists, a fresh worktree is a checkout and nothing more — reuse saves seconds, while renaming a branch destroys whatever intent its name encoded. Classify the repo before consulting the tree: + +```bash +ls <repo>/package.json <repo>/pnpm-lock.yaml <repo>/yarn.lock <repo>/Cargo.toml \ + <repo>/go.mod <repo>/pom.xml <repo>/build.gradle <repo>/requirements.txt \ + <repo>/uv.lock <repo>/Gemfile +``` + +Any hit → **heavy**. No hit → **lightweight** (a docs or shell-skill repo, for instance). + ``` -inactive candidates found? -├─ YES → AskUserQuestion: which one to reuse? -│ ├─ User selects one → Step 4A (rename/move) -│ └─ User says "create new" → Step 4B (new) -└─ NO → worktree count over the limit (see "Inactive Worktree Count Limit")? - ├─ YES → repurposable candidate found (§2.5)? → oldest one → Step 4A (rename/move) - │ └─ none found → report to user, ask before creating new - └─ NO → Step 4B (new) +repo has a dependency manifest / lockfile? +├─ NO (lightweight) → Step 4B (new). Reuse is opt-in — offer it only if the user asks. +└─ YES (heavy) ↓ + inactive candidates found? + ├─ YES → AskUserQuestion: which one to reuse? + │ ├─ User selects one → Step 4A (rename/move) + │ └─ User says "create new" → Step 4B (new) + └─ NO → worktree count over the limit (see "Inactive Worktree Count Limit")? + ├─ YES → repurposable candidate found (§2.5)? → oldest one → Step 4A (rename/move) + │ └─ none found → report to user, ask before creating new + └─ NO → Step 4B (new) ``` -**AskUserQuestion options must include both reuse and new-create** when inactive candidates exist. +**In a heavy repo, AskUserQuestion options must include both reuse and new-create** when inactive candidates exist. In a lightweight repo the same offer is noise — new-create is the recommended option, and a branch name that encodes planned intent is preserved rather than renamed. ### 4A. Reuse via rename or move @@ -133,6 +146,34 @@ cd .worktrees/<branch-name> git branch --show-current ``` +#### 4B-1. Default `.gitignore` Baseline Gate (HARD STOP) + +When standardizing on `<repo>/.worktrees/`, ensuring that `.worktrees/` is ignored in the root repository's `.gitignore` (or `.git/info/exclude`) is **mandatory**. Without this ignore rule, nested worktree trees, dirty edits, and untracked branches will bleed into `git status` in the main repository. + +Baseline default `.gitignore` patterns required for every managed repository: + +```gitignore +# Runtime & Worktree Isolation +.worktrees/ + +# OS / Editor Junk +.DS_Store + +# Language Caches & Bytecode +__pycache__/ + +# Backup & Transient Temp Files +*.bak +*.tmp +``` + +When creating a new worktree or initializing/migrating a repo, verify: + +```bash +# Verify .worktrees/ is excluded +grep -q "^\.worktrees/" .gitignore 2>/dev/null || echo ".worktrees/" >> .gitignore +``` + ### 5. Post-acquisition check (MANDATORY) Before writing any code in the worktree: @@ -200,19 +241,21 @@ For plain-base repos (no staging tier), steps 2-5 are manual: run the reuse-firs | 3 | Create worktree outside `.worktrees/` | Use `.worktrees/` | | 4 | Start coding without branch verification | `git branch --show-current` before any Write/Edit | | 5 | Chain `git checkout -b <new> <ref>` immediately followed by `git cherry-pick`/`git reset`/other git commands in a repo with a large pre-existing dirty working tree (e.g. `~/.agents`) | In-place checkout can fail silently ("local changes would be overwritten") while staying on the original branch, so the chained command runs on the wrong branch. Prefer `git branch <new> <ref>` (no working-tree switch) + `git worktree add <path> <new>` from the start when the repo is known to carry unrelated uncommitted content; if in-place checkout is used anyway, verify `git branch --show-current` before the next command (see failed-attempts.md "git-checkout-unverified-chain", 2 occurrences) | -| 5 | Delete inactive worktrees to "clean up" | Reuse them — rename is cheaper than delete+create (subject to count limit below) | -| 6 | Treat unmerged status codes (`DU`/`UU`/`AA`…) as plain dirty files and offer discard/stash/`git add` resolution | Unmerged entries = a conflicted operation is mid-flight (§2 Step 2.0 gate). Exclude the worktree from candidates + report the in-progress operation to the user | -| 7 | Classify "merged + ahead=0 + dirty" as abandoned leftovers | Run the operation-state gate first — a merged branch can host an in-progress cherry-pick applying new work on top | -| 8 | Check multiple state files with one `ls fileA fileB fileC 2>/dev/null \|\| echo "no in-progress op"` call | `ls` returns nonzero if **any** argument is missing, even while printing the paths of the ones that DO exist — a partial hit still fires the `\|\|` fallback and prints a false "no in-progress op" alongside the real hit. Check each file individually (see the operation-state gate command above), and always re-read the raw stdout before trusting a fallback message (see failed-attempts.md "ls multi-arg false negative") | -| 9 | Reuse an inactive worktree via `rename-worktree.sh` (or a manual `git checkout -b` inside it) without checking its parent directory | Before reusing, confirm the worktree's parent directory is already `<repo>/.worktrees/` — if not, relocate via [move-worktree](./move-worktree.md) Scenario B first, then rename/switch branch | +| 6 | Delete inactive worktrees to "clean up" | Reuse them — rename is cheaper than delete+create (subject to count limit below) | +| 7 | Treat unmerged status codes (`DU`/`UU`/`AA`…) as plain dirty files and offer discard/stash/`git add` resolution | Unmerged entries = a conflicted operation is mid-flight (§2 Step 2.0 gate). Exclude the worktree from candidates + report the in-progress operation to the user | +| 8 | Classify "merged + ahead=0 + dirty" as abandoned leftovers | Run the operation-state gate first — a merged branch can host an in-progress cherry-pick applying new work on top | +| 9 | Check multiple state files with one `ls fileA fileB fileC 2>/dev/null \|\| echo "no in-progress op"` call | `ls` returns nonzero if **any** argument is missing, even while printing the paths of the ones that DO exist — a partial hit still fires the `\|\|` fallback and prints a false "no in-progress op" alongside the real hit. Check each file individually (see the operation-state gate command above), and always re-read the raw stdout before trusting a fallback message (see failed-attempts.md "ls multi-arg false negative") | +| 10 | Reuse an inactive worktree via `rename-worktree.sh` (or a manual `git checkout -b` inside it) without checking its parent directory | Before reusing, confirm the worktree's parent directory is already `<repo>/.worktrees/` — if not, relocate via [move-worktree](./move-worktree.md) Scenario B first, then rename/switch branch | +| 11 | Create `<repo>/.worktrees/` without ignoring it in `.gitignore` | Ensure `.worktrees/` and baseline hygiene patterns (`__pycache__/`, `.DS_Store`, `*.bak`, `*.tmp`) exist in `.gitignore` or `.git/info/exclude` | -### Self-check (before reusing any inactive/repurposable candidate — §3/§4A) +### Self-check (before reusing or creating any worktree — §3/§4) -1. Is the candidate's path already `<repo>/.worktrees/<name>`? Check with `git worktree list` — the path column shows the full location. -2. If not (a legacy `.claude/worktrees/`, a sibling `<repo>-wt/`, a bare `~/.worktrees/`, or anything else) → relocate via move-worktree.md Scenario B **before** renaming/switching branch — do not reuse in place and leave the wrong location to persist across future reuse cycles. -3. Only after the path is confirmed canonical, proceed with rename-worktree.sh or the manual branch switch. +1. Is `<repo>/.gitignore` configured to ignore `.worktrees/` and baseline hygiene patterns (`__pycache__/`, `.DS_Store`, `*.bak`, `*.tmp`)? +2. Is the candidate's path already `<repo>/.worktrees/<name>`? Check with `git worktree list` — the path column shows the full location. +3. If not (a legacy `.claude/worktrees/`, a sibling `<repo>-wt/`, a bare `~/.worktrees/`, or anything else) → relocate via move-worktree.md Scenario B **before** renaming/switching branch — do not reuse in place and leave the wrong location to persist across future reuse cycles. +4. Only after the path is confirmed canonical, proceed with rename-worktree.sh or the manual branch switch. -Steps 1-2 can be run mechanically: `scripts/check-worktree-canonical.sh <repo> <candidate-name>` (exit 0 = canonical / 1 = non-canonical, prints the Scenario B move command / 2 = not registered). +Steps 2-3 can be run mechanically: `scripts/check-worktree-canonical.sh <repo> <candidate-name>` (exit 0 = canonical / 1 = non-canonical, prints the Scenario B move command / 2 = not registered). ## Inactive Worktree Count Limit (HARD STOP) @@ -226,10 +269,13 @@ Reuse via rename is the default for inactive worktrees (Don't/Do rule #5). Howev ### Decision matrix -| Inactive count (after cleanup of just-completed worktree) | Action for the just-completed worktree | Rationale | -|-----------------------------------------------------------|----------------------------------------|-----------| -| ≤ 5 | **B: reuse** — `git checkout --detach origin/main` + `git branch -D <feature>` | Pool is healthy. Reuse avoids the cost of fresh worktree creation (~10-30s + ENOSPC risk on small `.git/worktrees`) | -| > 5 | **A: remove** — `git worktree remove <path>` + `git branch -D <feature>` | Pool is full. Removing the just-completed worktree (rather than an older inactive one) avoids touching others' historical workspaces | +| Repo weight | Inactive count (after cleanup of just-completed worktree) | Action for the just-completed worktree | Rationale | +|-------------|-----------------------------------------------------------|----------------------------------------|-----------| +| **Lightweight** (no dependency manifest — see §3 cost gate) | any | **A: remove** — `git worktree remove <path>` + `git branch -D <feature>` | There is nothing to preserve: a replacement worktree is a plain checkout. Keeping it only grows `git worktree list` and the cost of every future reuse-vs-create decision | +| Heavy | ≤ 5 | **B: reuse** — `git checkout --detach origin/main` + `git branch -D <feature>` | Pool is healthy. What reuse preserves is the installed dependency tree / build cache — that is the actual saving, not the directory | +| Heavy | > 5 | **A: remove** — `git worktree remove <path>` + `git branch -D <feature>` | Pool is full. Removing the just-completed worktree (rather than an older inactive one) avoids touching others' historical workspaces | + +**Lightweight repos — removal is offerable at any completion stage.** Once work in a lightweight repo's worktree reaches a completion point — pushed, PR opened, or merged — offering to remove that worktree is appropriate; it need not be held as a reuse candidate. Ask rather than remove silently, since the user may still be reading the diff or expecting review feedback. ### Don't / Do @@ -312,13 +358,15 @@ User decision 2026-05-24 after PR #160 merge cleanup of `agent-abbddf41` worktre ## Inactive worktree inventory before creating a new one (HARD STOP) -**When a worktree is needed, inspect existing worktrees before creating a new one with `git worktree add`.** +**In a heavy repo, inspect existing worktrees before creating a new one with `git worktree add`.** Run the §3 cost gate first — this inventory obligation applies only where a fresh worktree would cost a dependency install or build. | # | Don't | Do | |---|-------|-----| -| 1 | Default to creating a new worktree with `git worktree add` whenever one is needed | Run `git worktree list` first → identify inactive / merged-PR worktrees → reuse via `/git-repo rename-worktree` or `/git-repo move-worktree` | -| 2 | AskUserQuestion options default to "create new and remove later" | Include "rename and reuse an inactive worktree" whenever at least one inactive candidate exists | -| 3 | Ignore worktrees pinned at the merge commit of a merged PR | The base commit hash matching a merge commit = a reuse candidate | +| 1 | Apply the inventory-first obligation regardless of repo weight | Run the §3 cost gate first. Heavy → inventory first. Lightweight → create new and move on | +| 2 | In a heavy repo, default to creating a new worktree with `git worktree add` whenever one is needed | Run `git worktree list` first → identify inactive / merged-PR worktrees → reuse via `/git-repo rename-worktree` or `/git-repo move-worktree` | +| 3 | In a heavy repo, let AskUserQuestion options default to "create new and remove later" | Include "rename and reuse an inactive worktree" whenever at least one inactive candidate exists | +| 4 | Ignore worktrees pinned at the merge commit of a merged PR (heavy repo) | The base commit hash matching a merge commit = a reuse candidate | +| 5 | In a lightweight repo, surface inactive candidates as if reuse were preferable | Create new. Mention candidates only if the user asks, or to offer removal of ones already finished | See the "Worktree decision tree" section above for the full procedure. @@ -335,7 +383,7 @@ See the "Worktree decision tree" section above for the full procedure. | 3 | Assume "only my changes are staged, so other changes don't matter" | The same push can include unpushed commits from another task, and other dirty working-directory state can leak into the next step | | 4 | Omit "split into worktree" from the commit-options AskUserQuestion list | When branch is main/master/develop AND there are 1+ other-task changes, "split into worktree" is a required option | | 5 | Leave another task's unstaged changes in place and push only the new commit | Confirm the other-task intent (report to the user) → split into a worktree or separate it into another task | -| 6 | **Place "create new worktree" as option 1 / Recommended when inactive candidates exist** | **If 1+ inactive worktree candidates exist, place "rename and reuse" as option 1 / Recommended**. New goes to option 2 or lower | +| 6 | **In a heavy repo, place "create new worktree" as option 1 / Recommended when inactive candidates exist** | **In a heavy repo with 1+ inactive candidates, place "rename and reuse" as option 1 / Recommended**; new goes to option 2 or lower. **In a lightweight repo the ordering reverses** — new-create is option 1, because reuse saves nothing there and renaming discards a branch name's intent | | 7 | Assume "worktree split = move the working-tree changes out of the current repo" (stash + checkout) when the current repo is a live runtime environment whose working tree state is actively consumed by the user (e.g., `~/.agents` — rules are loaded always_on, skills are hardlinked to `~/.claude/skills/`) | Distinguish two split modes: **(a) move** — stash + checkout to a new branch (default for one-off feature work) vs **(b) copy** — leave the source working tree untouched + replicate the diff into a separate worktree via `cp`/`rsync` and commit there. Use (b) whenever the source repo's working tree is a live runtime environment. The source working tree must not change state for the user during commit/PR | ### Self-check (every time before presenting commit options) diff --git a/skills/github-flow/CHANGELOG.md b/skills/github-flow/CHANGELOG.md index 7d2c3cfb..241b1c97 100644 --- a/skills/github-flow/CHANGELOG.md +++ b/skills/github-flow/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.9.0](https://github.com/es6kr/skills/compare/github-flow-v0.8.3...github-flow-v0.9.0) (2026-08-20) + + +### Features + +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + ## [0.8.3](https://github.com/es6kr/skills/compare/github-flow-v0.8.2...github-flow-v0.8.3) (2026-08-17) diff --git a/skills/github-flow/SKILL.md b/skills/github-flow/SKILL.md index 37012005..7a9a403b 100644 --- a/skills/github-flow/SKILL.md +++ b/skills/github-flow/SKILL.md @@ -27,7 +27,7 @@ Convert plans, research, and implementation results into GitHub issues and PRs. | identity-auth | Owner-based gh account mapping for commit author identity + gh auth scope refresh + GH_TOKEN env fallback for org repo 404 | [identity-auth.md](./identity-auth.md) | | merge | CI success and AI review check then merge with commit cleanup, including pre-merge blockedBy verification | [merge.md](./merge.md) | | plan-to-issue | Convert plan/research MD to GitHub issue body or comments | [plan-to-issue.md](./plan-to-issue.md) | -| pr | Create PR with structured body, test plan, and optional visual attachments. Multi-PR references in an AskUserQuestion payload are enforced by `resources/block-pr-url-gate.sh` (PreToolUse:AskUserQuestion, registered in `settings.json`) — every distinct PR number needs its own clickable URL | [pr.md](./pr.md) | +| pr | Create PR with structured body, test plan, and optional visual attachments. Every distinct PR number in an AskUserQuestion payload needs its own clickable URL — this is an authoring rule you apply yourself. `resources/block-pr-url-gate.sh` exists but is **not currently registered in any hook config**, so nothing enforces it at runtime | [pr.md](./pr.md) | | publish | Package a working-tree change into its own branch + draft PR against a staging-base branch, watch CI, ready-transition, content-review ask, merge — the full repeated sequence in one topic | [publish.md](./publish.md) | | push-guards | Branch-change ask + push rejection ask + force-push CI status check + main/master push restriction + shared-branch direct-push restriction | [push-guards.md](./push-guards.md) | | register | Evaluate duplicates and decide registration strategy (new issue vs comment vs sub-issue) | [register.md](./register.md) | diff --git a/skills/github-flow/merge.md b/skills/github-flow/merge.md index d743ba1a..f9bd2100 100644 --- a/skills/github-flow/merge.md +++ b/skills/github-flow/merge.md @@ -83,6 +83,7 @@ For projects pushing directly to master (e.g., infra-provisioning repos), commit ``` 2. **No Summary comment → unconditionally auto-invoke `/consolidate pr`**: + - **Antigravity Unapproved PR clawo Delegation Gate (HARD STOP)**: In Antigravity (Gemini), directly executing `/consolidate pr` or remediating unapproved PRs in the main chat turn is strictly forbidden (`HARD STOP`). You MUST delegate unapproved PR review consolidation and remediation to `clawo` (`clawo session-send <session_name>` or `Skill("clawo", "launch")`). - **If any AI review (CodeRabbit / Copilot / etc.) is present, consolidate is the default** — not optional - After consolidate, confirm the Summary comment was posted → continue to Step 3 - **Forbidden**: asking the user a merge / apply option without the Summary. consolidate must run first diff --git a/skills/github-flow/resources/block-pr-url-gate.sh b/skills/github-flow/resources/block-pr-url-gate.sh new file mode 100644 index 00000000..5c682c4e --- /dev/null +++ b/skills/github-flow/resources/block-pr-url-gate.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# block-pr-url-gate.sh — PR/issue reference gate for decision-UI payloads. +# +# Registered under two matchers (see hooks/hooks.json): +# - PreToolUse:AskUserQuestion — every distinct PR/issue number surfaced in +# a question/option needs its own clickable full URL somewhere in that +# same ask. A bare "#N" is ambiguous once the ask can span multiple repos. +# Rule: wip/resume.md "Per-item direction ask" Don't/Do row 6. +# - PreToolUse:TaskCreate — a PR/issue reference in a task `subject` needs +# a repo qualifier in the subject itself (e.g. "owner/repo PR #N: ..."), +# because TaskList never displays `description`. +# Rule: wip/resume.md "Medium separation principle" Don't/Do row 5. +# +# Both checks share one shape: bare "#N" present, but no accompanying +# repo-qualified reference (full GitHub URL for the ask case, "owner/repo" +# token for the TaskCreate case) present in the same payload. + +set -uo pipefail + +INPUT=$(cat) + +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) +[[ "$TOOL_NAME" != "AskUserQuestion" && "$TOOL_NAME" != "TaskCreate" ]] && exit 0 + +BARE_REF_PATTERN='#[0-9]+' +FULL_URL_PATTERN='https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/(pull|issues)/[0-9]+' +REPO_QUALIFIER_PATTERN='[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+[^/#]{0,20}#[0-9]+' + +# ============================================================================ +# AskUserQuestion: bare "#N" without a full clickable URL anywhere in the ask +# ============================================================================ +check_ask_bare_ref() { + local ask_text + ask_text=$(echo "$INPUT" | jq -r ' + .tool_input.questions[]? | + (.question // ""), + (.options[]? | (.label // ""), (.description // "")) + ' 2>/dev/null) + + [[ -z "$ask_text" ]] && return 0 + echo "$ask_text" | grep -qE "$BARE_REF_PATTERN" || return 0 + echo "$ask_text" | grep -qE "$FULL_URL_PATTERN" && return 0 + + cat >&2 <<'MSG' +DENIED: AskUserQuestion references a PR/issue by bare "#N" without a full URL. + +Why blocked: + - The question text or an option's label/description contains "#N" + - But no full clickable URL (https://github.com/<owner>/<repo>/pull|issues/<N>) + appears anywhere in this same ask + +Why this matters: + - An ask can surface work spanning multiple repos. A bare "#N" is + ambiguous the moment a second repo's PR/issue could also match that + number — the user cannot tell which repo it refers to without opening + a separate lookup. + +Required action (pick one before retrying): + 1. Add the full URL next to the "#N" reference (e.g. "PR #184 + (https://github.com/<owner>/<repo>/pull/184)") + 2. If multiple PR/issue numbers appear, give each its own full URL + 3. Run `gh pr view <N>` / `gh issue view <N>` first to confirm the URL + before including it — do not fabricate one + +Reference: wip/resume.md "Per-item direction ask" Don't/Do row 6. +MSG + exit 2 +} + +# ============================================================================ +# TaskCreate: bare "#N" in `subject` without a repo qualifier in the subject +# ============================================================================ +check_taskcreate_bare_ref() { + local subject + subject=$(echo "$INPUT" | jq -r '.tool_input.subject // empty' 2>/dev/null) + + [[ -z "$subject" ]] && return 0 + echo "$subject" | grep -qE "$BARE_REF_PATTERN" || return 0 + echo "$subject" | grep -qE "$REPO_QUALIFIER_PATTERN" && return 0 + + cat >&2 <<'MSG' +DENIED: TaskCreate subject references a PR/issue by bare "#N" without a repo qualifier. + +Why blocked: + - `subject` contains "#N" + - But no "owner/repo ... #N" style qualifier is present in the subject + itself + +Why this matters: + - `TaskList` displays `subject` only — `description` is never shown. + A bare "#184" in the subject is unrecoverably ambiguous the moment two + tracked repos both have a PR/issue #184. + +Required action: + - Prefix the subject with the repo qualifier, e.g. + "owner/repo PR #184: <short description>" + - Put any additional detail (full URL, context) in `description` as usual + +Reference: wip/resume.md "Medium separation principle" Don't/Do row 5. +MSG + exit 2 +} + +case "$TOOL_NAME" in + AskUserQuestion) check_ask_bare_ref ;; + TaskCreate) check_taskcreate_bare_ref ;; +esac + +exit 0 diff --git a/skills/hook-kit/.gitignore b/skills/hook-kit/.gitignore index 91fd7a16..64077acd 100644 --- a/skills/hook-kit/.gitignore +++ b/skills/hook-kit/.gitignore @@ -1,4 +1,5 @@ -data/ -*.tmp *.bak *.bak-* +*.debug.log +*.tmp +data/ diff --git a/skills/hook-kit/audit.md b/skills/hook-kit/audit.md index d1fdb200..c585e7d8 100644 --- a/skills/hook-kit/audit.md +++ b/skills/hook-kit/audit.md @@ -167,7 +167,7 @@ Validates the "every hook must have an owning skill" policy from `automation.md` ```bash # Skill hook source list — scan both resources/ and scripts/ (sources may be in either; -# e.g., claude-session/scripts/session-id-inject.sh). macOS-compatible: use sed for basename instead of GNU -printf. +# e.g., session/scripts/session-id-inject.sh). macOS-compatible: use sed for basename instead of GNU -printf. SOURCES=$(find ~/.claude/skills ~/.agents/skills \ \( -path "*/resources/*.sh" -o -path "*/scripts/*.sh" \) -type f 2>/dev/null \ | sed 's#.*/##' | sort -u) @@ -198,6 +198,93 @@ done - UNMANAGED → (1) import to hook skill resources/ via `/hook install` import procedure (2) mv to domain skill resources/ (3) keep - UNREGISTERED → register in settings.json via `/hook install` +### 3-C. Duplicate basename check (same hook script in 2+ resources/ directories) + +When the same script basename exists under two or more different skills' `resources/` (or `scripts/`) directories **and both are wired into hooks.json/settings.json**, both copies register as independent hooks under the same matcher — the hook fires twice per event, and if the two copies have diverged (one patched, one stale), they can produce contradictory verdicts on the exact same input. This is a distinct failure mode from 3-B's ORPHAN/UNMANAGED/UNREGISTERED classification, which is scoped to a single canonical copy; duplicate-basename detection catches the case where *multiple* canonical-looking copies coexist. + +**Scope note (avoids false positives on unregistered utility scripts)**: a same-named file under two skills' `scripts/` directories that is not itself wired into any hooks.json/settings.json entry is a harmless naming coincidence (e.g., two skills each shipping their own private helper that happens to share a filename) — it does not fire twice because it does not fire automatically at all. Intersect the duplicate-basename list against `REGISTERED` (3-B's variable) before flagging. + +```bash +# Reuse 3-B's REGISTERED set (registered hook basenames from settings.json/local.json +# — also union in any plugin's own hooks/hooks.json per 3-A.2's scope note). +# Cross-platform basename dedup, then intersect with REGISTERED. +find ~/.claude/skills ~/.agents/skills \ + \( -path "*/resources/*.sh" -o -path "*/scripts/*.sh" -o -path "*/resources/*.py" -o -path "*/scripts/*.py" \) -type f 2>/dev/null \ + | awk -F/ '{print $NF, $0}' | sort | awk ' + { if ($1 == prev_name) { print prev_line; print $0; dup=1 } + else if (dup) { dup=0 } + prev_name=$1; prev_line=$0 } + ' | while read -r name path; do + printf '%s\n' "$REGISTERED" | grep -qx "$name" && echo "$name $path" + true + done +``` + +| Result | Classification | +|--------|----------------| +| Basename appears under exactly 1 `resources/`/`scripts/` directory | OK | +| Basename appears under 2+ distinct `resources/`/`scripts/` directories, none registered as a hook | OK — naming coincidence only, no double-fire | +| Basename appears under 2+ distinct `resources/`/`scripts/` directories AND is registered in hooks.json/settings.json | **DUPLICATE** — both copies fire independently under the same matcher; diverging content risks contradictory verdicts on identical input | + +**Case history**: `block-cleanup-option-below-context-gate.sh` existed in both `hook-kit/resources/` and `es6p-hooks/resources/` simultaneously — both registered under the same `PreToolUse:AskUserQuestion` matcher, and after the two copies drifted apart they returned conflicting percentage-threshold verdicts (62.8%/24.0% vs 42.7%/51%+) on the same payload. Root-caused and the placement itself was fixed in PR #330, but no automated check existed to catch a recurrence — this section closes that gap. + +| # | Don't | Do | +|---|-------|-----| +| 1 | Treat 3-B's UNREGISTERED/UNMANAGED classification as sufficient duplicate coverage | 3-B classifies a single file against settings.json; it says nothing about a second file with the *same basename* existing elsewhere. Run 3-C separately | +| 2 | Assume duplicate basenames are always accidental copies safe to just delete one of | Diff the two copies first — either side may hold content the other lacks (a fix applied to only one copy after they forked). Merge the content, then remove the redundant copy | +| 3 | Scope the scan to one marketplace/plugin only | Duplicate hazards are cross-marketplace by construction (the PR #330 incident spanned `hook-kit` and `es6p-hooks`) — scan every `~/.claude/skills` and `~/.agents/skills` tree in one pass | + +### 3-D. Marketplace checkout vs. plugin cache desync (design — not yet automated) + +A marketplace entry under `~/.claude/plugins/marketplaces/<name>` is a symlink to a git checkout. What Claude Code actually executes at hook-run time is a **separate** copy under `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/...`, whose path comes from `~/.claude/plugins/installed_plugins.json`'s `installPath` field for that `<plugin>@<marketplace>` key. Editing (or committing) a hook script in the marketplace checkout does **not** guarantee the cache copy updates — a fix applied only to the checkout can silently fail to take effect while every symptom looks like "the fix didn't work." + +**Verified 2026-08-18 (two different marketplaces, two different failure shapes — do not assume either is universal)**: +- `daegunsoftDev/skills` (`dgs-plugins` marketplace): `installPath` **exists on disk** and was **stale** — a `next-trigger.sh` fix committed to the checkout had zero effect until the cache copy was manually force-synced (`cat <checkout-file> > <cache-file>`; a plain `cp` failed silently on an interactive overwrite prompt). This is the failure mode this check exists to catch. +- `es6kr/skills` (`es6kr-skills` marketplace): `installPath` (`~/.claude/plugins/cache/es6kr-skills/es6kr/0.1.0`) **does not exist on disk at all**. Editing the checkout took effect immediately in this session — Claude Code appears to fall back to reading the marketplace source directly when no cache copy exists — but this fallback behavior is an empirical observation from one session, not a documented contract; do not hard-code an assumption that a missing cache dir is always safe. + +**Detection procedure (design)**: +1. For a given marketplace checkout path (or a git commit's changed-file list within one), resolve which marketplace it is: match the checkout's `realpath` against every symlink target under `~/.claude/plugins/marketplaces/*`. +2. Look up that marketplace's plugin(s) and `installPath` in `~/.claude/plugins/installed_plugins.json` (key format `<plugin>@<marketplace>`). +3. `installPath` missing on disk → report **NO-CACHE** (informational — behavior unconfirmed beyond the one observed case above, flag for awareness rather than auto-fix). +4. `installPath` exists → for each changed/hook-relevant file, diff the checkout's copy against the corresponding cache-path copy. Differ → **STALE-CACHE** (the exact bug this section documents). + +```bash +# Sketch — not yet wired into a hook or a report loop; resolve $CHECKOUT_PATH and +# $CHANGED_FILES from the caller's context (e.g. git diff --name-only HEAD~1). +MARKETPLACE=$(for m in ~/.claude/plugins/marketplaces/*; do + [ "$(cd "$m" && pwd -P)" = "$(cd "$CHECKOUT_PATH" && pwd -P)" ] && basename "$m" && break +done) +[ -z "$MARKETPLACE" ] && { echo "not a known marketplace checkout"; exit 0; } + +INSTALL_PATH=$(python3 -c " +import json +d = json.load(open('$HOME/.claude/plugins/installed_plugins.json')) +for key, entries in d.get('plugins', {}).items(): + if key.endswith('@$MARKETPLACE'): + for e in entries: + print(e.get('installPath', '')) +") + +if [ -z "$INSTALL_PATH" ] || [ ! -d "$INSTALL_PATH" ]; then + echo "NO-CACHE: $MARKETPLACE has no populated cache dir — verify fallback behavior before trusting this is safe" +else + for f in $CHANGED_FILES; do + diff -q "$CHECKOUT_PATH/$f" "$INSTALL_PATH/$f" >/dev/null 2>&1 \ + || echo "STALE-CACHE: $f differs between checkout and $INSTALL_PATH" + done +fi +``` + +**Remaining implementation candidates (fix_plan.md, not done in this pass)**: +- Wire this into a `PostToolUse:Bash(git commit)` hook so it fires automatically after a commit to any marketplace-checkout repo, rather than requiring a manual `/hook-kit audit` invocation +- Confirm empirically (across more than the one `es6kr-skills` observation) whether a missing `installPath` reliably means "harness reads from marketplace source directly," or whether that was specific to this session's environment/timing + +| # | Don't | Do | +|---|-------|-----| +| 1 | Assume a fix committed to a marketplace checkout is live because the commit succeeded | The checkout and the executing cache copy are different files on disk — verify the cache copy too, or run this check | +| 2 | Treat a missing `installPath` as proof the fallback-to-source behavior is safe/guaranteed | It is one session's empirical observation for one marketplace, not a documented Claude Code contract — flag as NO-CACHE, don't silently treat it as fine | +| 3 | Assume `claudify`'s "Auto-sync hook: plugin-cache-sync.sh" text describes a real, active mechanism | Verified absent 2026-08-18 — no live file, no settings.json/hooks.json registration. The claudify SKILL.md text was corrected in the same pass that added this section | + ### 4. Report output ``` @@ -222,16 +309,23 @@ Installed file classification (resources / settings mapping): UNMANAGED user-custom.sh ← in settings only, no source UNREGISTERED experiment.sh ← has resources source but not in settings -Total: OK 15 / STALE 2 / MISSING 1 / STALE-PERM 1 / ORPHAN 1 / UNMANAGED 1 / UNREGISTERED 1 +Duplicate basename check: + OK bash-guard.sh + DUPLICATE block-cleanup-option-below-context-gate.sh + ~/.claude/skills/hook-kit/resources/block-cleanup-option-below-context-gate.sh + ~/.claude/skills/es6p-hooks/resources/block-cleanup-option-below-context-gate.sh + +Total: OK 15 / STALE 2 / MISSING 1 / STALE-PERM 1 / ORPHAN 1 / UNMANAGED 1 / UNREGISTERED 1 / DUPLICATE 1 ``` ### 5. Fix suggestions -For STALE/MISSING/STALE-PERM/ORPHAN/UNMANAGED/UNREGISTERED items, use AskUserQuestion(multiSelect:true) to choose action: +For STALE/MISSING/STALE-PERM/ORPHAN/UNMANAGED/UNREGISTERED/DUPLICATE items, use AskUserQuestion(multiSelect:true) to choose action: - **fix**: update to the correct reference - **chmod +x**: grant executable bit to STALE-PERM items (`chmod +x <file>`) - **import**: UNMANAGED → mv to owning skill resources/ via `/hook install` import procedure - **register**: UNREGISTERED → register in settings.json via `/hook install` - **archive**: ORPHAN → move to `~/.claude/.bak/` (`/safe-delete` or `/archive`) +- **merge-and-remove**: DUPLICATE → diff both copies, merge any content unique to either side into one, then delete the redundant copy and (if needed) its hooks.json/settings.json registration - **remove**: remove the hook entry (`/hook remove` guidance) - **skip**: skip for now diff --git a/skills/hook-kit/resources/block-axis-merged-ask.sh b/skills/hook-kit/resources/block-axis-merged-ask.sh index 616e2274..186fa8ff 100755 --- a/skills/hook-kit/resources/block-axis-merged-ask.sh +++ b/skills/hook-kit/resources/block-axis-merged-ask.sh @@ -63,7 +63,7 @@ fi # multi-finding ask writes keywords without adjacent counts ("[#1 Refactor # variables.tf] ..."). False-positive case: a verdict ask quoting # "Critical 0 / Important 2 / Minor 2" → DENY incorrectly. -FINDING_TEXT=$(echo "$OPT_TEXT" | sed -E "s/(Refactor|Tip|Nitpick|Critical|Important|Minor)[[:space:]]*[0-9]+${HG_AXIS_TALLY_KO_SUFFIX}//gI") +FINDING_TEXT=$(echo "$OPT_TEXT" | sed -E "s/(Refactor|Tip|Nitpick|Critical|Important|Minor|Finding|Task)[[:space:]]*[0-9]+${HG_AXIS_TALLY_KO_SUFFIX}//gI") # Detect finding-type keywords (case-insensitive, word-boundary-ish) FINDING_KEYWORDS=$(echo "$FINDING_TEXT" | grep -oiE '\b(Refactor|Tip|Nitpick|Critical|Important|Minor)\b' | sort -u) diff --git a/skills/hook-kit/resources/block-cleanup-missing-rename.sh b/skills/hook-kit/resources/block-cleanup-missing-rename.sh deleted file mode 100755 index 716ca5e4..00000000 --- a/skills/hook-kit/resources/block-cleanup-missing-rename.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# Stop event — Detect a cleanup/session-end completion report that includes -# the "Session ID:" identity line but omits the accompanying `/rename` -# recommendation (2-3 candidates) required by cleanup/run.md Step 5's -# "Session identity (mandatory)" row. -# -# Trigger: assistant response contains cleanup-completion or session-end markers -# AND a "Session ID:" line -# Detection: no `/rename ` command token anywhere in the same response. -# Locale-specific marker variants live in data/hangul-patterns.regex -# (git-ignored — this repo is PUBLIC, English-only source). -# Action: emit reminder via stdout (non-blocking read, but exit 2 blocks Stop -# and injects context for the next turn). -# -# Background: failed-attempts.md "cleanup-procedure" class, 11th recurrence — -# the Session-identity row's rename-candidate sub-clause had zero mechanical -# backstop (existing sibling hooks only cover RAG/claudify/skip-condition -# sub-rows), so it kept getting dropped when a cleanup report was composed -# from memory of a prior pass instead of re-reading run.md's literal template. -# -# Escalation policy (cleanup/run.md): cleanup-procedure class already -# hook-active for 2 mechanisms; this is a 3rd mechanism for the same class. - -if [[ "${RALPH_LOOP:-}" == "1" ]]; then exit 0; fi - -HG_DATA_FILE="$(dirname "$0")/../data/hangul-patterns.regex" -if [ -f "$HG_DATA_FILE" ]; then - # shellcheck source=/dev/null - . "$HG_DATA_FILE" -fi - -# Completion phrasings are included deliberately: a real wrap-up report is far -# more likely to be headed "cleanup complete" / "cleanup pass 2 complete" than -# to repeat the literal invocation "/cleanup run", and those natural headings -# previously matched nothing here, so the guard never even entered its checks. -# Additive, not override (`:+…|` rather than `:-`). The locale data file is sourced -# first, so with `:-` its value REPLACED everything below and the committed markers -# became dead code on any machine that has the file — `Session Cleanup` here was -# live in the repo and silently absent in practice. Which set wins should not depend -# on whether an untracked file happens to exist. Union keeps the committed baseline -# authoritative and lets the git-ignored file only ADD locale variants. -HG_CLEANUP_MARKERS="${HG_CLEANUP_MARKERS:+${HG_CLEANUP_MARKERS}|}(^|[[:space:]])/cleanup|cleanup run|cleanup wrap-up|cleanup complete|cleanup pass|cleanup finished|Session Ended|Session Cleanup|session-end report" -HG_SESSION_ID_MARKERS="${HG_SESSION_ID_MARKERS:+${HG_SESSION_ID_MARKERS}|}Session ID:|session[[:space:]]+id:" -# Words that, together with a markdown table, mark a response as the completion -# report itself rather than a mid-cleanup progress message. Used only to decide -# whether an ABSENT Session ID line is already due (see the omission branch). -HG_COMPLETION_WORDS="${HG_COMPLETION_WORDS:+${HG_COMPLETION_WORDS}|}complete|completed|finished|Session Ended|wrap-up" -# Row labels unique to run.md's Step 5 mandatory-rows table. Requiring one of -# these keeps the omission branch off unrelated "<something> cleanup finished" -# reports that merely happen to contain a table (e.g. a file-cleanup summary). -HG_CLEANUP_STEP_ROWS="${HG_CLEANUP_STEP_ROWS:+${HG_CLEANUP_STEP_ROWS}|}Self-Improve|Knowledge Persist|RAG Store|wip task|TaskList|Task prune|Weekly Report" - -INPUT=$(cat) - -TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) - -RESPONSE=$(echo "$INPUT" | jq -r ' - .response // .transcript // .assistant_message // empty -' 2>/dev/null) - -if [[ -z "$RESPONSE" ]] && [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then - RESPONSE=$(tail -50 "$TRANSCRIPT_PATH" | jq -rs '([.[] | select(.type=="assistant")] | last) as $m | ($m.message.content[]?.text? // empty)' 2>/dev/null) -fi - -if [[ -z "$RESPONSE" ]]; then - exit 0 -fi - -# Only fire on cleanup/session-end context responses -if ! echo "$RESPONSE" | grep -qiE "$HG_CLEANUP_MARKERS"; then - exit 0 -fi - -# A cleanup response that never reaches Step 5 is not a violation of this row -# yet — that is why an absent Session ID line cannot simply be treated as a -# violation. But the original "absent => always exit 0" rule left the most -# common failure mode silent: dropping the ENTIRE row from a finished report -# passed, while writing it partially (ID without /rename) was caught. Split the -# two cases: still exempt mid-cleanup progress messages, but treat the omission -# as a violation once the response is clearly the completion report itself -# (a markdown table plus completion wording). -if ! echo "$RESPONSE" | grep -qiE "$HG_SESSION_ID_MARKERS"; then - if echo "$RESPONSE" | grep -qE '^[[:space:]]*\|' \ - && echo "$RESPONSE" | grep -qiE "$HG_COMPLETION_WORDS" \ - && echo "$RESPONSE" | grep -qiE "$HG_CLEANUP_STEP_ROWS"; then - cat <<'EOF' -{ - "decision": "block", - "reason": "Cleanup/session-end completion report omits the 'Session identity (mandatory)' row entirely — no `Session ID:` line and therefore no `/rename` candidates either. cleanup/run.md Step 5's mandatory-rows table requires this row in BOTH the cleanup wrap-up table and any separate session-end report. Re-read that section's literal row text (do not reconstruct the table from memory of a prior pass — it silently drops rows) and re-emit the report with every mandatory row present: Session identity, 0 TaskList, 1 Commit, 2 Self-Improve, 3 Knowledge Persist, 3-C.1 RAG Store (separate row), 3-C.2 structured discovery chunk, 3-C.4 fix_plan sync when applicable, 4 Weekly Report, 5 wip task registration." -} -EOF - exit 2 - fi - exit 0 -fi - -# The row is satisfied if a `/rename` command token appears anywhere in the -# response (candidates are often listed as separate code spans). -if echo "$RESPONSE" | grep -qE "/rename[[:space:]]"; then - exit 0 -fi - -cat <<'EOF' -{ - "decision": "block", - "reason": "Cleanup/session-end report includes a 'Session ID:' line but no accompanying `/rename <model>-<topic>-<sessid8>` recommendation (2-3 candidates). cleanup/run.md Step 5's 'Session identity (mandatory)' row requires both together — re-read the row's literal text (do not reconstruct it from memory of a prior pass) and add the rename candidates as standalone `/rename ...` code spans (no label/colon inside the span, so a single copy-paste is directly runnable)." -} -EOF -exit 2 diff --git a/skills/hook-kit/resources/block-manual-handoff-web-task.sh b/skills/hook-kit/resources/block-manual-handoff-web-task.sh old mode 100644 new mode 100755 index cfea2932..3ccdcc71 --- a/skills/hook-kit/resources/block-manual-handoff-web-task.sh +++ b/skills/hook-kit/resources/block-manual-handoff-web-task.sh @@ -30,8 +30,21 @@ if [ -f "$HG_DATA_FILE" ]; then # shellcheck source=/dev/null . "$HG_DATA_FILE" fi -HG_HANDOFF_DELEGATE_PHRASES="${HG_HANDOFF_DELEGATE_PHRASES:-__NEVER_MATCH__}" -HG_HANDOFF_WEB_CONTEXT="${HG_HANDOFF_WEB_CONTEXT:-__NEVER_MATCH__}" +# English-only defaults, per this data file's own convention ("guards fall back +# to the English-only defaults built into each guard"). They were previously +# __NEVER_MATCH__, which made Gate 1 below exit 0 unconditionally — the hook was +# registered, syntactically valid, and structurally incapable of ever firing. +# A guard that cannot fire is worse than an absent one: the docs cite it as the +# backstop, so nobody looks. +# The locale file must AUGMENT these, not replace them. It is sourced above, so +# a plain `${VAR:-default}` would silently drop the English patterns the moment a +# localized copy exists — the guard would then only ever catch handoffs phrased in +# that one language. Compose instead: English always, plus the locale alternation +# when present. +HG_HANDOFF_DELEGATE_EN='(please[[:space:]]+(go[[:space:]]+to|open|visit|click|sign[[:space:]]+in|log[[:space:]]+in|navigate|add|grant|enable|set|toggle|check)|you[[:space:]]+(need[[:space:]]+to|will[[:space:]]+need[[:space:]]+to|must|should|have[[:space:]]+to)[[:space:]]+(go|open|click|visit|sign[[:space:]]+in|log[[:space:]]+in|add|grant|enable|do)|do[[:space:]]+(it|this)[[:space:]]+(yourself|manually|on[[:space:]]+your[[:space:]]+end)|handle[[:space:]]+(it|this)[[:space:]]+(yourself|manually)|(is|are)[[:space:]]+a[[:space:]]+(UI|manual)[[:space:]]+(task|step|action))' +HG_HANDOFF_WEB_EN='(console|dashboard|settings[[:space:]]+(page|screen|tab)|admin[[:space:]]+(panel|ui|console)|portal|web[[:space:]]?ui|browser)' +HG_HANDOFF_DELEGATE_PHRASES="${HG_HANDOFF_DELEGATE_EN}${HG_HANDOFF_DELEGATE_PHRASES:+|${HG_HANDOFF_DELEGATE_PHRASES}}" +HG_HANDOFF_WEB_CONTEXT="${HG_HANDOFF_WEB_EN}${HG_HANDOFF_WEB_CONTEXT:+|${HG_HANDOFF_WEB_CONTEXT}}" INPUT=$(cat) @@ -44,8 +57,20 @@ if [ -z "$LAST_MSG" ] || [ "$LAST_MSG" = "null" ]; then exit 0 fi -# Concatenate all text-content from the assistant message -LAST_TEXT=$(echo "$LAST_MSG" | jq -r '.message.content // [] | map(select(.type == "text") | .text) | join("\n")' 2>/dev/null) +# Concatenate the assistant message's text content AND the text it puts inside +# an AskUserQuestion payload. +# +# Scanning only `type == "text"` blocks misses the most natural way to hand work +# to a user: not a sentence in prose, but an option in a question. A handoff +# written as `question`/`options[].description` never appears in a text block, +# so both gates below were structurally blind to it — the delegation phrase and +# the console URL can sit in the payload and this hook sees an empty string. +LAST_TEXT=$(echo "$LAST_MSG" | jq -r ' + (.message.content // []) as $c + | (($c | map(select(.type == "text") | .text)) + + ($c | map(select(.type == "tool_use" and (.name == "AskUserQuestion")) + | (.input // {}) | tostring))) + | join("\n")' 2>/dev/null) [ -z "$LAST_TEXT" ] && exit 0 # Skip if a web-browser-capable tool was used recently: Skill call naming @@ -55,6 +80,13 @@ LAST_TEXT=$(echo "$LAST_MSG" | jq -r '.message.content // [] | map(select(.type # browser) that concludes with a plain-text status report should not be # treated as a zero-attempt bare handoff just because the reporting turn # itself made no tool call. +# Immunity requires an attempt on a backend the user can actually act in. +# Playwright MCP alone no longer grants it: its window is invisible by design, +# so a flow that stalls on a login/consent screen there learns nothing the user +# can resolve — and counting it as "already tried" turns a wrong-backend attempt +# into an alibi for the handoff that follows. That is the exact path a prior +# recurrence took. Visible/controllable backends (cmux, wmux, chrome-devtools, +# an OS-level window) and an explicit web-browser Skill dispatch still count. RECENT_MSGS=$(jq -s 'map(select(.type == "assistant")) | .[-5:]' "$TRANSCRIPT" 2>/dev/null) BROWSER_TOOL_COUNT=$(echo "$RECENT_MSGS" | jq -r ' map(.message.content // [] | map(select(.type == "tool_use")) | map( @@ -62,7 +94,8 @@ BROWSER_TOOL_COUNT=$(echo "$RECENT_MSGS" | jq -r ' ((.input // {} | tostring)) as $i | if $n == "WebFetch" then 1 elif $n == "Skill" and ($i | test("web-browser")) then 1 - elif ($n | test("browser|playwright")) then 1 + elif ($n | test("cmux|wmux|chrome-devtools")) then 1 + elif ($n == "Bash" and ($i | test("cmux browser|wmux browser|remote-debugging-port"))) then 1 elif $n == "PowerShell" and ($i | test("Start-Process")) then 1 else 0 end @@ -72,12 +105,15 @@ if [ -n "$BROWSER_TOOL_COUNT" ] && [ "$BROWSER_TOOL_COUNT" != "0" ]; then exit 0 fi -# Gate 1: delegation phrase present -echo "$LAST_TEXT" | grep -qE "$HG_HANDOFF_DELEGATE_PHRASES" || exit 0 +# Gate 1: delegation phrase present. +# Case-insensitive: the English patterns are written lowercase but a handoff +# sentence normally starts one ("Please go to..."), so a case-sensitive match +# silently skipped every English phrasing. Harmless for the caseless locale side. +echo "$LAST_TEXT" | grep -qiE "$HG_HANDOFF_DELEGATE_PHRASES" || exit 0 # Gate 2: co-occurs with a URL or console/dashboard word (scopes to web-reachable tasks) if ! echo "$LAST_TEXT" | grep -qE 'https?://'; then - echo "$LAST_TEXT" | grep -qE "$HG_HANDOFF_WEB_CONTEXT" || exit 0 + echo "$LAST_TEXT" | grep -qiE "$HG_HANDOFF_WEB_CONTEXT" || exit 0 fi REMINDER="[hook:block-manual-handoff-web-task] Manual web-task handoff phrase detected (delegation phrase + URL/console context) with no web-browser-capable tool_use in the same response. diff --git a/skills/hook-kit/resources/block-squash-recommend-multi-commit.sh b/skills/hook-kit/resources/block-squash-recommend-multi-commit.sh new file mode 100755 index 00000000..a7075337 --- /dev/null +++ b/skills/hook-kit/resources/block-squash-recommend-multi-commit.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# PreToolUse:AskUserQuestion — Block a squash-merge option for an es6kr/skills PR +# whose commit count is not exactly 1. +# +# Trigger: AskUserQuestion contains an option (label or description) mentioning +# "squash" (case-insensitive) AND a github.com/es6kr/skills/pull/<N> URL. +# Action: look up the PR's real commit count via `gh pr view`. Deny when it is +# not exactly 1. +# +# Why: es6kr/skills uses semantic-release, which assigns minor/patch bumps by +# commit type (feat/fix). Squash-merging a multi-commit PR collapses those +# distinct commit-type signals into a single squash commit, silently breaking +# the bump matrix. See skills-publishing.md "squash-merge recommendation only +# allowed for single-commit PRs" and failed-attempts.md "squash-recommend-multi-commit". + +INPUT=$(cat) + +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) +if [[ "$TOOL_NAME" != "AskUserQuestion" ]]; then + exit 0 +fi + +# Collect every (label + description) pair across all questions/options. +OPTION_TEXTS=$(echo "$INPUT" | jq -r ' + .tool_input.questions[]?.options[]? | + ((.label // "") + "\n" + (.description // "")) +' 2>/dev/null) + +if [[ -z "$OPTION_TEXTS" ]]; then + exit 0 +fi + +if ! echo "$OPTION_TEXTS" | grep -qiE 'squash'; then + exit 0 +fi + +PR_URL=$(echo "$OPTION_TEXTS" | grep -oE 'github\.com/es6kr/skills/pull/[0-9]+' | head -1) +if [[ -z "$PR_URL" ]]; then + # A squash mention without an es6kr/skills PR URL isn't this hook's concern + # (either a different repo, or ask-guard's PR-URL-matching guard handles it). + exit 0 +fi + +PR_NUM="${PR_URL##*/}" + +if ! command -v gh >/dev/null 2>&1; then + exit 0 +fi + +COMMIT_COUNT=$(gh pr view "$PR_NUM" -R es6kr/skills --json commits -q '.commits | length' 2>/dev/null) + +if [[ -z "$COMMIT_COUNT" ]]; then + # Could not determine commit count (auth/network issue) — don't false-block. + exit 0 +fi + +if [[ "$COMMIT_COUNT" != "1" ]]; then + { + echo "DENIED: squash-merge option proposed for es6kr/skills PR #$PR_NUM, which has $COMMIT_COUNT commits (not 1)." + echo "" + echo "Why blocked:" + echo " - es6kr/skills uses semantic-release, which bumps versions by commit type" + echo " (feat/fix). Squashing a multi-commit PR collapses that type information" + echo " into one commit, silently corrupting the bump matrix." + echo "" + echo "Required action:" + echo " Remove the squash option. Offer a non-squash merge (preserves each commit)," + echo " or a 'commit-tidy first, then squash' option instead." + echo "" + echo "Reference: skills-publishing.md 'squash-merge recommendation only allowed for single-commit PRs'" + echo " failed-attempts.md 'squash-recommend-multi-commit'" + } >&2 + exit 2 +fi + +exit 0 diff --git a/skills/hook-kit/resources/block-taskoutput-long-block.sh b/skills/hook-kit/resources/block-taskoutput-long-block.sh old mode 100644 new mode 100755 diff --git a/skills/hook-kit/resources/block-wip-register-before-execute.sh b/skills/hook-kit/resources/block-wip-register-before-execute.sh deleted file mode 100755 index 86232228..00000000 --- a/skills/hook-kit/resources/block-wip-register-before-execute.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# PreToolUse:Edit|Write guard - /wip register-before-execute. -# Delegates to the sibling .py. Korean registration-verb variants are loaded -# from data/hangul-patterns.regex (git-ignored; English-only fallback keeps the -# guard functional without locale data). Fail-open if python is unavailable. -HG_DATA_FILE="$(dirname "$0")/../data/hangul-patterns.regex" -if [ -f "$HG_DATA_FILE" ]; then - # shellcheck source=/dev/null - . "$HG_DATA_FILE" -fi -export HG_WIP_REGISTER_VERBS="${HG_WIP_REGISTER_VERBS:-add|write|create|draft|record|register}" - -PY="$(command -v python3 || true)" -[ -z "$PY" ] && exit 0 -exec "$PY" "$(dirname "$0")/block-wip-register-before-execute.py" diff --git a/skills/hook-kit/resources/check-ask-bypass-keywords.sh b/skills/hook-kit/resources/check-ask-bypass-keywords.sh index f32ea302..480f3ac1 100755 --- a/skills/hook-kit/resources/check-ask-bypass-keywords.sh +++ b/skills/hook-kit/resources/check-ask-bypass-keywords.sh @@ -19,6 +19,16 @@ # — hook was UNREGISTERED in settings.json + single interrogative escaped the # list>=2 gate. Registered in Stop + INTERROGATIVE_PATTERN added. See failed-hooks.md. # +# 22nd 2026-08-13: a direct-answer response ending on a bare trailing +# "?" (a small disambiguation question) did NOT block live, even though +# offline replay of the exact transcript state through this script +# correctly returned decision:block — matching logic + settings.json +# registration both confirmed intact. Root cause of the live miss +# unconfirmed (no prior invocation trail to inspect). Added per-invocation +# debug logging (check-ask-bypass-keywords.debug.log, mirrors +# next-trigger.sh) so the next live miss has direct evidence instead of +# requiring offline reconstruction. +# # Cannot block the response itself (Stop hook fires after the response ends). # Reminder is injected so the NEXT turn does the AskUserQuestion call. @@ -35,24 +45,43 @@ fi HG_BYPASS_KEYWORD_PATTERN="${HG_BYPASS_KEYWORD_PATTERN:-__NEVER_MATCH__}" HG_BYPASS_INTERROGATIVE_PATTERN="${HG_BYPASS_INTERROGATIVE_PATTERN:-__NEVER_MATCH__}" +# Debug log of this hook's own invocations — mirrors next-trigger.sh's +# next-trigger.debug.log. Added after a live-miss (failed-attempts.md +# "ask-text-question" 22nd recurrence) where the hook, empirically re-run offline +# against the exact transcript state, correctly returned decision:block — +# yet no block occurred in the live session. Without a per-invocation trail, +# that class of miss can only be diagnosed by slow after-the-fact +# reconstruction. Self-trims at 500 lines, keeps last 200 (same policy as +# next-trigger.sh). +DEBUG_LOG="$(dirname "$0")/check-ask-bypass-keywords.debug.log" +_log() { { printf '%s\t%s\ttranscript=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" >> "$DEBUG_LOG"; } 2>/dev/null || true; } + INPUT=$(cat) TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) -[ -f "$TRANSCRIPT" ] || exit 0 +if [ ! -f "$TRANSCRIPT" ]; then + _log "early_exit=no_transcript" "$TRANSCRIPT" + exit 0 +fi # Last assistant message (whole JSON entry) LAST_MSG=$(jq -s 'map(select(.type == "assistant")) | last // empty' "$TRANSCRIPT" 2>/dev/null) if [ -z "$LAST_MSG" ] || [ "$LAST_MSG" = "null" ]; then + _log "early_exit=no_last_assistant_msg" "$TRANSCRIPT" exit 0 fi # Concatenate all text-content from the assistant message LAST_TEXT=$(echo "$LAST_MSG" | jq -r '.message.content // [] | map(select(.type == "text") | .text) | join("\n")' 2>/dev/null) -[ -z "$LAST_TEXT" ] && exit 0 +if [ -z "$LAST_TEXT" ]; then + _log "early_exit=no_text_content" "$TRANSCRIPT" + exit 0 +fi # Skip if AskUserQuestion was actually called in this response ASK_COUNT=$(echo "$LAST_MSG" | jq -r '.message.content // [] | map(select(.type == "tool_use" and .name == "AskUserQuestion")) | length' 2>/dev/null) if [ -n "$ASK_COUNT" ] && [ "$ASK_COUNT" != "0" ]; then + _log "early_exit=ask_already_called ask_count=$ASK_COUNT" "$TRANSCRIPT" exit 0 fi @@ -62,20 +91,48 @@ fi # When the data file is absent both fall back to __NEVER_MATCH__ so the hook # becomes a no-op (intentional — bypass framing is locale-specific). -if echo "$LAST_TEXT" | grep -qE "$HG_BYPASS_INTERROGATIVE_PATTERN"; then +# Language-agnostic trailing-question-mark check — the response's last +# non-whitespace character is "?"/"?". Catches any interrogative ending +# (Korean or English) without relying on an enumerated verb/ending list, which +# 14 prior recurrences showed always misses the next novel phrasing (see +# failed-attempts.md "ask-text-question" class). Scoped to the LAST LINE only +# (not the whole response) to keep the false-positive surface bounded — a +# question mark earlier in the body (e.g. a quoted question being analyzed) +# does not trigger this. +LAST_LINE=$(printf '%s' "$LAST_TEXT" | tail -n 1) +TRAILING_QUESTION=0 +if printf '%s' "$LAST_LINE" | grep -qE '[??][[:space:]"'"'"']*$'; then + TRAILING_QUESTION=1 +fi + +MATCH_REASON="" +if [ "$TRAILING_QUESTION" = "1" ]; then + # Bare trailing "?" on the last line — fire regardless of keyword/list gates. + MATCH_REASON="trailing_question_mark" +elif echo "$LAST_TEXT" | grep -qE "$HG_BYPASS_INTERROGATIVE_PATTERN"; then # Direct interrogative offer — fire regardless of list count. - : + MATCH_REASON="interrogative_offer" elif echo "$LAST_TEXT" | grep -qE "$HG_BYPASS_KEYWORD_PATTERN"; then # Delegation/next-step framing — require bullet/numbered list >= 2 (cuts FP). LIST_COUNT=$(echo "$LAST_TEXT" | grep -cE '^[[:space:]]*([0-9]+\.|[-*])[[:space:]]+') if [ "$LIST_COUNT" -lt 2 ]; then + _log "pass=keyword_matched_but_list_count_below_2 list_count=$LIST_COUNT" "$TRANSCRIPT" exit 0 fi + MATCH_REASON="delegation_keyword_list>=2" else + _log "pass=no_pattern_matched" "$TRANSCRIPT" exit 0 fi -REMINDER="[hook:check-ask-bypass-keywords] Text-question pattern detected (delegation/next-step framing + list>=2, or direct interrogative offer) + no AskUserQuestion call in the same response. +_log "BLOCK reason=$MATCH_REASON" "$TRANSCRIPT" + +# Trim to last 200 lines once the log exceeds 500 (same policy as next-trigger.sh) +if [ "$(wc -l < "$DEBUG_LOG" 2>/dev/null || echo 0)" -gt 500 ]; then + tail -n 200 "$DEBUG_LOG" > "$DEBUG_LOG.tmp" 2>/dev/null && mv "$DEBUG_LOG.tmp" "$DEBUG_LOG" 2>/dev/null +fi + +REMINDER="[hook:check-ask-bypass-keywords] Text-question pattern detected (last line ends with a bare '?', delegation/next-step framing + list>=2, or direct interrogative offer) + no AskUserQuestion call in the same response. ask-user-question.md \"Questions must use the AskUserQuestion tool — text questions are forbidden\" rule applies. If a user-decision axis is identified, call AskUserQuestion instead of writing a text prompt. diff --git a/skills/hook-kit/resources/check-completed-bloat.js b/skills/hook-kit/resources/check-completed-bloat.js index 65badd76..aa0ebfbf 100755 --- a/skills/hook-kit/resources/check-completed-bloat.js +++ b/skills/hook-kit/resources/check-completed-bloat.js @@ -56,7 +56,13 @@ if (filePath && (filePath.includes('fix_plan.md') || filePath.includes('checklis const rest = content.slice(headingMatch.index + headingMatch[0].length); const nextHeadingMatch = rest.match(/(?:^|\n)## /); const completedSection = nextHeadingMatch ? rest.slice(0, nextHeadingMatch.index) : rest; - const items = completedSection.split('\n').filter((line) => line.trim().startsWith('-')); + // Only top-level entries (marker at column 0) count as "completed items" — + // matches cleanup.py's own entry-boundary logic (indent == 0). A `.trim()` + // before the startsWith check would also match indented sub-bullets like + // " - **Why**: ..." whose prose can cite unrelated historical dates + // (e.g. the item's original registration date), which is not the entry's + // completion date and must not be scanned for staleness. + const items = completedSection.split('\n').filter((line) => /^-\s/.test(line)); const today = new Date(); // Current week starts on Monday. @@ -66,7 +72,14 @@ if (filePath && (filePath.includes('fix_plan.md') || filePath.includes('checklis monday.setHours(0, 0, 0, 0); const mondayStr = localDateStr(monday); - const dateRegex = /\b(20\d{2})-(\d{2})-(\d{2})\b/; + // Anchored at the start of the item's text (after the "- " marker) — + // matches cleanup.py's own date extraction (re.match(r"^(\d{4}-\d{2}-\d{2})", + // node.text), which is also start-anchored). A date appearing later in the + // line (e.g. a "(YYYY-MM-DD 추가)" registration-date aside before a later + // "완료(YYYY-MM-DD)" mention) is prose, not the entry's own leading date — + // scanning it produced false "stale" hits on entries cleanup.py itself + // never considers dated at all. + const dateRegex = /^-\s*(20\d{2})-(\d{2})-(\d{2})\b/; const staleItems = []; for (const item of items) { diff --git a/skills/hook-kit/resources/check-session-rag.sh b/skills/hook-kit/resources/check-session-rag.sh index fa7376aa..3a594d34 100755 --- a/skills/hook-kit/resources/check-session-rag.sh +++ b/skills/hook-kit/resources/check-session-rag.sh @@ -32,6 +32,22 @@ set -uo pipefail # persistence via their own wrapper, so pass unconditionally here. if [[ "${RALPH_LOOP:-}" == "1" ]]; then exit 0; fi +# A workspace with no RAG receiver wired up cannot satisfy a store demand, +# so the demand must not be made. Without consulting the config this guard +# only pattern-matches tool names, so it cannot tell "receiver absent" from +# "session was negligent" and blocks on both — observed blocking a session +# whose workspace had no RAG MCP server connected at all. +# +# Degrades toward the previous behaviour: if the resolver is missing or +# fails, WSCFG_RAG_KIND stays unset and the guard runs as it always did, +# rather than quietly switching itself off. +WSCFG_SHIM="$(dirname "$0")/workspace-config.sh" +if [ -x "$WSCFG_SHIM" ]; then + eval "$("$WSCFG_SHIM" --export 2>/dev/null)" || true + export WSCFG_RAG_KIND="${WSCFG_RAG_KIND:-}" + [ "$WSCFG_RAG_KIND" = "none" ] && exit 0 +fi + # Load locale-specific regex patterns from data/. The file is git-ignored so # the public repo never sees Korean characters. When absent, the audit signal # pattern falls back to an English-only regex. @@ -40,13 +56,25 @@ if [ -f "$HG_DATA_FILE" ]; then # shellcheck source=/dev/null . "$HG_DATA_FILE" fi -HG_RAG_AUDIT_SIGNAL="${HG_RAG_AUDIT_SIGNAL:+${HG_RAG_AUDIT_SIGNAL}|}audit|discovery|decision|deployment|fa-prune|self-improving|retrospect" +HG_RAG_AUDIT_SIGNAL="${HG_RAG_AUDIT_SIGNAL:-audit|discovery|decision|deployment|fa-prune|self-improving|retrospect}" export HG_RAG_AUDIT_SIGNAL +# Probe for a Python that actually runs — the Windows py3 stub exits 49 instead +# of executing. Match workspace-config.sh's probe so this scan degrades the same +# way: no usable interpreter -> skip (fail-safe), never a bogus block. +PY="" +for cand in python3 python; do + if command -v "$cand" >/dev/null 2>&1 && "$cand" -c 'import json,sys' >/dev/null 2>&1; then + PY="$cand" + break + fi +done +[ -z "$PY" ] && exit 0 + input="$(cat)" [ -z "$input" ] && exit 0 -transcript="$(printf '%s' "$input" | python3 -c " +transcript="$(printf '%s' "$input" | "$PY" -c " import json, sys try: d = json.load(sys.stdin) @@ -59,18 +87,29 @@ except Exception: [ ! -f "$transcript" ] && exit 0 # Single transcript scan extracting all metrics for both checks -metrics="$(python3 - "$transcript" <<'PYEOF' +metrics="$("$PY" - "$transcript" <<'PYEOF' import json, os, re, sys path = sys.argv[1] store_re = re.compile(r"^mcp__[A-Za-z0-9_-]+__.*-store$") find_re = re.compile(r"^mcp__[A-Za-z0-9_-]+__.*-find$") +# Vendor script route counts the same as MCP calls (tool-priority rule: +# skill script -> CLI -> HTTP -> MCP; mirrors edit-guard.sh vendor_pat). +script_store_re = re.compile(r"qdrant-(import|store-chunk)\.py") +script_find_re = re.compile(r"qdrant-(search|find)\.py") audit_re = re.compile( os.environ.get("HG_RAG_AUDIT_SIGNAL", r"audit|discovery|decision|deployment|fa-prune|self-improving|retrospect"), re.IGNORECASE, ) skill_path_re = re.compile(r"/(skills|rules|hooks)/.*\.(md|sh|py|js|ts)$") +# The literal phrases stay accepted for muscle memory; the config-derived +# one is added so the escape hatch keeps working after a vendor change. +store_skip_phrases = ["no RAG store needed", "skip qdrant store"] +_rag_kind = os.environ.get("WSCFG_RAG_KIND", "") +if _rag_kind and _rag_kind != "none": + store_skip_phrases.append("skip %s store" % _rag_kind) + store_count = 0 find_count = 0 task_completed = 0 @@ -99,6 +138,12 @@ with open(path, encoding="utf-8", errors="ignore") as fh: store_count += 1 elif find_re.match(tname): find_count += 1 + elif tname == "Bash": + cmd = tinput.get("command", "") or "" + if script_store_re.search(cmd): + store_count += 1 + elif script_find_re.search(cmd): + find_count += 1 elif tname == "TaskUpdate" and tinput.get("status") == "completed": task_completed += 1 elif tname in {"Edit", "Write"}: @@ -116,7 +161,7 @@ with open(path, encoding="utf-8", errors="ignore") as fh: if txt: if "skip-find-check" in txt: find_skip = True - if "no RAG store needed" in txt or "skip qdrant store" in txt: + if any(p in txt for p in store_skip_phrases): store_skip = True if audit_re.search(txt): audit_signal += 1 @@ -165,7 +210,7 @@ Signals detected: - Audit/discovery prompts: $audit_signal - RAG-store calls: $store_count -Per skill-usage.md "session-end RAG store requirement": store key findings to a RAG receiver before ending the session. Use the appropriate <vendor>-store MCP tool (1 call per finding, with metadata keys: type, project, date, category). +Per skill-usage.md "session-end RAG store requirement": store key findings to a RAG receiver before ending the session. Use the appropriate <vendor>-store MCP tool (1 call per finding, with metadata keys: type, project, date, category). MCP store tool unavailable this session (MCP bindings are fixed at session start)? Use the vendor script route instead — e.g. Skill("es6kr", "qdrant-import") / qdrant-import.py — it counts as a store call here, per the tool-priority rule (skill script -> CLI -> HTTP -> MCP). Do NOT conclude the store is impossible from MCP absence alone. To skip this check intentionally, the user must explicitly say "no RAG store needed" or "skip qdrant store". diff --git a/skills/hook-kit/resources/topic-dispatch-discipline.sh b/skills/hook-kit/resources/topic-dispatch-discipline.sh index 6c3a98de..b367dce8 100755 --- a/skills/hook-kit/resources/topic-dispatch-discipline.sh +++ b/skills/hook-kit/resources/topic-dispatch-discipline.sh @@ -29,7 +29,14 @@ ARGS=$(echo "$INPUT" | jq -r '.tool_input.args // empty' 2>/dev/null) # a "marketplace:skill" or "plugin:skill" prefix — only the last segment # names the actual skill directory. BARE_NAME="${SKILL_NAME##*:}" +# The prefix is not noise — when present it names the plugin, and a plugin name +# is declared by exactly one marketplace's .claude-plugin/marketplace.json. +# Resolving it back to that marketplace lets the search below stay inside the +# right tree instead of matching same-named skills in unrelated marketplaces. +PLUGIN_PREFIX="" +[[ "$SKILL_NAME" == *:* ]] && PLUGIN_PREFIX="${SKILL_NAME%%:*}" SKILL_MD="" +AMBIGUOUS_NOTE="" for root in ~/.claude/skills ~/.agents/skills; do if [[ -f "$root/$BARE_NAME/SKILL.md" ]]; then SKILL_MD="$root/$BARE_NAME/SKILL.md" @@ -41,10 +48,53 @@ if [[ -z "$SKILL_MD" ]]; then # and a plain `find` will not descend through them — every skill that lives # under a symlinked marketplace silently fails to resolve, so the hook exits # "fail open" and never reminds about the very skills it should cover. - CANDIDATE=$(find -L ~/.claude/plugins/marketplaces ~/.claude/plugins/cache -maxdepth 6 -type d -iname "$BARE_NAME" 2>/dev/null | head -1) + # + # Following the symlink lands inside a working checkout, which in this + # workspace routinely holds several git worktrees under .worktrees/ (or + # .claude/worktrees/). Each one carries its own copy of every skill, so a + # single skill name matches N+1 directories and `head -1` picks arbitrarily + # among them. Observed: this hook pointed at a worktree's cleanup/run.md that + # was two lines behind the live one, five times in one session. Worktrees are + # in-progress branches by definition — never the installed copy — so prune + # them rather than trying to rank the matches. + # Pruning worktrees is necessary but not sufficient: several marketplaces can + # each ship a skill of the same name (consolidate, github-flow, code-quality + # …), and every one of those is a legitimate install, so no prune rule can + # separate them. `head -1` then picks by directory-walk order — observed + # answering Skill("es6kr:consolidate") and Skill("es6kr:github-flow") with a + # different marketplace's copy, even though the call named its plugin. + # + # So when the call carries a prefix, resolve it to the one marketplace whose + # manifest declares that plugin and search only there. Without a prefix there + # is nothing to narrow by, and the search stays global. + SEARCH_ROOTS=(~/.claude/plugins/marketplaces ~/.claude/plugins/cache) + if [[ -n "$PLUGIN_PREFIX" ]] && command -v jq >/dev/null 2>&1; then + for mf in ~/.claude/plugins/marketplaces/*/.claude-plugin/marketplace.json; do + [[ -f "$mf" ]] || continue + if jq -e --arg p "$PLUGIN_PREFIX" \ + '(.plugins // []) | map(.name) | index($p)' "$mf" >/dev/null 2>&1; then + SEARCH_ROOTS=("$(dirname "$(dirname "$mf")")") + break + fi + done + fi + + # Plain string + head/grep instead of mapfile: this hook also runs where + # `env bash` resolves to 3.2 (stock macOS), which has no mapfile. + CANDIDATE_LIST=$(find -L "${SEARCH_ROOTS[@]}" -maxdepth 6 \ + \( -name '.worktrees' -o -name 'worktrees' -o -name '.git' \) -prune -o \ + -type d -iname "$BARE_NAME" -print 2>/dev/null) + CANDIDATE=$(printf '%s\n' "$CANDIDATE_LIST" | head -1) + CANDIDATE_COUNT=$(printf '%s\n' "$CANDIDATE_LIST" | grep -c . ) if [[ -n "$CANDIDATE" && -f "$CANDIDATE/SKILL.md" ]]; then SKILL_MD="$CANDIDATE/SKILL.md" fi + # More than one survivor means the narrowing above could not decide. Say so + # rather than presenting an arbitrary pick as if it were the answer — a wrong + # path here is silent, because it still resolves to a real SKILL.md. + if [[ "$CANDIDATE_COUNT" -gt 1 ]]; then + AMBIGUOUS_NOTE=$'\n\n'"NOTE: ${CANDIDATE_COUNT} directories match \"${BARE_NAME}\" and the call did not narrow to one. The path above is simply the first match — verify it is the installed copy before trusting it:"$'\n'"$(printf '%s\n' "$CANDIDATE_LIST" | sed 's/^/ - /')" + fi fi # Could not resolve the skill directory — fail open (no reminder, no block). @@ -82,7 +132,7 @@ Topic files in $TARGET_DIR: Read the topic(s) covering what you are about to do before acting. If the index genuinely suffices (you only needed the topic list), proceed — but do -not treat the router page as the skill's rules. +not treat the router page as the skill's rules.$AMBIGUOUS_NOTE MSG exit 2 fi @@ -113,6 +163,6 @@ carries "Launching skill: $SKILL_NAME" — the injected body is the SKILL.md router page, not topic content. Read $TARGET_PATH now before acting on the SKILL.md's own default section (failed-attempts.md "skill-topic-dispatch-returns-default-section", status=diagnosed — expected -harness behavior, not a bug; topic routing is the skill's own self-instruction). +harness behavior, not a bug; topic routing is the skill's own self-instruction).$AMBIGUOUS_NOTE MSG exit 2 diff --git a/skills/hook-kit/resources/workspace-config.sh b/skills/hook-kit/resources/workspace-config.sh new file mode 100755 index 00000000..33da47e3 --- /dev/null +++ b/skills/hook-kit/resources/workspace-config.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# workspace-config.sh — shared workspace config resolver. +# +# Single entry point for every hook/script that needs to know which +# receivers (checklist / backlog / rag / wiki) the current workspace is +# wired to. Consumers do not parse the config themselves: +# +# eval "$(workspace-config.sh --export)" +# [ "${WSCFG_RAG_KIND:-none}" = "none" ] && exit 0 # receiver absent -> skip +# +# Roles carry a `kind` discriminator so the vendor can change without any +# consumer edit, and `kind: "none"` makes "not configured" a first-class +# state. That distinction is the point: without it a guard cannot tell an +# unconfigured workspace from a negligent session, and blocks on both. +# +# Config resolution: +# $AGENT_WORKSPACE_CONFIG (explicit; no fallback if it is missing) +# ~/.config/agent-workspace/config.json (v2) +# ~/.config/plane-backlog/config.json (v1, translated on the fly) +# +# Profile resolution: +# $AGENT_WORKSPACE_PROFILE > match.path_components (v2) / cwd_match (v1) +# > "default". An unmatched path resolves to "default", never to an +# arbitrary configured profile — silently targeting another workspace's +# token or collection is worse than resolving nothing. +# +# Failure policy: this runs on every hook invocation, so it degrades rather +# than fails. Missing file, malformed JSON, or no usable interpreter all +# emit all-"none" roles and exit 0. + +set -uo pipefail + +MODE="--export" +TARGET="" +for arg in "$@"; do + case "$arg" in + --export|--json) MODE="$arg" ;; + -h|--help) + sed -n '2,32p' "$0" + exit 0 + ;; + *) TARGET="$arg" ;; + esac +done +[ -n "$TARGET" ] || TARGET="$PWD" + +# Roles a consumer may rely on existing, whatever the config says. +emit_safe_defaults() { + echo "WSCFG_PROFILE=default" + for role in CHECKLIST BACKLOG RAG WIKI; do + echo "WSCFG_${role}_KIND=none" + done +} + +# Windows ships a python3 stub that exits 49 instead of running, so probe +# for an interpreter that actually executes rather than trusting the name. +PY="" +for cand in python3 python; do + if command -v "$cand" >/dev/null 2>&1 && "$cand" -c 'import json,sys' >/dev/null 2>&1; then + PY="$cand" + break + fi +done +if [ -z "$PY" ]; then + emit_safe_defaults + exit 0 +fi + +if [ -n "${AGENT_WORKSPACE_CONFIG:-}" ]; then + CONFIG="$AGENT_WORKSPACE_CONFIG" +else + CONFIG="$HOME/.config/agent-workspace/config.json" + [ -f "$CONFIG" ] || CONFIG="$HOME/.config/plane-backlog/config.json" +fi + +OUT="$("$PY" - "$CONFIG" "$TARGET" "$MODE" <<'PYEOF' 2>/dev/null +import json, os, re, shlex, sys +from pathlib import Path + +cfg_path, target, mode = sys.argv[1], sys.argv[2], sys.argv[3] + +ROLES = ("checklist", "backlog", "rag", "wiki") +BUILTIN_DEFAULTS = { + "checklist": {"kind": "file", "path": ".agents/fix_plan.md"}, + "backlog": {"kind": "none"}, + "rag": {"kind": "none"}, + "wiki": {"kind": "none"}, +} +# Scalar fields promoted to WSCFG_<ROLE>_<FIELD>. Anything else in a role +# spec is ignored rather than guessed at. +FIELDS = ("endpoint", "path", "token_env", "project", + "mcp_prefix", "skill", "topic", "tool_prefix") + + +def bail(): + print("WSCFG_PROFILE=default") + for r in ROLES: + print("WSCFG_%s_KIND=none" % r.upper()) + sys.exit(0) + + +try: + with open(cfg_path, encoding="utf-8") as fh: + cfg = json.load(fh) + if not isinstance(cfg, dict): + bail() +except Exception: + bail() + +profiles = cfg.get("profiles") or {} +if not isinstance(profiles, dict): + bail() + + +def v1_to_roles(p): + """Translate a v1 (flat, vendor-named keys) profile into v2 roles. + + Keeps the old config usable during migration instead of forcing a + big-bang cutover. A blank endpoint means the role is unconfigured, + which is exactly kind: none. + """ + roles = {} + tracker = p.get("tracker_root") or ".agents" + roles["checklist"] = {"kind": "file", "path": "%s/fix_plan.md" % tracker.rstrip("/")} + + if p.get("plane_host"): + roles["backlog"] = { + "kind": "plane", + "endpoint": p["plane_host"], + "token_env": p.get("plane_token_env", "PLANE_API_KEY"), + "project": p.get("default_project", ""), + } + if p.get("qdrant_url"): + collections = {} + for key in ("memory", "task", "wiki"): + val = p.get("qdrant_%s_collection" % key) + if val: + collections[key] = val + roles["rag"] = { + "kind": "qdrant", + "endpoint": p["qdrant_url"], + "mcp_prefix": "mcp__qdrant__", + "collections": collections, + } + if p.get("llm_wiki_path"): + roles["wiki"] = {"kind": "git", "path": p["llm_wiki_path"]} + return roles + + +def match_tokens(name, prof): + """v2 `match.path_components`, falling back to v1 `cwd_match`.""" + match = prof.get("match") + if isinstance(match, dict) and match.get("path_components"): + return match["path_components"] + return prof.get("cwd_match") or [name] + + +def token_matches(token, parts): + """Match a cwd token against a contiguous run of path segments. + + Tokens come in two shapes: a single component ("es6kr") and a path + fragment ("ghq/github.com/es6kr"). Splitting on "/" and comparing + segment sequences handles both, and because each segment is compared + for equality a token still cannot match a longer component that merely + contains it ("not-es6kr-scratch"). + + A component-only matcher silently failed every multi-segment token in + the live config, resolving all workspaces to "default" — i.e. localhost + endpoints and the wrong collections. Keep this sequence-aware. + """ + seq = [s for s in str(token).split("/") if s] + if not seq: + return False + n = len(seq) + return any(list(parts[i:i + n]) == seq for i in range(len(parts) - n + 1)) + + +name = os.environ.get("AGENT_WORKSPACE_PROFILE") +if name not in profiles: + parts = Path(target).resolve().parts + name = "default" + for pname, prof in profiles.items(): + if not isinstance(prof, dict): + continue + if any(token_matches(tok, parts) for tok in match_tokens(pname, prof)): + name = pname + break + +# v2 is identified by the top-level version, not by a profile carrying `roles`: +# a v2 profile may define only `match` and rely entirely on top-level defaults. +# Keying off per-profile `roles` would misclassify such a profile as v1, and +# v1_to_roles would overwrite its configured (default) checklist path. +is_v2 = cfg.get("version") == 2 or any( + isinstance(p, dict) and "roles" in p for p in profiles.values() +) +profile = profiles.get(name) or {} +roles = dict(cfg.get("defaults") or BUILTIN_DEFAULTS) +if isinstance(profile, dict): + if is_v2: + roles.update(profile.get("roles") or {}) + else: + roles.update(v1_to_roles(profile)) +for role, spec in BUILTIN_DEFAULTS.items(): + roles.setdefault(role, spec) + +if mode == "--json": + print(json.dumps({"profile": name, "roles": roles}, indent=2, ensure_ascii=False)) + sys.exit(0) + +SAFE = re.compile(r"[^A-Z0-9_]") + + +def emit(key, value): + print("WSCFG_%s=%s" % (SAFE.sub("_", key.upper()), shlex.quote(str(value)))) + + +emit("PROFILE", name) +for role, spec in roles.items(): + if not isinstance(spec, dict): + continue + emit("%s_KIND" % role, spec.get("kind", "none")) + for field in FIELDS: + if spec.get(field): + emit("%s_%s" % (role, field), spec[field]) + for cname, cval in (spec.get("collections") or {}).items(): + emit("%s_COLLECTION_%s" % (role, cname), cval) +PYEOF +)" + +if [ -z "$OUT" ]; then + emit_safe_defaults + exit 0 +fi + +printf '%s\n' "$OUT" +exit 0 diff --git a/skills/hook-kit/tests/test-block-axis-merged-ask.sh b/skills/hook-kit/tests/test-block-axis-merged-ask.sh index 852e20a0..e447d102 100755 --- a/skills/hook-kit/tests/test-block-axis-merged-ask.sh +++ b/skills/hook-kit/tests/test-block-axis-merged-ask.sh @@ -89,6 +89,18 @@ check "path-fp1 single option bundles 2 files" 0 "$(run "$(mk \ 'Apply to skill.md and topic.md::update both docs together' \ 'Defer::not now')")" +# 8. Finding count with Korean counter suffix ("Finding 3" + counter) → ALLOW. +check "kw-fp2 Korean finding tally" 0 "$(run "$(mk \ + "$(printf 'Finding 3\uac74 \uac80\ud1a0 \uc644\ub8cc \u2014 \ub2e4\uc74c \uc9c4\ud589?')" \ + "$(printf '\ubc18\uc601::\ud328\uce58 \uc801\uc6a9')" \ + "$(printf '\ubcf4\ub958::\ub2e4\uc74c\uc5d0')")")" + +# 9. Korean severity tally ("Critical 0 / Important 2 / Minor 2" + counter) → ALLOW. +check "kw-fp3 Korean severity tally with counter" 0 "$(run "$(mk \ + "$(printf '\uba38\uc9c0 \uc900\ube44 \uc644\ub8cc: Critical 0\uac74 / Important 2\uac74 / Minor 2\uac74')" \ + "$(printf '\uba38\uc9c0 \uc9c4\ud589::\ubaa8\ub4e0 \uc218\uce58 \uc9a9\uc871')" \ + "$(printf '\ubcf4\ub958::\uc7ac\uac80\ud1a0')")")" + echo "" if [[ "$FAIL" -eq 0 ]]; then echo "ALL PASS" diff --git a/skills/hook-kit/tests/test-check-session-rag.sh b/skills/hook-kit/tests/test-check-session-rag.sh new file mode 100755 index 00000000..467dffc9 --- /dev/null +++ b/skills/hook-kit/tests/test-check-session-rag.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# Tests for check-session-rag.sh — the session-end RAG store/find guard. +# +# Regression under test: the guard demanded a RAG store from a workspace +# that has no RAG receiver configured at all, blocking session end with an +# instruction the session could not possibly follow. It could not tell +# "receiver absent" from "session was negligent" because it never consulted +# the workspace config — it only pattern-matched tool names. +# +# Run: bash ~/.agents/skills/hook-kit/tests/test-check-session-rag.sh + +set -uo pipefail + +HOOK="$(cd "$(dirname "$0")/../resources" && pwd)/check-session-rag.sh" +FIXTURE="$(mktemp -d)" +HTTP_PID="" +trap 'rm -rf "$FIXTURE"; [ -n "$HTTP_PID" ] && kill "$HTTP_PID" 2>/dev/null' EXIT + +pass=0 +fail=0 + +check() { # name expected actual + if [ "$2" = "$3" ]; then + pass=$((pass + 1)); printf 'PASS %s\n' "$1" + else + fail=$((fail + 1)); printf 'FAIL %s\n expected exit=[%s]\n actual exit=[%s]\n' "$1" "$2" "$3" + fi +} + +# A session with a finding signal (audit prompt) and zero RAG-store calls — +# the exact shape that tripped the guard. +cat > "$FIXTURE/findings.jsonl" <<'JSONL' +{"message":{"role":"user","content":[{"type":"text","text":"please run an audit of the hook registrations"}]}} +{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/tmp/x.md"}}]}} +JSONL + +# Same, but the session did store to a RAG receiver. +cat > "$FIXTURE/stored.jsonl" <<'JSONL' +{"message":{"role":"user","content":[{"type":"text","text":"please run an audit of the hook registrations"}]}} +{"message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__qdrant__qdrant-find","input":{}}]}} +{"message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__qdrant__qdrant-store","input":{}}]}} +JSONL + +cat > "$FIXTURE/rag-none.json" <<'JSON' +{"version":2, + "profiles":{"wsNoRag":{"match":{"path_components":["wsNoRag"]}, + "roles":{"rag":{"kind":"none"}}}}} +JSON + +cat > "$FIXTURE/rag-on.json" <<'JSON' +{"version":2, + "profiles":{"wsRag":{"match":{"path_components":["wsRag"]}, + "roles":{"rag":{"kind":"qdrant","mcp_prefix":"mcp__qdrant__"}}}}} +JSON + +# A receiver declared without an MCP prefix: nothing to match a health line +# against, so connectivity is unknowable and the guard must not relax. +cat > "$FIXTURE/rag-noprefix.json" <<'JSON' +{"version":2, + "profiles":{"wsRagNP":{"match":{"path_components":["wsRagNP"]}, + "roles":{"rag":{"kind":"qdrant"}}}}} +JSON + +# Stand-in for the CLI so the suite never health-checks the real servers. +# FAKE_MCP_OUTPUT selects the canned health report, FAKE_MCP_FAIL makes the +# command fail, and FAKE_MCP_MARKER records that it ran at all. +mkdir -p "$FIXTURE/bin" +cat > "$FIXTURE/bin/claude" <<'SH' +#!/usr/bin/env bash +[ -n "${FAKE_MCP_MARKER:-}" ] && printf 'x' >> "$FAKE_MCP_MARKER" +[ "${FAKE_MCP_FAIL:-0}" = "1" ] && exit 1 +cat "${FAKE_MCP_OUTPUT:-/dev/null}" +SH +chmod +x "$FIXTURE/bin/claude" + +cat > "$FIXTURE/mcp-connected.txt" <<'TXT' +plugin:playwright:playwright: npx @playwright/mcp@latest - ✔ Connected +qdrant: docker run --rm -i -e QDRANT_URL --entrypoint mcp-server-qdrant img --transport stdio - ✔ Connected +TXT + +cat > "$FIXTURE/mcp-down.txt" <<'TXT' +plugin:playwright:playwright: npx @playwright/mcp@latest - ✔ Connected +qdrant: docker run --rm -i -e QDRANT_URL --entrypoint mcp-server-qdrant img --transport stdio - ✘ Failed to connect — -32000: MCP error -32000: Connection closed +TXT + +cat > "$FIXTURE/mcp-absent.txt" <<'TXT' +plugin:playwright:playwright: npx @playwright/mcp@latest - ✔ Connected +context7: npx -y @upstash/context7-mcp - ✔ Connected +TXT + +export FAKE_MCP_OUTPUT="$FIXTURE/mcp-connected.txt" + +run() { # transcript + printf '{"transcript_path":"%s"}' "$1" | PATH="$FIXTURE/bin:$PATH" "$HOOK" >/dev/null 2>&1 + echo "$?" +} + +# --- The regression ----------------------------------------------------- +export AGENT_WORKSPACE_CONFIG="$FIXTURE/rag-none.json" +export AGENT_WORKSPACE_PROFILE=wsNoRag +check "R1 no RAG receiver configured -> allow stop" "0" "$(run "$FIXTURE/findings.jsonl")" + +# --- The guard must still work where a receiver exists ------------------ +export AGENT_WORKSPACE_CONFIG="$FIXTURE/rag-on.json" +export AGENT_WORKSPACE_PROFILE=wsRag +check "R2 receiver configured + findings + no store -> block" "2" "$(run "$FIXTURE/findings.jsonl")" +check "R3 receiver configured + store + find -> allow" "0" "$(run "$FIXTURE/stored.jsonl")" + +# --- Existing escape hatches must survive ------------------------------- +cat > "$FIXTURE/skip.jsonl" <<'JSONL' +{"message":{"role":"user","content":[{"type":"text","text":"please run an audit of the hook registrations"}]}} +{"message":{"role":"user","content":[{"type":"text","text":"no RAG store needed"}]}} +JSONL +check "R4 explicit user skip phrase -> allow" "0" "$(run "$FIXTURE/skip.jsonl")" + +RALPH_LOOP=1 check "R5 ralph loop bypass -> allow" "0" "$(RALPH_LOOP=1 run "$FIXTURE/findings.jsonl")" + +# --- Configured but not connected in this session ----------------------- +# A receiver the session cannot reach cannot be stored to, so demanding a +# store would be an instruction the session has no way to follow. +export AGENT_WORKSPACE_CONFIG="$FIXTURE/rag-on.json" +export AGENT_WORKSPACE_PROFILE=wsRag + +export FAKE_MCP_OUTPUT="$FIXTURE/mcp-down.txt" +check "R6 receiver configured but not connected -> allow" "0" "$(run "$FIXTURE/findings.jsonl")" + +export FAKE_MCP_OUTPUT="$FIXTURE/mcp-absent.txt" +check "R7 receiver not registered at all -> allow" "0" "$(run "$FIXTURE/findings.jsonl")" + +export FAKE_MCP_OUTPUT="$FIXTURE/mcp-connected.txt" +check "R8 receiver connected + findings + no store -> block" "2" "$(run "$FIXTURE/findings.jsonl")" + +# Unknowable connectivity must keep the guard blocking, not open a silent +# escape hatch: no prefix to match on, and a health command that failed. +export AGENT_WORKSPACE_CONFIG="$FIXTURE/rag-noprefix.json" +export AGENT_WORKSPACE_PROFILE=wsRagNP +check "R9 no mcp_prefix to match -> block" "2" "$(run "$FIXTURE/findings.jsonl")" + +export AGENT_WORKSPACE_CONFIG="$FIXTURE/rag-on.json" +export AGENT_WORKSPACE_PROFILE=wsRag +export FAKE_MCP_FAIL=1 +check "R10 health command failed -> block" "2" "$(run "$FIXTURE/findings.jsonl")" +unset FAKE_MCP_FAIL + +# --- The health check is expensive, so it runs only on the block path --- +export FAKE_MCP_MARKER="$FIXTURE/marker" +rm -f "$FAKE_MCP_MARKER" +run "$FIXTURE/stored.jsonl" >/dev/null +if [ -e "$FAKE_MCP_MARKER" ]; then ran="yes"; else ran="no"; fi +check "R11 allow path does not health-check every server" "no" "$ran" + +rm -f "$FAKE_MCP_MARKER" +run "$FIXTURE/findings.jsonl" >/dev/null +if [ -e "$FAKE_MCP_MARKER" ]; then ran="yes"; else ran="no"; fi +check "R12 block path does health-check" "yes" "$ran" +unset FAKE_MCP_MARKER + +# --- The receiver is reachable without MCP at all ------------------------ +# The domain skill stores through a script that talks HTTP to the same +# endpoint. Treating MCP as the only transport gets this wrong twice: a +# script-based store earns no credit, and an MCP outage waves the session +# through even though the script path still works. + +# A session that stored via the skill's import script, not via an MCP tool. +# It searched via MCP, so only the store side is under test here. +cat > "$FIXTURE/script-stored.jsonl" <<'JSONL' +{"message":{"role":"user","content":[{"type":"text","text":"please run an audit of the hook registrations"}]}} +{"message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__qdrant__qdrant-find","input":{}}]}} +{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"uvx --from fastembed --with requests python scripts/qdrant-import.py --session-id abc --collection claude-memory"}}]}} +JSONL + +# Neither side went through MCP: stored with the import script, consulted with +# the search script. Counting only MCP names reports this as write-only data. +cat > "$FIXTURE/script-both.jsonl" <<'JSONL' +{"message":{"role":"user","content":[{"type":"text","text":"please run an audit of the hook registrations"}]}} +{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"python3 scripts/qdrant-search.py --recent Ralph --limit 5"}}]}} +{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"uvx --from fastembed --with requests python scripts/qdrant-store-chunk.py --document x"}}]}} +JSONL + +# Merely naming the script is not storing with it. +cat > "$FIXTURE/script-mentioned.jsonl" <<'JSONL' +{"message":{"role":"user","content":[{"type":"text","text":"please run an audit of the hook registrations"}]}} +{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"find ~/.agents/skills -name 'qdrant-import*'"}}]}} +JSONL + +HTTP_PORT=8973 +python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1 & +HTTP_PID=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do + curl -sS -o /dev/null -m 1 "http://127.0.0.1:$HTTP_PORT" >/dev/null 2>&1 && break + sleep 0.3 +done + +cat > "$FIXTURE/rag-reachable.json" <<JSON +{"version":2, + "profiles":{"wsReach":{"match":{"path_components":["wsReach"]}, + "roles":{"rag":{"kind":"qdrant", + "endpoint":"http://127.0.0.1:$HTTP_PORT", + "mcp_prefix":"mcp__qdrant__"}}}}} +JSON + +# Port 1 is privileged and unbound: connection is refused at once, no waiting. +cat > "$FIXTURE/rag-unreachable.json" <<'JSON' +{"version":2, + "profiles":{"wsUnreach":{"match":{"path_components":["wsUnreach"]}, + "roles":{"rag":{"kind":"qdrant", + "endpoint":"http://127.0.0.1:1", + "mcp_prefix":"mcp__qdrant__"}}}}} +JSON + +export AGENT_WORKSPACE_CONFIG="$FIXTURE/rag-reachable.json" +export AGENT_WORKSPACE_PROFILE=wsReach +export FAKE_MCP_OUTPUT="$FIXTURE/mcp-connected.txt" +check "S1 script-based store counts as a store" "0" "$(run "$FIXTURE/script-stored.jsonl")" +check "S2 naming the script is not storing with it" "2" "$(run "$FIXTURE/script-mentioned.jsonl")" +check "S5 script search counts as consulting the receiver" "0" "$(run "$FIXTURE/script-both.jsonl")" + +export FAKE_MCP_OUTPUT="$FIXTURE/mcp-down.txt" +check "S3 MCP down but endpoint reachable -> still block" "2" "$(run "$FIXTURE/findings.jsonl")" + +export AGENT_WORKSPACE_CONFIG="$FIXTURE/rag-unreachable.json" +export AGENT_WORKSPACE_PROFILE=wsUnreach +check "S4 MCP down and endpoint unreachable -> allow" "0" "$(run "$FIXTURE/findings.jsonl")" + +printf -- '---\npass=%d fail=%d\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/skills/hook-kit/tests/test-workspace-config.sh b/skills/hook-kit/tests/test-workspace-config.sh new file mode 100755 index 00000000..833ef8b2 --- /dev/null +++ b/skills/hook-kit/tests/test-workspace-config.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# Tests for workspace-config.sh — the shared workspace config resolver. +# +# Run: bash ~/.agents/skills/hook-kit/tests/test-workspace-config.sh +# +# Contract under test: +# workspace-config.sh --export [target_path] +# -> emits eval-able `WSCFG_<ROLE>_<FIELD>=<value>` lines on stdout +# +# Resolution order: AGENT_WORKSPACE_PROFILE env > profile match on path +# components > "default". A missing/unparseable config must degrade to +# kind=none (skip), never to a hard failure — hooks source this on every +# invocation and a crash here would break every session. + +set -uo pipefail + +SHIM="$(cd "$(dirname "$0")/../resources" && pwd)/workspace-config.sh" +FIXTURE="$(mktemp -d)" +trap 'rm -rf "$FIXTURE"' EXIT + +pass=0 +fail=0 + +check() { # name expected actual + if [ "$2" = "$3" ]; then + pass=$((pass + 1)); printf 'PASS %s\n' "$1" + else + fail=$((fail + 1)); printf 'FAIL %s\n expected=[%s]\n actual =[%s]\n' "$1" "$2" "$3" + fi +} + +# Clear every WSCFG_* var so a stale value from a previous case cannot +# masquerade as a pass. Must use `compgen -v`, not `env`: the shim emits +# plain assignments (not `export`), so `env` does not list them and the +# stale value survives into the next case as a false pass. +reset_env() { + local name + for name in $(compgen -v | grep '^WSCFG_' || true); do + unset "$name" + done +} + +load() { # target_path + reset_env + eval "$("$SHIM" --export "$1" 2>/dev/null)" || true +} + +cat > "$FIXTURE/config.json" <<'JSON' +{ + "version": 2, + "defaults": { + "checklist": { "kind": "file", "path": ".agents/fix_plan.md" }, + "backlog": { "kind": "none" }, + "rag": { "kind": "none" }, + "wiki": { "kind": "none" } + }, + "profiles": { + "wsA": { + "match": { "path_components": ["wsA"] }, + "roles": { + "rag": { + "kind": "qdrant", + "endpoint": "http://example.invalid:6333", + "mcp_prefix": "mcp__qdrant__", + "collections": { "wiki": "a-wiki", "task": "a-task" } + }, + "wiki": { "kind": "skill", "skill": "x:wiki", "topic": "query" } + } + }, + "wsB": { + "match": { "path_components": ["wsB"] }, + "roles": { + "wiki": { "kind": "mcp", "tool_prefix": "mcp__kordoc__" } + } + } + } +} +JSON + +export AGENT_WORKSPACE_CONFIG="$FIXTURE/config.json" + +# --- Profile detection ------------------------------------------------- +load "/tmp/wsA/repo" +check "T1 profile matched by path component" "wsA" "${WSCFG_PROFILE:-}" + +# --- role -> kind indirection ------------------------------------------ +check "T2 rag kind from profile" "qdrant" "${WSCFG_RAG_KIND:-}" +check "T3 rag mcp_prefix exported" "mcp__qdrant__" "${WSCFG_RAG_MCP_PREFIX:-}" +check "T4 collections flattened per key" "a-wiki" "${WSCFG_RAG_COLLECTION_WIKI:-}" + +# --- wiki supports 3 kinds (git / skill / mcp) ------------------------- +check "T5 wiki kind=skill (not git)" "skill" "${WSCFG_WIKI_KIND:-}" +check "T6 wiki skill id exported" "x:wiki" "${WSCFG_WIKI_SKILL:-}" + +load "/tmp/wsB/repo" +check "T7 wiki kind=mcp with tool_prefix" "mcp__kordoc__" "${WSCFG_WIKI_TOOL_PREFIX:-}" + +# --- No match must NOT leak another workspace's receivers -------------- +load "/tmp/unrelated/repo" +check "T8 unmatched path -> default profile" "default" "${WSCFG_PROFILE:-}" +check "T9 default rag kind is none" "none" "${WSCFG_RAG_KIND:-}" + +# --- defaults inheritance ---------------------------------------------- +# wsA defines no checklist role; it must inherit from defaults rather than +# vanishing (consumers rely on the checklist path always resolving). +reset_env +export AGENT_WORKSPACE_PROFILE=wsA +eval "$("$SHIM" --export "/tmp" 2>/dev/null)" || true +check "T10 role absent in profile inherits default" ".agents/fix_plan.md" "${WSCFG_CHECKLIST_PATH:-}" +unset AGENT_WORKSPACE_PROFILE + +# --- Degradation: a broken/missing config must not block --------------- +export AGENT_WORKSPACE_CONFIG="$FIXTURE/does-not-exist.json" +load "/tmp/wsA/repo" +check "T11 missing config degrades to none" "none" "${WSCFG_RAG_KIND:-}" + +printf 'not json at all' > "$FIXTURE/broken.json" +export AGENT_WORKSPACE_CONFIG="$FIXTURE/broken.json" +load "/tmp/wsA/repo" +check "T12 unparseable config degrades to none" "none" "${WSCFG_RAG_KIND:-}" + +"$SHIM" --export "/tmp/wsA/repo" >/dev/null 2>&1 +check "T13 broken config still exits 0" "0" "$?" + +# --- v1 config translation -------------------------------------------- +# The live v1 config writes cwd_match as multi-segment strings +# ("ghq/github.com/es6kr"). A matcher that only compares single path +# components can never match those, which silently resolved every +# workspace to "default" (localhost qdrant, empty plane host). +cat > "$FIXTURE/v1.json" <<'JSON' +{ + "profiles": { + "wsLegacy": { + "cwd_match": ["ghq/github.com/wsLegacy", "WSLEGACY"], + "default_project": "proj-1", + "llm_wiki_path": "/tmp/wsLegacy/llm-wiki", + "plane_host": "https://plane.example.invalid", + "plane_token_env": "WSLEGACY_TOKEN", + "qdrant_memory_collection": "claude-memory", + "qdrant_url": "http://example.invalid:30333", + "qdrant_wiki_collection": "legacy-wiki", + "workspace_name": "wsLegacy" + } + } +} +JSON +export AGENT_WORKSPACE_CONFIG="$FIXTURE/v1.json" + +load "/tmp/ghq/github.com/wsLegacy/repo" +check "T14 multi-segment cwd_match matches" "wsLegacy" "${WSCFG_PROFILE:-}" +check "T15 v1 qdrant_url -> rag endpoint" "http://example.invalid:30333" "${WSCFG_RAG_ENDPOINT:-}" +check "T16 v1 collections translated" "legacy-wiki" "${WSCFG_RAG_COLLECTION_WIKI:-}" +check "T17 v1 plane_host -> backlog kind" "plane" "${WSCFG_BACKLOG_KIND:-}" +check "T18 v1 llm_wiki_path -> wiki kind=git" "git" "${WSCFG_WIKI_KIND:-}" + +# A multi-segment token must match contiguous segments only — a token must +# not match a longer component that merely contains it. +load "/tmp/ghq/github.com/not-wsLegacy-scratch/repo" +check "T19 substring-only path does not match" "default" "${WSCFG_PROFILE:-}" + +# --- v2 profile with no `roles` key inherits top-level defaults --------- +# Regression: v2 used to be detected per-profile via `roles` presence. A v2 +# profile that defines only `match` and relies on top-level `defaults` was +# misread as v1, and v1_to_roles overwrote its configured checklist path with +# the `.agents/fix_plan.md` v1 fallback. version==2 must win regardless. +cat > "$FIXTURE/v2-noroles.json" <<'JSON' +{ + "version": 2, + "defaults": { + "checklist": { "kind": "file", "path": ".custom/tracker.md" }, + "rag": { "kind": "qdrant", "endpoint": "http://example.invalid:6333", "mcp_prefix": "mcp__qdrant__" } + }, + "profiles": { + "wsBare": { "match": { "path_components": ["wsBare"] } } + } +} +JSON +export AGENT_WORKSPACE_CONFIG="$FIXTURE/v2-noroles.json" +load "/tmp/wsBare/repo" +check "T20 no-roles v2 profile resolves" "wsBare" "${WSCFG_PROFILE:-}" +check "T21 no-roles v2 keeps default checklist" ".custom/tracker.md" "${WSCFG_CHECKLIST_PATH:-}" +check "T22 no-roles v2 keeps default rag kind" "qdrant" "${WSCFG_RAG_KIND:-}" + +printf -- '---\npass=%d fail=%d\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/skills/next/CHANGELOG.md b/skills/next/CHANGELOG.md index 359bd2ca..d6b64384 100644 --- a/skills/next/CHANGELOG.md +++ b/skills/next/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [0.9.0](https://github.com/es6kr/skills/compare/next-v0.8.1...next-v0.9.0) (2026-08-20) + + +### Features + +* promote next-feat batch (hook registry schema, self-reference path anchoring) ([0c33ffa](https://github.com/es6kr/skills/commit/0c33ffac99a9237f4530566470dabeaea128c209)) +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + + +### Bug Fixes + +* **hooks:** correct ghost path for block-wip-register-before-execute.sh ([d5875a2](https://github.com/es6kr/skills/commit/d5875a2e624eeb4a3dce9beba5d96a4365a8f950)) +* **next:** require a re-measure before executing a context-gate-justified choice ([#325](https://github.com/es6kr/skills/issues/325)) ([ed76956](https://github.com/es6kr/skills/commit/ed769564400494fe3a3b84ebc52cfee4ab1bcbbe)) +* promote next-fix batch (hook path repair, topic-dispatch scoping, conflict diagnosis) ([eb7ecb6](https://github.com/es6kr/skills/commit/eb7ecb61dda9701d78f12dc810781dc7cb687caa)) + + +### Refactor + +* **session:** rename claude-session to session skill ([318d6a1](https://github.com/es6kr/skills/commit/318d6a1fcc04621459e7f3e6cab2394bc0b68590)) + ## [0.8.1](https://github.com/es6kr/skills/compare/next-v0.8.0...next-v0.8.1) (2026-08-17) diff --git a/skills/next/SKILL.md b/skills/next/SKILL.md index 0ed5c01f..6ff1cad9 100644 --- a/skills/next/SKILL.md +++ b/skills/next/SKILL.md @@ -125,7 +125,7 @@ Identify the type of task just completed. |--------|------------------| | Visible TaskList | All pending/in_progress entries (call `TaskList` per Step 0.5) | | Just-completed work | Direct follow-ups (commit / push / verify / test / publish) | -| Open PRs / issues | `gh pr list --search "involves:@me state:open"` / `gh issue list` (when relevant) | +| Open PRs / issues | `gh pr list --search "involves:@me state:open"` / `gh issue list` (when relevant) — **In Antigravity, unapproved PRs must ONLY be offered as clawo delegation options (`[clawo] ... (/clawo consolidate PR #N)`), never as direct main-session tasks (HARD STOP)** | | Recent commits awaiting CI | `gh run list --limit 5` for pending CI watch | | fix_plan.md / checklist.md | Project-tracked next items (Ralph or general workspace) — **mandatory read when a fix_plan / checklist skill is available** (see "Dependency-gated behaviors"); otherwise an ordinary optional source | | Plane | Self-hosted project tracker, if this environment has one configured (check local infra docs for connection details) — check open issues/cycles when the project has one wired up | @@ -200,8 +200,6 @@ AskUserQuestion({ (See failed-attempts.md "background-agent-without-parallel-work" for recurrence history.) -**Decide foreground vs background BEFORE spawning, not after (HARD STOP)**: idle-waiting on a lone background agent buys zero parallelism and only exposes the turn to prompt-cache-TTL-expiry cost (the 5-minute window — usage-overage state cannot be known in advance, so always plan for the shorter window) once nothing else fills the wait. Before every `Agent` spawn, check: is there other selected/pending work this turn could drive while the agent runs? If yes, background it and drive that other work. If no, spawn it in the foreground (`run_in_background: false`, or the Agent tool's default synchronous behavior) instead of backgrounding it and then idle-waiting alone for its own notification. A single-item follow-up (e.g. "run Internal Review on this PR, then post the Summary") with nothing else queued is a foreground case, not a background-and-wait case. (See failed-attempts.md "background-agent-without-parallel-work".) - ## Suggestion Patterns Per-context option templates for "After X" completions (code change, feature, bug fix, config, commit, push, PR fix-commit re-review, PR creation reviewer matrix, skill/agent creation, file creation, refactoring, complex workflow, exploration, session wrap-up, PR consolidate). diff --git a/skills/next/ask-gates.md b/skills/next/ask-gates.md index 68a138e8..9f5e49d3 100644 --- a/skills/next/ask-gates.md +++ b/skills/next/ask-gates.md @@ -1,5 +1,7 @@ # Ask Gates — recording-skip / TaskList primary-source / current-work confirmation +> **Applies even when this file wasn't Read this turn (HARD STOP — recurrence, 3rd occurrence)**: a Stop hook (e.g. `next-invocation-guard`'s `next-trigger.sh`) can force an `AskUserQuestion` directly, without `Skill("next")` ever being invoked — its block message reads narrowly as "just call AskUserQuestion, nothing else." That narrow framing is not license to skip the cross-cutting rules that would normally apply via this file and `suggestion-patterns.md`. The PR/issue full-URL requirement (`suggestion-patterns.md` "Cross-cutting rule — PR/issue references in options require the full URL") applies to **every** `AskUserQuestion` that references a PR/issue number, regardless of entry path. Recurrence: `failed-attempts.md` "ask-option-pr-ref-missing-url" (3rd, 2026-08-22) — the 1st and 2nd occurrences both went through `Skill("next")` with a skipped topic Read; the 3rd bypassed `Skill("next")` entirely via a Stop-hook-forced direct ask, so a self-check that only fires *inside* the skill's own Step 2 never ran. Before calling `AskUserQuestion` from a Stop-hook directive, still check: does any option reference a PR/issue number? If yes, does each distinct number have its own full URL? + ## Step 0.3: Recording/management topic ask-skip gate (HARD STOP) **If the just-completed work is a "simple recording/management topic", skip the next-action ask entirely.** Stop hook auto-triggers next skill on every task completion, but recording-topic completion is not a user-decision branch point. @@ -156,6 +158,7 @@ Otherwise → proceed to Step 0.5. 1. Does this ask relate to task progress direction? → If yes, TaskList Read is mandatory 2. Do the tasks mentioned in option descriptions **actually exist in TaskList**? — 1:1 mapping with TaskList output 3. If there are N pending tasks but only M < N appear in options → state the filtering reason in description or use the wrap-up pattern +4. **Does any option/question text reference a PR or issue?** → If yes, the full clickable URL (`https://github.com/<owner>/<repo>/pull|issues/<N>`) must appear in that same ask — a bare `#N` is forbidden even when accompanied by a repo name. This is `suggestion-patterns.md`'s own "Cross-cutting rule — PR/issue references in options require the full URL" restated here because Step 0.5's TaskList check is exactly the point in the flow where a task's subject (which may itself be a bare `#N`) gets copied into an option — do not carry that bare reference forward without resolving its URL first. Recurred twice (2026-07-17 fix, 2026-08-18/19 recurrence) precisely because `suggestion-patterns.md` was not Read before composing the ask on the second occurrence — treat `SKILL.md`'s "Read suggestion-patterns.md BEFORE composing options" HARD STOP as non-optional, not as background context you can skip once you've read it before in the session ## Step 0.6: Workspace fix_plan.md active integration protocol (MANDATORY — when TaskList is empty, done, or unavailable) @@ -229,8 +232,13 @@ This is distinct from Step 0.65 (zero-candidate repeated firing) — here candid | 4 | Treat "the hook fired again" as proof a fresh ask is owed regardless of content | The hook firing is a *reminder to check*, not a mandate to always ask — check both preconditions first | | 5 | Silently drop the carryover items on grounds of "already asked" | Still report their carryover status in plain text (per Step 0.3's skip-still-reports discipline) — silence is the ask-gates violation this table exists to prevent elsewhere | | 6 | Treat an intervening non-cleanup action (RAG import, a push, a PR conversion) between the prior ask and this fire as "nothing changed, so cleanup precondition is close enough" | Only a literal `/cleanup` run satisfies the cleanup precondition. Any other intervening action means this step's silent-skip path does not apply — ask | +| 7 | Extend the cleanup-adjacent silent-skip across an entire "waiting on external/user action" polling period — treat the global "don't repeat the same ask while the user is actively polling/working" rule as overriding this step's own Exception clause for every subsequent re-fire | The cleanup-adjacent skip is a **single, one-time** use immediately after `/cleanup`. Every re-fire after that (including during a polling wait) falls back to the Exception below (minimal single-item confirmation) — the global polling-ask-avoidance rule governs *repeating a full multi-option ask*, not *whether any ask at all is owed on a Stop-hook re-fire* | + +**Exception**: if the one item that is still genuinely open differs from the rest (e.g., 3 items already deferred, 1 item newly blocking), a minimal single-item confirmation for that one item is allowed — do not fold it back into a full 4-option re-ask of the whole set. This minimal-confirmation form is available even when the cleanup precondition fails (it is not a "full ask", so it isn't gated the same way) — use it instead of a silent status report whenever precondition 1 fails. **This is the required fallback for every next-trigger re-fire during a polling/waiting period** (row 7) — a bare status report with zero `AskUserQuestion` call is never correct here, no matter how many times the hook has already fired. + +### Case history — repeated re-fires during a polling wait (2026-08-17) -**Exception**: if the one item that is still genuinely open differs from the rest (e.g., 3 items already deferred, 1 item newly blocking), a minimal single-item confirmation for that one item is allowed — do not fold it back into a full 4-option re-ask of the whole set. This minimal-confirmation form is available even when the cleanup precondition fails (it is not a "full ask", so it isn't gated the same way) — use it instead of a silent status report whenever precondition 1 fails. +A session waited on a user's manual `git push` (a bash-guard-blocked command) while `next-trigger.sh` fired 8+ times in a row. Each time the assistant called `Skill("next")` (satisfying the hook's literal check) but ended with plain status text ("still waiting", "same state") — zero `AskUserQuestion` calls across the entire wait. The assistant had correctly applied the cleanup-adjacent single-use skip once, then kept reusing that same reasoning for every subsequent fire, reasoning that the global "don't repeat the same ask while the user is polling" rule justified continuing to skip. It does not — see row 7. The mechanical root cause (why the hook kept re-firing at all) turned out to be a separate bug in `next-trigger.sh` itself: the hook's own injected `"Stop hook feedback: ..."` block text is stored in the transcript as a `type=="user"` entry with string content, indistinguishable from a genuine human prompt to the hook's own "last real user turn" anchor — so every re-fire reset the hook's turn-based suppression against itself. Fixed at the hook level (exclude that literal prefix from the anchor computation) plus a belt-and-suspenders 3-consecutive-fires-without-`AskUserQuestion` escalation counter, independent of this specific bug, for any other scenario producing the same symptom. ### Self-check @@ -370,13 +378,14 @@ Get the number the same way the cleanup-gate section does: the latest injected ` |---|-------|-----| | 1 | Compose a next-action ask with zero cleanup candidates and omit the context percentage because "no cleanup option is being offered, so the citation rule doesn't apply" | Cite the live percentage in the question text regardless — it is baseline situational awareness for the user, independent of whether cleanup is being recommended | | 2 | Assume `block-cleanup-option-below-context-gate.sh` will catch a missing percentage | That hook only fires when a cleanup/wrap-up option is present in the payload. A plain "what next" ask with no such option is invisible to it — this is a skill-level obligation, not a hook-enforced one | -| 3 | Treat a percentage that was correct at ask-composition time as still valid once the user answers, and execute the selection without re-measuring | The gate has no validity duration. Automatic context compression can fire **while the ask is open** and leaves no `isCompactSummary` / `compact_boundary` marker, so the staleness is undetectable after the fact — re-measure before executing any selection the percentage justified. Full rule: suggestion-patterns.md "Round-trip invalidation" | +| 3 | Assume the gate is NOT met because an earlier reading this turn/chain was below threshold (**stale-LOW**) — a long tool-call chain (audits, a full /fix flow) can grow usage tens of points past the old number | Re-measure immediately before deciding next-vs-cleanup and before composing the ask. A below-threshold reading taken more than a few tool calls ago never proves the gate is still unmet — staleness cuts both ways (stale-HIGH overstates after a compact; stale-LOW understates after a long chain) | +| 4 | Treat a percentage that was correct at ask-composition time as still valid once the user answers, and execute the selection without re-measuring | The gate has no validity duration. Automatic context compression can fire **while the ask is open** and leaves no `isCompactSummary` / `compact_boundary` marker, so the staleness is undetectable after the fact — re-measure before executing any selection the percentage justified. Full rule: suggestion-patterns.md "Round-trip invalidation" | ### Self-check (every time before calling `AskUserQuestion` from this skill) 1. Does the question text include the live percentage (`NN%` or `NN.N%`)? → If no, add it before calling -2. Is the reading fresh (post-compact, not from a stale earlier turn in a long tool-call chain)? → If stale, get a live reading first (suggestion-patterns.md "Live-check fallback") -3. **On receiving the answer**: was the percentage part of the chosen option's justification? → If yes, re-measure before executing. A reading that has since fallen below the threshold invalidates the user's consent, not just the rationale — report the new figure and re-offer instead of proceeding +2. Is the reading fresh (post-compact, not from a stale earlier turn in a long tool-call chain)? → If stale, get a live reading first (suggestion-patterns.md "Live-check fallback") — stale in EITHER direction: an old high reading overstates post-compact, an old low reading understates after a long tool-call chain +3. Is the fresh reading at/above the session model's threshold (or did the injection script emit a `CLEANUP-GATE` directive)? → The cleanup/retrospective option becomes **REQUIRED as the Recommended #1 option** of the turn-final ask — next-action candidate discovery is secondary and may be skipped --- diff --git a/skills/next/resources/next-reactive-guard.sh b/skills/next/resources/next-reactive-guard.sh new file mode 100644 index 00000000..48c136c3 --- /dev/null +++ b/skills/next/resources/next-reactive-guard.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# next-reactive-guard.sh — UserPromptSubmit hook for the next skill. +# +# Reactive counterpart to next-trigger.sh (Stop hook). next-trigger.sh cannot +# fire on a continuation chain: once it blocks (decision:"block"), the resumed +# turn carries stop_hook_active=true and the hook MUST exit 0 or it would loop +# forever. Every "next call missed" recurrence (see failed-attempts.md +# "next-invocation") happens inside that suppressed window. No Stop-based guard +# can close it — but UserPromptSubmit fires on the NEXT prompt and CAN look back. +# +# On each user prompt, this guard inspects the PRIOR turn and injects a +# corrective reminder when ALL of: +# 1. Suppression — the latest next-trigger.debug.log entry for this transcript +# is `suppressed=stop_hook_active` (the prior turn's Stop was silenced). +# 2. Completion — the prior turn's assistant text matches the completion +# PATTERN (reused verbatim from next-trigger.sh's data/*.regex loader). +# 3. No next-after-completion — the last Skill("next") call in the prior turn +# comes BEFORE the last completion signal (a mid-turn next ask does NOT +# satisfy a later batch completion — suggestion-patterns row 7). Firing on +# "completion with no subsequent next" catches the exact "already-invoked- +# this-chain" rationalization variant, not just "next never called". +# 4. No terminal ask — the completion signal is also NOT followed by an +# AskUserQuestion. A turn that ends on an ask (e.g. /wip Step 2 per-item +# direction, /fix disposition) surfaced follow-up to the user directly, so +# an earlier completion signal is not an un-followed batch. Without this, +# the guard false-fired on every ask-terminal turn (the known FP). +# +# Reactive limitation: fires on the NEXT prompt, so it cannot prevent the +# same-turn miss — it offloads detection from the user to the hook. +# +# Responsibility: next skill (automation.md). Registered directly from +# resources/ (like next-trigger.sh) so ../data and the debug log resolve. +# Input (stdin): JSON { transcript_path, ... } +# Output (stdout): on fire, {"hookSpecificOutput":{"hookEventName": +# "UserPromptSubmit","additionalContext":"<reminder>"}}. Otherwise empty. + +set -euo pipefail + +INPUT="$(cat)" +TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "") +[[ -z "$TRANSCRIPT" || ! -f "$TRANSCRIPT" ]] && exit 0 + +SELFDIR="$(cd "$(dirname "$0")" && pwd)" +GUARD_LOG="$SELFDIR/next-reactive-guard.debug.log" + +# --- Signal 1: continuation-chain suppression ------------------------------- +# The debug log lives next to next-trigger.sh (same resources/ dir). Resolve it +# tolerantly in case this guard is run from a copied location. +# NEXT_TRIGGER_DEBUG_LOG overrides for testing (fixture harness drives it). +DEBUG_LOG="${NEXT_TRIGGER_DEBUG_LOG:-}" +if [[ -z "$DEBUG_LOG" ]]; then + for cand in "$SELFDIR/next-trigger.debug.log" \ + "$HOME/.agents/skills/next/resources/next-trigger.debug.log" \ + "$HOME/.claude/skills/next/resources/next-trigger.debug.log"; do + [[ -f "$cand" ]] && { DEBUG_LOG="$cand"; break; } + done +fi +[[ -z "$DEBUG_LOG" || ! -f "$DEBUG_LOG" ]] && exit 0 + +# Latest next-trigger entry for THIS transcript must be a stop_hook_active +# suppression — i.e. the prior turn's Stop hook was silenced (the blind spot). +LATEST=$(grep -F "transcript=$TRANSCRIPT" "$DEBUG_LOG" 2>/dev/null | tail -1 || true) +[[ "$LATEST" != *"suppressed=stop_hook_active"* ]] && exit 0 + +# --- Completion PATTERN (reused from next-trigger.sh) ----------------------- +DATA_DIR="$SELFDIR/../data" +if compgen -G "$DATA_DIR/*.regex" > /dev/null 2>&1; then + PATTERN=$(cat "$DATA_DIR"/*.regex | sed 's/#.*$//' | awk 'NF' | paste -sd'|' -) +else + PATTERN='Fix complete:|✅|all done|^[[:space:]]*done\.|task (complete|completed|finished)|completed[\.\!\)\*,[:space:]]|finished[\.\!\)\*,[:space:]]|wrapped up' +fi + +# --- Signals 2 & 3: ordered completion-vs-next events over the prior turn ---- +# Emit, in transcript order, one token per relevant event since the last real +# user prompt (a user message whose content is a STRING — tool_result content +# is an array, so those anchor lines are skipped, mirroring next-trigger.sh): +# "N" -> a Skill("next") tool_use +# "A" -> an AskUserQuestion tool_use (terminal ask suppresses firing) +# "T\t<txt>" -> an assistant text block (tested against PATTERN below) +STREAM=$(jq -R 'fromjson? // empty' "$TRANSCRIPT" 2>/dev/null | jq -rs ' + [ .[] | select(type == "object") ] as $e + | if ($e | length) == 0 then empty + else + ([ range(0; ($e | length)) + | select($e[.].type == "user" and (($e[.].message.content? | type) == "string")) ] | max // -1) as $lu + | $e[($lu + 1):][] + | select(.type == "assistant") + | (.message.content // [])[] + | if .type == "text" then "T\t" + ((.text // "") | gsub("\n"; " ")) + elif (.type == "tool_use" and .name == "Skill" and ((.input.skill? // "") == "next")) then "N" + elif (.type == "tool_use" and .name == "AskUserQuestion") then "A" + else empty end + end +' 2>/dev/null || echo "") + +last_c=-1 # index of last completion-signal text block +last_n=-1 # index of last Skill("next") call +last_a=-1 # index of last AskUserQuestion tool_use +idx=0 +while IFS= read -r line; do + [[ -z "$line" ]] && continue + idx=$((idx + 1)) + if [[ "$line" == "N" ]]; then + last_n=$idx + elif [[ "$line" == "A" ]]; then + last_a=$idx + else + txt=${line#T$'\t'} + if printf '%s' "$txt" | grep -qiE "$PATTERN"; then + last_c=$idx + fi + fi +done <<< "$STREAM" + +# Diagnostics (matches next-trigger.sh's evidence-trail philosophy). +{ + printf '%s\ttranscript=%s\tlast_c=%s\tlast_n=%s\tlast_a=%s\tfire=%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TRANSCRIPT" "$last_c" "$last_n" "$last_a" \ + "$([[ "$last_c" -gt "$last_n" && "$last_c" -gt "$last_a" ]] && echo true || echo false)" >> "$GUARD_LOG" + if [[ "$(wc -l < "$GUARD_LOG" 2>/dev/null || echo 0)" -gt 500 ]]; then + tail -n 200 "$GUARD_LOG" > "$GUARD_LOG.tmp" && mv "$GUARD_LOG.tmp" "$GUARD_LOG" + fi +} 2>/dev/null || true + +# Fire when a completion signal is the last relevant event — no Skill("next") +# AND no AskUserQuestion after it (last_n/last_a == -1 means never called this +# turn). A terminal ask surfaced follow-up directly, so it suppresses firing. +if [[ "$last_c" -gt "$last_n" && "$last_c" -gt "$last_a" ]]; then + REASON='next-invocation continuation-chain guard: the prior turn appears to have completed a task batch inside the stop_hook_active window (Stop hook suppressed) without a Skill("next") call after the completion. A mid-turn ask does not satisfy a batch-completion next-action ask (suggestion-patterns row 7). If a batch actually completed, invoke the `next` skill now to surface follow-up options before continuing; if not, proceed.' + jq -cn --arg ctx "$REASON" '{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $ctx}}' +fi + +exit 0 diff --git a/skills/next/resources/next-trigger.sh b/skills/next/resources/next-trigger.sh new file mode 100644 index 00000000..bacad61c --- /dev/null +++ b/skills/next/resources/next-trigger.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# next-trigger.sh — Stop hook for next skill +# +# Detects task completion keywords in the last assistant message and emits +# a skill-trigger marker so that the LLM invokes the `next` skill in its +# follow-up response. +# +# Besides the keyword scan, two blind-spot guards fire unconditionally: +# 1. Tool-call-only ending — the last assistant message has zero text blocks +# (e.g. the turn ended on a bare ScheduleWakeup call). No text = no report +# to the user AND the keyword scan can never match, so this is always a +# defect signal. +# 2. Waiting-turn ending — the last assistant message registered a +# ScheduleWakeup (polling/wait handoff). Control returns to the user for a +# long window, so follow-up options are due even without a completion +# keyword. +# +# Trigger condition: Stop hook fires when Claude finishes a response. +# Input (stdin): JSON { session_id, transcript_path, stop_hook_active } +# Output (stdout): on match, a JSON Stop-hook decision object of the form +# {"decision":"block","reason":"<skill-trigger name=\"next\">…</skill-trigger>"} +# (the skill-trigger marker is embedded inside the JSON `reason` field — it is +# NOT emitted as a bare standalone marker, because Stop hooks deliver stdout to +# the debug log only; the `decision:"block"` envelope is what surfaces the +# `reason` text to the LLM. See ~/.claude/skills/hook-kit/SKILL.md "Output channel +# spec per event"). On no match, output is empty. +# +# Responsibility: next skill (per automation.md "Hook responsibility policy"). +# Install: copy to ~/.claude/hooks/next-trigger.sh and register in +# ~/.claude/settings.json under "Stop" matcher (see next/SKILL.md Install). + +set -euo pipefail + +INPUT="$(cat)" + +# JSON parsing uses jq (a hook-wide dependency — the trigger dispatchers all use it). +# Do NOT use `python3` here: on Windows Git Bash `python3` is often the Microsoft +# Store stub, which silently no-ops and leaves this hook permanently dormant. +TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "") + +STOP_HOOK_ACTIVE=$(printf '%s' "$INPUT" | jq -r 'if .stop_hook_active then "true" else "false" end' 2>/dev/null || echo "false") + +DEBUG_LOG="$(dirname "$0")/next-trigger.debug.log" + +# Prevent infinite loops: do not re-trigger when this hook itself caused the stop. +# OBSERVABILITY (next-invocation family, 12th recurrence): this suppression used to +# exit with NO log line, hiding the fact that EVERY later stop of a continuation +# chain (a turn resumed from an earlier Stop-hook block) is silently skipped by the +# harness — exactly the window where the "next call missed" family recurs on long +# chained turns. The suppression itself cannot be bypassed (loop prevention is the +# point); log it so the gap is diagnosable from evidence instead of guesses. +if [[ "$STOP_HOOK_ACTIVE" == "true" ]]; then + { printf '%s\tsuppressed=stop_hook_active\ttranscript=%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TRANSCRIPT" >> "$DEBUG_LOG"; } 2>/dev/null || true + exit 0 +fi + +if [[ -z "$TRANSCRIPT" || ! -f "$TRANSCRIPT" ]]; then + { printf '%s\tearly_exit=no_transcript\ttranscript=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TRANSCRIPT" >> "$DEBUG_LOG"; } 2>/dev/null || true + exit 0 +fi + +# Extract concatenated text of the most recent assistant message from JSONL. +# IMPORTANT: A single response is split into multiple text blocks (between tool_use +# blocks). We must concatenate ALL text blocks of the LAST message — not overwrite +# with each block, which captures only the trailing fragment (often non-completion). +# See ~/.agents/rules/failed-attempts.md "Hook last_text missed multiple text-blocks". +# jq pipeline (python3-free — see note above). Stage 1 (`jq -R 'fromjson? // empty'`) +# tolerantly parses each JSONL line, skipping any malformed line (mirrors the old +# per-line try/except). Stage 2 slurps, takes the LAST assistant message's uuid, and +# concatenates every text block across assistant lines sharing that uuid — so a single +# response split into multiple text blocks (between tool_use blocks) is fully captured. +# +# SCOPE: everything after the last real user prompt — i.e. the whole current +# turn — NOT just the last assistant entry's uuid group. A turn that emits text +# and then ends on a tool call is written as TWO assistant entries with +# DIFFERENT uuids (text entry, then tool_use-only entry). Scoping to the last +# uuid therefore saw an empty text and fired the "tool-call-only" guard below on +# turns that did report to the user (observed twice in one session, 2026-07-24). +# `tool_result` entries also carry type=="user", so the anchor accepts only user +# entries whose content is a STRING (a real prompt); tool_result content is an +# array. Mirrors block-ask-on-question-signal.sh's anchor. +LAST_TEXT=$(jq -R 'fromjson? // empty' "$TRANSCRIPT" 2>/dev/null | jq -rs ' + [ .[] | select(type == "object") ] as $e + | if ($e | length) == 0 then "" + else + ([ range(0; ($e | length)) + | select($e[.].type == "user" and (($e[.].message.content? | type) == "string")) ] | max // -1) as $lu + | [ $e[($lu + 1):][] + | select(.type == "assistant") + | (.message.content // [])[] | select(.type == "text") | .text ] + | join("\n") + end +' 2>/dev/null || echo "") + +# Tool names used by the last assistant message (same uuid group) — needed for +# the waiting-turn guard below. +LAST_TOOLS=$(jq -R 'fromjson? // empty' "$TRANSCRIPT" 2>/dev/null | jq -rs ' + [ .[] | select(.type == "assistant") ] as $a + | if ($a | length) == 0 then "" + else + ($a | last | .uuid // "") as $u + | [ $a[] | select((.uuid // "") == $u) | (.message.content // [])[] | select(.type == "tool_use") | .name ] + | join(",") + end +' 2>/dev/null || echo "") + +# Blind-spot guard 1 — tool-call-only ending (no final text at all). +# A turn that stops without any user-facing text is always a reporting defect: +# the final message must carry the report. Empty text also means the keyword +# scan below can never fire, so this exact case (e.g. ending on a bare +# ScheduleWakeup call) silently bypassed the trigger before this guard. +# The LAST_TOOLS check distinguishes a genuine tool-call-only message from an +# empty/assistant-less transcript (both yield empty LAST_TEXT). +if [[ -z "$LAST_TEXT" && -n "$LAST_TOOLS" ]]; then + echo '{"decision":"block","reason":"<skill-trigger name=\"next\">Turn ended with a tool-call-only message (no final text). Emit a final status report for the user; if a task batch completed or control returns to the user (e.g. waiting on CI/wakeup), invoke the `next` skill to offer follow-up options.</skill-trigger>"}' + exit 0 +fi + +# Blind-spot guard 2 — waiting-turn ending: the final message registered a +# ScheduleWakeup (polling/wait handoff). Control returns to the user for a long +# window, so follow-up options are due even without a completion keyword. +if [[ ",${LAST_TOOLS}," == *",ScheduleWakeup,"* ]]; then + echo '{"decision":"block","reason":"<skill-trigger name=\"next\">Waiting-turn detected (ScheduleWakeup registered in the final message). Ensure a final status report was given, then invoke the `next` skill to offer interim follow-up options while waiting.</skill-trigger>"}' + exit 0 +fi + +# Completion keyword detection (case-insensitive). +# Patterns are loaded from data/*.regex files — each non-empty, non-comment line +# is concatenated into a single egrep -E alternation. The data/ directory is +# git-ignored (skills/next/.gitignore) and publish-ignored (.clawhubignore), +# so each user adds their own locale patterns (en.regex, ko.regex, ja.regex…). +# If no data files exist, fall back to a built-in English default. +DATA_DIR="$(dirname "$0")/../data" +if compgen -G "$DATA_DIR/*.regex" > /dev/null 2>&1; then + PATTERN=$(cat "$DATA_DIR"/*.regex | sed 's/#.*$//' | awk 'NF' | paste -sd'|' -) +else + PATTERN='Fix complete:|✅|all done|^[[:space:]]*done\.|task (complete|completed|finished)|completed[\.\!\)\*,[:space:]]|finished[\.\!\)\*,[:space:]]|wrapped up' +fi + +MATCHED="false" +if echo "$LAST_TEXT" | grep -qiE "$PATTERN"; then + MATCHED="true" +fi + +# Diagnostics (HARD STOP rationale) — this hook's "next skill call missed" failure mode +# has recurred 9+ times (see failed-attempts.md) with every prior fix guessing at regex +# content, because there was no evidence trail proving whether the hook even ran or +# what LAST_TEXT it actually extracted. A 2026-07-22 session confirmed the ko.regex +# pattern DID match the live completion text, yet no decision:block ever surfaced — +# meaning the failure is upstream (dispatch/transcript-read timing), not content. This +# log lets the next occurrence be diagnosed from hard evidence instead of another guess. +{ + printf '%s\ttranscript=%s\tlast_text_len=%s\tmatched=%s\tsnippet=%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TRANSCRIPT" "${#LAST_TEXT}" "$MATCHED" \ + "$(printf '%s' "$LAST_TEXT" | tr '\n' ' ' | cut -c1-80)" >> "$DEBUG_LOG" + # Rotate: Stop fires every turn, so cap growth — keep the last 200 entries. + if [[ "$(wc -l < "$DEBUG_LOG" 2>/dev/null || echo 0)" -gt 500 ]]; then + tail -n 200 "$DEBUG_LOG" > "$DEBUG_LOG.tmp" && mv "$DEBUG_LOG.tmp" "$DEBUG_LOG" + fi +} 2>/dev/null || true + +if [[ "$MATCHED" == "true" ]]; then + # Output JSON decision:"block" — Stop hook spec: stdout goes to debug log only; + # JSON decision:"block" prevents stop and feeds reason to Claude as a follow-up signal. + # See ~/.claude/skills/hook-kit/SKILL.md "Output channel spec per event". + echo '{"decision":"block","reason":"<skill-trigger name=\"next\">Task completion signal detected. Invoke the `next` skill to suggest follow-up actions.</skill-trigger>"}' +fi + +exit 0 diff --git a/skills/next/stall-detect.md b/skills/next/stall-detect.md index 451d256f..9327974e 100644 --- a/skills/next/stall-detect.md +++ b/skills/next/stall-detect.md @@ -46,7 +46,7 @@ The fix skill will: ### Example 1: Deploy without sync ``` -Completed: clawhub publish claude-session v0.1.2 +Completed: clawhub publish session v0.1.2 Stall: sync.sh not run, es6kr/skills repo not updated → Skill("fix", "stall detected: ClawHub publish completed but es6kr/skills sync not executed") ``` diff --git a/skills/next/suggestion-patterns.md b/skills/next/suggestion-patterns.md index 1d06e63d..28a02b17 100644 --- a/skills/next/suggestion-patterns.md +++ b/skills/next/suggestion-patterns.md @@ -66,6 +66,21 @@ - `github-flow/pr.md` lines 13-14, 276-279 (Draft-default HARD STOP at execution time) — this skill mirrors that rule into option-description time. - Triggering keyword in option description (any locale): `create PR`, `PR creation`, `register PR`, `worktree+PR`, `cherry-pick + PR`, `gh pr create`, and equivalent localized forms. Any of these without a "draft" qualifier = violation. +## Cross-cutting rule — Antigravity unapproved PR clawo delegation in next-action options (HARD STOP) + +**In the Antigravity (Gemini) environment, whenever composing next-action options, proposing unapproved/unmerged PR review, consolidation, or remediation as a direct task for the main Antigravity session is strictly forbidden (`HARD STOP`).** + +If an unapproved PR is surfaced as a candidate from open PR discovery (`gh pr list`), it MUST be explicitly framed as delegating to `clawo` (e.g. `[clawo] PR #<N> delegate consolidate review (/clawo consolidate PR #<N>)`), never as direct in-session review. **When dispatching the clawo session, the prompt passed to clawo MUST explicitly execute `/consolidate pr <PR_URL>` (or `/consolidate pr #<N>`) as its entry point.** + +### Don't / Do + +| # | Don't | Do | +|---|-------|-----| +| 1 | Propose "[repo] PR #<N> review & process" as a direct option in Antigravity | Frame explicitly as "[clawo] PR #<N> delegate consolidate (/clawo consolidate PR #<N>)" | +| 2 | Start direct code review or consolidate inside the main Antigravity turn | Dispatch the PR workflow to an isolated `clawo` session (`Skill("clawo", "launch")`) | +| 3 | Surface open PRs without checking if the current environment is Antigravity | When in Antigravity, all unapproved PR candidates must carry the `[clawo]` prefix and delegation command | +| 4 | Send unstructured instructions ("verify and merge...") in the clawo prompt | Explicitly pass `/consolidate pr <PR_URL>` so the worker runs the standard consolidate workflow | + ## Deferred-status overrides severity + pending-task precedence (HARD STOP) The severity table above governs findings **inside the current PR's diff**. A finding that is **deferred by status** — outside the PR's diff, explicitly postponed, or parked in a fix_plan hold section — is bundle-and-late **regardless of severity** (even 🟠 Important / 🔴 Critical). It must NOT appear as an individual next-action option while real pending backlog work exists. @@ -448,7 +463,7 @@ options: [ A session-cleanup / retrospective / wrap-up option — including as a diversity slot inside a regular next-action ask — may be offered only when at least one of these holds: 1. The user explicitly signaled wrap-up intent (wrap-up keyword, or 2+ consecutive declines of other follow-ups), or -2. The injected context-usage signal (a `Context usage: ... (NN%)` line in hook additionalContext, when the environment provides one) reports **≥ 45%** — **read from the LATEST injection in the transcript at ask-composition time**. The signal only refreshes on user-prompt events, and a compact/summarization boundary shrinks context, so any reading taken before the most recent injection (or before an intervening compact) is stale and **overstates** usage. A stale reading NEVER satisfies the gate: if the freshest injection is below the threshold — or no post-compact reading exists yet — treat condition 2 as NOT met. +2. The injected context-usage signal (a `Context usage: ... (NN%)` line in hook additionalContext, when the environment provides one) reports at/above the **session model's live threshold** (Fable/Mythos 55%, Opus 50%, others 45% — 45% is also the generic fallback when the model is unknown; the injection script publishes the live per-model value and emits an explicit `CLEANUP-GATE` directive line when the reading is over it) — **read from the LATEST injection in the transcript at ask-composition time**. **Active trigger**: when this condition holds on a FRESH reading, the cleanup/retrospective option is not merely permitted — it is REQUIRED as the Recommended #1 option of the turn-final ask (or a standalone cleanup ask when no other ask is happening this turn), citing the live percentage. The signal only refreshes on user-prompt events, and a compact/summarization boundary shrinks context, so any reading taken before the most recent injection (or before an intervening compact) is stale and **overstates** usage. A stale reading NEVER satisfies the gate: if the freshest injection is below the threshold — or no post-compact reading exists yet — treat condition 2 as NOT met. **Post-compact floor (HARD STOP)**: immediately after a compact/summarization boundary — an explicit compact command, an `isCompactSummary` entry, or a session that opened with a "continued from a previous conversation that ran out of context" summary — assume usage is **under 20% until re-measured**, and never quote a percentage that appears in the pre-compact conversation or its summary. The measurement mechanism reads the last assistant-message usage field, which still describes the pre-compact session until a new assistant turn has been generated; a figure read at that moment can overstate reality by tens of percentage points. This is the operator-facing counterpart of the injection script's own first-post-compact suppression — the script suppresses its own output, but nothing stops a composer from quoting a number it read elsewhere. diff --git a/skills/plane-backlog/SKILL.md b/skills/plane-backlog/SKILL.md index a59d7963..4f1e2a5b 100644 --- a/skills/plane-backlog/SKILL.md +++ b/skills/plane-backlog/SKILL.md @@ -50,8 +50,13 @@ python3 "$FIX_PLAN_SCRIPTS/artifact_post_ingest.py" <path/to/artifact.md> ### 3. Plane Checklist Sync ```bash python3 "$FIX_PLAN_SCRIPTS/plane_sync.py" --dry-run +# --push-done: also transition Plane issues to Done when their linked local +# item is already [x] but Plane hasn't caught up (never DELETEs; respects --dry-run) +python3 "$FIX_PLAN_SCRIPTS/plane_sync.py" --dry-run --push-done ``` +Every sync run also prints a **priority-drift report** (report-only — compares each tracked issue's local `P0`-`P3` tag against Plane's live `priority` field, never writes either side). See "Plane Issue DELETE Prohibition & Priority Mapping (HARD STOP)" below. + ### 4. Workspace Profile Verification ```bash python3 "$FIX_PLAN_SCRIPTS/workspace_profile.py" --json @@ -60,6 +65,9 @@ python3 "$FIX_PLAN_SCRIPTS/workspace_profile.py" --json ### 5. Plane Issue & Intake Creation ```bash python3 "$FIX_PLAN_SCRIPTS/plane_create_issue.py" --title "<title>" --description "<description>" --json +# --priority accepts a fix_plan P0-P3 marker (case-insensitive) or a direct +# Plane value (urgent/high/medium/low/none) -- both forms normalize the same way. +python3 "$FIX_PLAN_SCRIPTS/plane_create_issue.py" --title "<title>" --priority P1 --json ``` ### 6. Plane Issue Comment Creation @@ -94,6 +102,13 @@ Plane's Issue model has (at least) three description-related fields: **Fix**: convert markdown to real HTML (`<strong>`, `<ul><li>`, `<code>`, etc. — not `<pre>`-escaped raw text) before writing `description_html`. If the editor-blank problem also needs fixing, `description` (the JSON doc) must be populated separately — the REST API does not do this for you. +## Plane Issue DELETE Prohibition & Priority Mapping (HARD STOP) + +- **Never `DELETE` a Plane issue.** Deleted issues break inbound links from wikis/trackers that already cited them, and there is no first-class "undo". When a local `fix_plan.md` item completes (`[x]`), the corresponding Plane issue must transition to the project's `completed`-group ("Done") state instead — see `plane_sync.py`'s `transition_issue_to_done()` / `--push-done` flag. This is a policy guard, not an API limitation (the Plane REST API does expose a DELETE endpoint) — every script in this skill and `fix-plan/scripts/` must keep it that way. +- **P0-P3 ↔ Plane native priority is a fixed 1:1 mapping**: `P0`=`urgent`, `P1`=`high`, `P2`=`medium`, `P3`=`low` (Plane's own `none` has no marker equivalent). `plane_create_issue.py --priority` and `plane_sync.py`'s internal `normalize_priority()`/`priority_to_marker()` both use this table — do not introduce a second mapping elsewhere. +- **Priority drift is report-only by default.** `plane_sync.py` prints a mismatch (local `P*` tag vs Plane's live `priority`) on every run but never auto-writes either side — a first-adoption sync run stays advisory until a human confirms the direction, since an existing local marker may carry local triage judgment Plane doesn't know about (2026-08-20 Fable audit, `plan-plane-done-state-and-priority-mapping.md` §7-3). +- **`plane_create_issue.py` exists as two copies** (`skills/fix-plan/scripts/` and `skills/plane-backlog/scripts/`) — keep both in sync when touching `--priority`/priority-injection logic (see `git.md`-adjacent "Duplicate Script Drift" risk in the plan doc). `plane_sync.py` has no such duplicate; its priority-mapping constants are self-contained rather than cross-imported. + ## LLM Wiki Storage Architecture (HARD STOP) - **Authoritative plans under `pages/`**: plan and architecture documents live under `pages/<domain>/<name>.md` (e.g. `pages/workflow/<name>.md`) as single authoritative documents. Do not dual-store plan content under `raw/`, and do not keep a duplicate copy of an authoritative plan in `outputs/` — archive superseded duplicates instead. diff --git a/skills/plane-backlog/scripts/plane_client.py b/skills/plane-backlog/scripts/plane_client.py index 3d24e70e..9f3d2769 100644 --- a/skills/plane-backlog/scripts/plane_client.py +++ b/skills/plane-backlog/scripts/plane_client.py @@ -36,8 +36,59 @@ RATE_LIMIT_FIRST_WAIT = 65 RATE_LIMIT_RETRY_WAIT = 30 MAX_ATTEMPTS = 4 + +# plane.es6.kr sits behind Cloudflare, which 403s the default `Python-urllib` +# User-Agent. A browser-like User-Agent header is required — same constant as +# plane_create_comment.py, the in-repo precedent that already clears the WAF. +UA = "Mozilla/5.0 (plane-backlog)" PAGE_SIZE = 100 +# fix_plan `[BLOCKED:P0-P3:...]` marker <-> Plane native `priority` field. +# Single source of truth — plane_create_issue.py and plane_sync.py both +# import this instead of re-declaring the mapping (drift class already hit +# once this session with the two plane_create_issue.py copies). +MARKER_TO_PRIORITY = { + "P0": "urgent", + "P1": "high", + "P2": "medium", + "P3": "low", +} +PRIORITY_TO_MARKER = {native: marker for marker, native in MARKER_TO_PRIORITY.items()} +VALID_PLANE_PRIORITIES = frozenset(MARKER_TO_PRIORITY.values()) | {"none"} + + +def normalize_priority(value): + """Normalize a priority value to one of Plane's native priority strings. + + Accepts case-insensitive P-tags (``p0``/``P0`` ... ``p3``/``P3``), Plane's + own native values (``urgent``/``high``/``medium``/``low``/``none``), or a + falsy value (treated as ``"none"``). Raises ``ValueError`` on anything + else — a typo in ``--priority`` should fail loudly, not silently post an + issue with no priority set. + """ + if not value: + return "none" + text = str(value).strip() + upper = text.upper() + if upper in MARKER_TO_PRIORITY: + return MARKER_TO_PRIORITY[upper] + lower = text.lower() + if lower in VALID_PLANE_PRIORITIES: + return lower + raise ValueError( + f"unrecognized priority {value!r} — expected one of P0-P3 " + f"(case-insensitive) or {sorted(VALID_PLANE_PRIORITIES)}" + ) + + +def priority_to_marker(native_priority): + """Reverse of normalize_priority: Plane native value -> P0-P3 marker. + + Returns ``None`` for ``"none"`` or an unrecognized value — callers decide + whether the absence of a marker is itself meaningful. + """ + return PRIORITY_TO_MARKER.get((native_priority or "").strip().lower()) + def _workspace_profile_dirs(): """Directories that may hold ``workspace_profile.py``, most specific first. @@ -135,7 +186,7 @@ def __init__(self, profile=None, cwd=None, throttle=DEFAULT_THROTTLE, cache_path def request(self, path, method="GET", data=None): """Issue one API call, retrying on 429 and throttling every response.""" url = "%s/api/v1/%s" % (self.profile["plane_host"], path.lstrip("/")) - headers = {"x-api-key": self.profile["token"], "Content-Type": "application/json"} + headers = {"x-api-key": self.profile["token"], "Content-Type": "application/json", "User-Agent": UA} body = json.dumps(data).encode("utf-8") if data is not None else None last_error = None diff --git a/skills/plane-backlog/scripts/plane_create_issue.py b/skills/plane-backlog/scripts/plane_create_issue.py index e89c9f0e..99876acd 100755 --- a/skills/plane-backlog/scripts/plane_create_issue.py +++ b/skills/plane-backlog/scripts/plane_create_issue.py @@ -21,9 +21,15 @@ import subprocess import base64 import re +import shutil SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +# plane.es6.kr sits behind Cloudflare, which 403s the default `Python-urllib` +# User-Agent. A browser-like User-Agent header is required — same constant as +# plane_create_comment.py, the in-repo precedent that already clears the WAF. +UA = "Mozilla/5.0 (plane-backlog)" + def _shared_script_dirs(): """Directories holding the shared plane-backlog / fix-plan script modules. @@ -49,7 +55,7 @@ def _shared_script_dirs(): # Single source of truth for profile resolution. Importing it eagerly is # deliberate: a missing resolver must fail loudly rather than silently degrade # into a run that targets whichever workspace the environment happens to name. -from plane_client import resolve_profile # noqa: E402 +from plane_client import resolve_profile, normalize_priority # noqa: E402 def parse_inline_tiptap(text: str) -> list: @@ -202,7 +208,7 @@ def markdown_to_tiptap_and_html(md_text: str): return tiptap_doc, html_out, md_text -def create_via_rest_api(profile: dict, title: str, description: str = "", project_id: str = None, is_intake: bool = True) -> dict: +def create_via_rest_api(profile: dict, title: str, description: str = "", project_id: str = None, is_intake: bool = True, priority: str = None) -> dict: plane_host = (profile.get("plane_host") or "").rstrip("/") token = profile.get("token") workspace_slug = profile.get("workspace_slug") @@ -231,9 +237,10 @@ def create_via_rest_api(profile: dict, title: str, description: str = "", projec url = f"{plane_host}/api/v1/workspaces/{workspace_slug}/projects/{prj_id}/issues/" headers = { "x-api-key": token, - "Content-Type": "application/json" + "Content-Type": "application/json", + "User-Agent": UA } - + tiptap_doc, html_desc, plain_desc = markdown_to_tiptap_and_html(description) payload = { "name": title, @@ -241,6 +248,8 @@ def create_via_rest_api(profile: dict, title: str, description: str = "", projec "description_html": html_desc, "description_stripped": plain_desc } + if priority: + payload["priority"] = normalize_priority(priority) try: req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST") @@ -276,30 +285,15 @@ def create_via_rest_api(profile: dict, title: str, description: str = "", projec return {"success": False, "reason": str(e)} -def create_via_k3s_fallback(profile: dict, title: str, description: str = "", project_id: str = None, is_intake: bool = True) -> dict: - workspace_slug = profile.get("workspace_slug") - prj_id = project_id or profile.get("default_project") - plane_host = (profile.get("plane_host") or "").rstrip("/") - - missing = [ - name - for name, value in ( - ("workspace_slug", workspace_slug), - ("project_id", prj_id), - ("plane_host", plane_host), - ) - if not value - ] - if missing: - return { - "success": False, - "reason": ( - f"Unresolved workspace profile fields: {', '.join(missing)}. " - "Refusing to fall back to an arbitrary workspace or project." - ), - } +def build_k3s_py_script(workspace_slug: str, prj_id: str, plane_host: str, title: str, description: str, is_intake: bool, normalized_priority: str = None) -> str: + """Build the Django-shell script executed inside the Plane API pod. - py_script = f"""import json, re + Caller-supplied values (title, description, slugs) are injected via + json.dumps — never raw f-string interpolation — and every literal brace in + the generated source is doubled, so quotes/braces in user input cannot + raise ValueError at build time or break the generated code. + """ + return f"""import json, re from plane.db.models import Issue, IntakeIssue, Workspace, Project, User ws = Workspace.objects.filter(slug={json.dumps(workspace_slug)}).first() @@ -449,7 +443,7 @@ def markdown_to_tiptap_and_html(md_text: str): elif tok_type == 'heading': inline_nodes = parse_inline_tiptap(text) tiptap_content.append({{"type": "heading", "attrs": {{"level": indent}}, "content": inline_nodes}}) - html_parts.append(f"<h{{indent}}>{{inline_to_html(text)}}</h{{level}}>") + html_parts.append(f"<h{{indent}}>{{inline_to_html(text)}}</h{{indent}}>") i += 1 elif tok_type == 'paragraph': inline_nodes = parse_inline_tiptap(text) @@ -480,7 +474,7 @@ def markdown_to_tiptap_and_html(md_text: str): description_stripped=plain_desc, project=prj, workspace=ws, - created_by=u + created_by=u{("," + chr(10) + " priority=" + json.dumps(normalized_priority)) if normalized_priority else ""} ) if {str(is_intake)}: @@ -501,9 +495,47 @@ def markdown_to_tiptap_and_html(md_text: str): print("RESULT_JSON:" + json.dumps(res)) """ + +def create_via_k3s_fallback(profile: dict, title: str, description: str = "", project_id: str = None, is_intake: bool = True, priority: str = None) -> dict: + normalized_priority = normalize_priority(priority) if priority else None + workspace_slug = profile.get("workspace_slug") + prj_id = project_id or profile.get("default_project") + plane_host = (profile.get("plane_host") or "").rstrip("/") + + missing = [ + name + for name, value in ( + ("workspace_slug", workspace_slug), + ("project_id", prj_id), + ("plane_host", plane_host), + ) + if not value + ] + if missing: + return { + "success": False, + "reason": ( + f"Unresolved workspace profile fields: {', '.join(missing)}. " + "Refusing to fall back to an arbitrary workspace or project." + ), + } + + if shutil.which("kubectl") is None: + return { + "success": False, + "reason": "kubectl not available on PATH — skipping K3s fallback", + } + + # Live cluster reality: the Plane deployment runs in the `plane-ce` + # namespace (es6.kr), not `plane` — resolve from the workspace profile + # first so other clusters can override without a code change. + k3s_namespace = profile.get("k3s_namespace") or "plane-ce" + k3s_workload = profile.get("k3s_workload") or "deploy/plane-api-wl" + + py_script = build_k3s_py_script(workspace_slug, prj_id, plane_host, title, description, is_intake, normalized_priority) b64_script = base64.b64encode(py_script.encode('utf-8')).decode('utf-8') cmd = [ - "kubectl", "exec", "-n", "plane", "deploy/plane-api-wl", "--", + "kubectl", "exec", "-n", k3s_namespace, k3s_workload, "--", "python3", "manage.py", "shell", "-c", f"import base64; exec(base64.b64decode('{b64_script}').decode('utf-8'))" ] @@ -519,14 +551,14 @@ def markdown_to_tiptap_and_html(md_text: str): return {"success": False, "reason": f"K3s execution failed: {str(e)}"} -def create_plane_issue(title: str, description: str = "", project_id: str = None, is_intake: bool = True, cwd: str = None) -> dict: +def create_plane_issue(title: str, description: str = "", project_id: str = None, is_intake: bool = True, cwd: str = None, priority: str = None) -> dict: profile = resolve_profile(cwd or os.getcwd()) - res = create_via_rest_api(profile, title, description, project_id, is_intake) + res = create_via_rest_api(profile, title, description, project_id, is_intake, priority) if res.get("success"): return res - + # Fallback to K3s django shell - res_k3s = create_via_k3s_fallback(profile, title, description, project_id, is_intake) + res_k3s = create_via_k3s_fallback(profile, title, description, project_id, is_intake, priority) if res_k3s.get("success"): return res_k3s @@ -540,9 +572,13 @@ def main(): parser.add_argument("--project", default=None, help="Project ID or slug") parser.add_argument("--no-intake", action="store_true", help="Do not mark as intake issue") parser.add_argument("--json", action="store_true", help="Output raw JSON") + parser.add_argument( + "-p", "--priority", default=None, + help="P0-P3 (case-insensitive) or a native Plane priority (urgent/high/medium/low/none)" + ) args = parser.parse_args() - res = create_plane_issue(args.title, args.description, args.project, is_intake=not args.no_intake) + res = create_plane_issue(args.title, args.description, args.project, is_intake=not args.no_intake, priority=args.priority) if args.json: print(json.dumps(res, indent=2, ensure_ascii=False)) diff --git a/skills/claude-session/.gitignore b/skills/session/.gitignore similarity index 100% rename from skills/claude-session/.gitignore rename to skills/session/.gitignore diff --git a/skills/claude-session/CHANGELOG.md b/skills/session/CHANGELOG.md similarity index 87% rename from skills/claude-session/CHANGELOG.md rename to skills/session/CHANGELOG.md index d579ee00..8636cc3e 100644 --- a/skills/claude-session/CHANGELOG.md +++ b/skills/session/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.8.1 (2026-08-20) + + +### Bug Fixes + +* **hook-kit,session:** resolve copilot review findings on ambient auth and claude-code rewind ([055c8a2](https://github.com/es6kr/skills/commit/055c8a26d9c5a1ec440b87c729e5148fdd2c9c65)) +* **hooks:** correct ghost path for block-wip-register-before-execute.sh ([d5875a2](https://github.com/es6kr/skills/commit/d5875a2e624eeb4a3dce9beba5d96a4365a8f950)) +* promote next-fix batch (hook path repair, topic-dispatch scoping, conflict diagnosis) ([eb7ecb6](https://github.com/es6kr/skills/commit/eb7ecb61dda9701d78f12dc810781dc7cb687caa)) +* **wip:** cross-ref PR-URL and TaskCreate subject repo-qualifier rules ([#186](https://github.com/es6kr/skills/issues/186)) ([4982364](https://github.com/es6kr/skills/commit/49823641a7b08123ebd0325273892bee41bc3280)) +* **wip:** cross-ref PR-URL and TaskCreate subject repo-qualifier rules ([#186](https://github.com/es6kr/skills/issues/186)) ([951c1e6](https://github.com/es6kr/skills/commit/951c1e6871e78e226757c6a7ae5ae53efeb7bfb0)) + + +### Refactor + +* **session:** rename claude-session to session skill ([318d6a1](https://github.com/es6kr/skills/commit/318d6a1fcc04621459e7f3e6cab2394bc0b68590)) + ## [0.8.1](https://github.com/es6kr/skills/compare/claude-session-v0.8.0...claude-session-v0.8.1) (2026-08-17) diff --git a/skills/claude-session/SKILL.md b/skills/session/SKILL.md similarity index 85% rename from skills/claude-session/SKILL.md rename to skills/session/SKILL.md index 0f1cbd0c..db891218 100644 --- a/skills/claude-session/SKILL.md +++ b/skills/session/SKILL.md @@ -1,10 +1,10 @@ --- -name: claude-session +name: session description: | - Claude Code session management. Topics — id (current session UUID), list (enumerate sessions), search (keyword + result validation), import, summarize, analyze (stats), archive (move to ~/.claude/projects/.bak/ with flat naming), classify, clean-profanity (sanitize text in session JSONL), split (topic boundaries), compress (MCP-direct), destroy, dual-sync (Windows/WSL memory path mapping), install (hook), memory-trim (MEMORY.md index byte-budget trim), migrate (project to worktree), move (with cwd update), purge (dead sessions), rename (custom title), repair (chain/tool_result/UUID), url (web URL). Use when: "session id", "current session", "session list", "list sessions", "session search", "find session", "session classify", "session compress", "session migrate", "session move", "session repair", "chain repair", "session rename", "session split", "session purge", "dead session", "session url", "session analyze", "session import", "session summarize", "session archive", "archive session", "session clean", "clean profanity", "sanitize session", "redact session", "worktree session", "session cleanup", "memory trim", "MEMORY.md over budget", "dual sync", "WSL memory", "Windows memory path" + Claude Code & Antigravity session management. Topics — id (lookup UUID), list (enumerate), search (keyword validate), import, summarize, analyze (stats), archive (flat bak), classify, clean-profanity (sanitize JSONL), split (boundaries), compress (MCP), destroy, dual-sync (Win/WSL memory), install (hook), memory-trim (budget trim), migrate (to worktree), move (update cwd), purge (dead sessions), rename (custom title), repair (chain/tool_result/UUID), rewind (context truncate), url (web URL). Use when: "session id", "current session", "session list", "list sessions", "session search", "find session", "session classify", "session compress", "session migrate", "session move", "session repair", "session rename", "session split", "session purge", "session rewind", "session analyze", "session import", "session summarize", "session archive", "archive session", "clean profanity", "session cleanup", "memory trim", "dual sync" metadata: author: es6kr - version: "0.1.5" + version: "0.8.0" depends-on: - cleanup - git-repo @@ -16,7 +16,7 @@ Integrated skill for managing Claude Code sessions. ## Topic Dispatch -**When this skill is invoked with a topic specifier (e.g., `/claude-session id` or `Skill("claude-session", "id")`), load and follow only the matching topic file (`id.md`). Do not echo the Topics table or summarize other topics in the response.** The Topics table below is an index for invocations without a topic specifier — it is not user-facing output when a topic is named. +**When this skill is invoked with a topic specifier (e.g., `/session id` or `Skill("session", "id")`), load and follow only the matching topic file (`id.md`). Do not echo the Topics table or summarize other topics in the response.** The Topics table below is an index for invocations without a topic specifier — it is not user-facing output when a topic is named. ### HARD STOP — unknown topic word + extra args @@ -58,6 +58,7 @@ Decision procedure: | purge | Delete dead sessions (hook-only, no assistant response) permanently | [purge.md](./purge.md) | | rename | Assign and look up custom title for session | [rename.md](./rename.md) | | repair | Restore session structure (chain, tool_result, UUID) | [repair.md](./repair.md) | +| rewind | Soft-rewind conversation context without reverting local workspace code | [rewind.md](./rewind.md) | | search | Keyword session search with result validation (verb/path/class checks) | [search.md](./search.md) | | summarize | View and summarize conversation content from other sessions | [summarize.md](./summarize.md) | | url | Generate claude-sessions web URL from session ID | [url.md](./url.md) | @@ -97,8 +98,8 @@ Decision procedure: ```bash /session archive <session_id> # move to ~/.claude/projects/.bak/<project-key>_<uuid>.jsonl -bash ~/.claude/skills/claude-session/scripts/archive-session.sh <session_id> # direct script call -bash ~/.claude/skills/claude-session/scripts/archive-session.sh <session_id> --dry-run # preview only +bash scripts/archive-session.sh <session_id> # direct script call +bash scripts/archive-session.sh <session_id> --dry-run # preview only ``` Moves to `~/.claude/projects/.bak/<project-key>_<uuid>.jsonl` (flat naming, single backup root shared with transient backups). UUID portion preserved unchanged. Updates `INDEX.md` ledger. @@ -231,10 +232,10 @@ Script: `scripts/purge-dead-sessions.sh <project_name> [--delete]` ```bash # Single session -python3 ~/.claude/skills/claude-session/scripts/clean-profanity.py <session_file.jsonl> +python3 scripts/clean-profanity.py <session_file.jsonl> # Multiple sessions -python3 ~/.claude/skills/claude-session/scripts/clean-profanity.py file1.jsonl file2.jsonl +python3 scripts/clean-profanity.py file1.jsonl file2.jsonl # Resolve UUID → path first (glob catches both bare <uuid>.jsonl and # archived flat names like <project-key>_<uuid>.jsonl) @@ -257,8 +258,8 @@ Replaces matched tokens with `****` in place. Patterns loaded from `data/profani **Primary script** (full pipeline: backup → dedup → 400 error → orphan tool_result → chain → validate): ```bash -python3 ~/.claude/skills/claude-session/scripts/repair-session.py <session_file> -python3 ~/.claude/skills/claude-session/scripts/repair-session.py <session_file> --dry-run +python3 scripts/repair-session.py <session_file> +python3 scripts/repair-session.py <session_file> --dry-run ``` Repair targets: diff --git a/skills/claude-session/analyze.md b/skills/session/analyze.md similarity index 100% rename from skills/claude-session/analyze.md rename to skills/session/analyze.md diff --git a/skills/claude-session/archive.md b/skills/session/archive.md similarity index 99% rename from skills/claude-session/archive.md rename to skills/session/archive.md index 3c80c9e7..d94ce4c5 100644 --- a/skills/claude-session/archive.md +++ b/skills/session/archive.md @@ -124,7 +124,7 @@ Always show the preview before invoking the destructive move. Use the script: ```bash -bash ~/.claude/skills/claude-session/scripts/archive-session.sh <session_id> +bash ~/.claude/skills/session/scripts/archive-session.sh <session_id> ``` Or inline (single session): diff --git a/skills/claude-session/classify.md b/skills/session/classify.md similarity index 98% rename from skills/claude-session/classify.md rename to skills/session/classify.md index b5903be7..160bf96a 100644 --- a/skills/claude-session/classify.md +++ b/skills/session/classify.md @@ -38,7 +38,7 @@ Analyzes all Claude sessions in a project and classifies them as delete/keep/ext **Always use the script first** — do not inline grep/sed/jq for JSONL parsing: ```bash -python3 ~/.claude/skills/claude-session/scripts/classify-sessions.py <project-name> +python3 ~/.claude/skills/session/scripts/classify-sessions.py <project-name> ``` Output: TSV with columns `ID | Lines | UserMsgs | FirstDate | LastDate | Title | LastMessages` @@ -145,7 +145,7 @@ mcp__claude-sessions-mcp__summarize_session({ re-scanned by Claude Code): ```bash -bash ~/.claude/skills/claude-session/scripts/archive-session.sh <session_id> +bash ~/.claude/skills/session/scripts/archive-session.sh <session_id> ``` - The session disappears from the session list (same UX as delete) but stays recoverable. diff --git a/skills/claude-session/clean-profanity.md b/skills/session/clean-profanity.md similarity index 83% rename from skills/claude-session/clean-profanity.md rename to skills/session/clean-profanity.md index ff5a75cc..46e13576 100644 --- a/skills/claude-session/clean-profanity.md +++ b/skills/session/clean-profanity.md @@ -12,10 +12,10 @@ Scrub profanity tokens from a session JSONL file in place. Designed for sanitizi ```bash # Single session -python3 ~/.claude/skills/claude-session/scripts/clean-profanity.py <session_file.jsonl> +python3 scripts/clean-profanity.py <session_file.jsonl> # Multiple sessions in one call -python3 ~/.claude/skills/claude-session/scripts/clean-profanity.py file1.jsonl file2.jsonl +python3 scripts/clean-profanity.py file1.jsonl file2.jsonl ``` Replaces matched tokens with `****` and rewrites the file in place. Reports `N lines modified` per file. @@ -33,7 +33,7 @@ Apply the script to the discovered path. ## Pattern source -Patterns are loaded from `~/.claude/skills/claude-session/data/profanity-patterns.json` (an array of `{pattern, replacement}` regex entries). If the file is absent, the script falls back to a minimal built-in pattern set. +Patterns are loaded from `data/profanity-patterns.json` (an array of `{pattern, replacement}` regex entries). If the file is absent, the script falls back to a minimal built-in pattern set. To extend coverage, add entries to `data/profanity-patterns.json`. Use `\b` word boundaries to avoid matching substrings inside legitimate identifiers (e.g., `\bass\b` won't match `assistant`). diff --git a/skills/claude-session/compress.md b/skills/session/compress.md similarity index 100% rename from skills/claude-session/compress.md rename to skills/session/compress.md diff --git a/skills/claude-session/destroy.md b/skills/session/destroy.md similarity index 100% rename from skills/claude-session/destroy.md rename to skills/session/destroy.md diff --git a/skills/claude-session/dual-sync.md b/skills/session/dual-sync.md similarity index 100% rename from skills/claude-session/dual-sync.md rename to skills/session/dual-sync.md diff --git a/skills/claude-session/id.md b/skills/session/id.md similarity index 100% rename from skills/claude-session/id.md rename to skills/session/id.md diff --git a/skills/claude-session/import.md b/skills/session/import.md similarity index 80% rename from skills/claude-session/import.md rename to skills/session/import.md index 74257bf1..986a9e02 100644 --- a/skills/claude-session/import.md +++ b/skills/session/import.md @@ -8,8 +8,16 @@ Delivers session data to other agents/skills via pipeline. /session import --hookify # Fetch session and deliver to hookify /session import --analyze # Session analysis pipeline /session import --to <agent> # Deliver to a specific agent +/session import --to-agy # Migrate IDE session to agy CLI ``` +## Cross-Platform Migration (IDE ↔ agy CLI) + +To migrate an active IDE session (`<conversation-id>`) to `antigravity-cli`: +1. Copy SQLite session DB: `~/.gemini/antigravity-ide/conversations/<uuid>.db` → `~/.gemini/antigravity-cli/conversations/<uuid>.db` +2. Copy Brain artifacts: `~/.gemini/antigravity-ide/brain/<uuid>/` → `~/.gemini/antigravity-cli/brain/<uuid>/` +3. Upsert session summary into SQLite index: `~/.gemini/antigravity-cli/conversation_summaries.db` (`conversation_summaries` table with `app_data_dir='antigravity-cli'`). + ## Prerequisites ### 0. Verify claude-sessions-mcp tool registration diff --git a/skills/claude-session/install.md b/skills/session/install.md similarity index 82% rename from skills/claude-session/install.md rename to skills/session/install.md index 448dd330..362d286f 100644 --- a/skills/claude-session/install.md +++ b/skills/session/install.md @@ -4,14 +4,14 @@ Register the `session-id-inject.sh` hook in `settings.json` so that every sessio ## When to Use -- After installing `claude-session` skill via `clawhub install` +- After installing `session` skill via `clawhub install` - When setting up a new machine/environment - When session ID is not appearing in context ## Prerequisites - `session-id-inject.sh` must exist at one of: - - `~/.claude/skills/claude-session/scripts/session-id-inject.sh` (ClawHub install — preferred) + - `scripts/session-id-inject.sh` (ClawHub install — preferred) - `~/.claude/hooks/session-id-inject.sh` (legacy location) - `jq` must be available in PATH @@ -20,9 +20,9 @@ Register the `session-id-inject.sh` hook in `settings.json` so that every sessio ### 1. Verify Script Exists ```bash -ls ~/.claude/skills/claude-session/scripts/session-id-inject.sh 2>/dev/null \ +ls scripts/session-id-inject.sh 2>/dev/null \ || ls ~/.claude/hooks/session-id-inject.sh 2>/dev/null \ - || echo "MISSING — run: clawhub install claude-session" + || echo "MISSING — run: clawhub install session" ``` ### 2. Register in settings.json @@ -38,7 +38,7 @@ Add to `SessionStart` and `UserPromptSubmit` hooks. The script accepts the event "hooks": [ { "type": "command", - "command": "bash ~/.claude/skills/claude-session/scripts/session-id-inject.sh", + "command": "bash scripts/session-id-inject.sh", "timeout": 5 } ] @@ -57,7 +57,7 @@ Add to `SessionStart` and `UserPromptSubmit` hooks. The script accepts the event "hooks": [ { "type": "command", - "command": "bash ~/.claude/skills/claude-session/scripts/session-id-inject.sh UserPromptSubmit", + "command": "bash ~/.agents/skills/session/scripts/session-id-inject.sh UserPromptSubmit", "timeout": 5 } ] diff --git a/skills/claude-session/list.md b/skills/session/list.md similarity index 100% rename from skills/claude-session/list.md rename to skills/session/list.md diff --git a/skills/claude-session/memory-trim.md b/skills/session/memory-trim.md similarity index 100% rename from skills/claude-session/memory-trim.md rename to skills/session/memory-trim.md diff --git a/skills/claude-session/migrate.md b/skills/session/migrate.md similarity index 100% rename from skills/claude-session/migrate.md rename to skills/session/migrate.md diff --git a/skills/claude-session/move.md b/skills/session/move.md similarity index 96% rename from skills/claude-session/move.md rename to skills/session/move.md index b05767fe..9c7bc30d 100644 --- a/skills/claude-session/move.md +++ b/skills/session/move.md @@ -41,7 +41,7 @@ AskUserQuestion { ### 3. Execute Script ```bash -python ~/.claude/skills/claude-session/scripts/move-session.py \ +python scripts/move-session.py \ <session_id> [session_id2 ...] <target_project_path> \ --cwd-mode <first|all> ``` diff --git a/skills/claude-session/purge.md b/skills/session/purge.md similarity index 100% rename from skills/claude-session/purge.md rename to skills/session/purge.md diff --git a/skills/claude-session/rename.md b/skills/session/rename.md similarity index 82% rename from skills/claude-session/rename.md rename to skills/session/rename.md index 377debdc..731f724d 100644 --- a/skills/claude-session/rename.md +++ b/skills/session/rename.md @@ -36,6 +36,19 @@ Analyze the conversation content and generate 2–4 name candidates. - **Language**: English preferred; technical terms in English - **Avoid**: dates, unnecessary words like "session"/"task", **compound multi-clause names, and `+`-chained enumerations of several tasks** +#### Wrap-up / findability variant (cleanup format) + +The bare single-slug rule above is the **default** — optimized for the `agent-name` addressing use case. It is not the only valid format: `cleanup/run.md`'s Session Identity Rule defines an **intentional extension**, `<model>-<topic>-<sessid8>`, for end-of-session findability + greppability (model family token + dominant-topic slug + the session UUID's leading 8 hex). Switch to that extended format instead of the bare slug when either applies: + +- the user explicitly asks to match cleanup's rename format ("match cleanup's format", "findable name", etc.), or +- this suggestion is being composed as part of (or immediately adjacent to) a session wrap-up / `/cleanup` report + +Otherwise, default to the bare single-slug rule above. + +| # | Don't | Do | +|---|-------|-----| +| 4 | Default to the bare single-slug format when the user explicitly asked for the cleanup-style / findable name | Switch to `<model>-<topic>-<sessid8>` — see `cleanup/run.md` "Session Identity Rule" for the exact token derivation | + ### 2. Apply the Name **Current session** → Output as copyable list only (NO AskUserQuestion): diff --git a/skills/claude-session/repair.md b/skills/session/repair.md similarity index 99% rename from skills/claude-session/repair.md rename to skills/session/repair.md index 3ba27cfc..5e9785d7 100644 --- a/skills/claude-session/repair.md +++ b/skills/session/repair.md @@ -8,10 +8,10 @@ Detects and repairs structural issues in session JSONL files. ```bash # Repair a specific session -python3 ~/.claude/skills/claude-session/scripts/repair-session.py <session_file> +python3 scripts/repair-session.py <session_file> # Preview without changes -python3 ~/.claude/skills/claude-session/scripts/repair-session.py <session_file> --dry-run +python3 scripts/repair-session.py <session_file> --dry-run ``` The script uses `os.replace` for atomic file swap, bypassing macOS zsh `mv -i` alias prompts that would hang background bash calls. Use the script even when running checks manually — it is the source of truth for the repair pipeline. diff --git a/skills/claude-session/resources/session-id-inject.sh b/skills/session/resources/session-id-inject.sh similarity index 100% rename from skills/claude-session/resources/session-id-inject.sh rename to skills/session/resources/session-id-inject.sh diff --git a/skills/session/rewind.md b/skills/session/rewind.md new file mode 100644 index 00000000..a48965af --- /dev/null +++ b/skills/session/rewind.md @@ -0,0 +1,92 @@ +# Session Rewind (Direct Truncation & Soft Rewind) + +Provides direct truncation and soft-rewind of conversation context (JSONL for Claude Code, SQLite DB for Antigravity) without reverting working directory source code modifications. + +## Rewind Modes + +| Rewind Type | Conversation Context | Local Code Files | Method / Command | +|-------------|----------------------|------------------|------------------| +| **Direct Truncation Engine** (Recommended) | Truncated to step N / line M | **Preserved 100% (Untouched)** | `python3 scripts/rewind-session.py` | +| **Git Stash Bridge** (Native UI/CLI) | Native rollback to step N | **Preserved 100% (Stash/Pop)** | `git stash` → Native Rollback → `git stash pop` | +| **Hard Rewind** (Default UI/CLI) | Rollback to step N | Reverted to step N state | Native Checkpoint Rollback / UI Rewind | + +## Method 1: Direct Truncation Engine (`scripts/rewind-session.py`) + +### 1. Engine Selection (`/session rewind`) + +When `/session rewind` is invoked without flags, present an interactive choice for the target engine via `AskUserQuestion`: + +- **1. Antigravity IDE** (`~/.gemini/antigravity-ide/conversations/*.db`) +- **2. Antigravity CLI** (`~/.gemini/antigravity-cli/conversations/*.db`) +- **3. Claude Code** (`~/.claude/projects/*/*.jsonl`) + +### 2. UUID Selection (`/session rewind --<engine>`) + +When invoked with an engine flag (or after engine selection), list recent session UUIDs with `mtime`, `steps/lines` count, and title, then present interactive UUID choices: + +```bash +# List recent sessions for an engine +python3 scripts/rewind-session.py --list-sessions <antigravity-ide|antigravity-cli|claude-code> +``` + +### 3. Checkpoint & Ask Preservation Selection (`/session rewind --<engine> <uuid>`) + +When a UUID is selected, fetch rewindable checkpoints (User Prompts & AskQuestions): + +```bash +# List checkpoints (step index, type, summary) +python3 scripts/rewind-session.py --list-checkpoints <antigravity-ide|antigravity-cli> --uuid <uuid> +``` + +- **User Prompt Step**: Rewinds to right before the user prompt was sent. +- **Ask Question Step (Ask Preservation)**: Rewinds right after the `AskUserQuestion` tool call step so that the **Ask question prompt is preserved in context**, allowing the user to select a different answer without re-generating the model's Ask output. + +### 4. Direct Truncation Execution + +```bash +# Antigravity (IDE/CLI) SQLite DB & transcript.jsonl direct truncation +python3 scripts/rewind-session.py \ + --antigravity-ide \ + --uuid <uuid> \ + --step <cutoff_step_index> [--preserve-ask] + +# Claude Code JSONL direct truncation +python3 scripts/rewind-session.py \ + --claude-code \ + --uuid <uuid> \ + --line <keep_line_count> +``` + +## Method 2: Git Stash Bridge (For Native UI/CLI Rewinds) + +Before triggering a native UI or CLI rewind command that reverts files: + +```bash +# 1. Stash all uncommitted local code changes & untracked files +git stash push -u -m "agy-soft-rewind-keep-code-$(date +%Y%m%d_%H%M%S)" + +# 2. Perform native conversation rewind in agy CLI / IDE to the desired checkpoint step +# (e.g. agy --conversation=<uuid> or UI rewind) + +# 3. Restore all local code changes (resolve merge conflicts if modified during native rewind) +git stash pop +``` + +## Method 3: Manual SQLite Step Truncation (Database-Only Fallback) + +> [!NOTE] +> This manual SQL fallback truncates step rows in `SESSION_DB` and updates step counts. For full session consistency (including `transcript.jsonl` and `transcript_full.jsonl` truncation), use `Method 1: Direct Truncation Engine` (`scripts/rewind-session.py`). + +```bash +SESSION_DB="$HOME/.gemini/antigravity-cli/conversations/<conversation_id>.db" +cp "$SESSION_DB" "${SESSION_DB}.bak" +sqlite3 "$SESSION_DB" "DELETE FROM steps WHERE idx > 150;" +sqlite3 "$HOME/.gemini/antigravity-cli/conversation_summaries.db" \ + "UPDATE conversation_summaries SET step_count=(SELECT COUNT(*) FROM steps WHERE conversation_id='<conversation_id>') WHERE conversation_id='<conversation_id>';" +``` + +## Safety & Backups + +- SQLite DB files are atomically backed up as `<uuid>.db.bak`. +- Transcript logs are backed up as `transcript.jsonl.bak`. +- Local working directory source code files are **100% untouched**. diff --git a/skills/claude-session/scripts/archive-session.sh b/skills/session/scripts/archive-session.sh similarity index 98% rename from skills/claude-session/scripts/archive-session.sh rename to skills/session/scripts/archive-session.sh index 8d34831a..11fb7ecf 100755 --- a/skills/claude-session/scripts/archive-session.sh +++ b/skills/session/scripts/archive-session.sh @@ -3,7 +3,7 @@ # to ~/.claude/projects/.bak/<project-key>_<uuid>.jsonl # # Naming convention matches the existing ~/.claude/projects/.bak/ layout -# (flat: <project-key>_<uuid>.jsonl). See claude-session/archive.md. +# (flat: <project-key>_<uuid>.jsonl). See session/archive.md. # # Usage: archive-session.sh <session-uuid> [--dry-run] # archive-session.sh --dry-run <session-uuid> # --dry-run accepted in any positional slot diff --git a/skills/claude-session/scripts/batch-compress.py b/skills/session/scripts/batch-compress.py similarity index 100% rename from skills/claude-session/scripts/batch-compress.py rename to skills/session/scripts/batch-compress.py diff --git a/skills/claude-session/scripts/classify-sessions.py b/skills/session/scripts/classify-sessions.py similarity index 100% rename from skills/claude-session/scripts/classify-sessions.py rename to skills/session/scripts/classify-sessions.py diff --git a/skills/claude-session/scripts/clean-profanity.py b/skills/session/scripts/clean-profanity.py similarity index 100% rename from skills/claude-session/scripts/clean-profanity.py rename to skills/session/scripts/clean-profanity.py diff --git a/skills/claude-session/scripts/dedup-session.py b/skills/session/scripts/dedup-session.py similarity index 100% rename from skills/claude-session/scripts/dedup-session.py rename to skills/session/scripts/dedup-session.py diff --git a/skills/claude-session/scripts/destroy-session.sh b/skills/session/scripts/destroy-session.sh similarity index 100% rename from skills/claude-session/scripts/destroy-session.sh rename to skills/session/scripts/destroy-session.sh diff --git a/skills/claude-session/scripts/extract-todos.py b/skills/session/scripts/extract-todos.py similarity index 100% rename from skills/claude-session/scripts/extract-todos.py rename to skills/session/scripts/extract-todos.py diff --git a/skills/claude-session/scripts/find-session-id.sh b/skills/session/scripts/find-session-id.sh similarity index 100% rename from skills/claude-session/scripts/find-session-id.sh rename to skills/session/scripts/find-session-id.sh diff --git a/skills/claude-session/scripts/move-session.py b/skills/session/scripts/move-session.py similarity index 100% rename from skills/claude-session/scripts/move-session.py rename to skills/session/scripts/move-session.py diff --git a/skills/claude-session/scripts/purge-dead-sessions.sh b/skills/session/scripts/purge-dead-sessions.sh similarity index 100% rename from skills/claude-session/scripts/purge-dead-sessions.sh rename to skills/session/scripts/purge-dead-sessions.sh diff --git a/skills/claude-session/scripts/rename-session.sh b/skills/session/scripts/rename-session.sh similarity index 100% rename from skills/claude-session/scripts/rename-session.sh rename to skills/session/scripts/rename-session.sh diff --git a/skills/claude-session/scripts/repair-session.py b/skills/session/scripts/repair-session.py similarity index 100% rename from skills/claude-session/scripts/repair-session.py rename to skills/session/scripts/repair-session.py diff --git a/skills/claude-session/scripts/restart-extension-host.sh b/skills/session/scripts/restart-extension-host.sh similarity index 100% rename from skills/claude-session/scripts/restart-extension-host.sh rename to skills/session/scripts/restart-extension-host.sh diff --git a/skills/session/scripts/rewind-session.py b/skills/session/scripts/rewind-session.py new file mode 100644 index 00000000..f01d1542 --- /dev/null +++ b/skills/session/scripts/rewind-session.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +""" +Direct Session Rewind Helper for Antigravity (IDE/CLI) & Claude Code. + +Features: + --list-sessions <engine> : Enumerate sessions with UUID, title, step/line count, mtime + --list-checkpoints <engine> <uuid> : Enumerate user prompts, AskUserQuestion steps, and planner responses + --antigravity-ide [--uuid <id>] [--step <idx>] [--preserve-ask] : Truncate Antigravity IDE SQLite DB & transcript.jsonl + --antigravity-cli [--uuid <id>] [--step <idx>] [--preserve-ask] : Truncate Antigravity CLI SQLite DB & transcript.jsonl + --claude-code [--uuid <id>] [--line <idx>] : Truncate Claude Code JSONL file +""" + +import sys +import os +import json +import sqlite3 +import argparse +import glob +from datetime import datetime + +def list_antigravity_sessions(engine_dir): + conv_dir = os.path.expanduser(os.path.join(engine_dir, "conversations")) + summary_db = os.path.expanduser(os.path.join(engine_dir, "conversation_summaries.db")) + + if not os.path.exists(conv_dir): + return [] + + results = [] + titles = {} + if os.path.exists(summary_db): + try: + conn = sqlite3.connect(summary_db) + cursor = conn.cursor() + cursor.execute("SELECT conversation_id, title FROM conversation_summaries") + for cid, title in cursor.fetchall(): + titles[cid] = title + conn.close() + except Exception: + pass + + for db_path in glob.glob(os.path.join(conv_dir, "*.db")): + cid = os.path.splitext(os.path.basename(db_path))[0] + mtime = datetime.fromtimestamp(os.path.getmtime(db_path)).strftime('%Y-%m-%d %H:%M:%S') + title = titles.get(cid, "(No Title)") + step_count = 0 + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM steps") + step_count = cursor.fetchone()[0] + conn.close() + except Exception: + pass + + results.append({ + "uuid": cid, + "title": title, + "mtime": mtime, + "steps": step_count, + "path": db_path + }) + + results.sort(key=lambda x: x["mtime"], reverse=True) + return results + +def get_transcript_path(engine_dir, cid): + return os.path.expanduser(os.path.join(engine_dir, "brain", cid, ".system_generated", "logs", "transcript.jsonl")) + +def parse_ask_question_text(args_dict): + if not isinstance(args_dict, dict): + return "" + q = args_dict.get("questions", "") + if isinstance(q, str) and q.startswith("["): + try: + parsed = json.loads(q) + if isinstance(parsed, list) and len(parsed) > 0: + return parsed[0].get("question", "") + except Exception: + pass + elif isinstance(q, list) and len(q) > 0: + return q[0].get("question", "") + return args_dict.get("question", "") + +def list_antigravity_checkpoints(engine_dir, cid): + t_path = get_transcript_path(engine_dir, cid) + checkpoints = [] + + if os.path.exists(t_path): + try: + with open(t_path, 'r', encoding='utf-8') as f: + for line in f: + if not line.strip(): + continue + data = json.loads(line) + step_idx = data.get("step_index", 0) + step_type = data.get("type", "") + + if step_type == "USER_INPUT": + content = data.get("content", {}) + text = content.get("text", "") if isinstance(content, dict) else str(content) + summary = text[:80].replace("\n", " ") + checkpoints.append({ + "step_index": step_idx, + "type": "USER_INPUT", + "is_ask": False, + "label": f"[USER PROMPT] Step {step_idx}: {summary}", + "summary": summary + }) + elif step_type == "PLANNER_RESPONSE": + tool_calls = data.get("tool_calls", []) + for tc in tool_calls: + tname = tc.get("name", "") or tc.get("tool_name", "") + if tname in ("ask_question", "AskUserQuestion"): + args = tc.get("args", {}) or tc.get("arguments", {}) + q_text = parse_ask_question_text(args) + summary = q_text[:80].replace("\n", " ") + checkpoints.append({ + "step_index": step_idx, + "type": "ASK_QUESTION", + "is_ask": True, + "label": f"[ASK QUESTION] Step {step_idx}: {summary}", + "summary": summary + }) + break + except Exception as e: + print(f"Warning: Error reading transcript.jsonl: {e}", file=sys.stderr) + + return checkpoints + +def rewind_antigravity_db(db_path, cutoff_step, cid=None, summary_db_path=None, preserve_ask=True, transcript_path=None): + if not os.path.exists(db_path): + print(f"Error: DB file not found: {db_path}", file=sys.stderr) + return False + + effective_cutoff = cutoff_step + if not preserve_ask and transcript_path and os.path.exists(transcript_path): + try: + with open(transcript_path, 'r', encoding='utf-8') as f: + for line in f: + if not line.strip(): + continue + data = json.loads(line) + if data.get("step_index", 0) == cutoff_step: + tool_calls = data.get("tool_calls", []) + for tc in tool_calls: + tname = tc.get("name", "") or tc.get("tool_name", "") + if tname in ("ask_question", "AskUserQuestion"): + effective_cutoff = max(0, cutoff_step - 1) + break + break + except Exception: + pass + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute("SELECT COUNT(*) FROM steps WHERE idx > ?", (effective_cutoff,)) + delete_count = cursor.fetchone()[0] + + # Backup DB + backup_db = db_path + ".bak" + import shutil + shutil.copy2(db_path, backup_db) + + cursor.execute("DELETE FROM steps WHERE idx > ?", (effective_cutoff,)) + conn.commit() + conn.close() + + # Truncate transcript.jsonl & transcript_full.jsonl if present + if transcript_path and os.path.exists(transcript_path): + try: + backup_t = transcript_path + ".bak" + shutil.copy2(transcript_path, backup_t) + new_lines = [] + with open(transcript_path, 'r', encoding='utf-8') as f: + for line in f: + if not line.strip(): + continue + data = json.loads(line) + if data.get("step_index", 0) <= effective_cutoff: + new_lines.append(line.strip()) + + tf_path = os.path.join(os.path.dirname(transcript_path), "transcript_full.jsonl") + if os.path.exists(tf_path): + shutil.copy2(tf_path, tf_path + ".bak") + tf_lines = [l.strip() for l in open(tf_path, encoding="utf-8") if l.strip()] + valid_tf = [l for l in tf_lines if json.loads(l).get("step_index", 0) <= effective_cutoff] + with open(tf_path + ".tmp", "w", encoding="utf-8") as f_tf: + f_tf.write("\n".join(valid_tf) + "\n") + os.replace(tf_path + ".tmp", tf_path) + tmp_t = transcript_path + ".tmp" + with open(tmp_t, 'w', encoding='utf-8') as f: + f.write('\n'.join(new_lines) + '\n') + os.replace(tmp_t, transcript_path) + print(f"Successfully truncated transcript {transcript_path} and transcript_full.jsonl to step <= {effective_cutoff}") + except Exception as e: + print(f"Warning: Failed to truncate transcripts: {e}", file=sys.stderr) + + if summary_db_path and os.path.exists(summary_db_path) and cid: + try: + s_conn = sqlite3.connect(summary_db_path) + s_cursor = s_conn.cursor() + s_cursor.execute( + "UPDATE conversation_summaries SET step_count=(SELECT COUNT(*) FROM steps WHERE conversation_id=?) WHERE conversation_id=?", + (cid, cid) + ) + s_conn.commit() + s_conn.close() + except Exception as e: + print(f"Warning: Failed to update summary DB: {e}", file=sys.stderr) + + print(f"Successfully truncated DB {db_path} to idx <= {cutoff_step} (Deleted {delete_count} steps). Backup saved to {backup_db}") + return True + +def find_claude_session_file(uuid_or_path): + if os.path.exists(uuid_or_path): + return os.path.abspath(uuid_or_path) + projects_dir = os.path.expanduser("~/.claude/projects") + if not os.path.exists(projects_dir): + return None + for root, dirs, files in os.walk(projects_dir): + if ".bak" in root: + continue + for f in files: + if f == f"{uuid_or_path}.jsonl" or f == uuid_or_path: + return os.path.join(root, f) + return None + +def list_claude_sessions(): + projects_dir = os.path.expanduser("~/.claude/projects") + if not os.path.exists(projects_dir): + return [] + results = [] + for root, dirs, files in os.walk(projects_dir): + if ".bak" in root: + continue + for f in files: + if f.endswith(".jsonl"): + full_path = os.path.join(root, f) + cid = os.path.splitext(f)[0] + mtime = datetime.fromtimestamp(os.path.getmtime(full_path)).strftime('%Y-%m-%d %H:%M:%S') + line_count = 0 + title = "(No Title)" + try: + with open(full_path, "r", encoding="utf-8") as s_file: + for idx, line in enumerate(s_file): + line_count += 1 + if idx < 5 and title == "(No Title)" and line.strip(): + try: + data = json.loads(line) + if "custom-title" in data: + title = data["custom-title"] + elif data.get("type") == "user": + msg = data.get("message", {}) + content = msg.get("content", "") if isinstance(msg, dict) else str(msg) + if isinstance(content, list): + content = " ".join(b.get("text", "") for b in content if isinstance(b, dict)) + text = content if isinstance(content, str) else str(content) + if text: + title = text[:60].replace("\n", " ") + except Exception: + pass + except Exception: + pass + results.append({ + "uuid": cid, + "title": title, + "mtime": mtime, + "lines": line_count, + "path": full_path + }) + results.sort(key=lambda x: x["mtime"], reverse=True) + return results + +def rewind_claude_session(uuid_or_path, keep_lines): + session_file = find_claude_session_file(uuid_or_path) + if not session_file or not os.path.exists(session_file): + print(f"Error: Claude Code session file not found for UUID: {uuid_or_path}", file=sys.stderr) + return False + if keep_lines < 0: + print(f"Error: keep_lines must be non-negative (got {keep_lines})", file=sys.stderr) + return False + + backup_path = session_file + ".bak" + import shutil + shutil.copy2(session_file, backup_path) + + retained_lines = [] + total_lines = 0 + with open(session_file, "r", encoding="utf-8") as f: + for idx, line in enumerate(f): + total_lines += 1 + if idx < keep_lines: + retained_lines.append(line.rstrip("\r\n")) + + tmp_path = session_file + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + if retained_lines: + f.write("\n".join(retained_lines) + "\n") + os.replace(tmp_path, session_file) + + deleted_lines = max(0, total_lines - len(retained_lines)) + print(f"Successfully truncated Claude Code session {session_file} to {len(retained_lines)} lines (Deleted {deleted_lines} lines). Backup saved to {backup_path}") + return True + +def main(): + parser = argparse.ArgumentParser(description="Direct Session Rewind Engine") + parser.add_argument("--list-sessions", choices=["antigravity-ide", "antigravity-cli", "claude-code"], help="List sessions for engine") + parser.add_argument("--list-checkpoints", choices=["antigravity-ide", "antigravity-cli"], help="List checkpoints for session UUID") + parser.add_argument("--antigravity-ide", action="store_true", help="Target Antigravity IDE") + parser.add_argument("--antigravity-cli", action="store_true", help="Target Antigravity CLI") + parser.add_argument("--claude-code", action="store_true", help="Target Claude Code") + parser.add_argument("--uuid", help="Session UUID") + parser.add_argument("--step", type=int, help="Target step_index to truncate steps after (for Antigravity)") + parser.add_argument("--line", type=int, help="Target line index to truncate lines after (for Claude Code)") + parser.add_argument("--preserve-ask", action="store_true", help="Preserve the AskUserQuestion step itself when rewinding to an Ask response") + + args = parser.parse_args() + + if args.list_sessions: + engine = args.list_sessions + if engine == "antigravity-ide": + sessions = list_antigravity_sessions("~/.gemini/antigravity-ide") + elif engine == "antigravity-cli": + sessions = list_antigravity_sessions("~/.gemini/antigravity-cli") + elif engine == "claude-code": + sessions = list_claude_sessions() + else: + sessions = [] + + print(json.dumps(sessions, ensure_ascii=False, indent=2)) + sys.exit(0) + + if args.list_checkpoints: + if not args.uuid: + print("Error: --uuid is required for --list-checkpoints", file=sys.stderr) + sys.exit(1) + engine_dir = "~/.gemini/antigravity-ide" if args.list_checkpoints == "antigravity-ide" else "~/.gemini/antigravity-cli" + checkpoints = list_antigravity_checkpoints(engine_dir, args.uuid) + print(json.dumps(checkpoints, ensure_ascii=False, indent=2)) + sys.exit(0) + + if args.antigravity_ide or args.antigravity_cli: + engine_dir = "~/.gemini/antigravity-ide" if args.antigravity_ide else "~/.gemini/antigravity-cli" + if not args.uuid or args.step is None: + print("Error: --uuid and --step are required for DB truncation.", file=sys.stderr) + sys.exit(1) + + db_path = os.path.expanduser(os.path.join(engine_dir, "conversations", f"{args.uuid}.db")) + summary_db = os.path.expanduser(os.path.join(engine_dir, "conversation_summaries.db")) + transcript_path = get_transcript_path(engine_dir, args.uuid) + success = rewind_antigravity_db(db_path, args.step, cid=args.uuid, summary_db_path=summary_db, preserve_ask=args.preserve_ask, transcript_path=transcript_path) + sys.exit(0 if success else 1) + + if args.claude_code: + if not args.uuid or args.line is None: + print("Error: --uuid and --line are required for Claude Code session truncation.", file=sys.stderr) + sys.exit(1) + success = rewind_claude_session(args.uuid, args.line) + sys.exit(0 if success else 1) + + parser.print_help(file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() + diff --git a/skills/claude-session/scripts/session-id-inject.sh b/skills/session/scripts/session-id-inject.sh similarity index 100% rename from skills/claude-session/scripts/session-id-inject.sh rename to skills/session/scripts/session-id-inject.sh diff --git a/skills/claude-session/scripts/summarize-session.py b/skills/session/scripts/summarize-session.py similarity index 100% rename from skills/claude-session/scripts/summarize-session.py rename to skills/session/scripts/summarize-session.py diff --git a/skills/claude-session/scripts/test-repair-compact-boundary.py b/skills/session/scripts/test-repair-compact-boundary.py similarity index 100% rename from skills/claude-session/scripts/test-repair-compact-boundary.py rename to skills/session/scripts/test-repair-compact-boundary.py diff --git a/skills/claude-session/scripts/trim-memory-index.py b/skills/session/scripts/trim-memory-index.py similarity index 100% rename from skills/claude-session/scripts/trim-memory-index.py rename to skills/session/scripts/trim-memory-index.py diff --git a/skills/claude-session/search.md b/skills/session/search.md similarity index 100% rename from skills/claude-session/search.md rename to skills/session/search.md diff --git a/skills/claude-session/split.md b/skills/session/split.md similarity index 100% rename from skills/claude-session/split.md rename to skills/session/split.md diff --git a/skills/claude-session/summarize.md b/skills/session/summarize.md similarity index 100% rename from skills/claude-session/summarize.md rename to skills/session/summarize.md diff --git a/skills/claude-session/url.md b/skills/session/url.md similarity index 100% rename from skills/claude-session/url.md rename to skills/session/url.md diff --git a/skills/skill-kit/CHANGELOG.md b/skills/skill-kit/CHANGELOG.md index b7ea3518..448ab88c 100644 --- a/skills/skill-kit/CHANGELOG.md +++ b/skills/skill-kit/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.7.0](https://github.com/es6kr/skills/compare/skill-kit-v0.6.3...skill-kit-v0.7.0) (2026-08-20) + + +### Features + +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + ## [0.6.3](https://github.com/es6kr/skills/compare/skill-kit-v0.6.2...skill-kit-v0.6.3) (2026-08-17) diff --git a/skills/skill-kit/upgrade.md b/skills/skill-kit/upgrade.md index 5cdfbd63..10c208f4 100644 --- a/skills/skill-kit/upgrade.md +++ b/skills/skill-kit/upgrade.md @@ -475,7 +475,7 @@ After upgrade Edit/Write is complete, **commit the changed skill files in the `~ #### Branch guard (HARD STOP): PUBLIC skill → feat/fix branch, never develop/main direct -**For a PUBLIC skill (in `published.json`), the commit MUST land on a dedicated `feat/<slug>-...` or `fix/<slug>-...` branch (worktree-isolated), never directly on `develop`/`master`/`main`.** The es6kr/skills repo flow is feat/fix branch → PR → main (release-please runs on main). `develop` is NOT a change-accumulation branch for published skills. This mirrors `es6kr` `deploy-skill.md` §6 Branch strategy — keep the two consistent. +**For a PUBLIC skill (in `published.json`), the commit MUST land on a dedicated `feat/<slug>-...` or `fix/<slug>-...` branch (worktree-isolated), never directly on a shared integration branch (`develop`/`master`/`main`).** The PR **base** is a per-workspace customization, not a skill constant — resolve it from the workspace's `staging` role in works-config (see the works-config v0.2.0 schema). Where a two-tier staging is configured (`staging.kind: "branch"`), a `fix/*` branch PRs into the configured `next_fix` staging branch and a `feat/*` branch into the configured `next_feat`, and those staging branches are promoted to the release branch (where release-please runs) via a separate promotion PR. Where no staging layer is configured (`staging.kind: "none"`), `feat/fix` PRs into the repo's default integration branch directly. **Do not hardcode `next-*` or `main` as the base** — the staging convention lives in works-config, not in this skill. | # | Don't | Do | |---|-------|-----| diff --git a/skills/todowrite/conversation-id.md b/skills/todowrite/conversation-id.md index 74d723e2..21659c28 100644 --- a/skills/todowrite/conversation-id.md +++ b/skills/todowrite/conversation-id.md @@ -13,7 +13,7 @@ This rule applies to **every output the user can see or that downstream tools re | Medium | Self-check timing | Hookable? | |--------|-------------------|-----------| | **Response text (assistant output)** | Before generating every response. Most frequent violation site | No — cannot be blocked technically; the self-check is the only defense | -| `AskUserQuestion` option `label` / `description` | Right before each call | Yes — but only for sessions that START after the hook is registered. `block-tasklist-id-in-conversation.sh` (`todowrite/resources/`, PreToolUse:AskUserQuestion — re-homed here from `hook-kit/resources/`, where it briefly lived bundled inside `ask-guard.sh`) blocks bare `#NN`. **Long-running sessions load hooks at session start, so a session predating the hook's registration is NOT protected → the in-session self-check is the only defense there** (see failed-attempts.md "hook re-homed mid-session, long-running session unprotected") | +| `AskUserQuestion` option `label` / `description` | Right before each call | Yes — but only for sessions that START after the hook is registered. `block-tasklist-id-in-conversation.sh` (`todowrite/resources/`, PreToolUse:AskUserQuestion — re-homed here from `hook-kit/resources/`, where it briefly lived bundled inside `ask-guard.sh`) blocks bare `#NN`, and its 2nd guard blocks any `PR #N` reference lacking its own `/pull/<N>` URL in the same payload (question skill `options.md` section 4). **Long-running sessions load hooks at session start, so a session predating the hook's registration is NOT protected → the in-session self-check is the only defense there** (see failed-attempts.md "hook re-homed mid-session, long-running session unprotected") | | `TodoWrite` content | Right before each call | No (no hook yet) | | `TaskCreate` subject | Right before each call (subject already enforces a prefix → bare `#NNN` is forbidden) | Partially | | `Edit` / `Write` description / arguments | Right before each call | No | diff --git a/skills/todowrite/media-separation.md b/skills/todowrite/media-separation.md index bea2a940..ed636974 100644 --- a/skills/todowrite/media-separation.md +++ b/skills/todowrite/media-separation.md @@ -12,7 +12,8 @@ | Layer | Data nature | Medium | |-------|-------------|--------| -| Tracking | High-churn current state (pending → in-progress → done transitions) | fix_plan.md / checklist.md / TaskList | +| Tracking | High-churn current state (pending → in-progress → done transitions, lean 1-line metadata) | fix_plan.md / checklist.md / TaskList | +| Activity / Progress Notes | Step-by-step audit logs, triage results, interim execution narrative (`✅ classification complete`, `✅ execution complete`) | Plane Issue Comments (`plane_create_comment.py`) / Walkthroughs | | Recording | Immutable completed history (accumulate + semantic "did we do this before?" search) | RAG (Qdrant or similar vector store) | | Knowledge | Low-churn domain facts / decisions / patterns | LLM Wiki (raw → pages) | @@ -21,8 +22,9 @@ | # | Don't | Do | |---|-------|----| | 1 | Manage task-state tracking via wiki pages or RAG upserts | Use tracking files only (fix_plan.md / checklist.md / TaskList). Wiki is for knowledge only | -| 2 | Accumulate completed-work records as wiki pages | RAG store via `/cleanup` rag-store flow. Only distilled knowledge (not records) goes to wiki | -| 3 | Bury decisions/facts from completed work in RAG alone | Promote knowledge to a wiki page — recording ("what did we do") ≠ knowledge ("what is true") | +| 2 | Dump multi-paragraph execution logs (`✅ classification complete`, `✅ execution complete`, detailed audit tables) into `fix_plan.md` | Post detailed execution logs and audit narratives as **Plane issue comments** (`plane_create_comment.py`) or session walkthroughs, keeping `fix_plan.md` strictly lean with concise 1-line pointers | +| 3 | Accumulate completed-work records as wiki pages | RAG store via `/cleanup` rag-store flow. Only distilled knowledge (not records) goes to wiki | +| 4 | Bury decisions/facts from completed work in RAG alone | Promote knowledge to a wiki page — recording ("what did we do") ≠ knowledge ("what is true") | ## Exceptions diff --git a/skills/todowrite/resources/block-tasklist-id-in-conversation.sh b/skills/todowrite/resources/block-tasklist-id-in-conversation.sh index e0d163db..07710c65 100755 --- a/skills/todowrite/resources/block-tasklist-id-in-conversation.sh +++ b/skills/todowrite/resources/block-tasklist-id-in-conversation.sh @@ -12,6 +12,22 @@ # format. TaskList IDs are NOT shown in the user's UI, so a bare #NN in an # option label is meaningless to the user and collides visually with PR/issue # numbers. +# +# PR-URL gate (2nd guard, same file): every DISTINCT PR number referenced in ask +# text requires its OWN matching clickable PR URL somewhere in the same questions +# payload, so the user can open and inspect each PR before deciding. A bare "PR #N" +# — even with the repo name — is insufficient, and one PR's URL does not satisfy +# references to other PR numbers in the same payload (payload-wide "any URL exists" +# checking under-enforces multi-PR asks). Enforces question skill options.md +# section 4. Tracked in failed-attempts.md (grep "bare PR"). +# +# The gate was authored in the hook-kit copy of this file, then silently dropped +# when the TaskList-ID check was re-homed here: the re-home carried only the +# TaskList-ID logic, and the split-out target the refactor announced +# (github-flow/resources/block-pr-url-gate.sh) was never created. The surviving +# hook-kit copy kept the gate but was never registered in hooks/hooks.json, so +# the gate stopped running entirely. Merged back here and the hook-kit copy +# deleted so exactly one implementation exists, on the registered path. set -uo pipefail @@ -38,17 +54,33 @@ if [[ "${1:-}" == "--test" ]]; then # literal "task #118" bare TaskList reference is NOT denied. See # failed-attempts.md "TaskList-ID hook: Task-word ordinal-exception ambiguity". check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"q","options":[{"label":"do task #118","description":"x"}]}]}}' - check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"q","options":[{"label":"merge PR #118","description":"x"}]}]}}' + # PR-URL gate: a PR reference needs its OWN clickable URL in the same payload + check DENY '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"q","options":[{"label":"merge PR #118","description":"x"}]}]}}' + check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"q","options":[{"label":"merge PR #118","description":"https://github.com/es6kr/skills/pull/118"}]}]}}' + # one PR URL does not cover a different PR number in the same payload + check DENY '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"PR #12 https://github.com/es6kr/claude-plugins/pull/12","options":[{"label":"also PR #13","description":"x"}]}]}}' + check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"PR #12 https://github.com/es6kr/claude-plugins/pull/12","options":[{"label":"also PR #13","description":"https://github.com/es6kr/claude-plugins/pull/13"}]}]}}' check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"q","options":[{"label":"see issue #42","description":"x"}]}]}}' check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"q","options":[{"label":"Finding #3 is real","description":"x"}]}]}}' check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"q","options":[{"label":"already merged #57","description":"x"}]}]}}' check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"q","options":[{"label":"see https://github.com/es6kr/skills/pull/60","description":"x"}]}]}}' + # Quantity mention with quantifier ("PR 2 items", "PR 3 items") is NOT a PR number reference + check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"PR 2 items to review?","options":[{"label":"proceed","description":"x"}]}]}}' + check ALLOW '{"tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"PR 3 items to check","options":[{"label":"yes","description":"x"}]}]}}' check ALLOW '{"tool_name":"Bash","tool_input":{"command":"echo #123 not an ask"}}' echo "Total: $((pass+fail)), Pass: $pass, Fail: $fail" [[ "$fail" -eq 0 ]] && exit 0 || exit 1 fi +# Load locale-specific regex patterns from hook-kit data/ +HG_DATA_FILE="$(dirname "$0")/../../hook-kit/data/hangul-patterns.regex" +if [[ -f "$HG_DATA_FILE" ]]; then + # shellcheck source=/dev/null + . "$HG_DATA_FILE" +fi +HG_QUANTIFIER_SUFFIX="${HG_QUANTIFIER_SUFFIX:-}" + INPUT=$(cat) TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) @@ -62,6 +94,47 @@ ASK_TEXT=$(echo "$INPUT" | jq -r ' (.options[]? | (.label // ""), (.description // "")) ' 2>/dev/null) +# --- PR-URL gate: each DISTINCT PR number must have its own matching URL --- +# (payload-wide "any URL exists" checking under-enforces multi-PR asks — a URL +# for PR #A does not satisfy a bare reference to PR #B in the same payload) +# Strip PR quantity mentions (e.g. "PR 2 items", "PR 3 items", "PR 5 items") before finding referenced PR numbers +CLEANED_ASK_TEXT=$(echo "$ASK_TEXT" | sed -E "s/\bPR[[:space:]]*[0-9]+[[:space:]]*(${HG_QUANTIFIER_SUFFIX}${HG_QUANTIFIER_SUFFIX:+|}items|prs|pull requests)\b//gI") +PR_NUMS_REFERENCED=$(echo "$CLEANED_ASK_TEXT" | grep -oiE '\bPR[[:space:]]*#?[0-9]+' | grep -oE '[0-9]+' | sort -un) +if [[ -n "$PR_NUMS_REFERENCED" ]]; then + PR_NUMS_WITH_URL=$(echo "$ASK_TEXT" | grep -oiE 'https?://[^[:space:])]+/(pull|merge_requests)/[0-9]+' | grep -oE '[0-9]+$' | sort -un) + MISSING_URL_FOR=() + while IFS= read -r n; do + [[ -z "$n" ]] && continue + if ! grep -qxF "$n" <<< "$PR_NUMS_WITH_URL"; then + MISSING_URL_FOR+=("$n") + fi + done <<< "$PR_NUMS_REFERENCED" + if [[ ${#MISSING_URL_FOR[@]} -gt 0 ]]; then + { + echo "DENIED: AskUserQuestion references PR number(s) without a matching URL for each." + echo "" + echo "Why blocked:" + echo " - An ask is a self-contained decision UI: the user must be able to open" + echo " and inspect EVERY referenced PR before deciding, without hunting through" + echo " scroll-back" + echo " - A bare 'PR #N' — even with the repo/project name — is not clickable" + echo " - One PR's URL does not satisfy references to a DIFFERENT PR number in the" + echo " same payload — each distinct number needs its own URL" + echo "" + echo "PR number(s) missing their own URL: ${MISSING_URL_FOR[*]}" + echo "" + echo "Required action:" + echo " Add the full PR/MR URL (e.g., https://github.com/<owner>/<repo>/pull/<N> or" + echo " GitLab MR URL) for each PR number listed above, in the question text or" + echo " the relevant option's description, then retry." + echo "" + echo "Reference: question skill options.md section 4 (metadata + full URL);" + echo " failed-attempts.md (grep \"bare PR\")" + } >&2 + exit 2 + fi +fi + # PR / issue / pull #N -> explicit GitHub reference, allowed ISSUE_PREFIX='(PR|issue|pull)[[:space:]]*#[0-9]|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]' # Explicit enumeration prefix -> not a TaskList ID, an ordinal reference diff --git a/skills/web-browser/SKILL.md b/skills/web-browser/SKILL.md index f7326025..22abf349 100644 --- a/skills/web-browser/SKILL.md +++ b/skills/web-browser/SKILL.md @@ -98,6 +98,34 @@ During a closed shadow DOM `ak-library` cascade investigation, used a `npx playw While building a domain-registration payment-request report, captured the domain-search-result page (showing a promotional price) and the login screen, then stopped at the login wall with a disclaimer ("have finance/ops enter payment details"), never asking whether to continue via login to verify the real checkout price. The report's stated price differed from the actual payment-screen price. User feedback (paraphrased): "don't arbitrarily skip capturing screens that require login — ask first." +## Known Automation Limitations — SaaS Portal Action-Level CAPTCHA Gates + +Some SaaS portals allow full browser login automation but selectively trigger CAPTCHA challenges +on **creation/mutation actions** (not just on login). Document confirmed cases here so agents do +not repeat failed automation attempts. + +| Service | Automatable | CAPTCHA-blocked | Fallback | +|---------|-------------|-----------------|----------| +| **Discord Developer Portal** | Login (via persistent profile with saved credentials) | **New application creation**, bot token reset | Keep browser visible (`headless: false`); user handles hCaptcha manually; script polls `page.url()` for `/bot` URL and auto-captures token once user navigates there | +| **Discord Developer Portal** | Reading existing app info, navigating between tabs | _(same)_ | _(same)_ | + +### Discord Developer Portal — specific notes (2026-08-18, 1st confirmed) + +- **Login**: Playwright persistent context (`launchPersistentContext`) with a saved user data directory + retains Discord session cookies. Navigation to `discord.com/developers/applications` succeeds + without re-authentication. +- **Bot creation blocked**: Clicking "New Application" and submitting the modal triggers an hCaptcha + dialog (e.g. "Hold on! You are human, right?"). The `force: true` checkbox click and JS `dispatchEvent` + workarounds successfully activate the Create button, but Discord's backend detects the automated + browser and intercepts submission with CAPTCHA. +- **Recommended hybrid flow**: + 1. Launch Playwright with `headless: false` + `launchPersistentContext` (reuses login session). + 2. Navigate to the applications page. + 3. Set up a polling loop watching `page.url()` for the `/bot` path (every 2s, max ~4min timeout). + 4. Inform user to manually create the application (handle hCaptcha) and navigate to the Bot tab. + 5. When `/bot` URL is detected, script resumes: click "Reset Token" → capture `input[readonly]` value → enable `[role="switch"]` intents → save changes. + 6. Write token to a temp file → hand off to next automation (K8s Secret injection, etc.). + --- ## Step 0: Environment Detection (MANDATORY — before any browser action) diff --git a/skills/wip/CHANGELOG.md b/skills/wip/CHANGELOG.md index 6aed6044..c97b1bcf 100644 --- a/skills/wip/CHANGELOG.md +++ b/skills/wip/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [0.5.0](https://github.com/es6kr/skills/compare/wip-v0.4.5...wip-v0.5.0) (2026-08-20) + + +### Features + +* promote next-feat staging (lifecycle guards, triage automation, and workflow safety procedures) ([77d58ac](https://github.com/es6kr/skills/commit/77d58ac3a771a4897043c9eea8b149ea1e8ba2ff)) + + +### Bug Fixes + +* **hook-kit:** repair hook paths broken by the within-marketplace relocation ([#340](https://github.com/es6kr/skills/issues/340)) ([0d9b701](https://github.com/es6kr/skills/commit/0d9b70181015e4822c7cf5cd2fe122d5af708d26)) +* promote next-fix batch (hook path repair, topic-dispatch scoping, conflict diagnosis) ([eb7ecb6](https://github.com/es6kr/skills/commit/eb7ecb61dda9701d78f12dc810781dc7cb687caa)) + ## [0.4.5](https://github.com/es6kr/skills/compare/wip-v0.4.4...wip-v0.4.5) (2026-08-17) diff --git a/skills/wip/resources/block-wip-register-before-execute.py b/skills/wip/resources/block-wip-register-before-execute.py index 589de12d..f7ac811a 100755 --- a/skills/wip/resources/block-wip-register-before-execute.py +++ b/skills/wip/resources/block-wip-register-before-execute.py @@ -141,9 +141,52 @@ def was_registration_wip_without_followup(log_path, registered_pattern): return False return True +def find_antigravity_active_log_path(): + brain_root = os.path.expanduser("~/.gemini/antigravity-ide/brain") + if not os.path.isdir(brain_root): + return "" + candidates = [] + try: + for d in os.listdir(brain_root): + full_d = os.path.join(brain_root, d) + log_file = os.path.join(full_d, ".system_generated", "logs", "transcript.jsonl") + if os.path.isfile(log_file): + try: + candidates.append((os.path.getmtime(log_file), log_file)) + except Exception: + pass + except Exception: + return "" + if not candidates: + return "" + candidates.sort(reverse=True) + return candidates[0][1] + +def was_workflow_or_registration_without_followup(log_path, registered_pattern): + events = load_events(log_path) + last_user = find_last_genuine_user_prompt(events) + if last_user is None: + return False + raw_user, e_user = events[last_user] + utext = text_of(e_user) or raw_user + + # Check for slash commands or explicit task directives + is_workflow = bool(re.search(r"/(fix|fa|code-workflow|deploy|consolidate|wip|fix-plan)\b", utext, re.I) + or re.search(r"command-name>/?(fix|fa|code-workflow|deploy|consolidate|wip|fix-plan)<", raw_user, re.I) + or (re.search(r"command-name>/?wip<", raw_user) and re.search(r"(" + REGISTER_VERBS + r")", utext, re.I))) + + if not is_workflow: + return False + + for j in range(last_user + 1, len(events)): + rawj, _ = events[j] + if re.search(registered_pattern, rawj): + return False + return True + if AG_TOOL: # Antigravity runtime. Matcher mirrors ~/.gemini/config/hooks.json's - # "Edit|Write|write_to_file" (tool-name spelling unconfirmed - kept broad). + # "Edit|Write|write_to_file|replace_file_content|multi_replace_file_content" if AG_TOOL not in ("Edit", "Write", "write_to_file", "replace_file_content", "multi_replace_file_content"): allow() ag_args = payload.get("toolCall", {}).get("args", {}) or {} @@ -154,30 +197,25 @@ def was_registration_wip_without_followup(log_path, registered_pattern): or "" ) if str(target_path).endswith("task.md"): - # The write IS the registration act (wip/antigravity.md's task.md - # medium) - never block it. + # The write IS the registration act - never block it. allow() - # Best-effort: try guessed transcript/conversation-log field names. If - # Antigravity's real payload uses a different key, this silently falls - # through to allow() below rather than erroring - documented gap, not a - # silent false-negative masquerading as verified coverage. + ag_log_path = ( payload.get("transcriptPath") or payload.get("transcript_path") or (payload.get("toolCall", {}) or {}).get("transcriptPath") + or find_antigravity_active_log_path() or "" ) if ag_log_path and os.path.exists(ag_log_path): try: - # Antigravity has no TaskCreate/TodoWrite tool_use to scan for; - # the registration signal is a write to task.md, which we can - # only detect here as a raw-line substring on the artifact path. - if was_registration_wip_without_followup(ag_log_path, r"task\.md"): + if was_workflow_or_registration_without_followup(ag_log_path, r"task\.md"): deny_antigravity( - "/wip register-before-execute (HARD STOP): a registration-mode " - "/wip was invoked but task.md has not been written since. " - "Register the task in task.md FIRST, then edit the deliverable. " - "Ref: wip/antigravity.md Step 2 'Initialize'." + "Tool Call #1 MANDATORY & /wip register-before-execute (HARD STOP): " + "A workflow slash-command (/fix, /fa, /wip, /code-workflow, etc.) was invoked " + "but task.md has not been updated in the current turn. " + "The VERY FIRST tool call MUST update task.md before editing deliverable files. " + "Ref: GEMINI.md 'Tool Call #1 MANDATORY' & wip/antigravity.md Step 2." ) except Exception: pass # fall through to allow() diff --git a/skills/wip/resources/block-wip-register-before-execute.sh b/skills/wip/resources/block-wip-register-before-execute.sh new file mode 100755 index 00000000..45e6a8d6 --- /dev/null +++ b/skills/wip/resources/block-wip-register-before-execute.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# PreToolUse:Edit|Write guard - /wip register-before-execute. +# Lives next to its .py implementation in the skill it gates (see CLAUDE.md +# "within-marketplace" placement rule). Korean registration-verb variants load +# from hook-kit/data/hangul-patterns.regex (git-ignored; the English-only +# fallback keeps the guard functional without locale data). +# +# Fails OPEN when either python3 or the sibling .py is unavailable. A missing +# dependency must degrade this guard, never block every Edit/Write in the +# session - that is exactly what happened when the .py moved away from the +# wrapper and the wrapper exec'd a path that no longer existed. +HG_DATA_FILE="$(dirname "$0")/../../hook-kit/data/hangul-patterns.regex" +if [ -f "$HG_DATA_FILE" ]; then + # shellcheck source=/dev/null + . "$HG_DATA_FILE" +fi +export HG_WIP_REGISTER_VERBS="${HG_WIP_REGISTER_VERBS:-add|write|create|draft|record|register}" + +PY="$(command -v python3 || true)" +[ -z "$PY" ] && exit 0 +IMPL="$(dirname "$0")/block-wip-register-before-execute.py" +[ -f "$IMPL" ] || exit 0 +exec "$PY" "$IMPL" diff --git a/skills/wip/resources/wip-task-complete-detect.sh b/skills/wip/resources/wip-task-complete-detect.sh index 1099683e..c8eda3b0 100755 --- a/skills/wip/resources/wip-task-complete-detect.sh +++ b/skills/wip/resources/wip-task-complete-detect.sh @@ -4,9 +4,11 @@ USER_MSG="${CLAUDE_USER_PROMPT:-}" -# Locale detection patterns live in git-ignored data/ (Korean + English). The hook -# carries an English-only fallback so the PUBLIC copy works without the data file. -HG_DATA_FILE="$(dirname "$0")/../data/hangul-patterns.regex" +# Locale detection patterns live in git-ignored hook-kit/data/ (Korean + English). +# The hook carries an English-only fallback so the PUBLIC copy works without the +# data file. The path stays hook-kit-relative because the regex data did not move +# when this guard was relocated into the wip skill. +HG_DATA_FILE="$(dirname "$0")/../../hook-kit/data/hangul-patterns.regex" [ -f "$HG_DATA_FILE" ] && . "$HG_DATA_FILE" WIP_COMPLETE_KEYWORDS="${WIP_COMPLETE_KEYWORDS:-finished|completed|done in another session|handled it|already (did|handled)}" WIP_TASKREF_PATTERN="${WIP_TASKREF_PATTERN:-(#[0-9]+|task [0-9]+)}" diff --git a/skills/wip/resume.md b/skills/wip/resume.md index 519b94bc..8696f94e 100644 --- a/skills/wip/resume.md +++ b/skills/wip/resume.md @@ -158,7 +158,7 @@ This fast path is scoped narrowly to the **count == 1** case. With 2+ remaining | 3 | Bundle tasks under one ask via `multiSelect` | Each question independently decides the direction of its task | | 4 | Mark the first item `in_progress` without the direction ask | Step 3 may only be entered after Step 2 is complete | | 5 | Offer only "Hold (keep as task)" for external-wait items (user manual action / merge instruction / reply pending) | Include **Defer to checklist** in the option set — external-wait items belong in the checklist medium per "Medium separation principle" below. Hold keeps them polluting the task list across sessions | -| 6 | Reference a PR/issue by bare `#N` in the question text or an option's description | Every distinct PR/issue number needs its own clickable full URL (`https://github.com/<owner>/<repo>/pull/<N>`) somewhere in that same ask — this rule is not scoped to any one skill's option-composition path, it applies wherever a PR/issue surfaces in a decision UI. Enforced by `block-pr-url-gate.sh` (PreToolUse:AskUserQuestion) | +| 6 | Reference a PR/issue by bare `#N` in the question text or an option's description | Every distinct PR/issue number needs its own clickable full URL (`https://github.com/<owner>/<repo>/pull/<N>` for PRs or `https://github.com/<owner>/<repo>/issues/<N>` for issues) somewhere in that same ask — this rule is not scoped to any one skill's option-composition path, it applies wherever a PR/issue surfaces in a decision UI. `block-pr-url-gate.sh` was written to enforce this but is **not registered in any hook config**, so treat it as unenforced — the discipline is yours alone | | 7 | Ask direction for a single remaining item whose state is already known and whose direction is not actually in question | Apply the "Single unambiguous item — skip the ask" fast path above: state the inferred direction in one line and proceed to Step 3 directly | ### Per-environment ask method @@ -231,7 +231,7 @@ The ask-medium ceiling (Claude `questions` max 4, Antigravity `ask.md` visibilit | 2 | Double-register BLOCKED items under the rationale "having it in the task list helps me not forget" | The checklist is reloaded every session, so it won't be forgotten. Duplicating the medium increases sync overhead | | 3 | Use `[BLOCKED]` as a task subject prefix | Use the checklist's `## Hold` section: `- [ ] [BLOCKED] <subject> (trigger: ...)` | | 4 | Report "BLOCKED still BLOCKED" on every `/wip` for external-wait tasks | If it is not in the task list, it is not a reporting target. When the response arrives, promote it from the checklist to a task | -| 5 | Write a task subject like "Consolidate PR #184..." with the repo/URL only in the description | `TaskList` displays subject only, never description. A PR/issue reference in the subject needs its own repo qualifier (e.g. "owner/repo PR #N: ...") in the subject itself. Enforced by `block-pr-url-gate.sh` (PreToolUse:TaskCreate) | +| 5 | Write a task subject like "Consolidate PR #184..." with the repo/URL only in the description | `TaskList` displays subject only, never description. A PR/issue reference in the subject needs its own repo qualifier (e.g. "owner/repo PR #N: ...") in the subject itself. `block-pr-url-gate.sh` was written to enforce this but is **not registered in any hook config** — unenforced, so apply it yourself | ### Exception — Ralph-autonomous-loop-owned checklist files (large backlog) diff --git a/tests/test_consolidate_sha_guard.bats b/tests/test_consolidate_sha_guard.bats new file mode 100644 index 00000000..84e80732 --- /dev/null +++ b/tests/test_consolidate_sha_guard.bats @@ -0,0 +1,123 @@ +#!/usr/bin/env bats +# Behavioral tests for block-summary-fabricated-claims.sh (consolidate fabrication gate). +# Runs offline: cwd is a non-git tmpdir so the guard's local `git cat-file` short-circuit +# is inert, and a mock `gh` on PATH fully controls SHA existence + Copilot count lookups. + +REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" +GUARD="$REPO_ROOT/skills/consolidate/resources/block-summary-fabricated-claims.sh" + +setup() { + TESTDIR="$BATS_TEST_TMPDIR/work" + MOCKBIN="$BATS_TEST_TMPDIR/bin" + mkdir -p "$TESTDIR" "$MOCKBIN" + + # Unset git environment variables so pre-push hook execution doesn't leak repo context into tmpdir + unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX + + # mock gh: real SHA => echoes .sha (rc 0); fake SHA (4e7ee9e / fake* / deadbee*) => 404-style err (rc 1); + # pulls/*/comments => MOCK_COPILOT_COUNT (default 2) + cat > "$MOCKBIN/gh" <<'MOCK' +#!/usr/bin/env bash +if [[ "$1" == "api" ]]; then + path="$2" + case "$path" in + */commits/*) + sha="${path##*/commits/}" + if [[ "$sha" == 4e7ee9e* || "$sha" == fake* || "$sha" == deadbee* ]]; then + echo "gh: No commit found for SHA: $sha (HTTP 422)" >&2 + exit 1 + fi + echo "$sha"; exit 0 ;; + */pulls/*/comments) + echo "${MOCK_COPILOT_COUNT:-2}"; exit 0 ;; + esac +fi +exit 0 +MOCK + chmod +x "$MOCKBIN/gh" + PATH="$MOCKBIN:$PATH" +} + +# build a consolidate-provenance body file and echo the command that posts it +_post_cmd() { + local body_file="$TESTDIR/body.md" + printf '%s\n' "$1" > "$body_file" + echo "gh pr comment 346 -R es6kr/skills --body-file $body_file" +} + +_run_guard() { + cd "$TESTDIR" + local cmd="$1" + run bash "$GUARD" <<EOF +{"tool_name":"Bash","tool_input":{"command":$(printf '%s' "$cmd" | jq -Rs .)}} +EOF +} + +@test "guard script exists and is executable" { + [[ -x "$GUARD" ]] +} + +@test "fabricated SHA (4e7ee9e) in a Summary is blocked" { + local body="## AI Review Summary — [receiving-code-review](x) +<!-- consolidate:verified --> +| 9 | Internal | hooks.json | Fixed (commit 0d9b701, 035a27c, 4e7ee9e) | repaired |" + _run_guard "$(_post_cmd "$body")" + [[ "$status" -eq 2 ]] + [[ "$output" == *"4e7ee9e"* ]] +} + +@test "all-real SHAs in a Summary pass" { + local body="## AI Review Summary — [receiving-code-review](x) +<!-- consolidate:verified --> +| 1 | copilot | Fixed (commit 055c8a2, 0d9b701) | ok |" + _run_guard "$(_post_cmd "$body")" + [[ "$status" -eq 0 ]] +} + +@test "inflated Copilot count (claims 4, actual 2) is blocked" { + MOCK_COPILOT_COUNT=2 + local body="## AI Review Summary — [receiving-code-review](x) +<!-- consolidate:verified --> +| GitHub Copilot | External | Completed | 4 findings (2 inline, 2 doc) |" + _run_guard "$(_post_cmd "$body")" + [[ "$status" -eq 2 ]] + [[ "$output" == *"Copilot"* ]] +} + +@test "correct Copilot count (claims 2, actual 2) passes" { + MOCK_COPILOT_COUNT=2 + local body="## AI Review Summary — [receiving-code-review](x) +<!-- consolidate:verified --> +| GitHub Copilot | External | Completed | 2 findings (2 actionable inline) |" + _run_guard "$(_post_cmd "$body")" + [[ "$status" -eq 0 ]] +} + +@test "non-consolidate review comment (no provenance) is not guarded" { + local body="## CodeRabbit findings +one finding — will fix in commit 4e7ee9e" + _run_guard "$(_post_cmd "$body")" + [[ "$status" -eq 0 ]] +} + +@test "override ALLOW_SUMMARY_FABRICATED_CLAIMS=1 bypasses the block" { + local body="## AI Review Summary — [receiving-code-review](x) +<!-- consolidate:verified --> +Fixed (commit 4e7ee9e)" + local bf="$TESTDIR/body.md"; printf '%s\n' "$body" > "$bf" + _run_guard "ALLOW_SUMMARY_FABRICATED_CLAIMS=1 gh pr comment 346 -R es6kr/skills --body-file $bf" + [[ "$status" -eq 0 ]] +} + +@test "non-Bash tool call is ignored" { + cd "$TESTDIR" + run bash "$GUARD" <<'EOF' +{"tool_name":"Read","tool_input":{"file_path":"/tmp/x"}} +EOF + [[ "$status" -eq 0 ]] +} + +@test "a non-POST bash command is ignored even with a fabricated SHA in it" { + _run_guard "echo commit 4e7ee9e receiving-code-review" + [[ "$status" -eq 0 ]] +} diff --git a/tests/test_hook_integrity_check.py b/tests/test_hook_integrity_check.py new file mode 100644 index 00000000..39eb2416 --- /dev/null +++ b/tests/test_hook_integrity_check.py @@ -0,0 +1,122 @@ +"""Unit tests for skills/fix-plan/scripts/hook_integrity_check.py. + +Regression class under test (PR #363 review, CodeRabbit Major): + - The checker iterated the OUTER entries of the installed nested hooks.json + schema ({event: [{matcher, hooks: [{type, command}]}]}), found no "command" + key there, and silently skipped every registered hook -> audit reported + nothing while claiming success. + - Interpreter-prefixed commands ("python3 /path/hook.sh") resolved the + interpreter token instead of the script operand, so the wrong path was + existence-checked. + +Run: + python -m pytest tests/test_hook_integrity_check.py -v + +CI (.github/workflows/test.yml) collects via `python -m pytest tests -v`, so this +file must live under tests/. The script under test stays in +skills/fix-plan/scripts/ and is loaded by path. +""" +import importlib.util +import json +import os + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CANON = os.path.join(REPO_ROOT, "skills", "fix-plan", "scripts", "hook_integrity_check.py") + + +def _load(): + spec = importlib.util.spec_from_file_location("hook_integrity_check", CANON) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +mod = _load() + + +# --- iter_hook_commands: schema coverage --- + +def test_iter_nested_schema_yields_every_command(): + hooks_data = { + "hooks": { + "PostToolUse": [ + {"matcher": "Read", "hooks": [ + {"type": "command", "command": "/a/one.sh"}, + {"type": "command", "command": "/a/two.sh"}, + ]}, + {"matcher": "Bash", "hooks": [ + {"type": "command", "command": "python3 /a/three.sh"}, + ]}, + ], + } + } + got = list(mod.iter_hook_commands(hooks_data)) + assert got == [ + ("PostToolUse", "/a/one.sh"), + ("PostToolUse", "/a/two.sh"), + ("PostToolUse", "python3 /a/three.sh"), + ] + + +def test_iter_flat_schema_still_supported(): + hooks_data = { + "hooks": { + "Stop": ["/flat/one.sh", {"command": "/flat/two.sh"}, {"script": "/flat/three.sh"}], + } + } + got = list(mod.iter_hook_commands(hooks_data)) + assert got == [ + ("Stop", "/flat/one.sh"), + ("Stop", "/flat/two.sh"), + ("Stop", "/flat/three.sh"), + ] + + +# --- resolve_script_operand: interpreter/env-prefix skipping --- + +def test_resolve_skips_interpreter_and_flags(): + assert mod.resolve_script_operand("python3 /p/hook.sh") == "/p/hook.sh" + assert mod.resolve_script_operand("bash -euo /p/guard.sh") == "/p/guard.sh" + assert mod.resolve_script_operand("node /p/check.js") == "/p/check.js" + + +def test_resolve_skips_env_assignment_prefix(): + assert mod.resolve_script_operand("FOO=1 python3 /p/hook.py") == "/p/hook.py" + + +def test_resolve_plain_path_unchanged(): + assert mod.resolve_script_operand('"/p/with space/hook.sh"') == "/p/with space/hook.sh" + + +# --- check_hook_integrity: end-to-end on the installed schema --- + +def test_installed_schema_is_audited(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # win32 expanduser + + present = tmp_path / "present-hook.sh" + present.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") + present.chmod(0o755) + missing = tmp_path / "missing-hook.sh" + + cfg_dir = tmp_path / ".claude" + cfg_dir.mkdir() + (cfg_dir / "hooks.json").write_text(json.dumps({ + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [ + {"type": "command", "command": f"python3 {present}"}, + {"type": "command", "command": str(missing)}, + ]}, + ], + } + }), encoding="utf-8") + + results = mod.check_hook_integrity(str(tmp_path)) + + ok_files = [i["file"] for i in results["OK"]] + missing_files = [i["file"] for i in results["MISSING"]] + # The script operand (not the interpreter) is what got audited: + assert str(present) in ok_files + assert str(missing) in missing_files + assert "python3" not in ok_files + missing_files diff --git a/tests/test_plane_profile.py b/tests/test_plane_profile.py index fb98a942..a34ba75d 100644 --- a/tests/test_plane_profile.py +++ b/tests/test_plane_profile.py @@ -87,6 +87,14 @@ def isolated_workspace(tmp_path, monkeypatch, scripts_on_path): import workspace_profile monkeypatch.setattr(workspace_profile, "CONFIG_FILE", config_file) + # The v2 loader reads CONFIG_FILE_V2 (~/.config/agent-workspace/config.json) + # before CONFIG_FILE. On a machine that has a real v2 config, that file would + # shadow this fixture and the workspace would resolve to the wrong (or + # default) profile. Point it at a path that does not exist so the loader + # falls through to the fixture on CONFIG_FILE. + monkeypatch.setattr( + workspace_profile, "CONFIG_FILE_V2", tmp_path / "no-agent-workspace.json" + ) # Environment must not be able to satisfy the assertions on its own. for var in ( @@ -204,6 +212,7 @@ def _fake_run(cmd, capture_output, text, check): captured_cmd["cmd"] = cmd return _FakeCompletedProcess() + monkeypatch.setattr(plane_create_issue.shutil, "which", lambda _: "/usr/local/bin/kubectl") monkeypatch.setattr(plane_create_issue.subprocess, "run", _fake_run) malicious_slug = "acme'; Workspace.objects.all().delete(); x='" diff --git a/tests/test_plane_script_defects.py b/tests/test_plane_script_defects.py new file mode 100644 index 00000000..7607ae2b --- /dev/null +++ b/tests/test_plane_script_defects.py @@ -0,0 +1,196 @@ +"""Regression guards for the two plane-script defect classes (2026-08-20 plan). + +Defect 1 — Cloudflare WAF 403: plane.es6.kr rejects the default +`Python-urllib/3.x` User-Agent, so every urllib client here must send the +browser-like UA that `plane_create_comment.py` established as the in-repo +precedent. These tests capture the outbound `urllib.request.Request` and +assert the header, so a regression to the default UA fails loudly instead of +surfacing as a live 403. + +Defect 2 — K3s fallback ValueError: `create_via_k3s_fallback` embeds a Django +shell script in an f-string; unescaped single braces raised +`ValueError: Invalid format specifier` at build time before any execution. +The builder is now a dedicated function whose output must both build (with +hostile quotes/braces in user input) and parse as valid Python. + +Cross-cutting — the `fix-plan` and `plane-backlog` copies of +`plane_create_issue.py` must stay byte-identical (dual-script drift is how +Defect 2 survived: one copy was fixed, the other kept the stale template). +""" + +import ast +import filecmp +import importlib.util +import json +import sys +import types +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +FIX_PLAN_SCRIPTS = REPO_ROOT / "skills" / "fix-plan" / "scripts" +PLANE_BACKLOG_SCRIPTS = REPO_ROOT / "skills" / "plane-backlog" / "scripts" + +EXPECTED_UA = "Mozilla/5.0 (plane-backlog)" + +CREATE_ISSUE_COPIES = [ + pytest.param(PLANE_BACKLOG_SCRIPTS / "plane_create_issue.py", id="plane-backlog"), +] + + +PROFILE = { + "plane_host": "https://plane.invalid", + "token": "test-token", + "workspace_slug": "testws", + "default_project": "11111111-1111-1111-1111-111111111111", +} + +# Inputs with the exact character classes that triggered Defect 2: single +# braces and quotes flowing into an f-string template. +HOSTILE_TITLE = 'Title with "quotes", {braces} and \'apostrophes\'' +HOSTILE_DESCRIPTION = "# Head {x}\n- item {y}\n - **sub** `code {z}`\nplain 'text'" + + +def load_module(path: Path, name: str): + """Load a script by file path under a unique module name. + + Both scripts self-bootstrap their sibling directories onto sys.path, but + the interpreter running pytest still needs the shared dirs visible before + the module-level `from plane_client import ...` / `from workspace_profile + import ...` lines execute. + """ + for d in (str(PLANE_BACKLOG_SCRIPTS), str(FIX_PLAN_SCRIPTS)): + if d not in sys.path: + sys.path.insert(0, d) + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class FakeResponse: + def __init__(self, payload: dict, status: int = 201): + self.status = status + self._payload = payload + + def read(self): + return json.dumps(self._payload).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +# ---------------------------------------------------------------- Defect 1: UA + + +@pytest.mark.parametrize("script_path", CREATE_ISSUE_COPIES) +def test_create_issue_rest_sends_browser_user_agent(script_path, monkeypatch): + mod = load_module(script_path, f"pci_ua_{script_path.parent.parent.name.replace('-', '_')}") + captured = [] + + def fake_urlopen(req, *args, **kwargs): + captured.append(req) + return FakeResponse({"id": "issue-1", "sequence_id": 7}) + + monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen) + res = mod.create_via_rest_api(PROFILE, "title", "desc", is_intake=False) + + assert res["success"] is True + ua = captured[0].get_header("User-agent") + assert ua == EXPECTED_UA + assert not (ua or "").startswith("Python-urllib") + + +def test_plane_sync_request_sends_browser_user_agent(monkeypatch): + mod = load_module(FIX_PLAN_SCRIPTS / "plane_sync.py", "plane_sync_ua") + captured = [] + + def fake_urlopen(req, *args, **kwargs): + captured.append(req) + return FakeResponse({}, status=200) + + monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen) + mod.make_plane_request( + {"plane_host": "https://plane.invalid", "plane_token": "tok", "plane_token_env": "X"}, + "workspaces/testws/projects/", + ) + + assert captured, "make_plane_request never issued a request" + assert captured[0].get_header("User-agent") == EXPECTED_UA + + +def test_plane_client_request_sends_browser_user_agent(monkeypatch): + pc = load_module(PLANE_BACKLOG_SCRIPTS / "plane_client.py", "plane_client_ua") + captured = [] + + def fake_urlopen(req, *args, **kwargs): + captured.append(req) + return FakeResponse({"ok": True}, status=200) + + monkeypatch.setattr(pc.urllib.request, "urlopen", fake_urlopen) + client = pc.PlaneClient(profile=dict(PROFILE), throttle=0) + client.request("workspaces/testws/projects/") + + assert captured[0].get_header("User-agent") == EXPECTED_UA + + +# ------------------------------------------------- Defect 2: fallback template + + +@pytest.mark.parametrize("script_path", CREATE_ISSUE_COPIES) +def test_k3s_script_builds_and_parses_with_hostile_input(script_path): + mod = load_module(script_path, f"pci_tpl_{script_path.parent.parent.name.replace('-', '_')}") + # Build must not raise (the old template died here with + # "ValueError: Invalid format specifier" on single braces) ... + script = mod.build_k3s_py_script( + "testws", + "11111111-1111-1111-1111-111111111111", + "https://plane.invalid", + HOSTILE_TITLE, + HOSTILE_DESCRIPTION, + True, + ) + # ... and the generated Django-shell source must be valid Python. + ast.parse(script) + + +@pytest.mark.parametrize("script_path", CREATE_ISSUE_COPIES) +def test_k3s_fallback_resolves_namespace_from_profile(script_path, monkeypatch): + mod = load_module(script_path, f"pci_ns_{script_path.parent.parent.name.replace('-', '_')}") + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return types.SimpleNamespace( + stdout='RESULT_JSON:{"success": true, "method": "K3s Django Shell Fallback"}', + stderr="", + returncode=0, + ) + + monkeypatch.setattr(mod.shutil, "which", lambda _: "/usr/local/bin/kubectl") + monkeypatch.setattr(mod.subprocess, "run", fake_run) + + res = mod.create_via_k3s_fallback(dict(PROFILE), "title") + assert res["success"] is True + assert calls[0][calls[0].index("-n") + 1] == "plane-ce" + + mod.create_via_k3s_fallback(dict(PROFILE, k3s_namespace="custom-ns"), "title") + assert calls[1][calls[1].index("-n") + 1] == "custom-ns" + + +@pytest.mark.parametrize("script_path", CREATE_ISSUE_COPIES) +def test_k3s_fallback_skips_gracefully_without_kubectl(script_path, monkeypatch): + mod = load_module(script_path, f"pci_skip_{script_path.parent.parent.name.replace('-', '_')}") + ran = [] + + monkeypatch.setattr(mod.shutil, "which", lambda _: None) + monkeypatch.setattr(mod.subprocess, "run", lambda *a, **k: ran.append(a)) + + res = mod.create_via_k3s_fallback(dict(PROFILE), "title") + assert res["success"] is False + assert "kubectl" in res["reason"] + assert not ran, "fallback must not shell out when kubectl is absent" diff --git a/tests/test_structure.bats b/tests/test_structure.bats index 2b6f7589..eca9b603 100644 --- a/tests/test_structure.bats +++ b/tests/test_structure.bats @@ -77,6 +77,26 @@ tracked_skills() { [[ -z "$result" ]] } +@test "no duplicate basenames among hooks.json-registered scripts" { + # Scope: only basenames that are actually wired into hooks.json — a same-named + # utility script under two skills' scripts/ that neither skill registers as a + # hook is a harmless naming coincidence, not a double-fire hazard. + local registered + registered=$(jq -r '.. | objects | select(.command) | .command' "$REPO_ROOT/hooks/hooks.json" 2>/dev/null \ + | grep -oE '[^ /]+\.(sh|py|js)' | sort -u) + local dupes + dupes=$(git -C "$REPO_ROOT" ls-files -- 'skills/*/resources/*.sh' 'skills/*/scripts/*.sh' 'skills/*/resources/*.py' 'skills/*/scripts/*.py' 'skills/*/resources/*.js' 'skills/*/scripts/*.js' \ + | awk -F/ '{print $NF, $0}' | sort | awk ' + { if ($1 == prev_name) { print prev_line; print $0; dup=1 } + else if (dup) { dup=0 } + prev_name=$1; prev_line=$0 } + ' | while read -r name path; do + printf '%s\n' "$registered" | grep -qx "$name" && echo "$name $path" + true + done) + [[ -z "$dupes" ]] || { echo "Duplicate basenames registered as hooks (both copies fire under the same hooks.json matcher — see hook-kit/audit.md 3-C): $dupes"; return 1; } +} + @test "no Korean in frontmatter" { local bad=() for skill in $(tracked_skills); do @@ -111,3 +131,60 @@ _native_path() { cygpath -w "$1" 2>/dev/null || echo "$1"; } source=$(_python -c "import json; m=json.load(open(r'$fpath')); print(m['plugins'][0]['source'])") [[ "$source" == "./" ]] } + +# --- hooks.json registration integrity --- +# +# A registration whose script is missing on disk does not fail loudly: the hook +# exits 127 and the harness cannot tell that apart from "the guard had no +# objection", so the guard is silently inert while still listed. Relocating a +# hook into its owning skill without updating hooks.json produces exactly that, +# and the same three cleanup-guard paths have already been corrected three times +# and reverted twice through messy local-branch merges. This test is the guard +# that makes the next revert fail in CI instead of going unnoticed. + +@test "hooks.json is valid JSON" { + [[ -f "$REPO_ROOT/hooks/hooks.json" ]] + _python -m json.tool "$(_native_path "$REPO_ROOT/hooks/hooks.json")" > /dev/null +} + +@test "every hooks.json command path exists on disk" { + local missing + missing=$(_python - "$(_native_path "$REPO_ROOT/hooks/hooks.json")" "$(_native_path "$REPO_ROOT")" <<'PY' +import json +import os +import sys + +hooks_path, repo_root = sys.argv[1], sys.argv[2] +MARKER = "${CLAUDE_PLUGIN_ROOT}/" +missing = set() + + +def walk(node): + if isinstance(node, dict): + command = node.get("command") + if isinstance(command, str): + # commands may be bare, quoted, or prefixed with an interpreter + for token in command.replace('"', " ").replace("'", " ").split(): + if MARKER in token: + rel = token.split(MARKER, 1)[1] + if not os.path.exists(os.path.join(repo_root, rel)): + missing.add(rel) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + +with open(hooks_path, encoding="utf-8") as fh: + walk(json.load(fh)) + +print("\n".join(sorted(missing))) +PY +) + if [[ -n "$missing" ]]; then + echo "hooks.json registers scripts that are not on disk (they would exit 127 and silently do nothing):" >&2 + echo "$missing" >&2 + return 1 + fi +} diff --git a/tests/test_verify_consolidate.py b/tests/test_verify_consolidate.py index 3a81fc12..b78d6757 100644 --- a/tests/test_verify_consolidate.py +++ b/tests/test_verify_consolidate.py @@ -1,3 +1,5 @@ +"""Unit tests for verify_consolidate.py mechanical validation script.""" + from __future__ import annotations import os @@ -13,7 +15,6 @@ from skills.consolidate.scripts.verify_consolidate import ConsolidateValidator - class TestVerifyConsolidate(unittest.TestCase): def setUp(self): self.sample_inline_comments = [ diff --git a/tests/test_verify_hooks_json.py b/tests/test_verify_hooks_json.py new file mode 100644 index 00000000..9f918ed2 --- /dev/null +++ b/tests/test_verify_hooks_json.py @@ -0,0 +1,201 @@ +"""Unit tests for verify-hooks-json.py (synthetic fixtures only, independent of +repo content). + +Two failure classes under test: + - Duplicate registration: the same (event, matcher, script-basename) registered + 2+ times -> fires twice on every trigger + - Ghost registration: a registered path has no file behind it -> the hook dies + with exit 127, which the harness cannot distinguish from "ran, no objection", + so the guard stays listed while enforcing nothing + +Run: + python -m pytest tests/test_verify_hooks_json.py -v + +CI (.github/workflows/test.yml) collects via `python -m pytest tests -v`, so this +file must live under tests/. The script under test stays in scripts/ and is loaded +by path. + +Ported from es6kr/claude-plugins (PR #23, merged) — translated from the original +Korean docstrings/comments since this repo is PUBLIC and English-only. +""" +import importlib.util +import json +import os + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CANON = os.path.join(REPO_ROOT, "scripts", "verify-hooks-json.py") + + +def _load(): + spec = importlib.util.spec_from_file_location("verify_hooks_json", CANON) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +mod = _load() + + +def _write_plugin(tmp_path, commands, scripts_to_create=()): + """Create <plugin-root>/hooks/hooks.json and return its path.""" + hooks_dir = tmp_path / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + payload = { + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": c} for c in commands]} + ] + } + } + (hooks_dir / "hooks.json").write_text(json.dumps(payload), encoding="utf-8") + for rel in scripts_to_create: + target = tmp_path / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + return hooks_dir / "hooks.json" + + +# --- ghost registration --- + +def test_ghost_registration_is_reported(tmp_path): + """A registered path with no file behind it is flagged as a ghost.""" + fp = _write_plugin(tmp_path, ['bash "${CLAUDE_PLUGIN_ROOT}/skills/a/resources/gone.sh"']) + errors, checked, skipped = mod.check_hooks_file(fp) + assert checked == 1 and skipped == 0 + assert len(errors) == 1 + assert "Ghost hook registration" in errors[0] + assert "skills/a/resources/gone.sh" in errors[0] + + +def test_existing_script_passes(tmp_path): + """A registered path that exists on disk passes.""" + rel = "skills/a/resources/present.sh" + fp = _write_plugin(tmp_path, ['bash "${CLAUDE_PLUGIN_ROOT}/' + rel + '"'], [rel]) + errors, checked, skipped = mod.check_hooks_file(fp) + assert errors == [] and checked == 1 and skipped == 0 + + +def test_relocated_script_is_caught(tmp_path): + """The real-world regression: a script was moved but its registration wasn't.""" + fp = _write_plugin( + tmp_path, + ['bash "${CLAUDE_PLUGIN_ROOT}/skills/hook-kit/resources/moved.sh"'], + ["skills/cleanup/resources/moved.sh"], # actual location is the relocated one + ) + errors, _, _ = mod.check_hooks_file(fp) + assert len(errors) == 1 and "Ghost hook registration" in errors[0] + + +# --- path-extraction shapes --- + +def test_path_extraction_handles_command_shapes(): + """Recognizes quoted/unquoted, with/without interpreter, with/without braces.""" + cases = [ + ('bash "${CLAUDE_PLUGIN_ROOT}/a/b.sh"', ["a/b.sh"]), + ("${CLAUDE_PLUGIN_ROOT}/a/b.sh", ["a/b.sh"]), + ("$CLAUDE_PLUGIN_ROOT/a/b.sh", ["a/b.sh"]), + ('node "${CLAUDE_PLUGIN_ROOT}/a/b.js" Read', ["a/b.js"]), + ("echo hello", []), + ] + for cmd, expected in cases: + assert mod.extract_plugin_root_paths(cmd) == expected, cmd + + +def test_path_extraction_does_not_produce_false_ghosts(): + """Getting the path terminator wrong produces a path that doesn't exist -> a + false positive (CI failing on a legitimate change). + + A false positive is worse than a miss — it blocks normal work. Whitespace + inside quotes must stay one unit; a shell metacharacter outside quotes must + terminate the path. + """ + cases = [ + # space inside quotes: truncating it yields a nonexistent path + ('bash "${CLAUDE_PLUGIN_ROOT}/a/my guard.sh"', ["a/my guard.sh"]), + ("bash '${CLAUDE_PLUGIN_ROOT}/a/my guard.sh'", ["a/my guard.sh"]), + # shell metacharacter outside quotes: including it yields a nonexistent path + ("${CLAUDE_PLUGIN_ROOT}/a/g.sh;", ["a/g.sh"]), + ("${CLAUDE_PLUGIN_ROOT}/a/g.sh)", ["a/g.sh"]), + ('bash "${CLAUDE_PLUGIN_ROOT}/a/g.sh" && echo ok', ["a/g.sh"]), + ("bash ${CLAUDE_PLUGIN_ROOT}/a/g.sh | tee /tmp/x", ["a/g.sh"]), + ('(bash "${CLAUDE_PLUGIN_ROOT}/a/g.sh")', ["a/g.sh"]), + # two references in one command + ( + "${CLAUDE_PLUGIN_ROOT}/a/one.sh && ${CLAUDE_PLUGIN_ROOT}/a/two.sh", + ["a/one.sh", "a/two.sh"], + ), + ] + for cmd, expected in cases: + assert mod.extract_plugin_root_paths(cmd) == expected, cmd + + +def test_quoted_path_with_space_resolves_instead_of_false_ghost(tmp_path): + """A path containing a space still passes when it exists (end-to-end false-positive regression guard).""" + rel = "skills/a/resources/my guard.sh" + fp = _write_plugin(tmp_path, ['bash "${CLAUDE_PLUGIN_ROOT}/' + rel + '"'], [rel]) + errors, checked, skipped = mod.check_hooks_file(fp) + assert errors == [] and checked == 1 and skipped == 0 + + +def test_unresolvable_command_is_skipped_not_failed(tmp_path): + """An inline command with no CLAUDE_PLUGIN_ROOT reference is a skip, not an error.""" + fp = _write_plugin(tmp_path, ["echo inline-guard"]) + errors, checked, skipped = mod.check_hooks_file(fp) + assert errors == [] and checked == 0 and skipped == 1 + + +def test_plugin_root_is_grandparent_of_hooks_json(tmp_path): + """${CLAUDE_PLUGIN_ROOT} = the grandparent directory of <plugin-root>/hooks/hooks.json.""" + fp = tmp_path / "plugins" / "code-quality" / "hooks" / "hooks.json" + assert mod.plugin_root_for(fp) == tmp_path / "plugins" / "code-quality" + + +# --- duplicate registration (existing-behavior regression guard) --- + +def test_duplicate_registration_is_reported(tmp_path): + """The same (event, matcher, basename) registered twice is flagged as a duplicate.""" + rel = "skills/a/resources/dup.sh" + cmd = 'bash "${CLAUDE_PLUGIN_ROOT}/' + rel + '"' + fp = _write_plugin(tmp_path, [cmd, cmd], [rel]) + errors, _, _ = mod.check_hooks_file(fp) + assert len(errors) == 1 + assert "Duplicate hook registration" in errors[0] + + +def test_duplicate_inline_command_is_reported(tmp_path): + """An inline command with no script token is still a duplicate when registered twice. + + Exercises the extract_script_basename fallback that uses the whole command + string as the dedup key. + """ + fp = _write_plugin(tmp_path, ["echo inline-guard", "echo inline-guard"]) + errors, checked, skipped = mod.check_hooks_file(fp) + assert checked == 0 and skipped == 2 + assert len(errors) == 1 and "Duplicate hook registration" in errors[0] + + +def test_distinct_inline_commands_are_not_duplicates(tmp_path): + """Two different inline commands are not duplicates (the fallback key differs per command).""" + fp = _write_plugin(tmp_path, ["echo one", "echo two"]) + errors, _, _ = mod.check_hooks_file(fp) + assert errors == [] + + +def test_duplicate_and_ghost_reported_together(tmp_path): + """The two classes are independent — both can be reported for one file.""" + cmd = 'bash "${CLAUDE_PLUGIN_ROOT}/skills/a/resources/gone.sh"' + fp = _write_plugin(tmp_path, [cmd, cmd]) + errors, _, _ = mod.check_hooks_file(fp) + assert sum("Duplicate hook registration" in e for e in errors) == 1 # only the 2nd registration is a duplicate + assert sum("Ghost hook registration" in e for e in errors) == 2 # both registrations are ghosts + assert len(errors) == 3 + + +def test_malformed_json_is_reported(tmp_path): + """A JSON parse failure is not silently swallowed.""" + hooks_dir = tmp_path / "hooks" + hooks_dir.mkdir(parents=True) + fp = hooks_dir / "hooks.json" + fp.write_text("{ not json", encoding="utf-8") + errors, checked, skipped = mod.check_hooks_file(fp) + assert len(errors) == 1 and "Failed to parse JSON" in errors[0]