@lemoncode/lemony 0.4.1 → 0.5.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.
package/catalog/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.1
1
+ 0.5.0
@@ -54,6 +54,16 @@ what you run, only how many calls carry it.
54
54
  behavior (vertical slices, never all-tests-then-all-code). When your invocation
55
55
  says **auto-commit is OFF**, the whole run obeys §Staging protocol (below) —
56
56
  zero commits, staged save-points.
57
+ **The checkbox is yours** (L1 — a task with a `tasks.md`; L2 has none). A task's
58
+ `- [ ]` is its done-marker: flip it to `- [x]` the moment the task reaches green
59
+ (suite passing), and let the tick ride the task's own commit (auto-commit OFF:
60
+ the same `git add` — at the human's OK the Orchestrator's pathspec split lands it
61
+ in the step's state commit beside the code commit; the index is where the two
62
+ move together) — so the file always says where the work is. Nobody
63
+ else ticks for you: `lemony review-ledger validate` reports a task still `[ ]` in
64
+ a group the loop has passed (`unticked-completed-task`) and the human reads it as
65
+ a forgotten mark **or as work never done** — the honest reason to keep the two in
66
+ step.
57
67
  **Scope: exactly what the invocation hands you.** By default that is the whole
58
68
  `tasks.md` list (all-at-once). In **step-by-step mode** the Orchestrator
59
69
  invokes you per step — scoped to **one group**, handed **by reference** (the
@@ -124,9 +134,10 @@ iteration only knows what this contract and the worktree tell it:
124
134
 
125
135
  - **Zero commits, zero pushes.** Never `git commit`, never `git push` — not for
126
136
  code, not for state. The Orchestrator commits once the human OKs.
127
- - **Stage after every green task** — `git add -A` when a checkbox/task reaches
128
- green (suite passing). The index is your save-point ladder: everything staged
129
- is a proven-green floor.
137
+ - **Stage after every green task** — tick the task's checkbox in `tasks.md`
138
+ (`- [ ]` → `- [x]`, step 2 of the procedure), then `git add -A`, when a task
139
+ reaches green (suite passing). The index is your save-point ladder: everything
140
+ staged is a proven-green floor, and the tick is part of the floor.
130
141
  - **Never stage mid-experiment.** A `git add` while red or mid-refactor silently
131
142
  **overwrites the save-point** — `git status` looks identical afterwards, and
132
143
  recovery yields nameless blobs (practically unrecoverable). Stage only at
@@ -365,7 +365,9 @@ numbers still never shift. For each group, in order:
365
365
  (fresh context, as always) scoped to **this one group**: give it the branch, the
366
366
  task-state paths, and the group's id + header line in `tasks.md` — by reference
367
367
  (§Sub-agent invocation), it reads the group's tasks from the file itself (`tdd`
368
- skill — checkbox by checkbox with small commits, as always). It commits to the
368
+ skill — checkbox by checkbox with small commits, as always, and it **ticks each
369
+ checkbox at green** — the tick rides the task's commit, or its `git add` under
370
+ auto-commit OFF; the file is the group's progress record). It commits to the
369
371
  branch (auto-commit OFF: it **stages** instead of committing — point the
370
372
  invocation at the staging protocol in `implementer.md`), logs to `progress.md`,
371
373
  and signals done. **No PR yet** — the PR opens after the last group,
@@ -422,13 +424,15 @@ numbers still never shift. For each group, in order:
422
424
  gates or the bare real-run floor (`undeclared`).
423
425
  Red → **an APPROVE with a red ledger is never relayed**, and a red ledger is not a
424
426
  REJECT of the code: the Implementer is not at fault and is **never** re-invoked for
425
- it. Route on the `[kind]` lines the verb prints — it closes with
427
+ the red itself (a human's `changes` at the checkpoint it feeds is the human's call, as
428
+ at every checkpoint). Route on the `[kind]` lines the verb prints — it closes with
426
429
  `N spec-side problem(s) … do not retry the Reviewer while any of these stands.`
427
430
  whenever any problem lives outside the sidecar:
428
431
 
429
432
  - **any spec-side problem** — the verb counts them out loud (`unknown-risk-class`, a
430
433
  tag or ref list that did not parse, a duplicated group number, an orphan task, an
431
- empty group, a dangling requirement ref, a step with no group) → no retry. They
434
+ empty group, a dangling requirement ref, a step with no group, a task still
435
+ `[ ]` in a group the loop has passed) → no retry. They
432
436
  live in `tasks.md` / `requirements.md`, which the Reviewer cannot fix: stop and
433
437
  bring them to the human as an **anticipated checkpoint** (below) with the verb's
434
438
  lines as the content — the spec needs a decision, and the fix routes as a discovery
@@ -436,6 +440,37 @@ numbers still never shift. For each group, in order:
436
440
  the sidecar on disk: green → the checkpoint; a sidecar-side red then follows the
437
441
  one-retry rule below. The human's `ok` on an anticipated checkpoint is their call,
438
442
  as at every gate — what never happens is you relaying a red as an APPROVE.
443
+ **`unticked-completed-task` is the one spec-side kind that needs no discovery**:
444
+ the Implementer ticks each task at green, so an unticked task in a passed group
445
+ is a forgotten mark or work that was never done, and only the human can tell
446
+ which. Name the task ids in the checkpoint and ask. `ok` = "done, the mark was
447
+ forgotten" → tick it yourself (`- [ ]` → `- [x]`) and **stage it the moment you
448
+ do** (`git add .claude/state/tasks/<id>/spec/tasks.md`) — a mediated edit, staged
449
+ on arrival like a Spec Author update, so the checkpoint contract's spec check
450
+ (item 4) never reads it as an unconfirmed human spec edit — then re-run the verb.
451
+ Wherever an OK's composite commits state, the staged tick rides its
452
+ `.claude/state` half and needs nothing more: every step OK (both knob states),
453
+ and auto-commit OFF's deferral-ending checkpoint OK, which its pre-gate full pass
454
+ feeds. **At the merge-gate presentation nothing else commits state before the
455
+ merge** — the step-8 full pass in either mode, all-at-once under auto-commit ON
456
+ having had no checkpoint at all — so there, commit it yourself as you stage it,
457
+ in the same composite turn as the re-run (§Turn economy):
458
+
459
+ ```bash
460
+ git commit -m "task(<id>): tick <T-ids> — human ok on unticked-completed-task" \
461
+ -- .claude/state/tasks/<id>/spec/tasks.md; \
462
+ git push # best-effort — a failure warns, never blocks
463
+ ```
464
+
465
+ A merged `tasks.md` still reading `[ ]` is exactly the harm the kind exists to
466
+ stop. That post-APPROVE state write moves the stale-approve fingerprint the way
467
+ the ledger's own write does, and routes as the merge gate's exit-40 rule says
468
+ (§Merge gate) — a fresh APPROVE re-records the hashes; `--force` stays the
469
+ human's separate, informed call. `changes` = the
470
+ task is not done → the normal fix iteration, feedback naming the task. Never tick
471
+ on your own judgment: the mark is the Implementer's claim, and the verb exists so
472
+ a missing one is read by a person, not smoothed over by you.
473
+
439
474
  - **anything else** (a problem in the sidecar itself) → re-invoke the **Reviewer**
440
475
  (fresh, as always) **once**, with the
441
476
  verb's `[kind] message` lines **verbatim** in the spawn prompt — the delta is the
@@ -461,7 +496,12 @@ numbers still never shift. For each group, in order:
461
496
  `step_completed` emit (here `review_iterations` is 3) and the same transient
462
497
  `awaiting human checkpoint (step N/M)` line in `progress.md` — except you present
