@asrunjs/asrun 0.9.0 → 0.10.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 (67) hide show
  1. package/.claude/commands/explore-case.md +11 -6
  2. package/.claude/commands/gen-spec.md +10 -6
  3. package/.claude/commands/heal-spec.md +13 -9
  4. package/.claude/commands/make-case.md +1 -1
  5. package/.claude/skills/exploring-cases/SKILL.md +65 -31
  6. package/.claude/skills/exploring-cases/references/assertion-targets.md +8 -4
  7. package/.claude/skills/exploring-cases/references/case-rules.md +110 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +73 -11
  9. package/.claude/skills/exploring-cases/references/open-questions.md +58 -6
  10. package/.claude/skills/exploring-cases/references/readiness.md +4 -3
  11. package/.claude/skills/exploring-cases/references/tools.md +117 -0
  12. package/.claude/skills/exploring-cases/references/user-handoff.md +46 -17
  13. package/.claude/skills/generating-specs/SKILL.md +71 -33
  14. package/.claude/skills/generating-specs/references/conventions.md +4 -0
  15. package/.claude/skills/making-cases/SKILL.md +14 -7
  16. package/.claude/skills/making-cases/references/gaps.md +7 -4
  17. package/.claude/skills/making-cases/references/normalizing.md +1 -1
  18. package/AGENTS.md +51 -11
  19. package/README.md +4 -3
  20. package/bin/asrun.mjs +1 -0
  21. package/dist/lib/case-doc.d.ts +41 -4
  22. package/dist/lib/case-doc.js +109 -17
  23. package/dist/scripts/check-cases.js +89 -1
  24. package/dist/scripts/doctor.js +50 -3
  25. package/dist/scripts/markers-confirm.d.ts +1 -0
  26. package/dist/scripts/markers-confirm.js +139 -0
  27. package/dist/ui/server/actions.js +50 -16
  28. package/dist/ui/server/app.js +7 -2
  29. package/dist/ui/server/job-store.d.ts +60 -0
  30. package/dist/ui/server/job-store.js +198 -0
  31. package/dist/ui/server/jobs.d.ts +46 -4
  32. package/dist/ui/server/jobs.js +361 -59
  33. package/dist/ui/server/process-info.d.ts +17 -0
  34. package/dist/ui/server/process-info.js +126 -0
  35. package/dist/ui/server/result-state.d.ts +10 -0
  36. package/dist/ui/server/result-state.js +106 -0
  37. package/dist/ui/shared/ai-commands.d.ts +4 -1
  38. package/dist/ui/shared/ai-commands.js +14 -4
  39. package/dist/ui/shared/types.d.ts +17 -2
  40. package/docs/agents/case-format.md +39 -5
  41. package/docs/ai-workflow.md +47 -15
  42. package/docs/cases.md +28 -4
  43. package/docs/commands.md +6 -1
  44. package/docs/troubleshooting.md +40 -0
  45. package/docs/web-ui.md +29 -6
  46. package/package.json +2 -1
  47. package/templates/.claude/commands/explore-case.md +11 -6
  48. package/templates/.claude/commands/gen-spec.md +10 -5
  49. package/templates/.claude/commands/heal-spec.md +11 -7
  50. package/templates/.claude/commands/make-case.md +1 -1
  51. package/templates/AGENTS.md +9 -6
  52. package/templates/cases/_template.md +7 -0
  53. package/templates/package.json +1 -0
  54. package/ui/dist/assets/{dist-B7HeglDd.js → dist-2msWI-5M.js} +1 -1
  55. package/ui/dist/assets/{dist-DfBpjgZT.js → dist-4U_gPHXf.js} +1 -1
  56. package/ui/dist/assets/{dist-B9_GXR2G.js → dist-BMfJatHo.js} +1 -1
  57. package/ui/dist/assets/{dist-Bb36rNUw.js → dist-BVvHVJ3N.js} +1 -1
  58. package/ui/dist/assets/{dist-WvvCZeeu.js → dist-BmWaNjqO.js} +1 -1
  59. package/ui/dist/assets/{dist-G9KNeh6u.js → dist-BwNz_xW2.js} +1 -1
  60. package/ui/dist/assets/{dist-GvRUDL-2.js → dist-J36xayOv.js} +1 -1
  61. package/ui/dist/assets/{dist-Dqc_tfzC.js → dist-e-KhTWHJ.js} +1 -1
  62. package/ui/dist/assets/{dist-DevASegc.js → dist-sfZKBSMm.js} +1 -1
  63. package/ui/dist/assets/{dist-Dq4Vnw5N.js → dist-wjB7g6OJ.js} +1 -1
  64. package/ui/dist/assets/{index-BJ5D2mFK.js → index-CGbRJT3b.js} +134 -132
  65. package/ui/dist/assets/index-CI0i0LWc.css +2 -0
  66. package/ui/dist/index.html +2 -2
  67. package/ui/dist/assets/index-BXY-eKBn.css +0 -2
