@asrunjs/asrun 0.10.0 → 0.12.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 (71) hide show
  1. package/.claude/commands/explore-case.md +2 -1
  2. package/.claude/commands/gen-spec.md +2 -1
  3. package/.claude/commands/heal-spec.md +2 -1
  4. package/.claude/commands/make-case.md +2 -1
  5. package/.claude/skills/exploring-cases/SKILL.md +59 -64
  6. package/.claude/skills/exploring-cases/references/case-rules.md +5 -0
  7. package/.claude/skills/exploring-cases/references/notes.md +87 -0
  8. package/.claude/skills/exploring-cases/references/open-questions.md +8 -0
  9. package/.claude/skills/exploring-cases/references/readiness.md +3 -2
  10. package/.claude/skills/exploring-cases/references/tools.md +34 -0
  11. package/.claude/skills/exploring-cases/references/user-handoff.md +13 -9
  12. package/.claude/skills/generating-specs/SKILL.md +61 -47
  13. package/.claude/skills/generating-specs/references/conventions.md +6 -2
  14. package/.claude/skills/making-cases/SKILL.md +12 -5
  15. package/.claude/skills/making-cases/references/writes-and-email.md +5 -2
  16. package/AGENTS.md +30 -14
  17. package/README.md +16 -11
  18. package/bin/asrun.mjs +1 -0
  19. package/dist/lib/case-doc.d.ts +1 -1
  20. package/dist/lib/config.d.ts +26 -0
  21. package/dist/lib/config.js +51 -0
  22. package/dist/lib/explore-notes.d.ts +74 -0
  23. package/dist/lib/explore-notes.js +242 -0
  24. package/dist/lib/fixtures.d.ts +1 -1
  25. package/dist/lib/fixtures.js +7 -4
  26. package/dist/lib/paths.d.ts +2 -0
  27. package/dist/lib/paths.js +2 -0
  28. package/dist/lib/records.d.ts +17 -2
  29. package/dist/lib/records.js +32 -2
  30. package/dist/lib/rules.d.ts +57 -0
  31. package/dist/lib/rules.js +140 -0
  32. package/dist/scripts/cleanup.js +10 -1
  33. package/dist/scripts/cli.js +17 -2
  34. package/dist/scripts/doctor.js +27 -48
  35. package/dist/scripts/explore-notes.d.ts +1 -0
  36. package/dist/scripts/explore-notes.js +131 -0
  37. package/dist/scripts/lint-specs.js +23 -1
  38. package/dist/scripts/rules.d.ts +1 -0
  39. package/dist/scripts/rules.js +148 -0
  40. package/docs/agents/case-format.md +9 -5
  41. package/docs/agents/framework-dev.md +4 -0
  42. package/docs/ai-workflow.md +30 -13
  43. package/docs/cases.md +14 -10
  44. package/docs/commands.md +33 -4
  45. package/docs/install.md +2 -1
  46. package/docs/theme-guide.html +79 -2
  47. package/docs/troubleshooting.md +11 -2
  48. package/docs/web-ui.md +40 -12
  49. package/package.json +3 -1
  50. package/templates/.claude/commands/explore-case.md +3 -1
  51. package/templates/.claude/commands/gen-spec.md +3 -1
  52. package/templates/.claude/commands/heal-spec.md +3 -1
  53. package/templates/.claude/commands/make-case.md +3 -1
  54. package/templates/AGENTS.md +17 -8
  55. package/templates/cases/_template.md +3 -3
  56. package/templates/package.json +5 -0
  57. package/ui/dist/assets/{dist-BmWaNjqO.js → dist-BX88bMYN.js} +1 -1
  58. package/ui/dist/assets/{dist-2msWI-5M.js → dist-Bfv586Or.js} +1 -1
  59. package/ui/dist/assets/{dist-e-KhTWHJ.js → dist-BjlbPf3t.js} +1 -1
  60. package/ui/dist/assets/{dist-BVvHVJ3N.js → dist-CJnqQMvX.js} +1 -1
  61. package/ui/dist/assets/{dist-BMfJatHo.js → dist-CaAcCNRS.js} +1 -1
  62. package/ui/dist/assets/{dist-sfZKBSMm.js → dist-Clj0wGFy.js} +1 -1
  63. package/ui/dist/assets/{dist-J36xayOv.js → dist-D8TUm3Ai.js} +1 -1
  64. package/ui/dist/assets/{dist-4U_gPHXf.js → dist-DUDqh_3e.js} +1 -1
  65. package/ui/dist/assets/{dist-BwNz_xW2.js → dist-DkAx5a-Y.js} +1 -1
  66. package/ui/dist/assets/{dist-wjB7g6OJ.js → dist-cuboTVh-.js} +1 -1
  67. package/ui/dist/assets/index-DaUQORCy.js +353 -0
  68. package/ui/dist/assets/index-j3W4BBCj.css +2 -0
  69. package/ui/dist/index.html +2 -2
  70. package/ui/dist/assets/index-CGbRJT3b.js +0 -348
  71. package/ui/dist/assets/index-CI0i0LWc.css +0 -2
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
5
5
  import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