463
498
  the unresolved disagreement (both positions, the spec slice) instead of a clean
464
- step.
499
+ step. When its content is the verb's `unticked-completed-task` lines, `ok` carries
500
+ one act before the state commit — tick and stage the named tasks (step 2's routing
501
+ bullet) — and the transient line names them,
502
+ `awaiting human checkpoint (step N/M) [unticked: T3, T4]` (step 5), so a cold
503
+ `/resume` re-presents the same content and owes the same act (the clean-step line
504
+ carries no suffix).
465
505
 
466
506
  In auto-commit OFF, `OK` is also the moment the group's single code commit
467
507
  lands, and `changes` sends the fresh Implementer to iterate **over the worktree**
@@ -571,8 +611,10 @@ numbers still never shift. For each group, in order:
571
611
  step's line is transient — update it in place as the loop progresses
572
612
  (`fix-loop iteration K — in progress` while implementing/reviewing,
573
613
  `awaiting ledger retry (step N/M, retry 1/1)` while the fresh Reviewer redoes a red
574
- ledger, `awaiting human checkpoint (step N/M)` while waiting on the human), then
575
- replace it with the resolved outcome:
614
+ ledger, `awaiting human checkpoint (step N/M)` while waiting on the human — with
615
+ the `[unticked: T3, T4]` suffix when the checkpoint is the anticipated one an
616
+ `unticked-completed-task` red raised, in all-at-once too, where the counter is
617
+ absent), then replace it with the resolved outcome:
576
618
 
577
619
  ```markdown
578
620
  Mode: step-by-step
@@ -611,7 +653,10 @@ spec-side problem to the human, and never an APPROVE relayed on a red ledger. Ou
611
653
  the step loop the transient line is `awaiting ledger retry (full pass, retry 1/1)` in
612
654
  `progress.md` (no step counter), and "the human" is the gate the pass feeds: the
613
655
  auto-commit-OFF checkpoint when there is one, otherwise the merge-gate presentation,
614
- with the verb's lines as the content.
656
+ with the verb's lines as the content. An `unticked-completed-task` there resolves as
657
+ in step 2's routing bullet — on `ok`, tick and stage the named tasks; at the
658
+ auto-commit-OFF checkpoint the OK's composite commits them, at the merge-gate
659
+ presentation you commit them yourself — then re-run the verb.
615
660
 
616
661
  ## Checkpoint contract (how a human gate presents work)
617
662
 
@@ -668,7 +713,10 @@ contract, not a vibe:
668
713
  agent-staged floor, so this check fires only on **unmediated** edits and
669
714
  the re-presented checkpoint is clean; an unstaged edit that matches the
670
715
  recorded `**Resolution**` in `discoveries.md` is confirmed content — stage
671
- it, don't re-raise.
716
+ it, don't re-raise. The one mediated edit that is **not** a discovery — your
717
+ own `- [ ]` → `- [x]` tick on the human's `ok` to an `unticked-completed-task`
718
+ checkpoint (step 2) — is staged the same way, as you make it; it has no
719
+ `discoveries.md` entry to match, and needs none: the `ok` is its record.
672
720
 
673
721
  The **spec check runs in both knob states** — in auto-commit ON, run it
674
722
  **before** step 3's `awaiting` state commit, which would otherwise silently
@@ -228,9 +228,15 @@ What the validator enforces in this version, so you never have to guess:
228
228
  - **Spec-side problems are reported, never dropped — and they are not yours to fix.** A
229
229
  `[risk: …]` tag outside the vocabulary (`unknown-risk-class`), a tag or a `(R<n>)` ref
230
230
  list that did not parse, a duplicated group number, a task above the first header, an
231
- empty group, a ref `requirements.md` never declares, a step with no group: each names
231
+ empty group, a ref `requirements.md` never declares, a step with no group, a task
232
+ still `[ ]` in a group the loop has passed (`unticked-completed-task` — the
233
+ Implementer's done-marker is missing, and whether the work is too is the human's
234
+ call, not yours): each names
232
235
  a defect in `tasks.md` / `requirements.md`, and the verb counts them out loud as
233
- **spec-side**. Finish the ledger (a dangling ref leaves the slice — never invent an
236
+ **spec-side**. The ticks themselves, wherever a diff shows them (the full pass's PR
237
+ diff does), are the Implementer's done-marks flipped at green — **not a spec
238
+ change**: never drift, never a finding on their own. Finish the ledger (a dangling
239
+ ref leaves the slice — never invent an
234
240
  entry for a requirement that does not exist), return your verdict as usual, and name
235
241
  them in it — the Orchestrator takes them to the human instead of sending you back.
236
242
 
@@ -9,8 +9,9 @@ A two-step pause:
9
9
 
10
10
  1. **Write the resume narrative** so future-you (or whoever resumes) can pick
11
11
  up cold:
12
- - Read `.claude/state/current-<your-user>.md` and the active task's
13
- `progress.md` (if any).
12
+ - Read the active task's `progress.md` (if any) and the latest note under
13
+ `.claude/state/sessions/<your-user>/` (if any) — that is where the
14
+ narrative lives; `current-<your-user>.md` only holds session timestamps.
14
15
  - Generate a UTC timestamp slug — run `date -u +%Y-%m-%dT%H:%M:%SZ` and use
15
16
  it as `<ts>`. Pick a short topic slug from the work in progress.
16
17
  - Write `.claude/state/sessions/<your-user>/<ts>-<topic>.md` with this
@@ -19,8 +20,8 @@ A two-step pause:
19
20
  ```markdown
20
21
  ---
21
22
  session_close_ts: <ts>
22
- active_task: <issue-id or null>
23
- branch: <current branch>
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>
24
25
  topic: <topic>
25
26
  reason: manual
26
27
  auto_close: false
@@ -42,7 +43,9 @@ A two-step pause:
42
43
  ```
43
44
 
44
45
  2. **Trigger the session-close hook** so the `session_closed` event lands in
45
- `events.jsonl` and `current-<your-user>.md` pointers update:
46
+ `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
48
+ `last_close_ts` stamped:
46
49
 
