@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
@@ -0,0 +1,233 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { UNRELEASED, migrationState, pendingMigrations, readMarker, resolveMigrations, } from './migrations.js';
4
+ import { MIGRATIONS } from '../migrations/index.js';
5
+ import { frameworkPath, frameworkVersion, isInstalled, outstandingDrift, scaffoldDrift } from './scaffold.js';
6
+ import { card } from './term.js';
7
+ /** The checks every upgrade ends with: the workspace's own tools, which know the installed release's rules. */
8
+ const CHECKS = ['pnpm asrun doctor', 'pnpm case:check', 'pnpm spec:lint', 'pnpm typecheck'];
9
+ /** The version of the framework installed in this workspace (not the one running the command). */
10
+ function installedVersion(root) {
11
+ try {
12
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, frameworkPath(root), 'package.json'), 'utf8'));
13
+ if (typeof manifest.version === 'string' && manifest.version !== '')
14
+ return manifest.version;
15
+ }
16
+ catch {
17
+ // fall back to the framework this command runs from
18
+ }
19
+ return frameworkVersion().version;
20
+ }
21
+ /** How the workspace reaches the framework: a registry install, or the framework's own git checkout. */
22
+ function installKind(root) {
23
+ return isInstalled(root) && !frameworkPath(root).startsWith('..') ? 'installed' : 'checkout';
24
+ }
25
+ /** One project file, as the upgrade output and the prompt describe it. */
26
+ function describeFile(entry) {
27
+ const ack = `pnpm asrun init --ack ${entry.file}`;
28
+ switch (entry.state) {
29
+ case 'missing':
30
+ return { file: entry.file, state: entry.state, action: 'missing in the workspace', command: 'pnpm asrun init', projectData: entry.projectData };
31
+ case 'replace':
32
+ return {
33
+ file: entry.file,
34
+ state: entry.state,
35
+ action: 'the template changed and nobody edited the file',
36
+ command: `pnpm asrun init --force ${entry.file}`,
37
+ projectData: entry.projectData,
38
+ };
39
+ case 'merge':
40
+ return {
41
+ file: entry.file,
42
+ state: entry.state,
43
+ action: 'the template and the project both changed it: merge the template part, then acknowledge',
44
+ command: ack,
45
+ projectData: entry.projectData,
46
+ };
47
+ case 'unknown-base':
48
+ return {
49
+ file: entry.file,
50
+ state: entry.state,
51
+ action: 'no baseline for this file: compare it with the template, report what differs, then acknowledge',
52
+ command: ack,
53
+ projectData: entry.projectData,
54
+ };
55
+ default:
56
+ return { file: entry.file, state: entry.state, action: 'nothing to do', command: null, projectData: entry.projectData };
57
+ }
58
+ }
59
+ /** Everything an upgrade still leaves open, computed from the marker, the registry and the installed templates. */
60
+ export function upgradeOutstanding(root, marker = readMarker(root)) {
61
+ const to = installedVersion(root);
62
+ const registry = resolveMigrations(MIGRATIONS, to);
63
+ const state = migrationState(marker, to);
64
+ const pending = pendingMigrations(registry, state.since, to);
65
+ const byId = new Map(registry.map((migration) => [migration.id, migration]));
66
+ // Outstanding manual steps: what an earlier migrate recorded, plus the manual entries that are still pending here
67
+ // (a workspace that was installed by hand and has not run "migrate" yet).
68
+ const ids = [...new Set([...state.manual, ...pending.filter((migration) => (migration.manual ?? []).length > 0).map((migration) => migration.id)])];
69
+ const migrations = [];
70
+ for (const id of ids) {
71
+ const migration = byId.get(id);
72
+ if (migration === undefined) {
73
+ migrations.push({ id, version: 'unknown', summary: 'a migration this release no longer knows', manual: [], files: [], ack: `pnpm asrun migrate --ack ${id}` });
74
+ continue;
75
+ }
76
+ migrations.push({
77
+ id,
78
+ version: migration.version,
79
+ summary: migration.summary,
80
+ ...(migration.details !== undefined && { details: migration.details }),
81
+ manual: migration.manual ?? [],
82
+ files: migration.files ?? [],
83
+ ack: `pnpm asrun migrate --ack ${id}`,
84
+ });
85
+ }
86
+ const drift = scaffoldDrift(root, marker);
87
+ // An advisory entry asks nothing: `migrate` still records it, but it is not work a person has to see.
88
+ const work = pending.filter((migration) => migration.advisory !== true);
89
+ return {
90
+ drift,
91
+ files: outstandingDrift(drift).map(describeFile),
92
+ migrations,
93
+ pending: work.length,
94
+ };
95
+ }
96
+ /** True when an upgrade leaves nothing for a person or an AI to do. */
97
+ export function nothingToDo(outstanding) {
98
+ return outstanding.files.length === 0 && outstanding.migrations.length === 0 && outstanding.pending === 0;
99
+ }
100
+ /**
101
+ * The rows of the "NEXT" card: one per file or migration, each with the exact command. A person must be able to finish
102
+ * the upgrade by copying from here, without reading the prompt or the docs.
103
+ */
104
+ export function nextStepRows(outstanding) {
105
+ const rows = outstanding.files.map((entry) => ({
106
+ label: entry.file,
107
+ // The command goes on its own line: a wrapped command is a command nobody can copy.
108
+ text: entry.command === null ? entry.action : `${entry.action}\n${entry.command}`,
109
+ }));
110
+ for (const migration of outstanding.migrations) {
111
+ const first = migration.manual[0];
112
+ rows.push({
113
+ label: migration.id,
114
+ text: `${first ?? migration.summary}\nwhen done: ${migration.ack}`,
115
+ });
116
+ }
117
+ if (outstanding.pending > 0) {
118
+ rows.push({ label: 'migrations', text: `${outstanding.pending} apply to this workspace: pnpm asrun migrate` });
119
+ }
120
+ return rows;
121
+ }
122
+ /** One sentence for the title of the card, so the count and the work agree. */
123
+ export function nextStepTitle(outstanding) {
124
+ const files = outstanding.files.length;
125
+ const migrations = outstanding.migrations.length + (outstanding.pending > 0 ? 1 : 0);
126
+ const parts = [];
127
+ if (files > 0)
128
+ parts.push(`${files} project file${files === 1 ? '' : 's'}`);
129
+ if (migrations > 0)
130
+ parts.push(`${migrations} migration step${migrations === 1 ? '' : 's'}`);
131
+ return `${parts.join(' and ')} still need${files + migrations === 1 ? 's' : ''} a person or an AI`;
132
+ }
133
+ /** The prompt as Markdown: what an agent reads, and what the web UI copies. */
134
+ function renderPrompt(payload) {
135
+ const lines = [];
136
+ const none = payload.files.length === 0 && payload.migrations.length === 0;
137
+ lines.push(`# Bring this asrun workspace to ${payload.to}`, '');
138
+ lines.push(`Workspace: ${payload.workspace}`, `Framework: ${payload.framework} (${payload.to})${payload.from === null ? '' : `, upgraded from ${payload.from}`}`, 'Start from a clean tree: `git status` must be empty. If it is not, stop and say so.', '');
139
+ lines.push('Before you change anything, read:', '');
140
+ lines.push(`- \`${payload.framework}/AGENTS.md\` — the framework baseline that wins over everything else`, '- `pnpm asrun rules` — this project\'s §2 rules and its test-data prefix', `- \`${payload.framework}/docs/agents/case-format.md\` — the case document contract`);
141
+ lines.push('');
142
+ if (none) {
143
+ lines.push('## 1. Nothing in the project\'s own files needs updating', '');
144
+ lines.push('This release changed nothing a workspace has to merge. Run the checks below and report; do not change files.');
145
+ lines.push('');
146
+ }
147
+ else {
148
+ lines.push('## 1. Project files that need updating', '');
149
+ if (payload.files.length === 0)
150
+ lines.push('None.');
151
+ else {
152
+ lines.push('| File | State | What to do |');
153
+ lines.push('|---|---|---|');
154
+ for (const entry of payload.files) {
155
+ lines.push(`| \`${entry.file}\` | ${entry.state} | ${entry.action}${entry.command === null ? '' : ` → \`${entry.command}\``} |`);
156
+ }
157
+ }
158
+ lines.push('');
159
+ lines.push('## 2. Manual steps of this release', '');
160
+ if (payload.migrations.length === 0)
161
+ lines.push('None.');
162
+ for (const migration of payload.migrations) {
163
+ lines.push(`- \`${migration.id}\` (${migration.version}) — ${migration.summary}`);
164
+ if (migration.details !== undefined)
165
+ lines.push(` ${migration.details}`);
166
+ for (const step of migration.manual)
167
+ lines.push(` - ${step}`);
168
+ lines.push(` - when done: \`${migration.ack}\``);
169
+ }
170
+ lines.push('');
171
+ }
172
+ lines.push(`## ${none ? 2 : 3}. Checks — fix every finding, and do nothing else`, '');
173
+ for (const check of payload.checks)
174
+ lines.push(` ${check}`);
175
+ lines.push('');
176
+ lines.push(`## ${none ? 3 : 4}. In a case document, only structure may change`, '');
177
+ lines.push('Front-matter keys, section headings, `- Recipe` lines, `### T<n>` cards and their `[T<n>]` references, and the', '`❓ TODO` / `🚫 TODO` blocks. Never change what a case expects and never invent a value: an ambiguity becomes a', '`❓ TODO [non-blocking]` block with an empty `✍️ Answer:` line. Step tags stay as they are (`[input]`, `[writes]`,', '`[email]`).');
178
+ lines.push('');
179
+ lines.push(`## ${none ? 4 : 5}. Boundaries`, '');
180
+ lines.push(`- Never edit anything inside \`${payload.framework}\` (the installed package).`, '- Never read `.auth/`. Do not run the tests. Do not create test data.', '- Never use `--allow-writes` or `ASRUN_ALLOW_WRITES`. Do not run `pnpm asrun migrate --ack`: the person does that.', '- Do not change what a spec asserts. What `spec:lint` and `typecheck` cannot fix is left for `/heal-spec`.');
181
+ lines.push('');
182
+ lines.push(`## ${none ? 5 : 6}. Report`, '');
183
+ lines.push('A table: file → changed / left alone (why) / needs a person (why), then the commands the person should run,', 'verbatim:');
184
+ const acks = [...payload.files.filter((entry) => entry.command !== null && entry.command !== 'pnpm asrun init').map((entry) => entry.command), ...payload.acks.map((ack) => ack.command)];
185
+ if (acks.length === 0)
186
+ lines.push(' (none)');
187
+ else
188
+ for (const command of [...new Set(acks)])
189
+ lines.push(` ${command}`);
190
+ lines.push('', 'The last line of your final message is plain text and nothing else: exactly "RESULT: ready" when nothing needs a', 'person, or "RESULT: needs-input" when something does. Never put it in a code fence or in emphasis.');
191
+ lines.push('');
192
+ return lines.join('\n');
193
+ }
194
+ /** The payload and the prompt of one workspace, ready for the command line and for the web UI. */
195
+ export function upgradePrompt(root) {
196
+ const outstanding = upgradeOutstanding(root);
197
+ const marker = readMarker(root);
198
+ const previous = marker?.previous;
199
+ const base = {
200
+ workspace: root,
201
+ framework: frameworkPath(root),
202
+ kind: installKind(root),
203
+ from: typeof previous?.version === 'string' && previous.version !== UNRELEASED ? previous.version : null,
204
+ to: installedVersion(root),
205
+ drift: outstanding.drift,
206
+ files: outstanding.files,
207
+ migrations: outstanding.migrations,
208
+ pending: outstanding.pending,
209
+ acks: outstanding.migrations.map((migration) => ({ id: migration.id, command: migration.ack })),
210
+ checks: [...CHECKS],
211
+ };
212
+ return { ...base, prompt: renderPrompt(base) };
213
+ }
214
+ /**
215
+ * The framed "what is left" block, or null when the workspace has nothing outstanding. It carries the exact commands and
216
+ * names the recommended way to have an agent do them, so a person never has to remember a flag or an id.
217
+ */
218
+ export function nextStepsText(root, options) {
219
+ const outstanding = upgradeOutstanding(root);
220
+ if (nothingToDo(outstanding))
221
+ return null;
222
+ return card({
223
+ label: 'NEXT',
224
+ title: nextStepTitle(outstanding),
225
+ rows: nextStepRows(outstanding),
226
+ lines: [
227
+ '',
228
+ 'Recommended: run /upgrade-workspace in Claude Code or OpenCode.',
229
+ 'Without an AI CLI: pnpm asrun upgrade --prompt, and paste it to your agent.',
230
+ ],
231
+ color: options.color,
232
+ }, options.width);
233
+ }
@@ -8,16 +8,24 @@ import type { Migration } from '../lib/migrations.js';
8
8
  * for), and anything that only concerns brand-new workspaces belongs in `templates/`.
