@asrunjs/asrun 0.10.0 → 0.11.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 (39) hide show
  1. package/.claude/skills/exploring-cases/SKILL.md +45 -58
  2. package/.claude/skills/exploring-cases/references/notes.md +87 -0
  3. package/.claude/skills/exploring-cases/references/readiness.md +3 -2
  4. package/.claude/skills/exploring-cases/references/tools.md +34 -0
  5. package/.claude/skills/exploring-cases/references/user-handoff.md +1 -1
  6. package/.claude/skills/generating-specs/SKILL.md +44 -37
  7. package/AGENTS.md +10 -9
  8. package/README.md +6 -5
  9. package/bin/asrun.mjs +1 -0
  10. package/dist/lib/explore-notes.d.ts +74 -0
  11. package/dist/lib/explore-notes.js +242 -0
  12. package/dist/lib/paths.d.ts +2 -0
  13. package/dist/lib/paths.js +2 -0
  14. package/dist/scripts/cli.js +9 -2
  15. package/dist/scripts/explore-notes.d.ts +1 -0
  16. package/dist/scripts/explore-notes.js +131 -0
  17. package/docs/agents/case-format.md +1 -0
  18. package/docs/agents/framework-dev.md +4 -0
  19. package/docs/ai-workflow.md +9 -1
  20. package/docs/cases.md +4 -4
  21. package/docs/commands.md +11 -0
  22. package/docs/theme-guide.html +56 -0
  23. package/docs/web-ui.md +30 -9
  24. package/package.json +2 -1
  25. package/templates/package.json +1 -0
  26. package/ui/dist/assets/{dist-BmWaNjqO.js → dist--BXJInTq.js} +1 -1
  27. package/ui/dist/assets/{dist-2msWI-5M.js → dist-BMmNFIUH.js} +1 -1
  28. package/ui/dist/assets/{dist-sfZKBSMm.js → dist-BXNaYHeh.js} +1 -1
  29. package/ui/dist/assets/{dist-BVvHVJ3N.js → dist-ByLbEOlF.js} +1 -1
  30. package/ui/dist/assets/{dist-e-KhTWHJ.js → dist-C9ra6_BD.js} +1 -1
  31. package/ui/dist/assets/{dist-4U_gPHXf.js → dist-CK8mow8u.js} +1 -1
  32. package/ui/dist/assets/{dist-BwNz_xW2.js → dist-CQS0So-i.js} +1 -1
  33. package/ui/dist/assets/{dist-J36xayOv.js → dist-CTrwRc3Q.js} +1 -1
  34. package/ui/dist/assets/{dist-BMfJatHo.js → dist-DTYj2vUD.js} +1 -1
  35. package/ui/dist/assets/{dist-wjB7g6OJ.js → dist-mV0_nQZe.js} +1 -1
  36. package/ui/dist/assets/{index-CGbRJT3b.js → index-BzMqkmQ9.js} +41 -41
  37. package/ui/dist/assets/index-DcJR-8Mt.css +2 -0
  38. package/ui/dist/index.html +2 -2
  39. package/ui/dist/assets/index-CI0i0LWc.css +0 -2
