From 0c6bcd2b1d71f795b747932ab5ff2c4cf870d3f1 Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Mon, 7 Sep 2026 12:30:08 -0700 Subject: [PATCH 01/17] docs: record v0.29.0 release in primer Catch-up: tag v0.29.0 is pushed and the GitHub Release is live. Co-authored-by: Cursor --- .session-continuity/SESSION_PRIMER.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.session-continuity/SESSION_PRIMER.md b/.session-continuity/SESSION_PRIMER.md index 07a2e7a..e31683f 100644 --- a/.session-continuity/SESSION_PRIMER.md +++ b/.session-continuity/SESSION_PRIMER.md @@ -20,6 +20,7 @@ rarely. ## Current state +- **v0.29.0 released** — GitHub Issues labeled `backlog` replace `.session-continuity/BACKLOG.md`. Merged to `main` via PR #47 (`999e1f2`), tag `v0.29.0`, release https://github.com/talgolan/session-continuity/releases/tag/v0.29.0. Identity is `#N`; LEARNINGS stays a local file. This repo's live items are issues #35–#45. - **v0.27.1 — gate-escape self-condemnation hazard fixed, merged to `main` via PR #33 (`dd84ee7`) and released (tag `v0.27.1`, bumped in `c00cf17`).** Backlog: architect-workbench's `4e81` / this repo's own @@ -620,11 +621,11 @@ rarely. **Current `git log --oneline -5` (primary branch):** ``` +999e1f2 Merge pull request #47 from talgolan/feat/github-issues-backlog +dc9c788 feat: replace BACKLOG.md with GitHub Issues labeled backlog (v0.29.0) f79eb10 chore: bump to 0.28.0 — shared mechanics library (perf-log mark/since, primer-status.sh) 6b70556 Merge pull request #34 from talgolan/feat/shared-mechanics-library ff6b8c8 test: bump stale count-entries-smoke pins to real BACKLOG/LEARNINGS counts (16/18) -c8e3670 docs: Phase 3 doc pointers and changelog entry for the shared mechanics library -7358719 refactor: end-session.md collapses its four epoch-subtraction blocks to mark/since, fixing step-4-agent-active's start_epoch scope bug (52dc) ``` Regenerate this block whenever you commit — see From 16f8d06acb0e72135b8cb31429753dad96f872e6 Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Mon, 7 Sep 2026 12:44:28 -0700 Subject: [PATCH 02/17] fix: recognize GitHub Enterprise Server origins for the backlog queue backlog-issues.sh string-matched the origin URL for a literal "github.com", so any GHE-hosted repo (e.g. a company's git.company.internal) silently reported the backlog queue as unavailable even with gh fully authenticated against that host. Replace the string match with a host-auth check: extract the origin's hostname and run `gh auth status --hostname `, so any host gh is authenticated for works without the plugin hardcoding hostnames. Docs (README, PRIVACY, SKILL.md, doctor's report copy) updated to match; smoke tests extended to cover a GHE origin and an unauthenticated-host origin. --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 5 ++++ PRIVACY.md | 2 +- README.md | 4 +-- commands/doctor.md | 4 +-- hooks/lib/backlog-issues.sh | 20 +++++++++------ .../validation/2026-09-02-render-smoke.zsh | 2 +- .../2026-09-07-backlog-issues-smoke.zsh | 25 +++++++++++++------ skills/session-continuity/SKILL.md | 2 +- 9 files changed, 44 insertions(+), 22 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b5219f2..f2b5696 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session-continuity", - "version": "0.29.0", + "version": "0.29.1", "description": "Cross-session memory for Claude Code projects via four in-repo docs: SESSION_PRIMER.md (current state), PROJECT_CONTEXT.md (stable repo context), ROADMAP.md (strategic direction), and LEARNINGS.md (hard-won bugs), plus GitHub Issues labeled backlog for the work queue.", "author": { "name": "Tal Golan" diff --git a/CHANGELOG.md b/CHANGELOG.md index cc3f002..550a665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.29.1] — 2026-09-07 + +### Fixed +- **The GitHub Issues backlog now works on GitHub Enterprise Server, not just github.com.** `hooks/lib/backlog-issues.sh` no longer string-matches the origin URL for a literal `github.com`; it extracts the origin's hostname and checks `gh auth status --hostname `, so any host `gh` is authenticated against — github.com or a GHE instance — works without the plugin needing to know your GHE hostname in advance. Docs updated to match (README, PRIVACY, SKILL.md, `doctor`'s report copy). + ## [0.29.0] — 2026-09-07 ### Changed diff --git a/PRIVACY.md b/PRIVACY.md index 26eb2b9..496a724 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -45,7 +45,7 @@ Two classes of call, both to GitHub. **What data is sent:** Issue titles and bodies you supply; your GitHub credentials via `gh`; the repository identity inferred from `origin`. -**How to disable:** Use a non-github.com origin, or do not install/auth `gh`. The queue surface no-ops and `doctor` warns. There is no markdown fallback. +**How to disable:** Do not install/auth `gh` for the origin's host. The queue surface no-ops and `doctor` warns. There is no markdown fallback. ## What the plugin does **not** do diff --git a/README.md b/README.md index 30f51a1..48744f7 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ The four files ship as templates. The backlog does not — it lives on GitHub. One command, six behaviors, dispatched on the repo's current state: -- **No primer yet** → copies the templates into `.session-continuity/`, fills every placeholder it can derive (project name, latest commits, working directory, test command), asks you for the rest, files named follow-ups as GitHub Issues labeled `backlog` when origin is github.com, and stages four files. Any field you skip becomes `TBD` rather than a leftover `{{PLACEHOLDER}}`. +- **No primer yet** → copies the templates into `.session-continuity/`, fills every placeholder it can derive (project name, latest commits, working directory, test command), asks you for the rest, files named follow-ups as GitHub Issues labeled `backlog` when `gh` is authenticated for the origin's host (github.com or a GitHub Enterprise Server instance), and stages four files. Any field you skip becomes `TBD` rather than a leftover `{{PLACEHOLDER}}`. - **Primer exists but not yet split** → partitions its stable sections (layout, conventions, module table, "where to look for what") into a new `.session-continuity/PROJECT_CONTEXT.md`, leaving the primer with only the volatile shortlist. One-time content move, no file move. -- **Primer has an inline Outstanding items section, or a leftover `OUTSTANDING_ITEMS.md` / `BACKLOG.md`** → migrates that markdown queue to GitHub Issues labeled `backlog` when origin is github.com, then deletes the file. Without a github.com origin, the file is left as a fossil and `doctor` warns. +- **Primer has an inline Outstanding items section, or a leftover `OUTSTANDING_ITEMS.md` / `BACKLOG.md`** → migrates that markdown queue to GitHub Issues labeled `backlog` when `gh` is authenticated for the origin's host, then deletes the file. Without that, the file is left as a fossil and `doctor` warns. - **Primer exists but drifted** → regenerates the `git log --oneline -5` block, re-runs the primer's test commands (retrying flaky suites up to three times so a single bad sample doesn't cry wolf), surfaces every commit since the last refresh as a candidate, and prompts you for backlog changes before staging. - **Primer current** → reports a four-line status (HEAD, last refresh, backlog count, learnings count) and exits without touching anything. diff --git a/commands/doctor.md b/commands/doctor.md index 47d9c4e..41c87e3 100644 --- a/commands/doctor.md +++ b/commands/doctor.md @@ -74,13 +74,13 @@ Work through the six rows below using the output above. Never invent a result fo - Plugin mode: ✓ if `HOOKS_JSON_EXISTS=1` (Claude Code auto-wires this when the plugin is enabled — this is a sanity check that the install isn't partial/corrupted, not proof the user configured anything). ⚠️ if `HOOKS_JSON_EXISTS=0` — the plugin directory is missing `hooks/hooks.json`; reinstalling the plugin is the fix. - Vendored mode: grep the `.claude/settings.json` content captured above for the hook script names (`session-start.sh`, `learnings-surface.sh`, etc.). ✓ if at least `session-start.sh` and `learnings-surface.sh` appear (the two hooks a vendored install needs most — the primer reminder and the retrieval hook). ⚠️ listing which expected hook names are absent, with a pointer to `SKILL.md`'s hooks section for the entries to copy in. -3. **Four `.session-continuity/` files exist; primer not stale.** ✓/⚠️ per file from the `EXISTS`/`MISSING` lines (`SESSION_PRIMER.md`, `PROJECT_CONTEXT.md`, `ROADMAP.md`, `LEARNINGS.md`). `BACKLOG.md=FOSSIL` is a leftover markdown queue — ⚠️ "fossil BACKLOG.md; run `/session-continuity:primer` to migrate to GitHub Issues if origin is github.com." For `SESSION_PRIMER.md` specifically, if it exists, also compare its own `git log --oneline -5` block (read the file) against the `git log --oneline -5` output captured above — mismatch means ⚠️ stale, "run `/session-continuity:primer` to refresh." This is the only file with an objective staleness signal in this repo; the other three don't get a staleness check here, only an existence check. +3. **Four `.session-continuity/` files exist; primer not stale.** ✓/⚠️ per file from the `EXISTS`/`MISSING` lines (`SESSION_PRIMER.md`, `PROJECT_CONTEXT.md`, `ROADMAP.md`, `LEARNINGS.md`). `BACKLOG.md=FOSSIL` is a leftover markdown queue — ⚠️ "fossil BACKLOG.md; run `/session-continuity:primer` to migrate to GitHub Issues if `gh` is authenticated for the origin's host." For `SESSION_PRIMER.md` specifically, if it exists, also compare its own `git log --oneline -5` block (read the file) against the `git log --oneline -5` output captured above — mismatch means ⚠️ stale, "run `/session-continuity:primer` to refresh." This is the only file with an objective staleness signal in this repo; the other three don't get a staleness check here, only an existence check. 4. **`CLAUDE_PLUGIN_ROOT` resolves and isn't stale.** Skip this row entirely in vendored mode (nothing to check). In plugin mode: ✓ if `ROOT_EXISTS=1`. Then check staleness — from the `ls "$CACHE_PARENT"` output, if it lists sibling version directories, compare the resolved version (parsed from `plugin.json` above) against the highest version number listed. If a newer one exists: ⚠️ "resolved root is v``, but v`` is already installed in the cache — this session started before the update landed; restart the session to pick it up." If they match, or the cache-parent listing wasn't available (different install layout), ✓ with a note that the check was skipped when applicable — don't fail the row over a probe that simply didn't apply. 5. **Gate scripts executable.** Skip in vendored mode (no resolved root to check against). In plugin mode, one sub-row per `EXEC`/`NOEXEC:` line captured above. ✓ if all are `EXEC`. For each `NOEXEC:`, ⚠️ with the exact fix: `chmod +x `. -6. **GitHub backlog.** Warning-level, not a hard install break. ✓ if `GH=PRESENT`, `GH_AUTH=OK`, origin contains `github.com`, and `BACKLOG_COUNT` is an integer (including 0). ⚠️ listing which of those failed, and "run `/session-continuity:doctor` after `gh auth login`" or "queue inactive until origin is github.com." One sentence: backlog titles and bodies are sent to GitHub when filed; public repo means public issues. +6. **GitHub backlog.** Warning-level, not a hard install break. ✓ if `GH=PRESENT`, `GH_AUTH=OK`, and `BACKLOG_COUNT` is an integer (including 0) — works against github.com or any GitHub Enterprise Server host, since the underlying check is "`gh` has auth for the origin's host," not a literal `github.com` string match. ⚠️ listing which of those failed, and "run `/session-continuity:doctor` after `gh auth login --hostname `" or "queue inactive — `gh` has no auth for this origin's host." One sentence: backlog titles and bodies are sent to GitHub (or your GHE instance) when filed; public repo means public issues. **List every missing file, every missing hook name, and every non-executable script — do not summarize, filter, or pick a "primary" one.** If two gate scripts are missing their exec bit, the row lists both `chmod +x` commands, not one. diff --git a/hooks/lib/backlog-issues.sh b/hooks/lib/backlog-issues.sh index cb9c706..39bd699 100755 --- a/hooks/lib/backlog-issues.sh +++ b/hooks/lib/backlog-issues.sh @@ -9,8 +9,12 @@ # List mode prints one line per open issue: # N. #NUMBER Title # Empty-but-working: "No open backlog issues." -# Operational failure (no git, origin not github.com, gh missing/fail/timeout): -# one warning line, exit 0. +# Operational failure (no git, origin host gh isn't authenticated for, +# gh missing/fail/timeout): one warning line, exit 0. +# +# Works against github.com or any GitHub Enterprise Server host — the +# check is "does `gh` have auth for this remote's host", not a literal +# "github.com" string match, so it doesn't need to know your GHE hostname. # # --count prints an integer, or ? on failure, or 0 when the list is empty. # @@ -20,7 +24,7 @@ set -u -WARN='Backlog unavailable: GitHub Issues required (gh, github.com remote, auth). Run /session-continuity:doctor.' +WARN="Backlog unavailable: GitHub Issues required (gh, authenticated for this remote's host). Run /session-continuity:doctor." count_only=0 if [[ "${1:-}" == "--count" ]]; then @@ -45,16 +49,18 @@ else fi url="$(git -C "$DIR" remote get-url origin 2>/dev/null || true)" -case "$url" in - *github.com*) ;; - *) fail_open ;; -esac +[[ -z "$url" ]] && fail_open + +host="$(printf '%s' "$url" | sed -E 's#^(https?://|git@|ssh://git@)##; s#[:/].*##')" +[[ -z "$host" ]] && fail_open gh_bin="${GH_BIN:-gh}" if [[ ! -x "$gh_bin" ]] && ! command -v "$gh_bin" >/dev/null 2>&1; then fail_open fi +"$gh_bin" auth status --hostname "$host" >/dev/null 2>&1 || fail_open + timeout_s="${BACKLOG_ISSUES_TIMEOUT:-3}" raw="$( timeout "$timeout_s" "$gh_bin" issue list \ diff --git a/meta/superpowers/validation/2026-09-02-render-smoke.zsh b/meta/superpowers/validation/2026-09-02-render-smoke.zsh index f4d80b8..7bef896 100755 --- a/meta/superpowers/validation/2026-09-02-render-smoke.zsh +++ b/meta/superpowers/validation/2026-09-02-render-smoke.zsh @@ -17,7 +17,7 @@ pass=0; fail=0 ok() { print -P "%F{green}✓%f $1"; (( pass++ )); return 0; } bad() { print -P "%F{red}✗%f $1"; (( fail++ )); return 0; } -WARN='Backlog unavailable: GitHub Issues required (gh, github.com remote, auth). Run /session-continuity:doctor.' +WARN="Backlog unavailable: GitHub Issues required (gh, authenticated for this remote's host). Run /session-continuity:doctor." work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT diff --git a/meta/superpowers/validation/2026-09-07-backlog-issues-smoke.zsh b/meta/superpowers/validation/2026-09-07-backlog-issues-smoke.zsh index 7647b6e..bed09a1 100644 --- a/meta/superpowers/validation/2026-09-07-backlog-issues-smoke.zsh +++ b/meta/superpowers/validation/2026-09-07-backlog-issues-smoke.zsh @@ -11,7 +11,7 @@ pass=0; fail=0 ok() { print -P "%F{green}✓%f $1"; (( pass++ )); return 0; } bad() { print -P "%F{red}✗%f $1"; (( fail++ )); return 0; } -WARN='Backlog unavailable: GitHub Issues required (gh, github.com remote, auth). Run /session-continuity:doctor.' +WARN="Backlog unavailable: GitHub Issues required (gh, authenticated for this remote's host). Run /session-continuity:doctor." work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT @@ -27,6 +27,10 @@ git -C "$work" remote add origin "https://github.com/example/repo.git" mock="$work/fake-gh" cat > "$mock" <<'EOF' #!/usr/bin/env bash +if [[ "$1" == "auth" && "$2" == "status" ]]; then + [[ "${GH_MOCK_AUTH_FAIL:-}" == "1" ]] && exit 1 + exit 0 +fi if [[ "${GH_MOCK_FAIL:-}" == "1" ]]; then echo "boom" >&2 exit 1 @@ -63,14 +67,14 @@ out="$(bash "$helper" "$work")" cnt="$(bash "$helper" --count "$work")" [[ "$cnt" == "0" ]] && ok "--count: 0 when empty" || bad "--count empty: got '$cnt'" -# --- origin not github.com ----------------------------------------------- -git -C "$work" remote set-url origin "https://gitlab.com/example/repo.git" +# --- origin host gh has no auth for --------------------------------------- +export GH_MOCK_AUTH_FAIL=1 out="$(bash "$helper" "$work")" cnt="$(bash "$helper" --count "$work")" -[[ "$out" == "$WARN" ]] && ok "non-github origin: warning line" \ - || bad "non-github list: got '$out'" -[[ "$cnt" == "?" ]] && ok "non-github origin: count ?" || bad "non-github count: got '$cnt'" -git -C "$work" remote set-url origin "https://github.com/example/repo.git" +[[ "$out" == "$WARN" ]] && ok "unauthenticated host: warning line" \ + || bad "unauthenticated host list: got '$out'" +[[ "$cnt" == "?" ]] && ok "unauthenticated host: count ?" || bad "unauthenticated host count: got '$cnt'" +unset GH_MOCK_AUTH_FAIL # --- ssh github origin still works --------------------------------------- git -C "$work" remote set-url origin "git@github.com:example/repo.git" @@ -80,6 +84,13 @@ out="$(bash "$helper" "$work")" || bad "ssh origin: got '$out'" git -C "$work" remote set-url origin "https://github.com/example/repo.git" +# --- GitHub Enterprise Server origin accepted (any hostname, if gh has auth) --- +git -C "$work" remote set-url origin "https://git.example-enterprise.internal/example/repo.git" +out="$(bash "$helper" "$work")" +[[ "$out" == "$expect" ]] && ok "GHE origin accepted (gh authenticated for its host)" \ + || bad "GHE origin: got '$out'" +git -C "$work" remote set-url origin "https://github.com/example/repo.git" + # --- gh missing ---------------------------------------------------------- out="$(GH_BIN="/nonexistent/gh-binary-for-test" bash "$helper" "$work")" cnt="$(GH_BIN="/nonexistent/gh-binary-for-test" bash "$helper" --count "$work")" diff --git a/skills/session-continuity/SKILL.md b/skills/session-continuity/SKILL.md index fd0d07c..e413be7 100644 --- a/skills/session-continuity/SKILL.md +++ b/skills/session-continuity/SKILL.md @@ -56,7 +56,7 @@ Invoke when: ## Quick start (new project) -Run `/session-continuity:primer`. The command detects that no primer exists, copies four templates from `${CLAUDE_PLUGIN_ROOT}/skills/session-continuity/templates/` into the project's `.session-continuity/`, fills in every placeholder it can derive automatically (project name, latest commits, working directory, test command), prompts the user for anything left blank, files any named follow-ups as GitHub Issues labeled `backlog` when origin is github.com, and stages all four files. It does not commit. +Run `/session-continuity:primer`. The command detects that no primer exists, copies four templates from `${CLAUDE_PLUGIN_ROOT}/skills/session-continuity/templates/` into the project's `.session-continuity/`, fills in every placeholder it can derive automatically (project name, latest commits, working directory, test command), prompts the user for anything left blank, files any named follow-ups as GitHub Issues labeled `backlog` when `gh` is authenticated for the origin's host (github.com or a GitHub Enterprise Server instance), and stages all four files. It does not commit. After the user commits, remind them of the two maintenance rules: refresh the primer alongside substantive commits (stage the refresh in the same commit as the real change — do not commit the primer by itself), and add a LEARNINGS entry for every bug that took 15+ minutes to diagnose. From 8b11c4696b5f808cf484e7b058f882f839b4f0cd Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 10:04:55 -0700 Subject: [PATCH 03/17] docs: mark docguard generalization implemented, correct hard-block assumption --- .../recommendations/docguard-design-sketch.md | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/meta/superpowers/recommendations/docguard-design-sketch.md b/meta/superpowers/recommendations/docguard-design-sketch.md index b4d5310..3befeda 100644 --- a/meta/superpowers/recommendations/docguard-design-sketch.md +++ b/meta/superpowers/recommendations/docguard-design-sketch.md @@ -20,18 +20,32 @@ claim in a repo's shipped docs must match the actual repo state at commit time — enforced at the gate that runs on every commit, not left to whoever's authoring the next PR to remember. -## Design sketch (not built — lives outside any git repo) - -Generalize the existing `"NN pass"` special case into a declarative -per-repo config (e.g. `.docguard.yml`): a list of `{doc: , -claim_pattern: , actual_command: }` entries. On -each staged doc file matching an entry, extract the claimed value, run -the command, hard-block on mismatch — reusing the same code path and -escape-hatch pattern (`DOCGUARD_SKIP_COUNT=1`, generalized) the pass-count -check already has, rather than inventing a second mechanism. - -## Why not build it now - -Touches `~/.githooks` and `~/.claude/hooks`, not this repo — changes -behavior for every git commit on the machine, not just this project. -Bigger blast radius, deserves its own session and explicit go-ahead. +## Implemented (2026-09-08) + +Built in `~/.githooks` — machine-wide scope, per the "own session, explicit +go-ahead" note below, obtained 2026-09-08. Note: `~/.githooks` turned out +NOT to be its own git repo during implementation — it's a subdirectory of a +personal dotfiles repo rooted at the user's home directory (blanket +`.git/info/exclude` plus per-file `git add -f`). `post-merge` itself had +never been tracked by git before this work force-added it for the first +time. This doesn't change what shipped, but it does mean the branch this +work landed on can't simply be merged to that repo's `main` without an +explicit reconciliation step (git will try to check out a newly-tracked +`post-merge` over the path where the real live hook — untracked — already +sits). + +- `~/.githooks/lib/docguard-yaml.sh` — hand-rolled parser (no `yq` + dependency) for a fixed, flat `.docguard.yml` schema: a list of + `{doc, claim_pattern, actual_command}` triples. +- `~/.githooks/post-merge` — if a repo's root has a `.docguard.yml`, its + entries fully replace the old hard-coded primer-pass-count check; repos + without one see no behavior change. +- Correction to this sketch's original assumption: the check is + **advisory-only, not a hard block**. Enforcement already lives in + `post-merge` (moved there from `pre-commit` because PRs merge + server-side), and git ignores a post-merge hook's exit code — it cannot + block or undo a merge that already landed. +- Escape hatch unchanged: `DOCGUARD_SKIP_COUNT=1` skips the check, config- + driven or legacy. +- Plan: `meta/superpowers/plans/2026-09-08-docguard-generalization.md`. +- Closes GitHub issue #38. From d36d4725c313999eca025cb82be1a4038c2a087b Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 10:14:47 -0700 Subject: [PATCH 04/17] docs: don't claim GitHub issue #38 is closed until it actually is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final whole-branch review flagged this as a minor doc-accuracy issue — closing the issue is a separate follow-up step, not part of this commit. --- meta/superpowers/recommendations/docguard-design-sketch.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/meta/superpowers/recommendations/docguard-design-sketch.md b/meta/superpowers/recommendations/docguard-design-sketch.md index 3befeda..179b05d 100644 --- a/meta/superpowers/recommendations/docguard-design-sketch.md +++ b/meta/superpowers/recommendations/docguard-design-sketch.md @@ -48,4 +48,5 @@ sits). - Escape hatch unchanged: `DOCGUARD_SKIP_COUNT=1` skips the check, config- driven or legacy. - Plan: `meta/superpowers/plans/2026-09-08-docguard-generalization.md`. -- Closes GitHub issue #38. +- Implements GitHub issue #38 (closing it is a separate follow-up step, not + yet done as of this commit). From 827a3e56dc5569689ae6820a8cdd611a590c5c0d Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 11:03:36 -0700 Subject: [PATCH 05/17] docs: refresh primer and LEARNINGS after PR #49 merge Records PR #49 as merged, updates the git-log block, and captures two LEARNINGS entries from the docguard-generalization session: the worktree-isolation guard's broader non-git trigger surface (#17), and verifying subagent commit claims independently rather than trusting the status string (#18). --- .session-continuity/LEARNINGS.md | 38 +++++++++++++++++++++++++++ .session-continuity/SESSION_PRIMER.md | 27 ++++++++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/.session-continuity/LEARNINGS.md b/.session-continuity/LEARNINGS.md index d25c417..8b47f6c 100644 --- a/.session-continuity/LEARNINGS.md +++ b/.session-continuity/LEARNINGS.md @@ -16,6 +16,7 @@ within each group. each time it appends a new entry. --> +- A subagent reported commit `9b2f504` in repo A; `git rev-parse --verify` in… — #18 - After GitHub squash-merged PR #20, `git merge --ff-only — #15 - Clean-machine acceptance test for v0.4.0. `/session-continuity:primer` ran init mode cleanly, asked for… — #4 - Denied again, on the same file, despite the escape hatch already being… — #13 @@ -27,6 +28,7 @@ within each group. - Real invocation of `/session-continuity:primer` after installing the change failed every one of… — #11 - The Bash call is refused outright: "This session is isolated in the… — #8 - The first v0.2.0 release fired the workflow, created the GitHub Release, but… — #2 +- The same "too complex to verify that it stays inside the worktree"… — #17 - The self-gate check returned rc=0 (allowed) — but via the escape hatch… — #7 - The `/session-continuity:end-session` smoke test had two staged files (primer + `src/foo.js`). The… — #3 - The smoke test written for exactly this case (`Smoke: N/A deferred, this… — #16 @@ -37,6 +39,28 @@ within each group. ## Claude Code plugin mechanics +### 17. Worktree-isolation guard blocks any "too complex" command, not just `git -C` or compound git chains +Slug: worktree-guard-blocks-non-git-commands +Trigger: Bash /<\(|<<['"]?[A-Za-z]/ +Occurrence count: 3 of 3 +Invariant: Inside a worktree-isolated session, every Bash call is one +plain single-statement command targeting the current directory — no +`&&`/`;`/multi-line chains, no `<(...)` process substitution, no +heredocs, and no `git -C` — regardless of whether git is involved at +all. When multiple statements are genuinely needed in sequence, write +them to a script file (`Write` + `bash /tmp/script.sh`) instead of +one compound Bash call. + +**The trap.** [[worktree-compound-commands-blocked]] already covers `git -C` and `&&`/`;` chains. It's easy to assume the guard is git-specific and keep using `diff <(...) <(...)`, multi-line heredocs, or a `source`+function-call block for pure-bash work with zero git involvement. + +**Symptom.** The same "too complex to verify that it stays inside the worktree" refusal fires on plain `diff`/`sed`/`source` commands using process substitution or heredocs — no `git` anywhere in the command. Recurred 8 times over 36 minutes in one session. + +**Fix.** Split into plain single-statement commands (one command per Bash call, no `<(...)`, no heredoc, no `&&`/`;`), or when the logic genuinely needs several statements in sequence, write it to a script file with `Write` and run `bash /tmp/script.sh` as a single plain command. + +**Diagnostic signal** *(optional)*. Bash error text containing "too complex to verify that it stays inside the worktree" on a command with no `git` in it at all. + +--- + ### 11. `$CLAUDE_PLUGIN_ROOT` inside a bash fence in a skill/command file is never resolved — only the braced `${CLAUDE_PLUGIN_ROOT}` form is Slug: plugin-root-brace-required Trigger: Write|Edit /\$CLAUDE_PLUGIN_ROOT\// @@ -318,6 +342,20 @@ parsed structure, never on a substring of serialized output. ## Git / release mechanics +### 18. A subagent's "DONE, commit ``" can be a real commit — in the wrong repo +Slug: verify-subagent-commit-independently +Trigger: * + +**The trap.** When orchestrating subagents across two related git repos/worktrees in one session, it's tempting to trust a resumed subagent's self-report of a commit hash once it "looks real" (right format, plausible message) — especially after a first check already caught it lying once, making a second check feel redundant. + +**Symptom.** A subagent reported commit `9b2f504` in repo A; `git rev-parse --verify` in repo A correctly said it didn't exist, so the report was flagged as fabricated. Two tasks later, the *exact same hash* turned up as the real, current `HEAD` of a completely different repo (repo B) — the subagent's `git commit` had actually succeeded, just against the wrong repo, most likely due to a cwd reset across a resumed-agent tool call. The "fabricated" commit was real; it just wasn't where anyone was looking. + +**Fix.** Independently verify every subagent-claimed commit with `git rev-parse --show-toplevel` (confirm the repo) plus `git rev-parse --verify ` and `git log --oneline`/`git status --porcelain` (confirm the commit and a clean tree) — in every worktree that could plausibly have received it, not just the one it was supposed to go to. A "not found here" result answers only "not here," never "doesn't exist." + +**Diagnostic signal** *(optional)*. A claimed hash that fails `git rev-parse --verify` in the expected repo — before concluding "fabricated," check whether it exists as real `HEAD` in any other repo/worktree this session touched. + +--- + ### 15. Squash-merging a branch descended from an unpushed local commit orphans that commit — and any tag pointing at it Trigger: Bash /gh pr merge.*--squash/ Slug: squash-merge-orphans-unpushed-tag diff --git a/.session-continuity/SESSION_PRIMER.md b/.session-continuity/SESSION_PRIMER.md index e31683f..66c9620 100644 --- a/.session-continuity/SESSION_PRIMER.md +++ b/.session-continuity/SESSION_PRIMER.md @@ -20,6 +20,24 @@ rarely. ## Current state +- **Backlog #38 (docguard generalization) — this repo's side merged, the + actual mechanism not yet activated.** PR + https://github.com/talgolan/session-continuity/pull/49 (merged + 2026-09-08) updates this repo's design doc + (`meta/superpowers/recommendations/docguard-design-sketch.md`) to record + the mechanism as built. The actual code — a hand-rolled `.docguard.yml` + parser and `~/.githooks/post-merge` wiring — lives on branch + `docguard-generalization` in `~/.githooks`, a *separate* git repo + (discovered mid-session: `~/.githooks` is not its own repo, it's a + subdirectory of a personal dotfiles repo rooted at `$HOME` with a + blanket exclude + per-file force-add; `post-merge` had never been + tracked there before this work). That branch is intentionally kept + unmerged: merging would make git try to check out a newly-tracked + `post-merge` over the path where the live, still-untracked hook sits, + which needs a deliberate reconciliation step first. Issue #38 stays open + until that lands. Plan: + `meta/superpowers/plans/2026-09-08-docguard-generalization.md` (this + repo, untracked by design — a working record, not shipped content). - **v0.29.0 released** — GitHub Issues labeled `backlog` replace `.session-continuity/BACKLOG.md`. Merged to `main` via PR #47 (`999e1f2`), tag `v0.29.0`, release https://github.com/talgolan/session-continuity/releases/tag/v0.29.0. Identity is `#N`; LEARNINGS stays a local file. This repo's live items are issues #35–#45. - **v0.27.1 — gate-escape self-condemnation hazard fixed, merged to `main` via PR #33 (`dd84ee7`) and released (tag `v0.27.1`, bumped in `c00cf17`).** @@ -621,10 +639,11 @@ rarely. **Current `git log --oneline -5` (primary branch):** ``` -999e1f2 Merge pull request #47 from talgolan/feat/github-issues-backlog -dc9c788 feat: replace BACKLOG.md with GitHub Issues labeled backlog (v0.29.0) -f79eb10 chore: bump to 0.28.0 — shared mechanics library (perf-log mark/since, primer-status.sh) -6b70556 Merge pull request #34 from talgolan/feat/shared-mechanics-library +c9ecacc Merge pull request #49 from talgolan/worktree-docguard-generalization +d36d472 docs: don't claim GitHub issue #38 is closed until it actually is +8b11c46 docs: mark docguard generalization implemented, correct hard-block assumption +db8ec26 Merge pull request #48 from talgolan/fix/ghe-backlog-origin-check +16f8d06 fix: recognize GitHub Enterprise Server origins for the backlog queue ff6b8c8 test: bump stale count-entries-smoke pins to real BACKLOG/LEARNINGS counts (16/18) ``` From a07ace56ed3bde9075d98b1aab8e5570c82b54b9 Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 11:40:40 -0700 Subject: [PATCH 06/17] fix: agent-active.sh fallback no longer sums full wall-clock span The fallback's idle-gap exclusion checked $a.subtype=="turn_duration", which can never be true inside a branch already guarded on zero such records in range -- so the exclusion was dead code and the fallback degraded to the raw first-to-last timestamp span (verified: 2218.479s transcript span vs. corrected 1778.000s). Fixed by identifying the real turn-boundary signal instead: a genuine human-typed prompt (message.content is plain text, isMeta != true), not bare type=="user" -- most type=="user" records are tool_result blocks whose preceding gap is agent-active tool-execution time, not idle. Fixes #39 Co-Authored-By: Claude Sonnet 5 --- hooks/lib/agent-active.sh | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/hooks/lib/agent-active.sh b/hooks/lib/agent-active.sh index 972855a..fae3ca6 100755 --- a/hooks/lib/agent-active.sh +++ b/hooks/lib/agent-active.sh @@ -17,6 +17,16 @@ # spec's Change 2). Falls back to a timestamp turn-boundary walk (weaker: # infers boundaries from record adjacency rather than reading an explicit # field) only when zero turn_duration records exist in range at all. +# +# Fallback boundary signal: a genuine human-typed prompt, not bare +# type=="user" -- most type=="user" records are tool_result blocks (the +# Messages API forces tool results onto a "user" turn) and the gap before +# one of those is agent-active tool-execution time, not idle. Only a real +# human prompt (message.content is plain text, isMeta != true) marks the +# end of an idle gap. See is_human_prompt below; conflating the two used +# to make the fallback sum the entire wall-clock span instead of excluding +# idle time (bug: the exclusion checked $a.subtype=="turn_duration", which +# can never be true inside a branch already guarded on zero such records). set -u @@ -29,6 +39,15 @@ command -v jq >/dev/null 2>&1 || exit 0 RESULT="$(jq -s --argjson start "$START_EPOCH" ' def to_epoch: gsub("\\.[0-9]+Z$"; "Z") | fromdateiso8601; + # A genuine human-typed prompt, as opposed to a tool_result record (also + # type=="user" in this schema) or a synthetic isMeta injection. Only the + # arrival of a real human prompt marks the end of an idle (user-composing) + # gap -- tool_result gaps are agent-active time and must stay counted. + def is_human_prompt: + .type=="user" and (.isMeta != true) and ( + (.message.content | type) == "string" + or ((.message.content | type) == "array" and (.message.content | all(.type=="text"))) + ); (map(select(.timestamp != null and (.timestamp | to_epoch) >= $start))) as $in_range | ($in_range | map(select(.type=="system" and .subtype=="turn_duration"))) as $turns | if ($turns | length) > 0 then @@ -39,7 +58,7 @@ RESULT="$(jq -s --argjson start "$START_EPOCH" ' . as $acc | $sorted[$i] as $a | $sorted[$i+1] as $b - | if ($a.type=="system" and $a.subtype=="turn_duration") then $acc + | if ($b | is_human_prompt) then $acc else $acc + (($b.timestamp | to_epoch) - ($a.timestamp | to_epoch)) end )) From 93c433f10f55af28bc63d5d15e4c9a06f2ba84a5 Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 12:19:36 -0700 Subject: [PATCH 07/17] fix: overlap() dedupe before Jaccard so it stops over-merging retry-bursts Numerator counted a title's own repeated words as multiplicity while the denominator was already deduped, so a repeated word shared with an unrelated candidate's title (often the "re-run N times..." boilerplate) could inflate the score past the 0.7 dedup threshold and drop a genuinely distinct retry-burst. Dedupe both word lists before intersecting. Closes #40. Co-Authored-By: Claude Sonnet 5 --- .session-continuity/SESSION_PRIMER.md | 17 ++++++++++++++ hooks/lib/candidate-extract.jq | 4 ++-- .../2026-09-01-candidate-extract-smoke.zsh | 22 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.session-continuity/SESSION_PRIMER.md b/.session-continuity/SESSION_PRIMER.md index 66c9620..abeb74f 100644 --- a/.session-continuity/SESSION_PRIMER.md +++ b/.session-continuity/SESSION_PRIMER.md @@ -20,6 +20,23 @@ rarely. ## Current state +- **Closes backlog #40: `overlap()`'s asymmetric Jaccard in + `hooks/lib/candidate-extract.jq` over-merged distinct retry-bursts.** + The numerator (`$wa - ($wa - $wb)`) counted `$wa`'s own word + *multiplicity*, while the denominator (`$u`) was deduped — a repeated + word inside one candidate's title (e.g. a command whose args happen to + contain "file", which also sits in the "— re-run N times with M file + edits in between." boilerplate) inflated that title's similarity score + against an unrelated candidate enough to cross the 0.7 dedup threshold + and get it wrongly dropped. Fixed by deduping `$wa`/`$wb` before + intersecting (`unique` added at both `title_words` call sites), making + the ratio a real Jaccard index and direction-symmetric. New regression + case in `meta/superpowers/validation/2026-09-01-candidate-extract-smoke.zsh` + (two genuinely distinct retry-bursts — one command's args repeat "file" + — must both survive dedup); verified end-to-end pre-fix collapse (1 + candidate) vs. post-fix (2) via a real transcript fixture through + `candidate-extract.sh`, not just the isolated `overlap()` filter. Full + suite 31/31 green. - **Backlog #38 (docguard generalization) — this repo's side merged, the actual mechanism not yet activated.** PR https://github.com/talgolan/session-continuity/pull/49 (merged diff --git a/hooks/lib/candidate-extract.jq b/hooks/lib/candidate-extract.jq index c358f7a..6b7bb42 100644 --- a/hooks/lib/candidate-extract.jq +++ b/hooks/lib/candidate-extract.jq @@ -99,8 +99,8 @@ def title_words: | map(select(length > 0)); def overlap($ta; $tb): - ($ta | title_words) as $wa - | ($tb | title_words) as $wb + ($ta | title_words | unique) as $wa + | ($tb | title_words | unique) as $wb | ($wa + $wb | unique) as $u | if ($u | length) == 0 then 0 else (($wa - ($wa - $wb)) | length) / ($u | length) diff --git a/meta/superpowers/validation/2026-09-01-candidate-extract-smoke.zsh b/meta/superpowers/validation/2026-09-01-candidate-extract-smoke.zsh index d41dda2..a802dd7 100644 --- a/meta/superpowers/validation/2026-09-01-candidate-extract-smoke.zsh +++ b/meta/superpowers/validation/2026-09-01-candidate-extract-smoke.zsh @@ -117,6 +117,28 @@ n="$(print -r -- "$out" | jq '[.candidates[] | select(.heuristic=="retry-burst") || bad "expected 1 merged retry-burst, got $n: $out" rm -f "$fam_f" +# Regression: overlap() must dedupe words before intersecting, not just before +# unioning. A command whose own text repeats a word that also happens to sit +# in the "— re-run N times with M file edits in between." boilerplate (here, +# "file") must not inflate that title's similarity score against an unrelated +# burst enough to get it wrongly dropped as a duplicate. +jaccard_f="$(mktemp)" +{ + mk_bash_call "2026-09-08T00:00:00.000Z" "j1" "pytest tests/file_file_file_file_test.py" + mk_edit "2026-09-08T00:00:30.000Z" "je1" + mk_bash_call "2026-09-08T00:01:00.000Z" "j2" "pytest tests/file_file_file_file_test.py" + mk_bash_call "2026-09-08T00:02:00.000Z" "j3" "pytest tests/file_file_file_file_test.py" + mk_bash_call "2026-09-08T00:03:00.000Z" "j4" "curl -s https://example.com/api" + mk_edit "2026-09-08T00:03:30.000Z" "je2" + mk_bash_call "2026-09-08T00:04:00.000Z" "j5" "curl -s https://example.com/api" + mk_bash_call "2026-09-08T00:05:00.000Z" "j6" "curl -s https://example.com/api" +} > "$jaccard_f" +out="$(bash "$lib/candidate-extract.sh" "$jaccard_f")" +n="$(print -r -- "$out" | jq '[.candidates[] | select(.heuristic=="retry-burst")] | length')" +[[ "$n" -eq 2 ]] && ok "overlap(): a repeated word inside one title does not over-merge distinct retry-bursts" \ + || bad "expected 2 distinct retry-bursts, overlap() collapsed them to $n: $out" +rm -f "$jaccard_f" + # --- Heuristic B: revert / reset (needs a real tracked file) --------------- repo_dir="$(gt_make_repo)" From 858415add90260f386bf5cd2b86822f64b2bdd74 Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 13:14:24 -0700 Subject: [PATCH 08/17] feat: add checklist-assemble.sh, scripting end-session Step 3's row assembly --- hooks/lib/checklist-assemble.sh | 173 +++++++++++++++++ .../2026-09-08-checklist-assemble-smoke.zsh | 180 ++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100755 hooks/lib/checklist-assemble.sh create mode 100755 meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh diff --git a/hooks/lib/checklist-assemble.sh b/hooks/lib/checklist-assemble.sh new file mode 100755 index 0000000..a2de72d --- /dev/null +++ b/hooks/lib/checklist-assemble.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# CONTRACT_VERSION=1 +# hooks/lib/checklist-assemble.sh — Step 3 checklist renderer for +# /session-continuity:end-session (session-continuity plugin). +# +# Usage: checklist-assemble.sh [] +# Reads one JSON object on stdin (see +# meta/superpowers/plans/2026-09-08-determinism-phase-4-checklist-assembly.md +# Task 1 for the full contract) and prints the finished eight-row checklist, +# suggested-commit block, and terminal sign-off line on stdout. Always exits +# 0: a rendering failure must degrade to SC-FALLBACK, never abort the ritual. +# +# is read only when the input's backlog_mode=="normal". +# Each line is tag\tverdict\tcitation, one per open backlog item still +# subject to Step 1's overlap gate. A missing/unreadable path in that mode +# degrades to zero tracked items rather than falling back — an empty +# backlog is a valid state, distinct from malformed input. + +set -uo pipefail + +INPUT="$(cat)" +TSV_PATH="${1:-}" + +fallback() { # + printf 'SC-FALLBACK: manual — %s\n' "$1" + exit 0 +} + +command -v jq >/dev/null 2>&1 || fallback "jq is not installed." + +if ! printf '%s' "$INPUT" | jq -e 'type == "object"' >/dev/null 2>&1; then + fallback "malformed checklist JSON." +fi + +for key in staged unstaged untracked branch primer learnings backlog_mode; do + if ! printf '%s' "$INPUT" | jq -e "has(\"$key\")" >/dev/null 2>&1; then + fallback "checklist JSON missing required key '$key'." + fi +done + +# --- backlog TSV -> a JSON array jq can fold in ------------------------------ +BACKLOG_MODE="$(printf '%s' "$INPUT" | jq -r '.backlog_mode')" +case "$BACKLOG_MODE" in + normal|none|unavailable|not-migrated|fast-path) ;; + *) fallback "unrecognized backlog_mode '$BACKLOG_MODE'." ;; +esac + +BACKLOG_ITEMS_JSON='[]' +if [[ "$BACKLOG_MODE" == "normal" && -n "$TSV_PATH" && -r "$TSV_PATH" ]]; then + # jq's own JSON string encoding handles quotes, backslashes, and control + # characters correctly — no hand-rolled escaping. Split each line on tab; + # a citation containing a literal tab (columns 4+) is rejoined with "\t" + # rather than truncated. + BACKLOG_ITEMS_JSON="$( + jq -R -s ' + split("\n") | map(select(length > 0)) | map(split("\t")) | + map(select(length >= 3)) | + map({tag: .[0], verdict: .[1], citation: (.[2:] | join("\t"))}) + ' "$TSV_PATH" 2>/dev/null + )" + if ! printf '%s' "$BACKLOG_ITEMS_JSON" | jq -e . >/dev/null 2>&1; then + BACKLOG_ITEMS_JSON='[]' + fi +fi + +OUT="$( + printf '%s' "$INPUT" | jq -r --argjson items "$BACKLOG_ITEMS_JSON" ' + # --- row 1: primer ------------------------------------------------------- + def primer_row: + if .primer == "refreshed" then "✓ Primer refreshed and staged" + elif .primer == "closed" then "✓ Primer updated (outstanding item(s) closed)" + else "✓ Primer already current (no-op)" end; + + # --- row 2: learnings ----------------------------------------------------- + def learnings_row: + (.learnings // []) as $l + | if ($l | length) == 0 then "✓ No new learnings" + else + ($l | length) as $n + | (if $n == 1 then "entry" else "entries" end) as $noun + | ([$l[] | "#" + (.number|tostring) + ", \"" + .title + "\""] | join(", ")) as $cited + | "✓ " + ($n|tostring) + " LEARNINGS " + $noun + " captured (" + $cited + ")" + end; + + # --- row 3: backlog --------------------------------------------------------- + def backlog_row: + if .backlog_mode == "none" then {marker:"✓", text:"Backlog: none tracked"} + elif .backlog_mode == "unavailable" then {marker:"✓", text:"Backlog: GitHub queue unavailable — run /session-continuity:doctor"} + elif .backlog_mode == "not-migrated" then {marker:"✓", text:"Backlog: not migrated — run /session-continuity:primer"} + elif .backlog_mode == "fast-path" then {marker:"✓", text:"Backlog: " + (.backlog_fastpath_count|tostring) + " tracked — not re-verified this session (no repo changes since last close-out)"} + else + ($items) as $it + | ($it | length) as $n + | ($it | map(select(.verdict=="appears-DONE"))) as $done + | ($it | map(select(.verdict=="still-open"))) as $open + | ($it | map(select(.verdict=="manual"))) as $man + | ([ + (if ($done|length) > 0 then ($done|length|tostring) + " appears-DONE (" + ([$done[] | .tag + ", \"" + .citation + "\""] | join(", ")) + ")" else empty end), + (if ($open|length) > 0 then ($open|length|tostring) + " still-open (" + ([$open[] | .tag] | join(", ")) + ")" else empty end), + (if ($man|length) > 0 then ($man|length|tostring) + " manual (" + ([$man[] | .tag] | join(", ")) + ")" else empty end) + ] | join(", ")) as $clauses + | {marker: (if ($done|length) > 0 then "⚠️" else "✓" end), + text: "Backlog: " + ($n|tostring) + " tracked" + (if $n > 0 then " — " + $clauses else "" end)} + end; + + # --- rows 4-6: file lists --------------------------------------------------- + def staged_row: + (.staged // []) as $s + | if ($s|length) == 0 then {marker:"✓", text:"Nothing staged"} + else {marker:"✓", text:"Staged: " + ($s|join(", "))} end; + + def unstaged_row: + (.unstaged // []) as $u + | if ($u|length) == 0 then {marker:"✓", text:"No unstaged modifications"} + else {marker:"⚠️", text:"Unstaged: " + ($u|join(", "))} end; + + def untracked_row: + (.untracked // []) as $t + | if ($t|length) == 0 then {marker:"✓", text:"No untracked files"} + else {marker:"⚠️", text:($t|length|tostring) + " untracked: " + ($t|join(", ")) + " — ignore, add, or delete?"} end; + + # --- row 7: unpushed commits ------------------------------------------------- + def unpushed_row: + if .detached == true then + {marker:"⚠️", text:"detached HEAD at " + (.short_sha // "?")} + elif .upstream == null then + {marker:"⚠️", text:"branch `" + .branch + "` has no upstream — set one with `git push -u origin " + .branch + "`"} + elif (.ahead // 0) == 0 then + {marker:"✓", text:"Up to date with " + .upstream} + else + {marker:"⚠️", text:"Branch `" + .branch + "` is " + (.ahead|tostring) + " commits ahead of origin — push before closing?"} + end; + + # --- row 8: suggested commit -------------------------------------------------- + def suggested_row: + (.staged // []) as $s + | if ($s|length) == 0 then null + else + (if ([$s[] | startswith(".session-continuity/")] | all) then "docs: update session continuity" + elif .commit_subject != null then .commit_subject + else "chore: update " + ($s|length|tostring) + " file(s)" end) as $subject + | "→ Suggested:\n```\ngit commit -m \"" + $subject + "\"\n```" + end; + + (primer_row) as $r1 + | (learnings_row) as $r2 + | (backlog_row) as $r3 + | (staged_row) as $r4 + | (unstaged_row) as $r5 + | (untracked_row) as $r6 + | (unpushed_row) as $r7 + | (suggested_row) as $r8 + | [$r1, $r2, ($r3.marker + " " + $r3.text), ($r4.marker + " " + $r4.text), + ($r5.marker + " " + $r5.text), ($r6.marker + " " + $r6.text), + ($r7.marker + " " + $r7.text)] as $rows + | ($rows | map(test("⚠️")) | any) as $any_warn + | ($rows + (if $r8 != null then [$r8] else [] end)) as $all_lines + | ($all_lines | join("\n")) + + "\n\n" + + (if $any_warn then + "✅ Session complete. Safe to close. (Warnings above are advisory — review before closing if relevant.)" + else + "✅ Session complete. Safe to close." + end) + ' 2>/dev/null +)" +JQ_STATUS=$? + +if [[ "$JQ_STATUS" -ne 0 || -z "$OUT" ]]; then + fallback "checklist JSON did not match the expected shape." +fi + +printf '%s\n' "$OUT" diff --git a/meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh b/meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh new file mode 100755 index 0000000..a9a8573 --- /dev/null +++ b/meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh @@ -0,0 +1,180 @@ +#!/usr/bin/env zsh +# checklist-assemble.sh smoke test. Hermetic: fixture JSON + a throwaway +# TSV file, no real git state. +set -uo pipefail + +here="${0:A:h}" +repo="${here:h:h:h}" +lib="$repo/hooks/lib" +tool="$lib/checklist-assemble.sh" + +pass=0; fail=0 +ok() { print -P "%F{green}✓%f $1"; (( pass++ )); return 0; } +bad() { print -P "%F{red}✗%f $1"; (( fail++ )); return 0; } + +run() { print -rn -- "$1" | bash "$tool" "${2:-}"; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +base_json() { + # Minimal valid document: nothing staged/unstaged/untracked, primer + # current, no learnings, no backlog tracked, upstream clean. + cat <<'JSON' +{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false, + "short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current", + "learnings":[],"backlog_mode":"none","backlog_fastpath_count":null, + "commit_subject":null} +JSON +} + +# --- malformed JSON must not crash, must fall back ------------------------- +out="$(run 'not json at all')" +[[ "$out" == SC-FALLBACK:* ]] && ok "malformed JSON -> SC-FALLBACK" \ + || bad "expected SC-FALLBACK, got: $out" + +# --- missing required key must not crash ------------------------------------ +out="$(run '{"staged":[]}')" +[[ "$out" == SC-FALLBACK:* ]] && ok "missing required keys -> SC-FALLBACK, no crash" \ + || bad "expected SC-FALLBACK, got: $out" + +# --- fully clean run: every row ✓, no suggested-commit row, clean sign-off -- +out="$(run "$(base_json)")" +expected="✓ Primer already current (no-op) +✓ No new learnings +✓ Backlog: none tracked +✓ Nothing staged +✓ No unstaged modifications +✓ No untracked files +✓ Up to date with origin/main + +✅ Session complete. Safe to close." +[[ "$out" == "$expected" ]] && ok "fully clean run renders all-✓ checklist, no suggested-commit row, clean sign-off" \ + || bad "got:\n$out" + +# --- new learnings row: singular vs plural ---------------------------------- +one_learning='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[{"number":7,"title":"awk range collapse"}],"backlog_mode":"none","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$one_learning")" +[[ "$out" == *'✓ 1 LEARNINGS entry captured (#7, "awk range collapse")'* ]] \ + && ok "one learning -> singular 'entry'" \ + || bad "got:\n$out" + +two_learnings='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[{"number":7,"title":"A"},{"number":8,"title":"B"}],"backlog_mode":"none","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$two_learnings")" +[[ "$out" == *'✓ 2 LEARNINGS entries captured (#7, "A", #8, "B")'* ]] \ + && ok "two learnings -> plural 'entries', both cited" \ + || bad "got:\n$out" + +# --- backlog: fast-path mode ------------------------------------------------- +fastpath='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[],"backlog_mode":"fast-path","backlog_fastpath_count":5,"commit_subject":null}' +out="$(run "$fastpath")" +[[ "$out" == *'✓ Backlog: 5 tracked — not re-verified this session (no repo changes since last close-out)'* ]] \ + && ok "fast-path backlog mode renders the standing-count line" \ + || bad "got:\n$out" + +# --- backlog: unavailable / not-migrated modes ------------------------------- +unavail='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[],"backlog_mode":"unavailable","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$unavail")" +[[ "$out" == *'✓ Backlog: GitHub queue unavailable — run /session-continuity:doctor'* ]] \ + && ok "unavailable backlog mode renders the doctor pointer" \ + || bad "got:\n$out" + +notmig='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[],"backlog_mode":"not-migrated","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$notmig")" +[[ "$out" == *'✓ Backlog: not migrated — run /session-continuity:primer'* ]] \ + && ok "not-migrated backlog mode renders the primer pointer" \ + || bad "got:\n$out" + +# --- backlog: normal mode, mixed verdicts, TSV-driven ------------------------ +tsv="$work/backlog.tsv" +cat <<'TSV' > "$tsv" +#4 appears-DONE found test/end_to_end.bats -> 0 hits before, now present +#3 still-open no *.bats and no test/ dir -> item still open +#5 manual not auto-verifiable +#6 manual no related commits since last refresh -- not re-checked this session +TSV +normal='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[],"backlog_mode":"normal","backlog_fastpath_count":null,"commit_subject":null}' +out="$(print -rn -- "$normal" | bash "$tool" "$tsv")" +[[ "$out" == *'⚠️ Backlog: 4 tracked — 1 appears-DONE (#4, "found test/end_to_end.bats -> 0 hits before, now present"), 1 still-open (#3), 2 manual (#5, #6)'* ]] \ + && ok "normal mode tallies all three verdicts, cites only appears-DONE, marks ⚠️" \ + || bad "got:\n$out" +[[ "$out" == *'(Warnings above are advisory'* ]] \ + && ok "any ⚠️ row flips the sign-off line to the advisory variant" \ + || bad "sign-off did not carry the warning suffix:\n$out" + +# --- backlog: normal mode, zero appears-DONE -> ✓, no advisory suffix ------- +cat <<'TSV' > "$tsv" +#3 still-open no *.bats and no test/ dir -> item still open +TSV +out="$(print -rn -- "$normal" | bash "$tool" "$tsv")" +[[ "$out" == *'✓ Backlog: 1 tracked — 1 still-open (#3)'* ]] \ + && ok "normal mode with zero appears-DONE marks ✓, omits the empty appears-DONE clause" \ + || bad "got:\n$out" + +# --- backlog: normal mode, missing TSV path degrades to zero items --------- +out="$(print -rn -- "$normal" | bash "$tool" "$work/does-not-exist.tsv")" +[[ "$out" == *'✓ Backlog: 0 tracked'* ]] \ + && ok "normal mode with an unreadable TSV path degrades to zero tracked, no crash" \ + || bad "got:\n$out" + +# --- backlog: normal mode, citation with embedded quote and backslash ------ +printf '#9\tappears-DONE\tfound "weird" path C:\\temp\\x -> present\n' > "$tsv" +out="$(print -rn -- "$normal" | bash "$tool" "$tsv")" +[[ "$out" == *'1 appears-DONE (#9, "found "weird" path C:\temp\x -> present")'* ]] \ + && ok "citation with embedded quote/backslash renders without breaking JSON parsing" \ + || bad "got:\n$out" + +# --- unrecognized backlog_mode -> SC-FALLBACK, no silent normal-mode fallthrough +bad_mode='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[],"backlog_mode":"Normal","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$bad_mode")" +[[ "$out" == SC-FALLBACK:* ]] \ + && ok "unrecognized backlog_mode -> SC-FALLBACK, not silently treated as normal" \ + || bad "got: $out" + +# --- staged/unstaged/untracked rows ------------------------------------------ +files='{"staged":["a.md","b.md"],"unstaged":["c.md"],"untracked":["d.tmp","e.tmp"],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[],"backlog_mode":"none","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$files")" +[[ "$out" == *'✓ Staged: a.md, b.md'* ]] && ok "staged row lists every file" || bad "got:\n$out" +[[ "$out" == *'⚠️ Unstaged: c.md'* ]] && ok "unstaged row warns and lists" || bad "got:\n$out" +[[ "$out" == *'⚠️ 2 untracked: d.tmp, e.tmp — ignore, add, or delete?'* ]] && ok "untracked row counts and lists" || bad "got:\n$out" + +# --- unpushed commits: all four branch states -------------------------------- +detached='{"staged":[],"unstaged":[],"untracked":[],"branch":"HEAD","detached":true,"short_sha":"deadbee","upstream":null,"ahead":null,"primer":"current","learnings":[],"backlog_mode":"none","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$detached")" +[[ "$out" == *'⚠️ detached HEAD at deadbee'* ]] && ok "detached HEAD row" || bad "got:\n$out" + +noupstream='{"staged":[],"unstaged":[],"untracked":[],"branch":"feature-x","detached":false,"short_sha":"abc1234","upstream":null,"ahead":null,"primer":"current","learnings":[],"backlog_mode":"none","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$noupstream")" +[[ "$out" == *'⚠️ branch `feature-x` has no upstream — set one with `git push -u origin feature-x`'* ]] \ + && ok "no-upstream row" || bad "got:\n$out" + +ahead='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":3,"primer":"current","learnings":[],"backlog_mode":"none","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$ahead")" +[[ "$out" == *'⚠️ Branch `main` is 3 commits ahead of origin — push before closing?'* ]] \ + && ok "ahead-of-origin row" || bad "got:\n$out" + +# --- suggested commit: omitted when nothing staged (already covered above) -- +# --- suggested commit: docs-only staged -> literal subject, ignores override - +docs_only='{"staged":[".session-continuity/SESSION_PRIMER.md",".session-continuity/LEARNINGS.md"],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"refreshed","learnings":[],"backlog_mode":"none","backlog_fastpath_count":null,"commit_subject":"should be ignored"}' +out="$(run "$docs_only")" +[[ "$out" == *'git commit -m "docs: update session continuity"'* ]] \ + && ok "all-docs staged -> literal subject, ignores a supplied commit_subject" \ + || bad "got:\n$out" + +# --- suggested commit: mixed staged, caller-supplied subject used ----------- +mixed='{"staged":[".session-continuity/LEARNINGS.md","hooks/lib/foo.sh"],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"refreshed","learnings":[],"backlog_mode":"none","backlog_fastpath_count":null,"commit_subject":"fix(ci): extract CHANGELOG section with proper awk range"}' +out="$(run "$mixed")" +[[ "$out" == *'git commit -m "fix(ci): extract CHANGELOG section with proper awk range"'* ]] \ + && ok "mixed staged with a supplied subject -> subject used verbatim" \ + || bad "got:\n$out" + +# --- suggested commit: mixed staged, no subject supplied -> mechanical fallback +mixed_nosubj='{"staged":[".session-continuity/LEARNINGS.md","hooks/lib/foo.sh"],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"refreshed","learnings":[],"backlog_mode":"none","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$mixed_nosubj")" +[[ "$out" == *'git commit -m "chore: update 2 file(s)"'* ]] \ + && ok "mixed staged with no supplied subject -> mechanical N-file fallback, no invented theme" \ + || bad "got:\n$out" + +print "" +print -P "Result: %F{green}$pass passed%f, %F{red}$fail failed%f" +(( fail == 0 )) From 6b5b4fdb0cb90eb3d2dc367db3c2b4202cb56575 Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 13:29:01 -0700 Subject: [PATCH 09/17] fix: validate backlog_fastpath_count required for fast-path mode --- hooks/lib/checklist-assemble.sh | 7 +++++++ .../validation/2026-09-08-checklist-assemble-smoke.zsh | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/hooks/lib/checklist-assemble.sh b/hooks/lib/checklist-assemble.sh index a2de72d..68901b0 100755 --- a/hooks/lib/checklist-assemble.sh +++ b/hooks/lib/checklist-assemble.sh @@ -45,6 +45,13 @@ case "$BACKLOG_MODE" in *) fallback "unrecognized backlog_mode '$BACKLOG_MODE'." ;; esac +# --- fast-path mode requires backlog_fastpath_count (integer) ---------------- +if [[ "$BACKLOG_MODE" == "fast-path" ]]; then + if ! printf '%s' "$INPUT" | jq -e '.backlog_fastpath_count | type == "number"' >/dev/null 2>&1; then + fallback "checklist JSON missing required key 'backlog_fastpath_count' for backlog_mode fast-path." + fi +fi + BACKLOG_ITEMS_JSON='[]' if [[ "$BACKLOG_MODE" == "normal" && -n "$TSV_PATH" && -r "$TSV_PATH" ]]; then # jq's own JSON string encoding handles quotes, backslashes, and control diff --git a/meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh b/meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh index a9a8573..e770cd8 100755 --- a/meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh +++ b/meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh @@ -72,6 +72,13 @@ out="$(run "$fastpath")" && ok "fast-path backlog mode renders the standing-count line" \ || bad "got:\n$out" +# --- backlog: fast-path mode requires backlog_fastpath_count ---------------- +fastpath_null='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[],"backlog_mode":"fast-path","backlog_fastpath_count":null,"commit_subject":null}' +out="$(run "$fastpath_null")" +[[ "$out" == SC-FALLBACK:* ]] \ + && ok "fast-path mode with null backlog_fastpath_count -> SC-FALLBACK" \ + || bad "got:\n$out" + # --- backlog: unavailable / not-migrated modes ------------------------------- unavail='{"staged":[],"unstaged":[],"untracked":[],"branch":"main","detached":false,"short_sha":"abc1234","upstream":"origin/main","ahead":0,"primer":"current","learnings":[],"backlog_mode":"unavailable","backlog_fastpath_count":null,"commit_subject":null}' out="$(run "$unavail")" From d2915e423513b716df7795d458e6fdda3dd9a40b Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 13:32:44 -0700 Subject: [PATCH 10/17] chore: gitignore the Step 3 backlog-verdict scratch file --- hooks/lib/perf-log.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/lib/perf-log.sh b/hooks/lib/perf-log.sh index 3cbe438..becfd80 100755 --- a/hooks/lib/perf-log.sh +++ b/hooks/lib/perf-log.sh @@ -54,7 +54,7 @@ write_record() { local GITIGNORE="$REPO_ROOT/.gitignore" touch "$GITIGNORE" 2>/dev/null local LINE - for LINE in ".session-continuity/performance.log" ".session-continuity/.gitignore-ensured"; do + for LINE in ".session-continuity/performance.log" ".session-continuity/.gitignore-ensured" ".session-continuity/.end-session-checklist.tsv"; do if ! grep -qxF "$LINE" "$GITIGNORE" 2>/dev/null; then printf '%s\n' "$LINE" >> "$GITIGNORE" 2>/dev/null fi From f7b4bb9240ba5328560a139bbf06b32f4ee45686 Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 13:35:54 -0700 Subject: [PATCH 11/17] feat: end-session Step 1 records backlog verdicts for the Step 3 checklist script --- commands/end-session.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/commands/end-session.md b/commands/end-session.md index fe30b62..238068f 100644 --- a/commands/end-session.md +++ b/commands/end-session.md @@ -40,11 +40,14 @@ _PERF_START=$(date +%s.%N 2>/dev/null || echo "$SECONDS") git status --porcelain git log -1 --format=%H -- .session-continuity/SESSION_PRIMER.md # git rev-parse HEAD +bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/backlog-issues.sh" --count . # , "?" on failure _PERF_END=$(date +%s.%N 2>/dev/null || echo "$SECONDS") _PERF_DURATION=$(awk -v a="$_PERF_START" -v b="$_PERF_END" 'BEGIN{printf "%.3f", b-a}' 2>/dev/null || echo "$(( _PERF_END - _PERF_START ))") bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/perf-log.sh" record --source=command --name=end-session --step=step-1-fast-path --duration="$_PERF_DURATION" ``` +`` feeds Step 3's `backlog_fastpath_count`. If it printed `?` (GitHub unavailable), use `backlog_mode="unavailable"` in Step 3 instead of `"fast-path"` — same GitHub-unavailable handling as the non-fast-path skip condition below. + If `git status --porcelain` is empty AND `` equals `HEAD` (no commits have landed since the primer was last touched), skip the rest of Step 1 entirely — no drift check, no backlog verification, @@ -158,6 +161,30 @@ auto-removed**. Removal of any item always requires explicit user confirmation. A verdict never mutates the primer on its own. +**Record every verdict for Step 3.** Once every open item has a verdict +(from the overlap gate, the batched classify/verify pass, or the +non-code default), write them all in **one Bash call**. There is no +in-shell list to loop over — the verdicts exist only in what you just +decided — so write one literal `printf` line per item by hand (not a +shell `for`/`while` loop): + +```bash +mkdir -p .session-continuity +: > .session-continuity/.end-session-checklist.tsv +printf '%s\t%s\t%s\n' "#4" "appears-DONE" "found test/end_to_end.bats -> 0 hits before, now present" >> .session-continuity/.end-session-checklist.tsv +printf '%s\t%s\t%s\n' "#3" "still-open" "no *.bats and no test/ dir -> item still open" >> .session-continuity/.end-session-checklist.tsv +# ... one such literal printf line per remaining open item, tag first (the +# #N identity, never the ephemeral 1..N list position), verdict second +# (still-open|appears-DONE|manual), citation third (the same evidence string +# already decided above — "not auto-verifiable" for non-code items, "no +# related commits since last refresh — not re-checked this session" for +# overlap-gated ones) ... +``` + +Skip this entirely when there were zero open items to classify (the file is +absent; Step 3 treats a missing path under `backlog_mode="normal"` as zero +tracked items — see `hooks/lib/checklist-assemble.sh`'s contract). + ### Drift check (silent — no user prompt) Read `.session-continuity/SESSION_PRIMER.md` and compare its `git log --oneline -5` block to the actual output of `git log --oneline -5` against the primary branch. Two outcomes: From a8b6b9a4e3e7f29766dec9a759ef5ab5d93df297 Mon Sep 17 00:00:00 2001 From: Tal Golan Date: Tue, 8 Sep 2026 13:41:25 -0700 Subject: [PATCH 12/17] refactor: end-session Step 3 renders the checklist via checklist-assemble.sh --- commands/end-session.md | 154 ++++++++++++++++++++-------------------- 1 file changed, 76 insertions(+), 78 deletions(-) diff --git a/commands/end-session.md b/commands/end-session.md index 238068f..4d66275 100644 --- a/commands/end-session.md +++ b/commands/end-session.md @@ -435,94 +435,92 @@ Do not loop one-prompt-per-candidate. The batch is the unit. ## Step 3 — Final checklist -Run real git commands and emit a structured checklist. Every item must reflect actual repo state, not an assertion. - -### Gather the facts - -Run all six in **one Bash call** (one round trip, not six), timed: +One script call. Every row reflects actual repo state or a value you +decided in Step 1/Step 2 and pass through — never format or re-derive a row +by hand. + +### Gather the facts and render + +Run in **one Bash call**, timed. First, copy the Step 1 scratch TSV to a +location outside the repo and delete the in-repo copy — *before* any +git-status command runs, so the git commands below never see it (its path +can't be deleted-then-read, since `checklist-assemble.sh` needs to read it +after the git commands run; copying it out first is what makes both true +at once). Task 2's gitignore entry is the separate belt-and-suspenders case: +a ritual that crashes *before* this block ever runs leaves the file +in-repo, and only the gitignore entry (not this ordering) keeps it out of +a later `git ls-files --others`. Then run the seven git commands, then +build the JSON `checklist-assemble.sh` expects and pipe it through: ```bash _PERF_START=$(date +%s.%N 2>/dev/null || echo "$SECONDS") -git diff --cached --name-only # staged files -git diff --name-only # unstaged modifications -git ls-files --others --exclude-standard # untracked (ignoring .gitignore'd) -git rev-parse --abbrev-ref HEAD # current branch (or "HEAD" if detached) -git rev-parse --abbrev-ref @{u} 2>/dev/null # upstream branch, or empty if none -git rev-list --count @{u}..HEAD 2>/dev/null # unpushed commits, empty if no upstream +TSV_INREPO=".session-continuity/.end-session-checklist.tsv" +TSV="" +if [[ -r "$TSV_INREPO" ]]; then + TSV="$(mktemp)" + cp "$TSV_INREPO" "$TSV" + rm -f "$TSV_INREPO" +fi +BACKLOG_MODE="normal" # set to none|unavailable|not-migrated|fast-path per Step 1's skip conditions/fast path instead, when applicable +BACKLOG_FASTPATH_COUNT="null" # the fast path's , only when BACKLOG_MODE=fast-path + +STAGED_JSON="$(git diff --cached --name-only | jq -R -s 'split("\n") | map(select(length>0))')" +UNSTAGED_JSON="$(git diff --name-only | jq -R -s 'split("\n") | map(select(length>0))')" +UNTRACKED_JSON="$(git ls-files --others --exclude-standard | jq -R -s 'split("\n") | map(select(length>0))')" +BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)" +if [[ "$BRANCH" == "HEAD" ]]; then DETACHED=true; else DETACHED=false; fi +SHORT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo '?')" +UPSTREAM="$(git rev-parse --abbrev-ref @{u} 2>/dev/null || true)" +if [[ -z "$UPSTREAM" ]]; then UPSTREAM_JSON="null"; AHEAD_JSON="null"; else + UPSTREAM_JSON="$(printf '%s' "$UPSTREAM" | jq -R .)" + AHEAD="$(git rev-list --count @{u}..HEAD 2>/dev/null || echo 0)" + AHEAD_JSON="$AHEAD" +fi + +# PRIMER, LEARNINGS_JSON, COMMIT_SUBJECT_JSON: set these three from what +# Step 1/Step 2 actually did this invocation — see the field notes below. +PRIMER="current" # "refreshed" | "closed" | "current" +LEARNINGS_JSON="[]" # e.g. '[{"number":7,"title":"..."}]' from Step 2's captures +COMMIT_SUBJECT_JSON="null" # a quoted JSON string, or "null", per the field note below + +JSON_TMP="$(mktemp)" +jq -n \ + --argjson staged "$STAGED_JSON" --argjson unstaged "$UNSTAGED_JSON" \ + --argjson untracked "$UNTRACKED_JSON" --arg branch "$BRANCH" \ + --argjson detached "$DETACHED" --arg short_sha "$SHORT_SHA" \ + --argjson upstream "$UPSTREAM_JSON" --argjson ahead "$AHEAD_JSON" \ + --arg primer "$PRIMER" --argjson learnings "$LEARNINGS_JSON" \ + --arg backlog_mode "$BACKLOG_MODE" --argjson backlog_fastpath_count "$BACKLOG_FASTPATH_COUNT" \ + --argjson commit_subject "$COMMIT_SUBJECT_JSON" \ + '{staged:$staged, unstaged:$unstaged, untracked:$untracked, branch:$branch, + detached:$detached, short_sha:$short_sha, upstream:$upstream, ahead:$ahead, + primer:$primer, learnings:$learnings, backlog_mode:$backlog_mode, + backlog_fastpath_count:$backlog_fastpath_count, commit_subject:$commit_subject}' \ + > "$JSON_TMP" + +source "${CLAUDE_PLUGIN_ROOT}/hooks/lib/require-script.sh" +if require_script "${CLAUDE_PLUGIN_ROOT}/hooks/lib/checklist-assemble.sh" 1; then + CHECKLIST="$(bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/checklist-assemble.sh" "$TSV" < "$JSON_TMP")" +else + CHECKLIST="⚠️ $SC_REQUIRE_SCRIPT_MSG" +fi +rm -f "$TSV" "$JSON_TMP" + _PERF_END=$(date +%s.%N 2>/dev/null || echo "$SECONDS") _PERF_DURATION=$(awk -v a="$_PERF_START" -v b="$_PERF_END" 'BEGIN{printf "%.3f", b-a}' 2>/dev/null || echo "$(( _PERF_END - _PERF_START ))") bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/perf-log.sh" record --source=command --name=end-session --step=step-3-gather-facts --duration="$_PERF_DURATION" +echo "$CHECKLIST" ``` -- **Backlog verdicts** — reuse the per-item verdicts from Step 1's - verification sub-block; re-run - `bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/backlog-issues.sh" .` - to get the post-close issue set. No new git command — the evidence was already - gathered in Step 1. - -Handle these edge cases explicitly: - -- **Not a git repo.** If `git rev-parse` fails, the precondition in Step 0 should have caught this, but belt-and-suspenders: report "⚠️ not inside a git repo" once and skip git-dependent rows. -- **Detached HEAD.** `git rev-parse --abbrev-ref HEAD` returns `HEAD`. Note "⚠️ detached HEAD at ``" in the unpushed-commits row. -- **No upstream.** `git rev-parse --abbrev-ref @{u}` fails. Note "⚠️ branch `` has no upstream — set one with `git push -u origin `" in the unpushed-commits row. +**Field notes — the values only you know, filled in before running the block above:** -### Emit the checklist - -**List every file enumerated by the git commands — do not summarize, filter, or pick a "primary" one.** If `git diff --cached --name-only` returns three files, the "Staged files" row lists all three. Same rule for the Unstaged and Untracked rows. The suggested-commit message may emphasize one theme, but the checklist rows are inventories, not summaries. - -Output using this structure. Use ✓ (green), ⚠️ (yellow), or → (suggestion): - -| Row | Marker | Content | -|---|---|---| -| Primer refresh | ✓ | "Primer refreshed and staged" OR "Primer updated (outstanding item(s) closed)" OR "Primer already current (no-op)" | -| New learnings | ✓ | "N LEARNINGS entry/entries captured (#X, \"\" …)" OR "No new learnings" | -| Backlog | checkmark if none stale, else warning | "N tracked — <k> appears-DONE (#N, evidence), <m> still-open (#N…), <j> manual (#N…)" OR "none tracked" | -| Staged files | ✓ | "Staged: <file1>, <file2>, …" OR "Nothing staged" | -| Unstaged modifications | ✓ if none, else ⚠️ | "No unstaged modifications" OR "⚠️ Unstaged: <file1>, <file2>, …" | -| Untracked files | ✓ if none, else ⚠️ | "No untracked files" OR "⚠️ N untracked: <file1>, <file2>, … — ignore, add, or delete?" | -| Unpushed commits | ✓ / ⚠️ | "Up to date with origin/<branch>" OR "⚠️ Branch <name> is N commits ahead of origin — push before closing?" OR the detached-HEAD / no-upstream variants | -| Suggested commit | → | Derived from staged files + captured learnings. Omit row entirely if nothing is staged. | - -**Backlog row — re-derive, do not cache.** Step 3 re-runs the helper AFTER any Step 1 closures the -user confirmed. The *set* of issues and the counts are recomputed against the -post-close GitHub list; only the per-item -verdicts (`still-open` / `appears-DONE` / `manual`) computed in Step 1 are -reused. If the user closed an issue at the Step 1 prompt, it is gone from the -list and absent from this row. Marker: ✓ if -every remaining item is `still-open` or `manual` (nothing stale lingering); -⚠️ if any remaining item is `appears-DONE` (a resolved item still listed). -Cite the evidence for each `appears-DONE` item inline. A `manual` item's -citation is either `"not auto-verifiable"` (genuinely non-code) or `"no -related commits since last refresh — not re-checked this session"` (skipped -by the overlap gate) — keep whichever citation Step 1 assigned, don't -collapse them to one phrase. When the fast path fired, skip re-deriving this -row altogether and use its own citation as specified there. - -### Suggested commit message - -If files are staged, derive a commit message from the pattern: - -- Only `.session-continuity/` staged → `docs: update session continuity`. -- `.session-continuity/LEARNINGS.md` is staged with code → pick the most prominent captured learning's title (or the primary code-change theme) and use conventional-commit style: `<type>(<scope>): <subject>`. Keep subject line ≤ 72 chars. -- Only code staged (no docs) → should not happen if Step 1 ran; if it does, suggest based on the file paths. - -Prefix with `→ Suggested:` and wrap in a fenced code block so the user can copy-paste. - -### Example output - -``` -✓ Primer refreshed and staged -✓ 1 LEARNINGS entry captured (#7, "awk range collapse on single-version CHANGELOG") -⚠️ Backlog: 5 tracked — 1 appears-DONE (4 [c7d1], "add bats test harness": found test/end_to_end.bats → 0 hits before, now present), 1 still-open (3 [b092]), 3 manual (1 [a3f9], 2 [7f3e], 5 [e8a4]) -✓ Staged: .session-continuity/SESSION_PRIMER.md, .session-continuity/LEARNINGS.md, .github/workflows/release.yml -✓ No unstaged modifications -⚠️ 2 untracked files: scratch.md, tmp/debug.log — ignore, add, or delete? -⚠️ Branch "main" is 3 commits ahead of origin — push before closing? -→ Suggested: - git commit -m "fix(ci): extract CHANGELOG section with proper awk range" -``` +- `BACKLOG_MODE` / `BACKLOG_FASTPATH_COUNT`: `"fast-path"` + the fast path's `<fast-path-backlog-count>` when Step 1's fast path fired; otherwise whichever of `none`/`unavailable`/`not-migrated`/`normal` Step 1's Backlog verification section landed on (its own skip conditions already tell you which). +- `TSV`: leave as computed above (empty string unless the in-repo scratch file existed and was copied out before deletion) — never override it by hand. +- `PRIMER`: `"refreshed"` if the refresh flow ran and staged the primer, `"closed"` if only the drift-clean close-candidate prompt ran and closed item(s), `"current"` if Step 1 was a no-op (fast path or drift-clean-zero-candidates). +- `LEARNINGS_JSON`: the accepted drafts from Step 2's capture flow, as `[{"number":N,"title":"..."}]`; `[]` if Step 2 captured nothing. +- `COMMIT_SUBJECT_JSON`: `"null"` (the bare word, unquoted) unless staged files exist AND at least one is outside `.session-continuity/` — in that case, a quoted JSON string with your conventional-commit subject (`<type>(<scope>): <subject>`, ≤72 chars), e.g. `'"fix(ci): extract CHANGELOG section with proper awk range"'`. Pick the theme from the most prominent captured learning's title, or the primary code-change theme — same judgment call as before this phase, just handed to the script instead of formatted by hand. -*(Illustrative only — the real Backlog row reflects the current primer's actual item set and verdicts.)* +**Output.** If `$CHECKLIST` starts with `⚠️` (the `require_script` failure) or `SC-FALLBACK:` (the script's own malformed-input escape), print it as a single warning line and assemble the checklist by hand this one time, following the row table that existed before this phase (Primer refresh / New learnings / Backlog / Staged files / Unstaged modifications / Untracked files / Unpushed commits / Suggested commit, each ✓/⚠️/→, backlog citing evidence for `appears-DONE` only) — then still emit Step 4's sign-off line yourself, choosing the warning-suffixed variant if any row you assembled carries ⚠️. Otherwise, print `$CHECKLIST` verbatim — it already ends with the terminal sign-off line; do not print anything after it except whatever Step 4's timing calls require. ## Step 4 — Terminal sign-off (always) From 17d866d97e476cd0838fe59379742f8b383c8d38 Mon Sep 17 00:00:00 2001 From: Tal Golan <tg+github@nd.io> Date: Tue, 8 Sep 2026 13:53:12 -0700 Subject: [PATCH 13/17] refactor: end-session Step 4 becomes a pure timing step, sign-off now owned by checklist-assemble.sh --- commands/end-session.md | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/commands/end-session.md b/commands/end-session.md index 4d66275..54e98bb 100644 --- a/commands/end-session.md +++ b/commands/end-session.md @@ -522,9 +522,11 @@ echo "$CHECKLIST" **Output.** If `$CHECKLIST` starts with `⚠️` (the `require_script` failure) or `SC-FALLBACK:` (the script's own malformed-input escape), print it as a single warning line and assemble the checklist by hand this one time, following the row table that existed before this phase (Primer refresh / New learnings / Backlog / Staged files / Unstaged modifications / Untracked files / Unpushed commits / Suggested commit, each ✓/⚠️/→, backlog citing evidence for `appears-DONE` only) — then still emit Step 4's sign-off line yourself, choosing the warning-suffixed variant if any row you assembled carries ⚠️. Otherwise, print `$CHECKLIST` verbatim — it already ends with the terminal sign-off line; do not print anything after it except whatever Step 4's timing calls require. -## Step 4 — Terminal sign-off (always) +## Step 4 — Ritual timing (always) -After the checklist (and suggested-commit block, if any), emit a final closing line so the user knows the ritual completed and they are not blocked waiting for further prompts. +Step 3's `$CHECKLIST` already ended with the terminal sign-off line — this +step prints nothing of its own. It only logs how long the ritual took, so +the log carries one real end-to-end number per invocation. **Before that line, record total ritual time.** Each step above only timed its own Bash block, not the gaps between them — this reads back this @@ -577,21 +579,7 @@ block is skipped entirely — no `step-4-agent-active` line is logged for this invocation, same "skip rather than log a wrong number" rule that already governs the rest of this design. -**Always emit one of these two lines, exactly:** - -- If every checklist row was ✓ (no ⚠️ anywhere): - - ``` - ✅ Session complete. Safe to close. - ``` - -- If any checklist row had ⚠️: - - ``` - ✅ Session complete. Safe to close. (Warnings above are advisory — review before closing if relevant.) - ``` - -**Required.** Print this line on its own, after the checklist and any suggested-commit block. Never omit it. Never replace it with paraphrased prose. Never ask follow-up questions after this line — the line marks the end of the ritual. If the user wants to act on a warning, they will reply on their own. +**Never ask follow-up questions after Step 3's sign-off line printed.** It marks the end of the ritual. If the user wants to act on a warning, they will reply on their own. ## Notes From b7b82f3cc7a6d732765b68554d352e4c45a9405a Mon Sep 17 00:00:00 2001 From: Tal Golan <tg+github@nd.io> Date: Tue, 8 Sep 2026 14:00:09 -0700 Subject: [PATCH 14/17] docs: Phase 4 doc pointers, changelog, and version bump for the checklist script --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 5 +++++ .../2026-09-02-determinism-program-design.md | 15 +++++++++------ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f2b5696..db77b33 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session-continuity", - "version": "0.29.1", + "version": "0.30.0", "description": "Cross-session memory for Claude Code projects via four in-repo docs: SESSION_PRIMER.md (current state), PROJECT_CONTEXT.md (stable repo context), ROADMAP.md (strategic direction), and LEARNINGS.md (hard-won bugs), plus GitHub Issues labeled backlog for the work queue.", "author": { "name": "Tal Golan" diff --git a/CHANGELOG.md b/CHANGELOG.md index 550a665..f7fcc47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.30.0] — 2026-09-08 + +### Changed +- **`end-session`'s Step 3 checklist is now scripted.** New `hooks/lib/checklist-assemble.sh` consumes the seven git-status outputs and a `tag/verdict/citation` backlog-verdict file, and prints all eight finished checklist rows, the backlog tallies, every ✓/⚠️ marker, the suggested-commit block, and the terminal sign-off line as one deterministic block. The model's job shrinks to deciding backlog verdicts (unchanged from before) and picking a commit-message theme when code is staged — everything else (file-list rendering, tallying, marker selection, sign-off wording) is no longer hand-formatted per invocation. Step 4 no longer prints anything; the sign-off line is now part of Step 3's script output. + ## [0.29.1] — 2026-09-07 ### Fixed diff --git a/meta/superpowers/specs/2026-09-02-determinism-program-design.md b/meta/superpowers/specs/2026-09-02-determinism-program-design.md index 356ee72..5f04ffe 100644 --- a/meta/superpowers/specs/2026-09-02-determinism-program-design.md +++ b/meta/superpowers/specs/2026-09-02-determinism-program-design.md @@ -180,12 +180,15 @@ check mode. Unblocks phases 4 and 6 and closes `52dc` as a side effect. is decided. Plan: `meta/superpowers/plans/2026-09-03-shared-mechanics-library.md`. -**Phase 4 `#41` — `end-session` Step 3 checklist assembly.** One script consuming the -six git outputs and a `tag<TAB>verdict<TAB>citation` file, emitting the eight -finished rows, the four backlog tallies, the per-row markers, and the sign-off -boolean (612-675, 759-773), retiring the example block at 687-701. Depends on -Phase 3's `since`. Removes the file-inventory summarization failure that -line 646 exists to prevent. +**Phase 4 `#41` — `end-session` Step 3 checklist assembly.** `checklist-assemble.sh` +consumes the (now seven — a `git rev-parse --short HEAD` was added for the +detached-HEAD row) git outputs plus a `tag<TAB>verdict<TAB>citation` scratch +file, emitting all eight finished rows, the backlog tallies, every marker, +and the terminal sign-off line as one block — Step 4 no longer prints +anything of its own. Removes the "list every file, do not summarize" +instruction and the illustrative example entirely; the script's own output +is the contract. Plan: +`meta/superpowers/plans/2026-09-08-determinism-phase-4-checklist-assembly.md`. **Phase 5 `#42` — backlog mechanics.** Two scripts used by both `primer.md` and `end-session.md`: item bookkeeping (mint a 4-hex tag with a uniqueness grep, From 40f54968b3a6193b24a54372da01bd6c6dfbd807 Mon Sep 17 00:00:00 2001 From: Tal Golan <tg+github@nd.io> Date: Tue, 8 Sep 2026 14:11:23 -0700 Subject: [PATCH 15/17] fix(docs): inline sign-off strings in end-session fallback path Step 4 no longer prints the two canonical sign-off strings (removed earlier in this plan), so the fallback-assembly paragraph in Step 3 had a dangling cross-reference to "Step 4's sign-off line". Inline the two literal strings (confirmed against hooks/lib/checklist-assemble.sh) so the fallback path is self-contained. Also reword "print $CHECKLIST verbatim" to "relay the block's printed checklist unchanged" to avoid reading as a request for a second echo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- commands/end-session.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/end-session.md b/commands/end-session.md index 54e98bb..e0eb368 100644 --- a/commands/end-session.md +++ b/commands/end-session.md @@ -520,7 +520,7 @@ echo "$CHECKLIST" - `LEARNINGS_JSON`: the accepted drafts from Step 2's capture flow, as `[{"number":N,"title":"..."}]`; `[]` if Step 2 captured nothing. - `COMMIT_SUBJECT_JSON`: `"null"` (the bare word, unquoted) unless staged files exist AND at least one is outside `.session-continuity/` — in that case, a quoted JSON string with your conventional-commit subject (`<type>(<scope>): <subject>`, ≤72 chars), e.g. `'"fix(ci): extract CHANGELOG section with proper awk range"'`. Pick the theme from the most prominent captured learning's title, or the primary code-change theme — same judgment call as before this phase, just handed to the script instead of formatted by hand. -**Output.** If `$CHECKLIST` starts with `⚠️` (the `require_script` failure) or `SC-FALLBACK:` (the script's own malformed-input escape), print it as a single warning line and assemble the checklist by hand this one time, following the row table that existed before this phase (Primer refresh / New learnings / Backlog / Staged files / Unstaged modifications / Untracked files / Unpushed commits / Suggested commit, each ✓/⚠️/→, backlog citing evidence for `appears-DONE` only) — then still emit Step 4's sign-off line yourself, choosing the warning-suffixed variant if any row you assembled carries ⚠️. Otherwise, print `$CHECKLIST` verbatim — it already ends with the terminal sign-off line; do not print anything after it except whatever Step 4's timing calls require. +**Output.** If `$CHECKLIST` starts with `⚠️` (the `require_script` failure) or `SC-FALLBACK:` (the script's own malformed-input escape), print it as a single warning line and assemble the checklist by hand this one time, following the row table that existed before this phase (Primer refresh / New learnings / Backlog / Staged files / Unstaged modifications / Untracked files / Unpushed commits / Suggested commit, each ✓/⚠️/→, backlog citing evidence for `appears-DONE` only) — then still emit the terminal sign-off line yourself: `✅ Session complete. Safe to close.` if every row you assembled was ✓, or `✅ Session complete. Safe to close. (Warnings above are advisory — review before closing if relevant.)` if any row carries ⚠️. Otherwise, relay the block's printed checklist unchanged — it already ends with the terminal sign-off line; do not print anything after it except whatever Step 4's timing calls require. ## Step 4 — Ritual timing (always) From 8dfd9c93cbe2fc83503a25329a82074411f7f8f9 Mon Sep 17 00:00:00 2001 From: Tal Golan <tg+github@nd.io> Date: Tue, 8 Sep 2026 15:29:53 -0700 Subject: [PATCH 16/17] refactor: end-session's overlap gate is now hooks/lib/token-overlap.sh/.jq (#51) Replaces the two hand-computed prose copies of the commit-subject/ backlog-title token-overlap check (Backlog verification's "Overlap gate" and the refresh flow's "backlog overlay") with one script call, run once and reused by both. The stopword list moves out of prose into the .jq filter. Re-scopes Phase 5 (#42): the original framing assumed primer.md still carried its own copy of this gate (it doesn't, since the GitHub Issues migration) and that candidate-extract.jq's overlap() -- a Jaccard-ratio function for LEARNINGS-candidate dedup, unrelated to this cardinality- threshold gate -- was the thing to lift. That function's own bug (c9a4) was already fixed and closed as #40 in the prior session (93c433f) -- unrelated to this change, not reopened. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> --- .claude-plugin/plugin.json | 2 +- .session-continuity/SESSION_PRIMER.md | 27 ++++-- CHANGELOG.md | 5 + commands/end-session.md | 96 +++++++++++-------- hooks/lib/token-overlap.jq | 45 +++++++++ hooks/lib/token-overlap.sh | 54 +++++++++++ .../2026-09-02-determinism-program-design.md | 22 +++-- .../validation/2026-09-08-token-overlap.md | 32 +++++++ 8 files changed, 229 insertions(+), 54 deletions(-) create mode 100644 hooks/lib/token-overlap.jq create mode 100755 hooks/lib/token-overlap.sh create mode 100644 meta/superpowers/validation/2026-09-08-token-overlap.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index db77b33..7bf5b73 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session-continuity", - "version": "0.30.0", + "version": "0.31.0", "description": "Cross-session memory for Claude Code projects via four in-repo docs: SESSION_PRIMER.md (current state), PROJECT_CONTEXT.md (stable repo context), ROADMAP.md (strategic direction), and LEARNINGS.md (hard-won bugs), plus GitHub Issues labeled backlog for the work queue.", "author": { "name": "Tal Golan" diff --git a/.session-continuity/SESSION_PRIMER.md b/.session-continuity/SESSION_PRIMER.md index abeb74f..ba60047 100644 --- a/.session-continuity/SESSION_PRIMER.md +++ b/.session-continuity/SESSION_PRIMER.md @@ -20,6 +20,22 @@ rarely. ## Current state +- **Determinism Phase 5 re-scoped and shipped (issue #42, branch + `determinism-phase-5-token-overlap`).** Original scoping conflated two + unrelated things: `candidate-extract.jq`'s `overlap()` (a Jaccard ratio + for LEARNINGS-candidate dedup — already fixed and closed as #40, see + the bullet below, unrelated to this work) and the commit-subject / + backlog-issue-title cardinality-threshold gate duplicated as two prose + copies inside `commands/end-session.md` (the "Overlap gate" in Backlog + verification and the refresh flow's "backlog overlay" — primer.md's own + copy had already vanished with the GitHub Issues migration, so it was + never really "shared with primer.md" as originally framed). Shipped + `hooks/lib/token-overlap.sh`/`.jq`: tokenize, drop stopwords, intersect, + threshold ≥3, computed once per `end-session` run and reused by both + call sites; stopword list moved out of prose into the `.jq` filter. + 6 fixture cases verified (real match, near-miss, stopword-only overlap, + empty issues/commits, multiplicity dedup, missing-file failure) — see + `meta/superpowers/validation/2026-09-08-token-overlap.md`. v0.31.0. - **Closes backlog #40: `overlap()`'s asymmetric Jaccard in `hooks/lib/candidate-extract.jq` over-merged distinct retry-bursts.** The numerator (`$wa - ($wa - $wb)`) counted `$wa`'s own word @@ -656,12 +672,11 @@ rarely. **Current `git log --oneline -5` (primary branch):** ``` -c9ecacc Merge pull request #49 from talgolan/worktree-docguard-generalization -d36d472 docs: don't claim GitHub issue #38 is closed until it actually is -8b11c46 docs: mark docguard generalization implemented, correct hard-block assumption -db8ec26 Merge pull request #48 from talgolan/fix/ghe-backlog-origin-check -16f8d06 fix: recognize GitHub Enterprise Server origins for the backlog queue -ff6b8c8 test: bump stale count-entries-smoke pins to real BACKLOG/LEARNINGS counts (16/18) +66468ee refactor: end-session's overlap gate is now hooks/lib/token-overlap.sh/.jq +ddaccca Merge pull request #50 from talgolan/determinism-phase-4-step3-checklist +40f5496 fix(docs): inline sign-off strings in end-session fallback path +b7b82f3 docs: Phase 4 doc pointers, changelog, and version bump for the checklist script +17d866d refactor: end-session Step 4 becomes a pure timing step, sign-off now owned by checklist-assemble.sh ``` Regenerate this block whenever you commit — see diff --git a/CHANGELOG.md b/CHANGELOG.md index f7fcc47..eb09770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.31.0] — 2026-09-08 + +### Changed +- **`end-session`'s commit-subject/backlog-title overlap gate is now scripted.** New `hooks/lib/token-overlap.sh` / `token-overlap.jq` replace two hand-computed prose copies of the same tokenize-and-threshold algorithm (the "Overlap gate" in Backlog verification and the refresh flow's "backlog overlay"), computed once per `end-session` run and reused by both. The hardcoded stopword list moved out of prose into the `.jq` filter. This is unrelated to `candidate-extract.jq`'s `overlap()` (a Jaccard-ratio function used for LEARNINGS-candidate dedup, already fixed and closed as issue #40 in the prior session) — the two "overlap" concepts were previously conflated in Phase 5's original scoping. + ## [0.30.0] — 2026-09-08 ### Changed diff --git a/commands/end-session.md b/commands/end-session.md index e0eb368..60837d0 100644 --- a/commands/end-session.md +++ b/commands/end-session.md @@ -72,13 +72,19 @@ overlap gate below and the Refresh flow's overlay further down — compute it here, don't recompute it there. **Data source.** Run -`bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/backlog-issues.sh" .` + +```bash +mkdir -p .session-continuity +bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/backlog-issues.sh" . > .session-continuity/.end-session-issues.txt +cat .session-continuity/.end-session-issues.txt +``` + and identify each item by `#N`. Do not read `.session-continuity/BACKLOG.md`. **Skip conditions.** -- If the helper prints `No open backlog issues.`: skip verification. +- If the file's content is `No open backlog issues.`: skip verification. Step 3's row reads `Backlog: none tracked`. -- If the helper prints the GitHub-unavailable warning: skip verification. +- If the file's content is the GitHub-unavailable warning: skip verification. Step 3's row reads `Backlog: GitHub queue unavailable — run /session-continuity:doctor`. - If the primer still has an inline `## Outstanding items` heading or `.session-continuity/OUTSTANDING_ITEMS.md` exists: tell the user once @@ -86,22 +92,42 @@ and identify each item by `#N`. Do not read `.session-continuity/BACKLOG.md`. `/session-continuity:end-session`. Step 3's row reads `Backlog: not migrated — run /session-continuity:primer`. -**For each open issue** (`N. #NUMBER Title` from the helper). Identify +**For each open issue** (`N. #NUMBER Title` from the file). Identify the item by `#NUMBER`, never by the ephemeral 1..N list position. -**Overlap gate (cost control) — run this before classifying.** Tokenize the -issue title (same rule as the overlay below: lowercase, split on non-alphanumeric, -drop tokens <3 chars, drop the overlay's stopword list) and compare against -each commit subject in the list computed above, tokenized the same way. If -the intersection with EVERY commit subject has cardinality <3 — nothing that -landed since the last refresh implicates this item — skip the -classify/verify steps below for this item. Assign verdict **`manual`**, cited -as `"no related commits since last refresh — not re-checked this session"`. -This is the deliberate accuracy tradeoff of the gate: an item resolved -through means that leave no matching commit subject (a manual/external fix) -won't be caught until a touching commit lands or the user mentions it -directly. Items with cardinality ≥3 against at least one commit subject -proceed to full classify/verify below. +**Token overlap (cost control) — compute once, reuse here and in the refresh +flow further down.** Write the commit list (from "Compute the commit list +once" above) to a file, then run the shared gate script once against the +issues file already written above: + +```bash +git log <last-primer-commit>..HEAD --oneline > .session-continuity/.end-session-commits.txt +bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/token-overlap.sh" \ + .session-continuity/.end-session-issues.txt \ + .session-continuity/.end-session-commits.txt \ + > .session-continuity/.end-session-overlap.tsv +``` + +The result is a TSV of every `#N<TAB>commit subject` pair whose tokenized +titles share ≥3 tokens (short tokens and stopwords dropped first) — the +single source of truth for this section's skip decision and the refresh +flow's backlog overlay. **Read every row — do not summarize, filter, or +sample the output; a missing row for a real match is a silent skip.** Empty +output is a normal outcome (zero matches), not a failure signal. If the +script itself fails (nonzero exit), treat its output as empty and continue +— every item then proceeds to full classify/verify below instead of being +gated, which is the conservative direction. + +**Overlap gate.** For each item, check whether `#N` appears anywhere in +`.session-continuity/.end-session-overlap.tsv`. If it does not — no commit since the last +refresh reached the ≥3-token-overlap threshold for this item — skip the +classify/verify steps below for this item. Assign verdict **`manual`**, +cited as `"no related commits since last refresh — not re-checked this +session"`. This is the deliberate accuracy tradeoff of the gate: an item +resolved through means that leave no matching commit subject (a +manual/external fix) won't be caught until a touching commit lands or the +user mentions it directly. If `#N` appears against at least one commit +subject, proceed to full classify/verify below. 1. **Classify — code-verifiable or not.** An item is code-verifiable if a `grep`/`glob`/file-exists check *could* speak to it (it names a file, a @@ -255,36 +281,30 @@ Follow the logic in **Step 5 of `commands/primer.md`** (refresh mode): 2. If the primer has a test-counts section and the counts changed (after the 3× retry), update them to match current output. 3. **Surface commits since the last primer refresh, with backlog overlay.** Reuse the commit list already computed in the Backlog verification section above (`git log <last-primer-commit>..HEAD --oneline`) — do not recompute it. Present the subject list as candidate prompts. - Then compute a **backlog overlay** for each subject: - - - Tokenize the subject: lowercase, split on non-alphanumeric, drop tokens of length <3, drop the stopword list below. - - For each open backlog issue from the helper: tokenize the title the same way. - - Match if the intersection of subject tokens and item tokens has cardinality ≥ 3. - - **Stopwords** (extend per project as needed): - - ``` - the and for fix add update from with into feat chore docs primer learnings session continuity tag version release - ``` + Then look up the **backlog overlay** for each subject: filter + `.session-continuity/.end-session-overlap.tsv` (computed once in the + Backlog verification section above — do not recompute) for rows whose + commit-subject column equals this subject. The `#N` values on those rows + are the matching issues. **List every matching row for the subject — do + not summarize or pick one.** **Presentation.** Render the "May close outstanding items" block when EITHER token-overlap matches from commit subjects OR `appears-DONE` items from the Backlog verification sub-block above exist. **Render candidates as - a markdown ordered list, one item per line, using the item's current - `<position>` as the list ordinal** (e.g. `4. [a3f9] <cited code evidence> — <sha>`) - so the numbering the user sees matches the numbering in the primer — never a - bare bullet list or an inline comma-separated citation. Cite each - candidate by tag: commit-subject matches as `<sha> → item [a3f9]`, verification - candidates as `item [a3f9] (<cited code evidence>)`. Dedupe by tag (never by - position — it's recomputed per render and not a stable key): an item that is - both a commit-subject match and an `appears-DONE` candidate appears once, on - a single numbered line carrying both the `<sha>` and the code-evidence + a markdown ordered list, one item per line, identified by `#N`** (e.g. + `4. #40 <cited code evidence> — <sha>`) — never a bare bullet list or an + inline comma-separated citation. Cite each candidate by tag: commit-subject + matches as `<sha> → item #40`, verification candidates as `item #40 (<cited + code evidence>)`. Dedupe by `#N` (the stable issue identity — never by list + position, which is recomputed per render): an item that is both a + commit-subject match and an `appears-DONE` candidate appears once, on a + single numbered line carrying both the `<sha>` and the code-evidence citation. Omit the block only when BOTH sources are empty (do not print an empty section). **Refusal.** Never close an outstanding item without explicit user confirmation. The overlay is a candidate list, not an auto-close. - **Skip conditions.** If the helper printed a warning or `No open backlog issues.`, skip the overlay silently — the raw subject list still appears. + **Skip conditions.** If `.session-continuity/.end-session-issues.txt` is `No open backlog issues.` or the GitHub-unavailable warning, skip the overlay silently — the raw subject list still appears. 4. **Single combined prompt.** After printing the subject list (and overlay block if any), log a prompt-shown marker (same mechanism as the drift-clean prompt above — isolates human-response wait from ritual compute time, see Step 4): ```bash diff --git a/hooks/lib/token-overlap.jq b/hooks/lib/token-overlap.jq new file mode 100644 index 0000000..0296fda --- /dev/null +++ b/hooks/lib/token-overlap.jq @@ -0,0 +1,45 @@ +# CONTRACT_VERSION=1 +# hooks/lib/token-overlap.jq — commit-subject / backlog-issue-title token +# overlap. Invoked via token-overlap.sh; see that file for the CLI contract. +# +# This is the cardinality-threshold overlap gate (tokenize, drop stopwords, +# intersect, threshold >=3) used to decide whether a commit plausibly +# touches a backlog item. It is unrelated to candidate-extract.jq's +# overlap() (a Jaccard *ratio* used for LEARNINGS-candidate dedup) — the two +# solve different problems and must not be conflated. + +def stopwords: [ + "the","and","for","fix","add","update","from","with","into","feat", + "chore","docs","primer","learnings","session","continuity","tag", + "version","release" +]; + +def tokenize: + ascii_downcase + | gsub("[^a-z0-9]+"; " ") + | [splits(" +")] + | map(select(length >= 3)) + | map(select(. as $t | (stopwords | index($t)) == null)) + | unique; + +def parse_issue_line: + try (capture("^[0-9]+\\.\\s+#(?<n>[0-9]+)\\s+(?<title>.+)$")) catch null; + +def parse_commit_line: + try (capture("^\\S+\\s+(?<subject>.+)$")) catch null; + +($issues_raw | split("\n") | map(select(length > 0)) | map(parse_issue_line) + | map(select(. != null)) + | map({id: ("#" + .n), tokens: (.title | tokenize)}) +) as $issues + +| ($commits_raw | split("\n") | map(select(length > 0)) | map(parse_commit_line) + | map(select(. != null)) + | map({subject: .subject, tokens: (.subject | tokenize)}) + ) as $commits + +| $issues[] as $i +| $commits[] as $c +| ($i.tokens - ($i.tokens - $c.tokens)) as $inter +| select(($inter | length) >= 3) +| "\($i.id)\t\($c.subject)" diff --git a/hooks/lib/token-overlap.sh b/hooks/lib/token-overlap.sh new file mode 100755 index 0000000..5720b92 --- /dev/null +++ b/hooks/lib/token-overlap.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# CONTRACT_VERSION=1 +# hooks/lib/token-overlap.sh — commit-subject / backlog-issue-title token +# overlap gate. Replaces the two hand-computed prose copies that used to +# live in commands/end-session.md (the "Overlap gate" and the refresh +# flow's "backlog overlay") with one deterministic computation, run once. +# +# Usage: token-overlap.sh <issues-file> <commits-file> +# <issues-file> — raw backlog-issues.sh list output, one issue per line: +# "N. #NUMBER Title text" +# <commits-file> — raw `git log --oneline <range>` output, one commit per +# line: "<abbrev-hash> subject text" +# +# Prints TSV to stdout, one line per (issue, commit) pair whose tokenized +# titles share >=3 tokens after dropping short tokens and stopwords: +# #NUMBER<TAB>commit subject +# Zero matches is a normal, silent outcome (empty stdout, exit 0) — the +# caller treats "no line for this issue" / "no line for this subject" as +# "no match," not as an error. +# +# Operational failure (jq missing, filter missing/wrong version, a missing +# input file) prints one diagnostic line to stderr and exits 1. Callers +# that don't check the exit code still degrade safely: empty stdout reads +# as zero matches either way, which is the conservative direction for both +# call sites (skip-with-manual-verdict, or no overlay suggestion). + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JQ_FILTER="$SCRIPT_DIR/token-overlap.jq" +ISSUES_FILE="${1:-}" +COMMITS_FILE="${2:-}" + +die() { + printf 'token-overlap.sh: %s\n' "$1" >&2 + exit 1 +} + +[[ -n "$ISSUES_FILE" && -n "$COMMITS_FILE" ]] \ + || die "usage: token-overlap.sh <issues-file> <commits-file>" +[[ -r "$ISSUES_FILE" ]] || die "issues file is not readable: $ISSUES_FILE" +[[ -r "$COMMITS_FILE" ]] || die "commits file is not readable: $COMMITS_FILE" +command -v jq >/dev/null 2>&1 \ + || die "jq is not installed, so the overlap gate cannot run." +[[ -r "$JQ_FILTER" ]] \ + || die "token-overlap.jq is missing from $SCRIPT_DIR — the plugin cache is incomplete. Run \`/session-continuity:update\`." +grep -q '^# CONTRACT_VERSION=1$' "$JQ_FILTER" \ + || die "token-overlap.jq is from a different plugin version — run \`/session-continuity:update\`." + +ISSUES_RAW="$(cat "$ISSUES_FILE")" +COMMITS_RAW="$(cat "$COMMITS_FILE")" + +jq -r -n --arg issues_raw "$ISSUES_RAW" --arg commits_raw "$COMMITS_RAW" -f "$JQ_FILTER" \ + || die "the overlap filter failed." diff --git a/meta/superpowers/specs/2026-09-02-determinism-program-design.md b/meta/superpowers/specs/2026-09-02-determinism-program-design.md index 5f04ffe..303a7fa 100644 --- a/meta/superpowers/specs/2026-09-02-determinism-program-design.md +++ b/meta/superpowers/specs/2026-09-02-determinism-program-design.md @@ -190,15 +190,19 @@ instruction and the illustrative example entirely; the script's own output is the contract. Plan: `meta/superpowers/plans/2026-09-08-determinism-phase-4-checklist-assembly.md`. -**Phase 5 `#42` — backlog mechanics.** Two scripts used by both `primer.md` and -`end-session.md`: item bookkeeping (mint a 4-hex tag with a uniqueness grep, -stamp the date, renumber positions 1..N, grep the repo for a tag before -deletion) and the overlap gate (tokenize, drop short tokens and stopwords, -intersect with commit subjects, threshold at 3). The overlap algorithm -currently exists as two prose copies that can drift, and -`candidate-extract.jq:96-107` already has a working token-overlap -implementation to lift. Set-intersection cardinality is a task models get -wrong silently. +**Phase 5 `#42` — token-overlap gate (re-scoped 2026-09-08).** Original framing +above was stale on two counts: item bookkeeping (hex-tag mint/renumber/ +grep-delete) is gone with the GitHub Issues migration, and `primer.md` no +longer carries its own copy of the gate — only `end-session.md` does, in +two spots (the "Overlap gate" in Backlog verification and the refresh +flow's "backlog overlay"). `candidate-extract.jq`'s `overlap()` is a +Jaccard *ratio* for LEARNINGS-candidate dedup, not the same algorithm as +this cardinality-threshold gate; lifting it here would have been wrong +(already fixed and closed as #40 in the prior session, unrelated to this +change). Shipped as `hooks/lib/token-overlap.sh`/`.jq`: +tokenize, drop short tokens and stopwords, intersect, threshold at 3 — +computed once per `end-session` run, reused by both call sites. Set- +intersection cardinality is a task models get wrong silently. **Phase 6 `#43` — `primer` detect, migrate, init, drift.** Mode detection plus migration triggers (11-60, pure boolean logic over file existence); the diff --git a/meta/superpowers/validation/2026-09-08-token-overlap.md b/meta/superpowers/validation/2026-09-08-token-overlap.md new file mode 100644 index 0000000..011c75f --- /dev/null +++ b/meta/superpowers/validation/2026-09-08-token-overlap.md @@ -0,0 +1,32 @@ +# Validation log — token-overlap gate consolidation (Determinism Phase 5, #42) + +**Branch:** `determinism-phase-5-token-overlap` + +Fixture-driven checks of `hooks/lib/token-overlap.sh` / +`hooks/lib/token-overlap.jq`, run directly (no bats/test harness exists in +this repo yet — see open issue #36). Each case is a scratch issues-file / +commits-file pair passed straight to the script. + +| # | Case | Input | Result | Expected | +|---|------|-------|--------|----------| +| 1 | Real match | issue title and commit subject share `determinism`, `phase`, `5`, `overlap`, `gate` (≥3 non-stopword tokens) | one row `#42\t<subject>` | one row | +| 2 | Near-miss | only `phase`/`5`-adjacent overlap below threshold | empty | empty | +| 3 | Stopword-only overlap | shared words (`fix`, `and`, `update`, `the`, `docs`) are entirely in the stopword list | empty | empty — proves stopwords are dropped before counting, not just documented | +| 4a | Empty issues file | — | empty, exit 0 | empty | +| 4b | Empty commits file | — | empty, exit 0 | empty | +| 5 | Multiplicity | issue title repeats `overlap` 3×; only 2 *distinct* tokens (`overlap`, `gate`) are actually shared | empty (cardinality 2 < 3) | empty — proves each token set is deduped before intersecting, so multiplicity can't inflate cardinality (the class of bug split out to #40 for `candidate-extract.jq`'s unrelated Jaccard-ratio function) | +| 6 | Missing input file | nonexistent path | stderr diagnostic, exit 1 | non-zero exit, no crash | + +All six matched expectation. `commands/end-session.md`'s two prose copies +(the "Overlap gate" in Backlog verification and the refresh flow's "backlog +overlay") were replaced with two reads of one `.end-session-overlap.tsv` +computed by a single script invocation; the stopword list moved out of +prose entirely into `token-overlap.jq`. + +**Not exercised here:** the full `end-session.md` flow end-to-end against a +real scratch repo (that level of validation exists for other Phase work, +e.g. `2026-08-17-performance-logging.md`) — this task changes an internal +computation swapped in place of equivalent prose, with the same inputs +(`backlog-issues.sh`, `git log --oneline`) and output contract (`#N` +identity, TSV) the surrounding prose already expected, so a full command +run was judged unnecessary for this size of change. From de381a8e4863685540c5910ac61530bf4c9076c5 Mon Sep 17 00:00:00 2001 From: Tal Golan <tg+github@nd.io> Date: Tue, 8 Sep 2026 17:03:14 -0700 Subject: [PATCH 17/17] feat: primer-detect.sh/.jq, scripting /session-continuity:primer's Step 1 dispatch (Phase 6 sub-project A) (#52) * docs: design spec for primer-detect.sh/.jq (Phase 6 sub-project A) Re-scopes Determinism Phase 6 (#43) into five independent sub-projects after finding the original design-spec entry predates Step 3d (BACKLOG.md -> GitHub Issues, added in the 2026-09-07 migration). This spec covers only sub-project A: Step 1's detect/dispatch logic, the highest-frequency and zero-judgment piece of the phase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: correct backlog_to_issues trigger condition in primer-detect spec Caveman-review caught a real inconsistency: the spec's stated condition (BACKLOG_EXISTS=1) contradicted its own worst-case fixture, which chains backlog_rename -> backlog_to_issues in one STEPS output despite BACKLOG_EXISTS=0 being backlog_rename's own precondition. STEPS is computed from a single upfront fact snapshot -- no step has run yet at decision time -- so the trigger needs an explicit disjunction: fire now if BACKLOG_EXISTS=1, or fire anyway if backlog_rename is about to make it true. Also states explicitly that all facts are always emitted regardless of branch, and rewords the refresh-decision paragraph to stop implying it waits for migrations to execute first. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: primer-detect spec's dependency chain, one link deeper than the first fix Implementing the state machine against real jq surfaced a second instance of the bug caveman-review caught: backlog_rename's own condition (OUTSTANDING_ITEMS_EXISTS=1 AND BACKLOG_EXISTS=0) has the same problem the earlier fix solved for backlog_to_issues -- outstanding_split, if also queued, is what's about to make OUTSTANDING_ITEMS_EXISTS true, so a bare snapshot check silently drops backlog_rename whenever it's chained after outstanding_split. A second disjunction would work but doesn't generalize past one link. Replaced both disjunctions with a single "thread the projected fact forward" formulation, verified directly against all 11 fixtures in jq before writing this fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat: add primer-detect.sh/.jq, scripting /session-continuity:primer's dispatch decision Ports the state machine from the primer-detect design spec into an executable pair: primer-detect.jq (pure filter logic, directly testable) and primer-detect.sh (I/O wrapper). Includes hermetic smoke suite with 16 assertions covering all decision paths and error conditions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor: primer.md Step 1 dispatches via primer-detect.sh * docs: Phase 6 sub-project A doc pointers, changelog, and version bump * fix: primer.md's terminal-step rule covers migration-only STEPS lists Final whole-branch review found Step 1's dispatch rule only ran Step 5 when STEPS was fully empty, missing the migration-only case (STEPS non-empty but no refresh) entirely -- migrations ran but the terminal status report never fired, a real behavior gap versus the old system. The same rule change also resolves a related double-refresh risk when a migration step's own untouched 'fall through' language could re-trigger Step 4 after STEPS already lists refresh separately. Also fixes two doc-only inaccuracies: the design spec's Testing item 11 described its own fixture backwards (said the outstanding/backlog files already existed, when the fixture's whole point is that they don't -- that's what forces the chain through both projected-fact links), and stale case-count summaries in the spec and plan that didn't match the shipped 16-assertion smoke test. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 5 + commands/primer.md | 119 +-- hooks/lib/primer-detect.jq | 77 ++ hooks/lib/primer-detect.sh | 90 +++ .../plans/2026-09-08-primer-detect.md | 718 ++++++++++++++++++ .../2026-09-02-determinism-program-design.md | 23 +- .../specs/2026-09-08-primer-detect-design.md | 183 +++++ .../2026-09-08-primer-detect-smoke.zsh | 161 ++++ 9 files changed, 1309 insertions(+), 69 deletions(-) create mode 100644 hooks/lib/primer-detect.jq create mode 100755 hooks/lib/primer-detect.sh create mode 100644 meta/superpowers/plans/2026-09-08-primer-detect.md create mode 100644 meta/superpowers/specs/2026-09-08-primer-detect-design.md create mode 100755 meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7bf5b73..1654d50 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session-continuity", - "version": "0.31.0", + "version": "0.32.0", "description": "Cross-session memory for Claude Code projects via four in-repo docs: SESSION_PRIMER.md (current state), PROJECT_CONTEXT.md (stable repo context), ROADMAP.md (strategic direction), and LEARNINGS.md (hard-won bugs), plus GitHub Issues labeled backlog for the work queue.", "author": { "name": "Tal Golan" diff --git a/CHANGELOG.md b/CHANGELOG.md index eb09770..1cca560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.32.0] — 2026-09-08 + +### Changed +- **`/session-continuity:primer`'s Step 1 dispatch is now scripted.** New `hooks/lib/primer-detect.sh`/`.jq` replace ~15 lines of hand-evaluated nested-conditional prose (a 4-state classification plus 3 migration triggers with an easy-to-miss sequencing rule) with one script call that prints a definitive, ordered `STEPS=` list. The trigger chain is evaluated by threading each trigger's effect forward into the fact the next depends on (`outstanding_split → backlog_rename → backlog_to_issues`), not by re-deriving disjunctions per trigger — an approach tried and found not to compose past one chained link during this work. Determinism Phase 6 (#43) sub-project A; sub-projects B–E remain pending. + ## [0.31.0] — 2026-09-08 ### Changed diff --git a/commands/primer.md b/commands/primer.md index f94a088..f0ac916 100644 --- a/commands/primer.md +++ b/commands/primer.md @@ -10,58 +10,59 @@ You are responding to the `/session-continuity:primer` slash command. ## Step 1 — Detect state -Gather the raw data for every check below in **one Bash call**, timed: +Run the shared dispatch script once, timed: ```bash _PERF_START=$(date +%s.%N 2>/dev/null || echo "$SECONDS") -[ -f .session-continuity/SESSION_PRIMER.md ] && echo "PRIMER_EXISTS=1" || echo "PRIMER_EXISTS=0" -[ -f .session-continuity/LEARNINGS.md ] && echo "LEARNINGS_EXISTS=1" || echo "LEARNINGS_EXISTS=0" -[ -f .session-continuity/PROJECT_CONTEXT.md ] && echo "PROJECT_CONTEXT_EXISTS=1" || echo "PROJECT_CONTEXT_EXISTS=0" -[ -f .session-continuity/OUTSTANDING_ITEMS.md ] && echo "OUTSTANDING_ITEMS_EXISTS=1" || echo "OUTSTANDING_ITEMS_EXISTS=0" -grep -q '^## Outstanding items' .session-continuity/SESSION_PRIMER.md 2>/dev/null && echo "PRIMER_HAS_INLINE_OUTSTANDING=1" || echo "PRIMER_HAS_INLINE_OUTSTANDING=0" -[ -f .session-continuity/BACKLOG.md ] && echo "BACKLOG_EXISTS=1" || echo "BACKLOG_EXISTS=0" -[ -f .session-continuity/ROADMAP.md ] && echo "ROADMAP_EXISTS=1" || echo "ROADMAP_EXISTS=0" -git remote get-url origin 2>/dev/null || echo "NO_ORIGIN" -git log --oneline -5 -git diff --cached --name-only +source "${CLAUDE_PLUGIN_ROOT}/hooks/lib/require-script.sh" +if require_script "${CLAUDE_PLUGIN_ROOT}/hooks/lib/primer-detect.sh" 1; then + DETECT_OUTPUT="$(bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/primer-detect.sh" . 2>&1)" + DETECT_STATUS=$? +else + DETECT_OUTPUT="$SC_REQUIRE_SCRIPT_MSG" + DETECT_STATUS=1 +fi _PERF_END=$(date +%s.%N 2>/dev/null || echo "$SECONDS") _PERF_DURATION=$(awk -v a="$_PERF_START" -v b="$_PERF_END" 'BEGIN{printf "%.3f", b-a}' 2>/dev/null || echo "$(( _PERF_END - _PERF_START ))") bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/perf-log.sh" record --source=command --name=primer --step=step-1-detect-state --duration="$_PERF_DURATION" +echo "$DETECT_OUTPUT" +echo "DETECT_STATUS=$DETECT_STATUS" ``` -Interpret the output: - -1. Do `.session-continuity/SESSION_PRIMER.md` and `.session-continuity/LEARNINGS.md` exist? (`PRIMER_EXISTS` / `LEARNINGS_EXISTS` above.) -2. If a primer exists, does the `git log --oneline -5` block inside it match the `git log --oneline -5` output above? (mtime is intentionally not checked — formatters, save-on-blur, and `cat | tee` all bump mtime without changing content. The log-block diff is the authoritative drift signal.) -3. Does the `git diff --cached --name-only` output above contain any file outside `docs/`, `.session-continuity/`, `README*`, `CHANGELOG*`, `LICENSE*`? (Code is staged and a commit is imminent — the primer will be stale the moment that commit lands.) -4. If a primer exists, does `.session-continuity/PROJECT_CONTEXT.md` also exist? (`PROJECT_CONTEXT_EXISTS` above.) - -Four states result: - -- **No primer** → init mode (Step 2) -- **Primer exists but unsplit** (no `PROJECT_CONTEXT.md` yet) → split mode (Step 3) -- **Primer exists but stale** (log block drifted or code staged for commit) → refresh mode (Step 4) -- **Primer exists and current** (nothing staged) → check mode (Step 5) - -If `PRIMER_HAS_INLINE_OUTSTANDING=1` AND `OUTSTANDING_ITEMS_EXISTS=0`, -outstanding-items migration is needed — run it (Step 3b below) in addition -to whichever of the four states above applies. **Sequencing:** if the -primer is also unsplit (no `PROJECT_CONTEXT.md`), run the existing Split -mode (Step 3) to completion first, then run Step 3b against the resulting -primer, as two sequential edits — not simultaneous partitioning. The two -splits touch disjoint sections of the primer (stable-context headings vs. -the Outstanding items heading), so sequencing avoids any edit conflict. - -If `OUTSTANDING_ITEMS_EXISTS=1` AND `BACKLOG_EXISTS=0`, a file-rename -migration is needed — run it (Step 3c below) in addition to whichever of -the four states above applies. **Sequencing:** if Step 3b also fired this -run (inline heading present, no file yet), run Step 3b to completion -first — it still writes `OUTSTANDING_ITEMS.md` under the old name — then -run Step 3c against that result. Step 3c is strictly the one-level-up -file rename; it never inspects primer content. - -If `BACKLOG_EXISTS=1` (including after Step 3c) AND origin contains -`github.com`, run Step 3d (markdown backlog → GitHub Issues) after 3c. +**If `DETECT_STATUS` is nonzero, or `$DETECT_OUTPUT` has no `STEPS=` line: +stop.** Report `$DETECT_OUTPUT` to the user (it carries the diagnostic +either way — `require_script`'s message, or `primer-detect.sh`'s own +stderr, merged into stdout above) and do not execute any step below — +there is no safe default dispatch, since some steps run destructive +migrations (`git mv` in Step 3c, `git rm` in Step 3d). + +**Otherwise**, read `STEPS=` from `$DETECT_OUTPUT` and **execute every +name it lists, in the order given, then stop.** Do not re-derive which +steps should run from the individual `KEY=value` facts printed above +`STEPS=` — those are for transparency/debugging only, not a second +source of dispatch truth. If `STEPS` does not contain `refresh`, run Step 5 (check mode) as the +final step, after everything else `STEPS` named — this covers both the +fully-empty case (primer is current, nothing else to do) and a +migrations-only case (migrations ran, but the primer itself isn't +otherwise stale; Step 5's status report is still owed). If `STEPS` +contains `refresh`, Step 4 is the terminal step — its own reporting +already covers what Step 5 would say, so do not run Step 5 afterward. + +| Name in `STEPS` | Run | +|---|---| +| `init` | Step 2 (the only value `STEPS` can ever carry alone) | +| `split` | Step 3 | +| `outstanding_split` | Step 3b | +| `backlog_rename` | Step 3c | +| `backlog_to_issues` | Step 3d | +| `refresh` | Step 4 | + +Steps 3, 3b, 3c, and 3d's own bodies still say to "fall through" to +refresh or check mode after they finish — that phrasing predates this +dispatch. Treat it as already satisfied by the rule above: continue to +the next name in `STEPS` (if any), then apply the refresh/Step-5 rule +once, at the very end. Do not let a step's own fall-through sentence +trigger Step 4 or Step 5 a second time. ## Step 2 — Init mode @@ -173,11 +174,11 @@ contains). ## Step 3b — Outstanding-items split -Runs whenever `PRIMER_HAS_INLINE_OUTSTANDING=1` and -`OUTSTANDING_ITEMS_EXISTS=0` (see Step 1). Extract the primer's inline -`## Outstanding items` section into the new file; this is a one-time -content move, no numbering changes — the items keep whatever numbers -they currently have, and those become the first permanent IDs. +Runs when `outstanding_split` appears in Step 1's `STEPS`. Extract the +primer's inline `## Outstanding items` section into the new file; this +is a one-time content move, no numbering changes — the items keep +whatever numbers they currently have, and those become the first +permanent IDs. 1. Read the existing `.session-continuity/SESSION_PRIMER.md` in full. 2. Copy every top-level numbered item under `## Outstanding items` @@ -207,11 +208,11 @@ they currently have, and those become the first permanent IDs. ## Step 3c — Backlog rename migration -Runs whenever `BACKLOG_EXISTS=0` AND `OUTSTANDING_ITEMS_EXISTS=1` (see -Step 1). This is strictly the `OUTSTANDING_ITEMS.md` → `BACKLOG.md` -rename, one level up from Step 3b (which may have just created -`OUTSTANDING_ITEMS.md` under its old name this same run — Step 3c runs -after it, per the sequencing note in Step 1). +Runs when `backlog_rename` appears in Step 1's `STEPS`. This is strictly +the `OUTSTANDING_ITEMS.md` → `BACKLOG.md` rename, one level up from Step +3b (which may have just created `OUTSTANDING_ITEMS.md` under its old +name this same run — `STEPS` already places `backlog_rename` after +`outstanding_split` when both fire). 1. `git mv .session-continuity/OUTSTANDING_ITEMS.md .session-continuity/BACKLOG.md`. 2. Rewrite the moved file's first heading line from `# Outstanding Items @@ -248,11 +249,11 @@ split/migration step in this command. ## Step 3d — BACKLOG.md → GitHub Issues -Runs whenever `BACKLOG_EXISTS=1` (including after Step 3c just created -it) AND Step 1's origin URL contains `github.com`. If origin is missing -or not github.com, leave the file in place as a fossil and tell the -user `/session-continuity:doctor` will warn that the queue is inactive. -Do not keep writing to the fossil. +Runs when `backlog_to_issues` appears in Step 1's `STEPS`. If it doesn't +(non-github origin, or no backlog to migrate), leave any existing +`BACKLOG.md` in place as a fossil and tell the user +`/session-continuity:doctor` will warn that the queue is inactive. Do +not keep writing to the fossil. 1. `gh label create backlog --description "Agent backlog (session-continuity)" --force` 2. For each `### N. [tag] [YYYY-MM-DD] Title` heading whose title is diff --git a/hooks/lib/primer-detect.jq b/hooks/lib/primer-detect.jq new file mode 100644 index 0000000..c152db0 --- /dev/null +++ b/hooks/lib/primer-detect.jq @@ -0,0 +1,77 @@ +# CONTRACT_VERSION=1 +# hooks/lib/primer-detect.jq — /session-continuity:primer dispatch decision. +# Invoked via primer-detect.sh; see that file for the CLI contract and +# meta/superpowers/specs/2026-09-08-primer-detect-design.md for the state +# machine this ports. +# +# All decision logic lives here, not in the .sh wrapper — no I/O, so this +# is directly fixture-testable with synthetic strings (see the smoke test). +# The trigger chain is evaluated by threading each trigger's effect +# forward into the fact the next trigger reads (PROJ_OI, PROJ_BL below), +# not by writing out "OR about to become true" disjunctions per trigger — +# see the spec's "Why threading, not disjunctions" note for why the naive +# approach doesn't compose past one chained link. + +def has_inline_outstanding: + test("(?m)^## Outstanding items"); + +# jq/Oniguruma's "m" flag makes "." match newlines (the "s" flag means +# something else here -- single-line anchor mode -- unlike PCRE, where +# the letters are swapped). Without "m", .*? can never cross the log +# block's internal newlines and this always fails to match. +def log_drift($primer_exists; $actual_log; $primer_content): + if $primer_exists == 0 then 0 + else + (($primer_content | capture("Current `git log --oneline -5`[^`]*```\\n(?<block>.*?)```"; "m")) // null | .block) as $recorded + | if $recorded == null then 1 + elif ($recorded | gsub("^\\s+|\\s+$";"")) == ($actual_log | gsub("^\\s+|\\s+$";"")) then 0 + else 1 + end + end; + +def is_allowlisted: + (startswith("docs/") or startswith(".session-continuity/")) as $dir_ok + | (split("/") | .[-1]) as $base + | ($base | test("^(README|CHANGELOG|LICENSE)")) as $name_ok + | ($dir_ok or $name_ok); + +def code_staged($files): + ($files | split("\n") | map(select(length > 0))) as $paths + | ($paths | any(is_allowlisted | not)); + +def github_origin($origin): + $origin | test("github\\.com"); + +($primer_content | has_inline_outstanding) as $INLINE +| (log_drift($primer_exists; $git_log; $primer_content)) as $DRIFT +| (code_staged($staged_files)) as $STAGED +| (github_origin($origin_url)) as $GH + +| ($project_context_exists == 0) as $DO_SPLIT +| ($INLINE and ($outstanding_items_exists == 0)) as $DO_OSPLIT +| (if $DO_OSPLIT then 1 else $outstanding_items_exists end) as $PROJ_OI +| ($PROJ_OI == 1 and $backlog_exists == 0) as $DO_BRENAME +| (if $DO_BRENAME then 1 else $backlog_exists end) as $PROJ_BL +| ($PROJ_BL == 1 and $GH) as $DO_B2I +| ($DRIFT == 1 or $STAGED) as $DO_REFRESH + +| ([] + | if $DO_SPLIT then . + ["split"] else . end + | if $DO_OSPLIT then . + ["outstanding_split"] else . end + | if $DO_BRENAME then . + ["backlog_rename"] else . end + | if $DO_B2I then . + ["backlog_to_issues"] else . end + | if $DO_REFRESH then . + ["refresh"] else . end + ) as $triggered_steps +| (if $primer_exists == 0 then ["init"] else $triggered_steps end) as $steps + +| ("PRIMER_EXISTS=" + ($primer_exists|tostring)), + ("LEARNINGS_EXISTS=" + ($learnings_exists|tostring)), + ("PROJECT_CONTEXT_EXISTS=" + ($project_context_exists|tostring)), + ("OUTSTANDING_ITEMS_EXISTS=" + ($outstanding_items_exists|tostring)), + ("PRIMER_HAS_INLINE_OUTSTANDING=" + (if $INLINE then "1" else "0" end)), + ("BACKLOG_EXISTS=" + ($backlog_exists|tostring)), + ("ROADMAP_EXISTS=" + ($roadmap_exists|tostring)), + ("GITHUB_ORIGIN=" + (if $GH then "1" else "0" end)), + ("LOG_DRIFT=" + ($DRIFT|tostring)), + ("CODE_STAGED=" + (if $STAGED then "1" else "0" end)), + ("STEPS=" + ($steps | join(","))) diff --git a/hooks/lib/primer-detect.sh b/hooks/lib/primer-detect.sh new file mode 100755 index 0000000..369881d --- /dev/null +++ b/hooks/lib/primer-detect.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# CONTRACT_VERSION=1 +# hooks/lib/primer-detect.sh — dispatch decision for /session-continuity:primer. +# See meta/superpowers/specs/2026-09-08-primer-detect-design.md for the full +# state machine this ports (unchanged behavior, just executable instead of +# hand-evaluated per invocation) and +# meta/superpowers/plans/2026-09-08-primer-detect.md for the implementation +# plan. +# +# Usage: primer-detect.sh [<project-dir>] (default: .) +# Prints KEY=value lines to stdout on success, ending in +# STEPS=<comma,separated,ordered,list> (possibly empty — empty means check +# mode, primer is current and no migration triggers fired): +# PRIMER_EXISTS=0|1 LEARNINGS_EXISTS=0|1 +# PROJECT_CONTEXT_EXISTS=0|1 OUTSTANDING_ITEMS_EXISTS=0|1 +# PRIMER_HAS_INLINE_OUTSTANDING=0|1 BACKLOG_EXISTS=0|1 +# ROADMAP_EXISTS=0|1 GITHUB_ORIGIN=0|1 +# LOG_DRIFT=0|1 CODE_STAGED=0|1 +# STEPS=<split,outstanding_split,backlog_rename,backlog_to_issues,refresh,init> +# +# Operational failure (jq missing, primer-detect.jq missing or from a +# different CONTRACT_VERSION, <project-dir> not inside a git repository) +# prints one diagnostic line to stderr and exits 1 with NO STEPS= line on +# stdout at all — no conservative default dispatch. Some STEPS values gate +# destructive migrations (git mv/git rm in Steps 3c/3d), so guessing wrong +# on failure is worse than stopping; the caller must treat a missing +# STEPS= line as a hard stop, not degrade to any default step list. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JQ_FILTER="$SCRIPT_DIR/primer-detect.jq" +DIR="${1:-.}" + +die() { # <message> + printf 'primer-detect.sh: %s\n' "$1" >&2 + exit 1 +} + +command -v jq >/dev/null 2>&1 \ + || die "jq is not installed, so the primer dispatch cannot be computed." +[[ -r "$JQ_FILTER" ]] \ + || die "primer-detect.jq is missing from $SCRIPT_DIR — the plugin cache is incomplete. Run \`/session-continuity:update\`." +grep -q '^# CONTRACT_VERSION=1$' "$JQ_FILTER" \ + || die "primer-detect.jq is from a different plugin version — run \`/session-continuity:update\`." +git -C "$DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + || die "$DIR is not inside a git repository." + +file_flag() { # <path relative to $DIR> -> 1|0 + [[ -f "$DIR/$1" ]] && echo 1 || echo 0 +} + +PRIMER_EXISTS="$(file_flag .session-continuity/SESSION_PRIMER.md)" +LEARNINGS_EXISTS="$(file_flag .session-continuity/LEARNINGS.md)" +PROJECT_CONTEXT_EXISTS="$(file_flag .session-continuity/PROJECT_CONTEXT.md)" +OUTSTANDING_ITEMS_EXISTS="$(file_flag .session-continuity/OUTSTANDING_ITEMS.md)" +BACKLOG_EXISTS="$(file_flag .session-continuity/BACKLOG.md)" +ROADMAP_EXISTS="$(file_flag .session-continuity/ROADMAP.md)" + +PRIMER_CONTENT="" +[[ "$PRIMER_EXISTS" == "1" ]] && PRIMER_CONTENT="$(cat "$DIR/.session-continuity/SESSION_PRIMER.md")" + +ORIGIN_URL="$(git -C "$DIR" remote get-url origin 2>/dev/null || true)" +GIT_LOG="$(git -C "$DIR" log --oneline -5 2>/dev/null || true)" +STAGED_FILES="$(git -C "$DIR" diff --cached --name-only 2>/dev/null || true)" + +ERRFILE="$(mktemp)" +RESULT="$( + jq -r -n \ + --argjson primer_exists "$PRIMER_EXISTS" \ + --argjson learnings_exists "$LEARNINGS_EXISTS" \ + --argjson project_context_exists "$PROJECT_CONTEXT_EXISTS" \ + --argjson outstanding_items_exists "$OUTSTANDING_ITEMS_EXISTS" \ + --argjson backlog_exists "$BACKLOG_EXISTS" \ + --argjson roadmap_exists "$ROADMAP_EXISTS" \ + --arg origin_url "$ORIGIN_URL" \ + --arg git_log "$GIT_LOG" \ + --arg staged_files "$STAGED_FILES" \ + --arg primer_content "$PRIMER_CONTENT" \ + -f "$JQ_FILTER" 2>"$ERRFILE" +)" +JQ_STATUS=$? +DETAIL="$(head -1 "$ERRFILE" 2>/dev/null)" +rm -f "$ERRFILE" + +if [[ "$JQ_STATUS" -ne 0 || -z "$RESULT" ]]; then + die "the detect filter failed: ${DETAIL:-jq exited $JQ_STATUS}" +fi + +printf '%s\n' "$RESULT" diff --git a/meta/superpowers/plans/2026-09-08-primer-detect.md b/meta/superpowers/plans/2026-09-08-primer-detect.md new file mode 100644 index 0000000..4c7865d --- /dev/null +++ b/meta/superpowers/plans/2026-09-08-primer-detect.md @@ -0,0 +1,718 @@ +# Determinism Phase 6, sub-project A — `primer-detect.sh`/`.jq` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `commands/primer.md` Step 1's hand-evaluated 4-state-plus-3-trigger dispatch logic (~15 lines of nested-conditional prose, including a sequencing rule easy to misapply) with one script, `hooks/lib/primer-detect.sh`, that gathers the same raw facts already gathered today and prints a definitive, ordered `STEPS=` list telling the model exactly which of Steps 2/3/3b/3c/3d/4/5 to run. + +**Architecture:** `primer-detect.sh` (bash, I/O only — file-existence checks, three git commands, one file read) pipes everything into `primer-detect.jq` (pure decision function, no I/O, fixture-testable with synthetic strings). The `.jq` filter evaluates each migration trigger by **threading each trigger's effect forward** into the fact the next trigger reads (a chained `outstanding_split → backlog_rename → backlog_to_issues` dependency), not by writing out "OR about to become true" disjunctions per trigger — that approach was tried, found not to compose past one link, and replaced during this plan's own verification pass (see Task 1's notes). `commands/primer.md` Step 1 shrinks to one script call plus a lookup table from `STEPS` names to step numbers. + +**Tech Stack:** Bash, `jq` (already a hard dependency across this plugin), zsh (smoke tests only). + +**Spec:** `meta/superpowers/specs/2026-09-08-primer-detect-design.md`. That spec was corrected twice during writing (caveman-review, then this plan's own implementation-first verification) — both fixes are already folded into the spec text; this plan implements the spec as it now reads, no further corrections needed. + +## Global Constraints + +- `primer-detect.sh` carries a `# CONTRACT_VERSION=1` header (both the `.sh` and the `.jq`) and is called through `require_script` from `commands/primer.md`, exactly like `primer-status.sh` already is in Step 5. +- **No conservative default dispatch on failure.** Unlike `token-overlap.sh` (empty output safely degrades to "zero matches"), `primer-detect.sh` prints no `STEPS=` line at all on any operational failure and exits nonzero — some `STEPS` values gate destructive migrations (`git mv`/`git rm`), so guessing wrong is worse than stopping. `commands/primer.md` must treat a missing `STEPS=` line as a hard stop. +- **Never invent a value.** Every fact the script cannot resolve reflects reality (a missing file is `0`, not a guess) — this plan changes *how* the dispatch decision is computed, never *what* Steps 2/3/3b/3c/3d/4/5 themselves do. +- All decision logic lives in `primer-detect.jq`, none in `primer-detect.sh` — the `.sh` wrapper's only job is gathering raw facts and invoking the filter, mirroring `token-overlap.sh`/`candidate-extract.sh`. +- Do not touch Steps 2, 3, 3b, 3c, 3d, 4, or 5's own internal logic — this plan scripts *whether* they run, not *what* they do once entered. (Their own mechanics are sub-projects B/C/D per the spec's Context section — out of scope here.) +- Do not touch `candidate-extract.jq`'s `overlap()` (issue #40, already fixed and closed in a prior session) or `hooks/lib/token-overlap.sh`/`.jq` (issue #42, already shipped) — unrelated to this plan. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `hooks/lib/primer-detect.jq` (new) | Pure decision function: takes the raw facts as string/int arguments, runs the state machine, prints `KEY=value` lines ending in `STEPS=`. No I/O. | +| `hooks/lib/primer-detect.sh` (new) | Thin I/O wrapper: file-existence checks, `git remote get-url origin`, `git log --oneline -5`, `git diff --cached --name-only`, reads the primer file if present, invokes the filter. | +| `commands/primer.md` (modified) | Step 1 collapses to one script call plus a `STEPS`-name-to-step-number table. Steps 3b/3c/3d's opening "Runs whenever ..." sentences are simplified to "Runs when `<name>` appears in Step 1's `STEPS`" — restating the old boolean conditions in prose would now be actively misleading, since the corrected threaded logic lives only in the `.jq` filter. | +| `meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh` (new) | Smoke test: 16 assertions total (real scratch git repos, no mocking) — the 11 state-machine fixtures, a `5b` docs-allowlist variant, and 4 operational-failure cases. | +| `meta/superpowers/specs/2026-09-02-determinism-program-design.md` (modified) | Phase 6 entry notes sub-project A shipped, points to this plan; sub-projects B–E remain listed as pending. | +| `CHANGELOG.md` (modified) | New version entry. | +| `.claude-plugin/plugin.json` (modified) | Version bump. | + +--- + +### Task 1: `hooks/lib/primer-detect.jq` + `hooks/lib/primer-detect.sh` + +**Files:** +- Create: `hooks/lib/primer-detect.jq` +- Create: `hooks/lib/primer-detect.sh` +- Test: `meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh` + +**Interfaces:** +- Produces: `bash primer-detect.sh [<project-dir>]` (default `.`). Prints `KEY=value` lines to stdout, ending in `STEPS=<comma,separated,ordered,list>` (possibly empty). Exits 0 on success. On operational failure (jq missing, filter missing/wrong version, `<project-dir>` not a git repo), prints one diagnostic line to stderr and exits 1 with **no** `STEPS=` line anywhere in stdout. +- Consumes: `jq` (hard dependency), `git`. + +**A note on the log-drift fixture design (read before writing tests):** `primer-detect.sh` reads `SESSION_PRIMER.md` from the *working tree*, not from a git commit — so a fixture that wants "recorded log block matches actual `git log`" never needs the primer file's own content to be committed at all. Write the primer file to disk with whatever recorded block you want *after* whatever commits the fixture needs, and leave it uncommitted. There is no way to make a *committed* primer file accurately quote the `git log` of the very commit that adds it (the commit's own hash isn't known until after committing) — this is an inherent property of any git-log-embedding scheme, not a bug this plan introduces or needs to solve, and the real command's Step 4 (`refresh mode`) only ever *stages* the primer, never commits it itself, for exactly this reason. + +**jq/Oniguruma flag note (also read before writing tests):** jq's regex flag letters don't match PCRE convention. `"s"` means *single-line anchor mode* (`^`/`$` match string start/end only); `"m"` is the flag that makes `.` match newlines. A regex built assuming PCRE's `s` (dotall) will silently fail to match multi-line content and must use `"m"` instead. + +- [ ] **Step 1: Write the failing smoke test** + +Create `meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh`: + +```zsh +#!/usr/bin/env zsh +# primer-detect.sh/.jq smoke test. Hermetic: one scratch git repo per case, +# built fresh via mk_repo. No mocking needed -- every fact is a real file +# or a real git command against a real (throwaway) repository. +set -uo pipefail + +here="${0:A:h}" +repo="${here:h:h:h}" +lib="$repo/hooks/lib" +tool="$lib/primer-detect.sh" + +pass=0; fail=0 +ok() { print -P "%F{green}✓%f $1"; (( pass++ )); return 0; } +bad() { print -P "%F{red}✗%f $1"; (( fail++ )); return 0; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# mk_repo <dir> <split:0|1> <oi_file:0|1> <bl_file:0|1> <inline:0|1> <origin:gh|other> <staged:none|code|docs> +# Builds a fresh scratch repo with .session-continuity/ populated per the +# flags, commits everything, then writes SESSION_PRIMER.md's log block to +# exactly match the post-commit `git log --oneline -5` (never re-committed +# afterward -- see this plan's Task 1 notes on why that avoids a +# self-referential-hash problem). Callers that want DRIFT instead +# overwrite the block themselves after calling mk_repo. +mk_repo() { + local dir="$1" split=$2 oi=$3 bl=$4 inline=$5 origin=$6 staged=$7 + rm -rf "$dir" + mkdir -p "$dir/.session-continuity" + git -C "$dir" init -q + git -C "$dir" config user.email test@example.com + git -C "$dir" config user.name Test + if [[ "$origin" == "gh" ]]; then + git -C "$dir" remote add origin https://github.com/example/repo.git + else + git -C "$dir" remote add origin https://example.com/example/repo.git + fi + : > "$dir/.session-continuity/LEARNINGS.md" + (( split )) && : > "$dir/.session-continuity/PROJECT_CONTEXT.md" + : > "$dir/.session-continuity/ROADMAP.md" + (( oi )) && : > "$dir/.session-continuity/OUTSTANDING_ITEMS.md" + (( bl )) && : > "$dir/.session-continuity/BACKLOG.md" + local heading="" + (( inline )) && heading=$'## Outstanding items\n1. something\n\n' + print -r -- "${heading}# Primer + +placeholder body, overwritten below with the real log block +" > "$dir/.session-continuity/SESSION_PRIMER.md" + git -C "$dir" add -A + git -C "$dir" commit -qm init + local log + log="$(git -C "$dir" log --oneline -5)" + print -r -- "${heading}# Primer + +**Current \`git log --oneline -5\` (primary branch):** + +\`\`\` +$log +\`\`\` +" > "$dir/.session-continuity/SESSION_PRIMER.md" + case "$staged" in + code) print -r -- x > "$dir/src.js"; git -C "$dir" add src.js ;; + docs) print -r -- x >> "$dir/.session-continuity/LEARNINGS.md"; git -C "$dir" add .session-continuity/LEARNINGS.md ;; + esac +} + +steps_of() { bash "$tool" "$1" | awk -F= '/^STEPS=/{print $2}'; } + +# --- 1: fresh install (no .session-continuity/ at all) -> init ------------- +d="$work/case1"; mkdir -p "$d" +git -C "$d" init -q; git -C "$d" config user.email t@t.com; git -C "$d" config user.name T +: > "$d/README.md"; git -C "$d" add -A; git -C "$d" commit -qm init +git -C "$d" remote add origin https://github.com/example/repo.git +[[ "$(steps_of "$d")" == "init" ]] && ok "1: fresh install -> init" || bad "1: got '$(steps_of "$d")'" + +# --- 2: existing unsplit primer, otherwise current -> split ----------------- +d="$work/case2"; mk_repo "$d" 0 0 1 0 other none +[[ "$(steps_of "$d")" == "split" ]] && ok "2: unsplit only -> split" || bad "2: got '$(steps_of "$d")'" + +# --- 3: split + current + clean -> empty ------------------------------------ +d="$work/case3"; mk_repo "$d" 1 0 1 0 other none +[[ "$(steps_of "$d")" == "" ]] && ok "3: split+current+clean -> empty" || bad "3: got '$(steps_of "$d")'" + +# --- 4: recorded log block differs from actual -> refresh ------------------- +d="$work/case4"; mk_repo "$d" 1 0 1 0 other none +print -r -- "# Primer + +**Current \`git log --oneline -5\` (primary branch):** + +\`\`\` +0000000 stale placeholder +\`\`\` +" > "$d/.session-continuity/SESSION_PRIMER.md" +[[ "$(steps_of "$d")" == "refresh" ]] && ok "4: log drift -> refresh" || bad "4: got '$(steps_of "$d")'" + +# --- 5: non-allowlisted file staged -> refresh ------------------------------- +d="$work/case5"; mk_repo "$d" 1 0 1 0 other code +[[ "$(steps_of "$d")" == "refresh" ]] && ok "5: non-allowlisted staged -> refresh" || bad "5: got '$(steps_of "$d")'" + +# --- docs-only staged -> NOT refresh (allowlisted) -------------------------- +d="$work/case5b"; mk_repo "$d" 1 0 1 0 other docs +[[ "$(steps_of "$d")" == "" ]] && ok "5b: docs-only staged -> no refresh (allowlisted)" || bad "5b: got '$(steps_of "$d")'" + +# --- 6: inline heading, no OUTSTANDING_ITEMS.md -> outstanding_split ------- +d="$work/case6"; mk_repo "$d" 1 0 1 1 other none +[[ "$(steps_of "$d")" == "outstanding_split" ]] && ok "6: inline+no file -> outstanding_split" || bad "6: got '$(steps_of "$d")'" + +# --- 7: both unsplit AND inline -> split,outstanding_split (order) --------- +d="$work/case7"; mk_repo "$d" 0 0 1 1 other none +[[ "$(steps_of "$d")" == "split,outstanding_split" ]] && ok "7: unsplit+inline -> split,outstanding_split in order" || bad "7: got '$(steps_of "$d")'" + +# --- 8: OUTSTANDING_ITEMS.md exists, no BACKLOG.md -> backlog_rename ------- +d="$work/case8"; mk_repo "$d" 1 1 0 0 other none +[[ "$(steps_of "$d")" == "backlog_rename" ]] && ok "8: outstanding file, no backlog -> backlog_rename" || bad "8: got '$(steps_of "$d")'" + +# --- 9: BACKLOG.md exists, github origin -> backlog_to_issues -------------- +d="$work/case9"; mk_repo "$d" 1 0 1 0 gh none +[[ "$(steps_of "$d")" == "backlog_to_issues" ]] && ok "9: backlog exists, github -> backlog_to_issues" || bad "9: got '$(steps_of "$d")'" + +# --- 10: BACKLOG.md exists, non-github origin -> empty (fossil, no GH call) - +d="$work/case10"; mk_repo "$d" 1 0 1 0 other none +[[ "$(steps_of "$d")" == "" ]] && ok "10: backlog exists, non-github -> empty (fossil)" || bad "10: got '$(steps_of "$d")'" + +# --- 11: full worst-case stack, exact order --------------------------------- +d="$work/case11"; mk_repo "$d" 0 0 0 1 gh none +[[ "$(steps_of "$d")" == "split,outstanding_split,backlog_rename,backlog_to_issues" ]] \ + && ok "11: full stack fires in dependency order" || bad "11: got '$(steps_of "$d")'" + +# --- 12: operational failure -- missing filter, no STEPS line at all ------- +badlib="$work/badlib"; mkdir -p "$badlib" +cp "$lib/primer-detect.sh" "$badlib/" +out="$(bash "$badlib/primer-detect.sh" "$work/case3" 2>&1)"; rc=$? +[[ "$rc" -ne 0 && "$out" != *"STEPS="* ]] \ + && ok "12: missing filter -> nonzero exit, no STEPS= line" || bad "12: rc=$rc out='$out'" + +# --- 13: operational failure -- not a git repo ------------------------------ +notgit="$work/notgit"; mkdir -p "$notgit" +out="$(bash "$tool" "$notgit" 2>&1)"; rc=$? +[[ "$rc" -ne 0 && "$out" != *"STEPS="* ]] \ + && ok "13: not a git repo -> nonzero exit, no STEPS= line" || bad "13: rc=$rc out='$out'" + +# --- 14: operational failure -- wrong CONTRACT_VERSION ----------------------- +wronglib="$work/wronglib"; mkdir -p "$wronglib" +cp "$lib/primer-detect.sh" "$wronglib/" +sed 's/CONTRACT_VERSION=1/CONTRACT_VERSION=99/' "$lib/primer-detect.jq" > "$wronglib/primer-detect.jq" +out="$(bash "$wronglib/primer-detect.sh" "$work/case3" 2>&1)"; rc=$? +[[ "$rc" -ne 0 && "$out" != *"STEPS="* ]] \ + && ok "14: wrong CONTRACT_VERSION -> nonzero exit, no STEPS= line" || bad "14: rc=$rc out='$out'" + +# --- 15: operational failure -- jq absent from PATH ------------------------- +nojq="$work/nojq"; mkdir -p "$nojq/bin" +for b in bash git awk grep sed head cat mktemp dirname; do + p="$(command -v "$b")"; [[ -n "$p" ]] && ln -sf "$p" "$nojq/bin/$b" +done +out="$(PATH="$nojq/bin" bash "$tool" "$work/case3" 2>&1)"; rc=$? +[[ "$rc" -ne 0 && "$out" != *"STEPS="* ]] \ + && ok "15: jq absent from PATH -> nonzero exit, no STEPS= line" || bad "15: rc=$rc out='$out'" + +print "" +print -P "Result: %F{green}$pass passed%f, %F{red}$fail failed%f" +(( fail == 0 )) +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +chmod +x meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh +zsh meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh +``` + +Expected: FAIL on every assertion — `hooks/lib/primer-detect.sh` does not exist yet. + +- [ ] **Step 3: Write `hooks/lib/primer-detect.jq`** + +```jq +# CONTRACT_VERSION=1 +# hooks/lib/primer-detect.jq — /session-continuity:primer dispatch decision. +# Invoked via primer-detect.sh; see that file for the CLI contract and +# meta/superpowers/specs/2026-09-08-primer-detect-design.md for the state +# machine this ports. +# +# All decision logic lives here, not in the .sh wrapper — no I/O, so this +# is directly fixture-testable with synthetic strings (see the smoke test). +# The trigger chain is evaluated by threading each trigger's effect +# forward into the fact the next trigger reads (PROJ_OI, PROJ_BL below), +# not by writing out "OR about to become true" disjunctions per trigger — +# see the spec's "Why threading, not disjunctions" note for why the naive +# approach doesn't compose past one chained link. + +def has_inline_outstanding: + test("(?m)^## Outstanding items"); + +# jq/Oniguruma's "m" flag makes "." match newlines (the "s" flag means +# something else here -- single-line anchor mode -- unlike PCRE, where +# the letters are swapped). Without "m", .*? can never cross the log +# block's internal newlines and this always fails to match. +def log_drift($primer_exists; $actual_log; $primer_content): + if $primer_exists == 0 then 0 + else + (($primer_content | capture("Current `git log --oneline -5`[^`]*```\\n(?<block>.*?)```"; "m")) // null | .block) as $recorded + | if $recorded == null then 1 + elif ($recorded | gsub("^\\s+|\\s+$";"")) == ($actual_log | gsub("^\\s+|\\s+$";"")) then 0 + else 1 + end + end; + +def is_allowlisted: + (startswith("docs/") or startswith(".session-continuity/")) as $dir_ok + | (split("/") | .[-1]) as $base + | ($base | test("^(README|CHANGELOG|LICENSE)")) as $name_ok + | ($dir_ok or $name_ok); + +def code_staged($files): + ($files | split("\n") | map(select(length > 0))) as $paths + | ($paths | any(is_allowlisted | not)); + +def github_origin($origin): + $origin | test("github\\.com"); + +($primer_content | has_inline_outstanding) as $INLINE +| (log_drift($primer_exists; $git_log; $primer_content)) as $DRIFT +| (code_staged($staged_files)) as $STAGED +| (github_origin($origin_url)) as $GH + +| ($project_context_exists == 0) as $DO_SPLIT +| ($INLINE and ($outstanding_items_exists == 0)) as $DO_OSPLIT +| (if $DO_OSPLIT then 1 else $outstanding_items_exists end) as $PROJ_OI +| ($PROJ_OI == 1 and $backlog_exists == 0) as $DO_BRENAME +| (if $DO_BRENAME then 1 else $backlog_exists end) as $PROJ_BL +| ($PROJ_BL == 1 and $GH) as $DO_B2I +| ($DRIFT == 1 or $STAGED) as $DO_REFRESH + +| ([] + | if $DO_SPLIT then . + ["split"] else . end + | if $DO_OSPLIT then . + ["outstanding_split"] else . end + | if $DO_BRENAME then . + ["backlog_rename"] else . end + | if $DO_B2I then . + ["backlog_to_issues"] else . end + | if $DO_REFRESH then . + ["refresh"] else . end + ) as $triggered_steps +| (if $primer_exists == 0 then ["init"] else $triggered_steps end) as $steps + +| ("PRIMER_EXISTS=" + ($primer_exists|tostring)), + ("LEARNINGS_EXISTS=" + ($learnings_exists|tostring)), + ("PROJECT_CONTEXT_EXISTS=" + ($project_context_exists|tostring)), + ("OUTSTANDING_ITEMS_EXISTS=" + ($outstanding_items_exists|tostring)), + ("PRIMER_HAS_INLINE_OUTSTANDING=" + (if $INLINE then "1" else "0" end)), + ("BACKLOG_EXISTS=" + ($backlog_exists|tostring)), + ("ROADMAP_EXISTS=" + ($roadmap_exists|tostring)), + ("GITHUB_ORIGIN=" + (if $GH then "1" else "0" end)), + ("LOG_DRIFT=" + ($DRIFT|tostring)), + ("CODE_STAGED=" + (if $STAGED then "1" else "0" end)), + ("STEPS=" + ($steps | join(","))) +``` + +- [ ] **Step 4: Write `hooks/lib/primer-detect.sh`** + +```bash +#!/usr/bin/env bash +# CONTRACT_VERSION=1 +# hooks/lib/primer-detect.sh — dispatch decision for /session-continuity:primer. +# See meta/superpowers/specs/2026-09-08-primer-detect-design.md for the full +# state machine this ports (unchanged behavior, just executable instead of +# hand-evaluated per invocation) and +# meta/superpowers/plans/2026-09-08-primer-detect.md for the implementation +# plan. +# +# Usage: primer-detect.sh [<project-dir>] (default: .) +# Prints KEY=value lines to stdout on success, ending in +# STEPS=<comma,separated,ordered,list> (possibly empty — empty means check +# mode, primer is current and no migration triggers fired): +# PRIMER_EXISTS=0|1 LEARNINGS_EXISTS=0|1 +# PROJECT_CONTEXT_EXISTS=0|1 OUTSTANDING_ITEMS_EXISTS=0|1 +# PRIMER_HAS_INLINE_OUTSTANDING=0|1 BACKLOG_EXISTS=0|1 +# ROADMAP_EXISTS=0|1 GITHUB_ORIGIN=0|1 +# LOG_DRIFT=0|1 CODE_STAGED=0|1 +# STEPS=<split,outstanding_split,backlog_rename,backlog_to_issues,refresh,init> +# +# Operational failure (jq missing, primer-detect.jq missing or from a +# different CONTRACT_VERSION, <project-dir> not inside a git repository) +# prints one diagnostic line to stderr and exits 1 with NO STEPS= line on +# stdout at all — no conservative default dispatch. Some STEPS values gate +# destructive migrations (git mv/git rm in Steps 3c/3d), so guessing wrong +# on failure is worse than stopping; the caller must treat a missing +# STEPS= line as a hard stop, not degrade to any default step list. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JQ_FILTER="$SCRIPT_DIR/primer-detect.jq" +DIR="${1:-.}" + +die() { # <message> + printf 'primer-detect.sh: %s\n' "$1" >&2 + exit 1 +} + +command -v jq >/dev/null 2>&1 \ + || die "jq is not installed, so the primer dispatch cannot be computed." +[[ -r "$JQ_FILTER" ]] \ + || die "primer-detect.jq is missing from $SCRIPT_DIR — the plugin cache is incomplete. Run \`/session-continuity:update\`." +grep -q '^# CONTRACT_VERSION=1$' "$JQ_FILTER" \ + || die "primer-detect.jq is from a different plugin version — run \`/session-continuity:update\`." +git -C "$DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + || die "$DIR is not inside a git repository." + +file_flag() { # <path relative to $DIR> -> 1|0 + [[ -f "$DIR/$1" ]] && echo 1 || echo 0 +} + +PRIMER_EXISTS="$(file_flag .session-continuity/SESSION_PRIMER.md)" +LEARNINGS_EXISTS="$(file_flag .session-continuity/LEARNINGS.md)" +PROJECT_CONTEXT_EXISTS="$(file_flag .session-continuity/PROJECT_CONTEXT.md)" +OUTSTANDING_ITEMS_EXISTS="$(file_flag .session-continuity/OUTSTANDING_ITEMS.md)" +BACKLOG_EXISTS="$(file_flag .session-continuity/BACKLOG.md)" +ROADMAP_EXISTS="$(file_flag .session-continuity/ROADMAP.md)" + +PRIMER_CONTENT="" +[[ "$PRIMER_EXISTS" == "1" ]] && PRIMER_CONTENT="$(cat "$DIR/.session-continuity/SESSION_PRIMER.md")" + +ORIGIN_URL="$(git -C "$DIR" remote get-url origin 2>/dev/null || true)" +GIT_LOG="$(git -C "$DIR" log --oneline -5 2>/dev/null || true)" +STAGED_FILES="$(git -C "$DIR" diff --cached --name-only 2>/dev/null || true)" + +ERRFILE="$(mktemp)" +RESULT="$( + jq -r -n \ + --argjson primer_exists "$PRIMER_EXISTS" \ + --argjson learnings_exists "$LEARNINGS_EXISTS" \ + --argjson project_context_exists "$PROJECT_CONTEXT_EXISTS" \ + --argjson outstanding_items_exists "$OUTSTANDING_ITEMS_EXISTS" \ + --argjson backlog_exists "$BACKLOG_EXISTS" \ + --argjson roadmap_exists "$ROADMAP_EXISTS" \ + --arg origin_url "$ORIGIN_URL" \ + --arg git_log "$GIT_LOG" \ + --arg staged_files "$STAGED_FILES" \ + --arg primer_content "$PRIMER_CONTENT" \ + -f "$JQ_FILTER" 2>"$ERRFILE" +)" +JQ_STATUS=$? +DETAIL="$(head -1 "$ERRFILE" 2>/dev/null)" +rm -f "$ERRFILE" + +if [[ "$JQ_STATUS" -ne 0 || -z "$RESULT" ]]; then + die "the detect filter failed: ${DETAIL:-jq exited $JQ_STATUS}" +fi + +printf '%s\n' "$RESULT" +``` + +```bash +chmod +x hooks/lib/primer-detect.sh +``` + +- [ ] **Step 5: Run the smoke test to verify all assertions pass** + +```bash +zsh meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh +``` + +Expected: `Result: 16 passed, 0 failed`. + +- [ ] **Step 6: Commit** + +```bash +git add hooks/lib/primer-detect.jq hooks/lib/primer-detect.sh meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh +git commit -m "feat: add primer-detect.sh/.jq, scripting /session-continuity:primer's dispatch decision" +``` + +--- + +### Task 2: `commands/primer.md` — call the script + +**Files:** +- Modify: `commands/primer.md` (Step 1, currently lines 11-64; Steps 3b/3c/3d's opening sentences) + +**Interfaces:** +- Consumes: `hooks/lib/primer-detect.sh` (Task 1), resolved via `CLAUDE_PLUGIN_ROOT` and `require_script`, exactly like `primer-status.sh` already is in Step 5. + +- [ ] **Step 1: Replace Step 1 in full** + +Using the Edit tool, replace this exact block (currently `commands/primer.md` lines 11-64, from `## Step 1 — Detect state` through the line ending `...run Step 3d (markdown backlog → GitHub Issues) after 3c.`): + +````markdown +## Step 1 — Detect state + +Gather the raw data for every check below in **one Bash call**, timed: + +```bash +_PERF_START=$(date +%s.%N 2>/dev/null || echo "$SECONDS") +[ -f .session-continuity/SESSION_PRIMER.md ] && echo "PRIMER_EXISTS=1" || echo "PRIMER_EXISTS=0" +[ -f .session-continuity/LEARNINGS.md ] && echo "LEARNINGS_EXISTS=1" || echo "LEARNINGS_EXISTS=0" +[ -f .session-continuity/PROJECT_CONTEXT.md ] && echo "PROJECT_CONTEXT_EXISTS=1" || echo "PROJECT_CONTEXT_EXISTS=0" +[ -f .session-continuity/OUTSTANDING_ITEMS.md ] && echo "OUTSTANDING_ITEMS_EXISTS=1" || echo "OUTSTANDING_ITEMS_EXISTS=0" +grep -q '^## Outstanding items' .session-continuity/SESSION_PRIMER.md 2>/dev/null && echo "PRIMER_HAS_INLINE_OUTSTANDING=1" || echo "PRIMER_HAS_INLINE_OUTSTANDING=0" +[ -f .session-continuity/BACKLOG.md ] && echo "BACKLOG_EXISTS=1" || echo "BACKLOG_EXISTS=0" +[ -f .session-continuity/ROADMAP.md ] && echo "ROADMAP_EXISTS=1" || echo "ROADMAP_EXISTS=0" +git remote get-url origin 2>/dev/null || echo "NO_ORIGIN" +git log --oneline -5 +git diff --cached --name-only +_PERF_END=$(date +%s.%N 2>/dev/null || echo "$SECONDS") +_PERF_DURATION=$(awk -v a="$_PERF_START" -v b="$_PERF_END" 'BEGIN{printf "%.3f", b-a}' 2>/dev/null || echo "$(( _PERF_END - _PERF_START ))") +bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/perf-log.sh" record --source=command --name=primer --step=step-1-detect-state --duration="$_PERF_DURATION" +``` + +Interpret the output: + +1. Do `.session-continuity/SESSION_PRIMER.md` and `.session-continuity/LEARNINGS.md` exist? (`PRIMER_EXISTS` / `LEARNINGS_EXISTS` above.) +2. If a primer exists, does the `git log --oneline -5` block inside it match the `git log --oneline -5` output above? (mtime is intentionally not checked — formatters, save-on-blur, and `cat | tee` all bump mtime without changing content. The log-block diff is the authoritative drift signal.) +3. Does the `git diff --cached --name-only` output above contain any file outside `docs/`, `.session-continuity/`, `README*`, `CHANGELOG*`, `LICENSE*`? (Code is staged and a commit is imminent — the primer will be stale the moment that commit lands.) +4. If a primer exists, does `.session-continuity/PROJECT_CONTEXT.md` also exist? (`PROJECT_CONTEXT_EXISTS` above.) + +Four states result: + +- **No primer** → init mode (Step 2) +- **Primer exists but unsplit** (no `PROJECT_CONTEXT.md` yet) → split mode (Step 3) +- **Primer exists but stale** (log block drifted or code staged for commit) → refresh mode (Step 4) +- **Primer exists and current** (nothing staged) → check mode (Step 5) + +If `PRIMER_HAS_INLINE_OUTSTANDING=1` AND `OUTSTANDING_ITEMS_EXISTS=0`, +outstanding-items migration is needed — run it (Step 3b below) in addition +to whichever of the four states above applies. **Sequencing:** if the +primer is also unsplit (no `PROJECT_CONTEXT.md`), run the existing Split +mode (Step 3) to completion first, then run Step 3b against the resulting +primer, as two sequential edits — not simultaneous partitioning. The two +splits touch disjoint sections of the primer (stable-context headings vs. +the Outstanding items heading), so sequencing avoids any edit conflict. + +If `OUTSTANDING_ITEMS_EXISTS=1` AND `BACKLOG_EXISTS=0`, a file-rename +migration is needed — run it (Step 3c below) in addition to whichever of +the four states above applies. **Sequencing:** if Step 3b also fired this +run (inline heading present, no file yet), run Step 3b to completion +first — it still writes `OUTSTANDING_ITEMS.md` under the old name — then +run Step 3c against that result. Step 3c is strictly the one-level-up +file rename; it never inspects primer content. + +If `BACKLOG_EXISTS=1` (including after Step 3c) AND origin contains +`github.com`, run Step 3d (markdown backlog → GitHub Issues) after 3c. +```` + +with: + +````markdown +## Step 1 — Detect state + +Run the shared dispatch script once, timed: + +```bash +_PERF_START=$(date +%s.%N 2>/dev/null || echo "$SECONDS") +source "${CLAUDE_PLUGIN_ROOT}/hooks/lib/require-script.sh" +if require_script "${CLAUDE_PLUGIN_ROOT}/hooks/lib/primer-detect.sh" 1; then + DETECT_OUTPUT="$(bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/primer-detect.sh" . 2>&1)" + DETECT_STATUS=$? +else + DETECT_OUTPUT="$SC_REQUIRE_SCRIPT_MSG" + DETECT_STATUS=1 +fi +_PERF_END=$(date +%s.%N 2>/dev/null || echo "$SECONDS") +_PERF_DURATION=$(awk -v a="$_PERF_START" -v b="$_PERF_END" 'BEGIN{printf "%.3f", b-a}' 2>/dev/null || echo "$(( _PERF_END - _PERF_START ))") +bash "${CLAUDE_PLUGIN_ROOT}/hooks/lib/perf-log.sh" record --source=command --name=primer --step=step-1-detect-state --duration="$_PERF_DURATION" +echo "$DETECT_OUTPUT" +echo "DETECT_STATUS=$DETECT_STATUS" +``` + +**If `DETECT_STATUS` is nonzero, or `$DETECT_OUTPUT` has no `STEPS=` line: +stop.** Report `$DETECT_OUTPUT` to the user (it carries the diagnostic +either way — `require_script`'s message, or `primer-detect.sh`'s own +stderr, merged into stdout above) and do not execute any step below — +there is no safe default dispatch, since some steps run destructive +migrations (`git mv` in Step 3c, `git rm` in Step 3d). + +**Otherwise**, read `STEPS=` from `$DETECT_OUTPUT` and **execute every +name it lists, in the order given, then stop.** Do not re-derive which +steps should run from the individual `KEY=value` facts printed above +`STEPS=` — those are for transparency/debugging only, not a second +source of dispatch truth. An empty `STEPS=` means check mode: run Step 5. + +| Name in `STEPS` | Run | +|---|---| +| `init` | Step 2 (the only value `STEPS` can ever carry alone) | +| `split` | Step 3 | +| `outstanding_split` | Step 3b | +| `backlog_rename` | Step 3c | +| `backlog_to_issues` | Step 3d | +| `refresh` | Step 4 | +```` + +- [ ] **Step 2: Simplify Step 3b's opening sentence** + +Using the Edit tool, replace this exact block (currently `commands/primer.md`, the two sentences immediately under `## Step 3b — Outstanding-items split`): + +```markdown +Runs whenever `PRIMER_HAS_INLINE_OUTSTANDING=1` and +`OUTSTANDING_ITEMS_EXISTS=0` (see Step 1). Extract the primer's inline +`## Outstanding items` section into the new file; this is a one-time +content move, no numbering changes — the items keep whatever numbers +they currently have, and those become the first permanent IDs. +``` + +with: + +```markdown +Runs when `outstanding_split` appears in Step 1's `STEPS`. Extract the +primer's inline `## Outstanding items` section into the new file; this +is a one-time content move, no numbering changes — the items keep +whatever numbers they currently have, and those become the first +permanent IDs. +``` + +- [ ] **Step 3: Simplify Step 3c's opening sentence** + +Using the Edit tool, replace this exact block (currently `commands/primer.md`, the paragraph immediately under `## Step 3c — Backlog rename migration`): + +```markdown +Runs whenever `BACKLOG_EXISTS=0` AND `OUTSTANDING_ITEMS_EXISTS=1` (see +Step 1). This is strictly the `OUTSTANDING_ITEMS.md` → `BACKLOG.md` +rename, one level up from Step 3b (which may have just created +`OUTSTANDING_ITEMS.md` under its old name this same run — Step 3c runs +after it, per the sequencing note in Step 1). +``` + +with: + +```markdown +Runs when `backlog_rename` appears in Step 1's `STEPS`. This is strictly +the `OUTSTANDING_ITEMS.md` → `BACKLOG.md` rename, one level up from Step +3b (which may have just created `OUTSTANDING_ITEMS.md` under its old +name this same run — `STEPS` already places `backlog_rename` after +`outstanding_split` when both fire). +``` + +- [ ] **Step 4: Simplify Step 3d's opening sentence** + +Using the Edit tool, replace this exact block (currently `commands/primer.md`, the paragraph immediately under `## Step 3d — BACKLOG.md → GitHub Issues`): + +```markdown +Runs whenever `BACKLOG_EXISTS=1` (including after Step 3c just created +it) AND Step 1's origin URL contains `github.com`. If origin is missing +or not github.com, leave the file in place as a fossil and tell the +user `/session-continuity:doctor` will warn that the queue is inactive. +Do not keep writing to the fossil. +``` + +with: + +```markdown +Runs when `backlog_to_issues` appears in Step 1's `STEPS`. If it doesn't +(non-github origin, or no backlog to migrate), leave any existing +`BACKLOG.md` in place as a fossil and tell the user +`/session-continuity:doctor` will warn that the queue is inactive. Do +not keep writing to the fossil. +``` + +- [ ] **Step 5: Verify the old per-step condition restatements are gone** + +```bash +grep -c 'PRIMER_HAS_INLINE_OUTSTANDING=1.*and\|BACKLOG_EXISTS=0.*AND OUTSTANDING\|BACKLOG_EXISTS=1.*(including after Step 3c' commands/primer.md +``` + +Expected: `0` — the boolean conditions now live only in `primer-detect.jq`, referenced by `STEPS` membership everywhere else. + +- [ ] **Step 6: Commit** + +```bash +git add commands/primer.md +git commit -m "refactor: primer.md Step 1 dispatches via primer-detect.sh" +``` + +--- + +### Task 3: Doc pointers, regression pass, changelog + +**Files:** +- Modify: `meta/superpowers/specs/2026-09-02-determinism-program-design.md` +- Modify: `CHANGELOG.md` +- Modify: `.claude-plugin/plugin.json` + +- [ ] **Step 1: Update the design doc's Phase 6 entry** + +Using the Edit tool, replace this exact block in `meta/superpowers/specs/2026-09-02-determinism-program-design.md`: + +```markdown +**Phase 6 `#43` — `primer` detect, migrate, init, drift.** Mode detection plus +migration triggers (11-60, pure boolean logic over file existence); the +backlog rename migration (209-248, forty lines of prompt with no judgment in +any of its seven items, performing a destructive `git mv`); init-mode template +copy and mechanical placeholder substitution, collapsing the ten +`{{LATEST_COMMIT_*}}` slots into one `{{GIT_LOG_BLOCK}}`; and the drift check +plus test-count rerun with modal pinning (172-210, 249-250, 264-278). Largest +phase, lowest per-invocation frequency, highest blast radius — it runs a +`git mv` and rewrites five files. +``` + +with: + +```markdown +**Phase 6 `#43` — `primer` detect, migrate, init, drift.** Decomposed into +five sub-projects (see `meta/superpowers/specs/2026-09-08-primer-detect-design.md`'s +Context section for the full breakdown and why): **sub-project A shipped** +— Step 1's mode detection plus all three migration triggers, previously +hand-evaluated nested conditionals with an easy-to-miss sequencing rule, +now `hooks/lib/primer-detect.sh`/`.jq`. Plan: +`meta/superpowers/plans/2026-09-08-primer-detect.md`. Still pending: +sub-project B (Step 4's test-count majority-vote rerun — the same +compare-a-claimed-value-against-an-actual-one class Phase 7 is being +built to gate against), C (Step 3c/3d's `git mv`/`git rm` migration +mechanics themselves — sub-project A only scripted *whether* they run, +not *what* they do), D (Step 2's placeholder-derivation gather-and-regex), +E (Step 3/3b's section-bucketing judgment, lowest priority — near-zero +remaining audience, most judgment-heavy of the five). +``` + +- [ ] **Step 2: Full regression pass** + +```bash +for f in \ + meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh \ + meta/superpowers/validation/2026-09-08-token-overlap.md \ + meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh \ + meta/superpowers/validation/2026-08-17-perf-log-smoke.zsh \ + meta/superpowers/validation/2026-09-03-primer-status-smoke.zsh \ + meta/superpowers/validation/2026-08-12-session-start-smoke.zsh \ + meta/superpowers/validation/2026-09-01-require-script-smoke.zsh \ + meta/superpowers/validation/2026-09-07-backlog-issues-smoke.zsh \ + meta/superpowers/validation/2026-09-01-agent-active-smoke.zsh \ + meta/superpowers/validation/2026-09-02-resolve-transcript-smoke.zsh \ + meta/superpowers/validation/2026-09-02-count-entries-smoke.zsh \ + meta/superpowers/validation/2026-09-02-candidate-render-smoke.zsh \ + meta/superpowers/validation/2026-09-01-candidate-extract-smoke.zsh +do + [[ "$f" == *.zsh ]] || continue + echo "--- $f ---" + zsh "$f" || echo "FAILED: $f" +done +``` + +Expected: every runner ends `0 failed`. (`2026-09-08-token-overlap.md` in +the list above is a validation *log*, not a runner — included for +completeness of the "everything touched this program" review, nothing to +execute.) + +- [ ] **Step 3: Add a CHANGELOG entry** + +Add a new section at the top of `CHANGELOG.md`, directly under the `# Changelog` header and its description line, above the current top entry: + +```markdown +## [0.32.0] — 2026-09-08 + +### Changed +- **`/session-continuity:primer`'s Step 1 dispatch is now scripted.** New `hooks/lib/primer-detect.sh`/`.jq` replace ~15 lines of hand-evaluated nested-conditional prose (a 4-state classification plus 3 migration triggers with an easy-to-miss sequencing rule) with one script call that prints a definitive, ordered `STEPS=` list. The trigger chain is evaluated by threading each trigger's effect forward into the fact the next depends on (`outstanding_split → backlog_rename → backlog_to_issues`), not by re-deriving disjunctions per trigger — an approach tried and found not to compose past one chained link during this work. Determinism Phase 6 (#43) sub-project A; sub-projects B–E remain pending. +``` + +- [ ] **Step 4: Bump the plugin version** + +Using the Edit tool, update `.claude-plugin/plugin.json`'s `"version"` field from `"0.31.0"` to `"0.32.0"`. + +- [ ] **Step 5: Commit** + +```bash +git add meta/superpowers/specs/2026-09-02-determinism-program-design.md CHANGELOG.md .claude-plugin/plugin.json +git commit -m "docs: Phase 6 sub-project A doc pointers, changelog, and version bump" +``` diff --git a/meta/superpowers/specs/2026-09-02-determinism-program-design.md b/meta/superpowers/specs/2026-09-02-determinism-program-design.md index 303a7fa..d88083a 100644 --- a/meta/superpowers/specs/2026-09-02-determinism-program-design.md +++ b/meta/superpowers/specs/2026-09-02-determinism-program-design.md @@ -204,15 +204,20 @@ tokenize, drop short tokens and stopwords, intersect, threshold at 3 — computed once per `end-session` run, reused by both call sites. Set- intersection cardinality is a task models get wrong silently. -**Phase 6 `#43` — `primer` detect, migrate, init, drift.** Mode detection plus -migration triggers (11-60, pure boolean logic over file existence); the -backlog rename migration (209-248, forty lines of prompt with no judgment in -any of its seven items, performing a destructive `git mv`); init-mode template -copy and mechanical placeholder substitution, collapsing the ten -`{{LATEST_COMMIT_*}}` slots into one `{{GIT_LOG_BLOCK}}`; and the drift check -plus test-count rerun with modal pinning (172-210, 249-250, 264-278). Largest -phase, lowest per-invocation frequency, highest blast radius — it runs a -`git mv` and rewrites five files. +**Phase 6 `#43` — `primer` detect, migrate, init, drift.** Decomposed into +five sub-projects (see `meta/superpowers/specs/2026-09-08-primer-detect-design.md`'s +Context section for the full breakdown and why): **sub-project A shipped** +— Step 1's mode detection plus all three migration triggers, previously +hand-evaluated nested conditionals with an easy-to-miss sequencing rule, +now `hooks/lib/primer-detect.sh`/`.jq`. Plan: +`meta/superpowers/plans/2026-09-08-primer-detect.md`. Still pending: +sub-project B (Step 4's test-count majority-vote rerun — the same +compare-a-claimed-value-against-an-actual-one class Phase 7 is being +built to gate against), C (Step 3c/3d's `git mv`/`git rm` migration +mechanics themselves — sub-project A only scripted *whether* they run, +not *what* they do), D (Step 2's placeholder-derivation gather-and-regex), +E (Step 3/3b's section-bucketing judgment, lowest priority — near-zero +remaining audience, most judgment-heavy of the five). **Phase 7 `#44` — the gate that keeps it true.** A commit-time content gate on staged `commands/*.md` that blocks prompt text instructing a model to count, diff --git a/meta/superpowers/specs/2026-09-08-primer-detect-design.md b/meta/superpowers/specs/2026-09-08-primer-detect-design.md new file mode 100644 index 0000000..9c9d222 --- /dev/null +++ b/meta/superpowers/specs/2026-09-08-primer-detect-design.md @@ -0,0 +1,183 @@ +# Design — `primer-detect.sh`/`.jq` (Determinism Phase 6, sub-project A) + +**Issue:** #43 (Determinism Phase 6 — `primer` detect, migrate, init, drift) + +## Context + +Phase 6's original scoping (`meta/superpowers/specs/2026-09-02-determinism-program-design.md`) +predates the GitHub Issues migration (2026-09-07), which added a fourth +migration step (`commands/primer.md` Step 3d, BACKLOG.md → GitHub Issues) +never mentioned in that entry. Re-reading `commands/primer.md` (372 lines, +8 steps) end to end found the phase decomposes into five independent +pieces, each mechanical-plus-judgment in different ratios: + +| Step | Frequency | Mechanical seam | Judgment that stays prose | +|---|---|---|---| +| 1 Detect state | every invocation | ~9 fact checks → dispatch decision | none | +| 2 Init mode | once per repo | placeholder derivation | ground rules, escalation steps | +| 3/3b Split modes | near-zero (pre-v0.13/v0.22) | section-name lookup, title extraction | overflow routing, unknown sections | +| 3c/3d Migrations | near-zero (pre-v0.29), destructive | `git mv`/`git rm` + rewrites | hex-tag-to-repo mapping | +| 4 Refresh mode | every drift-detected invocation | test-count majority-vote rerun | candidate-close judgment | + +This spec covers **only sub-project A: Step 1's detect/dispatch logic** — +the highest-frequency, zero-judgment piece. Sub-projects B (Step 4's +test-count rerun), C (3c+3d migrations), and D (Step 2's placeholder +derivation) are each their own future spec/plan cycle, not in scope here. + +## Problem + +Step 1 today gathers ~9 raw facts in one bash call (already scripted), +then leaves the model to mentally evaluate a 4-state classification plus +three migration triggers with an explicit sequencing rule ("if the primer +is *also* unsplit, run Step 3 to completion first, then Step 3b"). This +nested nested-conditional is exactly the class of logic Phase 4 and Phase +5 already found models get wrong silently when done by hand per +invocation, and this phase's own issue names it as the program's highest +blast radius (some of the triggered steps run `git mv`/`git rm`). + +## Architecture + +``` +primer-detect.sh (I/O) primer-detect.jq (pure decision) + - file-existence checks --> - extract primer's recorded + - git remote get-url git-log block from content, + - git log --oneline -5 diff against actual git log + - git diff --cached --name-only - classify staged files against + - primer file content (if any) the allowlist + - detect inline-outstanding heading + - detect github.com in origin + - run the 4-state + 3-trigger + sequencing tree + - emit KEY=value facts + STEPS= +``` + +`primer-detect.sh` never makes a dispatch decision itself — it is a thin +I/O shim, identical in spirit to `token-overlap.sh`/`candidate-extract.sh`. +All decision logic lives in the `.jq` filter, which takes no I/O and is +therefore fixture-testable with synthetic JSON. + +## Output contract + +`primer-detect.sh <project-dir>` prints, always to stdout on success: + +``` +PRIMER_EXISTS=0|1 +LEARNINGS_EXISTS=0|1 +PROJECT_CONTEXT_EXISTS=0|1 +OUTSTANDING_ITEMS_EXISTS=0|1 +PRIMER_HAS_INLINE_OUTSTANDING=0|1 +BACKLOG_EXISTS=0|1 +ROADMAP_EXISTS=0|1 +GITHUB_ORIGIN=0|1 +LOG_DRIFT=0|1 +CODE_STAGED=0|1 +STEPS=<comma-separated ordered list, possibly empty> +``` + +`STEPS` values, in the only orders the state machine can produce them: +`split`, `outstanding_split`, `backlog_rename`, `backlog_to_issues`, +`refresh`, `init`. Empty `STEPS` means check mode (Step 5) — primer is +current and no migration triggers fired. + +**State machine** (ports the existing prose exactly, no behavior change): + +All facts (`PRIMER_EXISTS` through `CODE_STAGED`) are always computed and +emitted regardless of which branch below fires — no fact is conditionally +skipped, so the full `KEY=value` contract holds even for `STEPS=init`. + +`PRIMER_EXISTS=0` → `STEPS=init`, unconditionally — nothing else to +migrate or refresh yet. Otherwise, evaluate migration triggers in +dependency order, **threading each trigger's effect forward** into the +fact the next trigger reads — not by re-deriving increasingly complex +disjunctions per trigger, which doesn't compose past one link (see the +"Why threading, not disjunctions" note below): + +``` +DO_SPLIT = (PROJECT_CONTEXT_EXISTS == 0) +DO_OSPLIT = (PRIMER_HAS_INLINE_OUTSTANDING == 1) AND (OUTSTANDING_ITEMS_EXISTS == 0) +PROJ_OI = DO_OSPLIT ? 1 : OUTSTANDING_ITEMS_EXISTS +DO_BRENAME = (PROJ_OI == 1) AND (BACKLOG_EXISTS == 0) +PROJ_BL = DO_BRENAME ? 1 : BACKLOG_EXISTS +DO_B2I = (PROJ_BL == 1) AND (GITHUB_ORIGIN == 1) +DO_REFRESH = (LOG_DRIFT == 1) OR (CODE_STAGED == 1) + +STEPS = [split if DO_SPLIT] + [outstanding_split if DO_OSPLIT] + + [backlog_rename if DO_BRENAME] + [backlog_to_issues if DO_B2I] + + [refresh if DO_REFRESH] +``` + +`DO_REFRESH` reads the *raw* `LOG_DRIFT`/`CODE_STAGED` facts, not +projected ones — splitting and renaming don't touch the git-log block or +the staged-file set, so no threading is needed on this last link. + +**Why threading, not disjunctions.** An earlier draft of this spec wrote +`backlog_to_issues`'s condition as `BACKLOG_EXISTS=1 OR (OUTSTANDING_ITEMS_EXISTS=1 +AND BACKLOG_EXISTS=0)` — true now, or about to become true because +`backlog_rename` is queued. That was a real bug fix (caught by +caveman-review) but an incomplete one: `backlog_rename`'s *own* condition +(`OUTSTANDING_ITEMS_EXISTS=1 AND BACKLOG_EXISTS=0`) has the identical +problem one link further up the chain — `outstanding_split`, if also +queued, is what's about to make `OUTSTANDING_ITEMS_EXISTS` true, so a +bare `OUTSTANDING_ITEMS_EXISTS=1` snapshot check silently drops +`backlog_rename` whenever it's chained after `outstanding_split`. +Writing out the "OR about to become true" disjunction a second time +would have worked but doesn't generalize — a fifth chained trigger would +need a three-way disjunction, a sixth a four-way one. Threading a single +projected fact (`PROJ_OI`, `PROJ_BL`) forward through the pipeline scales +to any chain length and is easy to verify by construction: each trigger's +condition reads exactly the fact-state that will actually exist at its +own execution time, given everything queued before it. Verified against +all eleven fixtures below directly in `jq` before this spec was +finalized — see the Testing section. + +## Error handling + +Operational failure (jq missing, `primer-detect.jq` missing or from a +different `CONTRACT_VERSION`, a required git command failing outright) +prints one diagnostic line to stderr and **exits nonzero with no `STEPS=` +line on stdout at all** — no conservative default dispatch. Unlike +`token-overlap.sh` (where empty output safely degrades to "zero matches"), +there is no safe default here: some `STEPS` values gate destructive +migrations, and guessing wrong is worse than stopping. `commands/primer.md` +must treat a missing `STEPS=` line as a hard stop: surface the stderr +diagnostic, do not execute any step. + +## Testing + +Fixture-driven jq tests against synthetic JSON facts (no real git +required), mirroring `meta/superpowers/validation/2026-09-08-token-overlap.md`'s +format. The eleven state-machine cases below (plus a docs-allowlist +variant of case 5, and four operational-failure cases instead of the +single combined item 12 describes conceptually) make up the shipped +smoke test's 16 assertions total — see +`meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh`. + +1. Fresh install (all existence flags 0) → `STEPS=init`. +2. Existing unsplit primer, otherwise current → `STEPS=split`. +3. Split + current + clean → `STEPS=` (empty). +4. Split + current, but recorded log block differs from actual → `STEPS=refresh`. +5. Split + current, non-allowlisted file staged → `STEPS=refresh`. +6. Inline outstanding heading present, no `OUTSTANDING_ITEMS.md` → `STEPS=outstanding_split`. +7. Both unsplit AND inline-outstanding → `STEPS=split,outstanding_split` (order proves the sequencing rule). +8. `OUTSTANDING_ITEMS.md` exists, no `BACKLOG.md` → `STEPS=backlog_rename`. +9. `BACKLOG.md` exists, github origin → `STEPS=backlog_to_issues`. +10. `BACKLOG.md` exists, non-github origin → `STEPS=` (empty) — proves the fossil-file case never fires the GitHub migration. +11. Full worst-case stack (unsplit + inline-outstanding + no outstanding-items file + no backlog file + github origin) → `STEPS=split,outstanding_split,backlog_rename,backlog_to_issues` in that exact order — neither `OUTSTANDING_ITEMS.md` nor `BACKLOG.md` exists yet; both migrations still fire because they're queued via threading (`PROJ_OI`/`PROJ_BL`), which is the whole point of this case. +12. Missing/wrong-version filter, or jq absent → nonzero exit, no `STEPS=` line. + +## Rollout + +`commands/primer.md` Step 1's "Interpret the output" prose (currently +~15 lines of manual state/trigger reasoning) is replaced with: run +`primer-detect.sh`, then execute each name in `STEPS` in order, falling +through to Step 5 (check mode) when `STEPS` is empty. The per-step +sections (2, 3, 3b, 3c, 3d, 4, 5) are unchanged by this sub-project — +only the dispatch that routes into them is scripted. Version bump + +CHANGELOG entry, following the Phase 4/5 precedent. + +## Out of scope + +Sub-projects B (Step 4 test-count rerun), C (Step 3c/3d migration +mechanics themselves — this spec only scripts *whether* they run, not +*what* they do), D (Step 2 placeholder derivation), and E (Step 3/3b +section-bucketing judgment) are each their own future spec/plan cycle. diff --git a/meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh b/meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh new file mode 100755 index 0000000..9339be4 --- /dev/null +++ b/meta/superpowers/validation/2026-09-08-primer-detect-smoke.zsh @@ -0,0 +1,161 @@ +#!/usr/bin/env zsh +# primer-detect.sh/.jq smoke test. Hermetic: one scratch git repo per case, +# built fresh via mk_repo. No mocking needed -- every fact is a real file +# or a real git command against a real (throwaway) repository. +set -uo pipefail + +here="${0:A:h}" +repo="${here:h:h:h}" +lib="$repo/hooks/lib" +tool="$lib/primer-detect.sh" + +pass=0; fail=0 +ok() { print -P "%F{green}✓%f $1"; (( pass++ )); return 0; } +bad() { print -P "%F{red}✗%f $1"; (( fail++ )); return 0; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# mk_repo <dir> <split:0|1> <oi_file:0|1> <bl_file:0|1> <inline:0|1> <origin:gh|other> <staged:none|code|docs> +# Builds a fresh scratch repo with .session-continuity/ populated per the +# flags, commits everything, then writes SESSION_PRIMER.md's log block to +# exactly match the post-commit `git log --oneline -5` (never re-committed +# afterward -- see this plan's Task 1 notes on why that avoids a +# self-referential-hash problem). Callers that want DRIFT instead +# overwrite the block themselves after calling mk_repo. +mk_repo() { + local dir="$1" split=$2 oi=$3 bl=$4 inline=$5 origin=$6 staged=$7 + rm -rf "$dir" + mkdir -p "$dir/.session-continuity" + git -C "$dir" init -q + git -C "$dir" config user.email test@example.com + git -C "$dir" config user.name Test + if [[ "$origin" == "gh" ]]; then + git -C "$dir" remote add origin https://github.com/example/repo.git + else + git -C "$dir" remote add origin https://example.com/example/repo.git + fi + : > "$dir/.session-continuity/LEARNINGS.md" + (( split )) && : > "$dir/.session-continuity/PROJECT_CONTEXT.md" + : > "$dir/.session-continuity/ROADMAP.md" + (( oi )) && : > "$dir/.session-continuity/OUTSTANDING_ITEMS.md" + (( bl )) && : > "$dir/.session-continuity/BACKLOG.md" + local heading="" + (( inline )) && heading=$'## Outstanding items\n1. something\n\n' + print -r -- "${heading}# Primer + +placeholder body, overwritten below with the real log block +" > "$dir/.session-continuity/SESSION_PRIMER.md" + git -C "$dir" add -A + git -C "$dir" commit -qm init + local log + log="$(git -C "$dir" log --oneline -5)" + print -r -- "${heading}# Primer + +**Current \`git log --oneline -5\` (primary branch):** + +\`\`\` +$log +\`\`\` +" > "$dir/.session-continuity/SESSION_PRIMER.md" + case "$staged" in + code) print -r -- x > "$dir/src.js"; git -C "$dir" add src.js ;; + docs) print -r -- x >> "$dir/.session-continuity/LEARNINGS.md"; git -C "$dir" add .session-continuity/LEARNINGS.md ;; + esac +} + +steps_of() { bash "$tool" "$1" | awk -F= '/^STEPS=/{print $2}'; } + +# --- 1: fresh install (no .session-continuity/ at all) -> init ------------- +d="$work/case1"; mkdir -p "$d" +git -C "$d" init -q; git -C "$d" config user.email t@t.com; git -C "$d" config user.name T +: > "$d/README.md"; git -C "$d" add -A; git -C "$d" commit -qm init +git -C "$d" remote add origin https://github.com/example/repo.git +[[ "$(steps_of "$d")" == "init" ]] && ok "1: fresh install -> init" || bad "1: got '$(steps_of "$d")'" + +# --- 2: existing unsplit primer, otherwise current -> split ----------------- +d="$work/case2"; mk_repo "$d" 0 0 1 0 other none +[[ "$(steps_of "$d")" == "split" ]] && ok "2: unsplit only -> split" || bad "2: got '$(steps_of "$d")'" + +# --- 3: split + current + clean -> empty ------------------------------------ +d="$work/case3"; mk_repo "$d" 1 0 1 0 other none +[[ "$(steps_of "$d")" == "" ]] && ok "3: split+current+clean -> empty" || bad "3: got '$(steps_of "$d")'" + +# --- 4: recorded log block differs from actual -> refresh ------------------- +d="$work/case4"; mk_repo "$d" 1 0 1 0 other none +print -r -- "# Primer + +**Current \`git log --oneline -5\` (primary branch):** + +\`\`\` +0000000 stale placeholder +\`\`\` +" > "$d/.session-continuity/SESSION_PRIMER.md" +[[ "$(steps_of "$d")" == "refresh" ]] && ok "4: log drift -> refresh" || bad "4: got '$(steps_of "$d")'" + +# --- 5: non-allowlisted file staged -> refresh ------------------------------- +d="$work/case5"; mk_repo "$d" 1 0 1 0 other code +[[ "$(steps_of "$d")" == "refresh" ]] && ok "5: non-allowlisted staged -> refresh" || bad "5: got '$(steps_of "$d")'" + +# --- docs-only staged -> NOT refresh (allowlisted) -------------------------- +d="$work/case5b"; mk_repo "$d" 1 0 1 0 other docs +[[ "$(steps_of "$d")" == "" ]] && ok "5b: docs-only staged -> no refresh (allowlisted)" || bad "5b: got '$(steps_of "$d")'" + +# --- 6: inline heading, no OUTSTANDING_ITEMS.md -> outstanding_split ------- +d="$work/case6"; mk_repo "$d" 1 0 1 1 other none +[[ "$(steps_of "$d")" == "outstanding_split" ]] && ok "6: inline+no file -> outstanding_split" || bad "6: got '$(steps_of "$d")'" + +# --- 7: both unsplit AND inline -> split,outstanding_split (order) --------- +d="$work/case7"; mk_repo "$d" 0 0 1 1 other none +[[ "$(steps_of "$d")" == "split,outstanding_split" ]] && ok "7: unsplit+inline -> split,outstanding_split in order" || bad "7: got '$(steps_of "$d")'" + +# --- 8: OUTSTANDING_ITEMS.md exists, no BACKLOG.md -> backlog_rename ------- +d="$work/case8"; mk_repo "$d" 1 1 0 0 other none +[[ "$(steps_of "$d")" == "backlog_rename" ]] && ok "8: outstanding file, no backlog -> backlog_rename" || bad "8: got '$(steps_of "$d")'" + +# --- 9: BACKLOG.md exists, github origin -> backlog_to_issues -------------- +d="$work/case9"; mk_repo "$d" 1 0 1 0 gh none +[[ "$(steps_of "$d")" == "backlog_to_issues" ]] && ok "9: backlog exists, github -> backlog_to_issues" || bad "9: got '$(steps_of "$d")'" + +# --- 10: BACKLOG.md exists, non-github origin -> empty (fossil, no GH call) - +d="$work/case10"; mk_repo "$d" 1 0 1 0 other none +[[ "$(steps_of "$d")" == "" ]] && ok "10: backlog exists, non-github -> empty (fossil)" || bad "10: got '$(steps_of "$d")'" + +# --- 11: full worst-case stack, exact order --------------------------------- +d="$work/case11"; mk_repo "$d" 0 0 0 1 gh none +[[ "$(steps_of "$d")" == "split,outstanding_split,backlog_rename,backlog_to_issues" ]] \ + && ok "11: full stack fires in dependency order" || bad "11: got '$(steps_of "$d")'" + +# --- 12: operational failure -- missing filter, no STEPS line at all ------- +badlib="$work/badlib"; mkdir -p "$badlib" +cp "$lib/primer-detect.sh" "$badlib/" +out="$(bash "$badlib/primer-detect.sh" "$work/case3" 2>&1)"; rc=$? +[[ "$rc" -ne 0 && "$out" != *"STEPS="* ]] \ + && ok "12: missing filter -> nonzero exit, no STEPS= line" || bad "12: rc=$rc out='$out'" + +# --- 13: operational failure -- not a git repo ------------------------------ +notgit="$work/notgit"; mkdir -p "$notgit" +out="$(bash "$tool" "$notgit" 2>&1)"; rc=$? +[[ "$rc" -ne 0 && "$out" != *"STEPS="* ]] \ + && ok "13: not a git repo -> nonzero exit, no STEPS= line" || bad "13: rc=$rc out='$out'" + +# --- 14: operational failure -- wrong CONTRACT_VERSION ----------------------- +wronglib="$work/wronglib"; mkdir -p "$wronglib" +cp "$lib/primer-detect.sh" "$wronglib/" +sed 's/CONTRACT_VERSION=1/CONTRACT_VERSION=99/' "$lib/primer-detect.jq" > "$wronglib/primer-detect.jq" +out="$(bash "$wronglib/primer-detect.sh" "$work/case3" 2>&1)"; rc=$? +[[ "$rc" -ne 0 && "$out" != *"STEPS="* ]] \ + && ok "14: wrong CONTRACT_VERSION -> nonzero exit, no STEPS= line" || bad "14: rc=$rc out='$out'" + +# --- 15: operational failure -- jq absent from PATH ------------------------- +nojq="$work/nojq"; mkdir -p "$nojq/bin" +for b in bash git awk grep sed head cat mktemp dirname; do + p="$(command -v "$b")"; [[ -n "$p" ]] && ln -sf "$p" "$nojq/bin/$b" +done +out="$(PATH="$nojq/bin" bash "$tool" "$work/case3" 2>&1)"; rc=$? +[[ "$rc" -ne 0 && "$out" != *"STEPS="* ]] \ + && ok "15: jq absent from PATH -> nonzero exit, no STEPS= line" || bad "15: rc=$rc out='$out'" + +print "" +print -P "Result: %F{green}$pass passed%f, %F{red}$fail failed%f" +(( fail == 0 ))