9
9
  *
10
10
  * Adding one:
11
- * 1. Append an entry with an id of `<version>-<slug>` and the version of the release that *introduces* the change
12
- * (`version` in `package.json` after the release PR is merged, which is the next version at the time of writing).
11
+ * 1. Run `pnpm migrations:check`: it lists the surfaces this change touched. Append an entry with an id of
12
+ * `unreleased-<slug>` and `version: '0.15.0'`; never write a version number, because release-please only names
13
+ * the version in the release pull request. `scripts/build.ts` and `pnpm asrun migrate` resolve the sentinel to the
14
+ * installed release, so the published manifest is always correct; `pnpm migrations:freeze` turns it into a real
15
+ * version and id on the release branch (the release pull request does it automatically).
13
16
  * 2. Explain it for the person: `summary` is one line, `appliesTo` says which workspaces it matters for, `details`
14
- * adds what the summary cannot, and `manual` lists what only a person can do.
17
+ * adds what the summary cannot, and `manual` lists what only a person can do. `files` names the project-owned
18
+ * workspace files the change touches: acknowledging the migration then also records them as aligned with this
19
+ * release's templates, which is what clears them from the upgrade's NEXT block.
15
20
  * 3. `auto: true` only when the change is idempotent and safe to run without asking (`apply` decides, `detect` skips a
16
21
  * workspace that is already fine). A change to a file the project owns (`AGENTS.md`, `profiles.yaml`, a project
17
22
  * config) is a `manual` step, never an automatic edit.
