@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.
- package/.claude/commands/explore-case.md +8 -3
- package/.claude/skills/exploring-cases/SKILL.md +72 -14
- package/.claude/skills/exploring-cases/references/handoff-card.js +81 -7
- package/.claude/skills/exploring-cases/references/notes.md +42 -4
- package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
- package/.claude/skills/exploring-cases/references/readiness.md +6 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +60 -27
- package/.claude/skills/generating-specs/SKILL.md +10 -3
- package/AGENTS.md +12 -4
- package/README.md +14 -2
- package/dist/lib/desktop-notify.d.ts +38 -0
- package/dist/lib/desktop-notify.js +158 -0
- package/dist/lib/explore-notes.d.ts +15 -0
- package/dist/lib/explore-notes.js +34 -1
- package/dist/lib/migrations.d.ts +43 -4
- package/dist/lib/migrations.js +39 -7
- package/dist/lib/paths.d.ts +9 -2
- package/dist/lib/paths.js +32 -5
- package/dist/lib/pdf.js +23 -3
- package/dist/lib/scaffold.d.ts +87 -0
- package/dist/lib/scaffold.js +220 -0
- package/dist/lib/term.d.ts +36 -0
- package/dist/lib/term.js +77 -0
- package/dist/lib/update-check.d.ts +1 -7
- package/dist/lib/update-check.js +3 -15
- package/dist/lib/upgrade-prompt.d.ts +84 -0
- package/dist/lib/upgrade-prompt.js +233 -0
- package/dist/migrations/index.d.ts +12 -4
- package/dist/migrations/index.js +40 -5
- package/dist/migrations/manifest.json +27 -1
- package/dist/scripts/build.js +4 -1
- package/dist/scripts/check-cases.js +3 -3
- package/dist/scripts/cli.js +11 -3
- package/dist/scripts/doctor.js +73 -4
- package/dist/scripts/explore-notes.js +19 -2
- package/dist/scripts/init.d.ts +6 -26
- package/dist/scripts/init.js +82 -101
- package/dist/scripts/migrate.js +84 -21
- package/dist/scripts/migrations-check.d.ts +1 -0
- package/dist/scripts/migrations-check.js +272 -0
- package/dist/scripts/migrations-freeze.d.ts +1 -0
- package/dist/scripts/migrations-freeze.js +126 -0
- package/dist/scripts/notify-test.d.ts +1 -0
- package/dist/scripts/notify-test.js +48 -0
- package/dist/scripts/upgrade.js +53 -10
- package/dist/ui/server/actions.js +4 -2
- package/dist/ui/server/ai-stream.d.ts +7 -1
- package/dist/ui/server/ai-stream.js +26 -7
- package/dist/ui/server/app.js +4 -0
- package/dist/ui/server/jobs.d.ts +10 -0
- package/dist/ui/server/jobs.js +41 -1
- package/dist/ui/shared/ai-commands.js +4 -2
- package/dist/ui/shared/types.d.ts +8 -0
- package/docs/agents/case-format.md +8 -1
- package/docs/agents/framework-dev.md +44 -5
- package/docs/ai-workflow.md +27 -10
- package/docs/cases.md +5 -5
- package/docs/commands.md +73 -15
- package/docs/install.md +18 -5
- package/docs/troubleshooting.md +17 -0
- package/docs/web-ui.md +23 -8
- package/package.json +5 -2
- package/templates/.claude/commands/explore-case.md +9 -3
- package/templates/.claude/commands/upgrade-workspace.md +21 -0
- package/templates/AGENTS.md +1 -1
- package/templates/package.json +1 -1
- package/ui/dist/assets/{dist-C46EmZuI.js → dist-B4pM62Hc.js} +1 -1
- package/ui/dist/assets/{dist-DGzg2gyA.js → dist-BFADGd4p.js} +1 -1
- package/ui/dist/assets/{dist-P9roUi1O.js → dist-Bd-TmWTD.js} +1 -1
- package/ui/dist/assets/{dist-DMiOs9Qj.js → dist-Bt053MSo.js} +1 -1
- package/ui/dist/assets/{dist-DrFzCwMc.js → dist-CD9RixNE.js} +1 -1
- package/ui/dist/assets/{dist-_0g6iC9K.js → dist-ChACtLiA.js} +1 -1
- package/ui/dist/assets/{dist-D9GmSkjm.js → dist-CiW9sEPF.js} +1 -1
- package/ui/dist/assets/{dist-Db_XhDui.js → dist-CxEpzoZU.js} +1 -1
- package/ui/dist/assets/{dist-BB2cF3XN.js → dist-Dh7um7By.js} +1 -1
- package/ui/dist/assets/{dist-Dy45nXf8.js → dist-gHwKkZ6g.js} +1 -1
- package/ui/dist/assets/{index-BN4ucmmn.js → index-Ckax27uf.js} +158 -158
- 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
|
+
}
|
package/dist/scripts/upgrade.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
? `\
|
|
182
|
-
: `\
|
|
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
|
-
//
|
|
263
|
-
|
|
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
|
-
/**
|
|
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. */
|