diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b5219f2..1654d50 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session-continuity", - "version": "0.29.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/.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 07a2e7a..ba60047 100644 --- a/.session-continuity/SESSION_PRIMER.md +++ b/.session-continuity/SESSION_PRIMER.md @@ -20,6 +20,58 @@ 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 + *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 + 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`).** Backlog: architect-workbench's `4e81` / this repo's own @@ -620,11 +672,11 @@ rarely. **Current `git log --oneline -5` (primary branch):** ``` -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) +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 cc3f002..1cca560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ 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 +- **`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 +- **`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 +- **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/commands/end-session.md b/commands/end-session.md index fe30b62..60837d0 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, @@ -69,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 @@ -83,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 ..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 `#Ncommit 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 @@ -158,6 +187,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: @@ -228,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 ..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 - `` as the list ordinal** (e.g. `4. [a3f9] `) - 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 ` → item [a3f9]`, verification - candidates as `item [a3f9] ()`. 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 `` and the code-evidence + a markdown ordered list, one item per line, identified by `#N`** (e.g. + `4. #40 `) — never a bare bullet list or an + inline comma-separated citation. Cite each candidate by tag: commit-subject + matches as ` → item #40`, verification candidates as `item #40 ()`. 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 `` 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 @@ -408,98 +455,98 @@ 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. +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 +### Gather the facts and render -Run all six in **one Bash call** (one round trip, not six), timed: +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. - -### 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): +**Field notes — the values only you know, filled in before running the block above:** -| 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_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. -**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. +**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. -### Suggested commit message +## Step 4 — Ritual timing (always) -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" -``` - -*(Illustrative only — the real Backlog row reflects the current primer's actual item set and verdicts.)* - -## Step 4 — Terminal sign-off (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 @@ -552,21 +599,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 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/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 )) 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/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/hooks/lib/checklist-assemble.sh b/hooks/lib/checklist-assemble.sh new file mode 100755 index 0000000..68901b0 --- /dev/null +++ b/hooks/lib/checklist-assemble.sh @@ -0,0 +1,180 @@ +#!/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 [<backlog-tsv-path>] +# 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. +# +# <backlog-tsv-path> 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() { # <detail> + 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 + +# --- 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 + # 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/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 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/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/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/recommendations/docguard-design-sketch.md b/meta/superpowers/recommendations/docguard-design-sketch.md index b4d5310..179b05d 100644 --- a/meta/superpowers/recommendations/docguard-design-sketch.md +++ b/meta/superpowers/recommendations/docguard-design-sketch.md @@ -20,18 +20,33 @@ 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: <glob>, -claim_pattern: <regex w/ capture>, actual_command: <shell>}` 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`. +- Implements GitHub issue #38 (closing it is a separate follow-up step, not + yet done as of this commit). 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..d88083a 100644 --- a/meta/superpowers/specs/2026-09-02-determinism-program-design.md +++ b/meta/superpowers/specs/2026-09-02-determinism-program-design.md @@ -180,32 +180,44 @@ 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 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 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 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` — 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.** 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-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)" 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/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..e770cd8 --- /dev/null +++ b/meta/superpowers/validation/2026-09-08-checklist-assemble-smoke.zsh @@ -0,0 +1,187 @@ +#!/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: 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")" +[[ "$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 )) 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 )) 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. 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.