18
23
  * 4. Set `breaking: true` when an untouched workspace can stop working. `pnpm asrun upgrade` then asks before it
19
24
  * installs anything, using the manifest `pnpm build` writes (`dist/migrations/manifest.json`).
20
- * 5. Never delete or renumber an entry: a workspace on any older release may still need it. The interface is documented
25
+ * 5. A release that touched one of those surfaces but asks nothing of an existing workspace (documentation, a
26
+ * clarified contract, a new optional flag) gets an entry with `advisory: true` and no `manual` steps: it tells
27
+ * `pnpm migrations:check` that the change was reviewed, and `migrate` reports it and moves on.
28
+ * 6. Never delete or renumber an entry: a workspace on any older release may still need it. The interface is documented
21
29
  * in `docs/agents/framework-dev.md`.
22
30
  */
23
31
  export declare const MIGRATIONS: Migration[];
@@ -7,16 +7,51 @@
7
7
  * for), and anything that only concerns brand-new workspaces belongs in `templates/`.
8
8
  *
9
9
  * Adding one:
10
- * 1. Append an entry with an id of `<version>-<slug>` and the version of the release that *introduces* the change
11
- * (`version` in `package.json` after the release PR is merged, which is the next version at the time of writing).
10
+ * 1. Run `pnpm migrations:check`: it lists the surfaces this change touched. Append an entry with an id of
11
+ * `unreleased-<slug>` and `version: '0.15.0'`; never write a version number, because release-please only names
12
+ * the version in the release pull request. `scripts/build.ts` and `pnpm asrun migrate` resolve the sentinel to the
13
+ * installed release, so the published manifest is always correct; `pnpm migrations:freeze` turns it into a real
14
+ * version and id on the release branch (the release pull request does it automatically).
12
15
  * 2. Explain it for the person: `summary` is one line, `appliesTo` says which workspaces it matters for, `details`
