aboutsummaryrefslogtreecommitdiff
path: root/docs/design
diff options
context:
space:
mode:
Diffstat (limited to 'docs/design')
-rw-r--r--docs/design/2026-06-21-anki-titlefix-proposal.org57
-rw-r--r--docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org68
-rw-r--r--docs/design/2026-06-21-flashcard-stats-refutation-proposal.org57
-rw-r--r--docs/design/2026-06-21-host-identity-guard-proposal.org54
-rw-r--r--docs/design/2026-06-22-inbox-zero-capture-hardening.org39
-rw-r--r--docs/design/2026-06-23-install-lang-claude-md-gap.org31
-rw-r--r--docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org124
7 files changed, 430 insertions, 0 deletions
diff --git a/docs/design/2026-06-21-anki-titlefix-proposal.org b/docs/design/2026-06-21-anki-titlefix-proposal.org
new file mode 100644
index 0000000..08b8c13
--- /dev/null
+++ b/docs/design/2026-06-21-anki-titlefix-proposal.org
@@ -0,0 +1,57 @@
+#+TITLE: Proposal — flashcard-to-anki.py deck name should come from #+TITLE
+
+From: home session, 2026-06-21. Two attached files are the edited
+canonical scripts (flashcard-to-anki.py + its test). Applied locally in
+home as a stopgap; this is the durable proposal for the rulesets
+canonical. Please reconcile and re-sync.
+
+* The bug (longstanding)
+
+flashcard-to-anki.py's default_deck_name returned input_path.stem (the
+filename), so every deck generated through flashcard-sync (which passes no
+--deck) was named after the file, e.g. "personal-drill" / "health-drill"
+/ "kit", not the curated #+TITLE.
+
+flashcard-review.org already documents the intended behavior: "The
+#+TITLE line drives ... the Anki deck name on the phone" and "derives the
+Anki deck ID from the deck name." The script never matched the doc.
+deepsat only looked correct because its first run used an explicit
+--deck "DeepSat Flashcards".
+
+* The fix
+
+default_deck_name(input_path, org_text) now scans for a #+TITLE: line
+(case-insensitive, surrounding whitespace trimmed) and returns it; falls
+back to input_path.stem when there's no non-empty #+TITLE. main() passes
+the already-read org_text. Help text + module docstring updated.
+
+TDD: the two old deck-name tests asserted the buggy basename behavior —
+rewrote them. New tests cover title-driven naming, trimming,
+case-insensitive #+title, basename fallback (no title), and basename
+fallback (blank title). Full file: 29 pass.
+
+No companion script changes needed: flashcard-sync passes no --deck so it
+picks up the new default automatically, and flashcard-stats.py already
+reads #+TITLE. flashcard-review.org needs no change (the script now
+matches what it already says).
+
+* Migration caveat (worth a line in the doc if you want)
+
+Deck ID derives from the deck name, so this fix changes the ID for any
+deck previously generated without --deck. On next import those land as
+new decks; the old basename-named decks keep their review history and
+must be deleted by hand. The workflow's existing "Stable-ID caveat"
+already covers the mechanics. In home this affected personal-drill,
+health-drill, kit (regenerated this session as Personal / Health / KIT,
+with titles also stripped of "Flashcards"/"Drill" per Craig). deepsat is
+unaffected (already title-named).
+
+* Related idea (separate, not in these files) — apkg → org-drill converter
+
+deepsat-fundamentals.apkg (100-card DeepSat subset, made once with
+--deck "DeepSat Fundamentals") has no saved .org source anywhere. Craig
+wants an apkg → org-drill converter — the inverse of flashcard-to-anki.py
+— to recover orphaned decks and pull phone-authored cards back into the
+org source-of-truth. Flagging as a candidate rulesets tool alongside the
+flashcard-* family; deepsat-fundamentals is the concrete first use case.
+Not built yet; raising for the backlog.
diff --git a/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org b/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org
new file mode 100644
index 0000000..37a866f
--- /dev/null
+++ b/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org
@@ -0,0 +1,68 @@
+#+TITLE: Build request — apkg → org-drill converter (inverse of flashcard-to-anki.py)
+
+From: home session, 2026-06-21. Craig wants this built (backlogged, not
+urgent). Standalone build request — the earlier anki-title-fix-proposal
+only mentioned it in passing; this is the real ask.
+
+* Why
+
+The flashcard pipeline is one-directional (org-drill → apkg). Decks
+authored or curated on the phone, and orphaned apkgs whose .org source
+was never saved, can't get back into the org source-of-truth. Concrete
+case: deepsat-fundamentals.apkg — a 100-card DeepSat subset generated
+once with --deck "DeepSat Fundamentals" — has no .org source anywhere on
+ratio, velox, or in work git history. The converter recovers it and makes
+phone → org round-tripping possible.
+
+* What — contract (inverse of flashcard-to-anki.py)
+
+Input: an Anki =.apkg= (a zip containing collection.anki2 / .anki21
+sqlite, plus a media blob).
+Output: an org-drill =.org= file in the house canonical shape that
+flashcard-stats.py / flashcard-to-anki.py already agree on.
+
+Mapping (mirror flashcard-to-anki.py's parse/build):
+- Deck name (from the apkg) → =#+TITLE:=.
+- Each note → =** <Front> :drill:= with the Back as the body.
+- Card tag → top-level =* Section= grouping (inverse of section_to_tag;
+ cards sharing a tag collect under one section; the slug won't round-trip
+ to the exact original section title, so this is best-effort — emit the
+ tag as the section heading and let a human retitle).
+- Back HTML → org: convert =<br>= back to newlines; unescape
+ =&amp;/&lt;/&gt;=; strip the =<hr id="answer">= the card template adds
+ (the Back field itself shouldn't contain it, but guard anyway).
+- Generate a fresh =:ID:= UUID per card in a =:PROPERTIES:= drawer so the
+ output is immediately org-drill-valid and round-trips back through
+ flashcard-to-anki.py. (Note: GUIDs in flashcard-to-anki.py are derived
+ from the front text, not the :ID:, so a regenerated apkg still matches
+ existing phone cards by front — call that out in the docstring.)
+
+Edge cases to cover in tests (Normal/Boundary/Error):
+- Multiple decks in one apkg (emit one file per deck, or error asking for
+ a deck filter — pick one and document it).
+- Notes with multiple fields / non-basic note types (the pipeline only
+ models Front/Back — skip or warn on others, don't silently drop).
+- HTML entities, embedded =<br>=, and any =Source:= footer surviving
+ round-trip.
+- Empty back; media references (flag, since org side has no media path).
+- collection.anki2 vs .anki21 schema differences.
+
+* Where it lives
+
+Rulesets-owned, beside the flashcard-* family
+(=claude-templates/.ai/scripts/=): suggest =anki-to-flashcard.py= (or
+=apkg-to-orgdrill.py= — your naming call). Add tests under
+=scripts/tests/=. A new file can't be built downstream — home/.ai/scripts/
+is wiped to match the template by the startup =--delete= rsync — so this
+has to be built in the rulesets canonical. PEP 723 uv-run script like its
+sibling; genanki isn't needed for reading (stdlib =zipfile= + =sqlite3=
+suffice), so it has no runtime deps.
+
+* Acceptance
+
+Round-trip test: take a known org-drill source, run it through
+flashcard-to-anki.py, run the result back through this converter, and
+assert the cards (front/back/section) match the original (modulo
+regenerated :ID:s and best-effort section titles). Plus: run it on the
+real deepsat-fundamentals.apkg and hand the recovered .org back so its
+source can be filed (work project).
diff --git a/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org b/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org
new file mode 100644
index 0000000..bbbe175
--- /dev/null
+++ b/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org
@@ -0,0 +1,57 @@
+#+TITLE: Proposal — flashcard-stats.py refutation / claim-prompt mode
+
+From: home session, 2026-06-21. Backlog, not urgent. Relates to the
+refutation-drill deck being built in the home project.
+
+* Problem
+
+A new card family doesn't fit the linter: the *refutation / claim-prompt*
+card. Its heading is a bare false claim ("The earth is flat.") and its
+body is the rebuttal. This is a legit org-drill simple card (org-drill is
+happy), but flashcard-stats.py — built for Q&A decks — trips two BLOCKING
+checks on every such card, both false positives:
+
+- *non-prompt heading*: a declarative claim has no '?' and no
+ imperative verb, so it reads as "topic-as-heading not yet rewritten".
+ But for this family the declarative claim IS the intended prompt.
+- *answer leakage*: the claim's words necessarily reappear in the
+ refutation, so front/back overlap is high. But the answer (the rebuttal)
+ is not given away by the claim — there's no actual leakage.
+
+Concrete: the home refutation-drill.org (6 cards) reports 6 non-prompt
+headings + 1 leakage WARN, so flashcard-sync's gate blocks it entirely.
+The deck currently has to be generated with the flashcard-to-anki.py
+override, losing the safety net.
+
+* Proposed fix
+
+A per-deck opt-in marker that switches the two checks off for that file
+only. Two options (your call):
+
+1. A file-level keyword: =#+DECK_KIND: refutation= near the top. When
+ present, flashcard-stats skips the non-prompt-heading check and the
+ answer-leakage check for the whole file (keeps the others:
+ missing-:ID:, *** Answer sub-headers, duplicate fronts, the
+ non-blocking NOTEs).
+2. A per-card tag: cards tagged =:claim:= (alongside =:drill:=) are
+ exempted from those two checks individually.
+
+Option 1 is simpler and matches how this deck works (the whole file is
+one family). Option 2 is finer-grained if a deck ever mixes families.
+
+Either way: document the new card family in flashcard-review.org (a
+"Refutation / claim-prompt cards" subsection under Canonical Card Shape —
+heading is the bare claim, body is snap-response + backups + named-fallacy
++ restate, Source footer), and note that flashcard-sync then works
+normally on these decks.
+
+* Affected files
+- =flashcard-stats.py= — the check skip + (option 1) keyword parse / (option 2) tag check.
+- =flashcard-review.org= — document the family + the marker.
+- =flashcard-to-anki.py= / =flashcard-sync= — no change needed (they don't gate on heading form).
+- Tests: add cases for a refutation-marked file passing despite declarative headings + claim/answer overlap.
+
+* Companion context
+The home deck's card format and the org-drill-fine / Anki-linter-fights
+finding are written up in home:refutation-drill-sources.org (Tooling
+note). The override command is documented there too.
diff --git a/docs/design/2026-06-21-host-identity-guard-proposal.org b/docs/design/2026-06-21-host-identity-guard-proposal.org
new file mode 100644
index 0000000..f389825
--- /dev/null
+++ b/docs/design/2026-06-21-host-identity-guard-proposal.org
@@ -0,0 +1,54 @@
+#+TITLE: From archsetup — hardcoded machine identity in CLAUDE.md (consider fleet-wide)
+#+DATE: 2026-06-21
+
+* What we did
+
+Built a Super+F Dirvish popup in the archsetup/dotfiles + .emacs.d projects,
+modeled on the existing Super+Shift+N org-capture popup (launcher script names an
+emacsclient frame, Hyprland window rules float it, an Emacs command runs in the
+frame and q closes it). Cross-project: dotfiles half committed from archsetup,
+Emacs half handed off to .emacs.d's inbox.
+
+* The bug it surfaced
+
+While stowing on this machine, =make stow hyprland= pulled the *velox* host tier,
+and =uname -n= returned =velox=. But archsetup's CLAUDE.md asserted, as a fixed
+fact, "This machine is **ratio**." It was simply wrong on velox — a stale
+identity baked into a per-project doc that travels to every machine via git.
+
+I'd been reasoning from that line all session (e.g. "the touchpad-auto reminder
+is velox-only, and we're on ratio, so skip it") — exactly backwards. A hardcoded
+"this machine is X" in a synced/tracked project file is a latent trap on any
+multi-machine setup: the file is identical on every host, so the claim is false
+on every host but one.
+
+* The fix (this project)
+
+Replaced the fixed identity with a runtime instruction. The attached CLAUDE.md
+now reads, in the Notes section:
+
+ Never assume which machine this is — always run =uname -n= to find the hostname
+ (the =hostname= binary is absent, so =uname -n= is the source of truth;
+ =uname -r= is the kernel release, not the host). The fleet is ratio
+ (workstation) and velox (laptop), both Hyprland (Wayland)...
+
+(Craig initially said =uname -r=; that's the kernel release. =uname -n= is the
+nodename/hostname, which is what the stow host-tier logic already keys on.)
+
+* Why this is a rulesets concern
+
+This isn't an archsetup-only quirk. Any project whose CLAUDE.md / notes get
+synced or cloned across machines can hardcode environment identity — current
+host, current OS, "the laptop", an IP, a display name — and be wrong everywhere
+the doc lands but the origin. rulesets governs how every project's CLAUDE.md and
+rules are shaped, so it's the right layer to consider a general guard:
+
+- A rule (claude-rules) along the lines of: don't assert mutable
+ environment/host identity as a fixed fact in a tracked/synced project file;
+ derive it at runtime (=uname -n= for host, etc.) and name the command.
+- Possibly a startup or codify-time lint that flags "this machine is <name>" /
+ "the current host is" style claims in CLAUDE.md.
+
+Sending the edited CLAUDE.md (attached separately) plus this note so the rulesets
+session can decide whether to codify the broader pattern. Proposal, not a
+directive — your value gate applies.
diff --git a/docs/design/2026-06-22-inbox-zero-capture-hardening.org b/docs/design/2026-06-22-inbox-zero-capture-hardening.org
new file mode 100644
index 0000000..69acf94
--- /dev/null
+++ b/docs/design/2026-06-22-inbox-zero-capture-hardening.org
@@ -0,0 +1,39 @@
+#+TITLE: inbox-zero Phase D wedges live org-capture sessions on the roam inbox
+
+* The bug
+
+Phase D of =inbox-zero.org= removes claimed items from =~/org/roam/inbox.org= by editing the file on disk (Edit / sed / Write). That collides with any live org-capture session Craig has open against the same file.
+
+org-capture works through an *indirect buffer* cloned from the target file. When the inbox-zero disk write lands and Emacs reverts the main =inbox.org= buffer underneath, the indirect capture buffers are left pointing at stale state and wedge — they can no longer finalize cleanly with =C-c C-c=. The visible symptom is org-capture failing, and one or more orphaned =CAPTURE-*inbox.org= / =CAPTURE-N-inbox.org= buffers piling up as Craig retries.
+
+Hit live on 2026-06-22 during a home-session inbox-zero: I filed three home items, wrote =inbox.org= on disk, and Craig's open capture wedged, leaving two orphaned =CAPTURE-inbox.org= buffers. No data was lost that time (the orphaned buffers held only existing file content, not a freshly-typed item), but that was luck — had he typed an item into the capture before it wedged, finalizing the stale buffer afterward would have written it back against the post-edit file and could have clobbered the routing or a foreign item.
+
+* Why it's worth fixing in the canonical
+
+=inbox-zero.org= is a rulesets-owned synced workflow that runs in every project (startup nudge, wrap-up sub-step, on demand), and the roam inbox is the single shared file all of them edit. Craig edits that same file live in Emacs and captures into it constantly. So the collision window recurs in every project, every session — not a home-only quirk. A local fix in home gets reverted by the next template sync, so the durable fix has to land in rulesets.
+
+* Proposed fix (recommended: guard before the disk write)
+
+Add a pre-edit guard to Phase D, before removing any claimed items:
+
+1. If Emacs is reachable (=emacsclient -e t= succeeds), check for live capture buffers targeting the roam inbox:
+
+ #+begin_src bash
+ emacsclient -e '(mapcar #(quote buffer-name)
+ (seq-filter (lambda (b) (string-match-p "CAPTURE.*inbox" (buffer-name b)))
+ (buffer-list)))' 2>/dev/null
+ #+end_src
+
+2. If any =CAPTURE-*inbox*= buffer exists, *stop before editing* and surface it: "You have a live org-capture session open against the roam inbox — finalize (=C-c C-c=) or abort (=C-c C-k=) it before I route items, otherwise the edit will wedge the capture." Resume Phase D only once it's clear. This mirrors the existing pull-before-edit / surface-and-stop discipline already in Phase D.
+
+3. Independently, when Emacs has =inbox.org= open and *unmodified* (the common case, no live capture), the disk edit is benign — Emacs reverts a clean buffer without complaint. Optionally trigger an explicit =revert-buffer= via emacsclient afterward so the buffer is immediately consistent rather than lazily on next focus.
+
+* Alternative (heavier): do the removal through Emacs when it's running
+
+Instead of editing on disk, when Emacs is reachable, perform the claimed-item removal inside the running daemon (find the buffer, delete the items, save), and fall back to the disk edit only when Emacs isn't running. This keeps Emacs's buffer authoritative and sidesteps the disk/buffer divergence entirely. It's more code and more failure surface for arbitrary item removal, so I'd lean on the guard above unless you want the stronger guarantee.
+
+* Note for whoever builds it
+
+The =emacs.md= rule already covers "don't make Craig restart Emacs; push changes into the running daemon." This is the same principle one layer out: don't edit a file *on disk* that the running daemon is actively editing/capturing into. Worth a line in =emacs.md= too, or at least a cross-reference from inbox-zero Phase D.
+
+Origin: home, 2026-06-22.
diff --git a/docs/design/2026-06-23-install-lang-claude-md-gap.org b/docs/design/2026-06-23-install-lang-claude-md-gap.org
new file mode 100644
index 0000000..cf16256
--- /dev/null
+++ b/docs/design/2026-06-23-install-lang-claude-md-gap.org
@@ -0,0 +1,31 @@
+#+TITLE: install-lang CLAUDE.md gap — non-elisp projects get a wrong or missing CLAUDE.md
+#+DATE: 2026-06-23
+
+Surfaced while running the archangel .ai/ conversion you sent (the 2026-06-20 handoff). archangel is a bash project — 437 =.sh= files; the only =.el=/=.py= in the tree are under =work/x86_64/airootfs/= (archiso staging, not source). Per your handoff I installed both elisp and python bundles. The result exposed two coupled issues that block CLAUDE.md consistency across projects.
+
+* The two findings
+
+1. *Only the elisp bundle ships a CLAUDE.md template.* =languages/elisp/CLAUDE.md= exists; =languages/python/=, =go/=, =typescript/= ship none. =install-lang.sh= guards on =[ -f "$SRC/CLAUDE.md" ]=, so a bundle without a template silently contributes nothing — no line printed, no file seeded.
+
+2. *No shell/bash bundle exists* (only elisp, go, python, typescript). archangel and archsetup are bash projects with no bundle that fits.
+
+* The consequence
+
+- Install python (or go/ts) alone → project gets *no* CLAUDE.md.
+- Install elisp + anything → project gets the elisp stub, whose first line is "Elisp project." Because install-lang seeds CLAUDE.md only on first install and never overwrites without FORCE=1, install order doesn't matter — the elisp template is the only one available, so it always wins.
+- Net: archangel, a bash project, ended up with a CLAUDE.md headed "Elisp project." An inaccurate CLAUDE.md is worse than none — it mislabels the project for every future session.
+
+* Proposals (rulesets' call)
+
+1. *Add a shell/bash language bundle.* This is the real gap for archangel/archsetup and any other shell-heavy project.
+2. *Give every bundle its own CLAUDE.md template*, or ship a language-neutral default so install-lang always seeds an accurate (or at least non-misleading) header. A stub that says "<LANG> project — customize this" is only safe when the bundle actually matches the language.
+3. *Consider the multi-bundle case* — when a project installs more than one bundle, the CLAUDE.md "Project" line shouldn't hardcode a single language picked by which template happened to exist.
+
+* Companion files to reconcile
+
+- =scripts/install-lang.sh= — the seed-on-first-install / no-overwrite logic (sections 3 and 3b) is correct; the gap is the missing templates and missing bash bundle, not this logic.
+- =languages/elisp/CLAUDE.md= — the only template today; pattern to replicate per language.
+
+* What archangel did locally (stopgap)
+
+Installed both bundles as you asked; the generic =.claude/rules/= and gitignore hygiene are the real gain there. I flagged the elisp-stub mismatch to Craig and offered to hand-write archangel's CLAUDE.md as a bash ISO-build project. That local fix doesn't address the cross-project pattern — hence this note.
diff --git a/docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org b/docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org
new file mode 100644
index 0000000..a47aa2d
--- /dev/null
+++ b/docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org
@@ -0,0 +1,124 @@
+#+TITLE: Proposal — wrap-it-up teardown + "wrap it up and shutdown" variant
+
+* Source
+
+Raised by Craig in a home-project session, 2026-06-23, after talking the
+design through. Two related additions to =wrap-it-up.org=. Both touch the
+Claude-session lifecycle (workflow + hook + the =ai-term= buffer/tmux pair),
+so they're rulesets — with one companion piece that has to live in
+=.emacs.d/modules/ai-term.el= (flagged below). Originally floated as an
+archsetup task; archsetup owns the Hyprland/waybar layer, not the
+Claude-session lifecycle, so it was re-routed here.
+
+* Architecture this depends on (so the design is grounded)
+
+- =ai-term.el= (=.emacs.d=) is the in-Emacs launcher: a vertical-split vterm
+ buffer running a tmux session named =aiv-<project-basename>= (prefix
+ =aiv-=). Layering: =claude= process → tmux session =aiv-<proj>= → Emacs
+ vterm buffer.
+- Killing the tmux session takes the =claude= process with it, so "quit
+ Claude Code" is a *consequence* of killing =aiv-<proj>=, not a separate
+ step.
+- Hooks already exist under =~/.claude/hooks/= (e.g. =session-clear-resume.sh=,
+ =precompact-priorities.sh=) — the teardown trigger fits that pattern.
+- =sudo= is =NOPASSWD: ALL= on Craig's machines, so =sudo shutdown now= runs
+ unattended.
+
+* Item 1 — wrap-up also removes the buffer, quits Claude, removes the tmux session
+
+Recommend: yes, with one structural rule — the wrap-up runs *inside* the
+things it tears down, so teardown is self-terminating and must be the last,
+decoupled action, or the valediction may not flush before the session dies.
+
+Design:
+1. *Teardown lives in =ai-term.el=* (companion, see below): one function
+ =cj/ai-term-quit= that kills the =aiv-<proj>= tmux session (takes =claude=
+ with it), kills the vterm buffer, and restores the saved window geometry —
+ =ai-term.el= already owns the buffer↔session pair and the geometry logic.
+2. *Trigger from a Stop / SessionEnd hook, not inline.* Wrap-up does all its
+ git/archive work, delivers the valediction, then drops a sentinel (flag
+ file, e.g. =/tmp/ai-wrap-teardown-<session>=). The hook fires when Claude
+ finishes, sees the sentinel, and runs =cj/ai-term-quit= via =emacsclient=.
+ Decoupling guarantees the valediction lands before the session dies.
+3. *Gate on commit+push verified* — never tear down before the session record
+ is pushed (wrap-up's existing Step 4 / validation checklist already
+ enforces push; teardown is strictly after it).
+4. *Phrase split — teardown IS the default* (Craig's decision 2026-06-23).
+ Bare "wrap it up" does the full wrap AND removes the buffer/session/quits —
+ that's his typical case. The non-destructive variant gets the explicit
+ qualifier: "wrap it up with summary" summarizes + commits + pushes +
+ archives but keeps the buffer (no teardown), so the summary stays readable.
+ So: "wrap it up" → teardown; "wrap it up with summary" → no teardown;
+ "wrap it up and shutdown" → wrap + poweroff (supersedes teardown, Item 2).
+
+* Item 2 — "wrap it up and shutdown": 10-count then =sudo shutdown now=
+
+Recommend: yes, but the safety gate is load-bearing and the countdown has a
+rendering gotcha.
+
+Design:
+1. *"Only ai-term left" = hard blocking precondition*, evaluated BEFORE the
+ countdown. Count live sessions (=tmux ls | grep '^aiv-'= or
+ =pgrep -fc claude=). If more than this one is alive, ABORT the shutdown,
+ list what's running, and fall back to a normal wrap. Never power the box
+ off out from under another active Claude session. This is the most
+ important part of the item.
+2. *The live countdown can't run through Claude's tool output.* The Bash tool
+ buffers stdout until the command returns, so a =for i in $(seq 10 -1 1);
+ sleep 1= prints all ten at once at the end, not one per second. It has to
+ run detached or in Emacs:
+ - tty writer: =for i in $(seq 10 -1 1); do printf '\rShutting down in %2d…'
+ "$i" > /dev/tty; sleep 1; done; sudo shutdown now= (backgrounded), or
+ - an Emacs =run-at-time= timer printing 10→1 in the echo area, then
+ =(shell-command "sudo shutdown now")=.
+3. *Make it abort-able* (Ctrl-C / keypress cancels). A 10-second countdown's
+ whole purpose is a last-chance window; a non-cancellable one is just a
+ delay.
+4. *Sequencing.* "...and shutdown" supersedes Item 1's teardown — if the box
+ is powering off, killing the buffer/session first is moot. Wrap (commit +
+ push + archive) → session-count gate → countdown → =shutdown=.
+
+Packaging: a small rulesets bin script (e.g. =ai-wrap-shutdown=) doing the
+gate → abort-able countdown → shutdown, invoked by the workflow after the wrap
+commit/push. Countdown either in that script (tty) or handed to Emacs.
+
+* Companion — required change in =.emacs.d/modules/ai-term.el=
+
+Item 1's teardown function =cj/ai-term-quit= must live in =ai-term.el= (it
+owns =aiv-<proj>= session naming, the vterm buffer, and geometry restore).
+rulesets owns the workflow + hook + bin script that *call* it; =.emacs.d= owns
+the function itself. Spec for the =.emacs.d= side:
+
+- =cj/ai-term-quit (&optional project)= — resolve the =aiv-<basename>= session
+ for the current/!named project, =tmux kill-session= it, =kill-buffer= the
+ associated vterm buffer, restore saved geometry. Idempotent / no-op if the
+ session or buffer is already gone. Callable from =emacsclient -e= so the
+ Stop hook can invoke it headlessly.
+- (Optional) a count helper =cj/ai-term-live-count= so the Item-2 gate can ask
+ Emacs how many ai-term sessions are live, as an alternative to =tmux ls= /
+ =pgrep=.
+
+When rulesets builds the workflow/hook side, route this companion to
+=.emacs.d= (inbox-send) so the two land together.
+
+* Open decisions for Craig
+
+- Phrase set: DECIDED (2026-06-23) — "wrap it up" tears down (default);
+ "wrap it up with summary" wraps without teardown; "wrap it up and shutdown"
+ is the poweroff variant. Remaining nuance: confirm the exact non-destructive
+ qualifier wording is "with summary" (vs e.g. "and summarize").
+- Countdown home: tty-writer bin script vs Emacs timer. (Emacs timer reads
+ cleaner inside the vterm and is trivially abort-able.)
+- Session-count mechanism for the gate: =tmux ls=, =pgrep claude=, or
+ =cj/ai-term-live-count=.
+
+* Verify
+
+- Item 1: bare "wrap it up" → valediction renders fully, THEN buffer +
+ =aiv-<proj>= session + claude all gone, geometry restored; "wrap it up with
+ summary" → wrap completes but the buffer stays intact (no teardown).
+- Item 2 gate: with a second =aiv-*= session alive, "wrap it up and shutdown"
+ refuses, lists the other session, and does a normal wrap (no poweroff).
+- Item 2 happy path: sole session → 10→1 renders one-per-second, is
+ cancellable, then =shutdown= fires.
+- Teardown never runs before commit+push is verified.