@asrunjs/asrun 0.2.0 → 0.5.3

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 (103) hide show
  1. package/.claude/commands/explore-case.md +18 -15
  2. package/.claude/commands/gen-spec.md +16 -13
  3. package/.claude/commands/heal-spec.md +26 -23
  4. package/.claude/commands/make-case.md +11 -8
  5. package/.claude/skills/exploring-cases/SKILL.md +146 -132
  6. package/.claude/skills/exploring-cases/references/open-questions.md +116 -112
  7. package/.claude/skills/exploring-cases/references/readiness.md +146 -137
  8. package/.claude/skills/exploring-cases/references/user-handoff.md +157 -147
  9. package/.claude/skills/generating-specs/SKILL.md +219 -207
  10. package/.claude/skills/generating-specs/references/conventions.md +148 -148
  11. package/.claude/skills/making-cases/SKILL.md +72 -64
  12. package/.claude/skills/making-cases/references/normalizing.md +39 -38
  13. package/.claude/skills/making-cases/references/writes-and-email.md +37 -36
  14. package/AGENTS.md +169 -154
  15. package/CLAUDE.md +2 -1
  16. package/README.md +221 -410
  17. package/dist/lib/build-artifacts.d.ts +8 -0
  18. package/dist/lib/build-artifacts.js +10 -0
  19. package/dist/lib/case-doc.d.ts +311 -0
  20. package/dist/lib/case-doc.js +1117 -0
  21. package/dist/lib/cases.d.ts +2 -15
  22. package/dist/lib/cases.js +5 -78
  23. package/dist/lib/command.d.ts +24 -0
  24. package/dist/lib/command.js +90 -0
  25. package/dist/lib/fixtures.js +2 -2
  26. package/dist/lib/login-card.d.ts +22 -0
  27. package/dist/lib/login-card.js +183 -0
  28. package/dist/lib/node-cli.d.ts +6 -2
  29. package/dist/lib/node-cli.js +16 -6
  30. package/dist/lib/profiles.d.ts +25 -1
  31. package/dist/lib/profiles.js +54 -1
  32. package/dist/lib/run-filters.js +10 -2
  33. package/dist/lib/session.d.ts +13 -1
  34. package/dist/lib/session.js +73 -7
  35. package/dist/lib/targets.d.ts +5 -13
  36. package/dist/lib/targets.js +4 -29
  37. package/dist/lib/values.js +2 -2
  38. package/dist/scripts/build.js +2 -1
  39. package/dist/scripts/check-cases.js +74 -6
  40. package/dist/scripts/doctor.js +115 -4
  41. package/dist/scripts/explore.js +22 -1
  42. package/dist/scripts/go.mjs +188 -188
  43. package/dist/scripts/init.d.ts +7 -1
  44. package/dist/scripts/init.js +62 -14
  45. package/dist/scripts/lint-specs.js +4 -1
  46. package/dist/scripts/login.js +30 -2
  47. package/dist/ui/server/actions.d.ts +4 -0
  48. package/dist/ui/server/actions.js +25 -12
  49. package/dist/ui/server/ai-models.d.ts +3 -0
  50. package/dist/ui/server/ai-models.js +347 -0
  51. package/dist/ui/server/ai-stream.d.ts +34 -9
  52. package/dist/ui/server/ai-stream.js +283 -77
  53. package/dist/ui/server/app.js +74 -12
  54. package/dist/ui/server/jobs.d.ts +7 -1
  55. package/dist/ui/server/jobs.js +69 -15
  56. package/dist/ui/server/profiles.d.ts +20 -1
  57. package/dist/ui/server/profiles.js +214 -38
  58. package/dist/ui/shared/ai-commands.d.ts +16 -6
  59. package/dist/ui/shared/ai-commands.js +35 -12
  60. package/dist/ui/shared/types.d.ts +114 -0
  61. package/docs/agents/case-format.md +193 -0
  62. package/docs/agents/framework-dev.md +167 -0
  63. package/docs/ai-workflow.md +235 -0
  64. package/docs/cases.md +189 -0
  65. package/docs/commands.md +175 -0
  66. package/docs/install.md +174 -0
  67. package/docs/profiles.md +139 -0
  68. package/docs/theme-guide.html +922 -692
  69. package/docs/troubleshooting.md +101 -0
  70. package/docs/web-ui.md +139 -0
  71. package/package.json +20 -1
  72. package/templates/.claude/commands/explore-case.md +22 -21
  73. package/templates/.claude/commands/gen-spec.md +21 -20
  74. package/templates/.claude/commands/heal-spec.md +31 -30
  75. package/templates/.claude/commands/make-case.md +13 -12
  76. package/templates/.mcp.json +8 -8
  77. package/templates/AGENTS.md +66 -45
  78. package/templates/cases/_template.md +57 -63
  79. package/templates/opencode.jsonc +16 -11
  80. package/templates/profiles.yaml +42 -29
  81. package/ui/dist/assets/{dist-D_I45xgJ.js → dist-BudNAjOv.js} +1 -1
  82. package/ui/dist/assets/{dist-CBV7_vDN.js → dist-C-eCSFja.js} +1 -1
  83. package/ui/dist/assets/{dist-gF2bFn-5.js → dist-CO4heSzh.js} +1 -1
  84. package/ui/dist/assets/{dist-DX8knLVr.js → dist-CPLhRzom.js} +1 -1
  85. package/ui/dist/assets/{dist-BqAROxzI.js → dist-DEPJZ3Me.js} +1 -1
  86. package/ui/dist/assets/{dist-BwYRbe5s.js → dist-DZ7RiqmA.js} +1 -1
  87. package/ui/dist/assets/{dist-BHaIOZdW.js → dist-LixzGniF.js} +1 -1
  88. package/ui/dist/assets/{dist-DHCY9KpV.js → dist-O_RVi4tP.js} +1 -1
  89. package/ui/dist/assets/{dist-BiE4suo9.js → dist-jPS6HCJ5.js} +1 -1
  90. package/ui/dist/assets/{dist-BSJMM_jp.js → dist-vmf3fnkM.js} +1 -1
  91. package/ui/dist/assets/index-BIGt8Hfz.js +338 -0
  92. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  93. package/ui/dist/help/ai-tools.png +0 -0
  94. package/ui/dist/help/case-explore.png +0 -0
  95. package/ui/dist/help/case-generate.png +0 -0
  96. package/ui/dist/help/cases-memo.png +0 -0
  97. package/ui/dist/help/profile-form.png +0 -0
  98. package/ui/dist/help/profiles-login.png +0 -0
  99. package/ui/dist/help/reports.png +0 -0
  100. package/ui/dist/help/run-tests.png +0 -0
  101. package/ui/dist/index.html +22 -16
  102. package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
  103. package/ui/dist/assets/index-I7uU93kk.js +0 -294