13
- * adds what the summary cannot, and `manual` lists what only a person can do.
16
+ * adds what the summary cannot, and `manual` lists what only a person can do. `files` names the project-owned
17
+ * workspace files the change touches: acknowledging the migration then also records them as aligned with this
18
+ * release's templates, which is what clears them from the upgrade's NEXT block.
14
19
  * 3. `auto: true` only when the change is idempotent and safe to run without asking (`apply` decides, `detect` skips a
15
20
  * workspace that is already fine). A change to a file the project owns (`AGENTS.md`, `profiles.yaml`, a project
16
21
  * config) is a `manual` step, never an automatic edit.
17
22
  * 4. Set `breaking: true` when an untouched workspace can stop working. `pnpm asrun upgrade` then asks before it
18
23
  * installs anything, using the manifest `pnpm build` writes (`dist/migrations/manifest.json`).
19
- * 5. Never delete or renumber an entry: a workspace on any older release may still need it. The interface is documented
24
+ * 5. A release that touched one of those surfaces but asks nothing of an existing workspace (documentation, a
25
+ * clarified contract, a new optional flag) gets an entry with `advisory: true` and no `manual` steps: it tells
26
+ * `pnpm migrations:check` that the change was reviewed, and `migrate` reports it and moves on.
27
+ * 6. Never delete or renumber an entry: a workspace on any older release may still need it. The interface is documented
20
28
  * in `docs/agents/framework-dev.md`.
21
29
  */
22
- export const MIGRATIONS = [];
30
+ export const MIGRATIONS = [
31
+ {
32
+ id: '0.15.0-upgrade-workspace',
33
+ version: '0.15.0',
34
+ summary: 'A new /upgrade-workspace command aligns a workspace with the release it runs',
35
+ appliesTo: 'every workspace: the command is generated, its one line in the project-owned AGENTS.md is not',
36
+ details: 'The generated commands now include /upgrade-workspace (Claude Code and OpenCode): it runs "pnpm asrun upgrade ' +
37
+ '--prompt" and follows the task it prints, which is the work an upgrade leaves to a person or an AI. The layout ' +
38
+ 'list in AGENTS.md names the new command; that file belongs to the project and is never rewritten.',
39
+ files: ['AGENTS.md'],
40
+ manual: [
41
+ 'Add `/upgrade-workspace` to the command list in your AGENTS.md (the "Layout of this workspace" section, outside ' +
42
+ '## 2 and ## 3). The file the command itself lives in is generated and was already refreshed.',
43
+ ],
44
+ },
45
+ {
46
+ id: '0.15.0-explore-lead-docs',
47
+ version: '0.15.0',
48
+ summary: 'The case format documents per-profile reference lines for /explore-case --lead, and the workflow text names the flag',
49
+ appliesTo: 'every workspace: this is documentation; no file a workspace owns has to change',
50
+ details: 'A target card may now carry one "Reference <profile-id>:" line per profile that was looked at separately, and a ' +
51
+ 'profile that shares the lead screenshot says "none: <reason>". The case format gained a paragraph about what a ' +
52
+ 'diff profile adds to a case (its Values entry and its reference line, never a second copy of the steps). None of ' +
53
+ 'this changes an existing case: a card with one reference line stays valid.',
54
+ files: ['AGENTS.md', 'cases/**'],
55
+ advisory: true,
56
+ },
57
+ ];
@@ -1 +1,27 @@
1
- []
1
+ [
2
+ {
3
+ "id": "0.15.0-upgrade-workspace",
4
+ "version": "0.15.0",
5
+ "summary": "A new /upgrade-workspace command aligns a workspace with the release it runs",
6
+ "appliesTo": "every workspace: the command is generated, its one line in the project-owned AGENTS.md is not",
7
+ "details": "The generated commands now include /upgrade-workspace (Claude Code and OpenCode): it runs \"pnpm asrun upgrade --prompt\" and follows the task it prints, which is the work an upgrade leaves to a person or an AI. The layout list in AGENTS.md names the new command; that file belongs to the project and is never rewritten.",
8
+ "manual": [
9
+ "Add `/upgrade-workspace` to the command list in your AGENTS.md (the \"Layout of this workspace\" section, outside ## 2 and ## 3). The file the command itself lives in is generated and was already refreshed."
10
+ ],
11
+ "files": [
12
+ "AGENTS.md"
13
+ ]
14
+ },
15
+ {
16
+ "id": "0.15.0-explore-lead-docs",
17
+ "version": "0.15.0",
18
+ "summary": "The case format documents per-profile reference lines for /explore-case --lead, and the workflow text names the flag",
19
+ "appliesTo": "every workspace: this is documentation; no file a workspace owns has to change",
20
+ "details": "A target card may now carry one \"Reference <profile-id>:\" line per profile that was looked at separately, and a profile that shares the lead screenshot says \"none: <reason>\". The case format gained a paragraph about what a diff profile adds to a case (its Values entry and its reference line, never a second copy of the steps). None of this changes an existing case: a card with one reference line stays valid.",
21
+ "files": [
22
+ "AGENTS.md",
23
+ "cases/**"
24
+ ],
25
+ "advisory": true
26
+ }
27
+ ]
@@ -23,6 +23,9 @@ for (const file of COPIED_SCRIPTS) {
23
23
  }
