Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions .claude/skills/decide/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
---
name: decide
description: Put an open question in front of a person in a form they can actually answer — what is being decided and why now, what the world outside this repo does about it, what we have already decided that constrains it, the options drawn on real data, and what we are choosing not to consider. Registered in docs/decisions.json. Use when a decision needs making and the choice is not obvious, when writing or reviewing an options page, or when recording a decision that has just been made.
---

# Putting a decision in front of a person

Two failures, and the second is the common one.

The first is a page nobody outside this code can read — written in file names and our own
vocabulary, so it has to be re-derived before it can be used. That is the `CLAUDE.md` tenet,
and the second half of this document is that rule as a checklist.

The second is a page that is *legible and still not answerable*: here are three options, pick
one. A person cannot pick one, because the options are the last thing they need and the first
thing they were given. They do not know why this is being asked now, what happens if nothing
is chosen, whether anyone else has solved it already, or which of our earlier decisions have
quietly ruled half the space out. So they either rubber-stamp the recommendation or defer,
and both of those are the decision being made by whoever wrote the page.

**A decision record answers the questions below, in roughly this order.** Not every one needs
a section, and a short decision should stay short — but each one that goes unanswered should
go unanswered *on purpose*, and you should be able to say why.

## The questions a record has to answer

**1 · What is being decided?** One question, in plain words, that a person could answer with
one of the options. Not a topic — a question. This is the sentence that goes in the register.

**2 · Why is this being asked now?** What forced it. A measurement that came back, a feature
that cannot be built until this is settled, a defect that turned out to be a design gap.
"It seemed worth thinking about" is a real answer and worth writing, because it tells the
reader they are allowed to say *not yet*.

**3 · What happens if nobody decides?** Say the cost of the status quo out loud, and be
honest when it is small. Some questions can sit open for a year at no cost, and a reader who
learns that from the page will spend their attention on a different one. Say what *else* is
blocked behind it — decisions with nothing waiting on them are cheaper to leave open than
they look.

**4 · What does the app do today, and what is it costing?** Measured, not remembered. The
status quo is always one of the options, and it deserves the same evidence as the others.
Numbers here are the strongest thing on the page: the mark-granularity decision turned on
one — that 83% of what is annotated renders as nothing — and no paragraph would have produced
it.

**5 · What do people outside this project do about this?** Prior art, and name it. Other
mus'haf apps, the printed tradition, published guidance, how comparable software solves the
comparable problem. Link what you find. Two rules:

- **If you did not look, say you did not look.** A record silent about prior art reads as one
that found none, which is a much stronger claim than the truth.
- **Say why the answer does not transfer**, when it does not. The reference points here are
often print, and print has no state, no accessibility tree and no 150 KB budget. A
convention that exists because of a printing press is evidence about readers, not about
implementations.

**6 · What have we already decided that touches this?** Name the earlier decisions by their
row in the register, and say what each one *constrains* — not just that it is related. This
is the question that most often turns a three-option page into a two-option one, and it is
the one nobody remembers to ask, because the constraint feels obvious to whoever is holding
it and is invisible to everyone else. If an earlier decision would have to be reopened for an
option to work, that option is not on this page; it is on a bigger one.

**7 · What are the options?** Each one **drawn**, on real data, at the size it would really
be used — see the checklist below. Each with what it takes, what it gets, what it costs.
Doing nothing is always on the list and is often right.

**8 · What else could we consider, and why is it not here?** The options you thought about
and left off, with the reason. This is where a reader adds the one you missed. A page whose
option list arrives with no visible edge invites the reader to trust that the edge was
somewhere sensible, and it usually was not.

**9 · What would change the answer?** The measurement, the device, the person, or the
upstream change that would make a different option win. This is what makes a decision
reopenable on evidence rather than on mood, and it is what you check against when somebody
asks a year later why it was done this way.

**10 · What is this not settling?** State the gap on the page. An options page that reads as
complete when it is not is worse than one that admits its edge, because the gap gets
discovered after the choice instead of before it.

