@asrunjs/asrun 0.13.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 (78) hide show
  1. package/.claude/commands/explore-case.md +8 -3
  2. package/.claude/skills/exploring-cases/SKILL.md +72 -14
  3. package/.claude/skills/exploring-cases/references/handoff-card.js +81 -7
  4. package/.claude/skills/exploring-cases/references/notes.md +42 -4
  5. package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
  6. package/.claude/skills/exploring-cases/references/readiness.md +6 -0
  7. package/.claude/skills/exploring-cases/references/user-handoff.md +60 -27
  8. package/.claude/skills/generating-specs/SKILL.md +10 -3
  9. package/AGENTS.md +12 -4
  10. package/README.md +14 -2
  11. package/dist/lib/desktop-notify.d.ts +38 -0
  12. package/dist/lib/desktop-notify.js +158 -0
  13. package/dist/lib/explore-notes.d.ts +15 -0
  14. package/dist/lib/explore-notes.js +34 -1
  15. package/dist/lib/migrations.d.ts +43 -4
  16. package/dist/lib/migrations.js +39 -7
  17. package/dist/lib/paths.d.ts +9 -2
  18. package/dist/lib/paths.js +32 -5
  19. package/dist/lib/pdf.js +23 -3
  20. package/dist/lib/scaffold.d.ts +87 -0
  21. package/dist/lib/scaffold.js +220 -0
  22. package/dist/lib/term.d.ts +36 -0
  23. package/dist/lib/term.js +77 -0
  24. package/dist/lib/update-check.d.ts +1 -7
  25. package/dist/lib/update-check.js +3 -15
  26. package/dist/lib/upgrade-prompt.d.ts +84 -0
  27. package/dist/lib/upgrade-prompt.js +233 -0
  28. package/dist/migrations/index.d.ts +12 -4
  29. package/dist/migrations/index.js +40 -5
  30. package/dist/migrations/manifest.json +27 -1
  31. package/dist/scripts/build.js +4 -1
  32. package/dist/scripts/check-cases.js +3 -3
  33. package/dist/scripts/cli.js +11 -3
  34. package/dist/scripts/doctor.js +73 -4
  35. package/dist/scripts/explore-notes.js +19 -2
  36. package/dist/scripts/init.d.ts +6 -26
  37. package/dist/scripts/init.js +82 -101
  38. package/dist/scripts/migrate.js +84 -21
  39. package/dist/scripts/migrations-check.d.ts +1 -0
  40. package/dist/scripts/migrations-check.js +272 -0
  41. package/dist/scripts/migrations-freeze.d.ts +1 -0
  42. package/dist/scripts/migrations-freeze.js +126 -0
  43. package/dist/scripts/notify-test.d.ts +1 -0
  44. package/dist/scripts/notify-test.js +48 -0
  45. package/dist/scripts/upgrade.js +53 -10
  46. package/dist/ui/server/actions.js +4 -2
  47. package/dist/ui/server/ai-stream.d.ts +7 -1
  48. package/dist/ui/server/ai-stream.js +26 -7
  49. package/dist/ui/server/app.js +4 -0
  50. package/dist/ui/server/jobs.d.ts +10 -0
  51. package/dist/ui/server/jobs.js +41 -1
  52. package/dist/ui/shared/ai-commands.js +4 -2
  53. package/dist/ui/shared/types.d.ts +8 -0
  54. package/docs/agents/case-format.md +8 -1
  55. package/docs/agents/framework-dev.md +44 -5
  56. package/docs/ai-workflow.md +27 -10
  57. package/docs/cases.md +5 -5
  58. package/docs/commands.md +73 -15
  59. package/docs/install.md +18 -5
  60. package/docs/troubleshooting.md +17 -0
  61. package/docs/web-ui.md +23 -8
  62. package/package.json +5 -2
  63. package/templates/.claude/commands/explore-case.md +9 -3
  64. package/templates/.claude/commands/upgrade-workspace.md +21 -0
  65. package/templates/AGENTS.md +1 -1
  66. package/templates/package.json +1 -1
  67. package/ui/dist/assets/{dist-C46EmZuI.js → dist-B4pM62Hc.js} +1 -1
  68. package/ui/dist/assets/{dist-DGzg2gyA.js → dist-BFADGd4p.js} +1 -1
  69. package/ui/dist/assets/{dist-P9roUi1O.js → dist-Bd-TmWTD.js} +1 -1
  70. package/ui/dist/assets/{dist-DMiOs9Qj.js → dist-Bt053MSo.js} +1 -1
  71. package/ui/dist/assets/{dist-DrFzCwMc.js → dist-CD9RixNE.js} +1 -1
  72. package/ui/dist/assets/{dist-_0g6iC9K.js → dist-ChACtLiA.js} +1 -1
  73. package/ui/dist/assets/{dist-D9GmSkjm.js → dist-CiW9sEPF.js} +1 -1
  74. package/ui/dist/assets/{dist-Db_XhDui.js → dist-CxEpzoZU.js} +1 -1
  75. package/ui/dist/assets/{dist-BB2cF3XN.js → dist-Dh7um7By.js} +1 -1
  76. package/ui/dist/assets/{dist-Dy45nXf8.js → dist-gHwKkZ6g.js} +1 -1
  77. package/ui/dist/assets/{index-BN4ucmmn.js → index-Ckax27uf.js} +158 -158
  78. package/ui/dist/index.html +1 -1