24
24
  // The migration manifest of the published package. A still-old `pnpm asrun upgrade` reads it out of the *target*
25
25
  // tarball before installing, so it has to be in `dist/`, next to the compiled registry the installed command uses.
26
+ // `version: 'unreleased'` entries are resolved to the version being built: the manifest must never carry a sentinel,
27
+ // because the reader compares versions without knowing about one.
26
28
  const manifestFile = path.join(FRAMEWORK_ROOT, MANIFEST_FILE);
29
+ const built = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8'));
27
30
  fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
28
- fs.writeFileSync(manifestFile, JSON.stringify(toManifest(MIGRATIONS), null, 2) + '\n');
31
+ fs.writeFileSync(manifestFile, JSON.stringify(toManifest(MIGRATIONS, built.version ?? '0.0.0'), null, 2) + '\n');
@@ -58,7 +58,7 @@ function questionProblems(id) {
58
58
  warnings.push(`the "✍️ Answer:" line of the question on line ${question.line} is not the last line of its block`);
59
59
  }
60
60
  if (!question.parent) {
61
- warnings.push(`the question on line ${question.line} has no text above it: accepting it would delete a claim nobody sees`);
61
+ warnings.push(`the question on line ${question.line} has no sentence it is about: accepting it would delete a claim nobody sees`);
62
62
  continue;
63
63
  }
64
64
  const step = doc.steps.find((candidate) => candidate.line === question.parent?.line);
@@ -67,8 +67,8 @@ function questionProblems(id) {
67
67
  }
68
68
  const anchor = immediateAnchor(splitLines(doc.text), question.line, question.indent);
69
69
  if (anchor !== null && anchor !== question.parent.line) {
70
- const above = splitLines(doc.text).find((line) => line.number === anchor)?.text.trim() ?? '';
71
- warnings.push(`the question on line ${question.line} hangs under "${above.slice(0, 80)}", but the panel shows "${question.parent.text.slice(0, 80)}" as the text above it; indent it under that sentence or lift that sentence to a top-level bullet`);
70
+ const anchorText = splitLines(doc.text).find((line) => line.number === anchor)?.text.trim() ?? '';
71
+ warnings.push(`the question on line ${question.line} hangs under "${anchorText.slice(0, 80)}", but the panel shows "${question.parent.text.slice(0, 80)}" as the sentence it is about; indent it under that sentence or lift that sentence to a top-level bullet`);
72
72
  }
73
73
  }
74
74
  for (const step of doc.steps) {
@@ -44,13 +44,18 @@ async function main() {
44
44
  await import('./explore-notes.js');
45
45
  return;
46
46
  }
47
+ if (command === 'notify-test') {
48
+ const { main: notifyTest } = await import('./notify-test.js');
49
+ process.exitCode = await notifyTest(rest);
50
+ return;
51
+ }
47
52
  if (command === 'help' || command === '--help-init') {
48
53
  console.log(USAGE);
49
54
  return;
50
55
  }
51
56
  await import('./run.js');
52
57
  }
53
- const USAGE = `Usage: pnpm asrun [init|doctor|rules|reset|upgrade|migrate|explore:notes] [options]
58
+ const USAGE = `Usage: pnpm asrun [init|doctor|rules|reset|upgrade|migrate|explore:notes|notify-test] [options]
54
59
 
