@asrunjs/asrun 0.14.0 → 0.15.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 (57) hide show
  1. package/.claude/commands/explore-case.md +8 -3
  2. package/.claude/skills/exploring-cases/SKILL.md +47 -4
  3. package/.claude/skills/exploring-cases/references/notes.md +42 -4
  4. package/.claude/skills/generating-specs/SKILL.md +10 -3
  5. package/AGENTS.md +9 -2
  6. package/README.md +13 -1
  7. package/dist/lib/explore-notes.d.ts +15 -0
  8. package/dist/lib/explore-notes.js +34 -1
  9. package/dist/lib/migrations.d.ts +43 -4
  10. package/dist/lib/migrations.js +39 -7
  11. package/dist/lib/scaffold.d.ts +87 -0
  12. package/dist/lib/scaffold.js +220 -0
  13. package/dist/lib/term.d.ts +36 -0
  14. package/dist/lib/term.js +77 -0
  15. package/dist/lib/update-check.d.ts +1 -7
  16. package/dist/lib/update-check.js +3 -15
  17. package/dist/lib/upgrade-prompt.d.ts +84 -0
  18. package/dist/lib/upgrade-prompt.js +233 -0
  19. package/dist/migrations/index.d.ts +12 -4
  20. package/dist/migrations/index.js +40 -5
  21. package/dist/migrations/manifest.json +27 -1
  22. package/dist/scripts/build.js +4 -1
  23. package/dist/scripts/cli.js +2 -1
  24. package/dist/scripts/doctor.js +34 -3
  25. package/dist/scripts/explore-notes.js +19 -2
  26. package/dist/scripts/init.d.ts +6 -26
  27. package/dist/scripts/init.js +82 -101
  28. package/dist/scripts/migrate.js +84 -21
  29. package/dist/scripts/migrations-check.d.ts +1 -0
  30. package/dist/scripts/migrations-check.js +272 -0
  31. package/dist/scripts/migrations-freeze.d.ts +1 -0
  32. package/dist/scripts/migrations-freeze.js +126 -0
  33. package/dist/scripts/upgrade.js +53 -10
  34. package/dist/ui/server/app.js +4 -0
  35. package/dist/ui/shared/types.d.ts +6 -0
  36. package/docs/agents/case-format.md +6 -0
  37. package/docs/agents/framework-dev.md +44 -5
  38. package/docs/ai-workflow.md +16 -3
  39. package/docs/commands.md +53 -14
  40. package/docs/install.md +17 -4
  41. package/docs/web-ui.md +16 -4
  42. package/package.json +4 -1
  43. package/templates/.claude/commands/explore-case.md +6 -1
  44. package/templates/.claude/commands/upgrade-workspace.md +21 -0
  45. package/templates/AGENTS.md +1 -1
  46. package/ui/dist/assets/{dist-BlFnpAOk.js → dist-B4pM62Hc.js} +1 -1
  47. package/ui/dist/assets/{dist-BzDmxZ9V.js → dist-BFADGd4p.js} +1 -1
  48. package/ui/dist/assets/{dist-DxlXH2E-.js → dist-Bd-TmWTD.js} +1 -1
  49. package/ui/dist/assets/{dist-CjFzi6xl.js → dist-Bt053MSo.js} +1 -1
  50. package/ui/dist/assets/{dist-BwXTr_k4.js → dist-CD9RixNE.js} +1 -1
  51. package/ui/dist/assets/{dist-C1uAVsF-.js → dist-ChACtLiA.js} +1 -1
  52. package/ui/dist/assets/{dist-D7YBl9nc.js → dist-CiW9sEPF.js} +1 -1
  53. package/ui/dist/assets/{dist-jGvx4PBr.js → dist-CxEpzoZU.js} +1 -1
  54. package/ui/dist/assets/{dist-DxeJFPaO.js → dist-Dh7um7By.js} +1 -1
  55. package/ui/dist/assets/{dist-fpvlcqX7.js → dist-gHwKkZ6g.js} +1 -1
  56. package/ui/dist/assets/{index-Djsj43vv.js → index-Ckax27uf.js} +157 -157
  57. package/ui/dist/index.html +1 -1
@@ -8,14 +8,19 @@ Rules combine in this order, and a later one wins where they conflict: the frame
8
8
  framework skill, `.claude/skills/exploring-cases.md` if it exists, then the workspace `AGENTS.md` §2 in `examples/demo`
9
9
  (Project rules). F1–F6 and R2 always win. The effective set is what `pnpm asrun rules` prints; §2 is applied as
10
10
  written and is never turned into a question (a difference from a framework default is not a conflict).