@@ -68,10 +68,25 @@ const STREAM_ARGS = {
68
68
  claude: ['--output-format', 'stream-json', '--verbose'],
69
69
  opencode: ['--format', 'json'],
70
70
  };
71
- function invocation(tool, prompt, writes, session) {
71
+ function invocation(tool, prompt, writes, session, model, thinking) {
72
72
  // OpenCode creates the session when the id does not exist yet, and continues it when it does.
73
+ // A model is named on every run: without one OpenCode falls back to its built-in free model, which has a daily
74
+ // usage limit, and nothing in the CLI's own state (the TUI's last-used model) applies to a one-shot run.
73
75
  if (tool === 'opencode')
74
- return { file: 'opencode', args: ['run', '--auto', ...STREAM_ARGS.opencode, ...(session ? ['-s', session.id] : []), prompt] };
76
+ return {
77
+ file: 'opencode',
78
+ args: [
79
+ 'run',
80
+ '--auto',
81
+ ...STREAM_ARGS.opencode,
82
+ ...(session ? ['-s', session.id] : []),
83
+ ...(model ? ['--model', model] : []),
84
+ // Without it OpenCode does not ask the model to think, so the reasoning it does would never reach the log.
85
+ // Claude Code has no such flag: it shows thinking when the model and its settings have it on.
86
+ ...(thinking ? ['--thinking'] : []),
87
+ prompt,
88
+ ],
89
+ };
75
90
  return {
76
91
  file: 'claude',
77
92
  args: [
@@ -81,6 +96,7 @@ function invocation(tool, prompt, writes, session) {
81
96
  ...(session ? [session.resume ? '--resume' : '--session-id', session.id] : []),
82
97
  // `acceptEdits` approves edits anywhere in the folder, which would void the narrow Edit rules below.
83
98
  ...(writes === 'files' || writes === 'spec' ? ['--permission-mode', 'acceptEdits'] : []),
99
+ ...(model ? ['--model', model] : []),
84
100
  '--allowedTools',
85
101
  ...claudeAllowedTools(writes),
86
102
  '--disallowedTools',
@@ -100,7 +116,10 @@ const NON_INTERACTIVE = 'Non-interactive run: nobody can answer questions, so do
100
116
  * browser window, so the agent hands them the steps that write data and waits, as user-handoff.md describes.
101
117
  */
102
118
  const ATTENDED = 'Attended run: a person is at the machine and the browser window is visible, so hand steps that write data or ' +
103
- 'need a choice to them and wait, as the skill describes. Nobody can answer questions in the chat, so write open ' +
119
+ 'need a choice to them and wait, as the skill describes. Start every handoff (also a second one in the same run) ' +
120
+ 'with its own call that sets __handoffCfg, before the call that runs handoff-card.js, and never change those options ' +
121
+ 'inside the call that runs the card: the web UI sees that a person is needed only from that call, so without it ' +
122
+ 'there is no "Your turn" badge and no notification. Nobody can answer questions in the chat, so write open ' +
104
123
  'questions into the case file as the skill describes and end with the RESULT: line.';
105
124
  /** The prompt of a one-shot run. `heal-spec` has no questions of its own, so it keeps the plain slash command. */
106
125
  export function headlessPrompt(command, caseId, options) {
@@ -113,7 +132,7 @@ export function headlessPrompt(command, caseId, options) {
113
132
  }
114
133
  /** Executable and arguments of the one-shot (non-interactive) run. */
115
134
  export function headlessInvocation(tool, command, caseId, options, session) {
116
- return invocation(tool, headlessPrompt(command, caseId, options), AI_COMMAND_WRITES[command], session);
135
+ return invocation(tool, headlessPrompt(command, caseId, options), AI_COMMAND_WRITES[command], session, options.model, options.thinking);
117
136
  }
118
137
  /** The one-shot command as it is typed in a shell. */
119
138
  export function headlessCommandLine(tool, command, caseId, options, session) {
@@ -122,23 +141,27 @@ export function headlessCommandLine(tool, command, caseId, options, session) {
122
141
  /** Folder (relative to the AsRun root, git-ignored) where the clues typed into the Heal dialog are stored. */
123
142
  export const HINTS_DIR = '.tmp/hints';
124
143
  export const HINTS_MAX_CHARS = 8000;
144
+ /** Folder (relative to the AsRun root, git-ignored) where the answers typed into the Continue box are stored. */
145
+ export const REPLY_DIR = '.tmp/replies';
125
146
  export const REPLY_MAX_CHARS = 4000;
126
147
  export const USE_DEFAULTS_REPLY = 'Accept the suggestion of every open question: delete its TODO block and keep the sentence above it as confirmed text.';
127
148
  /**
128
149
  * The next turn of a run that ended with `RESULT: needs-input`: the user's answers. They go into the case file, which
129
- * stays the record of truth, and the task of the original command is finished.
150
+ * stays the record of truth, and the task of the original command is finished. The answers come from a file
151
+ * (`.tmp/replies/…md`) rather than from the prompt, so a line break or a quote in them cannot break the command line.
130
152
  */
131
- export function replyPrompt(command, reply) {
132
- return (`The user answers your open questions (still nobody to ask, so do not stop to ask again):\n${reply.trim()}\n` +
153
+ export function replyPrompt(command, replyFile) {
154
+ return (`The user answers your open questions (still nobody to ask, so do not stop to ask again). ` +
155
+ `Their answers are in ${replyFile}, relative to the project root: read that file first.\n` +
133
156
  'Fold every answer into the case file as the skill describes: write the answer into the sentence above the ' +
134
157
  'TODO block and remove the block, and keep a TODO block (with an empty Answer: line, emoji prefixes included) for what is still unknown. ' +
135
158
  `Then finish the task of /${command} and end with the RESULT: line.`);
136
159
  }
137
160
  /** The next turn of an earlier run, in its session and with exactly the permissions of the first turn. */
138
- export function continueInvocation(tool, command, reply, sessionId) {
139
- return invocation(tool, replyPrompt(command, reply), AI_COMMAND_WRITES[command], { id: sessionId, resume: true });
161
+ export function continueInvocation(tool, command, replyFile, sessionId, model, thinking) {
162
+ return invocation(tool, replyPrompt(command, replyFile), AI_COMMAND_WRITES[command], { id: sessionId, resume: true }, model, thinking);
140
163
  }
141
- export const continueCommandLine = (tool, command, reply, sessionId) => commandLine(continueInvocation(tool, command, reply, sessionId));
164
+ export const continueCommandLine = (tool, command, replyFile, sessionId, model, thinking) => commandLine(continueInvocation(tool, command, replyFile, sessionId, model, thinking));
142
165
  /** Folder (relative to the AsRun root, git-ignored) where memos from the Cases page are stored. */
143
166
  export const MEMO_DIR = '.tmp/memos';
144
167
  export const MEMO_MAX_CHARS = 100_000;
@@ -150,5 +173,5 @@ export const makeCasePrompt = (memoPath) => `/make-case ${memoPath} ` +
150
173
  'Never overwrite an existing case: choose a new id instead. ' +
151
174
  'Put the cases of one area in a group folder (cases/<group>/<name>.md, id <group>/<name>), reusing an existing folder when it fits. ' +
152
175
  'Write the cases, run pnpm case:check, and list the open questions in your final report.';
153
- export const makeCaseInvocation = (tool, memoPath) => invocation(tool, makeCasePrompt(memoPath), 'cases-new');
154
- export const makeCaseCommandLine = (tool, memoPath) => commandLine(makeCaseInvocation(tool, memoPath));
176
+ export const makeCaseInvocation = (tool, memoPath, model, thinking) => invocation(tool, makeCasePrompt(memoPath), 'cases-new', undefined, model, thinking);
177
+ export const makeCaseCommandLine = (tool, memoPath, model, thinking) => commandLine(makeCaseInvocation(tool, memoPath, model, thinking));
@@ -33,6 +33,8 @@ export interface JobInfo {
33
33
  aiCommand?: AiCommand;
34
34
  /** The conversation of an AI run; a later job can resume it. */
35
35
  sessionId?: string;
36
+ /** The model the AI run was started with; omitted = the CLI's own choice. */
37
+ aiModel?: string;
36
38
  /** What the agent said in its last `RESULT:` line; null when it did not say or the run did not succeed. */
37
39
  resultState?: ResultState | null;
38
40
  /** The earlier job whose conversation this job continues. */
@@ -47,10 +49,33 @@ export interface AwaitingUserInfo {
47
49
  }
48
50
  /** `needs-input`: the agent wrote open questions into the case file and cannot go on without answers. */
49
51
  export type ResultState = 'ready' | 'needs-input';
52
+ /**
53
+ * How one block of an AI job's log is presented. Absent for the plain output of every other job, which stays raw.
54
+ * `markdown`: what the agent said; `reasoning`: what it was thinking (not a result); `tool`: one call, started or
55
+ * finished; `result`: the first lines of a call's output; `error` / `hint`: what failed and what to do about it;
56
+ * `meta`: anything else a person should read (the waiting line, noise the CLI printed).
57
+ */
58
+ export type JobChunkKind = 'tool' | 'result' | 'reasoning' | 'markdown' | 'error' | 'hint' | 'meta';
59
+ /** A tool call of an AI job, so the log can color its name, its outcome and the time it took. */
60
+ export interface JobChunkTool {
61
+ name: string;
62
+ /** What the call is about (the file, the command, the intent); empty when the CLI gave nothing telling. */
63
+ detail: string;
64
+ ok: boolean;
65
+ /** Still running: Claude reports the start of a call, OpenCode only its end. */
66
+ pending?: boolean;
67
+ /** How long it ran, as a person reads it (`0.4 s`); absent when the CLI did not report a start time. */
68
+ took?: string;
69
+ }
50
70
  export interface JobChunk {
51
71
  seq: number;
52
72
  stream: 'stdout' | 'stderr' | 'system';
53
73
  data: string;
74
+ /** Set for the blocks of an AI job (see `JobChunkKind`); absent for a plain command's output. */
75
+ kind?: JobChunkKind;
76
+ /** When the block was produced, for AI jobs: shown as its own styled timestamp, not as part of the text. */
77
+ at?: string;
78
+ tool?: JobChunkTool;
54
79
  }
55
80
  export type StartJobRequest = {
56
81
  action: 'signin';
@@ -68,6 +93,16 @@ export type StartJobRequest = {
68
93
  env: string;
69
94
  force?: boolean;
70
95
  attended?: boolean;
96
+ /**
97
+ * Show the agent's reasoning in the log (`--thinking` for OpenCode; Claude shows it when its model has it on).
98
+ * Off by default: it costs tokens and time.
99
+ */
100
+ thinking?: boolean;
101
+ /**
102
+ * The model to run the agent with (as the CLI names it). Omitted = the CLI's own choice, which for OpenCode is
103
+ * its built-in free model. `heal-spec` keeps the model of the run it continues.
104
+ */
105
+ model?: string;
71
106
  /** `heal-spec` only: the profiles to heal; omitted = every profile that is logged in. */
72
107
  profiles?: string[];
73
108
  /** `heal-spec` only: what the user knows (optional). The server stores it as a file for the agent. */
@@ -82,6 +117,8 @@ export type StartJobRequest = {
82
117
  action: 'make-cases';
83
118
  tool: AiTool;
84
119
  memo: string;
120
+ model?: string;
121
+ thinking?: boolean;
85
122
  } | {
86
123
  action: 'run';
87
124
  env: string;
@@ -163,6 +200,16 @@ export interface RunPlan {
163
200
  }
164
201
  export type ProfileStatus = 'missing' | 'unverified' | 'ok' | 'expired' | 'error';
165
202
  /** One profile: a row of the Profiles page. */
203
+ export interface SessionCheckInfo {
204
+ /** Where a visitor without a valid session is sent: a path on the site (`/login`) or a full URL (single sign-on). */
205
+ loggedOutUrl: string;
206
+ /** A selector that only exists while signed in; null when the profile does not name one. */
207
+ loggedInSelector: string | null;
208
+ /** A selector that proves the visitor is signed out, e.g. the password field of a login form. */
209
+ loggedOutSelector: string | null;
210
+ /** How long to wait for one of those signals, in milliseconds. */
211
+ timeoutMs: number;
212
+ }
166
213
  export interface ProfileInfo {
167
214
  id: string;
168
215
  name: string;
@@ -170,6 +217,14 @@ export interface ProfileInfo {
170
217
  baseURL: string;
171
218
  account: string | null;
172
219
  tags: string[];
220
+ /** The page "Visit" opens after a login, e.g. `/app/home`; empty = the site root. */
221
+ startPath: string;
222
+ /** A page only a signed-in visitor sees; `/account/` unless set in profiles.yaml. */
223
+ sessionCheckPath: string;
224
+ /** How that page shows a missing login. */
225
+ sessionCheck: SessionCheckInfo;
226
+ /** Tests may send real email in this environment. Always false for production. */
227
+ email: boolean;
173
228
  status: ProfileStatus;
174
229
  verifying: boolean;
175
230
  /** When the session file was last written. */
@@ -186,11 +241,41 @@ export interface AddProfileRequest {
186
241
  /** A label for humans (a mailbox or username). Never a password. */
187
242
  account?: string;
188
243
  tags?: string[];
244
+ /** The page "Visit" opens after a login. Defaults to the site root. */
245
+ startPath?: string;
189
246
  /** Page that redirects to /login when logged out. Defaults to /account/. */
190
247
  sessionCheckPath?: string;
248
+ /** How that page shows a missing login. Omitted keys keep their default. */
249
+ sessionCheck?: Partial<SessionCheckInfo>;
191
250
  /** Only for environments other than production. Defaults to false. */
192
251
  email?: boolean;
193
252
  }
253
+ /** Changes to one existing profile. The id itself cannot change: commands, session files and baselines use it. */
254
+ export interface EditProfileRequest {
255
+ name: string;
256
+ /** The environment this profile points at, e.g. `production`, `staging`. */
257
+ env: string;
258
+ baseURL: string;
259
+ /** A label for humans (a mailbox or username). Never a password. */
260
+ account?: string;
261
+ tags?: string[];
262
+ /** The page "Visit" opens after a login. Defaults to the site root. */
263
+ startPath?: string;
264
+ /** Page that redirects to /login when logged out. Defaults to /account/. */
265
+ sessionCheckPath?: string;
266
+ /** How that page shows a missing login. Omitted keys keep their default. */
267
+ sessionCheck?: Partial<SessionCheckInfo>;
268
+ /** Only for environments other than production. Defaults to false. */
269
+ email?: boolean;
270
+ }
271
+ export interface EditProfileResult {
272
+ profile: ProfileInfo;
273
+ /**
274
+ * True when the site address changed: the saved login belonged to the old site, was deleted, and the
275
+ * profile has to log in again.
276
+ */
277
+ sessionCleared: boolean;
278
+ }
194
279
  export interface ProfilesResponse {
195
280
  profiles: ProfileInfo[];
196
281
  /** Every environment used in profiles.yaml, `production` first. */
@@ -255,3 +340,32 @@ export interface CaseImpact {
255
340
  snapshotDir: string | null;
256
341
  snapshotCount: number;
257
342
  }
343
+ /** One model an AI CLI can be run with, as the CLI itself names it. */
344
+ export interface AiModelOption {
345
+ /** Exactly what `--model` takes: `provider/model` or `provider/model#variant` (OpenCode), an alias or an id (Claude). */
346
+ id: string;
347
+ label: string;
348
+ /** Where the row comes from: a provider id, or `alias` / `settings` / `gateway` for a Claude CLI. */
349
+ group: string;
350
+ /** A second line: what the model is, or what an alias resolves to. */
351
+ detail?: string;
352
+ }
353
+ /** The models one AI CLI offers on this machine, read from the CLI itself or from its configuration. */
354
+ export interface AiModelsResponse {
355
+ tool: AiTool;
356
+ models: AiModelOption[];
357
+ /**
358
+ * What the CLI would run when asrun passes no `--model`; null when it cannot be known (OpenCode picks a built-in
359
+ * free model, which is not stored anywhere).
360
+ */
361
+ clientDefault: string | null;
362
+ /**
363
+ * What to preselect while the user has not chosen: the CLI's default (Claude), or the model the CLI's own last
364
+ * session used (OpenCode). Null when neither is known.
365
+ */
366
+ suggested: string | null;
367
+ /** Parts of the list that could not be read, in the words of the CLI: shown above the list, never fatal. */
368
+ notes: string[];
369
+ /** Set when there is no list at all (the CLI is not installed, or its answer could not be read). */
370
+ error: string | null;
371
+ }
@@ -0,0 +1,193 @@
1
+ # Case document contract (for AI agents)
2
+
3
+ This file describes the shape that `lib/case-doc.ts`, `lib/values.ts` and `lib/targets.ts` read, and how a generated
4
+ spec consumes it. It holds no rules of its own: the rules are in the framework `AGENTS.md`, in the skill, and in the
5
+ references listed at the end.
6
+
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.
9
+
10
+ ## Identity and paths
11
+
12
+ - A case id is its path below `cases/` without `.md`: `header-logo`, or `documents/landing-page` with **one** group
13
+ folder (`^[a-z0-9]+(-[a-z0-9]+)*(/[a-z0-9]+(-[a-z0-9]+)*)?$`).
14
+ - The spec is at the same path below `tests/` (`tests/documents/landing-page.spec.ts`), with its `*-snapshots/`
15
+ folder next to it.
16
+ - `cases/_template.md` and names starting with `_` are not cases.
17
+ - Specs import the framework by package name (`@asrunjs/asrun/*`), so a group folder changes no import.
18
+
19
+ ## Fixed tokens
20
+
21
+ The parser and the web panel match these literals. A project language never translates them:
22
+
23
+ - Section headings: `## Goal`, `## Steps`, `## Expected`, `## Values`, `## Visual checks`, `## Targets`, `## Notes`,
24
+ and `## Source` when the case was made from notes.
25
+ - Front matter keys: `id`, `title`, `login`, `suites`, `writes`, `writes-note`, `envs`.
26
+ - Step tags at the end of a step line: `[input]`, `[writes]`, `[email]`.
27
+ - Under a step: `- Ready when:` lines and `- Recipe (phase):` lines.
28
+ - Target cards: `### T<n> <name>` and their references `[T<n>]`.
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`.
32
+
33
+ ## Front matter
34
+
35
+ `---` on the first line, YAML, `---` to close. `pnpm case:check` calls `parseCaseMeta`, which refuses the file when
36
+ `suites`, `writes` or `envs` break the rules below. `id`, `title` and `login` are required by convention: keep them
37
+ present (the editor shows them, and the generator reads the title and the login mode), but `case:check` does not
38
+ enforce them.
39
+
40
+ | Key | Required | Rule |
41
+ |---|---|---|
42
+ | `id` | yes | The case id; the editor keeps it in step with the path. |
43
+ | `title` | yes | One line. |
44
+ | `login` | no | `required` (default) or `none`. `none` → the spec sets `test.use({mustLogin: false})`. |
45
+ | `suites` | yes | Non-empty list of lowercase words. Missing `suites` is an error. |
46
+ | `writes` | no | List of environments. Omit for read-only. Any value other than a known environment is refused; the old `writes: test-data` is refused with a hint. |
47
+ | `writes-note` | with `writes` | Non-empty text: what the case creates or changes. |
48
+ | `envs` | no | List of environments; every environment in `writes` must also be in `envs`. |
49
+
50
+ `priority: <level>` is the old name of `suites`: it is read as `suites: [<level>]`, and naming both is an error.
51
+ Unknown environments are refused against the environments of `profiles.yaml`.
52
+
53
+ `caseTags(id)` (`@asrunjs/asrun/cases`) turns the front matter into Playwright tags: one `@<suite>` per suite, plus
54
+ `@writes` for a case that writes. The spec passes `{tag: caseTags('<id>')}` to the top-level `test.describe`; it never
55
+ hard-codes tags.
56
+
57
+ ## Sections
58
+
59
+ ### Steps
60
+
61
+ - Numbered Markdown lines (`1.`, `2.`, ...). The spec wraps each one in `test.step('<n>. <text>', ...)` with the text
62
+ verbatim, including the number. Do not paraphrase it.
63
+ - A step's tags are the last tokens of its line: `[input]` (a person makes a choice), `[writes]` (it changes data),
64
+ `[email]` (it sends email). All may appear.
65
+ - `- Ready when:` is a sub-line of the step: the positive signal that what the step loads is there. The spec waits
66
+ for it before reading; it is not an assertion.
67
+ - `- Recipe (phase):` is a sub-line of an `[input]`/`[writes]` step: what a person did in the browser during an
68
+ attended exploration (positions, `[asrun]` names, control labels; no selectors). A tagged step with no recipe is a
69
+ `needs-input` stop for `/gen-spec`.
70
+ - A question block is indented under the sentence it belongs to: 4 spaces under a numbered step, 2 under a bullet.
71
+ The parent line is the suggestion, written as a finished sentence; the `TODO` line holds one question and optional
72
+ `(evidence)`; the block ends with `- ✍️ Answer:` at one level deeper and that line is the last line of the block.
73
+ Details: `.claude/skills/exploring-cases/references/open-questions.md`.
74
+
75
+ ### Expected
76
+
77
+ A bullet list of behaviour that must hold for every profile. Profile differences are values, not prose.
78
+
79
+ ### Values
80
+
81
+ One fenced `yaml` block, and nothing else in `## Values`:
82
+
83
+ ```yaml
84
+ default:
85
+ createButton: Create New
86
+ example-staging:
87
+ previewLogoAlt: Example
88
+ ```
89
+
90
+ - The top level is a map of profile id (or `default`) to a map of keys. Any other shape, an unknown profile id, or
91
+ a value that is not a map is an error.
92
+ - `requireValues('<id>', profile)` deep-merges `default` under the profile's own entry.
93
+ - A profile with no entry and no `default` fails with "Missing values ...". An empty value or one that starts with
94
+ `TODO` fails with "Incomplete values ...". Fill them before running; never make a spec read the YAML itself.
95
+ - Values hold literals only: names, labels, options, test-data names. The checks themselves stay in `## Steps` and
96
+ `## Expected`, and selectors come from `## Targets`.
97
+
98
+ ### Visual checks
99
+
100
+ Bullets naming what must match a screenshot baseline. A check on a logo, image, icon, file, PDF, screenshot or
101
+ repeated text needs a `[T<n>]` card.
102
+
103
+ ### Targets
104
+
105
+ One card per distinct thing a check looks at:
106
+
107
+ ```markdown
108
+ ### T1 Header logo
109
+ - What: <the thing itself, and what it is not when a look-alike exists>
110
+ - Where: <region of the page, dialog, tab or file page>
111
+ - Identified by: <role and name, test id, alt text or title; never a position>
112
+ - Not: <look-alikes that must not be matched>
113
+ - Ready when: <what shows that the content has loaded>
114
+ - Reference <profile-id>: .explore/targets/<case-id>/T1-<profile-id>.png
115
+ ```
116
+
117
+ - `What`, `Where` and `Identified by` are required for a card that is not blocking.
118
+ - `Reference <profile-id>:` must point under `.explore/targets/<case-id>/` (git-ignored), or be `none: <reason>`.
119
+ A missing file on this machine is a warning, not a failure: a fresh checkout has the card but not the screenshot.
120
+ - A card may hold a `🚫 TODO [blocking]` block. Such a card is not complete; `pnpm case:check` does not fail on it,
121
+ and the check that uses it is not generated yet.
122
+ - Every `[T<n>]` in a step, a ready signal or a check resolves to a card. `pnpm case:check` fails on a reference with
123
+ no card.
124
+ - The binding rules for choosing a target (never guess, list candidates) are in
125
+ `.claude/skills/exploring-cases/references/assertion-targets.md`.
126
+
127
+ ### Notes and Source
128
+
129
+ `## Notes` is free prose for a reviewer. `## Source` holds the original lines of a memo, written by `/make-case`.
130
+ The panel shows both read-only.
131
+
132
+ ## How a spec consumes the case
133
+
134
+ ```ts
135
+ // case: cases/<id>.md
136
+ import {caseTags} from '@asrunjs/asrun/cases';
137
+ import {configureCase, expect, test} from '@asrunjs/asrun/fixtures';
138
+ import {requireValues} from '@asrunjs/asrun/values';
139
+
140
+ test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
141
+ configureCase('<id>'); // no retries and a longer timeout for a case that writes data
142
+ test('<single outcome>', async ({page, profile}) => {
143
+ const expected = requireValues<{/* keys */}>('<id>', profile);
144
+
145
+ await test.step('1. <step text, verbatim>', async () => {
146
+ // an action that loads content: actAndWait(page, () => click(), {responses, ready})
147
+ // a confirmation that may appear: clickOptionalUntilHidden(confirm, target, {message})
148
+ // a download: verifyDownload(page, trigger, {kind})
149
+ });
150
+ });
151
+ });
152
+ ```
153
+
154
+ - The first line `// case: cases/<id>.md` is what `pnpm spec:lint` uses to find the case and its targets.
155
+ - `// target: T<n>` goes on the line before an image/screenshot/PDF check. `// ready: <signal>` goes before a check
156
+ for absence ("missing", "empty", "hidden") and needs a positive wait before it. `// pages: <the case's words>` goes
157
+ before an `expectPdf` that follows a case limit written in strong words ("only", "exactly", "must").
158
+ - Import `test`/`expect` from `@asrunjs/asrun/fixtures`, never from `@playwright/test`. Locators:
159
+ `getByTestId` > `getByRole` > `getByLabel` > text.
160
+ - No branching on `profile.id` or `profile.env`: differences go into `## Values`, selectors into `## Targets`.
161
+ - Paths are relative (`page.goto('/account/')`); the Playwright project sets `baseURL`.
162
+
163
+ ## Profiles, values and the write gate
164
+
165
+ - Each entry in `profiles.yaml` is one Playwright project named after its `id`. A spec runs once per profile.
166
+ - The `profile` fixture exposes `id`, `env`, `baseURL`, `account`, `tags`, `email`.
167
+ - The `caseGate` fixture enforces `envs` and `writes` at run time, also when `playwright test` is called directly:
168
+ a case limited to some environments is skipped elsewhere; a case that writes elsewhere only is skipped with a
169
+ notice; a write in a non-staging environment needs the confirmation the CLI sets, never an environment variable
170
+ that was merely present.
171
+ - Write cases: name everything `[asrun] ...`, register it with `records.track` right after creating it, remove it
172
+ newest first (also after a failure), turn retries off with `configureCase`, and keep email steps behind
173
+ `emailStepAllowed(profile, '<step title>')`.
174
+
175
+ ## Checks to run before reporting
176
+
177
+ | Command | Must |
178
+ |---|---|
179
+ | `pnpm case:check [<id>]` | no `FAIL` |
180
+ | `pnpm typecheck` | no errors |
181
+ | `pnpm spec:lint <id>` | no `FAIL` |
182
+ | `pnpm baselines:review <id>` | every new image is looked at (a person's job) |
183
+
184
+ ## Related references
185
+
186
+ In the framework package:
187
+
188
+ - `.claude/skills/exploring-cases/references/assertion-targets.md` — what a check looks at, and when to ask.
189
+ - `.claude/skills/exploring-cases/references/readiness.md` — wait for the content, optional confirmations, downloads.
190
+ - `.claude/skills/exploring-cases/references/open-questions.md` — question blocks and how answers are folded in.
191
+ - `.claude/skills/exploring-cases/references/user-handoff.md` — attended exploration and the handoff card.
192
+ - `.claude/skills/generating-specs/references/conventions.md` — spec shape, assertion layers, readable failures.
193
+ - `docs/cases.md` — the same format for people writing a case by hand.
@@ -0,0 +1,167 @@
1
+ # Working on AsRun itself
2
+
3
+ Read `AGENTS.md` first: it is the product baseline, and this file only adds how to change the framework. Nothing here
4
+ is relevant to a project that uses asrun as a dependency.
5
+
6
+ ## Framework layout
7
+
8
+ `<framework>` for a project is `node_modules/@asrunjs/asrun`. In this repository it is the root folder:
9
+
10
+ - `lib/` fixtures (`profile`, session guard, write gate) and helpers; specs import them as
11
+ `@asrunjs/asrun/<module>` (for example `@asrunjs/asrun/fixtures`, `@asrunjs/asrun/values`)
12
+ - `scripts/` the user-facing commands; every command and flag is documented in `docs/commands.md`
13
+ - `templates/` the files `pnpm asrun init` writes into a workspace
14
+ - `ui/` local web UI (`pnpm ui`). It may only run the fixed actions in `ui/server/actions.ts`
15
+ and must never serve anything under `.auth/`.
16
+ - `docs/` user documentation (`install.md`, `profiles.md`, `cases.md`, `ai-workflow.md`,
17
+ `commands.md`, `web-ui.md`, `troubleshooting.md`) and `docs/theme-guide.html`
18
+ - `docs/agents/` documents for AI agents: this file, and `case-format.md` (the case document contract)
19
+ - `examples/demo/` a workspace for developing the framework itself against a local Docker stack (Conduit);
20
+ it is committed (`.auth/` and `reports/` never are), see its README
21
+ - `.claude/` skills + slash commands for Claude Code; the skills are shared with OpenCode
22
+ - `.opencode/commands/` the same commands for OpenCode (it does not read `.claude/commands`)
23
+ - `AGENTS.md` the product baseline rules (OpenCode reads it); `CLAUDE.md` imports it for Claude Code
24
+ - `README.md` the user-facing overview: what it does, install, first test, cookbook, links to `docs/`
25
+
26
+ `package.json` `files` ships `README.md`, `AGENTS.md`, `CLAUDE.md`, `docs/`, `templates/`, `.claude/`, `bin/`,
27
+ `dist/` and `ui/dist/`, so a project can read the same documentation from
28
+ `node_modules/@asrunjs/asrun/docs/`.
29
+
30
+ ## Working on the framework itself
31
+
32
+ - The framework repository holds no workspace content at the root: no `profiles.yaml`, `cases/`, `tests/` or
33
+ reports there. The one workspace is `examples/demo`, which is committed because it runs its own local site; its
34
+ `.auth/`, `reports/` and other run output are not.
35
+ - Develop against the demo:
36
+
37
+ ```bash
38
+ pnpm demo:up # build and start the local stack
39
+ pnpm demo:seed # accounts, articles, follows, favorites
40
+ pnpm asrun init --root examples/demo # also writes the framework's .env (ASRUN_ROOT=examples/demo)
41
+ pnpm build # the demo's specs import the package, which resolves to dist/
42
+ pnpm asrun --env staging --read-only
43
+ ```
44
+
45
+ `pnpm demo:reset` deletes the database and seeds it again; logins must be redone afterwards. Demo accounts are in
46
+ `examples/demo/README.md`.
47
+ - Everything the framework reads for a project goes through `ROOT` in `lib/paths.ts` (the workspace root); its own
48
+ assets (templates, docs, skills, its `node_modules`) go through `FRAMEWORK_ROOT`. Never write into
49
+ `FRAMEWORK_ROOT` at run time.
50
+ - After changing `lib/`, run `pnpm build` before exercising the demo's specs.
51
+ - `templates/` is the source of the files a workspace gets. When a template changes, bump `version` in
52
+ `package.json` so `pnpm asrun doctor` in a project shows that it is behind.
53
+ - `init` owns two lists: `GENERATED` (rewritten by `--update`; the commands and the Playwright files) and `ONCE`
54
+ (written only when missing; `AGENTS.md`, `CLAUDE.md`, the configs). `AGENTS.md`, `CLAUDE.md` and
55
+ `.claude/skills/**` belong to the project: `init --force` refuses them unless `--reset-rules` is given, so a
56
+ framework update can never overwrite a project's rules.
57
+ - A project's rules never flow back here. The framework reads a workspace only through `ROOT` at run time; nothing
58
+ under `templates/` or `.claude/skills/` is generated from a project.
59
+
60
+ ### Installing this repository as a Git dependency (private)
61
+
62
+ The npm package is the public channel for users. This repository is private, so a Git dependency works only on a
63
+ machine that can clone it:
64
+
65
+ ```bash
66
+ pnpm add -D github:wizcas/asrun#<tag>
67
+ ```
68
+
69
+ pnpm 12 resolves a GitHub spec through the canonical HTTPS URL, not SSH. That machine therefore needs HTTPS
70
+ credentials (a credential helper or a token), or a rewrite to SSH:
71
+
72
+ ```bash
73
+ git config --global url."git@github.com:".insteadOf https://github.com/
74
+ ```
75
+
76
+ On CI, give the runner a `GITHUB_TOKEN`, for example:
77
+
78
+ ```bash
79
+ git config --global \
80
+ url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
81
+ ```
82
+
83
+ or a deploy key with the SSH rewrite above. A Git install also runs the package's `prepare` (it builds `dist/` and
84
+ `ui/dist/`), so it is slower than the registry: use it only to pin a commit, a branch or a tag that is not published
85
+ yet. For a one-off change, `pnpm patch @asrunjs/asrun` and commit the patch instead.
86
+
87
+ ## Documentation
88
+
89
+ The split is intentional; keep it:
90
+
91
+ - `README.md` — the user's first page: what AsRun does, requirements, install, the first test, customizing the
92
+ prompts, the every-day cookbook, safety in short, a documentation index, maintainer notes. It should stay readable
93
+ in one sitting; a section that grows past a screen belongs in `docs/`.
94
+ - `docs/*.md` — the user reference. `docs/commands.md` is the only place that lists every command and flag.
95
+ - `docs/agents/*.md` — for AI agents. `case-format.md` is the contract of the case document (fixed tokens, values,
96
+ targets); `AGENTS.md` references it, and the template `AGENTS.md` does too.
97
+
98
+ When behavior changes, update the matching document in the same change. A command or flag goes into
99
+ `docs/commands.md` and, if it is a daily one, into the README cookbook. A change to the case format goes into
100
+ `docs/agents/case-format.md` and `docs/cases.md` together. Keep the relative links working and the fixed tokens
101
+ spelled exactly as the parser reads them.
102
+
103
+ ## The commands and the two agent CLIs
104
+
105
+ - Claude Code reads `.claude/commands/*` and `.claude/skills/<name>/SKILL.md`.
106
+ - OpenCode reads `.opencode/commands/*`, `.claude/skills/<name>/SKILL.md`, `.opencode/skills/<name>/SKILL.md` and
107
+ `.agents/skills/<name>/SKILL.md`. It does **not** read `.claude/commands`, and it does **not** read a flat
108
+ `.claude/skills/<skill>.md` as a skill (that file is a project override, read by the command and by the skill).
109
+ - `GENERATED` renders the same four command texts into both `.claude/commands/` and `.opencode/commands/`, so edit
110
+ `templates/.claude/commands/*.md` once. In this repository the eight command files are written by hand: keep
111
+ `.claude/commands/` and `.opencode/commands/` identical (`diff -r .claude/commands .opencode/commands`).
112
+ - OpenCode discovers skills by walking up from the working directory to the git worktree, so a workspace sees only
113
+ its own skills, not the framework ones under `node_modules`: the command's `Load the framework skill <path>` is a
114
+ plain file read. That is why the skill itself must read the project override (`.claude/skills/<skill>.md`).
115
+
116
+ ## Web UI (`ui/web`)
117
+
118
+ Before adding or changing any UI element, read `docs/theme-guide.html` (open the file; it is static HTML) and follow it.
119
+ Its rules are binding, in particular:
120
+
121
+ - Icons: Feather via the `react-feather` package only (`import {ChevronDown} from 'react-feather'`). Never draw, paste
122
+ or hand-write SVGs, and never add another icon library. Size 14 in buttons, 16-20 elsewhere; decorative icons get
123
+ `aria-hidden="true"`, icon-only buttons get an `aria-label`. Do not use Unicode symbols (`▲ ▼ ◀ ▶ ⤢`) as icons.
124
+ - Colors: only the tokens in `ui/web/src/index.css` (the default Tailwind palette is removed). Pick the color by impact.
125
+ - Buttons and badges: use `buttonClass()` and `Badge` from `ui/web/src/components/status.tsx`, never hand-style them.
126
+ - Dropdowns: use `Select` from the same file, never a bare `<select>` or hand-copied classes (native arrow and height
127
+ differ). A required dropdown that is empty gets `invalid` (red). See the "Dropdowns" section of the theme guide.
128
+ - A list the user has to search in (a case id, a file name) is `SearchSelect` from
129
+ `ui/web/src/components/search-select.tsx`: an input with a filtered list below it, which a native `<select>` cannot
130
+ do. It keeps `Select`'s height, border and focus ring, so the two look like one family; do not build a third variant.
131
+ - If a change adds or alters a token, update `ui/web/src/index.css` and `docs/theme-guide.html` together.
132
+ - The Help page ("How to start") shows screenshots from `ui/web/public/help/`: retake one when the page it shows
133
+ changes.
134
+
135
+ Before reaching for a new dependency or writing a helper component, grep `package.json` and `ui/web/src` for what the
136
+ project already uses.
137
+
138
+ ### Copy (`ui/web/src`)
139
+
140
+ Every lead, label and tooltip is written for the person using the UI, never for the person who built it. Four rules:
141
+
142
+ - **What → what you do → next.** A page, panel or section explains three things, in that order: what it does, what the
143
+ user has to do, and what comes next (state it only when something does). A run that needs nothing from the user says so.
144
+ - **Short.** A lead is at most three sentences. A longer explanation becomes labeled paragraphs (`<b>While it runs</b> — …`)
145
+ or a bullet list, never one dense block. A tooltip is `# Title`, then at most three lines of one sentence each: it
146
+ wraps inside 320px (`ui/web/src/lib/tooltip.ts`).
147
+ - **No implementation in the copy.** File paths, directory names, CLI flags, skill or command names, and the agent's
148
+ internal syntax (`TODO`, `Answer:`, `RESULT: needs-input`, `- Recipe:`) never appear in a lead, a label or a tooltip.
149
+ They belong where the file, the command or the syntax is the object itself: the Spec view, the Jobs drawer, a
150
+ `CopyBlock`, or a folded `<details>` block titled "For the command line".
151
+ - **Say what the user sees.** "The run appears in the Jobs bar", not "the command runs in a one-shot session"; "the
152
+ browser shows a small card", not "the agent pauses on a `writes` step".
153
+
154
+ ## Before you open a pull request
155
+
156
+ ```bash
157
+ pnpm typecheck
158
+ pnpm build # dist/ must match lib/ and scripts/
159
+ pnpm case:check # examples/demo cases
160
+ pnpm spec:lint # examples/demo specs
161
+ pnpm ui:build # ui/dist/ must match ui/web and ui/server
162
+ ```
163
+
164
+ - `pnpm build` and `pnpm ui:build` also run from `prepare` and `prepack`, so a published package is complete.
165
+ - Bump `version` when `templates/` or the shipped docs change; `pnpm asrun doctor` in a project compares it.
166
+ - Keep the demo's committed expectations (`profiles.yaml`, `cases/`, `tests/`, baselines) in step with the demo site.
167
+ Baselines are a person's decision: never let an agent regenerate them.