## The plain-language rule, as a checklist

Run this over anything you are about to publish. Every line is a way a page stops being
answerable by the person it is for.

**Headings**

- [ ] Every section heading is a **question a reader might arrive holding**. "What is it
leaving out?" — not "Discarded annotations". A noun-phrase heading is describing the
machinery instead of answering the reader.
- [ ] The last heading is the decision itself: *so what is being decided?*

**Words**

- [ ] No file names, paths, function names, gate names, or commands anywhere a reader is
meant to follow. They belong in the body of the record, where the reasons are.
- [ ] Domain words stay — *verse*, *tajweed*, *madd*, *ghunnah*, *mus'haf*, *waṣl*. A reader
of this app knows them, and translating them out makes the page vaguer, not simpler.
- [ ] Our words for our plumbing go — *salience*, *shard*, *skin*, *family*, *pipeline*,
*annotation*. If one is load-bearing, say the thing it means instead.
- [ ] A short glossary near the top for the domain words. Three or four lines, once.
- [ ] Read the page out loud as if to someone who has never seen the app. Every place you
would have to add a sentence of explanation is a place the page is missing one.

**Evidence**

- [ ] Each option is **drawn**, at the size it would really be used. Not described.
- [ ] It is drawn on real data from this project, not a mock. A mock cannot surprise you, and
the surprise is the reason to draw it — a wash that turns out invisible at print size
has answered the question no paragraph would have.
- [ ] Any number on the page can be re-derived by running the generator.
- [ ] No option is written to lose. If one is obviously worse, either it is not a real option
or the page is arguing rather than presenting.

## Building it

The page is generated from committed data by a script, so the numbers stay true and anybody
can rebuild it. Split the generator when the data needs something not in the repo: an
`--extract` mode that reads whatever large local cache is involved and writes a small JSON of
findings, and a default mode that renders from **committed bytes only**. Then the page
rebuilds on a fresh clone and the expensive half runs once.

Two outputs, and the gate refuses one without the other:

- **the checked-in page**, under `docs/`, a full HTML document — a fragment renders in quirks
mode from `file://`, which is where it is read from
- **the published copy**, which is what a person is actually sent

The published copy is served under a policy that blocks every external host, so **inline
everything**: no relative URLs, no CDN links, no external fonts. A referenced asset does not
error, it silently renders nothing. Where the same picture repeats, define it once and
reference it rather than embedding several copies.

Before publishing, check what the bytes carry. This repo ships **no Qur'an text** — a
standing rule kept by the shipped bytes, not only by policy. A page built from mus'haf assets
must contain zero Arabic codepoints and no text elements; the vendored print is outlined
paths, so it does not, but verify rather than assume.

## Registering it

A decision that is not in the register does not exist — nothing counts it as open, and
nothing notices when it goes stale.

```
add a row to docs/decisions.json question, status, options, artifact, page, builtBy, doc, related
make decisions-doc re-render docs/decisions/README.md
pnpm gate:decisions the check
```

The row stores **one sentence of its own**: the plain-language question. Everything else it
points at. Do not summarise the decision into the register — the record owns the answer, and a
copy would be right for a while and then quietly stop being right.

`artifact` is the published link; `page` is the same page checked in here. Both, always: a
link with no copy dies when the host does, and a copy with no link cannot be sent to anybody.
The record named in `doc` has to link to each, or its argument is about a picture the reader
cannot reach.

`related` lists the ids of decisions that constrain or are constrained by this one — question
6, made machine-checkable. It is the one piece of a decision that no single record can hold,
because relatedness is a fact about a pair, and the second half is usually written months
after the first. Fill it in **both** directions; the gate says so, since a link a reader can
only follow one way is one they will not find from the end they are standing at.