@@ -0,0 +1,17 @@
1
+ export interface ProcessDetails {
2
+ /** The command line the system reports, empty when the platform could not read it. */
3
+ commandLine: string;
4
+ /** Milliseconds since the Unix epoch, or null when the platform could not report it. */
5
+ startedAt: number | null;
6
+ }
7
+ /**
8
+ * The details of these pids, or `null` for one that is gone. One call for all of them: a restart usually has only a
9
+ * handful of jobs to look at, but probing should not be started once per job.
10
+ */
11
+ export declare function probeProcesses(pids: number[]): Map<number, ProcessDetails | null>;
12
+ /**
13
+ * Ends a process and everything it started: the agent CLI, the browser and the MCP server below it. Off Windows the
14
+ * process was spawned as the leader of its own group, so the whole group is signalled; Windows has no process groups,
15
+ * and `taskkill /T` walks the tree instead.
16
+ */
17
+ export declare function killProcessTree(pid: number, signal: NodeJS.Signals): void;
@@ -0,0 +1,126 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ /** 100-ns ticks between 1601-01-01 and 1970-01-01, the epoch a Windows file time counts from. */
3
+ const WINDOWS_EPOCH_MS = 11644473600000;
4
+ /** True when the pid exists and this process may signal it. */
5
+ function alive(pid) {
6
+ try {
7
+ process.kill(pid, 0);
8
+ return true;
9
+ }
10
+ catch (error) {
11
+ // A process of another user still exists; only a missing one is gone.
12
+ return error.code === 'EPERM';
13
+ }
14
+ }
15
+ /**
16
+ * What the system knows about these pids on Windows, from one PowerShell call for all of them. The command line and the
17
+ * creation time are what tell a leftover job from a new process that merely reused its pid.
18
+ */
19
+ function windowsDetails(pids) {
20
+ const map = new Map();
21
+ const filter = pids.map((pid) => `ProcessId=${pid}`).join(' OR ');
22
+ // `\u0001` is an ordinary separator: a Windows command line never holds it.
23
+ const script = `$p = Get-CimInstance Win32_Process -Filter "${filter}"; ` +
24
+ 'foreach ($x in $p) { $ft = 0; try { $ft = $x.CreationDate.ToFileTimeUtc() } catch {}; ' +
25
+ 'Write-Output ("{0}\u0001{1}\u0001{2}" -f $x.ProcessId, $ft, ($x.CommandLine -replace "\\r?\\n", " ")) }';
26
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
27
+ encoding: 'utf8',
28
+ windowsHide: true,
29
+ timeout: 15_000,
30
+ });
31
+ if (result.status === 0 && typeof result.stdout === 'string') {
32
+ for (const line of result.stdout.split(/\r?\n/)) {
33
+ if (line.trim() === '')
34
+ continue;
35
+ const [pidText, fileTimeText, ...rest] = line.split('\u0001');
36
+ const pid = Number(pidText);
37
+ if (!Number.isInteger(pid))
38
+ continue;
39
+ const fileTime = Number(fileTimeText);
40
+ const startedAt = Number.isFinite(fileTime) && fileTime > 0 ? fileTime / 10_000 - WINDOWS_EPOCH_MS : null;
41
+ map.set(pid, { commandLine: rest.join('\u0001'), startedAt });
42
+ }
43
+ }
44
+ for (const pid of pids) {
45
+ if (!map.has(pid))
46
+ map.set(pid, alive(pid) ? { commandLine: '', startedAt: null } : null);
47
+ }
48
+ return map;
49
+ }
50
+ /** `etime` of `ps`: `MM:SS`, `HH:MM:SS` or `D-HH:MM:SS`. */
51
+ function parseElapsed(text) {
52
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(text);
53
+ if (!match)
54
+ return null;
55
+ const days = Number(match[1] ?? 0);
56
+ const hours = Number(match[2] ?? 0);
57
+ const minutes = Number(match[3]);
58
+ const seconds = Number(match[4]);
59
+ return ((days * 24 + hours) * 60 + minutes) * 60 * 1000 + seconds * 1000;
60
+ }
61
+ /** The same answer on Linux and macOS, from one `ps` call. */
62
+ function posixDetails(pids) {
63
+ const map = new Map();
64
+ const result = spawnSync('ps', ['-ww', '-p', pids.join(','), '-o', 'pid=,etime=,args='], {
65
+ encoding: 'utf8',
66
+ timeout: 15_000,
67
+ });
68
+ if (result.status === 0 && typeof result.stdout === 'string') {
69
+ for (const line of result.stdout.split(/\r?\n/)) {
70
+ const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line);
71
+ if (!match)
72
+ continue;
73
+ const pid = Number(match[1]);
74
+ const elapsed = parseElapsed(match[2]);
75
+ map.set(pid, { commandLine: match[3], startedAt: elapsed === null ? null : Date.now() - elapsed });
76
+ }
77
+ }
78
+ for (const pid of pids) {
79
+ if (!map.has(pid))
80
+ map.set(pid, alive(pid) ? { commandLine: '', startedAt: null } : null);
81
+ }
82
+ return map;
83
+ }
84
+ /**
85
+ * The details of these pids, or `null` for one that is gone. One call for all of them: a restart usually has only a
86
+ * handful of jobs to look at, but probing should not be started once per job.
87
+ */
88
+ export function probeProcesses(pids) {
89
+ const unique = [...new Set(pids.filter((pid) => Number.isInteger(pid) && pid > 0))];
90
+ if (unique.length === 0)
91
+ return new Map();
92
+ try {
93
+ return process.platform === 'win32' ? windowsDetails(unique) : posixDetails(unique);
94
+ }
95
+ catch {
96
+ const fallback = new Map();
97
+ for (const pid of unique)
98
+ fallback.set(pid, alive(pid) ? { commandLine: '', startedAt: null } : null);
99
+ return fallback;
100
+ }
101
+ }
102
+ /**
103
+ * Ends a process and everything it started: the agent CLI, the browser and the MCP server below it. Off Windows the
104
+ * process was spawned as the leader of its own group, so the whole group is signalled; Windows has no process groups,
105
+ * and `taskkill /T` walks the tree instead.
106
+ */
107
+ export function killProcessTree(pid, signal) {
108
+ if (!Number.isInteger(pid) || pid <= 0)
109
+ return;
110
+ if (process.platform === 'win32') {
111
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
112
+ return;
113
+ }
114
+ try {
115
+ process.kill(-pid, signal);
116
+ }
117
+ catch {
118
+ // Not a group leader any more (or already gone): end what is left of the process itself.
119
+ try {
120
+ process.kill(pid, signal);
121
+ }
122
+ catch {
123
+ // Already gone.
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,10 @@
1
+ import type { ResultState } from '../shared/types.js';
2
+ /**
3
+ * What a finished AI run declared, or null when its text holds no `RESULT:` line at all.
4
+ *
5
+ * A fence normally means "this is quoted", so its content is not the agent's own conclusion. A model still wraps its
6
+ * closing line in a block of its own often enough to matter, so a fence is read in two narrow cases, after nothing
7
+ * outside one was found: a fence that holds nothing but the result (the usual drift), and the last line of the whole
8
+ * message even when a fence wraps all of it (the agent answered from inside one block).
9
+ */
10
+ export declare function parseResultState(text: string): ResultState | null;
@@ -0,0 +1,106 @@
1
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
2
+ /** Zero-width characters that a copied line may carry, which would hide the token from a line-anchored match. */
3
+ const ZERO_WIDTH = /[\u200b-\u200d\ufeff]/g;
4
+ /** The first line of a fenced block: a run of three or more backticks or tildes, then an info string without that run. */
5
+ const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})([^`~]*)$/;
6
+ /** The last line of a fenced block: a run at least as long as the one that opened it, and nothing else. */
7
+ const FENCE_CLOSE = /^\s{0,3}(`{3,}|~{3,})\s*$/;
8
+ /** Splits the text into lines and marks the ones a fence wraps, so quoted content does not read as the conclusion. */
9
+ function textLines(source) {
10
+ const lines = [];
11
+ let open = null;
12
+ for (const text of source.split('\n')) {
13
+ if (open === null) {
14
+ const start = FENCE_OPEN.exec(text);
15
+ if (start) {
16
+ open = start[1];
17
+ lines.push({ text, fenced: true, marker: start[1] });
18
+ continue;
19
+ }
20
+ lines.push({ text, fenced: false });
21
+ continue;
22
+ }
23
+ const end = FENCE_CLOSE.exec(text);
24
+ const closes = !!end && end[1][0] === open[0] && end[1].length >= open.length;
25
+ lines.push({ text, fenced: true, ...(closes && { marker: end[1] }) });
26
+ if (closes)
27
+ open = null;
28
+ }
29
+ return lines;
30
+ }
31
+ /**
32
+ * The last `RESULT: ready|needs-input` line of what the agent said. The token is fixed (`AGENTS.md`, "Fixed tokens"),
33
+ * but a model drifts in every way that still means the same thing: a short label in front of it ("结果:", "Status:",
34
+ * "结果 —"), a full-width colon, bold or a code span, a list bullet or a heading, an emoji, or `needs input` for
35
+ * `needs-input`. A run that said `needs-input` must not be read as a run that said nothing, so the format is read
36
+ * leniently; the line stays anchored and the value has to follow the separator at once, so ordinary prose that merely
37
+ * mentions the token is still not a result. The label may not run across a newline and may not hold a colon of its own.
38
+ */
39
+ const RESULT_LINE = /^[\s\p{P}\p{S}\d]*(?:[^\n::]{1,40}(?:[::]|\s[–—-]\s)\s*)?RESULT[\s*_`]*(?:[::][\s*_`\p{S}]*|[\s*_`]*[–—-][\s*_`\p{S}]*)[\s*_`\p{S}]*(ready|needs[\s_-]?input)\b/gimu;
40
+ /** The state a single line declares, or null when the line is not a result line. */
41
+ function matchLine(text) {
42
+ const value = [...text.matchAll(RESULT_LINE)].at(-1)?.[1];
43
+ if (!value)
44
+ return null;
45
+ // The token is fixed, but a drifted `NEEDS INPUT` or `needs_input` means the same as `needs-input`.
46
+ return value.toLowerCase().replace(/[\s_]+/g, '-');
47
+ }
48
+ /** The state of the last of these lines that is a result line, or null when none is. */
49
+ function lastOf(lines) {
50
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
51
+ const state = matchLine(lines[index].text);
52
+ if (state)
53
+ return state;
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * What a finished AI run declared, or null when its text holds no `RESULT:` line at all.
59
+ *
60
+ * A fence normally means "this is quoted", so its content is not the agent's own conclusion. A model still wraps its
61
+ * closing line in a block of its own often enough to matter, so a fence is read in two narrow cases, after nothing
62
+ * outside one was found: a fence that holds nothing but the result (the usual drift), and the last line of the whole
63
+ * message even when a fence wraps all of it (the agent answered from inside one block).
64
+ */
65
+ export function parseResultState(text) {
66
+ const lines = textLines(text.replace(ANSI, '').replace(ZERO_WIDTH, ''));
67
+ // 1. What the agent said outside any fence: the usual case, and the only place two results can stand against each
68
+ // other.
69
+ const plain = lastOf(lines.filter((line) => !line.fenced));
70
+ if (plain)
71
+ return plain;
72
+ // 2. A fence whose body is the result and nothing else, as one block per fence.
73
+ const blocks = [];
74
+ let block = null;
75
+ for (const line of lines) {
76
+ if (!line.fenced) {
77
+ block = null;
78
+ continue;
79
+ }
80
+ if (!block) {
81
+ block = [];
82
+ blocks.push(block);
83
+ }
84
+ block.push(line);
85
+ }
86
+ for (let index = blocks.length - 1; index >= 0; index -= 1) {
87
+ const body = blocks[index].filter((line) => !line.marker && line.text.trim() !== '');
88
+ if (body.length !== 1)
89
+ continue;
90
+ const state = matchLine(body[0].text);
91
+ if (state)
92
+ return state;
93
+ }
94
+ // 3. The last line of the message, fence or not: the line a person reads as the conclusion, whatever wraps it.
95
+ // A fence that names two results is a quote of a document, not an answer, so it is not read from its tail.
96
+ const tail = [...lines].reverse().find((line) => !line.marker && line.text.trim() !== '');
97
+ if (!tail)
98
+ return null;
99
+ const tailState = matchLine(tail.text);
100
+ if (!tailState)
101
+ return null;
102
+ if (!tail.fenced)
103
+ return tailState;
104
+ const body = blocks.find((block) => block.includes(tail))?.filter((line) => !line.marker) ?? [];
105
+ return body.filter((line) => matchLine(line.text)).length === 1 ? tailState : null;
106
+ }
@@ -37,7 +37,10 @@ export interface AiRunOptions {
37
37
  * (all that are logged in, for heal). `gen-spec` takes none: it uses every profile the case names.
38
38
  */
39
39
  profiles?: string[];
40
- /** `heal-spec` only: file (relative to the project root) with what the user wants repaired or improved, and any clues. */
40
+ /**
41
+ * Every command: file (relative to the project root) with what the user knows, wants checked or wants changed. A
42
+ * reusable instruction is written into the case's `## Rules`; a one-off clue stays in the report.
43
+ */
41
44
  hints?: string;
42
45
  /** `heal-spec` only: the report (folder name under reports/) to read first instead of the newest one. */
43
46
  report?: string;
@@ -30,7 +30,7 @@ export const slashCommand = (command, caseId, options) => {
30
30
  `${attended && command === 'explore-case' ? ' --attended' : ''}` +
31
31
  `${picksProfiles && profiles?.length ? ` --profiles ${profiles.join(',')}` : ''}` +
32
32
  `${heal && report ? ` --report ${report}` : ''}` +
33
- `${heal && hints ? ` --hints ${hints}` : ''}`);
33
+ `${hints ? ` --hints ${hints}` : ''}`);
34
34
  };
