@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 +1 -1
- package/catalog/agents/implementer.md +14 -3
- package/catalog/agents/orchestrator.md +56 -8
- package/catalog/agents/reviewer.md +8 -2
- package/catalog/commands/pause.md +8 -5
- package/catalog/harness.config.schema.json +4 -0
- package/catalog/hooks/init.sh +36 -8
- package/catalog/hooks/session-close.sh +15 -7
- package/catalog/schemas/tier2-events-history.md +14 -0
- package/catalog/schemas/tier2-events.md +11 -9
- package/catalog/skills/a11y-audit/SKILL.md +3 -1
- package/catalog/skills/prd-to-spec/SKILL.md +5 -0
- package/catalog/skills/resolve-discovery/SKILL.md +6 -6
- package/catalog/skills/tdd/SKILL.md +3 -0
- package/catalog/templates/claude-code/harness.config.yml.tpl +10 -4
- package/dist/cli.mjs +254 -73
- package/package.json +1 -1
package/catalog/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
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** —
|
|
128
|
-
|
|
129
|
-
is
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
575
|
-
|
|
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
|
|
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**.
|
|
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
|
|
13
|
-
|
|
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`
|
|
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
|
package/catalog/hooks/init.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
147
|
-
#
|
|
148
|
-
if [ -n "$ACTIVE_TASK" ]
|
|
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
|
|
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`
|
|
87
|
-
# set of UI/style
|
|
88
|
-
# Add extra
|
|
89
|
-
#
|
|
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({
|
|
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
|
|
1561
|
-
|
|
1562
|
-
const
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
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
|
|
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)
|
|
1597
|
-
problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — expected {
|
|
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
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
for (const mode of
|
|
1641
|
-
const
|
|
1642
|
-
|
|
1643
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
3995
|
-
branch
|
|
4138
|
+
activeTask: parseTaskBranch(branch),
|
|
4139
|
+
branch,
|
|
3996
4140
|
behind,
|
|
3997
|
-
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
5814
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|