package/AGENTS.md CHANGED
@@ -77,8 +77,9 @@ These apply to every agent that drives a browser to explore pages for cases or s
77
77
  - **B1 — Read-only for the agent.** Open, look, read, hover, switch tabs, open and close dialogs and menus.
78
78
  Everything that writes is the person's (see F3).
79
79
  - **B2 — Waiting for a person.** Only in an attended run. Announce the task in the browser window (a small draggable
80
- card with a Done button and click-to-copy for texts to type), name the finish condition, wait, and tell the person
81
- when control is back. The card starts and stops the recording itself and returns `recorded`; never call
80
+ card that lists the steps as a numbered list, shows the finish condition in its own block, has a Done button and
81
+ click-to-copy for texts to type), wait, and tell the person when control is back. The card starts and stops the
82
+ recording itself and returns `recorded`; never call
82
83
  `browser_start_recording` or `browser_stop_recording`: the `playwright-test` MCP lists many browser tools it cannot
83
84
  run (`devtools`, `storage`, `network`, `pdf`, `vision`, and the `skillOnly` ones such as `browser_network_clear`),
84
85
  and the substitutes for each are in `<framework>/.claude/skills/exploring-cases/references/tools.md`. Record what
@@ -198,11 +199,13 @@ The framework folder is read-only in a project: never edit it. Its own layout an
198
199
  Three steps, each learns one thing: the intent, the facts and inputs, the implementation.
199
200
  1. `making-cases` (or `/make-case <notes>`) turns raw notes into case documents. It writes `cases/*.md` only and
200
201
  never opens a browser. Check them with `pnpm case:check`.
201
- 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
202
203
  case. It edits that case file: wording, observations, TODO blocks, and, with `--attended`, the `- Recipe:` lines
203
204
  of the steps that write or need a choice, and it writes a local note under `.explore/notes/` with the locators and
204
205
  data-call paths it saw. `--profiles` narrows the run to those profiles; runs add up, so the case can be explored in
205
- 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.
206
209
  3. `generating-specs` (or `/gen-spec <case-id> <env>`) turns the case into a spec. It replays the case, its
207
210
  step recipes and the exploration note, and `## Values`, writes the spec (values stay in the case document), but never
208
211
  runs the tests, never writes data and never creates baselines. There is no profile selection here: it covers every