@@ -0,0 +1,242 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { isValidCaseId } from './case-id.js';
5
+ import { CASES_DIR, EXPLORE_DIR } from './paths.js';
6
+ /**
7
+ * The local exploration note: what `/explore-case` saw on the page, in a form `/gen-spec` can read.
8
+ *
9
+ * It lives under `.explore/notes/<case-id>/<profile-id>.json`, one file per profile, so a run with `--profiles` never
10
+ * has to merge with an earlier one. `.explore/` is git-ignored local state: a note is optional, may be deleted at any
11
+ * time, and a fresh checkout simply has none (generation then replays the case as before).
12
+ *
13
+ * A note holds only what the case does not: locators the agent confirmed on the page, the data-call paths of the
14
+ * actions that load something, and the match count of a target card's element. Expected values stay in the case's
15
+ * `## Values`, ready signals in the case's `Ready when:` lines, and assertions nowhere near this file.
16
+ */
17
+ export const NOTES_VERSION = 1;
18
+ const HASH = /^[a-f0-9]{64}$/;
19
+ const PROFILE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
20
+ const STEP_KEY = /^[1-9]\d*$/;
21
+ const TARGET_KEY = /^T[1-9]\d*$/;
22
+ // A request pattern is a path, never a URL with a host or a query string that could carry a token.
23
+ const UNSAFE_REQUEST = /[?#]|:\/\//;
24
+ const SECRET_PARAM = /(?:^|[/&])(?:token|cookie|password|authorization|secret|signature|sig|session)=/i;
25
+ const MAX_LOCATOR = 400;
26
+ const MAX_PURPOSE = 200;
27
+ const MAX_REQUEST = 200;
28
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
29
+ const isOneLine = (value) => !/[\r\n]/.test(value);
30
+ /** The folder that holds one case's notes; `<case-id>` may contain its group folder (`documents/landing-page`). */
31
+ export function exploreNotesDir(caseId) {
32
+ if (!isValidCaseId(caseId))
33
+ throw new Error(`"${caseId}" is not a valid case id`);
34
+ return path.join(EXPLORE_DIR, 'notes', ...caseId.split('/'));
35
+ }
36
+ /** The note of one profile: `.explore/notes/<case-id>/<profile-id>.json`. */
37
+ export function exploreNotesFile(caseId, profileId) {
38
+ if (!PROFILE_FILE.test(profileId))
39
+ throw new Error(`"${profileId}" is not a valid profile id`);
40
+ return path.join(exploreNotesDir(caseId), `${profileId}.json`);
41
+ }
42
+ /** sha256 of the case document, or null when it does not exist. */
43
+ export function caseSha256(caseId) {
44
+ if (!isValidCaseId(caseId))
45
+ return null;
46
+ try {
47
+ return crypto.createHash('sha256').update(fs.readFileSync(path.join(CASES_DIR, `${caseId}.md`))).digest('hex');
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * Whether a note still describes the case. A stale note is not an error: `/gen-spec` replays the case and ignores it.
55
+ */
56
+ export function noteFreshness(note) {
57
+ const current = caseSha256(note.caseId);
58
+ if (current === null)
59
+ return { state: 'no-case', reason: 'the case file does not exist' };
60
+ if (current !== note.caseSha256)
61
+ return { state: 'stale', reason: 'the case file changed after the note was written' };
62
+ return { state: 'fresh' };
63
+ }
64
+ function checkLocator(value, where, problems) {
65
+ if (typeof value !== 'string' || value.trim() === '') {
66
+ problems.push(`${where} must be a locator string`);
67
+ return;
68
+ }
69
+ if (!isOneLine(value))
70
+ problems.push(`${where} must be one line`);
71
+ if (value.length > MAX_LOCATOR)
72
+ problems.push(`${where} is longer than ${MAX_LOCATOR} characters`);
73
+ if (!value.startsWith('page.'))
74
+ problems.push(`${where} must start with "page." (use browser_generate_locator)`);
75
+ if (value.includes('=>'))
76
+ problems.push(`${where} must be a locator, not code`);
77
+ }
78
+ function checkRequest(value, where, problems) {
79
+ if (typeof value !== 'string' || value.trim() === '') {
80
+ problems.push(`${where} must be a request path`);
81
+ return;
82
+ }
83
+ if (!isOneLine(value))
84
+ problems.push(`${where} must be one line`);
85
+ if (value.length > MAX_REQUEST)
86
+ problems.push(`${where} is longer than ${MAX_REQUEST} characters`);
87
+ if (!value.startsWith('/') || value.startsWith('//'))
88
+ problems.push(`${where} must start with "/" (a path, not a URL)`);
89
+ if (UNSAFE_REQUEST.test(value))
90
+ problems.push(`${where} must have no host, query string or fragment`);
91
+ if (SECRET_PARAM.test(value))
92
+ problems.push(`${where} must not carry a token, cookie or session`);
93
+ }
94
+ function checkPurpose(value, where, problems) {
95
+ if (typeof value !== 'string' || value.trim() === '')
96
+ problems.push(`${where} must be a short description`);
97
+ else if (!isOneLine(value))
98
+ problems.push(`${where} must be one line`);
99
+ else if (value.length > MAX_PURPOSE)
100
+ problems.push(`${where} is longer than ${MAX_PURPOSE} characters`);
101
+ }
102
+ function checkSteps(value, problems) {
103
+ if (!isRecord(value)) {
104
+ problems.push('steps must be an object keyed by step number');
105
+ return;
106
+ }
107
+ for (const [step, body] of Object.entries(value)) {
108
+ const where = `steps["${step}"]`;
109
+ if (!STEP_KEY.test(step))
110
+ problems.push(`${where}: the key must be a positive step number`);
111
+ if (!isRecord(body)) {
112
+ problems.push(`${where} must be an object`);
113
+ continue;
114
+ }
115
+ if (body.url !== undefined) {
116
+ const url = body.url;
117
+ if (typeof url !== 'string' || !url.startsWith('/'))
118
+ problems.push(`${where}.url must start with "/"`);
119
+ else if (UNSAFE_REQUEST.test(url))
120
+ problems.push(`${where}.url must have no host, query string or fragment`);
121
+ }
122
+ if (body.locators !== undefined) {
123
+ if (!Array.isArray(body.locators))
124
+ problems.push(`${where}.locators must be a list`);
125
+ else {
126
+ for (const [index, locator] of body.locators.entries()) {
127
+ const item = `${where}.locators[${index}]`;
128
+ if (!isRecord(locator)) {
129
+ problems.push(`${item} must be an object with "purpose" and "locator"`);
130
+ continue;
131
+ }
132
+ checkPurpose(locator.purpose, `${item}.purpose`, problems);
133
+ checkLocator(locator.locator, `${item}.locator`, problems);
134
+ }
135
+ }
136
+ }
137
+ if (body.requests !== undefined) {
138
+ if (!Array.isArray(body.requests))
139
+ problems.push(`${where}.requests must be a list`);
140
+ else
141
+ body.requests.forEach((request, index) => checkRequest(request, `${where}.requests[${index}]`, problems));
142
+ }
143
+ const known = new Set(['url', 'locators', 'requests']);
144
+ for (const key of Object.keys(body))
145
+ if (!known.has(key))
146
+ problems.push(`${where}.${key} is not a known field`);
147
+ }
148
+ }
149
+ function checkTargets(value, problems) {
150
+ if (!isRecord(value)) {
151
+ problems.push('targets must be an object keyed by target id');
152
+ return;
153
+ }
154
+ for (const [id, target] of Object.entries(value)) {
155
+ const where = `targets["${id}"]`;
156
+ if (!TARGET_KEY.test(id))
157
+ problems.push(`${where}: the key must be a target id such as "T1"`);
158
+ if (!isRecord(target)) {
159
+ problems.push(`${where} must be an object`);
160
+ continue;
161
+ }
162
+ checkLocator(target.locator, `${where}.locator`, problems);
163
+ if (target.matches !== undefined && (!Number.isInteger(target.matches) || target.matches < 1)) {
164
+ problems.push(`${where}.matches must be a positive whole number`);
165
+ }
166
+ const known = new Set(['locator', 'matches']);
167
+ for (const key of Object.keys(target))
168
+ if (!known.has(key))
169
+ problems.push(`${where}.${key} is not a known field`);
170
+ }
171
+ }
172
+ /** Every reason the note is not usable, in plain words. An empty list means it is valid. */
173
+ export function validateExploreNote(value, expected) {
174
+ const problems = [];
175
+ if (!isRecord(value))
176
+ return ['the note is not a JSON object'];
177
+ if (value.version !== NOTES_VERSION)
178
+ problems.push(`version must be ${NOTES_VERSION}`);
179
+ if (value.caseId !== expected.caseId)
180
+ problems.push(`caseId must be "${expected.caseId}"`);
181
+ if (value.profile !== expected.profile)
182
+ problems.push(`profile must be "${expected.profile}"`);
183
+ if (typeof value.profile !== 'string' || !PROFILE_FILE.test(value.profile)) {
184
+ problems.push('profile must be a profile id (letters, digits, ".", "_" and "-")');
185
+ }
186
+ if (typeof value.caseSha256 !== 'string' || !HASH.test(value.caseSha256)) {
187
+ problems.push('caseSha256 must be the sha256 of the case file (run "explore:notes <case-id> --hash")');
188
+ }
189
+ if (typeof value.capturedAt !== 'string' || value.capturedAt.trim() === '') {
190
+ problems.push('capturedAt must be an ISO date-time');
191
+ }
192
+ if (value.updatedAt !== undefined && typeof value.updatedAt !== 'string')
193
+ problems.push('updatedAt must be a string');
194
+ if (value.env !== undefined && (typeof value.env !== 'string' || value.env.trim() === '')) {
195
+ problems.push('env must be a non-empty string when it is present');
196
+ }
197
+ checkSteps(value.steps, problems);
198
+ if (value.targets !== undefined)
199
+ checkTargets(value.targets, problems);
200
+ const known = new Set(['version', 'caseId', 'profile', 'env', 'caseSha256', 'capturedAt', 'updatedAt', 'steps', 'targets']);
201
+ for (const key of Object.keys(value))
202
+ if (!known.has(key))
203
+ problems.push(`${key} is not a known field`);
204
+ return problems;
205
+ }
206
+ /**
207
+ * Reads every note of a case (or one profile's), parses and validates it. A malformed file is reported, never thrown:
208
+ * the caller decides whether it matters, and generation treats an unreadable note as a missing one.
209
+ */
210
+ export function readExploreNotes(caseId, profileId) {
211
+ const errors = [];
212
+ const notes = [];
213
+ if (!isValidCaseId(caseId))
214
+ return { notes, errors: [{ file: caseId, problems: ['not a valid case id'] }] };
215
+ let files;
216
+ try {
217
+ files = fs.readdirSync(exploreNotesDir(caseId)).filter((file) => file.endsWith('.json'));
218
+ }
219
+ catch {
220
+ return { notes, errors };
221
+ }
222
+ for (const file of files.sort()) {
223
+ const profile = path.basename(file, '.json');
224
+ if (profileId !== undefined && profile !== profileId)
225
+ continue;
226
+ const full = path.join(exploreNotesDir(caseId), file);
227
+ let parsed;
228
+ try {
229
+ parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
230
+ }
231
+ catch (error) {
232
+ errors.push({ file: full, problems: [`not valid JSON: ${error.message}`] });
233
+ continue;
234
+ }
235
+ const problems = validateExploreNote(parsed, { caseId, profile });
236
+ if (problems.length > 0)
237
+ errors.push({ file: full, problems });
238
+ else
239
+ notes.push(parsed);
240
+ }
241
+ return { notes, errors };
242
+ }
@@ -25,6 +25,8 @@ export declare const CASES_DIR: string;
25
25
  export declare const TESTS_DIR: string;
26
26
  export declare const REPORTS_DIR: string;
27
27
  export declare const TMP_DIR: string;
28
+ /** Local agent output (reference screenshots, exploration notes), git-ignored and safe to delete. */
29
+ export declare const EXPLORE_DIR: string;
28
30
  export declare const TEMPLATES_DIR: string;
29
31
  export declare const SKILLS_DIR: string;
30
32
  /**
package/dist/lib/paths.js CHANGED
@@ -82,6 +82,8 @@ export const CASES_DIR = path.join(ROOT, 'cases');
82
82
  export const TESTS_DIR = path.join(ROOT, 'tests');
83
83
  export const REPORTS_DIR = path.join(ROOT, 'reports');
84
84
  export const TMP_DIR = path.join(ROOT, '.tmp');
85
+ /** Local agent output (reference screenshots, exploration notes), git-ignored and safe to delete. */
86
+ export const EXPLORE_DIR = path.join(ROOT, '.explore');
85
87
  export const TEMPLATES_DIR = path.join(FRAMEWORK_ROOT, 'templates');
86
88
  export const SKILLS_DIR = path.join(FRAMEWORK_ROOT, '.claude', 'skills');
87
89
  /**
@@ -32,13 +32,19 @@ async function main() {
32
32
  process.exitCode = await migrate(rest);
33
33
  return;
34
34
  }
35
+ if (command === 'explore:notes') {
36
+ // The script reads `process.argv` itself; drop the subcommand so it sees only its own arguments.
37
+ process.argv.splice(2, 1);
38
+ await import('./explore-notes.js');
39
+ return;
40
+ }
35
41
  if (command === 'help' || command === '--help-init') {
36
42
  console.log(USAGE);
37
43
  return;
38
44
  }
39
45
  await import('./run.js');
40
46
  }
41
- const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
47
+ const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate|explore:notes] [options]
42
48
 
43
49
  (no subcommand) Run the tests of one environment. See "pnpm asrun --help".
44
50
  init Create the files a workspace needs (never overwrites; --update refreshes generated files,
@@ -50,5 +56,6 @@ const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
50
56
  upgrade Install the newest asrun, refresh the workspace files and apply its migrations, after checking the
51
57
  target release for breaking changes (see "pnpm asrun upgrade --help").
52
58
  migrate Apply the workspace migrations of the installed release, or list them (see "pnpm asrun migrate
53
- --help"). "upgrade" runs it; use it after a manual install.`;
59
+ --help"). "upgrade" runs it; use it after a manual install.
60
+ explore:notes Print or validate the local exploration note of a case (see "pnpm asrun explore:notes --help").`;
54
61
  await main();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,131 @@
1
+ import path from 'node:path';
2
+ import { isValidCaseId } from '../lib/case-id.js';
3
+ import { caseSha256, exploreNotesDir, exploreNotesFile, noteFreshness, readExploreNotes, } from '../lib/explore-notes.js';
4
+ import { ROOT } from '../lib/paths.js';
5
+ import { loadProfiles } from '../lib/profiles.js';
6
+ const USAGE = `Usage: pnpm explore:notes <case-id> [--profile <profile-id>] [--json] [--check] [--hash]
7
+
8
+ Prints the exploration notes /explore-case wrote for a case under .explore/notes/<case-id>/.
9
+ They are local, git-ignored and optional: /gen-spec reads a fresh one to verify instead of rediscovering the page,
10
+ and falls back to a full replay when it is missing or stale.
11
+
12
+ --profile <id> only this profile's note
13
+ --json the parsed notes (and any errors) as JSON, for a run that reads them
14
+ --check validate every note; fails on a malformed file, warns on a stale one
15
+ --hash print the case file's sha256, for "caseSha256" in a note
16
+
17
+ Paths are relative to the workspace root (${ROOT}).`;
18
+ const args = process.argv.slice(2);
19
+ let caseId;
20
+ let profile;
21
+ let json = false;
22
+ let check = false;
23
+ let hash = false;
24
+ let help = false;
25
+ for (let index = 0; index < args.length; index++) {
26
+ const arg = args[index];
27
+ if (arg === '--help' || arg === '-h')
28
+ help = true;
29
+ else if (arg === '--json')
30
+ json = true;
31
+ else if (arg === '--check')
32
+ check = true;
33
+ else if (arg === '--hash')
34
+ hash = true;
35
+ else if (arg === '--profile') {
36
+ const value = args[++index];
37
+ if (value === undefined || value.startsWith('--')) {
38
+ console.error(`--profile needs a profile id.\n\n${USAGE}`);
39
+ process.exit(2);
40
+ }
41
+ profile = value;
42
+ }
43
+ else if (!arg.startsWith('--') && caseId === undefined)
44
+ caseId = arg;
45
+ else {
46
+ console.error(`Unexpected argument: ${arg}\n\n${USAGE}`);
47
+ process.exit(2);
48
+ }
49
+ }
50
+ if (help) {
51
+ console.log(USAGE);
52
+ process.exit(0);
53
+ }
54
+ if (caseId === undefined) {
55
+ console.error(USAGE);
56
+ process.exit(1);
57
+ }
58
+ if (!isValidCaseId(caseId)) {
59
+ console.error(`"${caseId}" is not a valid case id (one optional group folder, for example documents/landing-page).`);
60
+ process.exit(1);
61
+ }
62
+ if (hash) {
63
+ const value = caseSha256(caseId);
64
+ if (value === null) {
65
+ console.error(`cases/${caseId}.md does not exist.`);
66
+ process.exit(1);
67
+ }
68
+ console.log(value);
69
+ process.exit(0);
70
+ }
71
+ const { notes, errors } = readExploreNotes(caseId, profile);
72
+ const relative = (file) => path.relative(ROOT, file).split(path.sep).join('/');
73
+ if (json) {
74
+ console.log(JSON.stringify({
75
+ caseId,
76
+ notes: notes.map((note) => ({ ...note, freshness: noteFreshness(note) })),
77
+ errors: errors.map((error) => ({ file: relative(error.file), problems: error.problems })),
78
+ }, null, 2));
79
+ }
80
+ else {
81
+ console.log(`Exploration notes for cases/${caseId}.md (${relative(exploreNotesDir(caseId))})`);
82
+ if (notes.length === 0)
83
+ console.log(' none yet: /gen-spec replays the case.');
84
+ for (const note of notes) {
85
+ console.log(` ${note.profile.padEnd(24)} ${describe(note)}`);
86
+ }
87
+ }
88
+ let failed = false;
89
+ for (const error of errors) {
90
+ failed = true;
91
+ for (const problem of error.problems)
92
+ console.log(`FAIL ${relative(error.file)}: ${problem}`);
93
+ }
94
+ if (check) {
95
+ const value = caseSha256(caseId);
96
+ if (value === null) {
97
+ failed = true;
98
+ console.log(`FAIL cases/${caseId}.md does not exist`);
99
+ }
100
+ const known = new Set(knownProfiles());
101
+ for (const note of notes) {
102
+ const freshness = noteFreshness(note);
103
+ if (freshness.state !== 'fresh') {
104
+ console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: ${freshness.reason}; current caseSha256 is ${value ?? '(unknown)'}`);
105
+ }
106
+ if (!known.has(note.profile)) {
107
+ console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: "${note.profile}" is not in profiles.yaml`);
108
+ }
109
+ }
110
+ if (failed) {
111
+ console.error('\nA note that fails here is ignored by /gen-spec. Fix it, or delete it and let the case be replayed.');
112
+ process.exit(1);
113
+ }
114
+ }
115
+ function describe(note) {
116
+ const freshness = noteFreshness(note);
117
+ const steps = Object.keys(note.steps)
118
+ .sort((a, b) => Number(a) - Number(b))
119
+ .join(',');
120
+ const targets = Object.keys(note.targets ?? {}).sort().join(',');
121
+ const state = freshness.state === 'fresh' ? 'fresh' : `stale (${freshness.reason})`;
122
+ return `${state} steps ${steps || '-'} targets ${targets || '-'} captured ${note.capturedAt}${note.updatedAt ? ` refreshed ${note.updatedAt}` : ''}`;
123
+ }
124
+ function knownProfiles() {
125
+ try {
126
+ return loadProfiles().map((entry) => entry.id);
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ }
@@ -223,5 +223,6 @@ In the framework package:
223
223
  - `.claude/skills/exploring-cases/references/readiness.md` — wait for the content, optional confirmations, downloads.
224
224
  - `.claude/skills/exploring-cases/references/open-questions.md` — question blocks and how answers are folded in.
225
225
  - `.claude/skills/exploring-cases/references/user-handoff.md` — attended exploration and the handoff card.
226
+ - `.claude/skills/exploring-cases/references/notes.md` — the local exploration note `/gen-spec` reads (not part of the case).
226
227
  - `.claude/skills/generating-specs/references/conventions.md` — spec shape, assertion layers, readable failures.
227
228
  - `docs/cases.md` — the same format for people writing a case by hand.
@@ -53,6 +53,10 @@ is relevant to a project that uses asrun as a dependency.
53
53
  - Everything the framework reads for a project goes through `ROOT` in `lib/paths.ts` (the workspace root); its own
54
54
  assets (templates, docs, skills, its `node_modules`) go through `FRAMEWORK_ROOT`. Never write into
55
55
  `FRAMEWORK_ROOT` at run time.
56
+ - `.explore/` is local state (git-ignored): target reference screenshots and the exploration notes that
57
+ `lib/explore-notes.ts` reads (`.explore/notes/<case-id>/<profile-id>.json`). Nothing there is committed, and a
58
+ workspace without it generates specs by replaying the case. `scripts/explore-notes.ts` (`pnpm explore:notes`) prints
59
+ and validates those notes.
56
60
  - After changing `lib/`, run `pnpm build` before exercising the demo's specs.
57
61
  - `pnpm build` copies `scripts/*.mjs` into `dist/scripts/`, where they sit one folder deeper than in this repository.
58
62
  `pnpm verify:pack` (part of CI) checks that layout on a packed tarball; the checkout alone hides such bugs, because
@@ -83,7 +83,10 @@ publishes, shares or deletes anything, in staging or in production. It:
83
83
  - turns what it cannot know into `🚫 TODO [blocking]` / `❓ TODO [non-blocking]` questions, each under a sentence that
84
84
  is its suggestion and can stand in the case as it is, with a last line `- ✍️ Answer:`;
85
85
  - lists the candidates instead of guessing a target (`## Targets`);
86
- - edits the case file only (plus reference screenshots under `.explore/targets/`).
86
+ - edits the case file and the `.explore/` files (reference screenshots and the note below), and nothing else;
87
+ - writes a local **exploration note** (`.explore/notes/<case-id>/<profile-id>.json`, git-ignored) with the locators and
88
+ data-call paths it saw. `/gen-spec` reads it to verify instead of rediscovering, and a page that only exists after a
89
+ write step is written from it. Delete it at any time: without it, generation replays the case.
87
90
 
88
91
  With many profiles, `--profiles a,b` opens only those (ids from `profiles.yaml`, comma-separated). The others are not
89
92
  opened and nothing is written about them, so a case can be explored in passes: A and B first, C later. Runs add up; the
@@ -138,6 +141,11 @@ subset adds up: explore A and B first and C later, and `/gen-spec` covers all th
138
141
  about runs on `default` alone, and the report names it. A profile that is not logged in is not dropped: its part is
139
142
  written from the case and marked `not verified on the page`.
140
143
 
144
+ It reads each profile's **exploration note** first: a fresh note supplies the locators and request patterns, which it
145
+ confirms on the page instead of rediscovering; a missing or stale note means a full replay. A page that only exists
146
+ after a write step is written from a fresh note. `pnpm explore:notes <case-id> [--profile <id>]` shows what was
147
+ recorded, and `--check` validates it.
148
+
141
149
  It does **not** run the spec and does **not** create screenshot baselines: running is a separate step (6).
142
150
 
143
151
  - A case must be in the project's artifact language. If it is in another language, `/gen-spec` translates the case
package/docs/cases.md CHANGED
@@ -207,7 +207,7 @@ that cannot be answered in place.
207
207
 
208
208
  ## Rename or move a case
209
209
 
210
- Use the Cases page (click the file name or the pencil icon). The spec and the baseline images move with it and the
211
- spec is rewritten to match. By hand, move the three things together (`cases/...md`, `tests/...spec.ts`, its
212
- `*-snapshots/`), and keep the spec's `// case:` line and its imports unchanged: specs import the framework by package
213
- name, so their folder depth does not change the imports.
210
+ Use the Cases page (open the case, then **Rename or move** in the **⋯** menu at the end of the tab strip). The spec
211
+ and the baseline images move with it and the spec is rewritten to match. By hand, move the three things together
212
+ (`cases/...md`, `tests/...spec.ts`, its `*-snapshots/`), and keep the spec's `// case:` line and its imports
213
+ unchanged: specs import the framework by package name, so their folder depth does not change the imports.
package/docs/commands.md CHANGED
@@ -18,6 +18,7 @@ thin wrappers, so `pnpm asrun`, `pnpm signin` and the rest work as below. The sa
18
18
  | Run tests | `pnpm asrun --env <env> [filters]` |
19
19
  | Open the last report | `pnpm report` |
20
20
  | Look around a page as a profile | `pnpm explore <profile-id> [path]` |
21
+ | See or validate what an exploration recorded for a case | `pnpm explore:notes <case-id> [--profile <id>] [--json] [--check]` |
21
22
  | Read what the last report says about a case | `pnpm heal:evidence --case <case-id> --env <env>` |
22
23
  | List or forget test data that could not be removed | `pnpm cleanup --env <env> [--forget]` |
23
24
  | Create or validate a case | `pnpm case:new <id>`, `pnpm case:check [case-id]` |
@@ -141,6 +142,9 @@ pnpm pdf:render report.pdf --out .explore/targets/header-logo --corner top-left
141
142
 
142
143
  ```bash
143
144
  pnpm explore example-staging /app/home
145
+ pnpm explore:notes conduit/header-navigation
146
+ pnpm explore:notes conduit/header-navigation --profile example-staging --json
147
+ pnpm explore:notes conduit/header-navigation --check
144
148
  pnpm heal:evidence --case header-logo --env staging
145
149
  ```
146
150
 
@@ -149,6 +153,13 @@ are saved to `~/Downloads` and never overwrite an existing file. `heal:evidence`
149
153
  step, error, call log and screenshots for a case, and whether the report is stale; it is what `/heal-spec` reads
150
154
  first.
151
155
 
156
+ `explore:notes` reads the **exploration note** that `/explore-case` writes for a case under
157
+ `.explore/notes/<case-id>/<profile-id>.json`. It prints one line per profile (fresh or stale, the steps and targets it
158
+ covers) and `--json` prints the parsed notes for a run that reads them. A note is local, git-ignored and optional:
159
+ `/gen-spec` uses a fresh one to verify the locators and request patterns instead of rediscovering them, and replays the
160
+ case when it is missing or stale. `--check` fails on a malformed note and warns about a stale one (it prints the current
161
+ `caseSha256`); `--hash` prints that hash. Deleting `.explore/notes/` is always safe.
162
+
152
163
  ## Cleanup
153
164
 
154
165
  ```bash
@@ -330,6 +330,7 @@
330
330
  <a href="#icons">Icons</a>
331
331
  <a href="#tooltips">Tooltips</a>
332
332
  <a href="#editor">Markdown editor</a>
333
+ <a href="#document-tabs">Document tabs</a>
333
334
  <a href="#surfaces">Surfaces</a>
334
335
  <a href="#sections">Sidebar sections</a>
335
336
  <a href="#checklist">Checklist</a>
@@ -959,6 +960,61 @@ import {FilterChips} from '../components/filter-chips';
959
960
  <li><b>Every expand/collapse animates.</b> Use <code>Collapse</code> from <code>components/collapse.tsx</code> (height and fade, 200ms, <code>ease-out</code>) for content that appears and disappears, and rotate the chevron with <code>transition-transform duration-200</code> instead of swapping two icons. Always add <code>motion-reduce:transition-none</code>. Turn transitions off while the user drags a resize handle.</li>
960
961
  </ul>
961
962
 
963
+ <h2 id="document-tabs">Document tabs</h2>
964
+ <p class="muted">
965
+ A page that holds more than one file of the same thing puts them in a tab strip: <code>DocumentTabs</code> in
966
+ <code>pages/cases.tsx</code>, where <b>Case</b> is the source a person writes and <b>Spec</b> what a run
967
+ generated from it. The strip is the only place the file names appear. The bar under it carries the tools of the
968
+ file on screen (<code>WorkbenchControls</code>, the layout and pane switches), never a second copy of the name,
969
+ and <b>Save</b> / <b>Discard</b> always sit at the same end of the strip and act on the file on screen.
970
+ </p>
971
+ <div class="demo" style="align-items:flex-end;gap:16px;padding:12px 16px 0;background:var(--warm);border-bottom:1px solid var(--line-mid)">
972
+ <span style="display:flex;align-items:center;gap:8px;margin-bottom:-1px;padding:6px 12px;border-bottom:2px solid var(--theme-btn);border-radius:6px 6px 0 0;background:var(--card);font-size:14px">
973
+ <span style="font-size:12px;font-weight:500;letter-spacing:.05em;color:var(--theme-ink)">Case</span>
974
+ <span class="mono">favorite-article.md</span>
975
+ <span title="Unsaved changes" style="width:6px;height:6px;border-radius:9999px;background:var(--gold-deep)"></span>
976
+ </span>
977
+ <span style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:2px solid transparent;border-radius:6px 6px 0 0;font-size:14px;color:var(--ink-2)">
978
+ <span style="font-size:12px;font-weight:500;letter-spacing:.05em;color:var(--ink-3)">Spec</span>
979
+ <span class="mono">favorite-article.spec.ts</span>
980
+ </span>
981
+ <span style="display:flex;align-items:center;gap:8px;margin-left:auto;padding-bottom:8px">
982
+ <span class="btn btn-secondary">Discard</span>
983
+ <span class="btn btn-primary">Save</span>
984
+ <span class="btn btn-secondary">...</span>
985
+ </span>
986
+ </div>
987
+ <div class="demo" style="gap:16px;background:var(--card);border-bottom:1px solid var(--line-mid);padding:8px 16px">
988
+ <span style="display:flex;align-items:center;gap:6px">
989
+ <span style="font-size:12px;color:var(--ink-3)">Layout</span>
990
+ <span style="display:flex;overflow:hidden;border-radius:4px;box-shadow:0 0 0 1px var(--line-mid)">
991
+ <span style="padding:4px 10px;font-size:14px;background:var(--card);color:var(--ink-2)">Split</span>
992
+ <span style="padding:4px 10px;font-size:14px;background:var(--theme-btn);color:#fff">Single</span>
993
+ </span>
994
+ </span>
995
+ <span style="display:flex;align-items:center;gap:6px">
996
+ <span style="font-size:12px;color:var(--ink-3)">Pane</span>
997
+ <span style="display:flex;overflow:hidden;border-radius:4px;box-shadow:0 0 0 1px var(--line-mid)">
998
+ <span style="padding:4px 10px;font-size:14px;background:var(--theme-btn);color:#fff">Panel</span>
999
+ <span style="padding:4px 10px;font-size:14px;background:var(--card);color:var(--ink-2)">Editor</span>
1000
+ <span style="padding:4px 10px;font-size:14px;background:var(--card);color:var(--ink-2)">Doc</span>
1001
+ </span>
1002
+ </span>
1003
+ </div>
1004
+ <ul class="tight">
1005
+ <li>The active tab is <code>bg-card</code> with a <code>border-b-2 border-theme-btn</code> underline; the others are
1006
+ <code>text-ink-2</code> on the warm strip. The name is <code>font-mono</code>; the kind in front of it is
1007
+ <code>text-xs tracking-wider</code>, <code>text-theme-ink</code> when active and <code>text-ink-3</code> otherwise.</li>
1008
+ <li>Unsaved changes are a 6px <code>bg-gold-deep</code> dot on the tab, never a badge in the page header. A file
1009
+ that does not exist yet keeps its tab, disabled at 45% opacity, with a tooltip that says how to create it.</li>
1010
+ <li>The switches of the bar under the tabs are a segmented control, not buttons: active <code>bg-theme-btn</code>
1011
+ with white text, the others <code>bg-card</code>, the whole group ringed with <code>ring-1 ring-line-mid</code>, and
1012
+ a <code>text-xs text-ink-3</code> label in front (<code>Layout</code>, <code>Pane</code>). A switch group without a
1013
+ label is not discoverable; never ship one.</li>
1014
+ <li>Switching tabs keeps the other tab's draft, so <b>Save</b> and <b>Discard</b> take the active file; the tab
1015
+ dot is what says another file still has unsaved changes.</li>
1016
+ </ul>
1017
+
962
1018
  <h2 id="sections">Sidebar sections and selection</h2>
963
1019
  <div class="row" style="align-items:flex-start">
964
1020
  <div class="side">
package/docs/web-ui.md CHANGED
@@ -55,9 +55,26 @@ One row per profile, with the same login check as `pnpm status`.
55
55
 
56
56
  ## Cases
57
57
 
58
- The case list is grouped by folder and collapses. The editor opens in **Single** with the **Panel** full width; the
59
- toolbar switches to **Split** (source left, one pane right; drag the divider to resize, double-click to reset) or, in
60
- Single, between **Panel**, **Editor** and **Doc**.
58
+ The case list is grouped by folder and collapses. A case opens on the **Case** tab, with the **Spec** tab beside it:
59
+
60
+ - **Case** (`favorite-article.md`) is the source a person writes: the steps, values, rules and questions.
61
+ - **Spec** (`favorite-article.spec.ts`) is what a generate or heal run wrote from it. It stays visible but disabled
62
+ until the case has a spec, and its tooltip says how to make one.
63
+
64
+ The tab is the page's state: it lives in the URL (`?view=spec`), so a reload, a shared link and the Back button land on
65
+ the same file, and every other link opens the case. Each tab keeps its own unsaved draft while you switch between them;
66
+ an amber dot on a tab means that file has unsaved changes, and **Save** (`Ctrl/Cmd+S`) and **Discard** always sit at the
67
+ right of the tab strip and act on the file on screen.
68
+
69
+ Under the tabs, the first row is the AI toolbar (command, environment, profiles, attended, **Run AI**, copy the shell
70
+ command, AI guide). It is the same on both tabs, because every AI command acts on the case. The row below holds the
71
+ tools of the file on screen: on **Case** the **Layout** (Split / Single) and **Pane** (Panel / Editor / Doc) switches
72
+ and the previous / next of an outstanding mark; on **Spec** the line that says the file is generated from the case
73
+ (with a link back to it), **Open in editor**, **Copy path** and copy the spec. **Rename or move** and **Delete** live
74
+ in the **⋯** menu at the end of the tab strip.
75
+
76
+ The editor opens in **Single** with the **Panel** full width; **Split** puts the source on the left and one pane on the
77
+ right (drag the divider to resize, double-click to reset).
61
78
 
62
79
  - **Source and preview** scroll together, the preview block at the caret is shaded, and a click in the preview puts
63
80
  the caret on that line. `Ctrl/Cmd+S` saves.
@@ -84,15 +101,19 @@ Single, between **Panel**, **Editor** and **Doc**.
84
101
  when it saved one, the **screenshot** from the exploration, so you look at the image instead of opening the page
85
102
  again. Rows that change an assertion are highlighted; **Confirm** settles one line and **Confirm all** settles the
86
103
  list in one undo step. None of this blocks generating a spec; healing stops until the case is settled.
87
- - **Spec view** is editable too (`Ctrl/Cmd+S`; refused when the file changed on disk meanwhile). **Open in editor**
88
- opens it in the default application for `.spec.ts`, **Copy path** copies its absolute path.
89
- - Click the file name (or the pencil icon) to **rename** or move the case: the spec and the baselines move with it and
90
- the spec is rewritten. **Delete** (type the id, or fill it in with the feather icon) removes the case, its spec and
91
- its baselines; both dialogs list exactly what they touch, and are refused while an AI or test run is in progress.
92
- - **Toolbar:** command (explore / generate / heal, explore by default), **Environment** (required, no default),
104
+ - **Spec view** is editable too (`Ctrl/Cmd+S`; refused when the file changed on disk meanwhile), and its tab links back
105
+ to the case it was generated from. **Open in editor** opens it in the default application for `.spec.ts`, **Copy
106
+ path** copies its absolute path.
107
+ - **Rename or move** (in the **⋯** menu) keeps the spec and the baselines with the case and rewrites the spec;
108
+ **Delete** (type the id, or fill it in with the feather icon) removes the case, its spec and its baselines. Both
109
+ dialogs list exactly what they touch, and are refused while an AI or test run is in progress or while a draft is
110
+ unsaved.
111
+ - **AI toolbar:** command (explore / generate / heal, explore by default), **Environment** (required, no default),
93
112
  **Profiles** (explore only: every profile that is logged in is ticked, the choice is remembered per environment, and
94
113
  only the ticked ones are opened), **Attended** (only for explore, ticked by default), **Run AI**, copy the shell
95
114
  command, open the AI guide.
115
+ - **Writes in …** is one line under the toolbar, in red: the environments whose data the case writes, then its note,
116
+ with the full note in its tooltip. A case that only runs in some environments shows that line instead.
96
117
  - **From memo** opens a Markdown editor with a preview for raw notes. **Make cases** saves them to `.tmp/memos/` and
97
118
  runs `/make-case` one-shot (defaults for gaps, `TODO` for unknown values, no page exploration, existing cases never
98
119
  overwritten); **Copy /make-case** copies the memo for the interactive dialogue. When the run ends, **What to do