35
35
  /** The MCP tools of the Playwright server that agents explore with. */
36
36
  const PLAYWRIGHT_MCP = 'mcp__playwright-test__*';
@@ -52,6 +52,7 @@ function claudeAllowedTools(writes) {
52
52
  'Edit(/tests/**)',
53
53
  'Bash(pnpm typecheck:*)',
54
54
  'Bash(pnpm spec:lint:*)',
55
+ 'Bash(pnpm markers:confirm:*)',
55
56
  nodeTsx,
56
57
  'Bash(pnpm baselines:review:*)',
57
58
  ];
@@ -111,12 +112,20 @@ function invocation(tool, prompt, writes, session, model, thinking) {
111
112
  };
112
113
  }
113
114
  const commandLine = ({ file, args }) => [file, ...args.map((arg) => (/^[\w@=,.:/-]+$/.test(arg) ? arg : JSON.stringify(arg)))].join(' ');
115
+ /**
116
+ * The closing line of every one-shot run. The server reads the last `RESULT:` line to tell a finished run from one that
117
+ * said nothing, and the shapes it drifts into are Markdown ones: a code fence, bold, a heading, a bullet. The wording is
118
+ * the same in every prompt so a continuation of a run asks for the same thing.
119
+ */
120
+ const RESULT_LINE_RULE = 'The last line of your final message is plain text and nothing else: exactly "RESULT: ready" or ' +
121
+ '"RESULT: needs-input". Never put it in a code fence or inline code, never make it bold, a heading or a list item, ' +
122
+ 'put no label, number or emoji in front of it, and write nothing after it.';
114
123
  /**
115
124
  * Said in every one-shot run: nobody is there to answer, so questions go into the case file and the last line
116
125
  * is a result the UI can read. The skills define the details.
117
126
  */
