@asrunjs/asrun 0.8.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 (70) hide show
  1. package/.claude/commands/explore-case.md +14 -5
  2. package/.claude/commands/gen-spec.md +12 -5
  3. package/.claude/commands/heal-spec.md +23 -8
  4. package/.claude/commands/make-case.md +1 -1
  5. package/.claude/skills/exploring-cases/SKILL.md +82 -37
  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 +60 -8
  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 +148 -55
  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 +68 -20
  19. package/README.md +8 -6
  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 +57 -18
  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 +9 -3
  38. package/dist/ui/shared/ai-commands.js +25 -11
  39. package/dist/ui/shared/types.d.ts +18 -3
  40. package/docs/agents/case-format.md +39 -5
  41. package/docs/ai-workflow.md +80 -21
  42. package/docs/cases.md +28 -4
  43. package/docs/commands.md +6 -1
  44. package/docs/theme-guide.html +93 -5
  45. package/docs/troubleshooting.md +47 -3
  46. package/docs/web-ui.md +55 -10
  47. package/package.json +2 -1
  48. package/templates/.claude/commands/explore-case.md +14 -4
  49. package/templates/.claude/commands/gen-spec.md +12 -4
  50. package/templates/.claude/commands/heal-spec.md +27 -13
  51. package/templates/.claude/commands/make-case.md +1 -1
  52. package/templates/AGENTS.md +9 -6
  53. package/templates/cases/_template.md +7 -0
  54. package/templates/package.json +1 -0
  55. package/ui/dist/assets/{dist-ChBHX4rh.js → dist-2msWI-5M.js} +1 -1
  56. package/ui/dist/assets/{dist-DoChb6QQ.js → dist-4U_gPHXf.js} +1 -1
  57. package/ui/dist/assets/{dist-CJiwxr1r.js → dist-BMfJatHo.js} +1 -1
  58. package/ui/dist/assets/{dist-N-o4QIaZ.js → dist-BVvHVJ3N.js} +1 -1
  59. package/ui/dist/assets/{dist-BC_P1Gnf.js → dist-BmWaNjqO.js} +1 -1
  60. package/ui/dist/assets/{dist-Do_MhHKF.js → dist-BwNz_xW2.js} +1 -1
  61. package/ui/dist/assets/{dist-D6jmnDr-.js → dist-J36xayOv.js} +1 -1
  62. package/ui/dist/assets/{dist-CavmZIv7.js → dist-e-KhTWHJ.js} +1 -1
  63. package/ui/dist/assets/{dist-BpkTvixI.js → dist-sfZKBSMm.js} +1 -1
  64. package/ui/dist/assets/{dist-BgPbx2GZ.js → dist-wjB7g6OJ.js} +1 -1
  65. package/ui/dist/assets/index-CGbRJT3b.js +348 -0
  66. package/ui/dist/assets/index-CI0i0LWc.css +2 -0
  67. package/ui/dist/help/case-explore.png +0 -0
  68. package/ui/dist/index.html +2 -2
  69. package/ui/dist/assets/index-Cy2l91qV.css +0 -2
  70. package/ui/dist/assets/index-DGQ5logJ.js +0 -338