55
60
  (no subcommand) Run the tests of one environment. See "pnpm asrun --help".
56
61
  init Create the files a workspace needs (never overwrites; --update refreshes generated files,
@@ -62,8 +67,11 @@ const USAGE = `Usage: pnpm asrun [init|doctor|rules|reset|upgrade|migrate|explor
62
67
  reset Remove node_modules and the files init writes, for a clean reinstall (keeps cases, specs,
63
68
  profiles.yaml, package.json and edited scaffold files; see "pnpm asrun reset --help").
64
69
  upgrade Install the newest asrun, refresh the workspace files and apply its migrations, after checking the
65
- target release for breaking changes (see "pnpm asrun upgrade --help").
70
+ target release for breaking changes. It ends with what an AI agent should still fix, and "upgrade
71
+ --prompt" prints that task (see "pnpm asrun upgrade --help").
66
72
  migrate Apply the workspace migrations of the installed release, or list them (see "pnpm asrun migrate
67
73
  --help"). "upgrade" runs it; use it after a manual install.
68
- explore:notes Print or validate the local exploration note of a case (see "pnpm asrun explore:notes --help").`;
74
+ explore:notes Print or validate the local exploration note of a case (see "pnpm asrun explore:notes --help").
75
+ notify-test Show one desktop notification and print the command behind it, to tell a hidden notification from a
76
+ broken one (see "pnpm asrun notify-test --help").`;
69
77
  await main();
@@ -2,13 +2,14 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
5
- import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
5
+ import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR, packageDir } from '../lib/paths.js';
6
6
  import { readWorkspaceConfig } from '../lib/config.js';
7
7
  import { readProjectRules, mentionedTestDataPrefixes } from '../lib/rules.js';
8
8
  import { spawnTarget } from '../lib/command.js';
9
- import { migrationState, pendingMigrations } from '../lib/migrations.js';
9
+ import { compareVersions, migrationState, pendingMigrations, resolveMigrations } from '../lib/migrations.js';
10
10
  import { MIGRATIONS } from '../migrations/index.js';
11
11
  import { GENERATED, frameworkPath, frameworkVersion, PACKAGE, renderTemplate } from './init.js';
12
+ import { outstandingDrift, scaffoldDrift } from '../lib/scaffold.js';
12
13
  import { BUILD_APPROVALS, RUNTIME_EXTRAS, hasCapability, installedInWorkspace } from '../lib/runtime-deps.js';
13
14
  import { builtFile, COPIED_SCRIPTS } from '../lib/build-artifacts.js';
14
15
  /**
@@ -85,6 +86,33 @@ function documentedBrowserTools() {
85
86
  const section = /^## Runnable\n([\s\S]*?)^## /m.exec(text)?.[1] ?? '';
86
87
  return [...new Set(section.match(/browser_[a-z0-9_]+/g) ?? [])];
87
88
  }
89
+ /**
90
+ * The PDF renderer, which is how an exploring agent looks at a downloaded PDF and how a spec checks one: `lib/pdf.js`
91
+ * imports `pdfjs-dist` and `@napi-rs/canvas` at run time, and pdf.js reads its fonts and cmaps from the package folder
92
+ * `packageDir` names. Both are runtime dependencies of the framework, so a failure means the install (or the Node
93
+ * version) is broken — and it used to surface only when someone rendered a PDF, halfway through an exploration.
94
+ */
95
+ async function pdfRenderer() {
96
+ let dir;
97
+ try {
98
+ dir = packageDir('pdfjs-dist');
99
+ }
100
+ catch {
101
+ // `packageDir` already says where it looked; the caller adds what to do about it.
102
+ return { ok: false, reason: 'pdfjs-dist is not installed where the framework can reach it' };
103
+ }
104
+ try {
105
+ const version = JSON.parse(read(path.join(dir, 'package.json')) ?? '{}').version ?? 'unknown';
106
+ // Loading both is what proves it: the module import resolves exactly like the one in `lib/pdf.js`, and the native
107
+ // canvas is what draws the page.
108
+ await import('pdfjs-dist/legacy/build/pdf.mjs');
109
+ await import('@napi-rs/canvas');
110
+ return { ok: true, version };
111
+ }
112
+ catch (error) {
113
+ return { ok: false, reason: error.message };
114
+ }
115
+ }
88
116
  export async function main(args) {
89
117
  if (args.includes('--help')) {
90
118
  console.log('Usage: pnpm asrun doctor');
@@ -134,6 +162,28 @@ export async function main(args) {
134
162
  }
135
163
  }
136
164
  const framework = recordedPath ?? actualPath;
165
+ // The registry, read before the workspace state: an entry above the installed release never runs, because
166
+ // `pendingMigrations` stops at the installed version. That is what a workspace several releases behind should see (the
167
+ // entry waits for its own release), but it is a mistake in the registry itself — a hand-written version, a checkout of
168
+ // a release that is not published yet — so doctor names it instead of letting the entry disappear silently.
169
+ const registry = resolveMigrations(MIGRATIONS, installedVersion.version);
170
+ const reportRegistryError = (error) => {
171
+ const message = `The migration registry is invalid: ${error instanceof Error ? error.message : String(error)}`;
172
+ if (!errors.includes(message))
173
+ errors.push(message);
174
+ };
175
+ try {
176
+ const newer = registry.filter((entry) => compareVersions(entry.version, installedVersion.version) > 0);
177
+ if (newer.length > 0) {
178
+ warnings.push(`${newer.length} migration entr${newer.length === 1 ? 'y' : 'ies'} in the registry ` +
179
+ `${newer.length === 1 ? 'names' : 'name'} a release newer than the installed ${installedVersion.version} ` +
180
+ `(${newer.map((entry) => entry.id).join(', ')}): ${newer.length === 1 ? 'it never runs' : 'they never run'} ` +
181
+ 'here, so an entry written by hand stays "unreleased" until the release names it.');
182
+ }
183
+ }
184
+ catch (error) {
185
+ reportRegistryError(error);
186
+ }
137
187
  // Workspace migrations: `migrations.since` is the release the workspace matches, so newer ones are still to do. A
138
188
  // workspace upgraded by hand (pnpm add + init --update) keeps the old baseline, which is what makes this check work.
139
189
  if (marker !== null) {
@@ -141,7 +191,7 @@ export async function main(args) {
141
191
  const state = migrationState(marker, installedVersion.version);
142
192
  const outstanding = state.manual.length;
143
193
  facts.push(`migrations: since ${state.since}${outstanding > 0 ? `, ${outstanding} manual step(s) outstanding` : ''}`);
144
- const pending = pendingMigrations(MIGRATIONS, state.since, installedVersion.version);
194
+ const pending = pendingMigrations(registry, state.since, installedVersion.version);
145
195
  if (pending.length > 0) {
146
196
  warnings.push(`${pending.length} workspace migration(s) apply between ${state.since} and ${installedVersion.version} ` +
147
197
  `(${pending.map((migration) => migration.id).join(', ')}): run "pnpm asrun migrate".`);
@@ -150,9 +200,17 @@ export async function main(args) {
150
200
  warnings.push(`Manual migration steps are still open (${state.manual.join(', ')}): run "pnpm asrun migrate --list" to see ` +
151
201
  'them, then "pnpm asrun migrate --ack <id>" once they are done.');
152
202
  }
203
+ // Project-owned files whose template moved under the workspace: no command can merge them, so doctor keeps
204
+ // naming them until somebody does it and records it. The prompt is the one entry point that explains the rest.
205
+ const drift = outstandingDrift(scaffoldDrift(ROOT, marker));
206
+ if (drift.length > 0) {
207
+ const shown = drift.map((entry) => `${entry.file} (${entry.state})`).join(', ');
208
+ warnings.push(`${drift.length} project file(s) to align with ${installedVersion.version}: ${shown}. Run ` +
209
+ '"pnpm asrun migrate --prompt" for the task, then "pnpm asrun init --ack <file>" (or "init --force <file>").');
210
+ }
153
211
  }
154
212
  catch (error) {
155
- errors.push(`The migration registry is invalid: ${error instanceof Error ? error.message : String(error)}`);
213
+ reportRegistryError(error);
156
214
  }
157
215
  }
158
216
  // The framework repository compiles itself into `dist/`, and `templates/playwright.config.ts` makes every workspace
@@ -296,6 +354,17 @@ export async function main(args) {
296
354
  warnings.push(`${extra.pkg} is not installed and this machine does not provide it: ${extra.provides} is skipped. Run ` +
297
355
  '"pnpm asrun init" (or "pnpm go") to install it, or install the tool yourself and run doctor again.');
298
356
  }
357
+ // The PDF renderer: "pnpm pdf:render", the exploring agent's look at a downloaded PDF, and every `expectPdf` check.
358
+ const pdf = await pdfRenderer();
359
+ if (pdf.ok) {
360
+ facts.push(`pdf renderer: pdfjs-dist ${pdf.version} (canvas ok)`);
361
+ }
362
+ else {
363
+ // An Error message may already end with a period; the sentence around it must not double it.
364
+ const reason = pdf.reason.replace(/\.\s*$/, '');
365
+ errors.push(`The PDF renderer does not work, so "pnpm pdf:render" and every PDF check fail (${reason}). To fix it, run ` +
366
+ '"pnpm install" in the workspace root (Node 22.13 or newer is required), or install the framework again.');
367
+ }
299
368
  let scripts = null;
300
369
  try {
301
370
  const packageJson = read(path.join(ROOT, 'package.json'));
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { isValidCaseId } from '../lib/case-id.js';
3
- import { caseSha256, exploreNotesDir, exploreNotesFile, noteFreshness, readExploreNotes, } from '../lib/explore-notes.js';
3
+ import { caseSha256, exploreNotesDir, exploreNotesFile, noteCoverage, noteFreshness, readExploreNotes, } from '../lib/explore-notes.js';
4
4
  import { ROOT } from '../lib/paths.js';
5
5
  import { loadProfiles } from '../lib/profiles.js';
6
6
  const USAGE = `Usage: pnpm explore:notes <case-id> [--profile <profile-id>] [--json] [--check] [--hash]
@@ -14,6 +14,9 @@ and falls back to a full replay when it is missing or stale.
14
14
  --check validate every note; fails on a malformed file, warns on a stale one
15
15
  --hash print the case file's sha256, for "caseSha256" in a note
16
16
 
17
+ A note is "full" (it explored every step) or "diff" (an /explore-case --lead run explored only the steps that can
18
+ differ on that profile, and "basedOn" names the lead profile the rest comes from).
19
+
17
20
  Paths are relative to the workspace root (${ROOT}).`;
18
21
  const args = process.argv.slice(2);
19
22
  let caseId;
@@ -98,6 +101,9 @@ if (check) {
98
101
  console.log(`FAIL cases/${caseId}.md does not exist`);
99
102
  }
100
103
  const known = new Set(knownProfiles());
104
+ // A --profile run must still see the lead note a diff note leans on.
105
+ const all = profile === undefined ? notes : readExploreNotes(caseId).notes;
106
+ const byProfile = new Map(all.map((note) => [note.profile, note]));
101
107
  for (const note of notes) {
102
108
  const freshness = noteFreshness(note);
103
109
  if (freshness.state !== 'fresh') {
@@ -106,6 +112,15 @@ if (check) {
106
112
  if (!known.has(note.profile)) {
107
113
  console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: "${note.profile}" is not in profiles.yaml`);
