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