118
127
  const NON_INTERACTIVE = 'Non-interactive run: nobody can answer questions, so do not stop to ask. Write open questions into the case file ' +
119
- 'as the skill describes and end with the RESULT: line.';
128
+ `as the skill describes. ${RESULT_LINE_RULE}`;
120
129
  /**
121
130
  * An attended exploration: nobody answers questions in the chat, but a person sits at the machine and works in the
122
131
  * browser window, so the agent hands them the steps that write data and waits, as user-handoff.md describes.
@@ -126,7 +135,7 @@ const ATTENDED = 'Attended run: a person is at the machine and the browser windo
126
135
  'with its own call that sets __handoffCfg, before the call that runs handoff-card.js, and never change those options ' +
127
136
  'inside the call that runs the card: the web UI sees that a person is needed only from that call, so without it ' +
128
137
  'there is no "Your turn" badge and no notification. Nobody can answer questions in the chat, so write open ' +
129
- 'questions into the case file as the skill describes and end with the RESULT: line.';
138
+ `questions into the case file as the skill describes. ${RESULT_LINE_RULE}`;
130
139
  /** The prompt of a one-shot run. Every command may leave open questions in the case, so every one ends with a RESULT line. */
131
140
  export function headlessPrompt(command, caseId, options) {
132
141
  const slash = slashCommand(command, caseId, options);
@@ -159,7 +168,7 @@ export function replyPrompt(command, replyFile) {
159
168
  `Their answers are in ${replyFile}, relative to the project root: read that file first.\n` +
160
169
  'Fold every answer into the case file as the skill describes: write the answer into the sentence above the ' +
161
170
  'TODO block and remove the block, and keep a TODO block (with an empty Answer: line, emoji prefixes included) for what is still unknown. ' +
162
- `Then finish the task of /${command} and end with the RESULT: line.`);
171
+ `Then finish the task of /${command}. ${RESULT_LINE_RULE}`);
163
172
  }