47
50
  ```bash
48
51
  .claude/hooks/session-close.sh --manual
@@ -89,6 +89,10 @@
89
89
  "items": {
90
90
  "type": "string"
91
91
  }
92
+ },
93
+ "verify": {
94
+ "type": "string",
95
+ "minLength": 1
92
96
  }
93
97
  },
94
98
  "additionalProperties": false
@@ -138,7 +138,10 @@ fi
138
138
  # ladder, and following `git pull --rebase` with `rebase.autostash` set flattens
139
139
  # that index — the documented rollback then reverts to the last commit and the
140
140
  # group's whole uncommitted work is gone. So on any other branch: silence.
141
- CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
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')"
142
145
  DEFAULT_BRANCH="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')"
143
146
  if [ -n "$DEFAULT_BRANCH" ] && [ "$CURRENT_BRANCH" = "$DEFAULT_BRANCH" ]; then
144
147
  BEHIND="$(git rev-list --count "HEAD..origin/$DEFAULT_BRANCH" 2>/dev/null || echo 0)"
@@ -191,8 +194,6 @@ if [ "${#ERRORS[@]}" -eq 0 ] && [ -n "$GIT_USER_EMAIL" ]; then
191
194
  mkdir -p "$REPO_ROOT/.claude/state"
192
195
  cat > "$CURRENT_PATH" <<EOF
193
196
  ---
194
- active_task: null
195
- branch: $(git symbolic-ref --quiet --short HEAD 2>/dev/null || echo "(unknown)")
196
197
  session_start_ts: $NOW_ISO
197
198
  last_close_ts: ""
198
199
  ---
@@ -201,22 +202,45 @@ last_close_ts: ""
201
202
 
202
203
  Per-dev pointer (gitignored). The lifecycle hooks read \`session_start_ts\`
203
204
  to compute \`session_active_h\` and reset it on each SessionStart that orients.
204
-
205
- ## Resume hint
206
-
207
- _(One paragraph — what to pick up next. Updated by \`/pause\`.)_
205
+ 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
207
+ the narrative resume lives under \`sessions/<user>/\` (written by \`/pause\`).
208
208
  EOF
209
209
  else
210
210
  # Refresh session_start_ts on every orient so close-time math is accurate.
211
211
  # awk in-place rewrite of the frontmatter scalar — without this,
212
212
  # `session_start_ts` would stay frozen at the first orient and
213
213
  # `session_active_h` would be cumulative across every clear/resume.
214
+ # The same pass drops the dead `active_task` / `branch` scalars a pointer
215
+ # created by an earlier version still carries — nothing ever updated them,
216
+ # so they lie (`null` / the init-day branch); the live branch is the source.
217
+ # Its "Resume hint" section goes too, but ONLY while it is still exactly
218
+ # the untouched placeholder — the heading, blank lines, the one placeholder
219
+ # line, and nothing else up to the next heading or EOF (its "Updated by
220
+ # `/pause`" promise was never kept). Anything a person wrote there — the
221
+ # placeholder replaced, a paragraph added below it, even a `---` rule —
222
+ # makes the section theirs and it stays, heading included; so does a
223
+ # section stripped to its bare heading. Decided by a read-only pre-pass so
224
+ # the rewrite below never looks ahead; `[[:space:]]*$` tolerates CRLF.
225
+ PRUNE_HINT="$(awk '
226
+ inhint && /^---/ { other = 1; next }
227
+ /^---/ { block++; next }
228
+ block < 2 { next }
229
+ /^## Resume hint[[:space:]]*$/ { inhint = 1; seen = 1; next }
230
+ inhint && /^## / { inhint = 0 }
231
+ !inhint { next }
232
+ /^[[:space:]]*$/ { next }
233
+ /Updated by `\/pause`/ { placeholder++; next }
234
+ { other = 1 }
235
+ END { print (seen && placeholder == 1 && !other) ? 1 : 0 }
236
+ ' "$CURRENT_PATH" 2>/dev/null)"
237
+ [ "$PRUNE_HINT" = "1" ] || PRUNE_HINT=0
214
238
  # Write through a mktemp file, not a predictable `$CURRENT_PATH.tmp`: the
215
239
  # latter is a known path an attacker could pre-plant as a symlink for the
216
240
  # redirect to follow. `mktemp` refuses to reuse an existing path, and `mv`
217
241
  # over CURRENT_PATH itself safely replaces a symlink with a regular file.
218
242
  if tmp="$(mktemp "$REPO_ROOT/.claude/state/.ptr.XXXXXX")"; then
219
- awk -v ts="$NOW_ISO" '
243
+ awk -v ts="$NOW_ISO" -v prune="$PRUNE_HINT" '
220
244
  /^---/ {
221
245
  block++
222
246
  if (block == 2 && !seen) print "session_start_ts: " ts
@@ -224,6 +248,10 @@ EOF
224
248
  next
225
249
  }
226
250
  block == 1 && /^session_start_ts:/ { print "session_start_ts: " ts; seen = 1; next }
251
+ block == 1 && /^(active_task|branch):/ { next }
252
+ prune && block >= 2 && /^## Resume hint[[:space:]]*$/ { inhint = 1; next }
253
+ inhint && /^## / { inhint = 0 }
254
+ inhint { next }
227
255
  { print }
228
256
  ' "$CURRENT_PATH" > "$tmp" && mv "$tmp" "$CURRENT_PATH" || rm -f "$tmp"
229
257
  fi
@@ -65,8 +65,6 @@ NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
65
65
  # (preinstalled) cover the rest.
66
66
  CURRENT_PATH="$REPO_ROOT/.claude/state/current-$USER_SLUG.md"
67
67
  SESSION_START_TS=""
68
- ACTIVE_TASK=""
69
- TASK_BRANCH=""
70
68
  read_frontmatter_field() {
71
69
  local field="$1"
72
70
  awk -v field="$field" '
@@ -89,10 +87,20 @@ read_frontmatter_field() {
89
87
  }
90
88
  if [ -f "$CURRENT_PATH" ]; then
91
89
  SESSION_START_TS="$(read_frontmatter_field session_start_ts)"
92
- ACTIVE_TASK="$(read_frontmatter_field active_task)"
93
- TASK_BRANCH="$(read_frontmatter_field branch)"
94
90
  fi
95
91
 
92
+ # ── Derive the task context from the live branch ───────────────────────────
93
+ # The pointer never carried a real `active_task` / `branch` — nothing in the
94
+ # contract wrote them after init, so every auto-close record copied `null` /
95
+ # 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')"
103
+
96
104
  # Fallback: a session with no recorded start defaults to "started now" so the
97
105
  # math is well-defined; duration becomes 0 hours, which the schema accepts.
98
106
  if [ -z "$SESSION_START_TS" ]; then
@@ -143,9 +151,9 @@ EMIT_ARGS=(
143
151
  --reason="$REASON"
144
152
  --auto-close="$AUTO_CLOSE"
145
153
  )
146
- # Skip a literal "null" — a buggy frontmatter may emit the string instead of a
147
- # real YAML null. The schema would accept it but the line is forensic noise.
148
- if [ -n "$ACTIVE_TASK" ] && [ "$ACTIVE_TASK" != "null" ]; then
154
+ # Only a task branch yields an id; a session closed on `main` (or detached)
155
+ # emits the envelope without `task_id`, as the schema allows.
156
+ if [ -n "$ACTIVE_TASK" ]; then
149
157
  EMIT_ARGS+=(--task-id="$ACTIVE_TASK")
150
158
  fi
151
159
  # Resolve the CLI via the launcher (local devDependency → global → fail-fast)
@@ -31,6 +31,20 @@ Empty sections may be omitted.
31
31
 
32
32
  ---
33
33
 
34
+ ## 0.4.2 — 2026-09-10
35
+
36
+ ### Changed
37
+
38
+ - **`session_closed.task_id` — now derived from the live branch at close time**
39
+ (`harness/<id>-<slug>` → `<id>`); absent on the default branch or a detached
40
+ HEAD. Previously the field was **never populated** on this event: the emitter
41
+ read it from a per-dev pointer field nothing ever wrote. **No field renamed,
42
+ added, or removed** — the envelope's optional `task_id` is simply present from
43
+ this version on when a session closes on a task branch. Readers bucketing
44
+ `session_closed` by task should expect the value to appear at this version.
45
+
46
+ ---
47
+
34
48
  ## 0.2.0 — 2026-07-31
35
49
 
36
50
  ### Changed
@@ -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 session/global events. 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 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. |
47
47
 
48
48
  ### Placeholder guard
49
49
 
@@ -114,7 +114,9 @@ forward-compatible — readers dispatch on `type` and ignore unknowns.
114
114
  ### 1. `session_closed` _(P5)_
115
115
 
116
116
  Emitted by `session-close.sh` on `SessionEnd` or `/pause` (manual). One per
117
- session.
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
120
 
119
121
  | Field | Type | Required | Axis | Notes |
120
122
  | ------------------ | ------- | -------- | --------------- | --------------------------------------------------------------------------------------------------------------- |
@@ -29,7 +29,9 @@ Run the tiers in order; each adds evidence the next builds on.
29
29
  token pair — including any dark-mode override — and exits non-zero on a pair below its
30
30
  floor. This is the one a11y measurement doable offline, because colour comes from the
31
31
  token file. It is complementary to `lemony design-tokens validate` (validate proves colour
32
- is a token reference; contrast proves the pair meets its floor).
32
+ is a token reference; contrast proves the pair meets its floor). When the project declares
33
+ `design_tokens.verify` in `harness.config.yml`, the same command also runs the design
34
+ system's own verifier and forwards its report — read that output as T0 evidence too.
33
35
  - **T1 — source a11y lint (rides the project).** The project's own linter usually carries an
34
36
  accessibility plugin (`eslint-plugin-jsx-a11y`, Svelte's or Vue's a11y rules). Run the
35
37
  project's `lint`; don't reimplement it. Read what it flags.
@@ -215,6 +215,11 @@ The review evidence ledger's validator enumerates each group's review slice from
215
215
  refs; a task it cannot read is reported as `malformed-task-refs` — a spec defect that
216
216
  pauses the loop, never read as "declares none".
217
217
 
218
+ The checkbox itself is the **Implementer's done-marker**: it flips `- [ ]` → `- [x]`
219
+ when the task reaches green, with the task's commit. Write every task `- [ ]`; the same
220
+ validator reports a task still `[ ]` in a group the loop has passed
221
+ (`unticked-completed-task`), so the file tracks the progress its shape promises.
222
+
218
223
  Rules: order so the first task is a tracer bullet; never "write all tests" then
219
224
  "write all code"; keep each task small enough to verify on its own. Grouping never
220
225
  changes task granularity — checkboxes stay atomic and TDD runs per task; only review
@@ -68,12 +68,12 @@ decision **stated in full** — at this moment it exists nowhere on disk; the en
68
68
  `**Resolution**` block is only written at step 4 — plus the `discoveries.md` entry
69
69
  **by path** for the surrounding context (it reads the entry itself):
70
70
 
71
- | Artifact changed by the decision | Owner to invoke |
72
- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
73
- | `spec/requirements.md`, `spec/design.md`, `spec/tasks.md` | **Spec Author** |
74
- | Implementation code, `progress.md`, `notes.md` | **Implementer** (often just the resumed sub-agent) |
75
- | A child issue's trace lines, a triage issue's fix plan, the parent partition-plan issue | **Orchestrator** (you) — `gh issue edit --body-file`, read-modify-write (an oversize discovery answered "partition": `.claude/agents/partition.md`) |
76
- | `docs/adr/NNNN-<slug>.md`, `docs/architecture.md`, `docs/playbooks/` | **Architect** (on-demand) — `write-adr` to record the decision, `update-architecture` to keep the map true, `playbook-iterate` for a `T6 PLAYBOOK_CONFLICT` |
71
+ | Artifact changed by the decision | Owner to invoke |
72
+ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
73
+ | `spec/requirements.md`, `spec/design.md`, `spec/tasks.md` | **Spec Author** — except a task's checkbox: `- [ ]` → `- [x]` is the Implementer's done-mark at green (the Orchestrator's on a checkpoint `ok`), never a spec edit |
74
+ | Implementation code, `progress.md`, `notes.md` | **Implementer** (often just the resumed sub-agent) |
75
+ | A child issue's trace lines, a triage issue's fix plan, the parent partition-plan issue | **Orchestrator** (you) — `gh issue edit --body-file`, read-modify-write (an oversize discovery answered "partition": `.claude/agents/partition.md`) |
76
+ | `docs/adr/NNNN-<slug>.md`, `docs/architecture.md`, `docs/playbooks/` | **Architect** (on-demand) — `write-adr` to record the decision, `update-architecture` to keep the map true, `playbook-iterate` for a `T6 PLAYBOOK_CONFLICT` |
77
77
 
78
78
  Not every resolution needs an artifact update first. If the decision is simply "do X"
79
79
  with no change to the contract, skip straight to recording it and resuming. If it
@@ -80,6 +80,9 @@ Rules:
80
80
  - Only enough code to pass current test
81
81
  - Don't anticipate future tests
82
82
  - Keep tests focused on observable behavior
83
+ - When the behaviors belong to a `tasks.md` task, its checkbox is the task's
84
+ done-marker: flip `- [ ]` → `- [x]` when the task's **last** behavior is GREEN
85
+ (the suite passing), and commit (or stage) it with the task's code
83
86
 
84
87
  ### 4. Refactor
85
88
 
@@ -83,10 +83,16 @@ rollback:
83
83
  # - test
84
84
  # - build
85
85
 
86
- # Design tokens (`design-tokens validate`). The anti-hardcode scan inspects a built-in
87
- # set of UI/style extensions (.css/.scss/.ts/.tsx/.vue/.svelte/.astro/.js/.mdx/.html/…).
88
- # Add extra suffixes here for a stack the built-ins don't cover — additive, never a
89
- # replacement. Default none.
86
+ # Design tokens (`design-tokens validate` / `design-tokens contrast`).
87
+ # `scan_extensions`: the anti-hardcode scan inspects a built-in set of UI/style
88
+ # extensions (.css/.scss/.ts/.tsx/.vue/.svelte/.astro/.js/.mdx/.html/…). Add extra
89
+ # suffixes here for a stack the built-ins don't cover — additive, never a replacement.
90
+ # Default none.
91
+ # `verify`: a command line `design-tokens contrast` runs after its own WCAG checks
92
+ # (through `sh -c`, in the repo root) — your design system's own verifier for the rules
93
+ # only it can know (palette under colour-vision-deficiency simulation, property grammar,
94
+ # CSS scans). Its output is forwarded; a non-zero exit fails the gate. Default none.
90
95
  # design_tokens:
91
96
  # scan_extensions:
92
97
  # - .foo
98
+ # verify: pnpm exec my-design-system verify
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { execFile } from "node:child_process";
2
+ import { execFile, spawn } from "node:child_process";
3
3
  import { access, appendFile, chmod, lstat, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
5
5
  import { argv, cwd, env, exit, stderr, stdin, stdout } from "node:process";
@@ -27,7 +27,8 @@ const DEPRECATED_IMPLEMENTATION_KEYS = ["pre_commit_review"];
27
27
  const HARNESS_CONFIG_SCHEMA_FILENAME = "harness.config.schema.json";
28
28
  const CONFIG_KEY_SINCE = {
29
29
  gates: "0.3.0",
30
- "implementation.auto_commit": "0.4.0"
30
+ "implementation.auto_commit": "0.4.0",
31
+ "design_tokens.verify": "0.5.0"
31
32
  };
32
33
  const TASK_STORAGE_REPO_PLACEHOLDER = "OWNER/REPO";
33
34
  const TARGETS = ["claude-code"];
@@ -120,7 +121,10 @@ const taskStorageSchema = z.object({
120
121
  }).strict();
121
122
  const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z.literal("unlimited")]).default(3) }).strict().prefault({});
122
123
  const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
123
- const designTokensSchema = z.object({ scan_extensions: z.array(z.string()).default([]) }).strict().prefault({});
124
+ const designTokensSchema = z.object({
125
+ scan_extensions: z.array(z.string()).default([]),
126
+ verify: z.string().trim().min(1).optional()
127
+ }).strict().prefault({});
124
128
  const mergeSchema = z.object({
125
129
  checks_timeout_secs: z.int().positive().default(600),
126
130
  allow_no_checks: z.boolean().default(false)
@@ -257,8 +261,6 @@ const pointerScalar = z.union([
257
261
  z.null()
258
262
  ]).optional();
259
263
  const pointerFrontmatterSchema = z.object({
260
- active_task: pointerScalar,
261
- branch: pointerScalar,
262
264
  session_start_ts: pointerScalar,
263
265
  last_close_ts: pointerScalar
264
266
  });
@@ -1450,6 +1452,7 @@ const flatten = (color, backdrop) => ({
1450
1452
  b: color.b * color.a + backdrop.b * (1 - color.a),
1451
1453
  a: 1
1452
1454
  });
1455
+ const compositeOver = (color, ground) => flatten(color, ground.a < 1 ? flatten(ground, WHITE) : ground);
1453
1456
  const linearizeChannel = (channel) => {
1454
1457
  const s = channel / 255;
1455
1458
  return s <= .03928 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
@@ -1505,15 +1508,17 @@ const runContrast = async (inputs) => {
1505
1508
  };
1506
1509
  const tokens = collectTokens(parsed);
1507
1510
  const problems = [];
1508
- const specs = discoverPairs(tokens, problems);
1509
1511
  const modes = declaredModes(tokens);
1512
+ const specs = discoverPairs(tokens, modes, problems);
1510
1513
  const pairs = measurePairs(specs, modes, tokens);
1514
+ const verify = inputs.verify ? await runVerifier(inputs.verify) : void 0;
1511
1515
  return {
1512
- ok: problems.length === 0 && pairs.every((pair) => pair.passes),
1516
+ ok: problems.length === 0 && pairs.every((pair) => pair.passes) && (verify === void 0 || verify.code === 0),
1513
1517
  tokensFound: true,
1514
1518
  pairsChecked: pairs.length,
1515
1519
  pairs,
1516
- problems
1520
+ problems,
1521
+ ...verify === void 0 ? {} : { verify }
1517
1522
  };
1518
1523
  };
1519
1524
  const emptyResult$1 = (tokensFound) => ({
@@ -1552,19 +1557,17 @@ const declaredModes = (tokens) => {
1552
1557
  for (const token of tokens.values()) {
1553
1558
  const modes = readExtension(token.node, MODES_EXTENSION);
1554
1559
  if (isRecord(modes)) {
1555
- for (const name of Object.keys(modes)) if (name.trim().length > 0) names.add(name);
1560
+ for (const name of Object.keys(modes)) if (name.trim().length > 0 && name !== "base") names.add(name);
1556
1561
  }
1557
1562
  }
1558
1563
  return [...names].toSorted();
1559
1564
  };
1560
- const discoverPairs = (tokens, problems) => {
1561
- const merged = /* @__PURE__ */ new Map();
1562
- const add = (spec) => {
1563
- merged.set(`${spec.foreground}|${spec.background}`, spec);
1564
- };
1565
- for (const spec of conventionPairs(tokens)) add(spec);
1566
- for (const spec of extensionPairs(tokens, problems)) add(spec);
1567
- return [...merged.values()];
1565
+ const pairKey = (spec) => `${spec.foreground}|${spec.background}`;
1566
+ const discoverPairs = (tokens, modes, problems) => {
1567
+ const convention = new Map(conventionPairs(tokens).map((spec) => [pairKey(spec), spec]));
1568
+ const explicit = extensionPairs(tokens, modes, problems);
1569
+ for (const spec of explicit) convention.delete(pairKey(spec));
1570
+ return [...convention.values(), ...explicit];
1568
1571
  };
1569
1572
  const conventionPairs = (tokens) => {
1570
1573
  const pairs = [];
@@ -1581,77 +1584,193 @@ const conventionPairs = (tokens) => {
1581
1584
  foreground: path,
1582
1585
  background: basePath,
1583
1586
  level: DEFAULT_LEVEL,
1587
+ floors: {},
1584
1588
  source: "convention"
1585
1589
  });
1586
1590
  }
1587
1591
  return pairs;
1588
1592
  };
1589
- const extensionPairs = (tokens, problems) => {
1593
+ const DECLARATION_SHAPE = "{ against: \"{token.path}\" | [\"{token.path}\", …], level?, floors?, modes?, over? }";
1594
+ const DECLARATION_KEYS = /* @__PURE__ */ new Set([
1595
+ "against",
1596
+ "level",
1597
+ "floors",
1598
+ "modes",
1599
+ "over"
1600
+ ]);
1601
+ const extensionPairs = (tokens, modes, problems) => {
1590
1602
  const pairs = [];
1591
1603
  for (const [path, token] of tokens) {
1592
1604
  const declaration = readExtension(token.node, CONTRAST_EXTENSION);
1593
1605
  if (declaration === void 0) continue;
1594
1606
  const entries = Array.isArray(declaration) ? declaration : [declaration];
1607
+ const declared = /* @__PURE__ */ new Set();
1595
1608
  for (const entry of entries) {
1596
- if (!isRecord(entry) || typeof entry["against"] !== "string") {
1597
- problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — expected { against: "{token.path}", level? }.`);
1598
- continue;
1599
- }
1600
- const against = aliasTarget(entry["against"]) ?? entry["against"];
1601
- if (against === path) {
1602
- problems.push(`token "${path}" declares contrast against itself — a pair needs two different tokens.`);
1603
- continue;
1604
- }
1605
- if (!tokens.has(against)) {
1606
- problems.push(`token "${path}" contrast-against "${entry["against"]}" does not resolve to a known token.`);
1609
+ if (!isRecord(entry)) {
1610
+ problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — expected ${DECLARATION_SHAPE}.`);
1607
1611
  continue;
1608
1612
  }
1609
- if (!resolveColor(against, "base", tokens)) {
1610
- problems.push(`token "${path}" contrast-against "${entry["against"]}" is not a colour token.`);
1613
+ const unknownKeys = Object.keys(entry).filter((key) => !DECLARATION_KEYS.has(key));
1614
+ if (unknownKeys.length > 0) {
1615
+ problems.push(`token "${path}" has unknown ${CONTRAST_EXTENSION} key(s) ${unknownKeys.map((key) => `"${key}"`).join(", ")} — expected ${DECLARATION_SHAPE}.`);
1611
1616
  continue;
1612
1617
  }
1618
+ const before = problems.length;
1619
+ const backgrounds = tokenPathList(entry["against"], path, "against", problems);
1620
+ if (backgrounds.length === 0) continue;
1613
1621
  if (!resolveColor(path, "base", tokens)) {
1614
1622
  problems.push(`token "${path}" declares a contrast pair but is not itself a colour token.`);
1615
1623
  continue;
1616
1624
  }
1617
- pairs.push({
1618
- foreground: path,
1619
- background: against,
1620
- level: normalizeLevel(entry["level"], path, problems),
1621
- source: "extension"
1622
- });
1625
+ const level = normalizeLevel(entry["level"], path, problems);
1626
+ const floors = normalizeFloors(entry["floors"], path, modes, problems);
1627
+ const ruleModes = normalizeModes(entry["modes"], path, modes, problems);
1628
+ const measuredModes = (ruleModes ?? modes).filter((mode) => mode !== BASE_MODE);
1629
+ const ownDefects = unresolvedModes(path, measuredModes, tokens);
1630
+ for (const mode of ownDefects) {
1631
+ const problem = `token "${path}" declares a contrast pair but its "${mode}" mode override does not resolve to a colour.`;
1632
+ if (!problems.includes(problem)) problems.push(problem);
1633
+ }
1634
+ if (ruleModes !== void 0) {
1635
+ for (const mode of Object.keys(floors)) if (mode !== "base" && !ruleModes.includes(mode)) problems.push(`token "${path}" sets a contrast floor for mode "${mode}" but restricts the rule to ${ruleModes.map((name) => `"${name}"`).join("/")} — the floor would never apply.`);
1636
+ }
1637
+ const grounds = entry["over"] === void 0 ? [] : tokenPathList(entry["over"], path, "over", problems);
1638
+ const colourGrounds = [];
1639
+ for (const ground of grounds) if (ground === path || backgrounds.includes(ground)) problems.push(`token "${path}" composites over "${ground}", which is an end of the pair itself — \`over\` names the opaque ground beneath the background.`);
1640
+ else if (isColourTarget(ground, path, "over", measuredModes, tokens, problems)) colourGrounds.push(ground);
1641
+ const colourBackgrounds = [];
1642
+ for (const against of backgrounds) if (against === path) problems.push(`token "${path}" declares contrast against itself — a pair needs two different tokens.`);
1643
+ else if (isColourTarget(against, path, "against", measuredModes, tokens, problems)) colourBackgrounds.push(against);
1644
+ if (ownDefects.length > 0 || problems.length > before) continue;
1645
+ for (const background of colourBackgrounds) for (const over of colourGrounds.length === 0 ? [void 0] : colourGrounds) {
1646
+ const key = `${background}|${over ?? ""}`;
1647
+ if (declared.has(key)) problems.push(`token "${path}" declares contrast against "${background}"${over === void 0 ? "" : ` over "${over}"`} more than once — one declaration per pair.`);
1648
+ declared.add(key);
1649
+ }
1650
+ if (ownDefects.length > 0 || problems.length > before) continue;
1651
+ for (const background of colourBackgrounds) {
1652
+ const base = {
1653
+ foreground: path,
1654
+ background,
1655
+ level,
1656
+ floors,
1657
+ source: "extension"
1658
+ };
1659
+ const withModes = ruleModes === void 0 ? base : {
1660
+ ...base,
1661
+ modes: ruleModes
1662
+ };
1663
+ if (colourGrounds.length === 0) pairs.push(withModes);
1664
+ else for (const over of colourGrounds) pairs.push({
1665
+ ...withModes,
1666
+ over
1667
+ });
1668
+ }
1623
1669
  }