@@ -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
+ }
@@ -32,9 +32,15 @@ export interface AiRunOptions {
32
32
  attended?: boolean;
33
33
  /** Show the agent's reasoning in the log (`--thinking` for OpenCode). Off by default: it costs tokens and time. */
34
34
  thinking?: boolean;
35
- /** `heal-spec` only: the profiles to heal. Omitted = every profile that is logged in in `env`. */
35
+ /**
36
+ * `explore-case` and `heal-spec` only: the profiles to open / heal. Omitted = every profile that applies in `env`
37
+ * (all that are logged in, for heal). `gen-spec` takes none: it uses every profile the case names.
38
+ */
36
39
  profiles?: string[];
37
- /** `heal-spec` only: file (relative to the project root) with the user's description and 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
+ */
38
44
  hints?: string;
39
45
  /** `heal-spec` only: the report (folder name under reports/) to read first instead of the newest one. */
40
46
  report?: string;
@@ -48,7 +54,7 @@ export interface AiSession {
48
54
  id: string;
49
55
  resume: boolean;
50
56
  }
51
- /** The prompt of a one-shot run. `heal-spec` has no questions of its own, so it keeps the plain slash command. */
57
+ /** The prompt of a one-shot run. Every command may leave open questions in the case, so every one ends with a RESULT line. */
52
58
  export declare function headlessPrompt(command: AiCommand, caseId: string, options: AiRunOptions): string;
53
59
  /** Executable and arguments of the one-shot (non-interactive) run. */
54
60
  export declare function headlessInvocation(tool: AiTool, command: AiCommand, caseId: string, options: AiRunOptions, session?: AiSession): {
@@ -17,18 +17,20 @@ export const AI_COMMAND_WRITES = {
17
17
  * Agents never write data in any of them: a person does those steps.
18
18
  */
19
19
  export const AI_ENV_HINT = {
20
- 'explore-case': 'Where the agent explores. It only reads; with Attended on, you do the steps that write data.',
20
+ 'explore-case': 'Where the agent explores. Only the profiles you tick are opened; it only reads, and with Attended on you do the steps that write data.',
21
21
  'gen-spec': 'Where the agent explores and verifies the spec.',
22
- 'heal-spec': 'Where the failing spec is diagnosed. Only the profiles that are logged in there are run.',
22
+ 'heal-spec': 'Where the failure is diagnosed and the pages are checked. Only the profiles that are logged in there are run.',
23
23
  };
24
24
  export const slashCommand = (command, caseId, options) => {
25
25
  const { env, force, attended, profiles, hints, report } = options;
26
26
  const heal = command === 'heal-spec';
27
+ // gen-spec has no profile option: its scope is every profile the case names.
28
+ const picksProfiles = command === 'explore-case' || heal;
27
29
  return (`/${command} ${caseId} ${env}${force && command === 'gen-spec' ? ' --force' : ''}` +
28
30
  `${attended && command === 'explore-case' ? ' --attended' : ''}` +
29
- `${heal && profiles?.length ? ` --profiles ${profiles.join(',')}` : ''}` +
31
+ `${picksProfiles && profiles?.length ? ` --profiles ${profiles.join(',')}` : ''}` +
30
32
  `${heal && report ? ` --report ${report}` : ''}` +
31
- `${heal && hints ? ` --hints ${hints}` : ''}`);
33
+ `${hints ? ` --hints ${hints}` : ''}`);
32
34
  };
33
35
  /** The MCP tools of the Playwright server that agents explore with. */
34
36
  const PLAYWRIGHT_MCP = 'mcp__playwright-test__*';
@@ -50,6 +52,7 @@ function claudeAllowedTools(writes) {
50
52
  'Edit(/tests/**)',
51
53
  'Bash(pnpm typecheck:*)',
52
54
  'Bash(pnpm spec:lint:*)',
55
+ 'Bash(pnpm markers:confirm:*)',
53
56
  nodeTsx,
54
57
  'Bash(pnpm baselines:review:*)',
55
58
  ];
@@ -69,7 +72,9 @@ const STREAM_ARGS = {
69
72
  opencode: ['--format', 'json'],
70
73
  };
71
74
  function invocation(tool, prompt, writes, session, model, thinking) {
72
- // OpenCode creates the session when the id does not exist yet, and continues it when it does.
75
+ // OpenCode creates the session when the id does not exist yet, and continues it when it does. It also keeps the
76
+ // agent (build/plan) on the session, so a session started in plan mode would only propose changes and ask a question
77
+ // nobody can answer; every one-shot run is a build run, whatever the session it continues.
73
78
  // A model is named on every run: without one OpenCode falls back to its built-in free model, which has a daily
74
79
  // usage limit, and nothing in the CLI's own state (the TUI's last-used model) applies to a one-shot run.
75
80
  if (tool === 'opencode')
@@ -78,6 +83,8 @@ function invocation(tool, prompt, writes, session, model, thinking) {
78
83
  args: [
79
84
  'run',
80
85
  '--auto',
86
+ '--agent',
87
+ 'build',
81
88
  ...STREAM_ARGS.opencode,
82
89
  ...(session ? ['-s', session.id] : []),
83
90
  ...(model ? ['--model', model] : []),
@@ -105,12 +112,20 @@ function invocation(tool, prompt, writes, session, model, thinking) {
105
112
  };
106
113
  }
107
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.';
108
123
  /**
109
124
  * Said in every one-shot run: nobody is there to answer, so questions go into the case file and the last line
110
125
  * is a result the UI can read. The skills define the details.
111
126
  */
112
127
  const NON_INTERACTIVE = 'Non-interactive run: nobody can answer questions, so do not stop to ask. Write open questions into the case file ' +
113
- 'as the skill describes and end with the RESULT: line.';
128
+ `as the skill describes. ${RESULT_LINE_RULE}`;
114
129
  /**
115
130
  * An attended exploration: nobody answers questions in the chat, but a person sits at the machine and works in the
116
131
  * browser window, so the agent hands them the steps that write data and waits, as user-handoff.md describes.
@@ -120,12 +135,10 @@ const ATTENDED = 'Attended run: a person is at the machine and the browser windo
120
135
  'with its own call that sets __handoffCfg, before the call that runs handoff-card.js, and never change those options ' +
121
136
  'inside the call that runs the card: the web UI sees that a person is needed only from that call, so without it ' +
122
137
  'there is no "Your turn" badge and no notification. Nobody can answer questions in the chat, so write open ' +
123
- 'questions into the case file as the skill describes and end with the RESULT: line.';
124
- /** The prompt of a one-shot run. `heal-spec` has no questions of its own, so it keeps the plain slash command. */
138
+ `questions into the case file as the skill describes. ${RESULT_LINE_RULE}`;
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. */
125
140
  export function headlessPrompt(command, caseId, options) {
126
141
  const slash = slashCommand(command, caseId, options);
127
- if (command === 'heal-spec')
128
- return slash;
129
142
  if (command === 'explore-case' && options.attended)
130
143
  return `${slash} ${ATTENDED}`;
131
144
  return `${slash} ${NON_INTERACTIVE}`;
@@ -155,7 +168,7 @@ export function replyPrompt(command, replyFile) {
155
168
  `Their answers are in ${replyFile}, relative to the project root: read that file first.\n` +
156
169
  'Fold every answer into the case file as the skill describes: write the answer into the sentence above the ' +
157
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. ' +
158
- `Then finish the task of /${command} and end with the RESULT: line.`);
171
+ `Then finish the task of /${command}. ${RESULT_LINE_RULE}`);
159
172
  }
160
173
  /** The next turn of an earlier run, in its session and with exactly the permissions of the first turn. */
161
174
  export function continueInvocation(tool, command, replyFile, sessionId, model, thinking) {
@@ -170,6 +183,7 @@ export const makeCasePrompt = (memoPath) => `/make-case ${memoPath} ` +
170
183
  'Non-interactive run: nobody can answer questions, so do not stop to ask. ' +
171
184
  'Do not open a browser or explore pages. Write the suggested default of every gap as a normal sentence and put a ' +
172
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. ' +
173
187
  'Never overwrite an existing case: choose a new id instead. ' +
174
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. ' +
175
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. */
@@ -103,9 +118,9 @@ export type StartJobRequest = {
103
118
  * its built-in free model. `heal-spec` keeps the model of the run it continues.
104
119
  */
105
120
  model?: string;
106
- /** `heal-spec` only: the profiles to heal; omitted = every profile that is logged in. */
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.