164
173
  /** The next turn of an earlier run, in its session and with exactly the permissions of the first turn. */
165
174
  export function continueInvocation(tool, command, replyFile, sessionId, model, thinking) {
@@ -174,6 +183,7 @@ export const makeCasePrompt = (memoPath) => `/make-case ${memoPath} ` +
174
183
  'Non-interactive run: nobody can answer questions, so do not stop to ask. ' +
175
184
  'Do not open a browser or explore pages. Write the suggested default of every gap as a normal sentence and put a ' +
176
185
  'TODO block with an empty Answer: line under it, as open-questions.md describes (every marker starts with its emoji). ' +
186
+ 'Never put a marker word inside a TODO block: one claim carries one signal. ' +
177
187
  'Never overwrite an existing case: choose a new id instead. ' +
178
188
  'Put the cases of one area in a group folder (cases/<group>/<name>.md, id <group>/<name>), reusing an existing folder when it fits. ' +
179
189
  'Write the cases, run pnpm case:check, and list the open questions in your final report.';
@@ -1,7 +1,16 @@
1
1
  import type { ResultStatus } from '../../lib/run-summary.js';
2
2
  import type { AiCommand, AiTool } from './ai-commands.js';
3
- export type JobStatus = 'running' | 'succeeded' | 'failed' | 'cancelled';
3
+ export type JobStatus = 'running' | 'succeeded' | 'failed' | 'cancelled' | 'interrupted';
4
4
  export type JobKind = 'signin' | 'visit' | 'run' | 'ai';
5
+ /**
6
+ * A job that was still running when the UI server stopped, and the process left behind. `alive`: it was identified as
7
+ * this job's process; `unknown`: a process has that pid but could not be identified, so it is never killed; `gone` /
8
+ * `stopped`: nothing is left to stop.
9
+ */
10
+ export interface LeftoverInfo {
11
+ pid: number;
12
+ state: 'alive' | 'unknown' | 'gone' | 'stopped';
13
+ }
5
14
  /** What changed on disk: case files (`cases/**`) and/or specs (`tests/**`). */
6
15
  export interface FilesChange {
7
16
  cases: boolean;
@@ -41,6 +50,12 @@ export interface JobInfo {
41
50
  continuesJobId?: string;
42
51
  /** An AI run that has handed a step to the person in the browser window and waits for them to press Done. */
43
52
  awaitingUser?: AwaitingUserInfo | null;
53
+ /** When the UI server found this job still running from a previous server. */
54
+ interruptedAt?: string;
55
+ /** A job the previous UI server left behind: what is known about its process, and whether it is still there. */
56
+ leftover?: LeftoverInfo;
57
+ /** True when "Run again" can start this job from the request it was started with. */
58
+ rerunable?: boolean;
44
59
  }
45
60
  export interface AwaitingUserInfo {
46
61
  /** What the person is asked to do, as the agent wrote it on the card; null when unknown. */
@@ -105,7 +120,7 @@ export type StartJobRequest = {
105
120
  model?: string;
106
121
  /** `explore-case` and `heal-spec` only: the profiles to open / heal; omitted = every profile that applies. `gen-spec` takes none: it uses every profile the case names. */
107
122
  profiles?: string[];
108
- /** `heal-spec` only: what the user knows (optional). The server stores it as a file for the agent. */
123
+ /** Every command: what the user knows or wants changed (optional). The server stores it as a file for the agent. */
109
124
  hints?: string;
110
125
  /** `heal-spec` only: the report to read first (folder name under reports/); omitted = the newest one. */
111
126
  report?: string;
@@ -5,7 +5,7 @@ spec consumes it. It holds no rules of its own: the rules are in the framework `
5
5
  references listed at the end.
6
6
 
7
7
  Read `AGENTS.md` first. If a project has `.claude/skills/<skill>.md`, that override comes after the skill; the
8
- project's `AGENTS.md` §2 comes last and wins over both. `F1`–`F6` always win.
8
+ project's `AGENTS.md` §2 comes last and wins over both. `F1`–`F6` and `R2` always win.
9
9
 
10
10
  ## Identity and paths
11
11
 
@@ -20,15 +20,17 @@ project's `AGENTS.md` §2 comes last and wins over both. `F1`–`F6` always win.
20
20
 
21
21
  The parser and the web panel match these literals. A project language never translates them:
22
22
 
23
- - Section headings: `## Goal`, `## Steps`, `## Expected`, `## Values`, `## Visual checks`, `## Targets`, `## Notes`,
24
- and `## Source` when the case was made from notes.
23
+ - Section headings: `## Goal`, `## Rules`, `## Steps`, `## Expected`, `## Values`, `## Visual checks`, `## Targets`,
24
+ `## Notes`, and `## Source` when the case was made from notes.
25
25
  - Front matter keys: `id`, `title`, `login`, `suites`, `writes`, `writes-note`, `envs`.
26
26
  - Step tags at the end of a step line: `[input]`, `[writes]`, `[email]`.
27
27
  - Under a step: `- Ready when:` lines and `- Recipe (phase):` lines.
28
28
  - Target cards: `### T<n> <name>` and their references `[T<n>]`.
29
29
  - Questions: `🚫 TODO [blocking]`, `❓ TODO [non-blocking]`, `✍️ Answer:`, and the markers
30
- `🔍 to be confirmed by QA` / `👁️ not observed`.
31
- - The last line of a run: `RESULT: ready` or `RESULT: needs-input`.
30
+ `🔍 to be confirmed` / `👁️ not observed`. A marker's parentheses may hold its reason (`🔍 to be confirmed: <what a
31
+ person must decide>`) and a screenshot (`evidence: <path under .explore/>`); `(seen on <profile>)` is a plain note.
32
+ - The last line of a run: `RESULT: ready` or `RESULT: needs-input`, plain text, alone on its line (no label in front of
33
+ it, no code fence or emphasis around it).
32
34
 
33
35
  ## Front matter
34
36
 
@@ -56,6 +58,28 @@ hard-codes tags.
56
58
 
57
59
  ## Sections
58
60
 
61
+ ### Rules
62
+
63
+ Case-local instructions for the agents that explore, generate and heal this case: **how** to run it, never **what is
64
+ true** (framework baseline `R1`–`R4`). The section is optional and written by a person; an AI run may add a rule only
65
+ under `R4` and never rewrites or deletes one silently.
66
+
67
+ - One top-level bullet per rule, plain prose. No nested bullets and no code fences: `parseBullets` reads only the
68
+ top-level bullets, so a nested list is not part of the rule the panel edits.
69
+ - A bullet may start with `Step <n>:`, `Before step <n>:` or `After step <n>:` to tie it to that step.
70
+ `pnpm case:check` fails when that step does not exist. Without a prefix the rule applies to the whole case.
71
+ - Use it for a page quirk, a mandatory read-only preparation, a control to avoid, or a waiting/attention hint.
72
+ - Do **not** use it for an expectation (`## Expected`), a literal (`## Values`), a target (`## Targets`), or a step that
73
+ writes, sends email or needs a person. Those stay in their own sections, and such a step keeps its tags and recipe.
74
+ - A rule that conflicts with the rest of the case is a `🚫 TODO [blocking]` question, not an override. A rule that no
75
+ longer holds (the page changed, the banner is gone) is recorded with a TODO and evidence, never deleted silently.
76
+ - The same artifact language as the rest of the case applies; the heading itself stays `## Rules`.
77
+
78
+ Good: `- Before step 1: dismiss the cookie banner; it covers the header and blocks clicks.`
79
+ Good: `- Never use "Continue with Google": it requires 2FA and is out of scope.`
80
+ Wrong: `- The header logo must be the Acme logo.` (an expectation → `## Expected` / `## Values` / `## Targets`)
81
+ Wrong: `- Create a draft named ... and delete it at the end.` (a write → a tagged step with a recipe)
82
+
59
83
  ### Steps
60
84
 
61
85
  - Numbered Markdown lines (`1.`, `2.`, ...). The spec wraps each one in `test.step('<n>. <text>', ...)` with the text
@@ -70,7 +94,11 @@ hard-codes tags.
70
94
  - A question block is indented under the sentence it belongs to: 4 spaces under a numbered step, 2 under a bullet.
71
95
  The parent line is the suggestion, written as a finished sentence; the `TODO` line holds one question and optional
72
96
  `(evidence)`; the block ends with `- ✍️ Answer:` at one level deeper and that line is the last line of the block.
97
+ Hang it under the immediate sentence, never under a broader one above it, and never inside the `## Values` yaml.
73
98
  Details: `.claude/skills/exploring-cases/references/open-questions.md`.
99
+ - A marker (`🔍 to be confirmed: <what a person must decide>`) is the alternative to a question, not a companion: it
100
+ says nobody has to decide, only to check. `(seen on <profile>)` is a plain note the run checks by itself. Its
101
+ `evidence: <path>` is served by the panel from `.explore/`.
74
102
 
75
103
  ### Expected
76
104
 
@@ -94,6 +122,8 @@ example-staging:
94
122
  `TODO` fails with "Incomplete values ...". Fill them before running; never make a spec read the YAML itself.
95
123
  - Values hold literals only: names, labels, options, test-data names. The checks themselves stay in `## Steps` and
96
124
  `## Expected`, and selectors come from `## Targets`.
125
+ - No marker or question inside the yaml: it would become part of the expected value. Put it on the prose line above
126
+ the block and name the key there.
97
127
 
98
128
  ### Visual checks
99
129
 
@@ -155,6 +185,9 @@ test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
155
185
  - `// target: T<n>` goes on the line before an image/screenshot/PDF check. `// ready: <signal>` goes before a check
156
186
  for absence ("missing", "empty", "hidden") and needs a positive wait before it. `// pages: <the case's words>` goes
157
187
  before an `expectPdf` that follows a case limit written in strong words ("only", "exactly", "must").
188
+ - `## Rules` are applied while replaying the case and writing the spec, but they never become assertions. Code that
189
+ exists only because of a rule carries `// rule: <short quote>` on the line before it. A rule that cannot be honored
190
+ is a question in the case, never an invented value or a skipped expectation.
158
191
  - Import `test`/`expect` from `@asrunjs/asrun/fixtures`, never from `@playwright/test`. Locators:
159
192
  `getByTestId` > `getByRole` > `getByLabel` > text.
160
193
  - No branching on `profile.id` or `profile.env`: differences go into `## Values`, selectors into `## Targets`.
@@ -185,6 +218,7 @@ test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
185
218
 
186
219
  In the framework package:
187
220
 
221
+ - `.claude/skills/exploring-cases/references/case-rules.md` — how `## Rules` are read, applied and maintained.
188
222
  - `.claude/skills/exploring-cases/references/assertion-targets.md` — what a check looks at, and when to ask.
189
223
  - `.claude/skills/exploring-cases/references/readiness.md` — wait for the content, optional confirmations, downloads.
190
224
  - `.claude/skills/exploring-cases/references/open-questions.md` — question blocks and how answers are folded in.
@@ -8,8 +8,8 @@ so the procedure lives in one place and a project can refine it (see
8
8
  | Command | What it does |
9
9
  |---|---|
10
10
  | `/make-case <notes-or-path>` | Turn raw notes into case documents. Optional step. |
11
- | `/explore-case <case-id> <env> [--attended] [--profiles a,b]` | Open the real pages and write what they show into the case. |
12
- | `/gen-spec <case-id> <env> [--force]` | Write `tests/<case-id>.spec.ts` and fill the case's `## Values`. |
11
+ | `/explore-case <case-id> <env> [--attended] [--profiles a,b] [--hints <file>]` | Open the real pages and write what they show into the case. |
12
+ | `/gen-spec <case-id> <env> [--force] [--hints <file>]` | Write `tests/<case-id>.spec.ts` and fill the case's `## Values`. |
13
13
  | `/heal-spec <case-id> <env> [--profiles a,b] [--report <run-id>] [--hints <file>]` | Repair a failing spec, or refine a case and its spec on request, from the last report and what you asked for. |
14
14
 
15
15
  The environment is **required** by every command except `/make-case`, which does not open pages. There is no default.
@@ -17,7 +17,13 @@ The environment is **required** by every command except `/make-case`, which does
17
17
  `--profiles` exists on `/explore-case` and `/heal-spec`, not on `/gen-spec`: a spec runs once per profile in the
18
18
  environment, so it is generated from every profile the case holds information for, never from a chosen subset.
19
19
 
20
- Every command ends with `RESULT: ready` or `RESULT: needs-input`. `needs-input` means somebody has to answer a
20
+ `--hints <file>` (or free text after the arguments) is optional on all three commands that read a case: what you know,
21
+ want checked or want changed. It guides that run. After the work, a reusable instruction that is **method**, not an
22
+ expectation, is written into the case's `## Rules`; a one-off clue stays in the report. See
23
+ [Rules for one case](#rules-for-one-case).
24
+
25
+ Every command ends with `RESULT: ready` or `RESULT: needs-input` as plain text on its last line (never in a code fence
26
+ or in emphasis: the web UI reads that line to show the outcome). `needs-input` means somebody has to answer a
21
27
  question in the case file (or, in the web UI, in the job's **Answer** sheet). A one-shot run cannot ask in the chat:
22
28
  it writes its questions into the case and stops.
23
29
 
@@ -49,6 +55,19 @@ It writes `cases/<id>.md` only, and never overwrites an existing case. `pnpm cas
49
55
  `pnpm case:new header-logo` creates `cases/header-logo.md` from the template, or use a case you already have. The
50
56
  format is in [cases.md](./cases.md). Write the steps, the expectations and — when you know them — the values.
51
57
 
58
+ ### Rules for one case
59
+
60
+ A case can hold a `## Rules` section: case-local instructions on **how** to run it (dismiss a cookie banner first, wait
61
+ for a dialog after a request, never use an entry that needs 2FA). It is the place to talk to the agent without putting
62
+ a requirement into the wrong section. One bullet per rule; `Step 3:`, `Before step 3:` or `After step 3:` ties it to a
63
+ step, and `pnpm case:check` fails when that step does not exist.
64
+
65
+ Rules never say what the case proves, never replace a step or an expected value, and never allow writing data, sending
66
+ email or doing a person's step: those stay in `## Expected`, `## Values` and tagged steps with a recipe. The AI reads
67
+ and applies every rule in every run, re-checks them against the page, and lists them in its report. `/explore-case`,
68
+ `/gen-spec` and `/heal-spec` re-check the rules and never delete or rewrite one on their own; a rule that no longer
69
+ holds becomes a question for you.
70
+
52
71
  ## 2. Explore the case (only where a person and the agent work together)
53
72
 
54
73
  ```
@@ -58,7 +77,9 @@ format is in [cases.md](./cases.md). Write the steps, the expectations and — w
58
77
  The agent opens the real pages of the profiles that are logged in and only **reads** them. It never creates, edits,
59
78
  publishes, shares or deletes anything, in staging or in production. It:
60
79
 
61
- - fixes wording in the case file and writes what the pages show into it, marked `observed, 🔍 to be confirmed by QA`;
80
+ - fixes wording in the case file and writes what the pages show into it: a plain note `(seen on <profile>)` when the
81
+ run checks it, and `(observed on <profile>, 🔍 to be confirmed: <what a person must decide>)` only for a line that
82
+ would pass even if it were wrong;
62
83
  - turns what it cannot know into `🚫 TODO [blocking]` / `❓ TODO [non-blocking]` questions, each under a sentence that
63
84
  is its suggestion and can stand in the case as it is, with a last line `- ✍️ Answer:`;
64
85
  - lists the candidates instead of guessing a target (`## Targets`);
@@ -82,7 +103,9 @@ the browser window opens and the agent waits while you do those steps.
82
103
  - The window shows a small draggable card in the bottom-left corner with what to do, **Done** (hand control back) and
83
104
  **Cannot do this**. The agent also continues by itself when the finish condition it named shows up (for example a
84
105
  "Saved" message); the card turns green when control is back. It waits up to 10 minutes per step.
85
- - What you did is recorded in the case under the step as **general recipe lines**
106
+ - Recording starts and stops by itself. If Playwright's own recording toolbar shows at the top of the page, leave it
107
+ alone: it can stop the recording.
108
+ - The card records what you did, and the agent writes it into the case under the step as **general recipe lines**
86
109
  (`- Recipe (phase): ...`): positions instead of names of changeable data, `[asrun]` names for created data, control
87
110
  labels but no selectors.
88
111
  - Delete the `[asrun]` items you created before you press Done on the last step.
@@ -161,7 +184,9 @@ whether it found a real product problem, and it **never** edits an expectation j
161
184
  instead of failing them).
162
185
  - The web UI first opens a dialog: which profiles to run (every logged-in profile ticked) and an optional text box
163
186
  for what should be repaired or improved, with evidence. The text is stored in `.tmp/hints/` and passed as `--hints`.
164
- It guides the work; it never lowers the bar for a product finding.
187
+ It guides the work; it never lowers the bar for a product finding. A reusable instruction that is method rather than
188
+ an expectation is also written into the case's `## Rules` (see [Rules for one case](#rules-for-one-case)); a one-off
189
+ clue stays in the report.
165
190
  - It reads the existing report first:
166
191
 
167
192
  ```bash
@@ -174,15 +199,20 @@ whether it found a real product problem, and it **never** edits an expectation j
174
199
  performs a step that writes data. After editing the spec, it runs the case once to confirm.
175
200
  - The case is the source of truth: a change to a step, an expectation, a value or a target is written into the case
176
201
  first, then into the spec. A large change regenerates the spec from the case.
177
- - **Review gate.** If the change leaves an open question or an unconfirmed observation in the case, healing stops
178
- before the spec: you review the case, answer or accept what it asks, and run `/explore-case` again. The spec is
179
- generated after that, not before.
202
+ - **Review gate.** If the change leaves an open question or a `🔍 to be confirmed` marker in the case, healing stops
203
+ before the spec: you review the case, answer what it asks or settle the marker (the panel shows why it needs you, and
204
+ `pnpm markers:confirm` settles the ones a passing run already checks), then run `/explore-case` again. The spec is
205
+ generated after that, not before. A plain `(seen on <profile>)` note never stops it: the run checks those. A marker
206
+ inside `## Rules` does not stop the spec either, unless it covers a literal the spec asserts.
180
207
 
181
208
  ## Why a generated spec can be trusted
182
209
 
183
- Three binding rules hold in exploring, generating and healing. They live in the framework's `AGENTS.md` and in
184
- `.claude/skills/exploring-cases/references/assertion-targets.md` and `readiness.md`.
210
+ Several binding rules hold in exploring, generating and healing. They live in the framework's `AGENTS.md` and in
211
+ `.claude/skills/exploring-cases/references/case-rules.md`, `assertion-targets.md` and `readiness.md`.
185
212
 
213
+ - **Case rules.** A `## Rules` bullet says how to run the case, never what it proves. The agent applies it while
214
+ exploring and generating, marks code that exists only because of a rule with `// rule: <short quote>`, and lists every
215
+ rule in its report. It never becomes an assertion and never authorizes a write or a person's step.
186
216
  - **Targets.** A check on a logo, image, icon, file, PDF, screenshot or repeated text points to a `[T1]` card under
187
217
  `## Targets`: what it is (and what it is not), where it is, how it is identified (never `.first()` or a bare `img`),
188
218
  when it is ready, and a highlighted reference screenshot under `.explore/targets/`. When in doubt the agent does not
@@ -204,7 +234,8 @@ Checks you can run yourself:
204
234
  |---|---|
205
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 |
206
236
  | `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 |
207
- | `pnpm case:check [case-id]` | fails on an incomplete card or bad values; warns on an image check with no card |
237
+ | `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 |
238
+ | `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 |
208
239
  | `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 |
209
240
 
210
241
  ## Customizing the prompts
@@ -225,11 +256,12 @@ A project refines a skill with a **flat override file**, named after the skill (
225
256
  The command and the skill read it; it is not discovered as a standalone skill. Rules combine in this order, and a
226
257
  later one wins where they conflict:
227
258
 
228
- 1. `F1`–`F6` of the framework baseline: safety, in every project. Nothing can change them.
259
+ 1. `F1`–`F6` and `R2` of the framework baseline: safety, in every project. Nothing can change them.
229
260
  2. The framework baseline, `<framework>/AGENTS.md`.
230
261
  3. The framework skill.
231
262
  4. The project override `.claude/skills/<skill>.md`.
232
263
  5. The project's own `AGENTS.md` §2 (Project rules).
264
+ 6. A case's `## Rules`: how to run that one case, never what is true.
233
265
 
234
266
  An override is plain Markdown; write only what differs:
235
267
 
@@ -241,8 +273,8 @@ An override is plain Markdown; write only what differs:
241
273
  - The staging account has no published documents: assert the empty state there instead.
242
274
  ```
243
275
 
244
- The project's `AGENTS.md` §2 is the other place: a table of rule ids (`F7`–`F10`; `F1`–`F6` are fixed) with `narrows`
245
- (stricter) or `replaces` (a different default). For example, a project that writes cases in Simplified Chinese
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
246
278
  `replaces` `F10`. `pnpm asrun doctor` fails on an unknown id, a missing `Effect`, or a non-negotiable rule, and lists
247
279
  the overrides it sees.
248
280