@lemoncode/lemony 0.5.1 → 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.
@@ -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
@@ -26,6 +26,8 @@ The design tool is a **projection** of the canonical JSON, never a peer source o
26
26
  Detect the tool at runtime: read the `com.lemony.design-tool` binding at the root of
27
27
  `docs/design-tokens.json`; if no tool is declared, the project is pure-code and there is
28
28
  nothing to sync. If the declared tool's MCP server is not connected, skip gracefully with a
29
- 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`.
30
32
 
31
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,12 +65,37 @@ WARNINGS=()
46
65
  # awk/grep/git (preinstalled) cover the rest.
47
66
  CONFIG_VERSION=""
48
67
  CONFIG_REPO=""
49
- if [ ! -e "$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\`.")
51
95
  elif [ ! -f "$CONFIG" ]; then
52
96
  # There, but not a file awk can read: a directory, or a FIFO it would block on
53
- # until something wrote to it. `-e`/`-f` follow a symlink, so a dead link stays
54
- # "not found" above and a link to one of these lands here.
97
+ # until something wrote to it. `-e`/`-f` follow a symlink, so a link to one of
98
+ # these lands here.
55
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.")
56
100
  elif [ ! -r "$CONFIG" ]; then
57
101
  # A file awk cannot open failed the key check below, so the boot blamed missing keys
@@ -147,12 +191,18 @@ fi
147
191
  # ladder, and following `git pull --rebase` with `rebase.autostash` set flattens
148
192
  # that index — the documented rollback then reverts to the last commit and the
149
193
  # group's whole uncommitted work is gone. So on any other branch: silence.
150
- # The full ref with `refs/heads/` stripped, not `--short`: the short form
151
- # lengthens to `heads/<name>` when a tag shares the branch's name, which would
152
- # fail the `harness/*` test below (same read as `session-close.sh` / `status`).
153
- 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")"
154
203
  DEFAULT_BRANCH="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')"
155
- 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
156
206
  BEHIND="$(git rev-list --count "HEAD..origin/$DEFAULT_BRANCH" 2>/dev/null || echo 0)"
157
207
  if [ "${BEHIND:-0}" -gt 0 ]; then
158
208
  WARNINGS+=("local branch is $BEHIND commit(s) behind origin/$DEFAULT_BRANCH. Consider \`git pull --rebase\` before starting.")
@@ -225,7 +275,8 @@ last_close_ts: ""
225
275
  Per-dev pointer (gitignored). The lifecycle hooks read \`session_start_ts\`
226
276
  to compute \`session_active_h\` and reset it on each SessionStart that orients.
227
277
  The active task and its branch are not recorded here — \`session-close.sh\`
228
- 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
229
280
  the narrative resume lives under \`sessions/<user>/\` (written by \`/pause\`).
230
281
  EOF
231
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
@@ -402,7 +403,14 @@ fi
402
403
  # before merge is standing practice and must not invalidate verdicts: a clean
403
404
  # base advance changes the tree but not the PR's own diff (verified
404
405
  # empirically, including that a base edit to a file the PR also touched DOES
405
- # 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
406
414
  # load-bearing: --raw abbreviates blob OIDs to a repo-size-dependent width by
407
415
  # default, so two machines could digest different bytes for the same diff.
408
416
  # The parity of this command with the Reviewer's recipe (reviewer.md) is
@@ -519,9 +527,11 @@ EOF
519
527
  fi
520
528
  # Canonical fingerprint command — byte-for-byte the Reviewer's recipe
521
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.
522
532
  cur_fp="$(
523
533
  set -o pipefail
524
- 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' \
525
535
  | git -C "$ROOT" hash-object --stdin
526
536
  )"
527
537
  if [ $? -ne 0 ] || [ -z "$cur_fp" ]; then
@@ -537,7 +547,7 @@ EOF
537
547
  echo "A human's informed \"merge anyway\" re-runs with --force."
538
548
  fail 40
539
549
  fi
540
- 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."
541
551
  fi
542
552
 
543
553
  # Pin the merge to the exact head this guard verified: a push landing
@@ -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
 
@@ -36,14 +36,14 @@ Every event line starts with this envelope. Per-type fields are added at the
36
36
  same top level — there is no nested `payload`, so Zod discriminated unions key on
37
37
  `type`.
38
38
 
39
- | Field | Type | Required | Axis | Notes |
40
- | ----------------- | ------ | -------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
41
- | `type` | string | yes | `internal-enum` | One of the 9 event types listed below. Discriminator. |
42
- | `ts` | string | yes | `metric` | UTC ISO 8601 with `Z` suffix (e.g. `2026-05-28T14:30:00.000Z`). **No local offsets.** |
43
- | `user` | string | yes | `local-only` | `git config user.email` of the actor. Never exported in any tier. |
44
- | `project` | string | yes | `identity` | `task_storage.repo` slug (e.g. `acme/widgets`), from `harness.config.yml`. **Never `OWNER/REPO`** — the CLI refuses to emit while that placeholder is the value (see [Placeholder guard](#placeholder-guard)). |
45
- | `task_id` | string | no | `identity` | Task issue id (e.g. `42`) when the event has a task context. Absent on global events; `session_closed` carries it when HEAD is a `harness/<id>-<slug>` task branch. A per-project correlator — only meaningful alongside `project`, so it shares the `identity` axis. |
46
- | `harness_version` | string | yes | `metric` | `version` of the **installed** `@lemoncode/lemony` package — _not_ `vendor_version` from config. |
39
+ | Field | Type | Required | Axis | Notes |
40
+ | ----------------- | ------ | -------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
41
+ | `type` | string | yes | `internal-enum` | One of the 9 event types listed below. Discriminator. |
42
+ | `ts` | string | yes | `metric` | UTC ISO 8601 with `Z` suffix (e.g. `2026-05-28T14:30:00.000Z`). **No local offsets.** |
43
+ | `user` | string | yes | `local-only` | `git config user.email` of the actor. Never exported in any tier. |
44
+ | `project` | string | yes | `identity` | `task_storage.repo` slug (e.g. `acme/widgets`), from `harness.config.yml`. **Never `OWNER/REPO`** — the CLI refuses to emit while that placeholder is the value (see [Placeholder guard](#placeholder-guard)). |
45
+ | `task_id` | string | no | `identity` | Task issue id (e.g. `42`) when the event has a task context. Absent on global events; `session_closed` carries it when the live branch is a `harness/<id>-<slug>` or `harness/closeout-<id>` task branch. A per-project correlator — only meaningful alongside `project`, so it shares the `identity` axis. |
46
+ | `harness_version` | string | yes | `metric` | `version` of the **installed** `@lemoncode/lemony` package — _not_ `vendor_version` from config. |
47
47
 
48
48
  ### Placeholder guard
49
49
 
@@ -115,8 +115,9 @@ forward-compatible — readers dispatch on `type` and ignore unknowns.
115
115
 
116
116
  Emitted by `session-close.sh` on `SessionEnd` or `/pause` (manual). One per
117
117
  session. The envelope's `task_id` is derived from the live branch at close time
118
- (`harness/<id>-<slug>` → `<id>`); a session closed on the default branch or a
119
- detached HEAD carries none.
118
+ (`harness/<id>-<slug>` or `harness/closeout-<id>` → `<id>`) — while a rebase has
119
+ HEAD detached, the branch being rebased; a session closed on the default branch or
120
+ any other detached HEAD carries none.
120
121
 
121
122
  | Field | Type | Required | Axis | Notes |
122
123
  | ------------------ | ------- | -------- | --------------- | --------------------------------------------------------------------------------------------------------------- |
@@ -148,17 +149,18 @@ Emitted by the Orchestrator when it transitions `spec-in-progress → spec-ready
148
149
 
149
150
  ### 4. `task_done` _(P5)_
150
151
 
151
- Emitted by the Orchestrator at closeout (after `gh pr view` confirms `MERGED`,
152
- before `git rm` of the task state).
152
+ Emitted by the Orchestrator at closeout **finalize** — after the closeout PR,
153
+ which archives the spec and drops the task's `progress.md`, has merged.
153
154
 
154
- | Field | Type | Required | Axis | Notes |
155
- | ------------------- | ------ | -------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
156
- | `task_id` | string | yes | `identity` | Required for this type. |
157
- | `level` | string | yes | `internal-enum` | `L1` \| `L2` \| `L3` — the task-fit dial value used. |
158
- | `cycle_time_h` | number | yes | `metric` | Wall-clock hours from issue creation to merge. ≥ 0, finite. |
159
- | `review_rejections` | number | yes | `metric` | Count of `review_rejected` events for this `task_id` (≥ 0, int). |
160
- | `mode` | string | no | `internal-enum` | `all_at_once` \| `step_by_step` — the mode chosen at the L1 approval gate. **Absent on L2** (the question only exists where `tasks.md` does). |
161
- | `steps` | number | no | `metric` | Count of `step_completed` events for this task (≥ 1, int). Only meaningful when `mode` is `step_by_step`; < total groups after a mid-task downgrade. |
155
+ | Field | Type | Required | Axis | Notes |
156
+ | ------------------- | ------ | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
157
+ | `task_id` | string | yes | `identity` | Required for this type. |
158
+ | `level` | string | yes | `internal-enum` | `L1` \| `L2` \| `L3` — the task-fit dial value used. |
159
+ | `cycle_time_h` | number | yes | `metric` | Wall-clock hours from issue creation to merge. ≥ 0, finite. |
160
+ | `review_rejections` | number | yes | `metric` | Count of `review_rejected` events for this `task_id` (≥ 0, int). |
161
+ | `mode` | string | no | `internal-enum` | `all_at_once` \| `step_by_step` — the mode chosen at the L1 approval gate. **Absent on L2** (the question only exists where `tasks.md` does), and on an L1 task whose gate choice closeout could not recover. |
162
+ | `steps` | number | no | `metric` | Count of **distinct steps** (distinct `step` values) across this task's `step_completed` events (≥ 1, int). Only meaningful when `mode` is `step_by_step`; < total groups after a mid-task downgrade. Before 0.6.0 it counted every event — see [history](tier2-events-history.md). |
163
+ | `checkpoints` | number | no | `metric` | Count of `step_completed` events for this task (≥ 1, int) — one per resolved human checkpoint, so a step sent back counts again. Sent together with `steps`; `checkpoints − steps` is the task's extra checkpoint rounds — normally its `changes` answers at step checkpoints. Not counted: any human gate after a downgrade to all-at-once (it emits nothing), and change requests at the merge gate. |
162
164
 
163
165
  ### 5. `review_rejected` _(P5)_
164
166
 
@@ -211,7 +213,9 @@ post-merge / production signal; conflating them would dirty the post-merge metri
211
213
  ### 9. `step_completed` _(step-by-step mode)_
212
214
 
213
215
  Emitted by the Orchestrator each time a human checkpoint **resolves** in
214
- step-by-step mode (L1 opt-in, chosen at the approval gate). One event per
216
+ step-by-step mode (L1 opt-in, chosen at the approval gate), through the
217
+ `checkpoint` verb that also makes the resolution's commits — or by hand,
218
+ late, when that call never ran for an answer. One event per
215
219
  checkpoint, not per step: a step the human sends back ("changes") emits again
216
220
  when it re-checkpoints, with the same `step`. This is the signal that justifies
217
221
  (or condemns) the mode — the rate of checkpoints that catch things, and where
@@ -272,7 +276,11 @@ A writer (the `lemony emit` CLI):
272
276
  2. Merges per-type fields into the same top level (no nested `payload`).
273
277
  3. Validates against the Zod schema for `type` (each schema is `.strict()`,
274
278
  so an unknown key — typically a typo'd `--task-iid` flag — **rejects**
275
- loud). Never writes a partial line.
279
+ loud). `task_done` also rejects step counts that contradict each other:
280
+ `steps`/`checkpoints` on a task whose `mode` is not `step_by_step`,
281
+ `checkpoints` without `steps`, and `checkpoints < steps`. These rules run at
282
+ emit time only — readers do not re-check lines already written. Never writes
283
+ a partial line.
276
284
  4. Appends the JSON line via `fs.appendFile` (single `O_APPEND` `write(2)`,
277
285
  POSIX-atomic up to `PIPE_BUF`) to `.claude/state/events.jsonl`. Creates the
278
286
  file (and parent dir, scaffolded by `install`) when missing.
@@ -10,7 +10,9 @@ invoked-by: [implementer]
10
10
  # Build UI
11
11
 
12
12
  The implementer's **build method**: turn a `ui-handoff.md` (the design contract) plus
13
- `docs/design-tokens.json` (the token source of truth) into UI code that applies the
13
+ `docs/design-tokens.json` (the token source of truth — or the path `design_tokens.file` names
14
+ in `harness.config.yml`, when set; read that file wherever this skill says
15
+ `docs/design-tokens.json`) into UI code that applies the
14
16
  project's tokens correctly, carries the design's point of view instead of generic
15
17
  defaults, and is accessible by construction.
16
18
 
@@ -44,7 +46,8 @@ v3→v4 jump turns it into a lie); the live model does not. So this skill names
44
46
 
45
47
  ## Tokens-as-code — the contract
46
48
 
47
- `docs/design-tokens.json` is the **single, client-owned source of truth**, a 3-tier
49
+ `docs/design-tokens.json` is the **single, client-owned source of truth** (or the path
50
+ `harness.config.yml` names in `design_tokens.file`, when set — read that file instead), a 3-tier
48
51
  W3C-DTCG model: **primitive** (raw scale values) → **semantic** (intent: `color.surface`,
49
52
  `space.inset.md`) → **component** (a component's specific slots). Two rules hold on every
50
53
  stack: