@miroshnik/elves 0.0.0 → 2026.1018.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +310 -0
  2. package/README.md +344 -0
  3. package/bin/ai-dev.ts +7 -0
  4. package/bin/elves.ts +2847 -0
  5. package/bin/shims/timeout +113 -0
  6. package/claude/CLAUDE.md +71 -0
  7. package/dist/github/scripts/github.js +3428 -0
  8. package/dist/spec/scripts/architecture.js +628 -0
  9. package/dist/spec/scripts/eslint-config.js +54 -0
  10. package/dist/spec/scripts/harness-qzb932c8.js +959 -0
  11. package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
  12. package/dist/spec/scripts/harness.js +53 -0
  13. package/dist/spec/scripts/knip-hints.cjs +34 -0
  14. package/docs/ci-concurrency.md +82 -0
  15. package/docs/cloud-sessions.md +203 -0
  16. package/docs/judgment.md +81 -0
  17. package/docs/parallel-checkouts.md +220 -0
  18. package/docs/pr-checks.md +117 -0
  19. package/docs/testing.md +304 -0
  20. package/package.json +70 -1
  21. package/skills/ci-wait/SKILL.md +180 -0
  22. package/skills/ci-wait/scripts/wait-ci.sh +377 -0
  23. package/skills/dashboard/SKILL.md +212 -0
  24. package/skills/dashboard/scripts/dashboard.ts +912 -0
  25. package/skills/dashboard/scripts/sessions.ts +772 -0
  26. package/skills/est/SKILL.md +221 -0
  27. package/skills/est/reference.md +381 -0
  28. package/skills/est/scripts/est.ts +4854 -0
  29. package/skills/github/SKILL.md +206 -0
  30. package/skills/github/locales/en.json +449 -0
  31. package/skills/github/locales/ru.json +449 -0
  32. package/skills/github/reference.md +1073 -0
  33. package/skills/github/scripts/github.ts +4390 -0
  34. package/skills/github/scripts/locale.ts +495 -0
  35. package/skills/github/scripts/package.json +3 -0
  36. package/skills/slot/SKILL.md +145 -0
  37. package/skills/slot/scripts/package.json +3 -0
  38. package/skills/slot/scripts/slot.ts +279 -0
  39. package/skills/spec/SKILL.md +247 -0
  40. package/skills/spec/canon.md +219 -0
  41. package/skills/spec/checks.md +709 -0
  42. package/skills/spec/ci.md +378 -0
  43. package/skills/spec/migration.md +125 -0
  44. package/skills/spec/reference.md +889 -0
  45. package/skills/spec/scripts/architecture.ts +839 -0
  46. package/skills/spec/scripts/c4.ts +184 -0
  47. package/skills/spec/scripts/eslint-config.ts +82 -0
  48. package/skills/spec/scripts/harness.ts +1483 -0
  49. package/skills/spec/scripts/knip-hints.cjs +34 -0
  50. package/skills/spec/scripts/knip-hints.d.cts +17 -0
  51. package/skills/spec/scripts/package-run.ts +22 -0
  52. package/skills/spec/scripts/package.json +3 -0
  53. package/skills/spec/scripts/spec-break.ts +232 -0
  54. package/skills/spec/scripts/spec-claims.ts +198 -0
  55. package/skills/spec/scripts/spec-diff.ts +1111 -0
  56. package/skills/spec/scripts/spec-doc.ts +744 -0
  57. package/skills/spec/scripts/spec-exceptions.ts +233 -0
  58. package/skills/spec/scripts/spec-publish.ts +214 -0
  59. package/skills/spec/scripts/spec-run.ts +235 -0
  60. package/skills/spec/scripts/spec-shard.ts +267 -0
  61. package/skills/spec/scripts/speclib.ts +826 -0
  62. package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
  63. package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
  64. package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
  65. package/skills/task-env/SKILL.md +231 -0
  66. package/skills/task-env/proxy/compose.yaml +51 -0
  67. package/skills/task-env/reference.md +254 -0
  68. package/skills/task-env/scripts/task-env.ts +1017 -0