When the decision is made: set `decided` to the option that won, `status` to decided, and `by`
and `date` to who chose and when. The losing options stay on the page. They are the reason the
choice was a choice, and the next person to reopen this will want them — along with question
9's answer, which is what tells them whether anything has actually changed.
117 changes: 111 additions & 6 deletions .claude/skills/validate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,12 @@ and the fix it names is that one command.
3. Pick one. Read it here with `make validate CHECK=<id>`; a step a producer
discharged prints as `[machine] …` with the command and the date, and you skip
it.
4. If it happens on a phone (most of them do), `make guide` and open the printed
LAN URL on the phone — same runbook, tickable, with the checkboxes persisted
so a screen lock does not lose the walkthrough. Run the check's own `setup`
commands (e.g. `make phone-perf`) in a second terminal.
4. Open the runbook where the check actually happens. Two surfaces, same source:
`make guide` for the phone (every check, tickable, nothing written down), or
`make session CHECK=<id>` for the one you are about to work (a single check,
everything you tick or type banked to a transcript as you do it — see below).
Run the check's own `setup` commands (e.g. `make phone-perf`) in a second
terminal either way.
5. `make record CHECK=<id> RESULT='<the verdict, in words>'`. The words are the
artifact: a `done` with no result is indistinguishable from a check nobody ran.
Recording stamps the ledger, regenerates the guide, and re-runs the gate.
Expand All @@ -334,14 +336,78 @@ check that tunes nothing, a pending human check with no runbook (nobody can run
it, so it will sit there looking tracked), or a `guide.html` that was not
regenerated after the ledger moved.

### Co-working a check — `make session`

```bash
make session CHECK=<id> # resume an unbanked sitting, or start one
make session CHECK=<id> NEW=1 # start a fresh transcript regardless
make session CHECK=<id> PORT=4180
```

One check, drawn from the same renderer the field guide uses, served on the LAN so
the phone can hold it — and, unlike the guide, it **writes**. Every box ticked,
every note typed, and every answer a check's own tool reports lands in
`docs/validation/sessions/<stamp>-<id>.jsonl` at the moment it happens. At the end
the page banks the verdict into the ledger for you (it runs `make record`), or
prints the `make record` line pre-filled from what you wrote if you would rather
say it yourself.

The gap this closes is an ordering problem, not a documentation one. The ledger's
`result` has always been typed after the walkthrough was over, so what you noticed
at step four survived only if you were still holding it at step ten. Here the
observation is on disk while the step is still in front of you.

Four properties, each load-bearing:

- **It cannot score.** The page shows how far through you are and whether the last
write landed, and nothing else. `placement-correction-by-eye` is a blind forced
choice whose validity rests on nobody — the worker included — knowing how it is
going while it is going, and a running tally would quietly turn the measurement
into a training exercise. `summarise()` in `scripts/lib/session-log.mjs` is
arithmetic only for exactly this reason.
- **It is not a second ledger.** Nothing reads a transcript to decide whether a
check passed. `docs/validation/sessions/README.md` is the format; the ledger
stays the register.
- **A dropped write is visible.** The client retries forever and shows an
unmissable "N not banked" pill while it does. A capture surface that loses
writes quietly is worse than the download it replaced, which at least failed
where you could see it.
- **The server binds `0.0.0.0`, so every write route carries a per-run token.**
The URL printed in your terminal has it; a second thing on the Wi-Fi does not.
The integrity argument is the real one — these files are treated as evidence.

**Giving a check its own tool.** A check whose work is a purpose-built page
declares it in the ledger, beside the runbook that builds it:

```json
"runbook": {
"tool": { "path": "packages/etl/out/mark-adjudication.html", "label": "…", "note": "…" }
}
```

The session serves that file with a small sink injected, and the page opts in:

```js
window.HIFTH_SESSION?.post("observation", { … }); // one answer, as it is given
window.HIFTH_SESSION?.artifact("ruling.json", { … }); // the file, at the end
```

Both calls are optional by construction. Opened as a plain file — still supported,
still the documented fallback — the sink is absent, the calls do nothing, and the
tool keeps whatever offline behaviour it had. **Never send the sink anything the
page is not supposed to know**: the adjudication tool posts *which panel was
chosen* and never whether it was right, because the answer key does not exist
until the scorer rebuilds it from the seed, and a reporting path is the last place
that should be where it leaks.

