diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9980ed57..1950c106 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "adept", - "version": "4.7.1", + "version": "4.7.2", "description": "Development-workflow skills: design, TDD, adversarial review, shipping, and campaign orchestration for Claude Code and Codex.", "author": { "name": "David Christensen" diff --git a/docs/adr/0004-seek-quest-occupancy-signals-and-cheatsheet-gate.md b/docs/adr/0004-seek-quest-occupancy-signals-and-cheatsheet-gate.md index 47e7b49f..9a05ad05 100644 --- a/docs/adr/0004-seek-quest-occupancy-signals-and-cheatsheet-gate.md +++ b/docs/adr/0004-seek-quest-occupancy-signals-and-cheatsheet-gate.md @@ -4,6 +4,11 @@ Accepted (2026-08-12) +> **Amended (2026-09-10):** A canonical `Blocked by #N` record may carry a non-empty +> explanation after the exact ` — ` delimiter. The record must still begin at the start of +> a line, contain one blocker, and fail closed when malformed. The occupancy and +> revalidation decisions otherwise stand as written. + ## Context Issue #56 asks for two things: a `$seek-quest` skill that recommends the next diff --git a/skills/bounty/SKILL.md b/skills/bounty/SKILL.md index 041da94f..88705692 100644 --- a/skills/bounty/SKILL.md +++ b/skills/bounty/SKILL.md @@ -162,7 +162,15 @@ operator confirmation. report line. Birth labels come from the caller's per-entry state, overriding step 5: `status:blocked` + a `Blocked by #` body line for dependents, `status:needs-triage` for open-question entries (blocked wins when - both apply), else `status:ready` — the same rule recovery applies below. + both apply), else `status:ready` — the same rule recovery applies below. Prefer the bare + dependency record. When its rationale must remain on the same line, use the exact ` — ` + delimiter and non-empty prose. Use one record per blocker, with no leading whitespace; + arbitrary trailing prose and combined references are malformed: + + ```text + Blocked by #123 + Blocked by #123 — the schema change must land first + ``` **Epic-parent recovery.** When the parent carries the `epic` label, its Decomposition section is the authoritative sub-issue list. Enumerate existing native sub-issues diff --git a/skills/quest-log/SKILL.md b/skills/quest-log/SKILL.md index 1c23faea..7e3d32dc 100644 --- a/skills/quest-log/SKILL.md +++ b/skills/quest-log/SKILL.md @@ -59,14 +59,21 @@ Rules: blocker is open or cannot be resolved. Exit is the canonical cleared-dependency edge below; explicit `$sort-board` and `$quest` remain manual fallback edges. - **Cleared-dependency exit edge.** An open, non-epic issue carrying `status:blocked` moves - to `status:ready` only when its body has at least one canonical whole-line - `Blocked by #N` record and every referenced issue resolves closed. `$return-to-town` is + to `status:ready` only when its body has at least one canonical `Blocked by #N` + record and every referenced issue resolves closed. `$return-to-town` is the primary owner after a verified merge and closure. `$resurrection` owns the same - repair edge behind its plan-and-confirm gate. A canonical line is case-sensitive, has no - leading or trailing content, and contains decimal digits after `#`. A line beginning - exactly `Blocked by #` but failing that grammar is malformed and holds the issue blocked; - other prose and every comment are ignored. Open, missing, malformed, or unreadable - references fail closed and produce an actionable report. + repair edge behind its plan-and-confirm gate. A canonical record is case-sensitive, starts + at the beginning of a line, and contains decimal digits after `#`. It either ends after the + issue number or carries a non-empty explanation after the exact delimiter ` — `. Each + blocker gets its own record. A line beginning exactly `Blocked by #` but failing that + grammar is malformed and holds the issue blocked; other prose and every comment are + ignored. Open, missing, malformed, or unreadable references fail closed and produce an + actionable report. Writers prefer the bare form; readers accept both: + + ```text + Blocked by #123 + Blocked by #123 — the schema change must land first + ``` ### Recipe: reconcile cleared dependencies diff --git a/skills/quest-log/assets/cleared-dependencies.sh b/skills/quest-log/assets/cleared-dependencies.sh index 3547eeb3..c98ac083 100755 --- a/skills/quest-log/assets/cleared-dependencies.sh +++ b/skills/quest-log/assets/cleared-dependencies.sh @@ -122,10 +122,11 @@ cleared_dependency_body_verdict() { # repo number body cleared_dependency_reason= cleared_dependency_error=false while IFS= read -r line; do - if [[ $line =~ ^Blocked\ by\ \#([0-9]+)$ ]]; then + if [[ $line =~ ^Blocked\ by\ \#([0-9]+)(\ —\ [^[:space:]].*)?$ ]]; then blockers+=("${BASH_REMATCH[1]}") elif [[ $line == 'Blocked by #'* ]]; then - cleared_dependency_reason="malformed reference on #$number; expected Blocked by #N" + cleared_dependency_reason="malformed reference on #$number;" + cleared_dependency_reason+=" expected Blocked by #N or Blocked by #N — explanation" cleared_dependency_error=true return 1 fi diff --git a/skills/quest-log/assets/profiles/github.sh b/skills/quest-log/assets/profiles/github.sh index 03c2ff74..ac41889f 100644 --- a/skills/quest-log/assets/profiles/github.sh +++ b/skills/quest-log/assets/profiles/github.sh @@ -507,7 +507,8 @@ profile_link_blocks() { # Already linked: return without writing. Skipping the write makes the # operation idempotent in the common case and keeps it out of the # read-modify-write race entirely. - if printf '%s\n' "$body" | rg -q "^Blocked by #$blocker\r?\$"; then + if printf '%s\n' "$body" | + rg -q --no-config "^Blocked by #$blocker( — [^[:space:]].*)?\r?\$"; then printf '{}\n' return 0 fi diff --git a/skills/return-to-town/SKILL.md b/skills/return-to-town/SKILL.md index b6dccd6a..6eacfffa 100644 --- a/skills/return-to-town/SKILL.md +++ b/skills/return-to-town/SKILL.md @@ -179,8 +179,9 @@ After verifying the merged issue is closed, run the `quest-log` skill's canonica recipe in Bash: `bash "$CLAUDE_PLUGIN_ROOT/skills/quest-log/assets/cleared-dependencies.sh" apply `. This is the primary owner of the cleared-dependency `status:blocked → status:ready` edge. Report every readied dependent and every retained dependent with its actionable reason. Do not limit the scan to -the merged issue's prose or comments: the recipe exhaustively evaluates canonical whole-line -`Blocked by #N` records on all open blocked, non-epic issues. A per-dependent failure does +the merged issue's prose or comments: the recipe exhaustively evaluates canonical +`Blocked by #N` records, including records with an exact ` — ` explanation suffix, on all +open blocked, non-epic issues. A per-dependent failure does not prevent other dependents from being evaluated. ## After a merge (yours or the user's) diff --git a/skills/saga/SKILL.md b/skills/saga/SKILL.md index 1f5bd30a..415b6421 100644 --- a/skills/saga/SKILL.md +++ b/skills/saga/SKILL.md @@ -95,7 +95,10 @@ the challenge summary. numbers are already known — so no crash window exists before their links land. 2. **One topological pass over all entries** — adopted and created alike, blockers before dependents, so every `Blocked by #` resolves to an already-numbered - sibling. Per entry, in graph order: + sibling. The accepted dependency-record forms are the bare `Blocked by #` and + `Blocked by # — ` forms; prefer the bare form, use the exact + delimiter for an explanation, and put each blocker on its own line with no leading + whitespace. Per entry, in graph order: - **Adopted entry:** link via the `sub_issues` API. If it sits in a dependent position, apply `status:blocked` + append a `Blocked by #` line (the stated, confirmed exception to leave-untouched) — its blocker was handled earlier in the diff --git a/skills/seek-quest/SKILL.md b/skills/seek-quest/SKILL.md index d71d3de6..7135b12d 100644 --- a/skills/seek-quest/SKILL.md +++ b/skills/seek-quest/SKILL.md @@ -76,9 +76,10 @@ Input: an optional caller-supplied risk allowlist and/or effort allowlist - For each surviving candidate, scan its `body` line by line for the `quest-log` dependency contract's three states: - **No line begins `Blocked by #`.** This check passes. - - **At least one line is a canonical whole-line `Blocked by #M` - record** (case-sensitive, no leading/trailing content, decimal - digits after `#`). Resolve every referenced `M` with + - **At least one line contains a canonical `Blocked by #M` record.** The record is + case-sensitive, starts at the beginning of the line, contains decimal digits after + `#`, and either ends after `M` or carries non-empty prose after the exact ` — ` + delimiter. Resolve every referenced `M` with `gh issue view M --repo --json state`. Drop the candidate unless every canonical reference resolves closed. - **A line begins exactly `Blocked by #` but fails that grammar.** diff --git a/skills/sort-board/SKILL.md b/skills/sort-board/SKILL.md index c83becb7..c2250f04 100644 --- a/skills/sort-board/SKILL.md +++ b/skills/sort-board/SKILL.md @@ -144,8 +144,10 @@ it, so no swap arises there. what the issue text actually states. Evaluate a sweep's blocked candidates under the `quest-log` canonical - cleared-dependency contract. Consider only whole-line `Blocked by #N` records in the - issue body, never comments, and resolve every distinct referenced issue with + cleared-dependency contract. Consider `Blocked by #N` records in the issue body, never + comments: a record starts at the beginning of a line and either ends after the decimal + issue number or carries non-empty prose after the exact ` — ` delimiter. Resolve every + distinct referenced issue with `gh issue view --repo --json state`. An open, missing, or unreadable blocker, a malformed `Blocked by #` record, or a body with no canonical references retains `status:blocked`; report the reason and propose no status swap. Once at least one diff --git a/tests/fixtures/quest-log/cleared-dependencies-test.sh b/tests/fixtures/quest-log/cleared-dependencies-test.sh index 41a25410..4a3e50bc 100755 --- a/tests/fixtures/quest-log/cleared-dependencies-test.sh +++ b/tests/fixtures/quest-log/cleared-dependencies-test.sh @@ -34,7 +34,7 @@ gh() { printf 'API \033[31mdenied\n' >&2 return 1 fi - printf '%s\n' '[[{"number":101,"state":"open","body":"Blocked by #1","labels":[{"name":"status:blocked"},{"name":"status:in-progress"}]},{"number":102,"state":"open","body":"Blocked by #1\nBlocked by #2","labels":[{"name":"status:blocked"}]},{"number":103,"state":"open","body":"Blocked by #abc","labels":[{"name":"status:blocked"}]},{"number":104,"state":"open","body":"Blocked by #1","labels":[{"name":"status:blocked"},{"name":"epic"}]}],[{"number":106,"state":"open","body":"Blocked by #1","labels":[{"name":"status:blocked"}]}]]' + printf '%s\n' '[[{"number":101,"state":"open","body":"Blocked by #1","labels":[{"name":"status:blocked"},{"name":"status:in-progress"}]},{"number":102,"state":"open","body":"Blocked by #1\nBlocked by #2","labels":[{"name":"status:blocked"}]},{"number":103,"state":"open","body":"Blocked by #abc","labels":[{"name":"status:blocked"}]},{"number":104,"state":"open","body":"Blocked by #1","labels":[{"name":"status:blocked"},{"name":"epic"}]}],[{"number":106,"state":"open","body":"Blocked by #1 — prerequisite lands first","labels":[{"name":"status:blocked"}]}]]' return fi if [[ $1 == label && $2 == create ]]; then @@ -116,6 +116,12 @@ reset_cleared_dependency_cache cleared_dependency_body_verdict owner/repo 10 $'Blocked by #1\nBlocked by #1' || fail 'multiple closed canonical blockers should clear' [[ $(wc -l <"$blocker_log") -eq 1 ]] || fail 'duplicate blockers were not deduplicated' +reset_cleared_dependency_cache +: >"$blocker_log" +cleared_dependency_body_verdict owner/repo 10 \ + 'Blocked by #1 — required registration point lands first' || + fail 'an annotated closed blocker should clear' +[[ $(cat "$blocker_log") == 1 ]] || fail 'annotated record did not resolve its blocker id' if cleared_dependency_body_verdict owner/repo 10 $'Blocked by #1\nBlocked by #2'; then fail 'an open blocker must retain the dependent' fi @@ -123,7 +129,20 @@ fi # shellcheck disable=SC2154 [[ $cleared_dependency_reason == 'open blocker #2 retains #10' ]] || fail 'open-blocker report is not actionable' -for fixture in 'Blocked by #404' 'Blocked by #500' 'Blocked by #abc' ' Blocked by #1'; do +reset_cleared_dependency_cache +if cleared_dependency_body_verdict owner/repo 10 'Blocked by #2 — prerequisite is pending'; then + fail 'an annotated open blocker must retain the dependent' +fi +[[ $cleared_dependency_reason == 'open blocker #2 retains #10' ]] || + fail 'annotated open-blocker report is not actionable' +for fixture in \ + 'Blocked by #404' \ + 'Blocked by #500' \ + 'Blocked by #abc' \ + ' Blocked by #1' \ + 'Blocked by #1 explanation without delimiter' \ + 'Blocked by #1 —' \ + 'Blocked by #1 — explanation after two spaces'; do if cleared_dependency_body_verdict owner/repo 10 "$fixture"; then fail "invalid dependency record cleared: $fixture" fi diff --git a/tests/fixtures/quest-log/tracker-test.sh b/tests/fixtures/quest-log/tracker-test.sh index 3752d371..567ec4fe 100755 --- a/tests/fixtures/quest-log/tracker-test.sh +++ b/tests/fixtures/quest-log/tracker-test.sh @@ -707,7 +707,13 @@ fi if [[ $1 == issue && $2 == view ]]; then case " $* " in *" comments "*) printf '%s\n' '{"comments":[{"body":"first"},{"body":"second"}]}' ;; - *" body "*) printf 'existing body\r\nBlocked by #7\r\n' ;; + *" body "*) + if [[ ${GH_ANNOTATED_BODY:-false} == true ]]; then + printf 'existing body\r\nBlocked by #7 — prerequisite lands first\r\n' + else + printf 'existing body\r\nBlocked by #7\r\n' + fi + ;; *) printf '%s\n' '{"number":101,"title":"T","body":"B","labels":[],"parent":null,"state":"OPEN","url":"u","updatedAt":"2026-01-01T00:00:00Z"}' ;; esac exit 0 @@ -771,6 +777,16 @@ edits=$(rg -c '^issue edit ' "$sandbox/calls" || true) [[ ${edits:-0} == 0 ]] || fail 'link-blocks rewrote a body that already carried the link (CRLF guard)' +# An annotated dependency record is the same link and must also be idempotent. +: >"$sandbox/calls" +GH_ANNOTATED_BODY=true GH_CALL_LOG="$sandbox/calls" PATH="$sandbox/bin:$PATH" \ + "$tracker" link-blocks --profile github --target example/repo 7 101 \ + >"$sandbox/out" 2>"$sandbox/err" || + fail 'link-blocks rejected an annotated existing link' +edits=$(rg -c '^issue edit ' "$sandbox/calls" || true) +[[ ${edits:-0} == 0 ]] || + fail 'link-blocks duplicated an annotated existing link' + # view now carries a real updated timestamp rather than a permanent null. run_op view-updated -- view --profile github --target example/repo 101 jq -e '.updated == "2026-01-01T00:00:00Z"' >/dev/null <"$sandbox/out" ||