@lemoncode/lemony 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +17 -16
  2. package/catalog/VERSION +1 -1
  3. package/catalog/agents/implementer.md +36 -1
  4. package/catalog/agents/orchestrator.md +206 -83
  5. package/catalog/agents/reviewer.md +8 -3
  6. package/catalog/agents/spinoff.md +3 -2
  7. package/catalog/agents/ui-design.md +3 -1
  8. package/catalog/agents/ui-designer.md +6 -3
  9. package/catalog/commands/pause.md +50 -5
  10. package/catalog/commands/resume.md +13 -2
  11. package/catalog/commands/spinoff.md +5 -3
  12. package/catalog/commands/sync-design-tokens.md +6 -2
  13. package/catalog/harness.config.schema.json +4 -0
  14. package/catalog/hooks/init.sh +80 -7
  15. package/catalog/hooks/lib/live-branch.sh +37 -0
  16. package/catalog/hooks/lib/merge-pr.sh +17 -6
  17. package/catalog/hooks/lib/playbook-scan.sh +4 -2
  18. package/catalog/hooks/session-close.sh +26 -7
  19. package/catalog/schemas/tier2-events-history.md +33 -2
  20. package/catalog/schemas/tier2-events.md +30 -22
  21. package/catalog/skills/build-ui/SKILL.md +5 -2
  22. package/catalog/skills/design-tool-sync/SKILL.md +48 -7
  23. package/catalog/skills/grill-ui/SKILL.md +4 -1
  24. package/catalog/skills/grill-ui/ui-handoff-format.md +2 -1
  25. package/catalog/skills/mutation-testing/SKILL.md +22 -8
  26. package/catalog/skills/prd-to-spec/SKILL.md +27 -5
  27. package/catalog/skills/review-pr/SKILL.md +7 -1
  28. package/catalog/skills/review-pr/reference.md +3 -2
  29. package/catalog/templates/claude-code/agents.md.tpl +2 -1
  30. package/catalog/templates/claude-code/harness.config.yml.tpl +12 -3
  31. package/dist/cli.mjs +2579 -775
  32. package/package.json +11 -10
@@ -74,7 +74,9 @@ This is opt-in surfacing of the client's own answers, not the harness authoring
74
74
  ## Design-tokens & design-tool on-ramp (offer)
75
75
 
76
76
  `docs/design-tokens.json` and a design-tool connection are **client-owned inputs** — consumed if
77
- present, never imposed. A repo adopting the harness fresh has neither, and silence there is a dead
77
+ present, never imposed. When `harness.config.yml` sets `design_tokens.file`, the path it names is
78
+ the token file wherever this file says `docs/design-tokens.json`; never create a second file at
79
+ the default. A repo adopting the harness fresh has neither, and silence there is a dead
78
80
  end. So when your `grill-ui` interview finds **either absent**, surface it as an opt-in offer (a
79
81
  human-facing choice, so it is yours) rather than only an open question:
80
82
 
@@ -58,7 +58,8 @@ Judge against your taste and the 11-section contract, then **return findings —
58
58
  decisions-altitude (not the placeholder template); sections that don't apply are marked
59
59
  N/A, not left blank.
60
60
  - **Internally consistent.** Dials, layout, components, states and motion agree; §9 sets an
61
- a11y target; §11 points at `docs/design-tokens.json` (no invented tokens); §1 personas are
61
+ a11y target; §11 points at the token file — `docs/design-tokens.json`, or the path
62
+ `design_tokens.file` names in `harness.config.yml` (no invented tokens); §1 personas are
62
63
  coherent and actually shape the decisions.
63
64
  - **One verdict.** Return `pass` / `needs-another-pass` with specific findings. The
64
65
  Orchestrator resolves them — tighten the handoff, re-ask the human, or record an open
@@ -82,7 +83,8 @@ Reviewer's own shape — a **mechanical pre-pass**, then **judgment**, then **on
82
83
  design-aware — that separation is deliberate.
83
84
 
84
85
  **Token sync (on-demand, not a step).** If the project binds a design tool (a
85
- `com.lemony.design-tool` provider in `docs/design-tokens.json`), check the drift state
86
+ `com.lemony.design-tool` provider in `docs/design-tokens.json`, or in the file
87
+ `design_tokens.file` names in `harness.config.yml` when set), check the drift state
86
88
  (`lemony status`). When an export is pending **and the tool is connected**, the Orchestrator
87
89
  offers to project the change and dispatches you to run the **`design-tool-sync`** skill —
88
90
  never sync silently, and skip gracefully if the tool isn't connected. The human's explicit
@@ -100,7 +102,8 @@ skill (`ui-handoff-format.md`): (1) personas; (2) aesthetic direction — the di
100
102
  variant, state what matters); the full component anatomy only for a novel or critical
101
103
  component.
102
104
  - **Text-first, tokens by reference.** Screens travel as text — an inventory, a nav/flow
103
- map and structural intent; tokens point at `docs/design-tokens.json`, never inlined.
105
+ map and structural intent; tokens point at the token file (`docs/design-tokens.json`, or
106
+ `design_tokens.file`), never inlined.
104
107
 