### `evidence` — the half a machine *can* run, written down

A check may carry an `evidence` block: one command (`run`), the runbook step
**ids** it discharges (`covers`), and the `residue` it cannot. `make validate-auto`
runs each one and writes the real exit code into
`docs/validation/evidence/<id>.json`; the terminal and the guide read those
records and strike the covered steps off. Three of the six checks have one today
`source-offer-resolves`, `kfgqpc-terms-primary-source`, `edge-spot-audit`.
records and strike the covered steps off. Three checks have one today
`source-offer-resolves`, `kfgqpc-terms-primary-source`, `edge-spot-audit`.

Four rules, each of them load-bearing:

Expand Down Expand Up @@ -439,6 +505,45 @@ verdict.
If a whole *class* of edge turns out wrong, that is a filter in
`packages/etl/scripts/build-adjacency.mjs`, not twenty more rejections.

### The placement adjudication — asking without telling

The second check with a dedicated tool, and the one to copy when a measurement
here needs a person to settle it. Steps:
`make validate CHECK=placement-correction-by-eye`.

**Never show someone a verdict and ask if they agree.** They agree. The
measurement sounds confident, the honest answer to "does this look right" is "I
suppose so", and what comes back is the machine's own opinion with a human's name
on it — indistinguishable from evidence, and worthless. This check exists because
the surface that already existed (a page of worst-first verdicts, each drawn with
the expected outline beside the claimed rectangle) is exactly that mistake, and it
sat unworked for weeks partly because nobody could say what working it would
prove.

**The shape that does work.** A forced choice between two candidates, nothing on
the screen saying which is ours, and — the part that makes it evidence rather
than a promise — no answer key on disk. `pnpm adjudicate:marks` plans the session
from a seed; `pnpm adjudicate:score` rebuilds the same session from the same seed
afterwards and only then knows which panel was which. Nobody had to be trusted not
to peek, because for the twenty minutes that mattered there was nothing to peek
at. The scorer refuses outright (exit 2) if the underlying measurement moved
between building the session and scoring it.

**A session carries its own controls, and they are not optional.** An easy
condition (a rectangle a whole letter off) so a session worked while distracted
fails visibly instead of adding noise; an unanswerable one (the same rectangle
twice) so a person who always picks something is caught; and a decoy displaced the
*same distance in another direction*, which does two jobs — it measures whether an
eye can resolve a difference that small at all, and it breaks the pattern that
would otherwise be learnable within twenty trials. Without the decoy, "people did
not prefer our correction" and "people cannot see a shift this small" are the same
number, and only one of them is a finding about the data.

**Bank the refusal as carefully as the confirmation.** The most valuable outcome
this can produce is *the decoys were seen clearly and ours still lost* — our
correction is wrong, not the test blunt — and it is the outcome most likely to be
left in a terminal scrollback. `make record` either way.

---

## Adding a validation
Expand Down
17 changes: 17 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ if [ -n "$staged" ] && command -v node >/dev/null 2>&1; then
echo " to bypass this one commit: git commit --no-verify"
exit 1
fi

# Same rule, one register over, with one addition the other two do not need:
# a decision carries links OUT of the repo as well as into it. An open
# decision must name both the published page and the copy checked in beside
# it, and the record must reach both — otherwise the argument is about a
# picture the reader cannot see. That pairing is easiest to break on exactly
# this commit: you added the artifact link and have not committed the page
# yet, or renamed the page and left the link. Scoped and silent the same way.
# shellcheck disable=SC2086
if ! node scripts/gate-decisions.mjs --files $staged; then
echo ""
echo "pre-commit: docs/decisions.json has a link that does not resolve (above)."
echo " an open decision needs BOTH an artifact link and the page checked in here,"
echo " and its record has to link to each — 'make decisions ID=<id>' shows the row."
echo " to bypass this one commit: git commit --no-verify"
exit 1
fi
fi

# The licence the reader is shown and the licence on record, on the one commit
Expand Down
Loading
Loading