108
114
  }
115
+ if (note.depth !== 'diff' || note.basedOn === undefined)
116
+ continue;
117
+ const lead = byProfile.get(note.basedOn);
118
+ if (lead === undefined) {
119
+ console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: based on "${note.basedOn}", which has no note; /gen-spec replays the steps this note does not hold`);
120
+ }
121
+ else if (lead.depth === 'diff') {
122
+ console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: based on "${note.basedOn}", which is itself a diff note; /gen-spec replays the steps neither note holds`);
123
+ }
109
124
  }
110
125
  if (failed) {
111
126
  console.error('\nA note that fails here is ignored by /gen-spec. Fix it, or delete it and let the case be replayed.');
@@ -119,7 +134,9 @@ function describe(note) {
119
134
  .join(',');
120
135
  const targets = Object.keys(note.targets ?? {}).sort().join(',');
121
136
  const state = freshness.state === 'fresh' ? 'fresh' : `stale (${freshness.reason})`;
122
- return `${state} steps ${steps || '-'} targets ${targets || '-'} captured ${note.capturedAt}${note.updatedAt ? ` refreshed ${note.updatedAt}` : ''}`;
137
+ const coverage = noteCoverage(note);
138
+ const cover = coverage.depth === 'diff' ? `diff, based on ${coverage.basedOn}` : 'full';
139
+ return `${state} ${cover} steps ${steps || '-'} targets ${targets || '-'} captured ${note.capturedAt}${note.updatedAt ? ` refreshed ${note.updatedAt}` : ''}`;
123
140
  }
124
141
  function knownProfiles() {
125
142
  try {
@@ -1,31 +1,11 @@
1
1
  import { MARKER, PACKAGE } from '../lib/paths.js';
2
- /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
3
- export declare const GENERATED: {
4
- file: string;
5
- template: string;
6
- }[];
7
- /** Files `init` creates once; after that the project owns them and may edit them freely. */
8
- export declare const ONCE: {
9
- file: string;
10
- template: string;
11
- }[];
2
+ /**
3
+ * The files `init` writes come from `lib/scaffold.ts`, together with the baseline that records which template a
4
+ * project-owned file is aligned with. They are re-exported here because `doctor`, `reset` and the prompt import them
5
+ * from the command that writes them.
6
+ */
7
+ export { GENERATED, ONCE, frameworkPath, frameworkVersion, isInstalled, renderTemplate } from '../lib/scaffold.js';
12
8
  export { MARKER };
13
9
  export { PACKAGE };
14
10
  export declare const DOTENV_HEADER = "# asrun: the workspace these commands run against (relative to this file).";
15
- /** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
16
- export declare function frameworkPath(root: string): string;
17
- /** True when this workspace has the package installed (or is the framework's own checkout). */
18
- export declare function isInstalled(root: string): boolean;
19
- /** Fills the `{{framework}}`, `{{playwright}}` and `{{asrun}}` placeholders of a template. */
20
- export declare function renderTemplate(name: string, root: string): string;
21
- /**
22
- * The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
23
- * inside the framework repository (examples/demo) has no `node_modules` of its own, so it points at the framework's.
24
- * Kept relative: the file is committed and must not name a machine.
25
- */
26
- export declare function playwrightBin(root: string): string;
27
- export declare function frameworkVersion(): {
28
- version: string;
29
- commit: string;
30
- };
31
11
  export declare function main(args: string[]): Promise<number>;