105
108
  A `Status:` field marks the handoff `in_progress` (design being defined) vs `completed`.
106
109
 
@@ -1,10 +1,18 @@
1
1
  ---
2
2
  description: Pause the current session — write a narrative resume + emit session_closed telemetry.
3
- allowed-tools: Read, Write, Bash(.claude/hooks/session-close.sh --manual), Bash(date -u *)
3
+ allowed-tools: Read, Write, Bash(.claude/hooks/session-close.sh --manual), Bash(.claude/hooks/lib/lemony.sh status --local), Bash(.claude/hooks/lib/lemony.sh checkpoint *), Bash(date -u *), Bash(git diff --stat *), Bash(git ls-files --others *)
4
4
  ---
5
5
 
6
6
  # /pause
7
7
 
8
+ **A checkpoint answer comes first.** When the human answered a pending checkpoint
9
+ with this `/pause`, or just before it (`ok`, `changes`, `ok_downgrade`), resolve it
10
+ before step 1, whatever the auto-commit setting, exactly as the orchestrator does
11
+ at any checkpoint (§Checkpoint contract, items 4–5): the spec check and the
12
+ human-delta revalidation first (a red one blocks the OK), on an OK under
13
+ auto-commit ON the human-delta commit, then the resolved `progress.md` line and the `checkpoint` verb.
14
+ Its commits and emit belong to the checkpoint, not to the pause.
15
+
8
16
  A two-step pause:
9
17
 
10
18
  1. **Write the resume narrative** so future-you (or whoever resumes) can pick
@@ -14,14 +22,19 @@ A two-step pause:
14
22
  narrative lives; `current-<your-user>.md` only holds session timestamps.
15
23
  - Generate a UTC timestamp slug — run `date -u +%Y-%m-%dT%H:%M:%SZ` and use
16
24
  it as `<ts>`. Pick a short topic slug from the work in progress.
25
+ - Read the task and branch — run `.claude/hooks/lib/lemony.sh status --local`
26
+ (local reads only, no network call): its `Active task:` and `Branch:` lines
27
+ are read off the live branch (a task branch, the `harness/closeout-<id>`
28
+ record branch, or the branch being rebased while a rebase has HEAD
29
+ detached). If it fails, write `null` and `(unknown)`.
17
30
  - Write `.claude/state/sessions/<your-user>/<ts>-<topic>.md` with this
18
31
  skeleton (fill the body — what you did, where you are, the next step):
19
32
 
20
33
  ```markdown
21
34
  ---
22
35
  session_close_ts: <ts>
23
- active_task: <issue-id or null — the current branch's id: `harness/<id>-<slug>` → `<id>`>
24
- branch: <current branch, or (unknown) if HEAD is detached>
36
+ active_task: <the `Active task:` id, or null when it reads (none)>
37
+ branch: <the `Branch:` name without its parenthesised suffix — (unknown) as printed>
25
38
  topic: <topic>
26
39
  reason: manual
27
40
  auto_close: false
@@ -29,6 +42,8 @@ A two-step pause:
29
42
 
30
43
  # Session — <topic> (<ts>)
31
44
 
45
+ <auto-commit OFF only — the unstaged-work line, see below>
46
+
32
47
  ## What I did
33
48
 
34
49
  - …
@@ -42,9 +57,38 @@ A two-step pause:
42
57
  - …
43
58
  ```
44
59
 
60
+ - **Auto-commit OFF: the note opens with the unstaged work.** When the task
61
+ runs auto-commit OFF (config pins `implementation.auto_commit: off`, or the
62
+ task's `progress.md` carries the `Auto-commit: off` line), run — `':/'`
63
+ scopes both to the whole repository, whatever the shell's directory:
64
+
65
+ ```bash
66
+ git diff --stat -- ':/' ':(top,exclude).claude/state'; \
67
+ git ls-files --others --exclude-standard --full-name -- ':/' ':(top,exclude).claude/state'
68
+ ```
69
+
70
+ and make the first line under the heading say what the output is, which
71
+ depends on where the task stands:
72
+ - **Mid-implementation** (`progress.md` not at `awaiting human checkpoint`):
73
+ the staged index is the last save-point (green, except the red starting
74
+ point of a `(red human delta)` fix-loop) and everything unstaged is
75
+ **unverified by construction** — a hand probe stopped between its mutation
76
+ and its revert looks exactly like work in progress.
77
+ `**Unverified (unstaged since the last save-point):** <files + stat summary>`
78
+ — or `**Unverified:** none` when both print nothing.
79
+ - **At an `awaiting` checkpoint**: every agent save-point was staged at
80
+ presentation, so unstaged work is the human's own edits.
81
+ `**Human delta (unstaged, to revalidate at the checkpoint):** <files + stat summary>`
82
+ — or `**Human delta:** none`.
83
+
84
+ Report only: stage, revert, or fix nothing here (a stage now is the
85
+ mid-experiment `add` the staging protocol forbids). Task state under
86
+ `.claude/state` is left out — it is disk-only by design in this mode.
87
+
45
88
  2. **Trigger the session-close hook** so the `session_closed` event lands in
46
89
  `events.jsonl` (its `task_id` is derived from the current branch when it is
47
- a `harness/<id>-<slug>` task branch) and `current-<your-user>.md` gets its
90
+ a `harness/<id>-<slug>` or `harness/closeout-<id>` task branch, or is being
91
+ rebased) and `current-<your-user>.md` gets its
48
92
  `last_close_ts` stamped:
49
93
 
50
94
  ```bash
@@ -54,7 +98,8 @@ A two-step pause:
54
98
  The hook prints `git status --porcelain` after emitting — it never
55
99
  auto-commits. Decide what (if anything) to stage and commit before leaving.
56
100
  Exception — a task running **auto-commit OFF** (orchestrator §Auto-commit
57
- OFF): do **not** commit; the zero-commit deferral binds `/pause` too.
101
+ OFF): do **not** commit; the zero-commit deferral binds `/pause` too (an answered
102
+ checkpoint was already resolved above, by the `checkpoint` verb).
58
103
  Leave the staged save-point untouched (it survives the session; staging
59
104
  anything now is the mid-experiment `add` the protocol forbids), note the pause
60
105
  in `progress.md` — disk-only, per the mode's contract.
@@ -37,7 +37,13 @@ drop `harness:needs-design` and continue toward spec-ready. When `progress.md` r
37
37
  exactly there: `awaiting human checkpoint (step N/M)` re-presents that pending
38
38
  checkpoint (inspect / run / OK / changes / OK+downgrade; in all-at-once under
39
39
  auto-commit OFF the line carries no `(step N/M)` counter — same
40
- re-entry); a `fix-loop iteration K`
40
+ re-entry). Every answer — `changes` too — resolves in the turn that records it,
41
+ after the human-delta check (orchestrator §Checkpoint contract, item 4), through one
42
+ call, `.claude/hooks/lib/lemony.sh checkpoint <answer> …` (orchestrator
43
+ §Checkpoint contract, item 5): it makes the commits and, step-by-step, the
44
+ `step_completed` emit together (the all-at-once gate omits `--step` and emits
45
+ nothing) — never make them by hand or copy them from `git log`. A
46
+ `fix-loop iteration K`
41
47
  line re-enters the per-step implement→review loop at that iteration; an
42
48
  `awaiting ledger retry (step N/M, retry 1/1)` line — or its full-pass twin
43
49
  `awaiting ledger retry (full pass, retry 1/1)` — re-runs
@@ -71,7 +77,12 @@ makes same-machine resume lossless). From a **different** machine, anything comm
71
77
  after the last successful push is unreachable and undetectable (origin is
72
78
  self-consistent, just older): resume from the pushed state and tell the human which
73
79
  state that is (`progress.md`'s last entry), so a re-run of an already-done review or
74
- checkpoint is a conscious replay, not a silent assumption. An `in-review` one resumes at the
80
+ checkpoint is a conscious replay, not a silent assumption. Same machine, auto-commit
81
+ OFF, mid-implementation (`progress.md` not at `awaiting human checkpoint`): work found
82
+ **unstaged** in the worktree is **unverified by construction** — never stage or present
83
+ it on sight; the fresh Implementer confirms or redoes it. At an `awaiting` checkpoint,
84
+ unstaged work is the human's own delta instead (authority: the orchestrator §Auto-commit
85
+ OFF). An `in-review` one resumes at the
75
86
  **merge gate**: surface the open PR's review comments and run the merge-gate procedure
76
87
  (route change-requests back to the Implementer, then offer to post threaded replies —
77
88
  authority is the orchestrator). A `closeout-pending` task has **nothing to check out** —
@@ -16,9 +16,11 @@ code pointer). If empty, ask one short question to capture the symptom, then pro
16
16
 
17
17
  ## Procedure
18
18
 
19
- 1. **Identify the parent task** (best-effort): the task in flight. Recover its id from
20
- the current branch (`harness/<id>-<slug>` → `<id>`) or the active task state. If there
21
- is no active task, capture with no parent (a deferred stub).
19
+ 1. **Identify the parent task** (best-effort): the task in flight. Run
20
+ `.claude/hooks/lib/lemony.sh status --local` and read its `Active task:` line — it
21
+ derives the id from the live branch (`harness/<id>-<slug>`, the `harness/closeout-<id>`
22
+ record branch, or the branch being rebased while a rebase has HEAD detached). If it
23
+ reads `(none)`, or the command fails, capture with no parent (a deferred stub).
22
24
 
23
25
  2. **Capture the stub** with the `spinoff` CLI verb via the launcher. In one call it
24
26
  opens a `harness:managed` + `harness:status:pending` issue (the level is decided at
@@ -18,12 +18,16 @@ Designer; follow the steps there.
18
18
  deletes tool-only variables). Preview the plan, push on confirmation, then record the drift
19
19
  baseline only after the push succeeds.
20
20
  - _empty_ — check the drift state (`lemony status`) and, if an export is pending and the tool
21
- is connected, offer `export`; otherwise report the current state.
21
+ is connected, offer `export`; otherwise report the current state. `status` shows no drift
22
+ line both when there is no token file and when the file cannot be read or fails
23
+ validation — tell the two apart with `lemony doctor`'s `design-tool-drift` check.
22
24
 
23
25
  The design tool is a **projection** of the canonical JSON, never a peer source of truth.
24
26
  Detect the tool at runtime: read the `com.lemony.design-tool` binding at the root of
25
27
  `docs/design-tokens.json`; if no tool is declared, the project is pure-code and there is
26
28
  nothing to sync. If the declared tool's MCP server is not connected, skip gracefully with a
27
- note — the deterministic drift check still works without it.
29
+ note — the deterministic drift check still works without it. When `harness.config.yml` sets
30
+ `design_tokens.file`, the path it names is the token file wherever this command says
31
+ `docs/design-tokens.json`.
28
32
 
29
33
  The human reviews every write (to the JSON and to the tool). Never sync silently.
@@ -93,6 +93,10 @@
93
93
  "verify": {
94
94
  "type": "string",
95
95
  "minLength": 1
96
+ },
97
+ "file": {
98
+ "type": "string",
99
+ "minLength": 1
96
100
  }
97
101
  },
98
102
  "additionalProperties": false
@@ -11,6 +11,22 @@
11
11
 
12
12
  set -u
13
13
 
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+
16
+ # shellcheck source=lib/live-branch.sh
17
+ if [ -f "$SCRIPT_DIR/lib/live-branch.sh" ] && [ -r "$SCRIPT_DIR/lib/live-branch.sh" ]; then
18
+ source "$SCRIPT_DIR/lib/live-branch.sh"
19
+ LIVE_BRANCH_LIB=1
20
+ else
21
+ # A hand-copied or partial install without the helper (`lemony install`
22
+ # copies all of lib/): read HEAD's branch only — a rebase in progress reads
23
+ # as no branch — and say so once instead of failing on every call.
24
+ live_branch() {
25
+ git -C "$1" symbolic-ref --quiet HEAD 2>/dev/null | sed -n 's#^refs/heads/##p'
26
+ }
27
+ LIVE_BRANCH_LIB=0
28
+ fi
29
+
14
30
  # Read stdin only when Claude Code is piping a payload; if a developer runs
15
31
  # this hook interactively (no pipe), default to an empty payload so the script
16
32
  # doesn't hang on `cat` waiting for input.
@@ -35,6 +51,9 @@ CONFIG="$REPO_ROOT/harness.config.yml"
35
51
 
36
52
  ERRORS=()
37
53
  WARNINGS=()
54
+ if [ "$LIVE_BRANCH_LIB" -eq 0 ]; then
55
+ WARNINGS+=(".claude/hooks/lib/live-branch.sh is missing or unreadable — the task branch is read from HEAD only, so a rebase in progress reads as no branch. Run \`lemony repair\` to restore it.")
56
+ fi
38
57
 
39
58
  # ── Block 1 ─ harness.config.yml present and parseable ─────────────────────
40
59
  # Pure-bash validation (no yq): presence of the four keys the harness
@@ -46,8 +65,42 @@ WARNINGS=()
46
65
  # awk/grep/git (preinstalled) cover the rest.
47
66
  CONFIG_VERSION=""
48
67
  CONFIG_REPO=""
49
- if [ ! -f "$CONFIG" ]; then
68
+ # An install's baseline: a version directory (not a dot entry, not a link) under
69
+ # `.claude/.harness/baseline/` — the CLI's `hasInstallBaseline`. The `baseline/` entry
70
+ # alone is no trace: an ignored file in it keeps it on a branch from before the install.
71
+ has_install_baseline() {
72
+ local base="$REPO_ROOT/.claude/.harness/baseline" dir
73
+ # One it may not list is there all the same: "run install" would clobber edits.
74
+ if [ -d "$base" ] && { [ ! -r "$base" ] || [ ! -x "$base" ]; }; then
75
+ return 0
76
+ fi
77
+ for dir in "$base"/*/; do
78
+ [ -d "$dir" ] && [ ! -L "${dir%/}" ] && return 0
79
+ done
80
+ return 1
81
+ }
82
+ if [ -L "$CONFIG" ] && [ ! -e "$CONFIG" ]; then
83
+ # `-e` follows the link, so a dead one (or a loop, or one behind a directory it
84
+ # may not search) read as "not found" — and `lemony install` refuses to write
85
+ # through a symlink, so "run install" went in a circle. On an installed repo,
86
+ # install after removing it re-scaffolds over the user's edits: restore first.
87
+ ERRORS+=("harness.config.yml at $REPO_ROOT is a symlink that does not resolve (its target is missing or cannot be reached, or it loops). Repoint it at the config file, or replace it with a copy of the config (from git history or a backup); remove it and run \`lemony install\` only in a repo that was never installed.")
88
+ elif [ ! -e "$CONFIG" ] && has_install_baseline; then
89
+ # No config next to an install's baseline: removed from an installed repo, or an
90
+ # install that did not finish (it writes the config last). "Run install" is wrong
91
+ # for the first — with no TTY it keeps the vendor copy of every edited file.
92
+ ERRORS+=("harness.config.yml not found at $REPO_ROOT, but .claude/.harness/baseline exists: the harness was installed here, or an install did not finish. If a \`lemony install\` did not finish here, re-run it; otherwise restore harness.config.yml from git history or a backup (\`lemony install\` would, with no TTY, replace the files you edited with the vendor copy).")
93
+ elif [ ! -e "$CONFIG" ]; then
50
94
  ERRORS+=("harness.config.yml not found at $REPO_ROOT. Run \`lemony install\`.")
95
+ elif [ ! -f "$CONFIG" ]; then
96
+ # There, but not a file awk can read: a directory, or a FIFO it would block on
97
+ # until something wrote to it. `-e`/`-f` follow a symlink, so a link to one of
98
+ # these lands here.
99
+ ERRORS+=("harness.config.yml at $REPO_ROOT is not a regular file (a directory, a FIFO, a socket or a device), so it was not read. Replace it with the config file.")
100
+ elif [ ! -r "$CONFIG" ]; then
101
+ # A file awk cannot open failed the key check below, so the boot blamed missing keys
102
+ # under a raw awk error.
103
+ ERRORS+=("harness.config.yml at $REPO_ROOT cannot be read (check its permissions), so it was not read.")
51
104
  elif ! awk '
52
105
  { sub(/\r$/, "") }
53
106
  /^[^[:space:]#]/ {
@@ -138,12 +191,18 @@ fi
138
191
  # ladder, and following `git pull --rebase` with `rebase.autostash` set flattens
139
192
  # that index — the documented rollback then reverts to the last commit and the
140
193
  # group's whole uncommitted work is gone. So on any other branch: silence.
141
- # The full ref with `refs/heads/` stripped, not `--short`: the short form
142
- # lengthens to `heads/<name>` when a tag shares the branch's name, which would
143
- # fail the `harness/*` test below (same read as `session-close.sh` / `status`).
144
- CURRENT_BRANCH="$(git symbolic-ref --quiet HEAD 2>/dev/null | sed -n 's#^refs/heads/##p')"
194
+ # Silence too while a rebase has HEAD detached, even a rebase of the default
195
+ # branch: the count would be taken from the half-replayed HEAD, and the advice
196
+ # cannot be followed mid-rebase — hence the attached-HEAD check.
197
+ # `live_branch` (same read as `session-close.sh` / `status`): the full ref
198
+ # with `refs/heads/` stripped — `--short` lengthens to `heads/<name>` when a
199
+ # tag shares the branch's name, failing the `harness/*` test below — or, while
200
+ # a rebase has HEAD detached, the branch being rebased (a rebase of a task
201
+ # branch is not a parked task).
202
+ CURRENT_BRANCH="$(live_branch "$REPO_ROOT")"
145
203
  DEFAULT_BRANCH="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')"
146
- if [ -n "$DEFAULT_BRANCH" ] && [ "$CURRENT_BRANCH" = "$DEFAULT_BRANCH" ]; then
204
+ if [ -n "$DEFAULT_BRANCH" ] && [ "$CURRENT_BRANCH" = "$DEFAULT_BRANCH" ] \
205
+ && git symbolic-ref --quiet HEAD >/dev/null 2>&1; then
147
206
  BEHIND="$(git rev-list --count "HEAD..origin/$DEFAULT_BRANCH" 2>/dev/null || echo 0)"
148
207
  if [ "${BEHIND:-0}" -gt 0 ]; then
149
208
  WARNINGS+=("local branch is $BEHIND commit(s) behind origin/$DEFAULT_BRANCH. Consider \`git pull --rebase\` before starting.")
@@ -190,6 +249,19 @@ if [ "${#ERRORS[@]}" -eq 0 ] && [ -n "$GIT_USER_EMAIL" ]; then
190
249
  CURRENT_PATH="$REPO_ROOT/.claude/state/current-$USER_SLUG.md"
191
250
  if [ -z "$USER_SLUG" ]; then
192
251
  : # path-unsafe slug — pointer write skipped above
252
+ elif [ -L "$REPO_ROOT/.claude" ] || [ -L "$REPO_ROOT/.claude/state" ]; then
253
+ # A linked directory takes the pointer, and every refresh after it, out of the
254
+ # repo — the same write-through the dangling-link branch below refuses.
255
+ WARNINGS+=(".claude or .claude/state is a symlink, so the session start was not recorded (the pointer would be written wherever it points). Replace it with a directory.")
256
+ elif [ -e "$CURRENT_PATH" ] && [ ! -f "$CURRENT_PATH" ]; then
257
+ # There, but not a file: writing to a FIFO blocks until something reads it, and
258
+ # awk reading one blocks until something writes. The pointer is not critical, so
259
+ # the boot goes on without it — a warning, not a blocking error.
260
+ WARNINGS+=(".claude/state/current-$USER_SLUG.md is not a regular file (a directory, a FIFO, a socket or a device), so the session start was not recorded. Remove it; the next session recreates it.")
261
+ elif [ -L "$CURRENT_PATH" ] && [ ! -e "$CURRENT_PATH" ]; then
262
+ # A symlink whose target is gone: `-e`/`-f` follow it and see nothing, and the
263
+ # `cat >` below would create the target wherever the link points.
264
+ WARNINGS+=(".claude/state/current-$USER_SLUG.md is a symlink whose target is missing, so the session start was not recorded (writing through it would create the file it points at). Remove it; the next session recreates it.")
193
265
  elif [ ! -f "$CURRENT_PATH" ]; then
194
266
  mkdir -p "$REPO_ROOT/.claude/state"
195
267
  cat > "$CURRENT_PATH" <<EOF
@@ -203,7 +275,8 @@ last_close_ts: ""
203
275
  Per-dev pointer (gitignored). The lifecycle hooks read \`session_start_ts\`
204
276
  to compute \`session_active_h\` and reset it on each SessionStart that orients.
205
277
  The active task and its branch are not recorded here — \`session-close.sh\`
206
- derives them from the live branch (\`harness/<id>-<slug>\`) at close time — and
278
+ derives them from the live branch (\`harness/<id>-<slug>\` or
279
+ \`harness/closeout-<id>\`, the rebasing branch mid-rebase) at close time — and
207
280
  the narrative resume lives under \`sessions/<user>/\` (written by \`/pause\`).
208
281
  EOF
209
282
  else
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # The branch a session is working on, read off live git state — sourced by
3
+ # `session-close.sh` (task id of the auto-close record + `session_closed`) and
4
+ # `init.sh` (the parked-branch nudge). The TS twin is `readLiveBranch`
5
+ # (`src/status/live-branch.ts`, `lemony status`); `live-branch.spec.ts` runs
6
+ # both against the same real repositories.
7
+ #
8
+ # `live_branch <repo-root>` prints the branch name (no `refs/heads/`), or
9
+ # nothing when there is none — never a guess:
10
+ #
11
+ # 1. HEAD's full ref with `refs/heads/` stripped. Not `--short`: the short
12
+ # form lengthens to `heads/<name>` when a tag shares the branch's name.
13
+ # 2. When HEAD is detached, git's own record of a rebase in progress —
14
+ # `rebase-merge/head-name` (merge backend, `rebase -i`) or
15
+ # `rebase-apply/head-name` (apply backend) under the git dir. A conflicted
16
+ # or stopped rebase detaches HEAD for its whole duration, and that is
17
+ # exactly when a session is likely to pause. `--absolute-git-dir` resolves
18
+ # inside a linked worktree. `git am` writes no `head-name`, and a rebase
19
+ # of an already-detached HEAD records `detached HEAD` — both stay empty,
20
+ # as does a plain `git checkout --detach` and anything outside a repo.
21
+
22
+ live_branch() {
23
+ local root="$1" branch git_dir head_name
24
+ branch="$(git -C "$root" symbolic-ref --quiet HEAD 2>/dev/null | sed -n 's#^refs/heads/##p')"
25
+ if [ -z "$branch" ]; then
26
+ git_dir="$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null)"
27
+ if [ -n "$git_dir" ]; then
28
+ for head_name in "$git_dir/rebase-merge/head-name" "$git_dir/rebase-apply/head-name"; do
29
+ if [ -f "$head_name" ]; then
30
+ branch="$(sed -n 's#^refs/heads/##p' "$head_name" 2>/dev/null)"
31
+ break
32
+ fi
33
+ done
34
+ fi
35
+ fi
36
+ printf '%s' "$branch"
37
+ }
@@ -38,7 +38,8 @@
38
38
  # APPROVE comment) and verify the PR's current content is
39
39
  # what that APPROVE reviewed. Three outcomes: tree equal →
40
40
  # merge; tree differs but diff fingerprint equal → the
41
- # base advanced (update-branch) → merge, informing on
41
+ # base advanced (update-branch) and/or only task state
42
+ # under .claude/state changed → merge, informing on
42
43
  # stderr; fingerprint differs → exit 40, never merge. The
43
44
  # merge call is then pinned with --match-head-commit to
44
45
  # the exact head the guard verified, so a push racing the
@@ -309,12 +310,13 @@ fi
309
310
  # same recipe as playbook-scan.sh): emit `<key>\t<value>` for the uncommented
310
311
  # `checks_timeout_secs` / `allow_no_checks` entries — inline comments stripped
311
312
  # (on the block header too) and surrounding quotes removed. Any column-0 line
312
- # ends the block. Absent/unreadable config → baked defaults.
313
+ # ends the block. Absent/unreadable/non-regular config → baked defaults.
313
314
  CONFIG_TIMEOUT=""
314
315
  CONFIG_ALLOW_NO_CHECKS=""
315
316
  read_merge_config() {
316
317
  local config="$ROOT/harness.config.yml"
317
- [ -r "$config" ] || return 0
318
+ # `-f` as well as `-r`: a FIFO is readable, and awk on it would hang the merge.
319
+ { [ -f "$config" ] && [ -r "$config" ]; } || return 0
318
320
 
319
321
  local key value
320
322
  while IFS=$'\t' read -r key value; do
@@ -401,7 +403,14 @@ fi
401
403
  # before merge is standing practice and must not invalidate verdicts: a clean
402
404
  # base advance changes the tree but not the PR's own diff (verified
403
405
  # empirically, including that a base edit to a file the PR also touched DOES
404
- # change it — exactly the case that needs fresh eyes). `--no-abbrev` is
406
+ # change it — exactly the case that needs fresh eyes). The digest excludes
407
+ # `.claude/state`: the Reviewer writes its evidence ledger and
408
+ # `progress.md` section after posting the APPROVE, by contract, and the
409
+ # Orchestrator keeps writing task state until the merge — none of it is
410
+ # reviewed content, so a commit of it must not read as a stale approve.
411
+ # `:(top,…)` anchors the exclude at the repo root: a bare `:(exclude)` is
412
+ # cwd-relative and silently excludes nothing when the recipe runs from a
413
+ # subdirectory. `--no-abbrev` is
405
414
  # load-bearing: --raw abbreviates blob OIDs to a repo-size-dependent width by
406
415
  # default, so two machines could digest different bytes for the same diff.
407
416
  # The parity of this command with the Reviewer's recipe (reviewer.md) is
@@ -518,9 +527,11 @@ EOF
518
527
  fi
519
528
  # Canonical fingerprint command — byte-for-byte the Reviewer's recipe
520
529
  # (reviewer.md records the APPROVE side with the same pinned flags).
530
+ # --no-literal-pathspecs: under GIT_LITERAL_PATHSPECS=1 the exclude is read
531
+ # as a literal path, the diff comes out empty and every head digests alike.
521
532
  cur_fp="$(
522
533
  set -o pipefail
523
- git -C "$ROOT" -c core.quotePath=true diff --raw --no-abbrev --no-renames --no-color "$merge_root" "$head_oid" \
534
+ git -C "$ROOT" --no-literal-pathspecs -c core.quotePath=true diff --raw --no-abbrev --no-renames --no-color "$merge_root" "$head_oid" -- ':(top,exclude).claude/state' \
524
535
  | git -C "$ROOT" hash-object --stdin
525
536
  )"
526
537
  if [ $? -ne 0 ] || [ -z "$cur_fp" ]; then
@@ -536,7 +547,7 @@ EOF
536
547
  echo "A human's informed \"merge anyway\" re-runs with --force."
537
548
  fail 40
538
549
  fi
539
- warn "stale-approve guard: the tree differs from the APPROVE on issue #$APPROVE_ISSUE but the diff fingerprint matches — the base advanced (e.g. update-branch); merging the approved content on the new base."
550
+ warn "stale-approve guard: the tree differs from the APPROVE on issue #$APPROVE_ISSUE but the diff fingerprint matches — the base advanced (e.g. update-branch) and/or only task state under .claude/state changed; merging the approved content."
540
551
  fi
541
552
 
542
553
  # Pin the merge to the exact head this guard verified: a push landing
@@ -39,7 +39,8 @@
39
39
  # which would breach the per-fire budget) and with no materialized state file to
40
40
  # go stale before an `update` command exists (P7). Falls back to the baked
41
41
  # defaults when the config, its `paths` block, or a key is absent or commented
42
- # out (fresh clone, the vendor repo dogfooding itself, an un-customized install).
42
+ # out (fresh clone, the vendor repo dogfooding itself, an un-customized install),
43
+ # and when the config is not a regular file.
43
44
  _resolve_playbook_dirs() {
44
45
  local repo_root="$1"
45
46
  local home_dir="$2"
@@ -47,7 +48,8 @@ _resolve_playbook_dirs() {
47
48
  PLAYBOOKS_GLOBAL_DIR="$home_dir/.claude/playbooks"
48
49
 
49
50
  local config="$repo_root/harness.config.yml"
50
- [ -r "$config" ] || return 0
51
+ # `-f` as well as `-r`: a FIFO is readable, and awk on it would hang the hook.
52
+ { [ -f "$config" ] && [ -r "$config" ]; } || return 0
51
53
 
52
54
  # One awk pass: within the top-level `paths:` block, emit `<key>\t<value>` for
53
55
  # the (uncommented) `playbooks` / `playbooks_global` entries — inline comments
@@ -12,6 +12,22 @@
12
12
 
13
13
  set -u
14
14
 
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+
17
+ # shellcheck source=lib/live-branch.sh
18
+ if [ -f "$SCRIPT_DIR/lib/live-branch.sh" ] && [ -r "$SCRIPT_DIR/lib/live-branch.sh" ]; then
19
+ source "$SCRIPT_DIR/lib/live-branch.sh"
20
+ LIVE_BRANCH_LIB=1
21
+ else
22
+ # A hand-copied or partial install without the helper (`lemony install`
23
+ # copies all of lib/): read HEAD's branch only — a rebase in progress reads
24
+ # as no branch — and say so once instead of failing on every call.
25
+ live_branch() {
26
+ git -C "$1" symbolic-ref --quiet HEAD 2>/dev/null | sed -n 's#^refs/heads/##p'
27
+ }
28
+ LIVE_BRANCH_LIB=0
29
+ fi
30
+
15
31
  MANUAL=0
16
32
  for arg in "$@"; do
17
33
  case "$arg" in
@@ -93,13 +109,16 @@ fi
93
109
  # The pointer never carried a real `active_task` / `branch` — nothing in the
94
110
  # contract wrote them after init, so every auto-close record copied `null` /
95
111
  # the init-day branch. The branch is the source of truth: the Orchestrator
96
- # works every task on `harness/<id>-<slug>`, so HEAD names the task. The full
97
- # ref is read and `refs/heads/` stripped rather than `--short`: the short form
98
- # lengthens to `heads/<name>` when a tag shares the branch's name, which would
99
- # silently drop the id. Empty (→ `(unknown)`, no task) on a detached HEAD, a
100
- # rebase in progress, or outside a repo — never a stale guess.
101
- TASK_BRANCH="$(git -C "$REPO_ROOT" symbolic-ref --quiet HEAD 2>/dev/null | sed -n 's#^refs/heads/##p')"
102
- ACTIVE_TASK="$(printf '%s' "$TASK_BRANCH" | sed -n 's#^harness/\([0-9][0-9]*\)-..*$#\1#p')"
112
+ # works every task on `harness/<id>-<slug>` and closes it out on
113
+ # `harness/closeout-<id>`, so the branch names the task. `live_branch` reads
114
+ # HEAD, or the rebasing branch while a rebase has HEAD detached; empty
115
+ # (→ `(unknown)`, no task) on any other detached HEAD or outside a repo —
116
+ # never a stale guess.
117
+ TASK_BRANCH="$(live_branch "$REPO_ROOT")"
118
+ if [ "$LIVE_BRANCH_LIB" -eq 0 ]; then
119
+ echo "session-close: .claude/hooks/lib/live-branch.sh is missing or unreadable — the task branch was read from HEAD only (a rebase in progress reads as no branch). Run \`lemony repair\` to restore it." >&2
120
+ fi
121
+ ACTIVE_TASK="$(printf '%s' "$TASK_BRANCH" | sed -n -e 's#^harness/\([0-9][0-9]*\)-..*$#\1#p' -e 's#^harness/closeout-\([0-9][0-9]*\)$#\1#p')"
103
122
 
104
123
  # Fallback: a session with no recorded start defaults to "started now" so the
105
124
  # math is well-defined; duration becomes 0 hours, which the schema accepts.
@@ -27,11 +27,42 @@ Format per entry (one block per release):
27
27
  - `<event_type>.<field>` — <semantics-only change: shape untouched, meaning/unit changed>
28
28
  ```
29
29
 
30
- Empty sections may be omitted.
30
+ Empty sections may be omitted. `<version>` is the release that ships the entry —
31
+ written ahead, under the version the train expects; the version cut fails if a heading
32
+ names a version it neither ships nor already shipped (rename it to the real one).
31
33
 
32
34
  ---
33
35
 
34
- ## 0.4.2 — 2026-09-10
36
+ ## 0.6.0 — 2026-09-27
37
+
38
+ ### Added
39
+
40
+ - `task_done.checkpoints` — optional count of `step_completed` events for the
41
+ task (≥ 1): one per resolved human checkpoint, so a step the human sent back
42
+ counts again. Carries the number `task_done.steps` reported before this
43
+ version. Sent with `steps` on step-by-step tasks; `checkpoints − steps` is the
44
+ task's extra checkpoint rounds — normally its `changes` answers at step
45
+ checkpoints. `lemony emit` refuses a `task_done` whose counts contradict each
46
+ other (see the Writer contract); lines already written are not re-checked.
47
+
48
+ ### Changed
49
+
50
+ - **`task_done.steps` — now counts distinct steps**, not `step_completed`
51
+ events. Before this version the Orchestrator counted every event, including
52
+ checkpoints that ended in `changes`, so a task with 4 groups could report 10.
53
+ Shape untouched; readers comparing `steps` across versions should treat
54
+ earlier lines as checkpoint counts (the same unit as `checkpoints`), not
55
+ group counts.
56
+
57
+ - **`session_closed.task_id` — present in two more task contexts**: on a
58
+ `harness/closeout-<id>` branch (the `task-closeout` record branch), and while a
59
+ rebase has HEAD detached (the id of the branch being rebased, read from git's
60
+ own rebase record). Shape untouched; the value simply appears where it used to
61
+ be absent.
62
+
63
+ ---
64
+
65
+ ## 0.5.0 — 2026-09-10
35
66
 
36
67
  ### Changed
37
68