6
+ import { readWorkspaceConfig } from '../lib/config.js';
7
+ import { readProjectRules, mentionedTestDataPrefixes } from '../lib/rules.js';
6
8
  import { spawnTarget } from '../lib/command.js';
7
9
  import { migrationState, pendingMigrations } from '../lib/migrations.js';
8
10
  import { MIGRATIONS } from '../migrations/index.js';
@@ -22,29 +24,6 @@ function read(file) {
22
24
  return null;
23
25
  }
24
26
  }
25
- /** The ids of the framework rules that a project may not narrow, parsed from the framework's AGENTS.md. */
26
- function nonNegotiableRules() {
27
- const ids = new Set();
28
- for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
29
- const match = /^- \*\*([A-Z]\d+)\b/.exec(line.trim());
30
- if (match && line.includes('[non-negotiable]'))
31
- ids.add(match[1]);
32
- }
33
- return ids;
34
- }
35
- /**
36
- * Every rule id the framework baseline defines (`F1`..`F10`, `B1`..`B4`, `A1`..`A6`, `R1`..`R4`), so a project cannot
37
- * name one that does not exist.
38
- */
39
- function frameworkRuleIds() {
40
- const ids = new Set();
41
- for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
42
- const match = /^- \*\*([A-Z]\d+)\b/.exec(line.trim());
43
- if (match)
44
- ids.add(match[1]);
45
- }
46
- return ids;
47
- }
48
27
  /**
49
28
  * The framework sources that `pnpm build` compiles into `dist/` and are older than it, or missing from it. Only the
50
29
  * framework repository has those sources; an installed package ships `dist/` alone, so the list is empty there.
@@ -253,8 +232,8 @@ export async function main(args) {
253
232
  warnings.push(`.claude/skills/${name} is a copy of the framework skill; delete it, or it will never see an update.`);
254
233
  }
255
234
  }
256
- // Project rules: a project may narrow or replace a baseline rule, never one of F1–F6. Rows are
257
- // `| <id> | <the project's rule> | <narrows|replaces> |`.
235
+ // Project rules: a project may narrow or replace a baseline rule, never one of F1–F6/R2, and may add its own rule
236
+ // under a project id. Rows are `| <id> | <the project's rule> | <narrows|replaces|adds> |`.
258
237
  const agents = read(path.join(ROOT, 'AGENTS.md'));
259
238
  if (agents === null) {
260
239
  warnings.push('AGENTS.md is missing: run "pnpm asrun init" and fill in the project facts.');
@@ -263,30 +242,30 @@ export async function main(args) {
263
242
  if (!agents.includes(`${framework}/AGENTS.md`)) {
264
243
  errors.push(`AGENTS.md does not point at the framework baseline (${framework}/AGENTS.md).`);
265
244
  }
266
- const locked = nonNegotiableRules();
267
- const known = frameworkRuleIds();
268
- let fenced = false;
269
- for (const line of agents.split('\n')) {
270
- // A fenced block is documentation (an example table), not the project's rules.
271
- if (line.trim().startsWith('```')) {
272
- fenced = !fenced;
273
- continue;
245
+ const project = readProjectRules(path.join(ROOT, 'AGENTS.md'));
246
+ errors.push(...project.errors);
247
+ warnings.push(...project.warnings);
248
+ if (project.rules.length > 0) {
249
+ facts.push(`project rules: ${project.rules.map((rule) => `${rule.id} (${rule.effect || '?'})`).join(', ')}`);
250
+ }
251
+ }
252
+ // The project's own settings (`package.json` "asrun"): a bad prefix makes every write case fail, so it is an error.
253
+ let prefix = null;
254
+ try {
255
+ prefix = readWorkspaceConfig().testDataPrefix;
256
+ facts.push(`test data: ${prefix} <base> (<run stamp>)`);
257
+ }
258
+ catch (error) {
259
+ errors.push(error.message);
260
+ }
261
+ // A document that names another test-data prefix cannot change the runtime: report it instead of letting a generated
262
+ // spec use one prefix while the case document says another.
263
+ if (agents !== null && prefix !== null) {
264
+ for (const mention of mentionedTestDataPrefixes(agents)) {
265
+ if (mention.token !== prefix) {
266
+ errors.push(`AGENTS.md:${mention.line} names ${mention.token} as the test-data prefix, but package.json has ` +
267
+ `"asrun.testDataPrefix": "${prefix}". Set it to "${mention.token}" (the runtime reads package.json, not prose).`);
274
268
  }
275
- if (fenced)
276
- continue;
277
- const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
278
- if (!/^F\d+$/.test(cells[0]))
279
- continue;
280
- const id = cells[0];
281
- const effect = (cells[2] ?? '').toLowerCase();
282
- if (!known.has(id))
283
- errors.push(`AGENTS.md names ${id}, which is not a rule of the framework baseline.`);
284
- else if (locked.has(id))
285
- errors.push(`AGENTS.md changes ${id}, which the framework marks [non-negotiable].`);
286
- else if (effect === '')
287
- warnings.push(`AGENTS.md rule ${id} has no Effect column: add "narrows" or "replaces" (a table written before 0.3.0 predates it).`);
288
- else if (effect !== 'narrows' && effect !== 'replaces')
289
- errors.push(`AGENTS.md rule ${id} has Effect "${cells[2]}"; use "narrows" or "replaces".`);
290
269
  }
291
270
  }
292
271
  // The install and the commands the docs use.
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { listCaseFileIds } from '../lib/cases.js';
3
+ import { caseWrites, hasCaseFile, listCaseFileIds, readCaseMeta } from '../lib/cases.js';
4
4
  import { TESTS_DIR } from '../lib/paths.js';
5
5
  import { parseTargets, readCaseText, TARGET_REF } from '../lib/targets.js';
6
6
  const specFiles = (dir) => fs.existsSync(dir)
@@ -125,6 +125,28 @@ function lint(file) {
125
125
  add('fail', i, 'blocking-target', `${match[1]} still has an open [blocking] question in the case; do not build a check on it (use test.fixme).`);
126
126
  }
127
127
  });
128
+ // Test data names must be unique per run (F11): a writes case names what it creates with testDataName().
129
+ // Old specs predate the rule, so this is a warning: regenerate or heal them to clear it.
130
+ if (caseId !== undefined && hasCaseFile(caseId) && caseWrites(readCaseMeta(caseId))) {
131
+ const text = lines.join('\n');
132
+ const composed = new Set([...text.matchAll(/(?:const|let)\s+(\w+)\s*=\s*(?:await\s+)?testDataName\s*\(/g)].map((match) => match[1]));
133
+ lines.forEach((line, i) => {
134
+ if (!/\brecords\.track\s*\(/.test(line))
135
+ return;
136
+ for (let end = i; end < Math.min(lines.length, i + 15); end++) {
137
+ const match = /^\s*name:\s*(.*?),?\s*$/.exec(lines[end]);
138
+ if (match === null)
139
+ continue;
140
+ const expression = match[1].trim();
141
+ if (/testDataName\s*\(/.test(expression))
142
+ return;
143
+ if (/^[A-Za-z_$][\w$]*$/.test(expression) && composed.has(expression))
144
+ return;
145
+ add('warn', end, 'test-data-name', `records.track() names the data "${expression}"; build it with testDataName() so the run's name carries its timestamp (F11).`);
146
+ return;
147
+ }
148
+ });
149
+ }
128
150
  TARGET_REF.lastIndex = 0;
129
151
  return findings;
130
152
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { caseSectionText } from '../lib/case-doc.js';
4
+ import { readWorkspaceConfig } from '../lib/config.js';
5
+ import { ROOT } from '../lib/paths.js';
6
+ import { baselineRules, checkProjectRules, describeProjectRule, mentionedTestDataPrefixes, parseProjectRules, } from '../lib/rules.js';
7
+ import { testDataStamp } from '../lib/records.js';
8
+ /**
9
+ * `pnpm asrun rules`: the effective rules of this workspace, as one digest. An AI step reads this before it applies
10
+ * anything, so "§2 wins over the baseline" is a fact it was told rather than a layer it has to reason about. It never
11
+ * writes anything. `--json` is the machine-readable form; `--case <id>` adds that case's `## Rules`.
12
+ */
13
+ const USAGE = `Usage: pnpm asrun rules [--json] [--case <case-id>]
14
+
15
+ Prints the framework baseline, the project's AGENTS.md §2 rules and the project settings
16
+ (test-data prefix, name format). --case <id> adds the case's own "## Rules".
17
+ Exit code 1 when §2 or a setting is invalid (the same errors "pnpm asrun doctor" shows).`;
18
+ const args = process.argv.slice(2);
19
+ if (args.includes('--help') || args.includes('-h')) {
20
+ console.log(USAGE);
21
+ process.exit(0);
22
+ }
23
+ const json = args.includes('--json');
24
+ const caseIndex = args.findIndex((arg) => arg === '--case' || arg.startsWith('--case='));
25
+ const caseId = caseIndex === -1 ? null : (args[caseIndex].includes('=') ? args[caseIndex].split('=')[1] : args[caseIndex + 1]);
26
+ if (caseIndex !== -1 && !caseId) {
27
+ console.error('Usage: pnpm asrun rules [--json] [--case <case-id>]\n --case needs a case id.');
28
+ process.exit(1);
29
+ }
30
+ const agentsFile = path.join(ROOT, 'AGENTS.md');
31
+ let agentsText;
32
+ try {
33
+ agentsText = fs.readFileSync(agentsFile, 'utf8');
34
+ }
35
+ catch {
36
+ console.error(`No AGENTS.md in ${ROOT}: run "pnpm asrun init" first.`);
37
+ process.exit(1);
38
+ }
39
+ const baseline = baselineRules();
40
+ const project = checkProjectRules(parseProjectRules(agentsText), baseline);
41
+ let config = null;
42
+ let configError = null;
43
+ try {
44
+ config = readWorkspaceConfig();
45
+ }
46
+ catch (error) {
47
+ configError = error.message;
48
+ }
49
+ let caseRules = [];
50
+ if (caseId !== null) {
51
+ const caseFile = path.join(ROOT, 'cases', `${caseId}.md`);
52
+ try {
53
+ const text = fs.readFileSync(caseFile, 'utf8');
54
+ const section = caseSectionText(text, 'Rules');
55
+ let current = null;
56
+ const flush = () => {
57
+ if (current !== null && current.length > 0)
58
+ caseRules.push({ text: current.join(' ') });
59
+ current = null;
60
+ };
61
+ for (const raw of section.split('\n')) {
62
+ if (/^-\s+\S/.test(raw.trim())) {
63
+ flush();
64
+ current = [raw.trim()];
65
+ }
66
+ else if (current !== null && /^\s+\S/.test(raw)) {
67
+ current.push(raw.trim());
68
+ }
69
+ else if (raw.trim() === '') {
70
+ flush();
71
+ }
72
+ }
73
+ flush();
74
+ }
75
+ catch {
76
+ console.error(`Case "${caseId}" not found (expected cases/${caseId}.md).`);
77
+ process.exit(1);
78
+ }
79
+ }
80
+ const prefix = config?.testDataPrefix ?? '';
81
+ const stamp = testDataStamp();
82
+ const errors = [...project.errors, ...(configError === null ? [] : [configError])];
83
+ const prefixMentions = mentionedTestDataPrefixes(agentsText);
84
+ if (config !== null) {
85
+ for (const mention of prefixMentions) {
86
+ if (mention.token !== config.testDataPrefix) {
87
+ errors.push(`AGENTS.md:${mention.line} names ${mention.token} as the test-data prefix, but package.json has ` +
88
+ `"asrun.testDataPrefix": "${config.testDataPrefix}". Set it to "${mention.token}" (the runtime reads package.json, not prose).`);
89
+ }
90
+ }
91
+ }
92
+ if (json) {
93
+ console.log(JSON.stringify({
94
+ workspace: ROOT,
95
+ baseline: baseline.map((rule) => ({ id: rule.id, locked: rule.locked, text: rule.text })),
96
+ projectRules: project.rules,
97
+ projectWarnings: project.warnings,
98
+ settings: { testDataPrefix: config?.testDataPrefix ?? null, testDataNameExample: `${prefix} <base> (${stamp})` },
99
+ prefixMentions,
100
+ ...(caseId !== null && { case: { id: caseId, rules: caseRules } }),
101
+ errors,
102
+ }, null, 2));
103
+ }
104
+ else {
105
+ const byLetter = new Map();
106
+ for (const rule of baseline) {
107
+ const letter = rule.id[0];
108
+ byLetter.set(letter, [...(byLetter.get(letter) ?? []), rule.id]);
109
+ }
110
+ console.log(`AsRun rules for ${ROOT}\n`);
111
+ console.log('Framework baseline:');
112
+ for (const [letter, ids] of byLetter) {
113
+ console.log(` ${letter}: ${ids[0]}–${ids[ids.length - 1]}`);
114
+ }
115
+ const locked = baseline.filter((rule) => rule.locked).map((rule) => rule.id);
116
+ console.log(` [non-negotiable]: ${locked.join(', ')}`);
117
+ console.log('\nProject rules (AGENTS.md §2):');
118
+ if (project.rules.length === 0)
119
+ console.log(' (none: the baseline defaults apply unchanged)');
120
+ else
121
+ for (const rule of project.rules)
122
+ console.log(` ${describeProjectRule(rule)}`);
123
+ console.log('\nProject settings:');
124
+ console.log(` testDataPrefix: ${config?.testDataPrefix ?? '(invalid)'} ` +
125
+ `(package.json "asrun.testDataPrefix"; default [asrun])`);
126
+ console.log(` test data name: ${prefix || '[asrun]'} <base> (${stamp}) (UTC, from the run id)`);
127
+ if (caseId !== null) {
128
+ console.log(`\nCase rules (cases/${caseId}.md §Rules):`);
129
+ if (caseRules.length === 0)
130
+ console.log(' (none)');
131
+ else
132
+ for (const rule of caseRules)
133
+ console.log(` - ${rule.text.replace(/^-\s+/, '')}`);
134
+ }
135
+ if (project.warnings.length > 0) {
136
+ console.log('');
137
+ for (const warning of project.warnings)
138
+ console.log(`! ${warning}`);
139
+ }
140
+ if (errors.length > 0) {
141
+ console.log('');
142
+ for (const error of errors)
143
+ console.log(`x ${error}`);
144
+ console.log('\nFix the errors above; the rules are not applied until they are valid.');
145
+ }
146
+ console.log('\nA project rule is applied as written: a difference from the baseline is never a conflict and never a question.');
147
+ }
148
+ process.exit(errors.length > 0 ? 1 : 0);
@@ -89,7 +89,7 @@ Wrong: `- Create a draft named ... and delete it at the end.` (a write → a tag
89
89
  - `- Ready when:` is a sub-line of the step: the positive signal that what the step loads is there. The spec waits
90
90
  for it before reading; it is not an assertion.
91
91
  - `- Recipe (phase):` is a sub-line of an `[input]`/`[writes]` step: what a person did in the browser during an
92
- attended exploration (positions, `[asrun]` names, control labels; no selectors). A tagged step with no recipe is a
92
+ attended exploration (positions, created names, control labels; no selectors). A tagged step with no recipe is a
93
93
  `needs-input` stop for `/gen-spec`.
94
94
  - A question block is indented under the sentence it belongs to: 4 spaces under a numbered step, 2 under a bullet.
95
95
  The parent line is the suggestion, written as a finished sentence; the `TODO` line holds one question and optional
@@ -121,7 +121,8 @@ example-staging:
121
121
  - A profile with no entry and no `default` fails with "Missing values ...". An empty value or one that starts with
122
122
  `TODO` fails with "Incomplete values ...". Fill them before running; never make a spec read the YAML itself.
123
123
  - Values hold literals only: names, labels, options, test-data names. The checks themselves stay in `## Steps` and
124
- `## Expected`, and selectors come from `## Targets`.
124
+ `## Expected`, and selectors come from `## Targets`. A name the case creates is stored here **without** its run
125
+ stamp: the literal is the base, and the spec composes `<base> (<run stamp>)` with `testDataName()` (F11).
125
126
  - No marker or question inside the yaml: it would become part of the expected value. Put it on the prose line above
126
127
  the block and name the key there.
127
128
 
@@ -201,9 +202,11 @@ test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
201
202
  a case limited to some environments is skipped elsewhere; a case that writes elsewhere only is skipped with a
202
203
  notice; a write in a non-staging environment needs the confirmation the CLI sets, never an environment variable
203
204
  that was merely present.
204
- - Write cases: name everything `[asrun] ...`, register it with `records.track` right after creating it, remove it
205
- newest first (also after a failure), turn retries off with `configureCase`, and keep email steps behind
206
- `emailStepAllowed(profile, '<step title>')`.
205
+ - Write cases: name everything `<prefix> <base> (<run stamp>)` through `testDataName(base)`
206
+ (`@asrunjs/asrun/records`), where the prefix is `asrun.testDataPrefix` in `package.json` (default `[asrun]`, printed
207
+ by `pnpm asrun rules`) and the stamp is the run's UTC time; register it with `records.track` right after creating
208
+ it, remove it newest first (also after a failure), turn retries off with `configureCase`, and keep email steps
209
+ behind `emailStepAllowed(profile, '<step title>')`.
207
210
 
208
211
  ## Checks to run before reporting
209
212
 
@@ -223,5 +226,6 @@ In the framework package:
223
226
  - `.claude/skills/exploring-cases/references/readiness.md` — wait for the content, optional confirmations, downloads.
224
227
  - `.claude/skills/exploring-cases/references/open-questions.md` — question blocks and how answers are folded in.
225
228
  - `.claude/skills/exploring-cases/references/user-handoff.md` — attended exploration and the handoff card.
229
+ - `.claude/skills/exploring-cases/references/notes.md` — the local exploration note `/gen-spec` reads (not part of the case).
226
230
  - `.claude/skills/generating-specs/references/conventions.md` — spec shape, assertion layers, readable failures.
227
231
  - `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,17 +83,21 @@ 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
90
93
  case keeps what the earlier runs recorded. In the web UI the profile picker of the toolbar does the same: every profile
91
94
  that is logged in in that environment is ticked, and the list you tick is remembered for the next run there.
92
95
 
93
- **To accept the suggestion, delete the whole block.** **To answer, write after `Answer:`.** Then run the command
94
- again. In the web UI, the run's **What to do next** bar offers **Answer**: the question list, an answer box,
95
- **Use the suggested defaults** and **Generate spec**. A continued job keeps the same conversation and permissions, and
96
- can be continued once per turn, only while the UI server has not restarted.
96
+ **To keep what the case says, delete the whole block.** **To answer, write after `Answer:`.** Then run the command
97
+ again. In the web UI the panel calls the first action **Keep what the case says**. The run's **What to do next** bar
98
+ offers **Answer**: the question list, an answer box, **Use the suggested defaults** and **Generate spec**. A continued
99
+ job keeps the same conversation and permissions, and can be continued once per turn, only while the UI server has not
100
+ restarted.
97
101
 
98
102
  ### Attended exploration (`--attended`)
99
103
 
@@ -106,9 +110,10 @@ the browser window opens and the agent waits while you do those steps.
106
110
  - Recording starts and stops by itself. If Playwright's own recording toolbar shows at the top of the page, leave it
107
111
  alone: it can stop the recording.
108
112
  - The card records what you did, and the agent writes it into the case under the step as **general recipe lines**
109
- (`- Recipe (phase): ...`): positions instead of names of changeable data, `[asrun]` names for created data, control
110
- labels but no selectors.
111
- - Delete the `[asrun]` items you created before you press Done on the last step.
113
+ (`- Recipe (phase): ...`): positions instead of names of changeable data, created names (project prefix plus the
114
+ run's UTC stamp), control labels but no selectors.
115
+ - Delete the test items you created (they start with the project's test-data prefix and carry this run's timestamp)
116
+ before you press Done on the last step.
112
117
  - Without `--attended` the agent never waits: a step that needs a person becomes a `TODO [blocking]`.
113
118
 
114
119
  In the web UI the **Attended** box is ticked by default for Explore.
@@ -138,6 +143,11 @@ subset adds up: explore A and B first and C later, and `/gen-spec` covers all th
138
143
  about runs on `default` alone, and the report names it. A profile that is not logged in is not dropped: its part is
139
144
  written from the case and marked `not verified on the page`.
140
145
 
146
+ It reads each profile's **exploration note** first: a fresh note supplies the locators and request patterns, which it
147
+ confirms on the page instead of rediscovering; a missing or stale note means a full replay. A page that only exists
148
+ after a write step is written from a fresh note. `pnpm explore:notes <case-id> [--profile <id>]` shows what was
149
+ recorded, and `--check` validates it.
150
+
141
151
  It does **not** run the spec and does **not** create screenshot baselines: running is a separate step (6).
142
152
 
143
153
  - A case must be in the project's artifact language. If it is in another language, `/gen-spec` translates the case
@@ -213,6 +223,10 @@ Several binding rules hold in exploring, generating and healing. They live in th
213
223
  - **Case rules.** A `## Rules` bullet says how to run the case, never what it proves. The agent applies it while
214
224
  exploring and generating, marks code that exists only because of a rule with `// rule: <short quote>`, and lists every
215
225
  rule in its report. It never becomes an assertion and never authorizes a write or a person's step.
226
+ - **Test data is findable per run.** Everything a write case creates is named with the project's test-data prefix
227
+ (`package.json` `"asrun"`, default `[asrun]`) and the run's UTC stamp: `testDataName()` builds
228
+ `[autotest] Draft CMA (2026-11-09 12-34-56)`. Two runs never share a name, the stamp matches `reports/<run-id>`, and
229
+ a person can search the product for one run's data. The case's `## Values` holds the base without the stamp.
216
230
  - **Targets.** A check on a logo, image, icon, file, PDF, screenshot or repeated text points to a `[T1]` card under
217
231
  `## Targets`: what it is (and what it is not), where it is, how it is identified (never `.first()` or a bare `img`),
218
232
  when it is ready, and a highlighted reference screenshot under `.explore/targets/`. When in doubt the agent does not
@@ -232,9 +246,10 @@ Checks you can run yourself:
232
246
 
233
247
  | Command | What it checks |
234
248
  |---|---|
235
- | `pnpm spec:lint [case-id]` | fails on screenshots without a target, `.first()` on images, sleeps, `networkidle`, raw download events, a `%PDF` check on its own |
249
+ | `pnpm spec:lint [case-id]` | fails on screenshots without a target, `.first()` on images, sleeps, `networkidle`, raw download events, a `%PDF` check on its own; warns when a tracked test-data name is not built with `testDataName()` |
236
250
  | `pnpm baselines:review [case-id] [--new]` | lists every baseline with the card that says what it must show, so you can look at each new image |
237
251
  | `pnpm case:check [case-id]` | fails on an incomplete card, bad values, a marker inside a question or a question under a marked line; warns on an image check with no card and on a marker that does not say what to decide |
252
+ | `pnpm asrun rules [--json] [--case <id>]` | prints the effective rules (baseline, §2, project settings) the AI steps read; fails on an invalid §2 row or `testDataPrefix` |
238
253
  | `pnpm markers:confirm --case <id> --env <env> [--report <run-id>]` | after a passing run, removes the `🔍 to be confirmed` markers whose line the spec checks with `// unconfirmed:`; `--dry-run` lists them |
239
254
  | `pnpm pdf:render <file.pdf> --out <dir> [--pages all] [--corner top-left]` | renders the pages to images and prints the text, to look at a PDF by hand |
240
255
 
@@ -273,10 +288,12 @@ An override is plain Markdown; write only what differs:
273
288
  - The staging account has no published documents: assert the empty state there instead.
274
289
  ```
275
290
 
276
- The project's `AGENTS.md` §2 is the other place: a table of rule ids (`F7`–`F10`, `R1`, `R3`, `R4`; `F1`–`F6` and
277
- `R2` are fixed) with `narrows` (stricter) or `replaces` (a different default). For example, a project that writes cases in Simplified Chinese
278
- `replaces` `F10`. `pnpm asrun doctor` fails on an unknown id, a missing `Effect`, or a non-negotiable rule, and lists
279
- the overrides it sees.
291
+ The project's `AGENTS.md` §2 is the other place: a table of rule ids (`F7`–`F11`, `R1`, `R3`, `R4`; `F1`–`F6` and
292
+ `R2` are fixed) with `narrows` (stricter) or `replaces` (a different default). A rule the baseline does not have gets
293
+ a project id (`P1`, `P2`, ...) and `adds`. For example, a project that writes cases in Simplified Chinese `replaces`
294
+ `F10`. `pnpm asrun doctor` fails on an unknown id, a missing `Effect`, a duplicate id, a non-negotiable rule or a
295
+ project id without `adds`, and `pnpm asrun rules` prints the effective set (the AI steps read it before they apply
296
+ anything). A project rule is applied as written: a difference from the baseline is never a question.
280
297
 
281
298
  An update never overwrites `AGENTS.md`, `CLAUDE.md` or `.claude/skills/**`: `pnpm asrun init --force` refuses them
282
299
  without `--reset-rules`. Never edit the framework package itself.
package/docs/cases.md CHANGED
@@ -110,13 +110,14 @@ report and the trace read like the case.
110
110
  choice (an existing document, items), `[email]` for a step that sends email. They are written at the end of the
111
111
  step line.
112
112
  - **`- Recipe (phase):`** under a tagged step records what a person did in the browser during an attended
113
- exploration: positions instead of names of changeable data (`select items #1 to #3`), `[asrun]` names for created
114
- data, control labels but no selectors. Several lines per step are normal. A tagged step with no recipe is not
115
- generated: `/gen-spec` stops with `needs-input`.
113
+ exploration: positions instead of names of changeable data (`select items #1 to #3`), created names (project prefix
114
+ plus run stamp), control labels but no selectors. Several lines per step are normal. A tagged step with no recipe is
115
+ not generated: `/gen-spec` stops with `needs-input`.
116
116
  - **Open questions** go in a `🚫 TODO [blocking]` or `❓ TODO [non-blocking]` block under the sentence they are about,
117
117
  with the agent's suggestion written above and a last line `- ✍️ Answer:`. Accepting a question means deleting the
118
- whole block; answering means writing after `Answer:`. Hang the block under the sentence that makes the claim, not
119
- under a broader one above it, and one claim gets one block.
118
+ whole block; answering means writing after `Answer:`. In the web panel the two actions are **Keep what the case
119
+ says** and **Write your answer**. Hang the block under the sentence that makes the claim, not under a broader one
120
+ above it, and one claim gets one block.
120
121
  - **What the AI saw** is marked by what happens if it is wrong: `(seen on <profile>)` is a plain note the run checks by
121
122
  itself, and `(observed on <profile>, 🔍 to be confirmed: <what a person must decide>)` is a line that would pass even
122
123
  if it were wrong, so a person settles it. A marker may name a screenshot (`evidence: .explore/.../x.png`) so nobody
@@ -173,7 +174,10 @@ anything.
173
174
 
174
175
  A case may write only in the environments listed under `writes`, and only data of the test account:
175
176
 
176
- - everything it creates is named `[asrun] ...` (the `records` fixture refuses other names);
177
+ - everything it creates is named with the project's prefix and the run stamp, `<prefix> <base> (<run stamp>)`
178
+ (prefix: `asrun.testDataPrefix` in `package.json`, default `[asrun]`; stamp: the run's UTC time). The spec builds
179
+ the name with `testDataName()` (`records` refuses a name without the prefix), so two runs never collide and a
180
+ person can search the product for one run's data. `## Values` holds the base without the stamp;
177
181
  - created data is registered with `records.track` and removed after the test, also after a failure;
178
182
  - the case's own last step is normally the deletion; `track` is the safety net;
179
183
  - staging needs no confirmation. In any other environment a person confirms every run (typing the environment name,
@@ -207,7 +211,7 @@ that cannot be answered in place.
207
211
 
208
212
  ## Rename or move a case
209
213
 
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.
214
+ Use the Cases page (open the case, then **Rename or move** in the **⋯** menu at the end of the tab strip). The spec
215
+ and the baseline images move with it and the spec is rewritten to match. By hand, move the three things together
216
+ (`cases/...md`, `tests/...spec.ts`, its `*-snapshots/`), and keep the spec's `// case:` line and its imports
217
+ unchanged: specs import the framework by package name, so their folder depth does not change the imports.