11
- Arguments: `<case-id> <env> [--attended] [--profiles a,b] [--hints <file>]`. The case id is the path below cases/ without `.md` (it may include a group
11
+ Arguments: `<case-id> <env> [--attended] [--profiles a,b] [--lead <profile-id>] [--hints <file>]`. The case id is the path below cases/ without `.md` (it may include a group
12
12
  folder, e.g. documents/landing-page) and is required. `<env>` is `staging` or `production`; it is required and there is no
13
13
  default (without one, stop and say so). With `--attended` I am at the machine and the browser window is visible: when a step
14
14
  writes data or needs a choice, hand it to me as the skill describes, wait for me, and record what I did as a general
15
15
  recipe under the step it belongs to (`- Recipe:` lines). Without `--attended`, never wait for me.
16
16
  `--profiles a,b` explores only those profiles (comma-separated ids from profiles.yaml); without it, at least one
17
- logged-in profile per domain. A profile the list leaves out is not opened and nothing is written about it: runs add up,
18
- so a later run covers the rest.
17
+ logged-in profile per domain. Every id in that list is opened in its own session (`generator_setup_page` with
18
+ `project=<id>`); two different hosts are two domains, even for the same product. A profile the list leaves out is not
19
+ opened and nothing is written about it: runs add up, so a later run covers the rest.
20
+ `--lead <profile-id>` explores the selection asymmetrically: that profile in full, every other selected profile only
21
+ where it can differ (its own `## Values` entries, its target screenshots and the steps that reach them), as the skill's
22
+ "Profile passes" says; a profile whose page or flow differs is explored in full and the report says so. Keep lead and
23
+ diff profiles in one run, because the notes are written at its end.
19
24
  `--hints <file>` (or free text after the arguments) is what I know or want checked; read it first. After the work, a
20
25
  reusable instruction goes into `## Rules` by the gate in `references/case-rules.md`; a one-off clue stays in the report.
21
26
  Read `## Rules` before the steps and apply it. Follow `case-rules.md` (a rule says how to run the case, never what it
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: exploring-cases
3
- description: Explores the real pages for one case document (cases/<id>.md) before its spec is generated, for the profiles named with --profiles or, without it, for at least one logged-in profile per domain. Checks the case against profiles.yaml and the pages, fixes wording mistakes, records what the pages show, turns unknowns into answerable TODO blocks, and, in an attended run, lets the person do the steps that write data or need a choice while it waits and records a general recipe under that step's `- Recipe:` lines. Also writes a local exploration note (locators and data-call paths) under `.explore/` for `/gen-spec` to verify instead of rediscovering. Ends with RESULT ready or needs-input. Use when asked to explore, review, check, clean up or narrow down a case, or for /explore-case. Its open-question rules are shared with generating-specs.
3
+ description: Explores the real pages for one case document (cases/<id>.md) before its spec is generated, for the profiles named with --profiles or, without it, for at least one logged-in profile per domain. With --lead <id> one profile is explored in full and the others only where they can differ. Checks the case against profiles.yaml and the pages, fixes wording mistakes, records what the pages show, turns unknowns into answerable TODO blocks, and, in an attended run, lets the person do the steps that write data or need a choice while it waits and records a general recipe under that step's `- Recipe:` lines. Also writes a local exploration note (locators and data-call paths) under `.explore/` for `/gen-spec` to verify instead of rediscovering. Ends with RESULT ready or needs-input. Use when asked to explore, review, check, clean up or narrow down a case, or for /explore-case. Its open-question rules are shared with generating-specs.
4
4
  ---
5
5
 
6
6
  # Exploring a case document
@@ -60,6 +60,11 @@ one exception is a handoff in an attended run, where waiting for the person is t
60
60
  selection is never widened: a profile it leaves out is not opened and nothing about it is written into the case.
61
61
  Runs add up: a later run with other profiles adds their observations and `## Values` entries next to the ones
62
62
  recorded before.
63
+ - Optionally `--lead <profile-id>`: explore the profiles of this run asymmetrically. The lead profile (one of the ids
64
+ this run explores, normally the first) is explored **in full**; every other selected profile gets the **diff pass**
65
+ of step 4: only what can differ on it, plus what reaches it. Without the flag every profile is explored in full.
66
+ Lead and diff profiles belong in **one run**: the case edits of a diff pass would make a note written before them
67
+ stale, so a split run falls back to a replay instead of the notes.
63
68
  - Optionally `--hints <file>` (or free text after the arguments): what the person knows or wants checked. Read it
64
69
  before the static review. It guides this run; after the work, a reusable instruction is written into `## Rules` by
65
70
  the gate in `references/case-rules.md`, and a one-off clue stays in the report.
@@ -102,6 +107,31 @@ one exception is a handoff in an attended run, where waiting for the person is t
102
107
  and record nothing from the wrong page. A different host is a different domain even when both profiles serve the
103
108
  same product, so two named profiles mean two sessions, never one. Finish every selected profile before you start
104
109
  writing the case.
110
+ **Profile passes (`--lead`).** Without the flag every selected profile is explored in full. With `--lead <id>` the
111
+ lead profile is explored in full (every step, every handoff, every recipe, every target, a note with every step) and
112
+ each other selected profile gets the **diff pass**:
113
+ - **Confirm the structure first.** Open the profile and check that it is the same application as the lead's: the same
114
+ header, navigation, page layout and components around each differing check. A different host is no reason to skip
115
+ this profile, and no reason to assume it matches: it is the same app with another address, style or dataset. A page,
116
+ a control or a step that differs from the lead's, and any profile whose flow needs another step, makes that
117
+ profile a **full pass** — explore it as if `--lead` had not been given, and say so in the report.
118
+ - **Do only the differing steps** and the read-only navigation and `Ready when` signals that reach them. A step
119
+ differs when (a) a value it asserts comes from that profile's own `## Values` entry, (b) it reads a `[T<n>]` whose
120
+ card needs this profile's `Reference <profile-id>:`, (c) a `## Rules` bullet tied to that step says so, or (d) its
121
+ page differs from the lead's. When in doubt whether a step can differ, run it: the diff pass never assumes.
122
+ - **A write step that a differing check needs is still a handoff** on that profile: data of a test account or a
123
+ tenant is usually private to it. Hand off only what reaches the check (create, observe, clean up, in that order),
124
+ and record the observation from the card's `recorded` result as on any profile.
125
+ - **Record the difference, not the flow**: one observation per differing check, one `Reference <profile-id>:`
126
+ screenshot for each target card it confirms, and that profile's own `## Values` entries. Every other step stays
127
+ as the lead observed it and is reported as `observed on <lead>, not verified on <profile>`; do not write a second
128
+ copy of the steps, a marker or a TODO about them.
129
+ - **What it cannot reach is a gap, not an assumption**: a differing check whose page cannot be opened gets a
130
+ `TODO [blocking]` under the step that needs it, never "same as the lead".
131
+ - **A step whose recipe would differ per profile** (another editor, another dialog, an extra step) is explored in
132
+ full on that profile, and reported: the case's `- Recipe:` lines are per step, not per profile, so a single recipe
133
+ cannot describe both. When the difference is real, put a `❓ TODO [non-blocking]` under the step asking whether the
134
+ case should be split; never rewrite the lead's recipe to fit one profile.
105
135
  - Do the read-only steps yourself: open, look, read, hover, switch tabs, open and close dialogs and menus.
106
136
  - **A step that writes or needs a choice is done by the person**, per `references/user-handoff.md`: announce it
107
137
  with one sentence plus one action per entry of `steps` and the finish condition in `finish`, run the wait loop
@@ -131,7 +161,8 @@ one exception is a handoff in an attended run, where waiting for the person is t
131
161
  in the five parts of `references/readiness.md`, "Downloads", and record the file name, size and kind. Render a
132
162
  downloaded PDF (`pnpm pdf:render`), open its page images and record which pages show the target, where, and the
133
163
  text; the pages follow the case (every page unless the case says ONLY).
134
- - Compare every step with what is really there.
164
+ - Compare every step with what is really there. On a diff pass, that means the steps the pass opened: the rest stay
165
+ as the lead observed them.
135
166
  - **Record the exploration note** for this profile as you go, in the shape of `references/notes.md`: the locator of
136
167
  each step's main control, the data-call paths of each loading action, and each target it confirmed. It is local
137
168
  cache under `.explore/`, never case content, and `/gen-spec` reads it; step 8 writes the file.
@@ -147,7 +178,8 @@ one exception is a handoff in an attended run, where waiting for the person is t
147
178
  (`testDataName`); the recipe records how to build it, never the stamp itself. A step that was not handed off gets
148
179
  no recipe line and a `[blocking]` TODO. A case
149
180
  that still has the old `## Inputs` section: move the lines of the steps you touch under those steps as
150
- `- Recipe:`; `pnpm case:check` names the rest.
181
+ `- Recipe:`; `pnpm case:check` names the rest. A diff pass normally adds no recipe line: the step's recipe from the
182
+ lead pass still describes it. When the pass had to write and the step has no recipe yet, write one from that handoff.
151
183
  7. **Resolve every open point** in the order of `references/open-questions.md`: answer it from the page, else apply
152
184
  the safe default as the sentence above a `[non-blocking]` TODO block, else keep a `[blocking]` block. Write the
153
185
  outcome into the case, every block with an empty `Answer:` line. Then mark the facts you wrote: a fact the run
@@ -172,8 +204,14 @@ one exception is a handoff in an attended run, where waiting for the person is t
172
204
  was not skipped for its login was not explored: write a `TODO [blocking]` under the first step it affects (a person
173
205
  re-runs `/explore-case` with that profile) and say so in the report; never report a run as complete for a profile
174
206
  that was not opened. A profile skipped because it is not logged in gets no note and is named as skipped instead.
207
+ **A full note covers every step; a diff note covers the steps its pass opened** and names the lead in `depth: "diff"`
208
+ and `basedOn: "<lead profile-id>"` (`references/notes.md`, "Coverage"). A diff note is complete as it is: do not add
209
+ the lead's steps to it, and do not report the profile as unexplored. `pnpm asrun explore:notes <case-id> --check`
210
+ warns when a diff note's lead has no note of its own.
175
211
  9. **Report** in the user's language: what was changed (wording fixes, observations added, TODOs closed or narrowed),
176
- what was explored (profile ids), which profiles `--profiles` left out (and that a run with them covers the rest),
212
+ what was explored (profile ids), which profile was explored in full and which in a diff pass (with the steps each
213
+ diff pass opened, which checks stay `observed on <lead>, not verified on <profile>`, and any profile that turned out
214
+ to need a full pass and why), which profiles `--profiles` left out (and that a run with them covers the rest),
177
215
  which steps the person did and what was recorded for them, what the exploration note covers (and what it does not),
178
216
  what was skipped and
179
217
  why, the target cards written (and which ones are still questions), the steps whose first read was too early,
@@ -196,6 +234,11 @@ one exception is a handoff in an attended run, where waiting for the person is t
196
234
  check asserts are written into the case's `## Values` under the profile id (or `default` when they hold for every
197
235
  profile), and never into `profiles.yaml`.
198
236
  - Never explore a profile that `--profiles` leaves out, and never widen the selection to cover a gap in the case.
237
+ - A diff pass never assumes: a page, a control or a step that differs from the lead's makes that profile a full pass,
238
+ and a differing check it cannot reach is a `TODO [blocking]`, never "same as the lead".
239
+ - The diff pass covers values, targets and the pages that reach them. A step whose recipe differs per profile is
240
+ explored in full on that profile and reported (a `- Recipe:` belongs to a step, not to a profile), never folded into
241
+ the lead's recipe.
199
242
  - Never pick a target when in doubt, and never record a fact that was read before the content finished loading.
200
243
  - Never read or print anything under `.auth/`. Never automate login or 2FA.
201
244
  - Prose follows the artifact language of the workspace `AGENTS.md` §2 (English by default); structural tokens stay English.
@@ -2,7 +2,8 @@
2
2
 
3
3
  Local, git-ignored and optional. `exploring-cases` writes it; `generating-specs` (and healing) read it to **verify**
4
4
  instead of rediscovering the page. It is not part of the case and never becomes part of it: expected values stay in
5
- `## Values`, behaviour in `## Expected`, ready signals in `Ready when:`, and assertions never appear here.
5
+ `## Values`, behaviour in `## Expected`, ready signals in `Ready when:`, and assertions never appear here. A note is
6
+ **full** (every step of the case) or **diff** (only the steps a `--lead` diff pass opened); see "Coverage".
6
7
 
7
8
  ## Where it lives
8
9
 
@@ -36,13 +37,47 @@ that is a normal state: generation falls back to replaying the case.
36
37
  ```
37
38
 
38
39
  - Keys of `steps` are the case's step numbers, keys of `targets` are its card ids (`T1`).
39
- - `steps` may be `{}` and `targets` may be omitted; every other key is required (`env` and `updatedAt` are optional).
40
- No other field is accepted.
40
+ - `steps` may be `{}` and `targets` may be omitted; every other key is required (`env`, `updatedAt`, `depth` and
41
+ `basedOn` are optional). No other field is accepted.
41
42
  - `caseSha256` is the 64-character hash `pnpm asrun explore:notes <case-id> --hash` prints (not the placeholder above).
42
43
  - `pnpm asrun explore:notes <case-id> --check` validates the files: it fails on a malformed note and warns on a note
43
44
  whose `caseSha256` no longer matches the case (the summary already prints it as `stale`; re-run the exploration, or
44
45
  delete the note and let the case be replayed). A file that fails is ignored by `/gen-spec`.
45
46
 
47
+ ## Coverage
48
+
49
+ A note is either **full** or **diff**:
50
+
51
+ - **full** (the default, and what a note without `depth` is): every step of the case is in `steps`. `/explore-case`
52
+ writes one for a profile it explored without `--lead`.
53
+ - **diff**: the note belongs to a profile that an `--lead` run explored only where it can differ. `depth` is
54
+ `"diff"` and `basedOn` names the lead profile whose note covers the steps this one did not open. `steps` holds only
55
+ the steps the diff pass opened; `targets` holds the cards it confirmed on that profile.
56
+
57
+ ```json
58
+ {
59
+ "version": 1,
60
+ "caseId": "conduit/header-logo",
61
+ "profile": "conduit-b",
62
+ "env": "staging",
63
+ "caseSha256": "<the same hash as the lead's note>",
64
+ "capturedAt": "2026-11-10T08:12:04Z",
65
+ "depth": "diff",
66
+ "basedOn": "conduit-a",
67
+ "steps": {"4": {"url": "/profile/asrun-b", "locators": [{"purpose": "the profile heading", "locator": "page.getByRole('heading', {name: 'asrun-b'})"}]}},
68
+ "targets": {"T1": {"locator": "page.getByRole('img', {name: 'Conduit B'})", "matches": 1}}
69
+ }
70
+ ```
71
+
72
+ Rules for a diff note:
73
+ - `basedOn` is required (`explore:notes --check` fails without it) and must be a profile, not a case or a URL.
74
+ - It is **complete as it is**. `/gen-spec` writes the steps the note does not hold from the lead's note, and reports
75
+ them as `observed on <lead>, not verified on <profile>`. Never pad it with steps copied from the lead's note.
76
+ - A diff note whose lead has no note of its own (or whose lead note is itself a diff note) is a warning, not a failure:
77
+ generation replays the steps neither note holds. A profile that a diff pass turned into a full pass gets a normal
78
+ full note.
79
+ - `caseSha256` is the same for every note of one run: they are all written after the last case edit.
80
+
46
81
  ## What goes in
47
82
 
48
83
  `steps.<n>`
@@ -53,6 +88,8 @@ that is a normal state: generation falls back to replaying the case.
53
88
  - `requests`: the data-call paths the step's action started, taken from `browser_network_requests` above the marker
54
89
  (`tools.md`, "Network requests without a clear"). Path only: `/api/articles`, never a host, a query string or a token.
55
90
  Images, fonts and analytics are not data calls.
91
+ - A diff note holds only the steps its pass opened; the steps it did not open come from `basedOn` and are not copied
92
+ here.
56
93
 
57
94
  `targets.T1`
58
95
  - `locator`: the card's `Identified by`, confirmed on the page.
@@ -76,7 +113,8 @@ the name of the item, because the data changes between runs. The same rule as a
76
113
  ## Freshness and the write-back
77
114
 
78
115
  - `caseSha256` is the sha256 of `cases/<case-id>.md` at the moment the note was written. If the case changes after that,
79
- the note is **stale**: `/gen-spec` replays the case and does not use it.
116
+ the note is **stale**: `/gen-spec` replays the case and does not use it. Every note of one run shares the hash, because
117
+ `/explore-case` writes them after its last case edit; a later run that edits the case makes them stale together.
80
118
  - `/gen-spec` confirms an entry of a fresh note on the page and may write the verified locator or request back
81
119
  (`updatedAt` gets the time). It never adds a step or a target, and it never touches a stale note.
82
120
  - Deleting `.explore/notes/` is always safe: generation then re-derives everything from the page.
@@ -135,7 +135,11 @@ Decide this first, before exploring anything. The spec path is `tests/<id>.spec.
135
135
  **Read each profile's exploration note first** (`../exploring-cases/references/notes.md`): run
136
136
  `pnpm asrun explore:notes <case-id> --json`. A fresh note (its `caseSha256` matches the case) supplies the locator,
137
137
  the request pattern and the target's match count for a step; confirm each on the page instead of rediscovering it,
138
- and skip what it does not hold. A missing or stale note means the plain replay below. After confirming an entry of
138
+ and skip what it does not hold. A `depth: "diff"` note covers only the steps that profile's exploration opened: take
139
+ those, take the rest from the lead note named in `basedOn` when it is fresh, and report the rest as
140
+ `observed on <lead>, not verified on <profile>`. A diff note is a normal input, never a gap: do not stop on it, do not
141
+ ask for a full exploration of that profile, and never write the lead's steps into it. A missing or stale note means
142
+ the plain replay below. After confirming an entry of
139
143
  a fresh note, you may write the verified locator or request back into that file (`updatedAt` gets the time); never
140
144
  add a step or a target, and never touch a stale note. Say in the report what came from a note and what was
141
145
  refreshed. Perform the steps in the environment you were given (never ask for it; there is no default).
@@ -147,7 +151,9 @@ Decide this first, before exploring anything. The spec path is `tests/<id>.spec.
147
151
  `/explore-case` recorded). Pages that only exist after such a step cannot be seen from here: write their locators
148
152
  from a fresh note first, then from the recipe, and mark them in the report as "observed by explore on <profile>" or
149
153
  "not verified on the page"; leave the first run of the spec (or `/heal-spec`) to confirm the latter. Do this for at
150
- least one logged-in profile per domain (example.com and exampleshop.com differ in markup). A profile the case holds
154
+ least one logged-in profile per domain (example.com and exampleshop.com differ in markup). A profile with a fresh
155
+ diff note does not have to be opened again for the steps the note leaves to its lead: open it only to resolve a
156
+ value or a behavior, as below. A profile the case holds
151
157
  information for that is not logged in is skipped and named in the report; its part is written from the case and
152
158
  marked `not verified` in the assertion table. Use this exploration to answer the open points of step 2, and read each
153
159
  profile's value from the case's `## Values`: open that profile's page only when a value or a behavior still has to be
@@ -196,7 +202,8 @@ Decide this first, before exploring anything. The spec path is `tests/<id>.spec.
196
202
  Do NOT run `pnpm asrun`, do not use
197
203
  `--update-snapshots` and do not create baselines: the first run of the spec does that, and a person reviews them.
198
204
  8. **Report** in the user's language: spec path, **an assertion table** (step, what is asserted, target card, ready signal, and
199
- `observed on the page` or `not verified`; no row may be missing), profiles explored, which steps were written from a
205
+ `observed on the page` or `not verified`; no row may be missing), profiles explored (naming the profile whose note
206
+ each row came from, and `observed on <lead>, not verified on <profile>` for a diff note's rows), which steps were written from a
200
207
  fresh exploration note and which notes were refreshed (or that there were none), profiles skipped (and why),
201
208
  the profiles of `profiles.yaml` the case holds no information for (they run on `default` alone), whether the case file was
202
209
  translated, what was added to the case file as `(seen on <profile>)` and which lines still need a person
package/AGENTS.md CHANGED
@@ -199,11 +199,13 @@ The framework folder is read-only in a project: never edit it. Its own layout an
199
199
  Three steps, each learns one thing: the intent, the facts and inputs, the implementation.
200
200
  1. `making-cases` (or `/make-case <notes>`) turns raw notes into case documents. It writes `cases/*.md` only and
201
201
  never opens a browser. Check them with `pnpm case:check`.
202
- 2. `exploring-cases` (or `/explore-case <case-id> <env> [--attended] [--profiles a,b]`) explores the real pages for one
202
+ 2. `exploring-cases` (or `/explore-case <case-id> <env> [--attended] [--profiles a,b] [--lead <profile-id>]`) explores the real pages for one
203
203
  case. It edits that case file: wording, observations, TODO blocks, and, with `--attended`, the `- Recipe:` lines
204
204
  of the steps that write or need a choice, and it writes a local note under `.explore/notes/` with the locators and
205
205
  data-call paths it saw. `--profiles` narrows the run to those profiles; runs add up, so the case can be explored in
206
- passes.
206
+ passes. `--lead` explores one profile in full and the rest only where they can differ (values, targets, the steps
207
+ that reach them), so a case with many profiles is not paid for once per profile; a profile whose page or flow
208
+ differs is explored in full instead, and the report says so.
207
209
  3. `generating-specs` (or `/gen-spec <case-id> <env>`) turns the case into a spec. It replays the case, its
208
210
  step recipes and the exploration note, and `## Values`, writes the spec (values stay in the case document), but never
209
211
  runs the tests, never writes data and never creates baselines. There is no profile selection here: it covers every
@@ -216,6 +218,11 @@ before installing), `pnpm asrun init [--update] [--force]` writes or refreshes t
216
218
  migrate` applies only the migrations (for a manual install), and `pnpm asrun reset` removes `node_modules` and the
217
219
  files init wrote (edited files, cases, specs, `profiles.yaml` and `package.json` stay), so
218
220
  `pnpm dlx @asrunjs/asrun init --install` can scaffold again.
221
+ A release can change a file the project owns (its `AGENTS.md`, `.mcp.json`, `cases/_template.md`) or ask for a manual
222
+ migration step; `pnpm asrun upgrade` ends with a `NEXT` block that names each one with its exact command.
223
+ `/upgrade-workspace` (or `pnpm asrun upgrade --prompt`, whose output it follows) does that work: it merges only the
224
+ scaffold text outside `## 2`/`## 3`, fixes case structure without changing what a case expects, runs the checks, and
225
+ leaves `pnpm asrun migrate --ack <id>` and the `init --ack <file>` confirmations to the person.
219
226
  Do not hand-write specs unless asked. Do not edit `lib/` to make a single spec pass.
220
227
  Every case step is wrapped in `test.step('<n>. <case step text>', ...)` so reports and traces show the
221
228
  case's own steps (see the skill's `references/conventions.md`).
package/README.md CHANGED
@@ -58,6 +58,17 @@ generated files, applies the migrations and runs `doctor`. `--dry-run` shows the
58
58
  that predates `upgrade` updates once by hand: `pnpm add -D @asrunjs/asrun@latest`, `pnpm asrun init --update`,
59
59
  `pnpm asrun migrate`, `pnpm asrun doctor`.
60
60
 
61
+ When the release changes a file the project owns (`AGENTS.md`, `.mcp.json`, a case template) or records manual
62
+ migration steps, the upgrade ends with a `NEXT` block that names each one with its exact command. Hand that block to
63
+ an AI:
64
+
65
+ ```bash
66
+ pnpm asrun upgrade --prompt # the task as Markdown; changes nothing
67
+ ```
68
+
69
+ or run `/upgrade-workspace` in Claude Code / OpenCode, which does the same. The prompt is the same for every release —
70
+ it reads which files moved and which steps are open from the workspace and the installed package.
71
+
61
72
  `init --update` alone refreshes only the generated files; it never overwrites `AGENTS.md`, `CLAUDE.md` or your skill
62
73
  overrides. `doctor` checks profiles, generated files, the installed version, the migrations and the project rules.
63
74
  `init --force` rewrites every file init writes (rules still need `--reset-rules`; `profiles.yaml` and `package.json`
@@ -102,7 +113,8 @@ exempts `@asrunjs/asrun` from pnpm's 24-hour release age, so a fresh release ins
102
113
  writes what they show into the case and turns what it cannot know into questions. It also saves a local note of the
103
114
  locators and data calls it saw. Answer the questions in the case file, or in the UI's **Answer** sheet, and run it
104
115
  again. With many profiles, `--profiles a,b` opens only those; a later run with the others adds theirs, and the case
105
- keeps both.
116
+ keeps both. `--lead <profile-id>` pays for the flow once: that profile is explored in full, the others only where
117
+ they can differ (their values, their target screenshots, the steps that reach them).
106
118
  5. **Generate, run, read.** `/gen-spec header-logo staging` reads those notes, writes `tests/header-logo.spec.ts` and
107
119
  fills the case's `## Values`. Then:
108
120
 
@@ -8,6 +8,9 @@
8
8
  * A note holds only what the case does not: locators the agent confirmed on the page, the data-call paths of the
9
9
  * actions that load something, and the match count of a target card's element. Expected values stay in the case's
10
10
  * `## Values`, ready signals in the case's `Ready when:` lines, and assertions nowhere near this file.
11
+ *
12
+ * A `full` note (the default) covers every step. A `diff` note belongs to a profile that an `--lead` run explored only
13
+ * where it can differ, and names the lead profile in `basedOn` for the steps it did not open.
11
14
  */
12
15
  export declare const NOTES_VERSION = 1;
13
16
  export interface ExploreLocator {
@@ -38,6 +41,13 @@ export interface ExploreNote {
38
41
  capturedAt: string;
39
42
  /** Set when a later `/gen-spec` refreshed an entry it confirmed on the page. */
40
43
  updatedAt?: string;
44
+ /**
45
+ * `full` (the default when absent) explored every step of the case; `diff` explored only the steps that can differ
46
+ * on this profile, and leans on `basedOn` for the rest. See `references/notes.md`, "Coverage".
47
+ */
48
+ depth?: 'full' | 'diff';
49
+ /** The lead profile a `diff` note leans on for the steps it did not open. */
50
+ basedOn?: string;
41
51
  steps: Record<string, ExploreNoteStep>;
42
52
  targets?: Record<string, ExploreNoteTarget>;
43
53
  }
@@ -64,6 +74,11 @@ export declare function validateExploreNote(value: unknown, expected: {
64
74
  caseId: string;
65
75
  profile: string;
66
76
  }): string[];
77
+ /** `full` when the note explored every step, `diff` when it covered only the steps that can differ per profile. */
78
+ export declare function noteCoverage(note: ExploreNote): {
79
+ depth: 'full' | 'diff';
80
+ basedOn?: string;
81
+ };
67
82
  /**
68
83
  * Reads every note of a case (or one profile's), parses and validates it. A malformed file is reported, never thrown:
69
84
  * the caller decides whether it matters, and generation treats an unreadable note as a missing one.
@@ -13,6 +13,9 @@ import { CASES_DIR, EXPLORE_DIR } from './paths.js';
13
13
  * A note holds only what the case does not: locators the agent confirmed on the page, the data-call paths of the
14
14
  * actions that load something, and the match count of a target card's element. Expected values stay in the case's
15
15
  * `## Values`, ready signals in the case's `Ready when:` lines, and assertions nowhere near this file.
16
+ *
17
+ * A `full` note (the default) covers every step. A `diff` note belongs to a profile that an `--lead` run explored only
18
+ * where it can differ, and names the lead profile in `basedOn` for the steps it did not open.
16
19
  */
17
20
  export const NOTES_VERSION = 1;
18
21
  const HASH = /^[a-f0-9]{64}$/;
@@ -194,15 +197,45 @@ export function validateExploreNote(value, expected) {
194
197
  if (value.env !== undefined && (typeof value.env !== 'string' || value.env.trim() === '')) {
195
198
  problems.push('env must be a non-empty string when it is present');
196
199
  }
200
+ if (value.depth !== undefined && value.depth !== 'full' && value.depth !== 'diff') {
201
+ problems.push('depth must be "full" or "diff" when it is present');
202
+ }
203
+ if (value.basedOn !== undefined) {
204
+ if (typeof value.basedOn !== 'string' || !PROFILE_FILE.test(value.basedOn)) {
205
+ problems.push('basedOn must be a profile id (letters, digits, ".", "_" and "-")');
206
+ }
207
+ else if (value.depth !== 'diff') {
208
+ problems.push('basedOn is only allowed on a note whose depth is "diff"');
209
+ }
210
+ }
211
+ if (value.depth === 'diff' && value.basedOn === undefined) {
212
+ problems.push('a diff note must name the lead profile in basedOn');
213
+ }
197
214
  checkSteps(value.steps, problems);
198
215
  if (value.targets !== undefined)
199
216
  checkTargets(value.targets, problems);
200
- const known = new Set(['version', 'caseId', 'profile', 'env', 'caseSha256', 'capturedAt', 'updatedAt', 'steps', 'targets']);
217
+ const known = new Set([
218
+ 'version',
219
+ 'caseId',
220
+ 'profile',
221
+ 'env',
222
+ 'caseSha256',
223
+ 'capturedAt',
224
+ 'updatedAt',
225
+ 'depth',
226
+ 'basedOn',
227
+ 'steps',
228
+ 'targets',
229
+ ]);
201
230
  for (const key of Object.keys(value))
202
231
  if (!known.has(key))
203
232
  problems.push(`${key} is not a known field`);
204
233
  return problems;
205
234
  }
235
+ /** `full` when the note explored every step, `diff` when it covered only the steps that can differ per profile. */
236
+ export function noteCoverage(note) {
237
+ return note.depth === 'diff' ? { depth: 'diff', basedOn: note.basedOn } : { depth: 'full' };
238
+ }
206
239
  /**
207
240
  * Reads every note of a case (or one profile's), parses and validates it. A malformed file is reported, never thrown:
208
241
  * the caller decides whether it matters, and generation treats an unreadable note as a missing one.
@@ -23,9 +23,9 @@ export interface MigrationContext {
23
23
  * on any older release may still need it.
24
24
  */
25
25
  export interface Migration {
26
- /** stable and unique: `<version>-<slug>`, for example `0.9.0-pnpm-release-age` */
26
+ /** stable and unique: `<version>-<slug>`, or `unreleased-<slug>` until the release names its version */
27
27
  id: string;
28
- /** the release that introduced the change; workspaces older than this get the migration */
28
+ /** the release that introduced the change; `unreleased` while it is still being prepared */
29
29
  version: string;
30
30
  /** one line for the person, in the release plan before the install and in the migration output after it */
31
31
  summary: string;
@@ -37,6 +37,17 @@ export interface Migration {
37
37
  details?: string;
38
38
  /** steps only a person can do; they are printed and remembered until they are acknowledged */
39
39
  manual?: string[];
40
+ /**
41
+ * The project files the change touches. A once-only scaffold file here (`AGENTS.md`, `.mcp.json`, ...) also advances
42
+ * that file's baseline when the migration is acknowledged, so one `migrate --ack` clears both reminders.
43
+ */
44
+ files?: string[];
45
+ /**
46
+ * `true` for a release that touched project files but asks nothing of an existing workspace: documentation, a
47
+ * clarified contract, a new optional flag. The entry exists so `pnpm migrations:check` can tell "reviewed, nothing
48
+ * to do" from "forgotten"; the command reports it and moves on.
49
+ */
50
+ advisory?: boolean;
40
51
  /** true when the migration runs without asking after the install; the command still prints what it did */
41
52
  auto?: boolean;
42
53
  /** true when the workspace already looks migrated; a false answer skips `apply` */
@@ -46,6 +57,21 @@ export interface Migration {
46
57
  }
47
58
  /** A migration as it travels in `dist/migrations/manifest.json`: the same entry without its code. */
48
59
  export type MigrationManifestEntry = Omit<Migration, 'detect' | 'apply'>;
60
+ /**
61
+ * `version` written by an entry that is still being prepared: the release it ships with names the version later, and
62
+ * release-please only knows it once the release pull request exists. Resolved to the installed release's own version,
63
+ * which is by definition the release that carries the entry's code.
64
+ */
65
+ export declare const UNRELEASED = "unreleased";
66
+ /** True for an entry whose version is still `unreleased`. */
67
+ export declare function isUnreleased(version: string): boolean;
68
+ /**
69
+ * The registry with `unreleased` resolved to the installed release. Every consumer compares versions, so they must all
70
+ * see the resolved entries: a leftover sentinel would sort as a plain string and silently pick the wrong migrations.
71
+ */
72
+ export declare function resolveMigrations<T extends {
73
+ version: string;
74
+ }>(entries: readonly T[], installedVersion: string): T[];
49
75
  /**
50
76
  * The migration state of a workspace, recorded in `.asrun.json`. `since` is the release the workspace was last
51
77
  * migrated from, so only newer entries apply. It survives `init --update`, which is what keeps a manual
@@ -63,6 +89,10 @@ export interface WorkspaceMarker {
63
89
  since?: unknown;
64
90
  manual?: unknown;
65
91
  };
92
+ /** The template each once-only project file is known to be aligned with (`lib/scaffold.ts`). */
93
+ scaffold?: unknown;
94
+ /** The release the workspace came from, before the last `init` wrote the current one. */
95
+ previous?: unknown;
66
96
  [key: string]: unknown;
67
97
  }
68
98
  /** Where `pnpm build` writes the manifest, relative to the package root. */
@@ -88,8 +118,12 @@ export interface VersionedMigration {
88
118
  * `since` is the release the workspace already matches, `to` is the release being installed.
89
119
  */
90
120
  export declare function pendingMigrations<T extends VersionedMigration>(entries: readonly T[], since: string, to: string): T[];
91
- /** The manifest of a registry: the same entries, without the code that cannot travel in JSON. */
92
- export declare function toManifest(entries: readonly Migration[]): MigrationManifestEntry[];
121
+ /**
122
+ * The manifest of a registry: the same entries, without the code that cannot travel in JSON. Versions are resolved
123
+ * first, so a published manifest never carries the `unreleased` sentinel: an older `upgrade` reads it out of the target
124
+ * tarball before installing anything, and has to compare real versions.
125
+ */
126
+ export declare function toManifest(entries: readonly Migration[], installedVersion: string): MigrationManifestEntry[];
93
127
  /** The marker of a workspace, or null when the file is missing or not a JSON object. */
94
128
  export declare function readMarker(root: string): WorkspaceMarker | null;
95
129
  /**
@@ -98,6 +132,11 @@ export declare function readMarker(root: string): WorkspaceMarker | null;
98
132
  * starts at the installed version, so no historical entry runs on it.
99
133
  */
100
134
  export declare function migrationState(marker: WorkspaceMarker | null, installedVersion: string): MigrationState;
135
+ /**
136
+ * Reads the marker, lets the caller change it and writes it back, keeping every other field. Every module that records
137
+ * something in `.asrun.json` goes through this one function, so two of them can never overwrite each other's block.
138
+ */
139
+ export declare function updateMarker(root: string, mutate: (marker: WorkspaceMarker) => void): void;
101
140
  /**
102
141
  * Writes the migration state back into the marker and keeps every other field (init writes the rest). The workspace
103
142
  * must already have a marker: `migrate` says so instead of creating one, because a workspace that was never
@@ -1,6 +1,23 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { MARKER } from './paths.js';
4
+ /**
5
+ * `version` written by an entry that is still being prepared: the release it ships with names the version later, and
6
+ * release-please only knows it once the release pull request exists. Resolved to the installed release's own version,
7
+ * which is by definition the release that carries the entry's code.
8
+ */
9
+ export const UNRELEASED = 'unreleased';
10
+ /** True for an entry whose version is still `unreleased`. */
11
+ export function isUnreleased(version) {
12
+ return version === UNRELEASED;
13
+ }
14
+ /**
15
+ * The registry with `unreleased` resolved to the installed release. Every consumer compares versions, so they must all
16
+ * see the resolved entries: a leftover sentinel would sort as a plain string and silently pick the wrong migrations.
17
+ */
18
+ export function resolveMigrations(entries, installedVersion) {
19
+ return entries.map((entry) => (entry.version === UNRELEASED ? { ...entry, version: installedVersion } : entry));
20
+ }
4
21
  /** Where `pnpm build` writes the manifest, relative to the package root. */
5
22
  export const MANIFEST_FILE = 'dist/migrations/manifest.json';
6
23
  /** The same file inside an npm tarball, which prefixes every path with `package/`. */
@@ -53,9 +70,13 @@ export function pendingMigrations(entries, since, to) {
53
70
  .filter((entry) => compareVersions(entry.version, since) > 0 && compareVersions(entry.version, to) <= 0)
54
71
  .sort((a, b) => compareVersions(a.version, b.version) || a.id.localeCompare(b.id));
55
72
  }
56
- /** The manifest of a registry: the same entries, without the code that cannot travel in JSON. */
57
- export function toManifest(entries) {
58
- return entries.map(({ id, version, summary, breaking, appliesTo, details, manual, auto }) => ({
73
+ /**
74
+ * The manifest of a registry: the same entries, without the code that cannot travel in JSON. Versions are resolved
75
+ * first, so a published manifest never carries the `unreleased` sentinel: an older `upgrade` reads it out of the target
76
+ * tarball before installing anything, and has to compare real versions.
77
+ */
78
+ export function toManifest(entries, installedVersion) {
79
+ return resolveMigrations(entries, installedVersion).map(({ id, version, summary, breaking, appliesTo, details, manual, files, advisory, auto }) => ({
59
80
  id,
60
81
  version,
61
82
  summary,
@@ -63,6 +84,8 @@ export function toManifest(entries) {
63
84
  ...(appliesTo !== undefined && { appliesTo }),
64
85
  ...(details !== undefined && { details }),
65
86
  ...(manual !== undefined && { manual }),
87
+ ...(files !== undefined && { files }),
88
+ ...(advisory !== undefined && { advisory }),
66
89
  ...(auto !== undefined && { auto }),
67
90
  }));
68
91
  }
@@ -93,14 +116,23 @@ export function migrationState(marker, installedVersion) {
93
116
  : [];
94
117
  return { since, manual: [...new Set(manual)] };
95
118
  }
119
+ /**
120
+ * Reads the marker, lets the caller change it and writes it back, keeping every other field. Every module that records
121
+ * something in `.asrun.json` goes through this one function, so two of them can never overwrite each other's block.
122
+ */
123
+ export function updateMarker(root, mutate) {
124
+ const file = path.join(root, MARKER);
125
+ const marker = readMarker(root) ?? {};
126
+ mutate(marker);
127
+ fs.writeFileSync(file, JSON.stringify(marker, null, 2) + '\n');
128
+ }
96
129
  /**
97
130
  * Writes the migration state back into the marker and keeps every other field (init writes the rest). The workspace
98
131
  * must already have a marker: `migrate` says so instead of creating one, because a workspace that was never
99
132
  * initialized has no release to migrate from.
100
133
  */
101
134
  export function writeMigrationState(root, state) {
102
- const file = path.join(root, MARKER);
103
- const marker = readMarker(root) ?? {};
104
- marker.migrations = { since: state.since, manual: [...new Set(state.manual)] };
105
- fs.writeFileSync(file, JSON.stringify(marker, null, 2) + '\n');
135
+ updateMarker(root, (marker) => {
136
+ marker.migrations = { since: state.since, manual: [...new Set(state.manual)] };
137
+ });
106
138
  }