@@ -0,0 +1,889 @@
1
+ # spec — script details and why it is so
2
+
3
+ A reference for `SKILL.md`: what each script does in detail (syntax, flags and
4
+ exit codes — its `--help`), then the reasoning behind decisions and parsing
5
+ details.
6
+
7
+ ## spec-doc output — what ends up in docs/spec
8
+
9
+ What ends up in `docs/spec/` (the generated text is in the project language):
10
+
11
+ - `README.md` — the index: «What the system does» (per
12
+ capability), «What it's made of» (the architecture page
13
+ as the first line, then per architecture rule; neither a model nor rules —
14
+ no section), «Which rules the code obeys»
15
+ (per standard); each link has the first paragraph of `<folder>.md` (the
16
+ description) and, if any, the number of skipped and failing tests; the
17
+ «Outside the tree» section — only if there are such tests;
18
+ - `architecture.md` — from the model `tests/architecture/model.ts`: Mermaid
19
+ diagrams C1 (`C4Context`, the system and external systems), C2
20
+ (`C4Container`, containers and links), C3 (`C4Component`, modules per
21
+ container) and a table of modules, below them — the architecture rules; a
22
+ level without data in the model isn't drawn, no model — no page. A marker
23
+ `<!-- spec: c4-context -->` (`c4-container`, `c4-component`, `c4-modules`)
24
+ on a line of its own in any `<folder>.md` is replaced with the same diagram
25
+ or table; a marker without a model or an unknown one — a warning,
26
+ `--strict` — exit code 1. The model doesn't load — exit code 2;
27
+ - `capabilities/<name>.md`, `architecture/<name>.md`, `standards/<name>.md` —
28
+ one file per folder, a story
29
+ in three layers: the heading is the folder name, under it the `<folder>.md`
30
+ description (**why**); `describe` → a section with prose from its own JSDoc
31
+ (**what it can do**; a nested one — a level deeper); the section's tests — a
32
+ collapsed block `<details><summary>✅ 12 tests</summary>` (**what checks
33
+ it**), with lines `- ✅ name` inside. A skipped one —
34
+ `⏭️ … — skipped: <reason>` (the reason comes from the Playwright
35
+ annotation; Vitest and `bun test` don't put the reason into the report), a
36
+ failing one —
37
+ `❌ … — failing`, `📝 … — todo`; if there are such, the counter is in the
38
+ block's line (`❌ 12 tests, 1 skipped, 1 failing`), and the block is
39
+ expanded. A test in several Playwright projects
40
+ — one line. There is no "section · path · N tests" line: the section and
41
+ path follow from the convention. Names are text: `<` outside a code span is
42
+ escaped, otherwise GitHub would eat `<type>` or `<!-- … -->` as HTML (same
43
+ in `spec-diff`);
44
+ - the first line of every file is the marker `<!-- spec-doc: … -->`: by it the
45
+ script deletes its own stale files (a capability disappeared) and doesn't
46
+ touch others'.
47
+
48
+ `tests/lib` is skipped. Tests outside `tests/capabilities/<name>`,
49
+ `tests/architecture/<name>` and `tests/standards/<name>` (in `src/`, in
50
+ `tests/unit/`, a file directly in `tests/capabilities/`) go to «Outside the tree» —
51
+ that's a signal to move them; `--strict` then returns exit code 1 (for CI).
52
+ The order is deterministic: folders by name; inside a folder the main file
53
+ first — the order of its `describe` is the order of the story, — in a monorepo
54
+ the packages' main files next, the remaining files by path; tests in
55
+ declaration order.
56
+ Identical `describe` from several files of a folder (unit and e2e of one
57
+ capability) merge into one section.
58
+
59
+ **The main file of a folder** — `tests/<kind>/<name>/<name>.*`
60
+ (`<name>.test.ts`, `<name>.e2e.ts`…) for a capability, an architecture rule
61
+ and a standard, in a monorepo also `tests/<kind>/<name>/<package>/<name>.*`
62
+ (a package subfolder, no deeper): its `describe` go first — the main file of
63
+ the folder root, then the packages' main files by package name, then the
64
+ remaining files by path; no main file either at the root or in subfolders —
65
+ stderr, `--strict` — exit code 1. With both `<name>.test.*` and
66
+ `<name>.e2e.ts` side by side, the main one is `.test.*`: the page opens with
67
+ the unit statements, e2e follows, before the folder's other files. **Names**
68
+ of `describe` and `it` are statements in the project language (`language` in
69
+ `.agents/elves.json` of the `--root` directory; `isStatement`): `ru` — a name
70
+ with Russian words, another language — a phrase of words (two or more, or one
71
+ whole word); an identifier (`parseScenarios`, `foo_bar`, `fooBar`), a function
72
+ or file name, a path, a word with a number — not a statement: a stderr line
73
+ `warning: the name isn't a statement …` with the name and file, without an
74
+ exit code — `--strict` doesn't fail on it, the PR section of `spec-diff`
75
+ counts the PR's new ones; code in backticks inside a statement is fine. A
76
+ measurement (#441): `--strict` failed on names 9 times in three weeks, all 9
77
+ paperwork — thousands of renames for the check or names put into exceptions;
78
+ the rule stays a rule, without an exit code (#447). Name exceptions are gone:
79
+ a leftover `names.exceptions/` directory or `names.exceptions.*` file of a
80
+ decision folder isn't read — a warning line asking to delete it; the
81
+ `--names-baseline` and `--names-exceptions` flags are gone too (exit code 2,
82
+ like any unknown flag). The generated `docs/spec`, the PR section of
83
+ `spec-diff` and the names of the harness's tests are in the project language
84
+ too (the `spec` keys of `locales/<language>.json` of the `github` skill).
85
+ **Description** — `tests/<kind>/<name>/<name>.md`: missing — `spec-doc` names
86
+ the file in stderr, `--strict` — exit code 1. A JSDoc header on a test file
87
+ doesn't go into the documentation — into stderr with a hint to move it to md,
88
+ `--strict` — exit code 1. A standard's former main file `rule.*` — also into
89
+ stderr with a hint to rename it to `<name>.*`, `--strict` — exit code 1.
90
+
91
+ ## Writing prose — `<folder>.md` and JSDoc
92
+
93
+ A capability page is a story (the "Specification — decisions" rule,
94
+ `canon.md`): why it exists, what it can do, what checks it. Names give only
95
+ "what it can do" and "what checks it"; "why", the reason and the rejected
96
+ options are the `<folder>.md` description next to the tests, and what isn't
97
+ visible from the name is JSDoc `/** … */` on `describe` and `it` (runner
98
+ reports carry no comments — `spec-doc` reads the sources with the same scanner
99
+ as `spec-diff`):
100
+
101
+ | Where the prose is | Where it goes in `docs/spec` |
102
+ |---|---|
103
+ | `<folder>.md` | the intro under the folder heading (md headings go under it); the first paragraph is the description in the index |
104
+ | JSDoc right before `describe` | a paragraph under its heading |
105
+ | JSDoc right before `it` / `test` | a `>` quote under the test line |
106
+
107
+ ```md
108
+ <!-- tests/capabilities/billing/billing.md -->
109
+ Billing: a customer receives a monthly invoice and pays it by card or bank transfer.
110
+
111
+ The accountant doesn't issue invoices by hand: they arrive by themselves on the
112
+ first day of the month; a manual invoice is an exception for one-off services.
113
+ ```
114
+
115
+ ```ts
116
+ // tests/capabilities/billing/billing.test.ts — the main file of the billing folder
117
+ import { describe, it } from "vitest";
118
+
119
+ /** A partial month is charged in proportion to its days. */
120
+ describe("An invoice is issued by itself on the first day of the month", () => { … });
121
+ describe("A paid invoice is closed without the accountant", () => { … });
122
+ ```
123
+
124
+ - `<folder>.md` — who the capability is for and which problem it solves, the
125
+ reason for the decision and the rejected alternatives; the first paragraph
126
+ is the description in the table of contents. Diagrams of behavior and
127
+ structure — only generated ones, never drawn by hand.
128
+ - A top-level `describe` is a capability as a statement in the user's terms,
129
+ not the name of a function, component or file: the headings alone show what
130
+ the capability can do. The most important goes first in the main file.
131
+ - Prose before `describe` and `it` — where the name isn't enough: one to three
132
+ sentences, not a retelling of the test. An unclear `it` name — rewrite it,
133
+ don't explain it.
134
+ - `//` and `/* … */` don't go into the documentation: file names, issue
135
+ numbers and the test's internals are for the code reader. Block tags
136
+ (`@see`, `@param`) and everything after the first of them — neither.
137
+ - A JSDoc belongs to the nearest code: between it and `describe`/`it` — only
138
+ whitespace and comments. The file's first JSDoc followed by something other
139
+ than a call (imports) is the file header; in a file without imports a JSDoc
140
+ right before the first `describe` is that `describe`'s prose.
141
+ - A test file's header doesn't go into the documentation (section "spec-doc
142
+ output" above): its text belongs in `<folder>.md` or in `//`.
143
+ - The source is read by the path from the report, from the root (`--root`); no
144
+ file (a report from another machine) — documentation without prose, the list
145
+ of such files in stderr.
146
+
147
+ ## spec-diff output — decision sections, scenarios, renames, a big PR
148
+
149
+ Decisions outside test names — as separate sections «<title> — removed» and
150
+ «<title> — added», only if they changed: «Architecture model» (modules,
151
+ dependencies, packages from `tests/architecture/model.ts`),
152
+ «Exceptions» (the files of the decision folders' `exceptions/` — not in a
153
+ subfolder and `.json`, as the harness reads them, — entries of the former
154
+ `exceptions.ts` — the item and the harness `rule` — and lint disables
155
+ `eslint-disable … -- #N` in changed files,
156
+ except the flow copy `.agents/` and `.claude/`; each entry
157
+ is its own line, identical ones aren't collapsed), «Harness checks»
158
+ (`invariant` registries and `examples` rules — the
159
+ `registry` / `rule` options of the object in the call's arguments, by parsing
160
+ the source, with a string known without a run; `deadCode` runs —
161
+ «code without a consumer», with `rule` or `production: true` in
162
+ parentheses; `architecture` — «architecture model»;
163
+ their tests appear only in a run). The model and
164
+ exceptions are read by importing the revision's data file: a module with
165
+ imports outside its own boundaries isn't read, its decisions aren't shown.
166
+
167
+ `--scenarios` — the task body (`gh issue view N --json body --jq .body | … --scenarios -`):
168
+ the `## Scenarios` section (the script reads only the `ru` name so
169
+ far) is checked against the added and changed tests — «### Task scenarios»;
170
+ each name on one line, once. A scenario that became a test
171
+ is named the same as the test: while the tests are in the lists above, it is
172
+ only in the «Became tests» count, and a test beyond the
173
+ scenarios gets the «beyond scenarios» mark in the list; in
174
+ the per-folder summary (no lists) — ✅ and the test's line.
175
+ ❌ — a scenario without a test, in its own wording. Tests with the scenario's
176
+ name in several folders (server and UI) — all tests of this scenario; in
177
+ `--json` the scenario has `tests`. A match — with the `it` name or the chain
178
+ "describe › it", case-insensitive, ignoring outer quotes (`«…»`, `"…"`,
179
+ `` `…` ``) and a trailing mark (`.`, `;`, `,` — before and after the quotes).
180
+ No test — with the name or chain of a `describe` in which `invariant`,
181
+ `examples`, `deadCode` or `architecture` is called (a scenario of a "registry
182
+ + invariant" standard): from the sources, without a report or the `spec`
183
+ branch; only a describe where the check is new (the describe is new, renamed,
184
+ or the registry, the rule or the `rule` of a `deadCode` run changed), — the
185
+ line names the registry, the rule, «code without a consumer» or
186
+ «architecture model». No call in the sources (the `spec-claims` report is written by a
187
+ script after the run) — the describes of new tests from the report
188
+ (`--report`), the line says «from the report». Tests from the
189
+ report that this check generated are tests of its scenario, not beyond the
190
+ scenarios. No section — the check says so.
191
+
192
+ Compares test names **statically** — parses the `tests/` files at two
193
+ revisions via `git show`, no need to run tests or switch branches.
194
+ The base is the merge-base of the base branch and HEAD, like a PR diff on
195
+ GitHub (a test added to `main` after branching isn't counted as removed);
196
+ `--no-merge-base` — comparison with the branch as is. `--worktree` — the
197
+ working tree instead of HEAD, to look before a commit. Before running —
198
+ `git fetch origin`, otherwise `origin/main` is stale.
199
+
200
+ Tests that the harness generates (per registry item, «not empty»,
201
+ violator, exceptions, examples) aren't in the sources — only the report sees
202
+ them. `--report <report>` (repeatable) adds them to the parsed ones: the head
203
+ — from the PR run's report, the base — `tests.json` of the `spec` branch
204
+ (written by `spec-doc`, published by `spec-publish`) from the commit whose
205
+ `Source:` is the merge-base or its ancestor (`--spec-branch`, default
206
+ `origin/spec`). No such commit — a diff by sources, and the line under «Base»
207
+ says so. `Source:` older than the diff base (publishing on a PR merge
208
+ was skipped, `spec-run`) — the line names both commits: the lists may contain
209
+ tests of PRs merged between them. A merge that didn't change the spec doesn't
210
+ create a gap: `spec-publish` confirms the publication with a new `Source:`.
211
+ A partial run's report (`bun test <one folder>`): a head file whose tests only
212
+ a run names — an `invariant`, `examples`, `deadCode` or `architecture` call,
213
+ or a test name that can't be computed statically (a template with a
214
+ substitution in a loop) — but no test in the report wasn't run: its tests
215
+ from `tests.json` aren't compared and aren't «Removed»; a line under «Base»
216
+ names the folders and asks for a full run's report, `--json` lists the tests
217
+ in `unreported`. A file without such a call or test in the head — its tests
218
+ are removed.
219
+
220
+ A test's identity is the folder path + the `describe` chain + `it`: a move
221
+ between files of one folder isn't a change, a move to another folder is
222
+ removed and added (the requirement changed home). The output is a ready
223
+ section for the PR body:
224
+
225
+ ```markdown
226
+ ## Spec (tests)
227
+
228
+ _Base: `origin/main (merge-base)`._
229
+
230
+ **Removed (1):**
231
+
232
+ - `tests/capabilities/billing` · Invoices › a draft is deleted without an invoice
233
+
234
+ **Changed (1):**
235
+
236
+ - `tests/capabilities/billing` · Invoices › ~~an invoice is issued~~ → a monthly invoice is issued
237
+
238
+ **Added (1):**
239
+
240
+ - `tests/capabilities/billing` · Invoices › an invoice for a partial month is proportional to its days
241
+
242
+ **Outside the `tests/` tree** changed test files: `src/utils/sum.test.ts`.
243
+ ```
244
+
245
+ The removed come first: a removed test is a removed requirement, it must be
246
+ visible. «Changed» are renames: a test with the same body in the
247
+ same file, even if both the `describe` and the name changed (translating names
248
+ into statements; the body is the only one in the file among the removed and
249
+ new ones, an empty one isn't a sign; compared without whitespace and trailing
250
+ commas — prettier re-wrapping a call doesn't change the body), a test with a
251
+ similar name in the same file and the same `describe` (similarity ≥ 0.6) or a
252
+ test with the same name under another `describe` of the same folder,
253
+ including from another file (that's what moving unit and e2e tests into the
254
+ main file's capability section looks like); a dissimilar name with a
255
+ different body is honestly removed and added. An empty list prints as «none»,
256
+ so it's visible that the check ran. Tests inside `tests/` but
257
+ outside capabilities/standards are marked `⚠️ outside the tree`;
258
+ changed test files outside `tests/` are listed by name. The flow copy of an
259
+ earlier release (`.agents/`, `.claude/` from the root) is not project code —
260
+ neither its tests nor its lint disables are in the spec diff. After the lists — the number of the PR's new
261
+ names that aren't statements in the project language: `it` and `describe`
262
+ names of added and changed tests that the same file didn't have at the base
263
+ (`⚠️ **Names that aren't statements: N** — new in this PR…`); none — no line;
264
+ `spec-doc` names each one in a warning. `--json` — the same, machine-readable
265
+ (the number — `not_statements`).
266
+
267
+ **A big PR.** More tests in the lists (removed, changed, added) than the
268
+ `--limit` threshold (default 100) — a per-folder summary instead of the lists:
269
+ a table with the columns «Folder», «Removed», «Changed», «Added», with
270
+ `--report` also «Of them, from the harness». The removed ones go above the table
271
+ as a list (a removed requirement is visible by name) as long as there are no
272
+ more of them than the threshold; the decision
273
+ sections and «Tests beyond scenarios» above the
274
+ threshold — a number per folder or file. The full list — `--full`; CI writes
275
+ it to the job summary, the summary goes to the PR body.
276
+
277
+ **A move into the tree** (a project moves to a test spec): a test that on the
278
+ base was in a file outside `tests/`, disappeared there and appeared in the
279
+ tree with the same `describe` chain and name is not "added" but moved. Such
280
+ tests go in one section, «Moved into the tree», — a
281
+ per-folder summary without a line-by-line list (otherwise a PR with thousands
282
+ of moved tests wouldn't fit into the PR body):
283
+
284
+ ```markdown
285
+ **Moved into the tree (3):** the same names as outside `tests/` on the base; from 2 files.
286
+
287
+ - `tests/capabilities/auth` — 1 test from 1 file
288
+ - `tests/capabilities/math` — 2 tests from 1 file
289
+ ```
290
+
291
+ A file moved entirely (deleted, all its tests found a pair) doesn't go into
292
+ the "outside the tree" list; one that stayed or has some tests without a pair
293
+ — by name: something was renamed or lost in the move. A test renamed during
294
+ the move is "added", a test that also stayed outside the tree is a copy, also
295
+ "added". In `--json` — `moved` with the original (`from`) and the new path of
296
+ each test.
297
+
298
+ The section is inserted into the PR body as a whole (after `Closes #N`); on
299
+ new commits with tests — regenerate it and replace it.
300
+
301
+ ## spec-publish — the branch, retries, exit codes
302
+
303
+ The root of the `spec` branch is the contents of `docs/spec`; the commit
304
+ message has `Source: <SHA>` it was built from (default `HEAD`; publishing on a
305
+ PR merge without CI on `main` — the merge SHA, `ci.md`: `spec-diff` searches
306
+ its ancestors). The working copy and `HEAD` aren't touched: the tree is
307
+ assembled in a temporary index, the commit goes on top of the published one,
308
+ the publication history is kept. The same source — no new commit
309
+ («no changes»); the same content from a new source — a commit with
310
+ the same tree and a new `Source:` («no changes — … confirmed for …»):
311
+ the branch names the last checked `main`,
312
+ and a gap with the diff base in `spec-diff` is only a skipped publication. The
313
+ published `Source:` is a descendant of the new source — «already newer»,
314
+ exit code 0, the branch isn't touched: publications arrive out of
315
+ merge order (`main` runs are parallel, `ci.md`); comparing is possible only
316
+ with history (in a shallow clone the previous source is unknown — it
317
+ publishes), the checkout in CI has it. A rejected push (another publication
318
+ moved the branch between `fetch` and `push`, a hosting failure) is not a
319
+ failure: the log gets the reason from `git push` stderr, a pause of
320
+ `--interval` × the attempt number (5 s, 10 s…), the branch is re-read, and the
321
+ decision is made again — built from a descendant gives «already newer», otherwise
322
+ a commit on top of the new head; up to `--attempts` attempts. After the push
323
+ the branch is read from the remote and compared with the directory: exactly
324
+ what was built is published, not "the push went through"; the next publication
325
+ already on top of ours (our commit in the branch's history) isn't a mismatch —
326
+ a log line names the new head (#443). `--check` —
327
+ comparison only (exit code 1 if the branch is behind or missing).
328
+ Exit codes: 0 — published, matches or already newer, 1 — mismatch or the push
329
+ rejected on all attempts, 2 — no directory or not git. Without a configured
330
+ `git user` the commit is from the GitHub Actions bot.
331
+
332
+ ## spec-run — the green run for the merge tree
333
+
334
+ Publishing on a PR merge without CI on `main` (`ci.md`): the id of the green
335
+ run that checked the merge commit's tree (`git rev-parse 'HEAD^{tree}'`) — to
336
+ stdout, for `gh run download`. A PR run names its artifact after the tree it
337
+ checked (merge-ref): `docs-spec-<hash>`. The script takes the unexpired
338
+ artifacts with this name from runs of this repository (not a fork) and asks
339
+ for the outcome of each run: an artifact is uploaded mid-run and doesn't
340
+ prove a green outcome. There is a run with `success` — that one; there is one
341
+ in progress — polling until the outcome (only state changes go to the log),
342
+ not finished by the ceiling — exit code 1. Otherwise — no artifact (a PR that
343
+ fell behind was merged: no run checked such a `main`), expired, from a fork,
344
+ the run isn't green — exit code 0, stdout empty, in stderr «main tree … not
345
+ fully checked — publishing skipped» with the reason: the `spec` branch lags
346
+ until the next
347
+ merge with a matching tree, but isn't rolled back. Exit codes: 0 — found or
348
+ publishing skipped, 1 — the run didn't finish by the ceiling, 2 — a call
349
+ error or `gh`.
350
+
351
+ `--artifact <name>` — for `spec-shard`: the newest green run of this
352
+ repository with an unexpired artifact of that name (`spec-reports` — the
353
+ merged reports of the `spec` job). Runs are asked newest first, up to the
354
+ first `success`; one in progress isn't waited for — its outcome is unknown,
355
+ the previous green one does. None — code 0, stdout empty, in stderr «no green
356
+ run» with the runs asked: the shards split by count. In Actions
357
+ (`GITHUB_RUN_ID`) — green by the start of the current run's first attempt:
358
+ every shard of the run gets one answer whenever it reaches the step, a re-run
359
+ of failed jobs — the source of the shards it keeps (`ci.md`, "Shards by
360
+ durations"). Artifacts uploaded after that start aren't asked; a run counts
361
+ by its attempt started by then and only if it finished by then, so a re-run
362
+ of the source started later doesn't change the answer. Outside Actions — the
363
+ newest green run now. A `gh` error — exit code 2: the shard step fails
364
+ instead of splitting by count next to shards split by durations.
365
+
366
+ ## spec-shard — CI shards by durations
367
+
368
+ A runner's `--shard` doesn't look at time: Playwright cuts the suite in a row
369
+ by the count of tests in path order, Vitest — files by the hash of the path.
370
+ The heavy tests of one folder land in one shard, the PR waits for it, the
371
+ others stand idle. `spec-shard --shard k/n --runner vitest|playwright|bun
372
+ --list <file> --report <file>…` prints shard k's filters, one per line; the
373
+ CI step (`ci.md`, "Shards by durations") passes them to the runner instead of
374
+ `--shard`.
375
+
376
+ - **The list** — the runner's own: `vitest list --filesOnly --json=<file>`
377
+ (`[{ file }]`, absolute paths), `playwright test --list --reporter=json`
378
+ (file suites relative to `config.rootDir`) or one path per line — bun test
379
+ lists nothing without a run, its list is `git ls-files` (`ci.md`, "A
380
+ project on bun test"). Absolute paths become relative to the root
381
+ (`--root`, default — the current directory: the runner reads the filters
382
+ from it).
383
+ - **Durations** — the reports `spec-doc` reads: Vitest/Jest — the file's
384
+ `endTime - startTime` (hooks between tests count), without them — the sum
385
+ of its tests; Playwright — the sum of all results of the file's tests (each
386
+ project and retry takes a worker's time); `bun test` JUnit — the file
387
+ suite's `time`. A report's path goes to the listed file it ends with: a
388
+ report from another machine (the CI artifact) works without a common
389
+ root. A file in several reports — the longest. A missing report file isn't
390
+ an error (the artifact may be gone); no durations — every file weighs the
391
+ same, the split is by count; a file without a measurement gets the mean of
392
+ the measured ones.
393
+ - **Layout** — greedy (LPT): groups from the heaviest into the lightest
394
+ shard; ties — by path (the sort is stable, the list is sorted). The same
395
+ inputs give the same shards in every job of the matrix: each job computes
396
+ its own shard, and the shards must agree.
397
+ - **Filters.** Vitest takes a file whose path relative to its project's root
398
+ contains the filter (or the filter's path relative to that root),
399
+ case-insensitively (`filterFiles` in `vitest/node`) — the filter is the
400
+ path. Playwright tests a regular expression against the absolute path —
401
+ the filter is `/(^|\/)<escaped path>$/`: a dot stays a dot, and the path
402
+ ends where the file does. bun test takes a file whose path from the
403
+ current directory contains the filter, case-sensitively, but a filter
404
+ with `./` — as the file's path, only that file: the filter is
405
+ `./<path>`. A filter that still takes another file
406
+ (`tests/a.test.ts` in `packages/web/tests/a.test.ts`; for Vitest — inside
407
+ any directory common to both paths: the project root isn't known) — both
408
+ go into one shard, a stderr line names them: the shard's filters take
409
+ exactly its files. The guard — a real Playwright run over the shards'
410
+ filters: every file once, in its shard.
411
+ - **Playwright dependencies** (a login project before the tests) aren't cut
412
+ by filters: the runner adds dependency projects without them — the login
413
+ runs in every shard. Its file in the list adds its time to one shard only.
414
+ - **An empty shard** (more shards than files) — stdout is empty, code 0: the
415
+ step doesn't start the runner, without filters it would run everything.
416
+ - **The unit is a file:** a file longer than a shard's share stays the bound —
417
+ split the file. With `--shard` Playwright splits a file between shards
418
+ (`fullyParallel`); with filters the file's tests spread over the shard's
419
+ workers.
420
+ - Rejected: a durations file in the repository — it goes stale and needs
421
+ commits; Vitest's `sequencer.shard` hook — a module in the project's
422
+ config, and Playwright has no such hook; Playwright's `--test-list` (an
423
+ exact list) and bun test's `--shard` with `--timings` (its own durations
424
+ file) — one mechanism and one source of durations for all runners.
425
+ - Codes: 0 — the shard is printed, 2 — a call error (arguments, the list or
426
+ a report unreadable).
427
+
428
+ ## spec-break — breakages and the revert
429
+
430
+ A decision's check must be able to fail: a breakage is a code edit that
431
+ should turn it red. The agent picks the breakage for one check (this is not
432
+ mutation testing): the `--find` fragment occurs in the file exactly once,
433
+ otherwise exit code 2 before the run. A plan is JSON
434
+ `[{ name, file, find, replace, cmd? }]` (`cmd` — a string for `sh -c`,
435
+ without it — the command after `--`); each breakage is a separate run: the
436
+ first red test in a serial group hides the rest.
437
+ Before the breakages — a baseline run of each command: red even without a
438
+ breakage — exit code 2, breakages aren't applied (`--no-baseline` — skip it).
439
+
440
+ Output — `✅ failed: <name> (<file>)` / `❌ didn't fail: …` and a total;
441
+ exit codes: 0 — every breakage made it fail, 1 — there is
442
+ a ❌, 2 — an error. Revert — always: after the run; on SIGINT/SIGTERM/SIGHUP
443
+ (the test command is killed with all its processes); after an interruption
444
+ without a revert (kill -9, a closed session) — from the journal
445
+ `.git/spec-break.json`, written before the edit: the next run or `--restore`
446
+ restores the file; the file was edited after the interruption — the journal
447
+ isn't applied, exit code 2 — compare by hand.
448
+
449
+ ## spec-exceptions — what it moves and rewrites
450
+
451
+ A decision's exceptions are the folder's `exceptions/` directory, one file per
452
+ exception (`<item>.json`: `{ item, rule?, issue, reason }`), not an array in
453
+ `exceptions.ts`: the subtasks paying down the debt delete their own files in
454
+ parallel and don't conflict (with an array each next one got a conflict on
455
+ neighboring lines and a repeated CI run). The command moves each
456
+ `exceptions.ts` (`.mts`, `.js`, `.mjs`, `.json`) of a decision folder
457
+ `tests/<kind>/<name>` or its subfolder into the decision folder's
458
+ `exceptions/` directory (read by `exceptionsIn()` of a test in any
459
+ subfolder): one file per exception (the name is the item in Latin letters,
460
+ `rule--` in front), the old file is deleted; the import in the files of the
461
+ folder and subfolders (`import exceptions from "./exceptions.ts"`) — to
462
+ `const exceptions = exceptionsIn()` with an import from the harness; the path
463
+ to the old file in `package.json` and `.github/workflows/*.yml` (`spec-claims
464
+ --exceptions`) — to the directory. Which file belongs to whom — one rule,
465
+ `exceptionFile` (`speclib.ts`), for the harness, spec-diff and
466
+ spec-exceptions (the Elves standard `exception-files`); the
467
+ command doesn't touch files outside decision folders. Output
468
+ — lines `-` (file moved), `+` (new file), `~` (import or path rewritten), `!`
469
+ (fix by hand: a reference from another folder, no harness import). Exit codes:
470
+ 0 — moved or nothing to move, 1 — there is a `!`, 2 — an exceptions file
471
+ doesn't load or isn't an array of `{ item, … }`. A repeated run changes
472
+ nothing.
473
+
474
+ ## Wiring into a repository — step by step
475
+
476
+ How to sort out an old spec's requirements (OpenSpec, a requirements
477
+ document) when switching — an outcome for each, check by breaking, what is
478
+ removed without a test — `migration.md` next to this file.
479
+
480
+ The scripts and modules come from the project's package — `@miroshnik/elves`
481
+ in `devDependencies`, its version committed with the project. The scripts run
482
+ under Bun — the flow's only runtime, a Node project included (in CI — the
483
+ `oven-sh/setup-bun@v2` step, `ci.md`). The modules the project's tests and
484
+ config import — `@miroshnik/elves/spec/harness`, `…/spec/architecture`,
485
+ `…/spec/eslint-config` — load under the project's own runner (Vitest, Jest,
486
+ Playwright, ESLint on Node) from the package's build, Bun and the type checker
487
+ take the TypeScript source: only `node:` APIs and erasable TypeScript, no
488
+ `import.meta` and no top-level `await` (tests/standards/harness-load). The
489
+ agent before a PR and CI call the same project `spec:doc` / `spec:diff` at
490
+ the same version, so what the agent sees locally matches CI. Why a package and
491
+ not a copy, a submodule or a checkout in the workflow — section "Wiring into
492
+ the project's CI" below.
493
+
494
+ 1. **Install** at the project root, the result (the package version in
495
+ `package.json` and the lockfile, `.agents/elves.json`, `.agents/slot.sh`,
496
+ the block in `AGENTS.md`) goes into a commit:
497
+ ```bash
498
+ bunx @miroshnik/elves install
499
+ ```
500
+ Update — `update` in the current task's branch: the package version, a few
501
+ lines; then the reports and `spec:doc --strict`; if the format changed — CI
502
+ publishes the new one after the merge.
503
+ 2. **Scripts** in `package.json` and CI — the package's `elves` (Bun runs
504
+ TypeScript itself, for any project `type`) under `bun --no-env-file`: `bun`
505
+ reads the bin itself, past its shebang, and without the flag would load the
506
+ project's env files — the script would pass their keys on to `gh`, `git`
507
+ and the checked command (`tests/standards/no-env-file`); a job without the
508
+ project's dependencies — `bunx @miroshnik/elves@<version> spec-…` (`ci.md`).
509
+ Reports — those the project has:
510
+ ```json
511
+ "spec:doc": "bun --no-env-file node_modules/.bin/elves spec-doc .spec-report.json .spec-playwright.json .spec-claims.xml --strict",
512
+ "spec:diff": "bun --no-env-file node_modules/.bin/elves spec-diff",
513
+ "spec:publish": "bun --no-env-file node_modules/.bin/elves spec-publish",
514
+ "spec:claims": "bun --no-env-file node_modules/.bin/elves spec-claims --entries tests/standards/entry-points/entries.ts"
515
+ ```
516
+ Standards and architecture run once — the `test:spec` script, a fast CI job
517
+ of its own; `test` excludes them, e2e doesn't run them (`checks.md`):
518
+ ```json
519
+ "test": "vitest run --exclude 'tests/standards/**' --exclude 'tests/architecture/**'",
520
+ "test:spec": "vitest run tests/standards tests/architecture"
521
+ ```
522
+ `github pr premerge` runs `test:spec` before a merge on the merge with a
523
+ fresh `main` (after `typecheck`, if there is one); the flow's `check`
524
+ requires it once `tests/standards` or `tests/architecture` exists and names
525
+ both lines — `test:spec` and `test` without the same roots; with
526
+ `test:spec` in place, a `test` that still runs them — an `○` line with
527
+ that `test`.
528
+ Before `spec:doc` locally — a full run with reports (`SKILL.md`, "Reports for
529
+ spec-doc"). In `.gitignore`: `docs/spec/`, `.spec-*.json`, `.spec-*.xml`,
530
+ `.spec-meta/`, `.spec-journal/`, `vitest-blob/`, `blob-report/`,
531
+ `playwright-blob/`.
532
+ 3. **`docs/spec/` isn't committed to `main` — CI publishes it to the `spec`
533
+ branch:** it is a derived copy of the tests, and in `main` parallel PRs
534
+ would conflict in its pages and table of contents. The reviewer sees the
535
+ requirements delta in `spec-diff` (the PR body), a prose edit — in the
536
+ test's JSDoc diff. Switching a project where `docs/spec` is already in
537
+ `main`: `git rm -r --cached docs/spec`, a line in `.gitignore`, CI steps —
538
+ item 4 and `ci.md`.
539
+ 4. **CI.** Shards write blob reports, a separate `spec` job after all shards
540
+ merges them with the runners' own tools (`vitest --merge-reports`,
541
+ `playwright merge-reports` — all Playwright projects in one report) and
542
+ builds `docs/spec` with `--strict`; on `main` it hands it as an artifact to
543
+ the `spec-publish` job — only that job has a write token
544
+ (`contents: write`), and it doesn't install the project's dependencies. A
545
+ shard's files — from `spec-shard` by the last green run's durations
546
+ (section "spec-shard — CI shards by durations"). A
547
+ test failed — `spec` doesn't run: there is no point building documentation
548
+ from a red run. `spec-diff` is its own light job: it needs git history and
549
+ Bun, not tests, so the section is in the summary even with red tests. A
550
+ fragment for pnpm (in `package.json` — `packageManager`), Vitest in 3
551
+ shards, Playwright in 2; concurrency per `docs/ci-concurrency.md` — the
552
+ workflow fragment is in `ci.md` next to this file.
553
+ All CI on the PR, no tests on `main` — publishing on a PR merge from its
554
+ run's artifact, the second variant in `ci.md`.
555
+ 5. **Monorepo** — a decision's tests in package subfolders
556
+ `tests/<kind>/<name>/<package>/` (`canon.md`, "A decision is the unit of
557
+ the spec, a folder is its home"): a package has its own runner project with
558
+ its aliases, environment and plugins, which takes `tests/*/*/<package>/`;
559
+ tests directly in the decision folder are cross-cutting, the root runner
560
+ takes them. Run from the repository root: the harness (`exceptionsIn`,
561
+ `.spec-meta/`, the `spec-claims` journal) counts paths from cwd; `fileOf` —
562
+ a path from the project root, without a correction for cwd. The
563
+ packages' reports go into `spec:doc` as one list: there is one decision
564
+ page, the main file is at the folder root or in a package subfolder.
565
+
566
+ ## Limitations to know about
567
+
568
+ - The TS/JS scanner takes tests from the parser's AST; the name is a string
569
+ known without a run (a literal, a template without substitutions, their
570
+ concatenation), the first argument of `describe` / `it` / `test` (and the
571
+ `x…`/`f…` variants, `suite`, `context`, `test.describe`), for `.each`,
572
+ `.for`, `.runIf`, `.skipIf` — of the second call: `it.each(...)('name %s')`
573
+ — a name with a placeholder; a type argument (`it.each<T>(…)`,
574
+ `test<Ctx>(…)`) doesn't get in the way. A name that can't be computed (a
575
+ template with a substitution, a variable) — a line in stderr with the
576
+ location: such a test isn't in the `spec-diff` lists, `spec-doc` names it if
577
+ it has a JSDoc. Modifiers — only known ones (`skip`, `only`, `todo`, `each`,
578
+ `for`, `concurrent`, `serial`, `fixme`, `fails`…): `test.step`, `test.use`,
579
+ `beforeEach` are not tests. A file the parser couldn't parse — a line in
580
+ stderr with the error location.
581
+ - Vitest and `bun test` reports don't contain the skip reason — keep it in the
582
+ test name or in a comment next to it (rule: `skip` without a reason and an
583
+ issue number is a lint error).
584
+ - The scanner knows the prose of `it.each` and `describe.each` under the name
585
+ with a placeholder (`sum %i + %i`), and the report — under the substituted
586
+ one; such JSDoc doesn't get into the documentation.
587
+ - The scripts don't judge tests — what fails, the runner will tell. `spec-doc`
588
+ only shows the status; on `main` everything must be ✅ or ⏭️ with a reason.
589
+ - The rationale for the choice of formats and static parsing — the "Report formats"
590
+ section and the "spec-diff: static parsing, not two reports" section below.
591
+
592
+ ## Report formats
593
+
594
+ - Vitest and Jest JSON are one format (`testResults[].assertionResults[]`);
595
+ Playwright JSON has its own tree `suites → specs → tests` with projects and
596
+ annotations (the `skip` reason exists only there). The format is detected by
597
+ the file's content, not its extension; several reports are merged by test
598
+ key.
599
+ - `bun test` has no JSON report but produces JUnit XML (`--reporter=junit`):
600
+ a file is a `testsuite` with `name = file`, a describe is a nested
601
+ `testsuite` with a `file` attribute, a test is a `testcase`; `<skipped/>` —
602
+ skipped, `<skipped message="TODO"/>` — todo. A hook (`beforeAll`,
603
+ `afterAll`) of a skipped describe bun writes as a skipped `testcase`
604
+ `(unnamed)` without `line` — not a test, it is dropped; a failed hook stays
605
+ a failing test. The XML is parsed with regular
606
+ expressions and a suite stack: Bun and Node have no XML parser without
607
+ dependencies, and the format is simple. The entities `&amp;`, `&lt;`, `&#…;`
608
+ are expanded.
609
+ - pytest and Python are deliberately not supported: the stack is TypeScript
610
+ and Bun (issue #27, question 1).
611
+ - Reports are temporary files, in `.gitignore`. A report from CI with someone
612
+ else's absolute paths works: the path is normalized by the `/tests/`
613
+ segment. The runner itself merges shards (blob reports → one JSON); don't
614
+ pass shard reports separately — Playwright splits a file between shards, and
615
+ the order of tests in a file would depend on the order of the reports (the
616
+ "Wiring into the project's CI" section below).
617
+
618
+ ## spec-diff: static parsing, not two reports
619
+
620
+ - A report needs a run on two revisions: checking out the base (or a second
621
+ worktree), installing dependencies, a full run — minutes and side effects,
622
+ while only the names are needed. Static parsing takes files via `git show
623
+ <rev>:<path>` in seconds and works in CI on a PR without a second run.
624
+ - The price is a parser instead of the runner: a name that can't be computed
625
+ without a run (a template with substitution, a variable) doesn't go into the
626
+ lists — it wouldn't match the name from a report — but is written as a line
627
+ to stderr with its location; `it.each` is shown with its placeholder. Under
628
+ the rule that `it` names are statements in the project language this is
629
+ rare, and `describe`/`it` with string literals are parsed reliably.
630
+ - The source is parsed by a parser — `@babel/parser` as a file in the skill
631
+ (`scripts/vendor/babel-parser.cjs` — the package's `lib/index.js`: one file
632
+ without `require`, the MIT license next to it, types —
633
+ `babel-parser.d.cts`). `speclib.ts` takes it with a static `import`, without
634
+ `import.meta`: Playwright and Jest in a project without `"type": "module"`
635
+ load the harness as CommonJS (#303). The home-grown lexer guessed where a
636
+ regex was and where a division, where a template, a comment and a type
637
+ argument, and on a mistake silently lost the file's tests (#267, #269, #271,
638
+ #275): TypeScript syntax is wider than any guess (#277). Rejected: the
639
+ project's TypeScript — 7.x (the Go port) has no in-process
640
+ `createSourceFile`, only an unstable API via the tsgo process, and the
641
+ synchronous variant doesn't work under Bun; `oxc-parser` — a native platform
642
+ binary; the parser as a project devDependency — the `spec-diff` job in the
643
+ project's CI would install dependencies, and its whole point is being light.
644
+ To update — `npm pack @babel/parser`: `lib/index.js` without the last
645
+ `//# sourceMappingURL` line (there is no map next to it — Vitest looks for it
646
+ and prints an error) → `vendor/babel-parser.cjs`, `LICENSE` →
647
+ `vendor/babel-parser.LICENSE`, the version — in a comment in `speclib.ts`.
648
+ - Plugins — by extension: `.ts` without JSX (`<T>x` is a type cast), `.tsx`
649
+ and JS — with JSX, an unknown file (`codeOnly(text)`) — TypeScript, then
650
+ TSX; decorators — both after `export` and on parameters. All arguments of a
651
+ `describe` after the name count as its body; for `.each`, `.for`, `.runIf`,
652
+ `.skipIf` the name is on the second call. A file that can't be parsed is an
653
+ error with its location, not an empty test list.
654
+ - There is one parse — `parseCode` in `speclib.ts`: it serves the name
655
+ scanner, the harness (ESLint comments, `codeOnly`, lines of neighboring
656
+ files) and the «Harness checks» section of `spec-diff` —
657
+ the `registry` option of `invariant` and `rule` of `examples` in the call
658
+ arguments (`callOptions`); regexes over the text took a fixture in a string
659
+ and an invariant's `rule` for a check (#289). Copies of the hand-written
660
+ traversal diverged: an NBSP before a regex was code to the scanner and
661
+ whitespace to the harness (#271); now JSX text `// …` is code for both, and a
662
+ comment inside a template's `${…}` is a comment.
663
+
664
+ ## A page is a story (#63)
665
+
666
+ - The first version put the headers of all the folder's files under the
667
+ capability heading, sections in alphabetical path order, all tests as an
668
+ expanded list. On a project that moved to a test spec from another format,
669
+ the page became a patchwork quilt: notes to the developer from every file
670
+ ("here is a pure function…"), sections by function names, a detail first,
671
+ a hundred and fifty lines of tests hiding what the capability can do. The
672
+ page must answer three questions: why, what it can do, what checks it.
673
+ - **The story is in the main file, named after the folder** (`<name>.test.ts`
674
+ for a capability, an architecture rule and a standard). A standard used to
675
+ have `rule.test.ts` — "the file by rule"; two rules instead of one, and the
676
+ standard's folder has a name anyway — now there is one (elves#72). The
677
+ place is known in advance: a code reader and an agent find the story without
678
+ searching, and the page has a definite beginning — the `describe` order of
679
+ the main file. Rejected: "any single file, `--strict` catches a second one" —
680
+ the story's beginning would depend on where it was written.
681
+ - **The decision's description is `<folder>.md`, not a JSDoc header of the
682
+ main file (elves#91).** A README next to the tests used to be rejected as a
683
+ second source of meaning. The "what it can do" meaning is still only in the
684
+ test names; the md holds what a test doesn't express — why, the reason for
685
+ the decision, rejected alternatives (formerly `//`, invisible in the spec) —
686
+ and GitHub renders it as is, with headings and diagrams. It is checked
687
+ mechanically: no md — a `--strict` error; a header on a test file — one too
688
+ (a second source of the introduction).
689
+ - **A test file's header is dropped, not glued in:** gluing is what made the
690
+ quilt (#63). `--strict` makes it a CI error; the text moves into
691
+ `<folder>.md` or into `//`.
692
+ - **Order — the main file first:** alphabetical path order put a random
693
+ detail first (`billing-vat` before `billing`). The other files follow by
694
+ path, identical `describe`s merge: unit and e2e tests of one capability are
695
+ one section.
696
+ - **Monorepo — the main file in a package subfolder too (elves#301).** A
697
+ decision's tests sit in packages (`export/api/`, `export/web/`): each package
698
+ has its own runner — aliases, environment, plugins — while the decision and
699
+ the page are one. Only a file directly in the folder counted as main, and
700
+ such a folder "had no" main file. Now the main file is `<name>.*` at the
701
+ folder root or in a package subfolder, one level deep; order — the root, then
702
+ packages by name: the decision's cross-cutting statement opens the page.
703
+ Rejected: a main file at the root run by a shared runner without the
704
+ package's aliases and environment — for tests of package code it is a dummy
705
+ just to pass the check.
706
+ - **Tests are collapsed into `<details>`:** the story reads through headings
707
+ and prose, the proof opens on click. Failing and skipped tests aren't
708
+ hidden — a counter in the block's line, the block expanded. Rejected: an open
709
+ list — with a hundred tests the evidence hides the story; a counter alone
710
+ without the lines — test names are the requirements, they must be in the
711
+ documentation and in its diff in the PR. GitHub renders markdown inside
712
+ `<details>` if there is a blank line after `<summary>` and before
713
+ `</details>`.
714
+
715
+ ## Prose from JSDoc
716
+
717
+ - A test name is a requirement, but the folder name (`est`) and a `describe`
718
+ ("Calibration of k") don't say what it is and why. Prose is needed, and it
719
+ lives next to the test: a separate hand-written document about behavior
720
+ drifts from the tests, and the "Specification — decisions" rule (`canon.md`)
721
+ doesn't allow such a document.
722
+ - JSDoc, not `//`: the split is already established in TypeScript — `/** */`
723
+ describes for the consumer (editor hints, generated docs), `//` is a note for
724
+ the code reader. Reasons for decisions and rejected alternatives stay in `//`
725
+ and don't leak into the documentation.
726
+ - Runner reports carry no comments, so `spec-doc` reads the sources with the
727
+ same scanner as `spec-diff` and links prose to a test by the chain of names.
728
+ A separate description file in the capability folder (a README next to the
729
+ tests) was rejected: a second source of the same meaning that nobody checks
730
+ against the tests.
731
+ - `spec-diff` doesn't compare prose: the requirement is the test name; a prose
732
+ edit shows in the diff of the test's JSDoc.
733
+ - A test's prose is a quote inside the list item: a paragraph after a blank
734
+ line would make the list "loose" (spacing between all items), and a line
735
+ without markup would merge with the name into one paragraph.
736
+ - There is no "section · path · N tests" line under the heading: the section
737
+ and path follow from the convention, the test count and problems are in the
738
+ collapsed block's line; the index keeps only skipped and failing — what needs
739
+ looking at.
740
+
741
+ ## Running — Bun
742
+
743
+ - `bun script.ts`: TypeScript without a build and without a `package.json`
744
+ next to it — the skill lives in `~/.claude/skills` or `~/.agents/skills`,
745
+ where nothing can be built. There are no dependencies: a TypeScript parser
746
+ is kept as a file in the skill — `scripts/vendor/` (the "spec-diff: static
747
+ parsing, not two reports" section above).
748
+ - Bun is the scripts' only runtime, a Node project included: its
749
+ `package.json` calls them via `bun`, its CI adds the `oven-sh/setup-bun@v2`
750
+ step. Rejected (#405): the scripts also under Node, so that a Node project's
751
+ CI calls them without Bun — two runtimes for every script, only their common
752
+ subset of APIs and a byte-for-byte comparison of the output in the tests,
753
+ while the flow needs Bun anyway (the installer, the other skills' scripts).
754
+ - The modules a project's tests and config import — `harness.ts`,
755
+ `architecture.ts`, `eslint-config.ts` and `speclib.ts` behind them — load
756
+ under the project's own runner on Node (Vitest, Jest, Playwright, ESLint), not
757
+ under Bun: only `node:` APIs, erasable syntax (no `enum`, `namespace` or
758
+ parameter properties, imports with `.ts`), no `import.meta` and no top-level
759
+ `await` — Playwright and Jest in a project without `"type": "module"` load
760
+ them as CommonJS. The guard is `tests/standards/harness-load`: a real
761
+ Playwright in a temporary project, CommonJS and ESM.
762
+ - The skill's tests — `bun test` (imports from `bun:test`): runner overhead of
763
+ 0.01 s versus 0.3 s for Vitest per file. Scripts in the tests are run via
764
+ `bun` — what the user runs. Node in the CI of Elves is set to 24
765
+ (`actions/setup-node`): `harness-load` and the harness tests run a project's
766
+ Node on it — Playwright, Node's own loader, `node:test`.
767
+
768
+ ## Wiring into the project's CI
769
+
770
+ - **The scripts' source is the project's package version** (#552). The agent
771
+ before a PR and CI need one version: the `docs/spec` format changes (as it
772
+ did in #32), and documentation built by the skill from a fresh Elves
773
+ wouldn't match CI on another version. `@miroshnik/elves` in the project's
774
+ `devDependencies` pins it: `bun install` brings the same package to the
775
+ agent, CI, a colleague and a cloud session (npm works there, `github:` is
776
+ 403); an update raises the version — a few lines in the current task's
777
+ branch. The same package is the flow of the project — there is no separate
778
+ path for CI.
779
+ - Node doesn't strip types in `node_modules`
780
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), and Playwright and Jest
781
+ don't transpile it: the package carries a build for Node — `elves pack`
782
+ builds the modules of `exports` into `dist/` on publishing (#556); the
783
+ `default` condition leads a runner on Node there, `types` and `bun` — to
784
+ the TypeScript source (tests/standards/harness-load).
785
+ - Rejected:
786
+ - a copy of the skill in the project (`.agents/skills/spec`, the install of
787
+ earlier releases): every update is a PR of thousands of lines and a task of
788
+ its own, plus exceptions in `.gitignore`, linters and formatters (#552);
789
+ - an Elves submodule: an extra step in every clone and worktree
790
+ (`git submodule update --init`), and its tests and code arrive in the
791
+ project, where the project's Vitest and ESLint pick them up;
792
+ - an Elves checkout in the workflow (`repository` + `ref: <SHA>`) pins only
793
+ CI: the agent locally calls the skill of another version.
794
+ - The skill's own directory (the machine, a clone) has
795
+ `scripts/package.json` with `"type": "module"`: in a project with
796
+ `"type": "commonjs"` Node's own loader (the project's `eslint.config.*`,
797
+ `node --test`) would take its ESM `.ts` files for CommonJS.
798
+ - **The runner merges shards.** `spec-doc` merges several reports but takes
799
+ the order of tests in a file by first appearance. Playwright with
800
+ `fullyParallel` splits a file between shards, and the line order would
801
+ depend on the order of reports on the command line (`shard-10` before
802
+ `shard-2` in a glob) — the published `docs/spec` would change without a test
803
+ edit. Blob reports → `merge-reports` → one JSON in declaration order; Vitest
804
+ shards by file, but the recipe is the same — one for both runners. The result
805
+ is verified: 3 Vitest shards and 3 Playwright shards with two projects give
806
+ `docs/spec` byte for byte the same as a run without shards.
807
+ - **`spec` after the tests, `spec-diff` separately.** Building and publishing
808
+ `docs/spec` from a red run is pointless (❌ in the documentation), hence
809
+ `needs`. `spec-diff` reads only git — a separate job without installing
810
+ dependencies posts its section to the summary even when tests failed.
811
+
812
+ ## Publishing — the `spec` branch, not `main`
813
+
814
+ - **`docs/spec` is a derived copy of the tests.** In `main`, every branch
815
+ regenerated the same pages and the table of contents: parallel PRs
816
+ conflicted on every rebase, CI guarded "not behind", the agent regenerated
817
+ and committed before every PR. The delta for review is already given by
818
+ `spec-diff` in the PR body, a prose edit — by the diff of the test's JSDoc.
819
+ - **The `spec` branch, written only by CI after the merge.** It is readable
820
+ on GitHub and by the agent (`git show origin/spec:README.md`), there are no
821
+ conflicts, every publication has `Source: <SHA main>`. The "reached the
822
+ reader" check isn't "the push went through", but the branch tree on the
823
+ remote equals the built directory.
824
+ - **A merge that didn't change `docs/spec` is a commit too:** the same tree,
825
+ a new `Source:`. The branch names the last checked `main`, and a `Source:`
826
+ older than the diff base in `spec-diff` means a skipped publication. Without
827
+ a commit, the gap line would come after every such merge and would stop
828
+ being read; the price is a commit in the `spec` branch per merge. A repeat
829
+ from the same source makes no commit.
830
+ - Rejected: GitHub Pages — not on every plan for a private repository; a CI
831
+ artifact — lives for days and the agent can't read it from git; a bot commit
832
+ to `main` — the same conflicts with open PRs and extra commits in history.
833
+ - The write token is only in the publishing job: it doesn't install the
834
+ project's dependencies and doesn't run on PR runs.
835
+ - **No CI on `main` — the artifact of the PR run that checked the merge
836
+ tree.** A project whose whole CI runs on PRs publishes, on PR merge, what
837
+ was built by the run whose tree (merge-ref) equals the merge commit's tree:
838
+ nothing was merged into `main` between the run's start and the merge — this
839
+ `main` is checked in full. A PR that fell behind is merged — the trees
840
+ differ, the publication is skipped: `spec` lags until the next merge with a
841
+ matching tree but doesn't roll back, and `spec-diff` names the gap under
842
+ "Base". `Source:` is the merge SHA: with squash and rebase the head
843
+ doesn't land in `main`'s history, and `spec-diff` looks for the publication
844
+ among merge-base's ancestors. Rejected: tests on merge — that is CI on
845
+ `main`; publishing from every PR run — unmerged work would land in `spec`;
846
+ publishing from the run of the behind PR's head — `spec` would lose the
847
+ pages of a PR merged earlier.
848
+
849
+ ## Identity and renames
850
+
851
+ - A test key is the folder path + the `describe` chain + the name. The file
852
+ isn't part of the key: the rule treats the folder as the spec's unit, and
853
+ moving tests between files of one capability doesn't change requirements.
854
+ - A rename is searched for in two steps: (1) the same file and `describe`, a
855
+ similar name, similarity ≥ 0.6 by Ratcliff/Obershelp (like
856
+ `difflib.SequenceMatcher.ratio`), greedily by descending similarity; (2) the
857
+ same folder and the same name, a different `describe` — the `describe` was
858
+ renamed or the test was moved to another section, including from another
859
+ file. The threshold is deliberately high: hiding a removed requirement as a
860
+ rename is worse than showing a rename as a deletion and an addition. Step 2
861
+ isn't limited to one file since #63: the requirement text is the same, and
862
+ regrouping into the main file's capability sections (unit and e2e of one
863
+ capability under one `describe`, files renamed to `<name>.test.ts`) would
864
+ otherwise give hundreds of "deleted".
865
+ - Merge-base by default — so the diff matches what the PR shows on GitHub, and
866
+ tests added to `main` in parallel don't look deleted.
867
+
868
+ ## Determinism
869
+
870
+ - No timestamps, durations or absolute paths in the output; order — folders
871
+ by name, files by path, tests in declaration order (the report gives them in
872
+ that order within a file; Vitest in concurrent mode doesn't — then the order
873
+ is taken from the report as is).
874
+ - Identical tests from several reports (two Playwright projects, two runs) are
875
+ merged; the status is the worst (`failed` > `passed` > `todo` > `skipped`,
876
+ "all skipped" — skipped).
877
+ - A marker on the file's first line is the only sign of "this is my file": the
878
+ script deletes only such files and doesn't touch hand-written ones in the
879
+ same directories.
880
+
881
+ ## What is not done
882
+
883
+ - No per-repository configuration: the `tests/` tree from the rule, the output
884
+ directory `docs/spec`, report formats — all default; they can be changed
885
+ only with flags (`--root`, `--out`, `--base`).
886
+ - No judging of tests: failing/skipped is what the runner said, the script only
887
+ shows it.
888
+ - No generation of reference documentation (schema, routes, environment) —
889
+ those are other generators, from code, not from tests.