@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,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
+ }
@@ -0,0 +1 @@
1
+ export declare function main(args: string[]): Promise<number>;
@@ -0,0 +1,48 @@
1
+ import { describeCommand, notificationCommand, sendNotification } from '../lib/desktop-notify.js';
2
+ /*
3
+ * `pnpm asrun notify-test`
4
+ *
5
+ * Shows one desktop notification through the same module the UI server notifies with, and prints what it ran. The
6
+ * point is to separate "the code is wrong" from "this machine hides notifications": a notification that never appears
7
+ * while this command exits 0 means the operating system dropped it, and the hint below says where to look.
8
+ */
9
+ const USAGE = `Usage: pnpm asrun notify-test [--help]
10
+
11
+ Shows one desktop notification and prints the command that showed it. Nothing is written to a case or a report.
12
+
13
+ Use it when "your turn" produced no notification: the UI server sends those through the same command.
14
+ macOS: System Settings > Notifications > the app that runs this (Terminal, iTerm2, ...).
15
+ Windows: Settings > System > Notifications, and turn off Focus assist while you test.
16
+ Linux: install libnotify (the "notify-send" command).`;
17
+ const HINT = {
18
+ darwin: 'If nothing appeared, the system dropped it: open System Settings > Notifications, find the app that runs this ' +
19
+ 'command (Terminal, iTerm2, the editor you run pnpm in) and allow notifications for it. Focus mode hides them too.',
20
+ win32: 'If nothing appeared, check Settings > System > Notifications (and Focus assist), and that a notification banner is ' +
21
+ 'not turned off for Windows PowerShell.',
22
+ linux: 'If nothing appeared, install libnotify (the "notify-send" command) and check that a notification daemon runs in ' +
23
+ 'this session.',
24
+ };
25
+ export async function main(args) {
26
+ if (args.includes('--help')) {
27
+ console.log(USAGE);
28
+ return 0;
29
+ }
30
+ const notification = {
31
+ title: 'AsRun — test',
32
+ body: 'A desktop notification: this is how "your turn in the browser window" reaches you.',
33
+ sound: true,
34
+ };
35
+ const command = notificationCommand(process.platform, notification);
36
+ if (command)
37
+ console.log(`Running: ${describeCommand(command)}`);
38
+ const outcome = await sendNotification(notification);
39
+ if (outcome.ok) {
40
+ console.log('Done: a notification should be on screen now. If it is not, the system dropped it, not asrun.');
41
+ return 0;
42
+ }
43
+ console.error(`No notification: ${outcome.reason ?? 'the command failed'}`);
44
+ const hint = HINT[process.platform];
45
+ if (hint)
46
+ console.error(hint);
47
+ return 1;
48
+ }
@@ -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
  }
@@ -259,8 +259,10 @@ export function buildJobSpec(request) {
259
259
  thinking: request.thinking,
260
260
  force: request.force,
261
261
  attended: request.attended,
262
- // Naming every profile of the environment is the same as leaving it out: the agent then takes all that are logged in.
263
- profiles: request.profiles && request.profiles.length < profilesInEnv.length ? request.profiles : undefined,
262
+ // Always the picked ids, even when they name every profile of the environment: without the flag explore falls back
263
+ // to one logged-in profile per domain, which is not the same as "the profiles you ticked". Heal runs only the
264
+ // profiles that are logged in anyway.
265
+ profiles: request.profiles,
264
266
  hints: hintsPath,
265
267
  report: request.report,
266
268
  };
@@ -13,6 +13,8 @@ export interface LogPart {
13
13
  /** The agent has put a handoff card in the browser window and waits for the person (see user-handoff.md). */
14
14
  export interface AwaitingUser {
15
15
  message: string | null;
16
+ /** The finish condition the card shows in its "DONE WHEN" block; null when the options did not name one. */
17
+ finish: string | null;
16
18
  since: number;
17
19
  }
18
20
  export declare class AiStreamFormatter {
@@ -21,8 +23,12 @@ export declare class AiStreamFormatter {
21
23
  private pending;
22
24
  private cardCalls;
23
25
  private waitingForUser;
24
- /** The message of the last options call, kept so a card that runs after unrelated calls still carries it. */
26
+ /**
27
+ * The message and finish condition of the last options call, kept so a card that runs after unrelated calls still
28
+ * carries them.
29
+ */
25
30
  private lastHandoffMessage;
31
+ private lastHandoffFinish;
26
32
  private lastEventAt;
27
33
  private lastWaitAt;
28
34
  /** The last call the agent finished, so a quiet spell can say what it is doing instead of only that nothing came. */