1624
1670
  }
1625
1671
  return pairs;
1626
1672
  };
1673
+ const tokenPathList = (raw, path, field, problems) => {
1674
+ const list = Array.isArray(raw) ? raw : [raw];
1675
+ if (list.length === 0 || !list.every((item) => typeof item === "string")) {
1676
+ problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — \`${field}\` must be a token path or a non-empty array of them (expected ${DECLARATION_SHAPE}).`);
1677
+ return [];
1678
+ }
1679
+ return list.map((item) => aliasTarget(item) ?? item);
1680
+ };
1681
+ const isColourTarget = (target, path, field, modes, tokens, problems) => {
1682
+ if (!tokens.has(target)) {
1683
+ problems.push(`token "${path}" contrast-${field} "${target}" does not resolve to a known token.`);
1684
+ return false;
1685
+ }
1686
+ if (!resolveColor(target, "base", tokens)) {
1687
+ problems.push(`token "${path}" contrast-${field} "${target}" is not a colour token.`);
1688
+ return false;
1689
+ }
1690
+ const unresolved = unresolvedModes(target, modes, tokens);
1691
+ for (const mode of unresolved) problems.push(`token "${path}" contrast-${field} "${target}" has a "${mode}" mode override that does not resolve to a colour.`);
1692
+ return unresolved.length === 0;
1693
+ };
1694
+ const unresolvedModes = (target, modes, tokens) => modes.filter((mode) => resolveColor(target, mode, tokens) === void 0);
1627
1695
  const normalizeLevel = (raw, path, problems) => {
1628
1696
  if (raw === void 0) return DEFAULT_LEVEL;
1629
1697
  if (typeof raw === "string" && CONTRAST_LEVELS.includes(raw)) return raw;
1630
1698
  problems.push(`token "${path}" has an unknown contrast level "${String(raw)}" — use ${CONTRAST_LEVELS.join("/")}.`);
1631
1699
  return DEFAULT_LEVEL;
1632
1700
  };
