diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..da27e47 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + macos: + runs-on: macos-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - name: Tool versions + run: | + bash --version | head -1 + /bin/bash --version | head -1 + python3 --version + jq --version + git --version + - name: Run the suite + run: ./tests/run.sh + - name: Run the suite under the system bash + run: ./tests/run.sh --system-bash diff --git a/CLAUDE.md b/CLAUDE.md index 8a497a6..a8029cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # claude-code-session-workflow -A public kit that installs a session workflow into someone's Claude Code: four slash commands, a shared audit include, the compact-resume hooks, an optional terseness enforcer, a CLAUDE.md block, and Karabiner chords. Read `README.md` for what it is and `INSTALL.md` for how it installs. `docs/README.md` indexes the design docs. +A public kit that installs a session workflow into someone's Claude Code: four slash commands, a shared audit include, the compact-resume hooks, an optional terseness enforcer, a CLAUDE.md block, and Karabiner keyboard shortcuts. Read `README.md` for what it is and `INSTALL.md` for how it installs. `docs/README.md` indexes the design docs. ## Layout @@ -11,13 +11,14 @@ A public kit that installs a session workflow into someone's Claude Code: four s - `karabiner/` `build.py` generates both rule files; never edit the JSON by hand - `install/` `install.sh` (check / plan / apply / verify / uninstall) plus the three Python helpers it calls: `merge-settings.py` (hook registrations), `update-claude-md.py` (the marked block), `manifest.py` (the install record uninstall replays) - `tests/run.sh` the whole suite, in a throwaway HOME +- `docs/images/` the three figures the README embeds and the 1280x640 social preview; `.github/workflows/tests.yml` runs `tests/run.sh` on a macOS runner for every pull request and every push to `main` ## Rules for changes - This repo is public. No private paths, personal data, or internal tooling vocabulary anywhere: not in code, docs, commits or PR text. - Only verifiable claims. Behaviour seen on the author's machine but not in the Claude Code docs is labelled as such in the docs. - One tldr policy: 10 lines / 130 words. It lives in `claude-md/tldr-block.md` and in the defaults of `hooks/terse-gate.sh`; change both or neither. -- Changing a chord: edit `karabiner/build.py`, run it, commit the regenerated JSON. +- Changing a shortcut: edit `karabiner/build.py`, run it, commit the regenerated JSON. - Changing a hook registration: edit `CORE` / `TERSE` in `install/merge-settings.py` and the `FILES` list in `install/install.sh` if a file is added. - Run `./tests/run.sh` before every PR. It must end with `failed: 0`. Every behaviour fix gets a test in the same change. - Commit messages: lowercase, imperative, type prefix (`add:`, `fix:`, `update:`, `docs:`). diff --git a/INSTALL.md b/INSTALL.md index 5cfb6cc..3932a02 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -38,9 +38,9 @@ These are the steps for the agent. If you are a person reading this, you can fol ``` bash install/install.sh check ``` - Stop and report if `python3` or `jq` is missing, or if the existing `settings.json` is not valid JSON (the installer will refuse to touch it). Tell the user which surface they are on: `/archive-session` only works in the desktop app, and the chords only on macOS with Karabiner-Elements. + Stop and report if `python3` or `jq` is missing, or if the existing `settings.json` is not valid JSON (the installer will refuse to touch it). Tell the user which surface they are on: `/archive-session` only works in the desktop app, and the keyboard shortcuts only on macOS with Karabiner-Elements. -3. **Ask about the two optional components before planning.** The terse gate (`--terse-gate`) blocks over-long replies when the user typed `tldr`; it is useful but opinionated. Tell the user the stash hook keeps a copy of each prompt on disk for 24 hours (a private file under the kit's state directory; the Stop hook needs it to know `tldr` was asked for). The chords (`--karabiner`) need Karabiner-Elements. Default to neither unless the user says yes. +3. **Ask about the two optional components before planning.** The terse gate (`--terse-gate`) blocks over-long replies when the user typed `tldr`; it is useful but opinionated. Tell the user the stash hook keeps a copy of each prompt on disk (a private file under the kit's state directory; the Stop hook needs it to know `tldr` was asked for). Prompt files older than 24 hours are deleted on the next stash-hook run; without another run, they remain on disk. The keyboard shortcuts (`--karabiner`) need Karabiner-Elements. Default to neither unless the user says yes. 4. **Plan, and show the user the output.** ``` @@ -60,7 +60,7 @@ These are the steps for the agent. If you are a person reading this, you can fol ``` It checks every file is present and executable, the hook registrations exist, the `CLAUDE.md` block is present and current, whether a manifest exists (a by-hand install has none; that is a note, not a failure), and it runs the three compact-resume hooks on sample input in a temporary state directory (store → precompact → inject, then a second inject that must emit nothing). It ends with `VERIFIED` or `FAILED`. Report the result verbatim. If it fails, do not retry blindly: show the failing line. -7. **Karabiner, if chosen.** The file is now in `~/.config/karabiner/assets/complex_modifications/`. Enabling it is a UI step the user does: Karabiner-Elements Settings → Complex Modifications → Add predefined rule → "Claude Code session workflow" → Enable. Tell them the chords assume a US-style layout and an empty input line. The scoped variant (`karabiner/session-workflow-scoped.json`) fires only in the Claude app, Terminal, Ghostty, VS Code and iTerm2; copy it the same way if they want the chords off elsewhere. In the Karabiner list it appears as "Claude Code session workflow (Claude app and terminals only)". +7. **Karabiner, if chosen.** The file is now in `~/.config/karabiner/assets/complex_modifications/`. Enabling it is a UI step the user does: Karabiner-Elements Settings → Complex Modifications → Add predefined rule → "Claude Code session workflow" → Enable. Tell them the shortcuts assume a US-style layout and an empty input line. The scoped variant (`karabiner/session-workflow-scoped.json`) fires only in the Claude app, Terminal, Ghostty, VS Code and iTerm2; copy it the same way if they want the shortcuts off elsewhere. In the Karabiner list it appears as "Claude Code session workflow (Claude app and terminals only)". 8. **Finish.** Tell the user: the session may need a restart for the hooks to load, which commit was installed, where the backups are, and that `bash install/install.sh uninstall` undoes what the manifest recorded (see Uninstall below for what that covers). Suggest a first run: `/compact-check` in any repo. @@ -120,8 +120,8 @@ Undoes what the manifest recorded, each item after a prompt (or all at once with - a file the kit created is deleted; a file the kit replaced is restored from the backup of the original - only the hook registrations the kit added are removed; one that was already there, even if it runs a kit script, stays -- the `CLAUDE.md` block is removed; a `CLAUDE.md` the kit created is deleted if nothing else is in it, and one the kit appended to keeps everything outside the block -- the Karabiner rule file is deleted or restored the same way (disable the rule in the UI first) +- the `CLAUDE.md` block is removed when the manifest records that the kit wrote it; a block that was already in place stays; a `CLAUDE.md` the kit created is deleted if nothing else is in it, and one the kit appended to keeps everything outside the block +- the Karabiner rule file is deleted or restored the same way when the manifest records it (disable the rule in the UI first); a rule file the install did not write stays - the manifest is deleted only when nothing managed remains; a declined prompt keeps it, so a later run can finish the job Backups and logs under `session-workflow-kit/` are kept. Without a manifest (a manual install), the kit's files are removed by name and registrations that run a kit script under the hooks directory are removed; nothing can be restored that way. diff --git a/README.md b/README.md index 2375995..9627aca 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Claude Code session workflow -Four slash commands, a pair of hooks and five one-handed chords that close the loop around `/compact` and the end of a session. +Keep the plan when you run `/compact`, and never end a session with work that exists only in chat. Four slash commands, two hooks and five one-handed keyboard shortcuts. This is the setup I run every day. -`/compact-check` audits the repo, drives it to a clean state and writes a resume block. The hooks put that block back after `/compact`, so you never copy and paste it. `/end-session` refuses to green-light while anything of value lives only in chat or on an unpushed branch. `/archive-session` closes the session once its PRs are merged. A `tldr` reply rule keeps answers short, with an optional hook that enforces the ceiling. This is the setup I run every day. +![Compacting a session. Before: four steps by hand, /compact-check, copy block, /compact, paste block. Now: two keys, Right Option plus comma for /compact-check and Right Option plus period for /compact.](docs/images/before-after.webp) ## Install with one prompt @@ -12,22 +12,33 @@ Paste this into Claude Code: Read https://github.com/dabodamjan/claude-code-session-workflow and set it up for me by following its INSTALL.md. ``` -This is a guided install, not an unattended one. Claude clones the repo, runs the requirements check, shows you every change as a diff (the files it copies, the hook registrations it adds to `settings.json`, the block it appends to `CLAUDE.md`), asks before writing, backs up every file it replaces, and verifies the result with a dry run of the hooks. Running it twice adds nothing the second time. `uninstall` undoes what the install recorded: files the kit created go, files it replaced come back from their backup, and only the registrations it added are removed. If you would rather not have an agent touch your config, [INSTALL.md](INSTALL.md) has the manual path too. +Nothing is written before you see it. Claude runs `install.sh plan`, which lists every file it would create, update or leave alone, shows the diff of each update (long ones truncated) and the diffs of the hook entries it adds to `settings.json` and of the block it appends to `CLAUDE.md`, then asks before `apply`. + +Every file it replaces is backed up first. Running the same install again changes no installed file and adds no second hook entry; only the manifest metadata is refreshed. `install.sh uninstall` reverses exactly what the install recorded. [What uninstall covers](INSTALL.md#uninstall) · [Manual install](INSTALL.md#path-2-by-hand) · [Tests](tests/run.sh) + +Needs macOS or Linux, `bash`, `jq`, `python3` 3.8 or newer and `git`; `gh` is optional. Works in the CLI, the VS Code extension and the desktop app. + +[![tests](https://github.com/dabodamjan/claude-code-session-workflow/actions/workflows/tests.yml/badge.svg)](https://github.com/dabodamjan/claude-code-session-workflow/actions/workflows/tests.yml) +Installer and hook tests run against a temporary home directory. ## What you get -| You type | Or press | What happens | +![Hold Right Option. Five shortcuts, one hand: comma for compact check, period for compact, slash for tldr, quote for end session, backslash for archive session.](docs/images/right-hand.webp) + +| You type | Shortcut (optional, macOS) | What happens | | --- | --- | --- | -| `/compact-check` | Right Option + `,` | Audits the repo (tree, upstream, PR, conflicts, type check, unwritten chat content). A green verdict prints a resume block and stores it for the hooks. Yellow or red lists the fixes and does the file-side ones. | -| `/compact` | Right Option + `.` | Claude Code's own compact. The hooks inject the stored resume block into the compacted session; the chord also types a `go` that starts the next turn. | +| `/compact-check` | Right Option + `,` | Checks the repo is safe to compact: working tree, push state, PR status, conflicts, the fast static checks, nothing left only in chat. Test suites are detected but not run. Green writes the resume block. Otherwise it lists the fixes, writes the files itself (a plan from chat, for example) and asks before any commit or push. | +| `/compact` | Right Option + `.` | Claude Code's own compact. A hook puts the resume block back. The shortcut then types `go` so Claude carries on; without the shortcut, type `go` yourself. | | `tldr` | Right Option + `/` | Short, plain reply: 10 lines or 130 words, ending with what you still need to do. | -| `/end-session` | Right Option + `'` | Same audit with a harder bar: everything committed, pushed, in a PR, or handed back. Green-light checklist plus the merge links. | -| `/archive-session` | Right Option + `\` | Checks this session's PRs are merged, then asks the desktop app to archive the session. | -| `/session-status` | | A short "what is this session doing" summary, under 25 lines, for when you run many at once. | +| `/end-session` | Right Option + `'` | Same check, higher bar: everything committed, pushed and in a PR, or listed for you to handle. Ends with a checklist and the links to merge. | +| `/archive-session` | Right Option + `\` | Archives the session once its PRs are merged. Desktop app only. | +| `/session-status` | | What this session is doing, in under 25 lines. Handy when several run at once. | + +Right Option is the Option key to the right of the space bar. The shortcuts need Karabiner-Elements and a US layout; the commands work without them. The commit and push rules are in [includes/session-audit-core.md](includes/session-audit-core.md). ## What it looks like -The shape of a `/compact-check` pass that comes back green (names made up, format real): +`/compact-check` on a clean repo (names made up, output format real): ``` 🟢 Safe to compact: clean tree, in sync, PR open, nothing in flight. @@ -57,7 +68,7 @@ Last completed: stages 1 to 3 of the plan; stage 4 (cut over the queue) not star Next up: execute docs/plans/mailer-migration.md from its RESUME block. ``` -And the same command when the tree is not clean: no resume block, a fix plan instead, and the command does the file writes itself before asking you about commits and pushes. +On a repo that is not clean you get a fix plan instead. The command does the file edits itself and asks before it commits or pushes: ``` 🟡 Compact with caution: 2 modified files and an unsaved 8-stage plan. @@ -68,52 +79,21 @@ Fix plan: 2. Commit it with the two modified files: "add: mailer migration plan". [needs your yes] ``` -A chord firing, keystroke by keystroke: - -``` -you press Right Option + , -Karabiner types /compact-check the slash opens Claude Code's autocomplete menu -400 ms later Escape closes the menu; Enter does not submit while a suggestion is highlighted -then Enter submits the command -``` - -## How the pieces fit - -``` -/compact-check ──prints──▶ resume block ──pipes into──▶ compact-resume-store.sh - writes pending/.md - prints compact-resume:stored: - (that marker lands in the transcript) - you press Right Option + . -/compact ──PreCompact hook──▶ compact-resume-precompact.sh - finds the pending file whose nonce is in THIS transcript - moves it to committed/.md - ──SessionStart(compact) hook──▶ compact-resume-inject.sh - emits committed/.md as additionalContext, once - ──the chord's queued "go"──▶ a turn starts and Claude continues from the block -``` - -The nonce step is what keeps parallel sessions apart. A slash command's shell has no reliable handle on its own session id. The PreCompact hook has both the id and the transcript, so it proves ownership by finding the marker the store step printed. One case the transcript cannot settle: a session branched with `/branch` after `/compact-check` carries a copy of the same transcript, marker included. Whichever of the two compacts first takes the block; the other finds nothing. +## How it works -## Requirements +![A loop: /compact-check saves the block, /compact runs, the PreCompact hook claims the block, compaction, the SessionStart hook restores it, go continues the work, and when the context fills up the loop starts again.](docs/images/resume-loop.webp) -- Claude Code on macOS or Linux. The commands and hooks work in the terminal CLI, the VS Code extension and the desktop app. `/archive-session` needs the desktop app. -- `bash` (3.2 is fine), `jq`, `python3` (3.8 or newer), `git`. `gh` is optional; without it the PR checks say "unknown" instead of pretending. -- Karabiner-Elements for the chords, macOS only. Optional. -- A `CLAUDE_CONFIG_DIR` other than `~/.claude` is honoured by the installer and the hooks. +`/compact-check` saves the resume block with a one-time marker. When you compact, a hook finds the block whose marker is in this session's own transcript and hands it back after the compaction, once. That is what keeps parallel sessions from picking up each other's plan. [docs/architecture.md](docs/architecture.md) has the hook-by-hook flow and the one edge case (`/branch`). -## Honest limits +## Limits -- **Guided means approvals.** The one-prompt install shows diffs and asks; it is not unattended. Claude Code reloads `settings.json` on change, but if a hook does not fire after the install, restart the session. -- **`/archive-session` is desktop-only.** It uses the session tools the desktop app gives Claude. In the CLI, VS Code or a cloud session it says so and stops. -- **The chords are macOS-only and assume a US-style layout** and an empty input line. They type text; if something is already in the input box, that text gets the command appended. On another layout, `karabiner/build.py` says how to find your key codes. -- **Slash commands cannot be bound to a key inside Claude Code.** `~/.claude/keybindings.json` binds built-in actions only, and none of them types text or runs a command. That is why the chord lives one layer down, in Karabiner. [docs/decisions/001-chords-live-in-karabiner.md](docs/decisions/001-chords-live-in-karabiner.md) has the full reasoning. -- **Escape before Enter.** Typing `/` opens the command menu. The docs say plain Enter does not submit while a suggestion is highlighted (Tab accepts it, Escape closes the menu), and that in fullscreen rendering the list opens with no row highlighted. Escape first covers both cases; Tab would be wrong because it accepts whatever is highlighted, and `/compact` prefix-matches `/compact-check`. I have verified the sequence on the desktop app only. -- **Injection is not resumption.** The post-compact hook puts the block in context; the Claude Code docs say the `initialUserMessage` output is for `-p` mode, so nothing starts a turn. The `/compact` chord types `go` afterwards. Claude Code documents that messages typed while it is busy are queued; that this also holds during a compaction is something I have only observed on my own machine. -- **Only manual `/compact`.** The hooks register for the `manual` matcher. Auto-compact gets no resume block because nobody ran `/compact-check` before it. -- **Session id across compaction.** The inject hook assumes the session id is the same before and after a compaction. I measured that on my machine; the docs do not promise it. -- **The terse gate is best-effort.** It is a Stop hook: it fires after the reply was already shown, it sees only the final message of a turn, and it counts words and lines (prose only, code and quotes exempt). A block produces a second, shorter reply; it does not retract the first. The UserPromptSubmit stash hook writes each prompt's text to a private file (mode 600) under the kit's state directory and deletes it after a day; without it the gate cannot know you typed `tldr`. CLAUDE.md itself is context, not enforcement, which is the whole reason the hook exists. -- **The commands use the `commands/` file format.** Claude Code documents it as the older format that still works; skills are the newer one. If that changes, the four files port one-to-one. +- **Guided means approvals.** The install shows diffs and asks; it is not unattended. If a hook does not fire afterwards, restart the session. +- **`/archive-session` is desktop-only.** In the CLI, VS Code or a cloud session it says so and stops. +- **The shortcuts are macOS-only** and assume a US layout and an empty input line. I have verified the Escape-then-Enter sequence on the desktop app only. [Why they live in Karabiner](docs/decisions/001-shortcuts-live-in-karabiner.md) +- **Only manual `/compact`.** Auto-compact gets no resume block because nobody ran `/compact-check` before it. +- **Two things I have only observed on my machine, not found in the docs:** the session id stays the same across a compaction, and a `go` typed during a compaction is queued. [Documented versus observed](docs/architecture.md#documented-versus-observed) +- **The terse gate is best-effort.** It runs after the reply was shown and asks for a shorter one; it does not retract the first. To know you typed `tldr`, the stash hook (installed with `--terse-gate`) saves each prompt in a private file and deletes files older than a day whenever it runs. [How it works](docs/architecture.md#the-terse-gate) +- **The commands use the `commands/` file format**, the older one Claude Code still documents. If that changes, the four files port one-to-one. ## Prior art @@ -123,7 +103,7 @@ The nonce step is what keeps parallel sessions apart. A slash command's shell ha ## Who made this -Damjan Dabo, indie developer in Croatia. I build ReleaseRocket (release announcements drafted from GitHub tags, with my wife Sara as co-founder) and the iOS apps Itemlist, QRGenie and BarcodeCraft. I build in public and write at [dabo.dev](https://dabo.dev). Find me on [X](https://x.com/DamjanDabo) and [LinkedIn](https://www.linkedin.com/in/damjan-dabo/). I run Claude Code daily from the desktop app. +Damjan Dabo, indie developer in Croatia. I build [ReleaseRocket](https://www.releaserocket.io) (release announcements drafted from GitHub tags, with my wife Sara as co-founder) and the iOS apps [Itemlist](https://www.getitemlist.app), [QRGenie](https://www.qrgenie.app) and [BarcodeCraft](https://www.barcodecraft.com). I build in public and write at [dabo.dev](https://dabo.dev). Find me on [X](https://x.com/DamjanDabo) and [LinkedIn](https://www.linkedin.com/in/damjan-dabo/). I run Claude Code daily from the desktop app. ## License diff --git a/docs/README.md b/docs/README.md index 8118340..2e59591 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,4 +4,5 @@ Design notes for the kit. User-facing material is in the repo root: [README.md]( - [architecture.md](architecture.md): the components, the compact-resume data flow, the state directory, the installer's algorithm, and which behaviours are documented by Claude Code versus only observed. - [decisions/](decisions/README.md): architecture decision records. - - [001-chords-live-in-karabiner.md](decisions/001-chords-live-in-karabiner.md): why the keyboard chords are a Karabiner rule and not a Claude Code keybinding. + - [001-shortcuts-live-in-karabiner.md](decisions/001-shortcuts-live-in-karabiner.md): why the keyboard shortcuts are a Karabiner rule and not a Claude Code keybinding, and what each shortcut types. +- [images/](images/): the three figures the README embeds and the social preview image for the GitHub page. diff --git a/docs/architecture.md b/docs/architecture.md index b68173c..3a3ff90 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ The problem: `/compact` replaces the conversation with a summary, and the summar /compact-resume/pending/.md and prints compact-resume:stored: to stdout. Bash tool output is recorded in the session transcript, so the marker is now in the transcript. -3. You run /compact (or press the chord). +3. You run /compact (or press the shortcut). 4. PreCompact (matcher manual) runs compact-resume-precompact.sh with {session_id, transcript_path}. It greps the transcript for each pending nonce. The one it finds is this session's. It moves that file to /compact-resume/committed/.md @@ -39,10 +39,26 @@ The problem: `/compact` replaces the conversation with a summary, and the summar 6. SessionStart (matcher compact) runs compact-resume-inject.sh with {session_id, source: "compact"}. It claims committed/.md with an atomic rename, deletes it, and emits the text as hookSpecificOutput.additionalContext, worded as a directive to continue from it. -7. Nothing starts a turn by itself, so the /compact chord types "go" + Enter after the compact Enter. +7. Nothing starts a turn by itself, so the /compact shortcut types "go" + Enter after the compact Enter. Claude Code queues messages typed while it is busy; the queued "go" fires when compaction ends. ``` +The same flow as a diagram: + +``` +/compact-check ──prints──▶ resume block ──pipes into──▶ compact-resume-store.sh + writes pending/.md + prints compact-resume:stored: + (that marker lands in the transcript) + you press Right Option + . +/compact ──PreCompact hook──▶ compact-resume-precompact.sh + finds the pending file whose nonce is in THIS transcript + moves it to committed/.md + ──SessionStart(compact) hook──▶ compact-resume-inject.sh + emits committed/.md as additionalContext, once + ──the shortcut's queued "go"──▶ a turn starts and Claude continues from the block +``` + ### Why the nonce A slash command's shell has no reliable handle on the current session id: it is delivered to hooks on stdin, not exposed to the Bash tool (the environment variables the docs list for hook and Bash subprocesses carry a session id only for Remote Control and cloud sessions), and the per-project transcript directory can hold many files from parallel sessions, so "the newest transcript" is not this session either. The store step therefore keys by an unguessable nonce, and the PreCompact hook, which does receive `session_id` and `transcript_path`, proves ownership by finding the nonce marker in its own transcript. A pending file whose nonce is in no transcript is left alone for its owner and expires after the TTL. @@ -55,7 +71,7 @@ PreCompact fires before compaction, while the transcript still contains the mark ### Why `additionalContext` and not `initialUserMessage` -The hooks reference documents `initialUserMessage` as the first user message "in non-interactive mode with the `-p` flag". In an interactive session after `/compact` it is accepted and ignored, which I measured on 2026-08-04 with a debug trace (a clean store → precompact → inject chain and an emit that never became a turn). `additionalContext` is delivered on `source: compact`. It lands as context, not as a turn, hence the directive wording and the chord's trailing `go`. +The hooks reference documents `initialUserMessage` as the first user message "in non-interactive mode with the `-p` flag". In an interactive session after `/compact` it is accepted and ignored, which I measured on 2026-08-04 with a debug trace (a clean store → precompact → inject chain and an emit that never became a turn). `additionalContext` is delivered on `source: compact`. It lands as context, not as a turn, hence the directive wording and the shortcut's trailing `go`. ### Guarantees the scripts give @@ -86,7 +102,7 @@ The hooks reference documents `initialUserMessage` as the first user message "in ## The terse gate -`terse-prompt-stash.sh` writes each prompt to `/terse/prompt--` (mode 600, deleted after a day). `terse-gate.sh` reads it back on Stop, strips fenced code and quoted lines from `last_assistant_message`, counts words and lines, and when the prompt contained `tldr` and the reply exceeds 10 lines or 130 words, exits 2 with the rewrite instruction on stderr. The ceiling applies to every tldr turn; the minimum-word threshold only exempts short replies from the filler score. The Stop payload reaches the Python part through a private temp file, not an environment variable, so a very long reply cannot push the hook over the OS limit on a single environment string. `stop_hook_active` plus a per-turn marker file guarantee at most one block per turn. Every turn is logged to a TSV, whatever the verdict, so `terse-report.sh` can show the trend. A filler-phrase score is computed alongside and only logged by default. +`terse-prompt-stash.sh` writes each prompt to `/terse/prompt--` (mode 600). Prompt files older than 24 hours are deleted on the next stash-hook run; without another run, they remain on disk. `terse-gate.sh` reads it back on Stop, strips fenced code and quoted lines from `last_assistant_message`, counts words and lines, and when the prompt contained `tldr` and the reply exceeds 10 lines or 130 words, exits 2 with the rewrite instruction on stderr. The ceiling applies to every tldr turn; the minimum-word threshold only exempts short replies from the filler score. The Stop payload reaches the Python part through a private temp file, not an environment variable, so a very long reply cannot push the hook over the OS limit on a single environment string. `stop_hook_active` plus a per-turn marker file guarantee at most one block per turn. Every turn is logged to a TSV, whatever the verdict, so `terse-report.sh` can show the trend. A filler-phrase score is computed alongside and only logged by default. ## State directory @@ -99,7 +115,7 @@ The hooks reference documents `initialUserMessage` as the first user message "in compact-resume/pending/.md compact-resume/committed/.md compact-resume/debug.log only with SESSION_WORKFLOW_DEBUG=1 - terse/prompt-- 24h retention + terse/prompt-- deleted when older than 24h, on the next stash-hook run terse/blocked-- per-turn loop guard logs/terse-gate.tsv ``` diff --git a/docs/decisions/001-chords-live-in-karabiner.md b/docs/decisions/001-shortcuts-live-in-karabiner.md similarity index 71% rename from docs/decisions/001-chords-live-in-karabiner.md rename to docs/decisions/001-shortcuts-live-in-karabiner.md index 7702028..c862fda 100644 --- a/docs/decisions/001-chords-live-in-karabiner.md +++ b/docs/decisions/001-shortcuts-live-in-karabiner.md @@ -1,4 +1,4 @@ -# 001: The chords live in Karabiner, not in Claude Code +# 001: The keyboard shortcuts live in Karabiner, not in Claude Code Status: accepted (2026-08-04, revalidated against the docs 2026-10-22) @@ -15,16 +15,29 @@ Two facts, both checked against the current docs: The binding lives one layer down, at the HID level, as a Karabiner-Elements complex modification: Right Option plus a key types the command text, waits, presses Escape, then Enter. The receiving application only sees keystrokes, so the same rule reaches the desktop app, any terminal and VS Code. I have verified the Escape-then-Enter sequence on the desktop app; the other two surfaces receive the same keys but I have not tested them. -Right Option specifically, so that left Option stays free for special characters and for Claude Code's own Option+O / Option+P / Option+T bindings. The `, . /` cluster sits under the right hand's resting position, which keeps the three frequent chords one-handed. The two rare ones (`'` and `\`) sit further out on purpose: a chord that must never fire by accident should cost a reach. +Right Option specifically, so that left Option stays free for special characters and for Claude Code's own Option+O / Option+P / Option+T bindings. The `, . /` cluster sits under the right hand's resting position, which keeps the three frequent shortcuts one-handed. The two rare ones (`'` and `\`) sit further out on purpose: a shortcut that must never fire by accident should cost a reach. + +## What the shortcut types + +One shortcut, keystroke by keystroke: + +``` +you press Right Option + , +Karabiner types /compact-check the slash opens Claude Code's autocomplete menu +400 ms later Escape closes the menu; Enter does not submit while a suggestion is highlighted +then Enter submits the command +``` + +The `/compact` shortcut adds a pause and a queued `go` + Enter after the first Enter (see below). ## Consequences - **macOS only.** Karabiner-Elements is a macOS tool. On Linux the equivalent is a text-expander or a keyboard daemon; the kit does not ship one. - **Layout-dependent.** The rule names physical keys (`comma`, `period`, `slash`, `quote`, `backslash`) and types characters by key name, which matches a US-style layout. Other layouts regenerate the rule with their own key codes via `karabiner/build.py`. -- **It types blind.** Karabiner cannot see the input box. If text is already there, the command is appended to it. The chords assume an empty line. +- **It types blind.** Karabiner cannot see the input box. If text is already there, the command is appended to it. The shortcuts assume an empty line. - **Escape before Enter.** Typing `/` opens the command menu, which puts Claude Code in the `Autocomplete` keybinding context. That context binds Tab (accept), Escape (dismiss), Up and Down. The keybindings reference says plain Enter does not submit while a suggestion is highlighted, so a synthetic Enter can leave the command typed but unsent; the interactive-mode reference adds that in fullscreen rendering the list opens with no row highlighted and Enter submits as typed. Escape first covers both cases. Not Tab: Tab accepts whatever entry is highlighted, and `/compact` prefix-matches both `/compact` and `/compact-check`, so Tab could run the wrong command. Escape does not care what is highlighted. - **A delay is needed between typing and submitting.** Karabiner fires a `to` array in about a millisecond; the app has not processed the text when the next key lands. `to_delayed_action` with a 400 ms delay gives the gap. `hold_down_milliseconds` does not: it sets the key-down/key-up interval of the single event it is attached to. -- **The `/compact` chord types a trailing `go`.** The post-compact hook injects the resume block as context, which does not start a turn. The chord types `go` + Enter after the compact Enter. Claude Code queues messages typed while it is busy, so the `go` fires whenever the compaction finishes, and no fixed delay has to guess the compaction's duration. The 1200 ms `vk_none` pause before it is belt and braces; the queue does the real work. That a message typed during a compaction is queued is something I observed on my own machine, not something the docs state. +- **The `/compact` shortcut types a trailing `go`.** The post-compact hook injects the resume block as context, which does not start a turn. The shortcut types `go` + Enter after the compact Enter. Claude Code queues messages typed while it is busy, so the `go` fires whenever the compaction finishes, and no fixed delay has to guess the compaction's duration. The 1200 ms `vk_none` pause before it is belt and braces; the queue does the real work. That a message typed during a compaction is queued is something I observed on my own machine, not something the docs state. ## Alternatives considered diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c3da9b5..5996919 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -2,4 +2,4 @@ Architecture decision records, numbered in the order they were made. -- [001-chords-live-in-karabiner.md](001-chords-live-in-karabiner.md): the keyboard chords are a Karabiner-Elements rule, not a Claude Code keybinding. +- [001-shortcuts-live-in-karabiner.md](001-shortcuts-live-in-karabiner.md): the keyboard shortcuts are a Karabiner-Elements rule, not a Claude Code keybinding. diff --git a/docs/images/before-after.webp b/docs/images/before-after.webp new file mode 100644 index 0000000..cffe649 Binary files /dev/null and b/docs/images/before-after.webp differ diff --git a/docs/images/resume-loop.webp b/docs/images/resume-loop.webp new file mode 100644 index 0000000..75850e7 Binary files /dev/null and b/docs/images/resume-loop.webp differ diff --git a/docs/images/right-hand.webp b/docs/images/right-hand.webp new file mode 100644 index 0000000..7e888d4 Binary files /dev/null and b/docs/images/right-hand.webp differ diff --git a/docs/images/social-preview.png b/docs/images/social-preview.png new file mode 100644 index 0000000..a3cae18 Binary files /dev/null and b/docs/images/social-preview.png differ diff --git a/hooks/compact-resume-inject.sh b/hooks/compact-resume-inject.sh index 6dd3846..705abe6 100755 --- a/hooks/compact-resume-inject.sh +++ b/hooks/compact-resume-inject.sh @@ -16,7 +16,7 @@ # In an interactive session after /compact it is accepted and ignored (observed by the # author on 2026-08-04). additionalContext is delivered on source=compact. It lands as # context, not as a turn, which is why the block is worded as a directive and why the -# /compact chord in karabiner/ types a follow-up message to start the turn. +# /compact shortcut in karabiner/ types a follow-up message to start the turn. # # Fail-silent: any missing field, stale or absent file exits 0 with no output. # Part of claude-code-session-workflow (hooks/compact-resume-*.sh). diff --git a/install/install.sh b/install/install.sh index e554f6e..95c37f6 100755 --- a/install/install.sh +++ b/install/install.sh @@ -131,9 +131,9 @@ do_check() { hr "surfaces" if [[ "$(uname -s)" == "Darwin" ]]; then [[ -d "/Applications/Claude.app" ]] && say "Claude desktop app: found (/archive-session works there)" || say "Claude desktop app: not found in /Applications (/archive-session needs it)" - [[ -d "/Applications/Karabiner-Elements.app" ]] && say "Karabiner-Elements: found (chords available with --karabiner)" || say "Karabiner-Elements: not found (chords are optional; https://karabiner-elements.pqrs.org)" + [[ -d "/Applications/Karabiner-Elements.app" ]] && say "Karabiner-Elements: found (shortcuts available with --karabiner)" || say "Karabiner-Elements: not found (shortcuts are optional; https://karabiner-elements.pqrs.org)" else - say "not macOS: Karabiner chords unavailable; /archive-session needs the desktop app" + say "not macOS: Karabiner shortcuts unavailable; /archive-session needs the desktop app" fi if ! need python3 || ! need jq; then say; say "Install python3 and jq, then rerun."; return 1 @@ -461,10 +461,12 @@ do_uninstall() { fi hr "CLAUDE.md" - if [[ -f "$CFG/CLAUDE.md" ]]; then - local md_prov="" del_flag="" - [[ $have_manifest -eq 1 ]] && md_prov=$(python3 -I "$MANIFEST_PY" get --manifest "$MANIFEST" --key claude_md) - [[ "$md_prov" == "created" ]] && del_flag="--delete-if-empty" + local md_prov="" del_flag="" + [[ $have_manifest -eq 1 ]] && md_prov=$(python3 -I "$MANIFEST_PY" get --manifest "$MANIFEST" --key claude_md) + [[ "$md_prov" == "created" ]] && del_flag="--delete-if-empty" + if [[ $have_manifest -eq 1 && -z "$md_prov" ]]; then + say "the manifest records no change to CLAUDE.md; left alone" + elif [[ -f "$CFG/CLAUDE.md" ]]; then if approve "remove the kit's block from $CFG_DISPLAY/CLAUDE.md?$( [[ -n "$del_flag" ]] && echo ' (the kit created the file; it is deleted if nothing else is in it)')"; then mkdir -p "$BACKUP_DIR" || { say "ABORT: cannot create $BACKUP_DIR"; exit 5; } python3 -I "$CLAUDEMD" remove --file "$CFG/CLAUDE.md" --backup "$BACKUP_DIR/CLAUDE.md" $del_flag || exit 5 @@ -476,7 +478,9 @@ do_uninstall() { local kline="" kpath="$KARABINER_DST" kaction="unknown" korig="" [[ $have_manifest -eq 1 ]] && kline=$(python3 -I "$MANIFEST_PY" get --manifest "$MANIFEST" --key karabiner_file) if [[ -n "$kline" ]]; then IFS='|' read -r kpath kaction korig <<<"$kline"; fi - if [[ -f "$kpath" ]]; then + if [[ $have_manifest -eq 1 && -z "$kline" ]]; then + say "the manifest records no rule file written by the kit; $kpath left alone" + elif [[ -f "$kpath" ]]; then say "disable the rule in Karabiner-Elements first if it is enabled" if remove_or_restore "$kpath" "karabiner/session-workflow.json" "$kaction" "$korig"; then [[ -n "$kline" ]] && PRUNE_ARGS+=(--karabiner-file) diff --git a/karabiner/build.py b/karabiner/build.py index fa6e8d9..74bc228 100755 --- a/karabiner/build.py +++ b/karabiner/build.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 -"""Generate the Karabiner-Elements rule files for the session-workflow chords. +"""Generate the Karabiner-Elements rule files for the session-workflow keyboard shortcuts. python3 karabiner/build.py # rewrite session-workflow.json and session-workflow-scoped.json python3 karabiner/build.py --check # exit 1 if the committed files differ from the generator output -Both files hold the same five chords. The "scoped" file adds a frontmost_application_if -condition so the chords only fire in the apps listed in BUNDLE_IDS; the plain file fires +Both files hold the same five shortcuts. The "scoped" file adds a frontmost_application_if +condition so the shortcuts only fire in the apps listed in BUNDLE_IDS; the plain file fires everywhere Karabiner is active. Key-code assumptions: a US-style (ANSI) layout, where the physical keys to the right of the spacebar carry , . / ' and \\ and typing a character means pressing the key of the same name. On another layout, open Karabiner-EventViewer, press the key you want, read its key_code, -and change CHORDS (the trigger keys) or KEYCODES (the characters typed) below, then rerun. +and change SHORTCUTS (the trigger keys) or KEYCODES (the characters typed) below, then rerun. Part of claude-code-session-workflow. """ @@ -29,7 +29,7 @@ GO_WAIT_MS = 1200 # pause between the /compact Enter and the queued follow-up message # (trigger key_code, text to type, needs Escape before Enter, trailing queued message) -CHORDS = [ +SHORTCUTS = [ ("comma", "/compact-check", True, None), ("period", "/compact", True, "go"), ("slash", "tldr", False, None), @@ -91,7 +91,7 @@ def rule(trigger, text, escape, follow_up, scoped): def build(scoped): title = "Claude Code session workflow" + (" (Claude app and terminals only)" if scoped else "") - return {"title": title, "rules": [rule(*c, scoped=scoped) for c in CHORDS]} + return {"title": title, "rules": [rule(*c, scoped=scoped) for c in SHORTCUTS]} def render(obj): diff --git a/tests/run.sh b/tests/run.sh index b1a1e21..d496ff0 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -388,6 +388,20 @@ check "uninstall: created files deleted" '[[ ! -f "$CFG/commands/end-session.md" check "uninstall: the registration the kit did not add stays" '[[ "$(grep -c compact-resume-inject "$CFG/settings.json")" -eq 1 ]] && ! grep -q "terse-" "$CFG/settings.json"' check "uninstall: a CLAUDE.md the kit created and that holds only the block is deleted" '[[ ! -f "$CFG/CLAUDE.md" ]]' check "uninstall: manifest deleted when nothing managed remains" '[[ ! -f "$CFG/session-workflow-kit/manifest.json" ]]' +# 4q uninstall leaves what the install did not record: a Karabiner rule file that was already +# there (install run without --karabiner) and a CLAUDE.md block that was already in place +rm -rf "$CFG" "$HOME/.config/karabiner"; mkdir -p "$CFG" "$HOME/.config/karabiner/assets/complex_modifications" +KFILE="$HOME/.config/karabiner/assets/complex_modifications/session-workflow.json" +printf 'MY OWN RULES\n' > "$KFILE" +printf 'my own notes\n' > "$CFG/CLAUDE.md" +python3 -I "$KIT/install/update-claude-md.py" apply --file "$CFG/CLAUDE.md" --block "$KIT/claude-md/tldr-block.md" --backup "$T/md-backup.md" >/dev/null 2>&1 +md_before=$(cat "$CFG/CLAUDE.md") +bash "$INST" apply --yes >/dev/null 2>&1 +check "unrecorded: the install records neither the rule file nor the block" '[[ -f "$CFG/session-workflow-kit/manifest.json" ]] && [[ "$(jq -r ".karabiner_file // .claude_md // empty" "$CFG/session-workflow-kit/manifest.json")" == "" ]]' +out=$(bash "$INST" uninstall --yes 2>&1); rc=$? +check "unrecorded: uninstall exits 0" '[[ $rc -eq 0 ]]' +check "unrecorded: a Karabiner rule file the install did not record stays as it was" '[[ -f "$KFILE" && "$(cat "$KFILE")" == "MY OWN RULES" ]]' +check "unrecorded: a CLAUDE.md block the install did not record stays as it was" '[[ "$(cat "$CFG/CLAUDE.md")" == "$md_before" ]] && grep -q session-workflow-kit:begin "$CFG/CLAUDE.md"' echo echo "passed: $PASS failed: $FAIL"