@@ -215,6 +218,11 @@ before installing), `pnpm asrun init [--update] [--force]` writes or refreshes t
215
218
  migrate` applies only the migrations (for a manual install), and `pnpm asrun reset` removes `node_modules` and the
216
219
  files init wrote (edited files, cases, specs, `profiles.yaml` and `package.json` stay), so
217
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.
218
226
  Do not hand-write specs unless asked. Do not edit `lib/` to make a single spec pass.
219
227
  Every case step is wrapped in `test.step('<n>. <case step text>', ...)` so reports and traces show the
220
228
  case's own steps (see the skill's `references/conventions.md`).
package/README.md CHANGED
@@ -29,7 +29,7 @@ AsRun is a devDependency, not a service. Your profiles, cases, specs and baselin
29
29
 
30
30
  ## Requirements
31
31
 
32
- - **Node.js 22+** and **pnpm**.
32
+ - **Node.js 22.13+** and **pnpm**.
33
33
  - **ffmpeg** to join the recordings of one test into a video. `pnpm go` installs it with Homebrew or winget; on Linux
34
34
  run `sudo apt install ffmpeg`. When it is missing, `pnpm go` / `pnpm asrun init` install the bundled `ffmpeg-static`
35
35
  into the workspace; if neither works, the report lists the videos separately.
@@ -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
 
@@ -0,0 +1,38 @@
1
+ /** One notification, already reduced to what a person reads at a glance. */
2
+ export interface DesktopNotification {
3
+ title: string;
4
+ body: string;
5
+ /** Ask for the platform's attention sound. Ignored where the platform has none. */
6
+ sound?: boolean;
7
+ }
8
+ /** The command that shows a notification: spawned directly, never through a shell. */
9
+ export interface NotifyCommand {
10
+ file: string;
11
+ args: string[];
12
+ }
13
+ /** What came of one attempt, for `pnpm asrun notify-test` and for the hint a failure leaves in a job log. */
14
+ export interface NotifyOutcome {
15
+ /** The command, as a person would type it; null when this platform has no notifier at all. */
16
+ command: string | null;
17
+ ok: boolean;
18
+ /** Why nothing was shown, when it was not. */
19
+ reason?: string;
20
+ }
21
+ /**
22
+ * The command one notification runs on this platform. Pure: the text is escaped here, so a message with quotes,
23
+ * Chinese or a colon cannot break the command. Null only for a platform with no notifier.
24
+ */
25
+ export declare function notificationCommand(platform: NodeJS.Platform, notification: DesktopNotification): NotifyCommand | null;
26
+ /** A command as a person would type it, cut short: a toast script is hundreds of characters long. */
27
+ export declare function describeCommand(command: NotifyCommand): string;
28
+ /**
29
+ * Shows one notification and waits for the command to end. Used by `pnpm asrun notify-test`, which has to report
30
+ * whether the operating system took it; the server uses `notifyDesktop` instead.
31
+ */
32
+ export declare function sendNotification(notification: DesktopNotification): Promise<NotifyOutcome>;
33
+ /**
34
+ * Shows one notification without waiting for it. The same text is sent once per few seconds, so two events of one
35
+ * handoff (the options call and the card) do not ring twice. `onError` says why nothing was shown, for a hint in the
36
+ * job log; it is never called on success.
37
+ */
38
+ export declare function notifyDesktop(notification: DesktopNotification, onError?: (reason: string) => void): void;
@@ -0,0 +1,158 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { spawnTarget } from './command.js';
3
+ /** A notification holds a line or two; the rest would be cut by the desktop anyway. */
4
+ const MAX_TEXT = 240;
5
+ /** The same notification twice in a row (a handoff reported by two events) is shown once. */
6
+ const DEDUPE_MS = 5_000;
7
+ /** A notifier that has not finished by now is a broken one, not a slow one. */
8
+ const TIMEOUT_MS = 10_000;
9
+ /** What went out recently, so the same text is not shown again by the next event of the same handoff. */
10
+ const recent = new Map();
11
+ /** A notification's text is one line: a line break in the body is read as the end of the notification. */
12
+ function oneLine(value) {
13
+ return value
14
+ .replace(/[\r\n\t\f\v]+/g, ' ')
15
+ .replace(/ {2,}/g, ' ')
16
+ .trim()
17
+ .slice(0, MAX_TEXT);
18
+ }
19
+ /** A string in an AppleScript literal: a backslash escapes, so both it and the quote have to be escaped themselves. */
20
+ const appleScriptText = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
21
+ /** XML text of a Windows toast: the five characters that would end the element early. */
22
+ const xmlText = (value) => value
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&apos;');
28
+ /** A PowerShell single-quoted literal: only a single quote is special inside it, and it is doubled. */
29
+ const powerShellText = (value) => value.replace(/'/g, "''");
30
+ /**
31
+ * Windows toast through Windows PowerShell (not pwsh: it has no WinRT). The app id is the one Windows PowerShell
32
+ * itself registers, so an unregistered toast does not silently disappear on a machine with nothing else installed.
33
+ */
34
+ function toastScript(title, body, sound) {
35
+ const toast = '<toast><visual><binding template="ToastGeneric">' +
36
+ `<text>${xmlText(title)}</text><text>${xmlText(body)}</text>` +
37
+ '</binding></visual>' +
38
+ (sound ? '<audio src="ms-winsoundevent:Notification.Reminder"/>' : '<audio silent="true"/>') +
39
+ '</toast>';
40
+ return [
41
+ "$ErrorActionPreference='Stop'",
42
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null',
43
+ '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] > $null',
44
+ '$xml=New-Object Windows.Data.Xml.Dom.XmlDocument',
45
+ `$xml.LoadXml('${powerShellText(toast)}')`,
46
+ '$toast=[Windows.UI.Notifications.ToastNotification]::new($xml)',
47
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(" +
48
+ "'{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)",
49
+ ].join(';');
50
+ }
51
+ /**
52
+ * The command one notification runs on this platform. Pure: the text is escaped here, so a message with quotes,
53
+ * Chinese or a colon cannot break the command. Null only for a platform with no notifier.
54
+ */
55
+ export function notificationCommand(platform, notification) {
56
+ const title = oneLine(notification.title) || 'AsRun';
57
+ const body = oneLine(notification.body);
58
+ if (platform === 'darwin') {
59
+ const script = `display notification "${appleScriptText(body)}" with title "${appleScriptText(title)}"` +
60
+ (notification.sound ? ' sound name "Glass"' : '');
61
+ return { file: 'osascript', args: ['-e', script] };
62
+ }
63
+ if (platform === 'win32') {
64
+ return {
65
+ file: 'powershell',
66
+ args: ['-NoProfile', '-NonInteractive', '-Command', toastScript(title, body, notification.sound === true)],
67
+ };
68
+ }
69
+ if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
70
+ return { file: 'notify-send', args: ['-a', 'AsRun', title, body] };
71
+ }
72
+ return null;
73
+ }
74
+ /** One argument as it would be typed in a shell, for the line `notify-test` prints (never used to run anything). */
75
+ const quote = (value) => (/^[\w@=,.:/~+-]+$/.test(value) ? value : JSON.stringify(value));
76
+ /** A command as a person would type it, cut short: a toast script is hundreds of characters long. */
77
+ export function describeCommand(command) {
78
+ const line = [command.file, ...command.args.map(quote)].join(' ');
79
+ return line.length > 200 ? `${line.slice(0, 197)}...` : line;
80
+ }
81
+ /**
82
+ * Shows one notification and waits for the command to end. Used by `pnpm asrun notify-test`, which has to report
83
+ * whether the operating system took it; the server uses `notifyDesktop` instead.
84
+ */
85
+ export async function sendNotification(notification) {
86
+ const command = notificationCommand(process.platform, notification);
87
+ if (!command)
88
+ return { command: null, ok: false, reason: `${process.platform} has no desktop notification` };
89
+ const display = describeCommand(command);
90
+ const target = spawnTarget(command.file, command.args);
91
+ if (!target)
92
+ return { command: display, ok: false, reason: `${command.file} was not found on PATH` };
93
+ return await new Promise((resolve) => {
94
+ let stderr = '';
95
+ let settled = false;
96
+ let timer;
97
+ const done = (outcome) => {
98
+ if (settled)
99
+ return;
100
+ settled = true;
101
+ if (timer)
102
+ clearTimeout(timer);
103
+ resolve(outcome);
104
+ };
105
+ let child;
106
+ try {
107
+ child = spawn(target.file, target.args, {
108
+ stdio: ['ignore', 'ignore', 'pipe'],
109
+ windowsHide: true,
110
+ ...(target.throughShell && { windowsVerbatimArguments: true }),
111
+ });
112
+ }
113
+ catch (error) {
114
+ resolve({ command: display, ok: false, reason: error.message });
115
+ return;
116
+ }
117
+ timer = setTimeout(() => {
118
+ child.kill();
119
+ done({ command: display, ok: false, reason: `no answer after ${TIMEOUT_MS / 1000} s` });
120
+ }, TIMEOUT_MS);
121
+ timer.unref();
122
+ child.stderr?.on('data', (chunk) => {
123
+ stderr += chunk.toString();
124
+ });
125
+ child.on('error', (error) => done({ command: display, ok: false, reason: error.message }));
126
+ child.on('close', (code) => {
127
+ const detail = stderr.trim().split('\n').filter(Boolean).at(-1);
128
+ done(code === 0
129
+ ? { command: display, ok: true }
130
+ : { command: display, ok: false, reason: detail ?? `exit code ${code}` });
131
+ });
132
+ });
133
+ }
134
+ /**
135
+ * Shows one notification without waiting for it. The same text is sent once per few seconds, so two events of one
136
+ * handoff (the options call and the card) do not ring twice. `onError` says why nothing was shown, for a hint in the
137
+ * job log; it is never called on success.
138
+ */
139
+ export function notifyDesktop(notification, onError) {
140
+ const key = `${notification.title}\u0000${notification.body}`;
141
+ const now = Date.now();
142
+ for (const [seen, at] of recent)
143
+ if (now - at > DEDUPE_MS)
144
+ recent.delete(seen);
145
+ if (recent.has(key))
146
+ return;
147
+ recent.set(key, now);
148
+ void sendNotification(notification).then((outcome) => {
149
+ if (outcome.ok || !onError)
150
+ return;
151
+ try {
152
+ onError(outcome.reason ?? 'the notification command failed');
153
+ }
154
+ catch {
155
+ // A caller that cannot report the failure is not worth failing the notification for.
156
+ }
157
+ });
158
+ }
@@ -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
  }
@@ -30,7 +30,14 @@ export declare const EXPLORE_DIR: string;
30
30
  export declare const TEMPLATES_DIR: string;
31
31
  export declare const SKILLS_DIR: string;
32
32
  /**
33
- * The `node_modules/<package>` folder of the framework's own install, or the workspace's (a hoisted or linked
34
- * install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use this.
33
+ * The folder of a package the framework needs at run time: the framework's own dependency, or the workspace's (a
34
+ * hoisted or linked install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
35
+ * this.
36
+ *
37
+ * Node's own resolution comes first, because it is the same one every `import` in this package goes through: the
38
+ * folder and the module that is loaded can then never disagree. A hand-built path could not promise that — under
39
+ * pnpm's virtual store the framework sits in `<store>/node_modules/@asrunjs/asrun`, so the folder that holds its
40
+ * dependencies is one `node_modules`, not two, and appending a second one named the wrong folder and made every PDF
41
+ * check fail with "pdfjs-dist is not installed" while the package was installed.
35
42
  */
36
43
  export declare function packageDir(packageName: string): string;
package/dist/lib/paths.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { createRequire } from 'node:module';
3
4
  /**
4
5
  * Where the framework's own code and assets live: the package folder (`node_modules/@asrunjs/asrun`). The compiled files sit in
5
6
  * `dist/lib`, so one level up from there is the package root again.
@@ -87,13 +88,39 @@ export const EXPLORE_DIR = path.join(ROOT, '.explore');
87
88
  export const TEMPLATES_DIR = path.join(FRAMEWORK_ROOT, 'templates');
88
89
  export const SKILLS_DIR = path.join(FRAMEWORK_ROOT, '.claude', 'skills');
89
90
  /**
90
- * The `node_modules/<package>` folder of the framework's own install, or the workspace's (a hoisted or linked
91
- * install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use this.
91
+ * The folder of a package the framework needs at run time: the framework's own dependency, or the workspace's (a
92
+ * hoisted or linked install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
93
+ * this.
94
+ *
95
+ * Node's own resolution comes first, because it is the same one every `import` in this package goes through: the
96
+ * folder and the module that is loaded can then never disagree. A hand-built path could not promise that — under
97
+ * pnpm's virtual store the framework sits in `<store>/node_modules/@asrunjs/asrun`, so the folder that holds its
98
+ * dependencies is one `node_modules`, not two, and appending a second one named the wrong folder and made every PDF
99
+ * check fail with "pdfjs-dist is not installed" while the package was installed.
92
100
  */
93
101
  export function packageDir(packageName) {
94
- // The package's own install, the pnpm virtual store that holds its dependencies, or a hoisted install in the workspace.
95
- for (const base of [FRAMEWORK_ROOT, path.dirname(path.dirname(FRAMEWORK_ROOT)), ROOT]) {
96
- const dir = path.join(base, 'node_modules', packageName);
102
+ // A bare name resolves to the package's entry point only; asking for the manifest is the portable way to its folder.
103
+ try {
104
+ const dir = path.dirname(createRequire(import.meta.url).resolve(`${packageName}/package.json`));
105
+ if (fs.existsSync(path.join(dir, 'package.json')))
106
+ return dir;
107
+ }
108
+ catch {
109
+ // Not resolvable from here (an `exports` map without "./package.json", or a package installed elsewhere): the
110
+ // folders that can hold an install answer instead.
111
+ }
112
+ // pnpm's virtual store already keeps a package's dependencies inside a `node_modules`, next to its scope folder;
113
+ // every other layout nests that folder inside the install root.
114
+ const store = path.dirname(path.dirname(FRAMEWORK_ROOT));
115
+ const candidates = [
116
+ // This package's own nested dependencies (an install strategy that nests instead of hoisting).
117
+ path.join(FRAMEWORK_ROOT, 'node_modules', packageName),
118
+ // The dependencies next to it: pnpm's store, or the root of a hoisted install.
119
+ path.basename(store) === 'node_modules' ? path.join(store, packageName) : path.join(store, 'node_modules', packageName),
120
+ // The workspace's own install: a peer dependency (`@playwright/test`) lives here.
121
+ path.join(ROOT, 'node_modules', packageName),
122
+ ];
123
+ for (const dir of candidates) {
97
124
  if (fs.existsSync(dir))
98
125
  return dir;
99
126
  }
package/dist/lib/pdf.js CHANGED
@@ -1,13 +1,33 @@
1
1
  import path from 'node:path';
2
+ import { createRequire } from 'node:module';
2
3
  import { pathToFileURL } from 'node:url';
3
4
  import { createCanvas } from '@napi-rs/canvas';
4
5
  import { expect } from '@playwright/test';
5
6
  import { packageDir } from './paths.js';
6
7
  let pdfjs;
7
- const loadPdfjs = () => (pdfjs ??= import('pdfjs-dist/legacy/build/pdf.mjs'));
8
- // pdf.js loads its fonts, cmaps and wasm files from the package folder, not from the consuming project.
8
+ const loadPdfjs = () => (pdfjs ??= import('pdfjs-dist/legacy/build/pdf.mjs').catch((error) => {
9
+ // The one dependency of this module. A raw ERR_MODULE_NOT_FOUND names this file and nothing a person can act on.
10
+ throw new Error(`The PDF renderer is unavailable: "pdfjs-dist" could not be loaded (${error.message}). The asrun install is ` +
11
+ 'incomplete: run "pnpm install" in the workspace root, or install the framework again ("pnpm add -D ' +
12
+ '@asrunjs/asrun"). "pnpm asrun doctor" says which part is missing.');
13
+ }));
14
+ // pdf.js loads its fonts, cmaps and wasm files from the package folder, not from the consuming project. The folder is
15
+ // derived from the very module that is imported, so it cannot drift from the code that reads it: a hand-built path was
16
+ // one `node_modules` too deep under pnpm's virtual store, and every PDF check failed although the package was there.
17
+ // `packageDir` answers only when the module itself cannot be resolved (then the load above reports it).
9
18
  let pdfjsDir;
10
- const dirUrl = (dir) => pathToFileURL(path.join((pdfjsDir ??= packageDir('pdfjs-dist')), dir)).href + '/';
19
+ function pdfjsRoot() {
20
+ if (pdfjsDir !== undefined)
21
+ return pdfjsDir;
22
+ try {
23
+ const build = createRequire(import.meta.url).resolve('pdfjs-dist/legacy/build/pdf.mjs');
24
+ return (pdfjsDir = path.resolve(path.dirname(build), '..', '..'));
25
+ }
26
+ catch {
27
+ return (pdfjsDir = packageDir('pdfjs-dist'));
28
+ }
29
+ }
30
+ const dirUrl = (dir) => pathToFileURL(path.join(pdfjsRoot(), dir)).href + '/';
11
31
  /** A corner of the page. Defaults: 30% of the width and 12% of the height, enough for a logo and nothing else. */
12
32
  export function corner(name, size = {}) {
13
33
  const width = size.width ?? 0.3;