1701
+ const normalizeFloors = (raw, path, modes, problems) => {
1702
+ if (raw === void 0) return {};
1703
+ if (!isRecord(raw)) {
1704
+ problems.push(`token "${path}" has malformed contrast floors — expected { base?: number, <mode>?: number }.`);
1705
+ return {};
1706
+ }
1707
+ const floors = Object.create(null);
1708
+ for (const [mode, value] of Object.entries(raw)) {
1709
+ if (mode !== "base" && !modes.includes(mode)) {
1710
+ problems.push(`token "${path}" sets a contrast floor for mode "${mode}", which no ${MODES_EXTENSION} block declares — use ${[BASE_MODE, ...modes].join("/")}.`);
1711
+ continue;
1712
+ }
1713
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value > 21) {
1714
+ problems.push(`token "${path}" has an invalid contrast floor for mode "${mode}" (${String(value)}) — a WCAG ratio is a number between 1 and 21.`);
1715
+ continue;
1716
+ }
1717
+ floors[mode] = value;
1718
+ }
1719
+ return floors;
1720
+ };
1721
+ const normalizeModes = (raw, path, modes, problems) => {
1722
+ if (raw === void 0) return void 0;
1723
+ if (!Array.isArray(raw) || raw.length === 0 || !raw.every((item) => typeof item === "string")) {
1724
+ problems.push(`token "${path}" has malformed contrast modes — expected a non-empty array of mode names.`);
1725
+ return;
1726
+ }
1727
+ const unknown = raw.filter((mode) => mode !== "base" && !modes.includes(mode));
1728
+ if (unknown.length > 0) {
1729
+ problems.push(`token "${path}" restricts contrast to mode(s) ${unknown.map((mode) => `"${mode}"`).join(", ")}, which no ${MODES_EXTENSION} block declares — use ${[BASE_MODE, ...modes].join("/")}.`);
1730
+ return;
1731
+ }
1732
+ return [...new Set(raw)];
1733
+ };
1734
+ const floorFor = (spec, mode) => ownFloor(spec.floors, mode) ?? ownFloor(spec.floors, "base") ?? WCAG_FLOORS[spec.level];
1735
+ const ownFloor = (floors, mode) => Object.hasOwn(floors, mode) ? floors[mode] : void 0;
1633
1736
  const measurePairs = (specs, modes, tokens) => {
1634
1737
  const rows = [];
1635
1738
  for (const spec of specs) {
1636
- const baseFg = resolveColor(spec.foreground, BASE_MODE, tokens);
1637
- const baseBg = resolveColor(spec.background, BASE_MODE, tokens);
1638
- if (!baseFg || !baseBg) continue;
1639
- rows.push(makeRow(spec, BASE_MODE, baseFg, baseBg));
1640
- for (const mode of modes) {
1641
- const fg = resolveColor(spec.foreground, mode, tokens) ?? baseFg;
1642
- const bg = resolveColor(spec.background, mode, tokens) ?? baseBg;
1643
- if (sameColor(fg, baseFg) && sameColor(bg, baseBg)) continue;
1644
- rows.push(makeRow(spec, mode, fg, bg));
1739
+ const base = resolvePair(spec, BASE_MODE, tokens);
1740
+ if (!base) continue;
1741
+ const explicit = spec.modes !== void 0;
1742
+ const rowModes = spec.modes ?? ["base", ...modes];
1743
+ for (const mode of rowModes) {
1744
+ const colours = mode === "base" ? base : resolvePair(spec, mode, tokens) ?? base;
1745
+ if (!explicit && mode !== "base" && sameColor(colours.fg, base.fg) && sameColor(colours.bg, base.bg) && floorFor(spec, mode) === floorFor(spec, "base")) continue;
1746
+ rows.push(makeRow(spec, mode, colours.fg, colours.bg));
1645
1747
  }
1646
1748
  }
1647
1749
  return rows;
1648
1750
  };
1751
+ const resolvePair = (spec, mode, tokens) => {
1752
+ const resolveEnd = (path) => resolveColor(path, mode, tokens) ?? (mode === "base" ? void 0 : resolveColor(path, "base", tokens));
1753
+ const fg = resolveEnd(spec.foreground);
1754
+ const against = resolveEnd(spec.background);
1755
+ if (!fg || !against) return void 0;
1756
+ if (spec.over === void 0) return {
1757
+ fg,
1758
+ bg: against
1759
+ };
1760
+ const ground = resolveEnd(spec.over);
1761
+ if (!ground) return void 0;
1762
+ return {
1763
+ fg,
1764
+ bg: compositeOver(against, ground)
1765
+ };
1766
+ };
1649
1767
  const makeRow = (spec, mode, foreground, background) => {
1650
1768
  const ratio = contrastRatio(foreground, background);
1651
- const floor = WCAG_FLOORS[spec.level];
1769
+ const floor = floorFor(spec, mode);
1652
1770
  return {
1653
1771
  foreground: spec.foreground,
1654
1772
  background: spec.background,
1773
+ ...spec.over === void 0 ? {} : { over: spec.over },
1655
1774
  mode,
1656
1775
  level: spec.level,
1657
1776
  ratio,
@@ -1675,6 +1794,13 @@ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
1675
1794
  if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
1676
1795
  return typeof value === "string" ? parseColor(value) : void 0;
1677
1796
  };
1797
+ const runVerifier = async (hook) => {
1798
+ const result = await hook.run("sh", ["-c", hook.command]);
1799
+ return {
1800
+ command: hook.command,
1801
+ ...result
1802
+ };
1803
+ };
1678
1804
  //#endregion
1679
1805
  //#region src/design-tokens/design-sync.constant.ts
1680
1806
  const DESIGN_TOOL_EXTENSION = "com.lemony.design-tool";
@@ -2095,13 +2221,14 @@ const SPEC_SIDE_KINDS = [
2095
2221
  "malformed-risk-marker",
2096
2222
  "malformed-task-refs",
2097
2223
  "unknown-risk-class",
2098
- "dangling-requirement-ref"
2224
+ "dangling-requirement-ref",
2225
+ "unticked-completed-task"
2099
2226
  ];
2100
2227
  const GROUP_HEADER = /^##\s+Group\s+(\d+)\b/;
2101
2228
  const RISK_MARKER = /\[risk:\s*([^\]]*)\]\s*$/;
2102
2229
  const RISK_MARKER_ALL = /\[risk:/gi;
2103
2230
  const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
2104
- const TASK_LINE = /^\s*[-*+]\s*\[[ xX]\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
2231
+ const TASK_LINE = /^\s*[-*+]\s*\[([ xX])\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
2105
2232
  const TASK_REFS = /^\(((?:R\d+)(?:\s*,\s*R\d+)*)\)/;
2106
2233
  const TASK_REFS_LOOKALIKE_ALL = /\(\s*R\d+/g;
2107
2234
  const TASK_TITLE_WRAPPER = /^\s*[-*+]\s*\[[ xX]\]\s*(\*\*|__)/;
@@ -2156,7 +2283,7 @@ const parseTasksSpec = (text) => {
2156
2283
  const blockEnd = taskBlockEnd(lines, index);
2157
2284
  const parts = lines.slice(index, blockEnd).map((part) => part.trim());
2158
2285
  index = blockEnd - 1;
2159
- const task = parseTaskBlock(match[1] ?? "", parts);
2286
+ const task = parseTaskBlock(match[2] ?? "", (match[1] ?? " ") !== " ", parts);
2160
2287
  if (task.malformedRefs) malformedTaskRefs.push(task.id);
2161
2288
  if (current) {
2162
2289
  current.tasks.push(task.task);
@@ -2197,7 +2324,7 @@ const taskBlockEnd = (lines, start) => {
2197
2324
  }
2198
2325
  return end;
2199
2326
  };
2200
- const parseTaskBlock = (id, parts) => {
2327
+ const parseTaskBlock = (id, ticked, parts) => {
2201
2328
  const block = parts.join(" ");
2202
2329
  const opener = refsOpener(parts, block);
2203
2330
  const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
@@ -2206,6 +2333,7 @@ const parseTaskBlock = (id, parts) => {
2206
2333
  id,
2207
2334
  task: {
2208
2335
  id,
2336
+ ticked,
2209
2337
  requirementRefs: [...new Set(requirementRefs)]
2210
2338
  },
2211
2339
  malformedRefs: refs === null && hasRefLookalike(parts)
@@ -2424,6 +2552,7 @@ const runLedgerValidate = async (inputs) => {
2424
2552
  });
2425
2553
  }
2426
2554
  }
2555
+ checkTicks(spec.groups, address, problems);
2427
2556
  const slice = sliceForGroups(groups);
2428
2557
  result.basis = slice.basis;
2429
2558
  const dangling = await collectDanglingRefs(requirementsPath, slice.ids, problems);
@@ -2459,6 +2588,17 @@ const resolveGroups = (groups, address, problems) => {
2459
2588
  message: `tasks.md declares ${groups.length} group(s); step ${address.step} has none. One step is one group.`
2460
2589
  });
2461
2590
  };
2591
+ const checkTicks = (groups, address, problems) => {
2592
+ const passed = address.kind === "full-pass" ? groups : groups.filter((group) => group.index <= address.step);
2593
+ for (const group of passed) for (const task of group.tasks) {
2594
+ if (task.ticked) continue;
2595
+ problems.push({
2596
+ kind: "unticked-completed-task",
2597
+ message: `${task.id} (Group ${group.index}) is still "[ ]" in tasks.md, in a group the loop has passed. The Implementer ticks a task when it reaches green; an unticked task here is a forgotten mark or work that was never done — the human decides which.`,
2598
+ subject: task.id
2599
+ });
2600
+ }
2601
+ };
2462
2602
  const collectDanglingRefs = async (requirementsPath, sliceIds, problems) => {
2463
2603
  const referenced = sliceIds.filter((id) => id.startsWith("R"));
2464
2604
  if (referenced.length === 0) return [];
@@ -3979,22 +4119,26 @@ const isReadable = async (path) => {
3979
4119
  }
3980
4120
  };
3981
4121
  //#endregion
4122
+ //#region src/status/parse-task-branch.ts
4123
+ const TASK_BRANCH = /^harness\/(\d+)-.+$/;
4124
+ const parseTaskBranch = (branch) => branch === null ? null : TASK_BRANCH.exec(branch)?.[1] ?? null;
4125
+ //#endregion
3982
4126
  //#region src/status/status.ts
3983
4127
  const PAUSED_LABEL = "harness:status:paused-for-clarification";
3984
4128
  const runStatus = async (deps) => {
3985
4129
  const { repoRoot } = deps;
3986
4130
  const config = await readHarnessConfig(repoRoot);
3987
- const pointer = await readPointer(repoRoot, deps.readGitUserEmail);
4131
+ const lastSession = await readPointer(repoRoot, deps.readGitUserEmail);
3988
4132
  const { branch, behind } = await deps.gitBehind(repoRoot);
3989
4133
  const provider = createTaskTrackerProvider(config.task_storage.type, deps.runCommand);
3990
4134
  const openDiscoveries = await countOpenDiscoveries(provider, config.task_storage.repo);
3991
4135
  return {
3992
4136
  vendorVersion: config.vendor_version,
3993
4137
  taskStorageRepo: config.task_storage.repo,
3994
- activeTask: pointer.activeTask,
3995
- branch: branch ?? pointer.branch,
4138
+ activeTask: parseTaskBranch(branch),
4139
+ branch,
3996
4140
  behind,
3997
- lastSession: pointer.lastSession,
4141
+ lastSession,
3998
4142
  openDiscoveries,
3999
4143
  designToolDrift: await readDriftState(repoRoot)
4000
4144
  };
@@ -4011,11 +4155,7 @@ const readDriftState = async (repoRoot) => {
4011
4155
  }
4012
4156
  };
4013
4157
  const readPointer = async (repoRoot, readGitUserEmail) => {
4014
- const empty = {
4015
- activeTask: null,
4016
- branch: null,
4017
- lastSession: null
4018
- };
4158
+ const empty = null;
4019
4159
  const email = await readGitUserEmail(repoRoot);
4020
4160
  if (!email) return empty;
4021
4161
  const slug = email.split("@")[0] ?? "";
@@ -4031,12 +4171,7 @@ const readPointer = async (repoRoot, readGitUserEmail) => {
4031
4171
  if (!match) return empty;
4032
4172
  const parsed = pointerFrontmatterSchema.safeParse(parse(match[1] ?? ""));
4033
4173
  if (!parsed.success) return empty;
4034
- const front = parsed.data;
4035
- return {
4036
- activeTask: normalize(front.active_task),
4037
- branch: normalize(front.branch),
4038
- lastSession: normalize(front.last_close_ts)
4039
- };
4174
+ return normalize(parsed.data.last_close_ts);
4040
4175
  };
4041
4176
  const normalize = (value) => {
4042
4177
  if (value === null || value === void 0) return null;
@@ -4108,7 +4243,7 @@ const COMMANDS = [
4108
4243
  },
4109
4244
  {
4110
4245
  name: "review-ledger",
4111
- summary: "Validate the Reviewer's evidence ledger (the JSON sidecar under the task's state) against the spec's groups, the anchored diff and the declared gates: shape, criteria-vs-slice, unknown risk classes, the mutant floor, the gates floor (deterministic, agent-free; exits non-zero on an invalid ledger).",
4246
+ summary: "Validate the Reviewer's evidence ledger (the JSON sidecar under the task's state) against the spec's groups, the anchored diff and the declared gates: shape, criteria-vs-slice, unknown risk classes, the mutant floor, the gates floor, the tick check on passed groups (deterministic, agent-free; exits non-zero on an invalid ledger).",
4112
4247
  usage: "lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass)"
4113
4248
  },
4114
4249
  {
@@ -5389,6 +5524,31 @@ const makeRunCommand = () => async (cmd, args) => {
5389
5524
  };
5390
5525
  }
5391
5526
  };
5527
+ const makeVerifierRunCommand = () => (cmd, args) => new Promise((resolveResult) => {
5528
+ const child = spawn(cmd, args, { stdio: [
5529
+ "ignore",
5530
+ "pipe",
5531
+ "pipe"
5532
+ ] });
5533
+ const out = [];
5534
+ const err = [];
5535
+ child.stdout.on("data", (chunk) => out.push(chunk));
5536
+ child.stderr.on("data", (chunk) => err.push(chunk));
5537
+ child.on("error", (error) => {
5538
+ resolveResult({
5539
+ code: error.code === "ENOENT" ? 127 : 1,
5540
+ stdout: Buffer.concat(out).toString("utf8"),
5541
+ stderr: `${Buffer.concat(err).toString("utf8")}${error.message}\n`
5542
+ });
5543
+ });
5544
+ child.on("close", (code, signal) => {
5545
+ resolveResult({
5546
+ code: code ?? 1,
5547
+ stdout: Buffer.concat(out).toString("utf8"),
5548
+ stderr: `${Buffer.concat(err).toString("utf8")}${signal ? `killed by ${signal}\n` : ""}`
5549
+ });
5550
+ });
5551
+ });
5392
5552
  const buildResolveDeps = () => {
5393
5553
  const rl = createInterface({
5394
5554
  input: stdin,
@@ -5413,10 +5573,10 @@ const gitBehind = async (repoRoot) => {
5413
5573
  repoRoot,
5414
5574
  "symbolic-ref",
5415
5575
  "--quiet",
5416
- "--short",
5417
5576
  "HEAD"
5418
5577
  ]);
5419
- const branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : null;
5578
+ const ref = branchResult.code === 0 ? branchResult.stdout.trim() : "";
5579
+ const branch = ref.startsWith("refs/heads/") ? ref.slice(11) || null : null;
5420
5580
  const headRef = await run("git", [
5421
5581
  "-C",
5422
5582
  repoRoot,
@@ -5800,20 +5960,41 @@ const designTokensValidate = async (args) => {
5800
5960
  exit(1);
5801
5961
  };
5802
5962
  const designTokensContrast = async () => {
5803
- const result = await runContrast({ repoRoot: cwd() });
5963
+ const verify = (await pathExists(join(cwd(), "harness.config.yml")) ? await readHarnessConfig(cwd()) : void 0)?.design_tokens.verify;
5964
+ const result = await runContrast({
5965
+ repoRoot: cwd(),
5966
+ ...verify === void 0 ? {} : { verify: {
5967
+ command: verify,
5968
+ run: makeVerifierRunCommand()
5969
+ } }
5970
+ });
5804
5971
  if (!result.tokensFound) {
5805
5972
  console.log(`No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the harness never creates it).`);
5806
5973
  return;
5807
5974
  }
5808
5975
  const failures = result.pairs.filter((pair) => !pair.passes);
5976
+ const forwardVerifier = () => {
5977
+ if (result.verify === void 0) return;
5978
+ const { stdout: out, stderr: err } = result.verify;
5979
+ if (out.length > 0) stdout.write(out.endsWith("\n") ? out : `${out}\n`);
5980
+ if (err.length > 0) stderr.write(err.endsWith("\n") ? err : `${err}\n`);
5981
+ };
5809
5982
  if (result.ok) {
5810
5983
  console.log(result.pairsChecked === 0 ? `design-tokens contrast: no foreground/background pairs found (use the on-* convention or $extensions["com.lemony.contrast"]).` : `design-tokens contrast OK: ${result.pairsChecked} pair(s) meet their WCAG floor.`);
5984
+ forwardVerifier();
5985
+ if (result.verify !== void 0) console.log(`design-tokens verify OK: \`${result.verify.command}\` exited 0.`);
5811
5986
  return;
5812
5987
  }
5813
- console.error(`design-tokens contrast found ${failures.length + result.problems.length} issue(s):`);
5814
- for (const pair of failures) console.error(` ${pair.foreground} on ${pair.background} (${pair.mode}, ${pair.level}): ${pair.ratio}:1 < ${pair.floor}:1`);
5988
+ const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
5989
+ console.error(`design-tokens contrast found ${failures.length + result.problems.length + (failedVerifier ? 1 : 0)} issue(s):`);
5990
+ for (const pair of failures) {
5991
+ const over = pair.over === void 0 ? "" : ` over ${pair.over}`;
5992
+ console.error(` ${pair.foreground} on ${pair.background}${over} (${pair.mode}, ${pair.level}): ${pair.ratio}:1 < ${pair.floor}:1`);
5993
+ }
5815
5994
  for (const problem of result.problems) console.error(` ${problem}`);
5816
- exit(1);
5995
+ forwardVerifier();
5996
+ if (failedVerifier) console.error(` design_tokens.verify \`${failedVerifier.command}\` exited ${failedVerifier.code}.`);
5997
+ process.exitCode = 1;
5817
5998
  };
5818
5999
  const designTokensImport = async (args) => {
5819
6000
  const from = parseFlag(args, "from");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemoncode/lemony",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Lemony — a Harness for AI Coding. Vendor package: installer, agent role catalog, generic skill catalog, hooks, and templates for a Spec-Driven Development workflow.",
5
5
  "type": "module",
6
6
  "private": false,