@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
@@ -0,0 +1,272 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { FRAMEWORK_ROOT } from '../lib/paths.js';
5
+ import { compareVersions, isVersion, UNRELEASED } from '../lib/migrations.js';
6
+ import { MIGRATIONS } from '../migrations/index.js';
7
+ import { GENERATED, ONCE } from '../lib/scaffold.js';
8
+ /**
9
+ * `pnpm migrations:check`: the release discipline of this repository, enforced instead of remembered.
10
+ *
11
+ * A change to something an *existing* workspace has to do something about needs a migration entry, and a migration
12
+ * entry needs the right shape. The check compares the working tree with the newest release tag and answers three
13
+ * questions: which surfaces changed, which entries cover them, and is every entry valid. It runs in CI on every pull
14
+ * request, so a forgotten entry stops the change instead of reaching a user as a silently stale workspace.
15
+ */
16
+ const USAGE = `Usage: pnpm migrations:check [--base <ref>] [--json]
17
+
18
+ --base <ref> compare with this ref (default: the newest asrun-v* / v* tag)
19
+ --json print the result as JSON
20
+
21
+ Exit code 1 when a changed surface has no migration entry, or an entry is invalid.
22
+ An entry names "unreleased" or a release that already has a tag; the one exception is the version package.json
23
+ declares, which is the release an open release pull request prepares and which gets its tag at the merge.
24
+ What needs an entry: templates/** of the once-only files (AGENTS.md, .mcp.json, ...), the case document format, the
25
+ rule ids, the peer dependency range. What does not: everything init --update or init's merge rewrites by itself.`;
26
+ /** Files whose change alters what an existing workspace must do, beyond what `init` refreshes by itself. */
27
+ const FORMAT_FILES = [
28
+ {
29
+ file: 'AGENTS.md',
30
+ names: ['AGENTS.md'],
31
+ why: 'the baseline rules and the fixed tokens live there; a workspace copies parts of them',
32
+ },
33
+ {
34
+ file: 'docs/agents/case-format.md',
35
+ names: ['cases/**'],
36
+ why: 'the case document contract changed',
37
+ },
38
+ { file: 'lib/case-doc.ts', names: ['cases/**'], why: 'the case document parser and its tokens changed' },
39
+ { file: 'lib/targets.ts', names: ['cases/**'], why: 'target cards changed' },
40
+ { file: 'lib/values.ts', names: ['cases/**'], why: 'the Values block changed' },
41
+ { file: 'lib/records.ts', names: ['cases/**'], why: 'the test-data name format changed' },
42
+ { file: 'lib/rules.ts', names: ['AGENTS.md'], why: 'how project rules are read or validated changed' },
43
+ ];
44
+ function git(args) {
45
+ return execFileSync('git', args, { cwd: FRAMEWORK_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
46
+ }
47
+ function tryGit(args) {
48
+ try {
49
+ return git(args);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** The newest release tag, so the check compares against what users have rather than against the last commit. */
56
+ function newestTag() {
57
+ const tags = tryGit(['for-each-ref', '--sort=-v:refname', '--format=%(refname:short)', 'refs/tags']);
58
+ if (tags === null)
59
+ return null;
60
+ return tags.split('\n').map((tag) => tag.trim()).find((tag) => /^asrun-v\d/.test(tag) || /^v\d/.test(tag)) ?? null;
61
+ }
62
+ /** Changed and untracked paths, relative to the repository root. */
63
+ function changedFiles(base) {
64
+ const diff = tryGit(['diff', '--name-only', base, '--']);
65
+ const untracked = tryGit(['ls-files', '--others', '--exclude-standard']);
66
+ const files = new Set();
67
+ for (const list of [diff, untracked]) {
68
+ for (const line of (list ?? '').split('\n')) {
69
+ const file = line.trim();
70
+ if (file !== '')
71
+ files.add(file.split('\\').join('/'));
72
+ }
73
+ }
74
+ return [...files].sort();
75
+ }
76
+ /** True when the change to `package.json` touches the peer dependency range, which a workspace has to follow. */
77
+ function peerDependencyChanged(base) {
78
+ const file = path.join(FRAMEWORK_ROOT, 'package.json');
79
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : tryGit(['show', ':package.json']);
80
+ const previous = tryGit(['show', `${base}:package.json`]);
81
+ if (current === null || previous === null)
82
+ return false;
83
+ const peers = (text) => {
84
+ try {
85
+ return JSON.stringify(JSON.parse(text).peerDependencies ?? null);
86
+ }
87
+ catch {
88
+ return '';
89
+ }
90
+ };
91
+ return peers(current) !== peers(previous);
92
+ }
93
+ /** The surfaces a change list touches. */
94
+ function surfaces(files, base) {
95
+ const found = [];
96
+ for (const file of files) {
97
+ const once = ONCE.find((entry) => `templates/${entry.template}` === file);
98
+ if (once !== undefined) {
99
+ // `package.json` is merged by init (scripts and devDependencies) and never replaced, so a change there only needs
100
+ // an entry when the peer dependency range moves, which the workspace has to follow by hand.
101
+ if (once.file === 'package.json')
102
+ continue;
103
+ found.push({ file, names: [once.file], why: `templates/${once.file} is a project-owned file that init never rewrites` });
104
+ continue;
105
+ }
106
+ const generated = GENERATED.find((entry) => `templates/${entry.template}` === file);
107
+ if (generated !== undefined)
108
+ continue; // init --update rewrites it; no entry needed
109
+ const format = FORMAT_FILES.find((entry) => entry.file === file);
110
+ if (format !== undefined)
111
+ found.push(format);
112
+ }
113
+ if (files.includes('package.json') && peerDependencyChanged(base)) {
114
+ found.push({ file: 'package.json', names: ['package.json'], why: 'the peer dependency range changed and a workspace has to follow it' });
115
+ }
116
+ return found;
117
+ }
118
+ /** True when an entry says it is about one of the names a surface requires. */
119
+ function covers(entry, surface) {
120
+ const files = entry.files ?? [];
121
+ return surface.names.some((name) => files.some((file) => {
122
+ const normalized = file.split('\\').join('/').replace(/^\.\//, '');
123
+ if (normalized === name)
124
+ return true;
125
+ // A `cases/**` name covers a case-format change; a specific case file does not.
126
+ return name.endsWith('/**') && normalized.startsWith(name.slice(0, -2));
127
+ }));
128
+ }
129
+ /** Why an entry is invalid, in the words of the person who wrote it. */
130
+ function entryProblems(entry, pending) {
131
+ const problems = [];
132
+ if (!/^([0-9]+\.[0-9]+\.[0-9]+|unreleased)-[a-z0-9][a-z0-9-]*$/.test(entry.id)) {
133
+ problems.push(`id "${entry.id}" is not <version>-<slug> or unreleased-<slug>`);
134
+ }
135
+ else if (!entry.id.startsWith(`${entry.version}-`)) {
136
+ // The id is what a person sees in `doctor` and in the upgrade output, so it has to name the version the entry
137
+ // declares; freezing writes both together, and a hand edit that moves only one of them leaves a lie behind.
138
+ problems.push(`id "${entry.id}" does not start with "${entry.version}-", the version the entry declares`);
139
+ }
140
+ if (entry.version !== UNRELEASED && !isVersion(entry.version)) {
141
+ problems.push(`version "${entry.version}" is not an x.y.z version (use "${UNRELEASED}" while the release is open)`);
142
+ }
143
+ if ((entry.manual ?? []).length === 0 && entry.auto !== true && entry.advisory !== true) {
144
+ problems.push('it has neither "manual" steps, "auto: true" nor "advisory: true", so nothing would ever clear it');
145
+ }
146
+ if (entry.version !== UNRELEASED && isVersion(entry.version)) {
147
+ // A migration runs only between the release a workspace came from and the release that is installed, so an entry
148
+ // above the installed release is silently skipped. The version in package.json is the ceiling: on a release pull
149
+ // request it is the release being prepared (frozen entries, no tag until the merge) and everywhere else it is the
150
+ // installed release itself.
151
+ const isPending = pending !== null && compareVersions(entry.version, pending) === 0;
152
+ if (pending !== null && compareVersions(entry.version, pending) > 0) {
153
+ problems.push(`version ${entry.version} is newer than ${pending} in package.json, the release this checkout prepares: ` +
154
+ `a migration above the installed release never runs. Use "${UNRELEASED}".`);
155
+ }
156
+ else if (!isPending && !releaseTagExists(entry.version)) {
157
+ const prepared = pending === null ? 'package.json names no readable version' : `package.json prepares ${pending}`;
158
+ problems.push(`version ${entry.version} has no release tag, and it is not the release this checkout prepares (${prepared}): ` +
159
+ `an entry names the release that ships it, so a version that names neither is a mistake. Use "${UNRELEASED}" ` +
160
+ 'and let the release pull request name it.');
161
+ }
162
+ }
163
+ for (const file of entry.files ?? []) {
164
+ const normalized = file.split('\\').join('/').replace(/^\.\//, '');
165
+ const known = ONCE.some((candidate) => candidate.file === normalized) || normalized.startsWith('cases/') || normalized === 'package.json';
166
+ if (!known)
167
+ problems.push(`files names "${file}", which is not a project-owned file this repository knows`);
168
+ }
169
+ return problems;
170
+ }
171
+ function releaseTagExists(version) {
172
+ const tags = tryGit(['tag', '--list']);
173
+ if (tags === null)
174
+ return true; // no git here: do not block on an environment that cannot answer
175
+ return tags.split('\n').some((tag) => tag.trim() === `asrun-v${version}` || tag.trim() === `v${version}`);
176
+ }
177
+ /**
178
+ * The version package.json declares. On a release pull request release-please has already bumped it and
179
+ * `release-freeze.yml` has frozen the entries to it, while the tag only appears when that pull request is merged:
180
+ * this is the one version the check accepts without a tag, and the ceiling every entry has to stay under.
181
+ */
182
+ function pendingVersion() {
183
+ try {
184
+ const file = path.join(FRAMEWORK_ROOT, 'package.json');
185
+ const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
186
+ return typeof manifest.version === 'string' && isVersion(manifest.version) ? manifest.version : null;
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ export function main(args) {
193
+ if (args.includes('--help') || args.includes('-h')) {
194
+ console.log(USAGE);
195
+ return 0;
196
+ }
197
+ const value = (name) => {
198
+ const index = args.indexOf(name);
199
+ return index >= 0 && args[index + 1] !== undefined && !args[index + 1].startsWith('-') ? args[index + 1] : undefined;
200
+ };
201
+ const base = value('--base') ?? newestTag();
202
+ if (base === null) {
203
+ console.error('No release tag to compare with: pass --base <ref> (for example the first commit).');
204
+ return 1;
205
+ }
206
+ const files = changedFiles(base);
207
+ const found = surfaces(files, base);
208
+ const pending = pendingVersion();
209
+ const problems = [];
210
+ const ids = new Set();
211
+ for (const entry of MIGRATIONS) {
212
+ if (ids.has(entry.id))
213
+ problems.push(`${entry.id}: two migrations share this id`);
214
+ ids.add(entry.id);
215
+ for (const problem of entryProblems(entry, pending))
216
+ problems.push(`${entry.id}: ${problem}`);
217
+ }
218
+ const missing = found.filter((surface) => !MIGRATIONS.some((entry) => covers(entry, surface)));
219
+ if (args.includes('--json')) {
220
+ console.log(JSON.stringify({ base, pending, files, surfaces: found, missing, problems }, null, 2));
221
+ }
222
+ else {
223
+ console.log(`migrations:check base ${base}`);
224
+ if (pending !== null && !releaseTagExists(pending)) {
225
+ console.log(` ${pending} is the release this checkout prepares (package.json): its tag comes with the merge`);
226
+ }
227
+ const generated = files.filter((file) => GENERATED.some((entry) => `templates/${entry.template}` === file));
228
+ if (found.length === 0)
229
+ console.log(' no surface changed that needs a migration entry');
230
+ for (const surface of found) {
231
+ const covered = !missing.includes(surface);
232
+ console.log(` ${covered ? 'ok ' : '-> '}${surface.file.padEnd(34)} ${covered ? 'covered' : 'needs a migration entry'}`);
233
+ console.log(` ${surface.why}`);
234
+ }
235
+ for (const file of generated)
236
+ console.log(` ${file.padEnd(34)} generated: init --update refreshes it, no entry`);
237
+ if (missing.length > 0) {
238
+ console.log(`\n${missing.length} surface(s) have no migration entry. Add one to migrations/index.ts:`);
239
+ console.log(entryTemplate(missing));
240
+ }
241
+ for (const problem of problems)
242
+ console.log(`\n ! ${problem}`);
243
+ }
244
+ if (missing.length > 0 || problems.length > 0)
245
+ return 1;
246
+ if (!args.includes('--json'))
247
+ console.log('\nEvery changed surface is covered, and every entry is valid.');
248
+ return 0;
249
+ }
250
+ /** A ready-to-paste entry for the missing surfaces, so the check is faster to satisfy than to ignore. */
251
+ function entryTemplate(missing) {
252
+ const files = [...new Set(missing.flatMap((surface) => surface.names))];
253
+ return [
254
+ '',
255
+ ' {',
256
+ ` id: '${UNRELEASED}-<slug>',`,
257
+ ` version: '${UNRELEASED}',`,
258
+ " summary: '<one line for the person>',",
259
+ " appliesTo: '<which workspaces this matters for>',",
260
+ " details: '<what changed and why>',",
261
+ ` files: [${files.map((file) => `'${file}'`).join(', ')}],`,
262
+ ' manual: [',
263
+ " '<what a person or an AI has to do, one step per line>',",
264
+ ' ],',
265
+ ' },',
266
+ '',
267
+ `No version: release-please names it in the release pull request, and "pnpm migrations:freeze" writes it then.`,
268
+ ].join('\n');
269
+ }
270
+ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
271
+ process.exit(main(process.argv.slice(2)));
272
+ }
@@ -0,0 +1 @@
1
+ export declare function main(args: string[]): number;
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { FRAMEWORK_ROOT } from '../lib/paths.js';
5
+ import { UNRELEASED, isVersion } from '../lib/migrations.js';
6
+ import { MIGRATIONS } from '../migrations/index.js';
7
+ /**
8
+ * `pnpm migrations:freeze`: writes the release's version into the entries that are still `unreleased`.
9
+ *
10
+ * Release Please names the version only once the release pull request exists, so an author never guesses one. Entries
11
+ * carry `version: 'unreleased'` until then; `scripts/build.ts` and `pnpm asrun migrate` resolve it to the installed
12
+ * release, so a release is always correct even if nobody freezes. Freezing only fixes the *id* that a workspace will
13
+ * see in `doctor` and in the upgrade output (`unreleased-agents-scaffold` → `0.15.0-agents-scaffold`), which is why the
14
+ * release pull request runs it.
15
+ *
16
+ * The version comes from `package.json`, which the release pull request has already bumped beyond the newest tag.
17
+ */
18
+ const USAGE = `Usage: pnpm migrations:freeze [--version <x.y.z>] [--check] [--dry-run]
19
+
20
+ --version <v> the release to freeze to (default: package.json, which must be newer than the newest tag)
21
+ --check exit 1 when an entry is still "unreleased"; change nothing (used before publishing)
22
+ --dry-run show what would change, and change nothing`;
23
+ const REGISTRY = path.join(FRAMEWORK_ROOT, 'migrations', 'index.ts');
24
+ function git(args) {
25
+ try {
26
+ return execFileSync('git', args, { cwd: FRAMEWORK_ROOT, encoding: 'utf8' }).trim();
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** The versions of the release tags, newest first; used to tell "the release is open" from "not yet". */
33
+ function tags() {
34
+ const list = git(['for-each-ref', '--sort=-v:refname', '--format=%(refname:short)', 'refs/tags']);
35
+ if (list === null)
36
+ return [];
37
+ return list
38
+ .split('\n')
39
+ .map((tag) => tag.trim())
40
+ .filter((tag) => /^(asrun-)?v\d/.test(tag))
41
+ .map((tag) => tag.replace(/^asrun-v/, '').replace(/^v/, ''));
42
+ }
43
+ function currentVersion() {
44
+ const manifest = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8'));
45
+ return manifest.version ?? '0.0.0';
46
+ }
47
+ /** The entries that still carry the sentinel, as ids and line numbers, for `--check` and the summary. */
48
+ function pendingEntries() {
49
+ return MIGRATIONS.filter((entry) => entry.version === UNRELEASED || entry.id.startsWith(`${UNRELEASED}-`)).map((entry) => entry.id);
50
+ }
51
+ /** Replaces the sentinel with the release version: `version: 'unreleased'` and the `unreleased-` id prefix. */
52
+ function freezeText(text, version) {
53
+ let replacements = 0;
54
+ const next = text
55
+ .replace(/(\bversion:\s*)(['"`])unreleased\2/g, (_match, head, quote) => {
56
+ replacements++;
57
+ return `${head}${quote}${version}${quote}`;
58
+ })
59
+ .replace(/(\bid:\s*)(['"`])unreleased-/g, (_match, head, quote) => {
60
+ replacements++;
61
+ return `${head}${quote}${version}-`;
62
+ });
63
+ return { next, replacements };
64
+ }
65
+ export function main(args) {
66
+ if (args.includes('--help') || args.includes('-h')) {
67
+ console.log(USAGE);
68
+ return 0;
69
+ }
70
+ const value = (name) => {
71
+ const index = args.indexOf(name);
72
+ return index >= 0 && args[index + 1] !== undefined && !args[index + 1].startsWith('-') ? args[index + 1] : undefined;
73
+ };
74
+ const check = args.includes('--check');
75
+ const dryRun = args.includes('--dry-run');
76
+ const pending = pendingEntries();
77
+ if (check) {
78
+ if (pending.length === 0) {
79
+ console.log('Every migration entry names a release.');
80
+ return 0;
81
+ }
82
+ console.error(`${pending.length} migration entr${pending.length === 1 ? 'y is' : 'ies are'} still "unreleased":`);
83
+ for (const id of pending)
84
+ console.error(` ${id}`);
85
+ console.error('\nRun "pnpm migrations:freeze" on the release branch and push it before the release is cut.');
86
+ return 1;
87
+ }
88
+ if (pending.length === 0) {
89
+ console.log('Nothing to freeze: every migration entry already names a release.');
90
+ return 0;
91
+ }
92
+ const installed = currentVersion();
93
+ const version = value('--version') ?? installed;
94
+ if (!isVersion(version)) {
95
+ console.error(`"${version}" is not a version. Pass --version x.y.z.`);
96
+ return 1;
97
+ }
98
+ const released = tags();
99
+ if (value('--version') === undefined && released.includes(version)) {
100
+ console.error(`package.json is still ${version}, which is already a release tag: the release pull request has not bumped it yet.\n` +
101
+ 'Wait for the release pull request, then run this on that branch (or pass --version explicitly).');
102
+ return 1;
103
+ }
104
+ const text = fs.readFileSync(REGISTRY, 'utf8');
105
+ const { next, replacements } = freezeText(text, version);
106
+ if (replacements === 0) {
107
+ console.log(`No sentinel text found in ${path.relative(FRAMEWORK_ROOT, REGISTRY)}; nothing to do.`);
108
+ return 0;
109
+ }
110
+ if (dryRun) {
111
+ console.log(`Would freeze ${pending.length} entr${pending.length === 1 ? 'y' : 'ies'} to ${version} (${replacements} replacements).`);
112
+ for (const id of pending)
113
+ console.log(` ${id} -> ${id.replace(new RegExp(`^${UNRELEASED}-`), `${version}-`)}`);
114
+ console.log('Nothing was written (--dry-run).');
115
+ return 0;
116
+ }
117
+ fs.writeFileSync(REGISTRY, next);
118
+ console.log(`Froze ${pending.length} migration entr${pending.length === 1 ? 'y' : 'ies'} to ${version}:`);
119
+ for (const id of pending)
120
+ console.log(` ${id} -> ${id.replace(new RegExp(`^${UNRELEASED}-`), `${version}-`)}`);
121
+ console.log('\nNext: "pnpm build" (the manifest in dist/), then "pnpm migrations:check".');
122
+ return 0;
123
+ }
124
+ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
125
+ process.exit(main(process.argv.slice(2)));
126
+ }
@@ -7,6 +7,8 @@ import { compareVersions, isVersion, migrationState, pendingMigrations, readMark
7
7
  import { FRAMEWORK_ROOT, MARKER, ROOT } from '../lib/paths.js';
8
8
  import { releaseInfo, releaseManifest } from '../lib/registry.js';
9
9
  import { excludeReleaseAge } from '../lib/runtime-deps.js';
10
+ import { nextStepsText, upgradePrompt } from '../lib/upgrade-prompt.js';
11
+ import { useColor } from '../lib/term.js';
10
12
  import { frameworkPath, PACKAGE } from './init.js';
11
13
  /**
12
14
  * `pnpm asrun upgrade`: one command that installs the newest framework, refreshes the workspace files and applies the
@@ -16,12 +18,14 @@ import { frameworkPath, PACKAGE } from './init.js';
16
18
  * changes are known before anything is written; the install comes next, and `init --update`, `migrate` and `doctor`
17
19
  * then run through the *newly installed* package, never through the code that is being replaced.
18
20
  */
19
- const USAGE = `Usage: pnpm asrun upgrade [--version <version|dist-tag>] [--root <dir>] [--dry-run] [--yes]
21
+ const USAGE = `Usage: pnpm asrun upgrade [--version <version|dist-tag>] [--root <dir>] [--dry-run] [--yes|-y] [--prompt [--json]]
20
22
 
21
23
  --version <v> the release to install (default: latest); a dist-tag or a concrete version
22
24
  --root <dir> the workspace to upgrade (default: the current workspace)
23
25
  --dry-run print the target release and the migrations it brings, and change nothing
24
- --yes install without asking (a breaking change, or a release that cannot be checked)
26
+ --yes, -y install without asking (a breaking change, or a release that cannot be checked)
27
+ --prompt print the prompt that hands the leftover work to an AI agent, and change nothing (local only)
28
+ --json with --prompt: the same answer as JSON, for the web UI
25
29
 
26
30
  What it does, in this order:
27
31
  1. resolve the target release and read its migration manifest from the registry;
@@ -30,7 +34,11 @@ What it does, in this order:
30
34
  4. run "init --update", "migrate" and "doctor" through the installed package.
31
35
 
32
36
  A release older than the migration manifest cannot be checked in advance: the command says so and asks before it
33
- installs. "pnpm asrun migrate" runs only the migration part, for a workspace that was upgraded by hand.`;
37
+ installs. "pnpm asrun migrate" runs only the migration part, for a workspace that was upgraded by hand.
38
+
39
+ When the release changes files the project owns (AGENTS.md and the other once-only files), the upgrade ends with a
40
+ NEXT block. "pnpm asrun upgrade --prompt" prints the task for an AI agent (or run "/upgrade-workspace" in Claude Code or
41
+ OpenCode).`;
34
42
  /** How a workspace reaches its install: the nearest `node_modules/@asrunjs/asrun` at or above the workspace root. */
35
43
  function installedDir(root) {
36
44
  for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
@@ -53,20 +61,27 @@ function installedVersion(dir) {
53
61
  return null;
54
62
  }
55
63
  }
56
- /** A person types the word; without a terminal there is no confirmation, and the command refuses. */
64
+ /**
65
+ * A person answers y/N; an empty line, or anything that is not "y"/"yes", means no. Without a terminal there is nobody
66
+ * to ask, and the command refuses instead of assuming an answer (`--yes` is the way through).
67
+ */
57
68
  async function confirm(question) {
58
69
  if (!process.stdin.isTTY || !process.stdout.isTTY)
59
70
  return null;
60
71
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
61
72
  try {
62
- return (await rl.question(question)).trim().toLowerCase() === 'upgrade';
73
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
74
+ return answer === 'y' || answer === 'yes';
63
75
  }
64
76
  finally {
65
77
  rl.close();
66
78
  }
67
79
  }
68
80
  function printMigration(entry) {
69
- const tags = [entry.breaking === true ? 'breaking' : '', entry.auto === true ? 'runs automatically' : 'needs a person']
81
+ const tags = [
82
+ entry.breaking === true ? 'breaking' : '',
83
+ entry.advisory === true ? 'nothing to do' : entry.auto === true ? 'runs automatically' : 'needs a person',
84
+ ]
70
85
  .filter(Boolean)
71
86
  .join(', ');
72
87
  console.log(` ${entry.breaking === true ? '!' : ' '} ${entry.version} ${entry.summary}`);
@@ -89,7 +104,9 @@ export async function main(args) {
89
104
  };
90
105
  const root = path.resolve(value('--root') ?? ROOT);
91
106
  const dryRun = args.includes('--dry-run');
92
- const yes = args.includes('--yes');
107
+ const yes = args.includes('--yes') || args.includes('-y');
108
+ const promptOnly = args.includes('--prompt');
109
+ const asJson = args.includes('--json');
93
110
  const spec = value('--version') ?? 'latest';
94
111
  if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
95
112
  console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun upgrade" in the project that uses ' +
@@ -100,6 +117,17 @@ export async function main(args) {
100
117
  console.error(`No ${MARKER} in ${root}: this is not an initialized asrun workspace. Run "pnpm asrun init" first.`);
101
118
  return 1;
102
119
  }
120
+ // `--prompt` answers "what is left" for the release that is already installed: it reads the workspace and the
121
+ // installed package only, so it works offline and changes nothing (not even the update cache).
122
+ if (promptOnly) {
123
+ const payload = upgradePrompt(root);
124
+ console.log(asJson ? JSON.stringify(payload, null, 2) : payload.prompt);
125
+ return 0;
126
+ }
127
+ if (asJson) {
128
+ console.error('--json belongs to --prompt: "pnpm asrun upgrade --prompt --json".');
129
+ return 1;
130
+ }
103
131
  console.log('[1/4] Checking the release');
104
132
  const directory = installedDir(root);
105
133
  const current = installedVersion(directory);
@@ -177,12 +205,17 @@ export async function main(args) {
177
205
  return 0;
178
206
  }
179
207
  if (needsConfirmation) {
208
+ if (breaking.length > 0) {
209
+ console.log('');
210
+ for (const entry of breaking)
211
+ console.log(` ! ${entry.id} ${entry.summary}`);
212
+ }
180
213
  const question = breaking.length > 0
181
- ? `\nType "upgrade" to install ${PACKAGE}@${target} despite ${breaking.length} breaking change(s): `
182
- : `\nType "upgrade" to install ${PACKAGE}@${target} without the pre-install check: `;
214
+ ? `\nInstall ${PACKAGE}@${target} in ${root}, despite ${breaking.length} breaking change(s)?`
215
+ : `\nInstall ${PACKAGE}@${target} in ${root} without the pre-install check?`;
183
216
  const answer = await confirm(question);
184
217
  if (answer === null) {
185
- console.error('Refusing to install: there is no terminal to confirm in. A person adds --yes to do it.');
218
+ console.error('Refusing to install: there is no terminal to confirm in. A person adds --yes (or -y) to do it.');
186
219
  return 1;
187
220
  }
188
221
  if (!answer) {
@@ -190,6 +223,9 @@ export async function main(args) {
190
223
  return 1;
191
224
  }
192
225
  }
226
+ else if (yes) {
227
+ console.log(`\nInstalling without asking (--yes).`);
228
+ }
193
229
  console.log(`\n[2/4] Installing ${PACKAGE}@${target}`);
194
230
  const age = excludeReleaseAge(root, PACKAGE);
195
231
  if (age === 'added' || age === 'created') {
@@ -252,5 +288,12 @@ export async function main(args) {
252
288
  console.log(`\n[4/4] Checking the workspace`);
253
289
  const doctor = run(['doctor']);
254
290
  console.log(`\nUpgraded ${PACKAGE} ${current} -> ${after ?? target} in ${root}.`);
291
+ // The release is installed; what is left are files the project owns and the manual steps a migration recorded. The
292
+ // block is printed only when there is something to do, so a patch release stays quiet.
293
+ const next = nextStepsText(root, { color: useColor(process.stdout) });
294
+ if (next === null)
295
+ console.log('Nothing in the project\'s own files needs updating.');
296
+ else
297
+ console.log(`\n${next}`);
255
298
  return doctor.status ?? 0;
256
299
  }
@@ -15,6 +15,7 @@ import { JobConflictError, jobs } from './jobs.js';
15
15
  import { listAiModels } from './ai-models.js';
16
16
  import { fileEvents } from './watch-files.js';
17
17
  import { refreshUpdate, updateEvents, updateInfo } from './update.js';
18
+ import { upgradePrompt } from '../../lib/upgrade-prompt.js';
18
19
  const REPORTS_DIR = path.join(ROOT, 'reports');
19
20
  // Built by `prepack` and shipped with the package; the framework repository builds it with `pnpm ui`.
20
21
  const WEB_DIST = path.join(FRAMEWORK_ROOT, 'ui', 'dist');
@@ -240,6 +241,9 @@ export function createApp({ port }) {
240
241
  void refreshUpdate(true);
241
242
  return c.json(info);
242
243
  });
244
+ // What an upgrade leaves open, and the prompt that hands it to an agent. Read-only; the dialog asks for it only when
245
+ // it opens, because it reads the installed templates and can be long.
246
+ api.get('/upgrade/prompt', (c) => c.json(upgradePrompt(ROOT)));
243
247
  api.get('/reports', (c) => {
244
248
  if (!fs.existsSync(REPORTS_DIR))
245
249
  return c.json([]);
@@ -348,6 +348,12 @@ export interface ReportInfo {
348
348
  counts: Record<ResultStatus, number> | null;
349
349
  }
350
350
  export type { ParsedError, ResultStatus, RunSettings, RunSummary, TestRunResult } from '../../lib/run-summary.js';
351
+ /**
352
+ * What an upgrade leaves to a person or an AI, and the prompt that hands it over. Built by `lib/upgrade-prompt.ts`,
353
+ * the same module `pnpm asrun upgrade --prompt --json` prints.
354
+ */
355
+ export type { ScaffoldDrift, ScaffoldState } from '../../lib/scaffold.js';
356
+ export type { OutstandingFile, OutstandingMigration, UpgradeOutstanding, UpgradePromptPayload } from '../../lib/upgrade-prompt.js';
351
357
  export interface InfoResponse {
352
358
  root: string;
353
359
  envs: string[];
@@ -149,6 +149,8 @@ One card per distinct thing a check looks at:
149
149
  - `What`, `Where` and `Identified by` are required for a card that is not blocking.
150
150
  - `Reference <profile-id>:` must point under `.explore/targets/<case-id>/` (git-ignored), or be `none: <reason>`.
151
151
  A missing file on this machine is a warning, not a failure: a fresh checkout has the card but not the screenshot.
152
+ A card carries one line per profile that was looked at separately: an `--lead` exploration adds its own line for a
153
+ profile whose element differs, and `none: <reason>` for one that shares the lead's screenshot.
152
154
  - A card may hold a `🚫 TODO [blocking]` block. Such a card is not complete; `pnpm case:check` does not fail on it,
153
155
  and the check that uses it is not generated yet.
154
156
  - Every `[T<n>]` in a step, a ready signal or a check resolves to a card. `pnpm case:check` fails on a reference with
@@ -208,6 +210,10 @@ test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
208
210
  by `pnpm asrun rules`) and the stamp is the run's UTC time; register it with `records.track` right after creating
209
211
  it, remove it newest first (also after a failure), turn retries off with `configureCase`, and keep email steps
210
212
  behind `emailStepAllowed(profile, '<step title>')`.
213
+ - `/explore-case --lead` explores one profile in full and the others only where they can differ. What a diff profile
214
+ adds to the case is its `## Values` entry and its `Reference <profile-id>:` line, never a second copy of the steps;
215
+ `/gen-spec` writes those profiles' parts from the lead's note and names them `not verified`. A step whose recipe
216
+ would differ per profile has no per-profile form (`- Recipe:` belongs to the step): split the case, or ask.
211
217
 
212
218
  ## Checks to run before reporting
213
219