@asrunjs/asrun 0.2.0 → 0.5.2

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 (94) 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 +159 -154
  15. package/CLAUDE.md +2 -1
  16. package/README.md +512 -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/init.d.ts +7 -1
  43. package/dist/scripts/init.js +61 -13
  44. package/dist/scripts/lint-specs.js +4 -1
  45. package/dist/scripts/login.js +30 -2
  46. package/dist/ui/server/actions.d.ts +4 -0
  47. package/dist/ui/server/actions.js +25 -12
  48. package/dist/ui/server/ai-models.d.ts +3 -0
  49. package/dist/ui/server/ai-models.js +347 -0
  50. package/dist/ui/server/ai-stream.d.ts +34 -9
  51. package/dist/ui/server/ai-stream.js +283 -77
  52. package/dist/ui/server/app.js +74 -12
  53. package/dist/ui/server/jobs.d.ts +7 -1
  54. package/dist/ui/server/jobs.js +69 -15
  55. package/dist/ui/server/profiles.d.ts +20 -1
  56. package/dist/ui/server/profiles.js +214 -38
  57. package/dist/ui/shared/ai-commands.d.ts +16 -6
  58. package/dist/ui/shared/ai-commands.js +35 -12
  59. package/dist/ui/shared/types.d.ts +114 -0
  60. package/docs/agents/framework-dev.md +79 -0
  61. package/docs/theme-guide.html +922 -692
  62. package/package.json +12 -1
  63. package/templates/.claude/commands/explore-case.md +22 -21
  64. package/templates/.claude/commands/gen-spec.md +21 -20
  65. package/templates/.claude/commands/heal-spec.md +31 -30
  66. package/templates/.claude/commands/make-case.md +13 -12
  67. package/templates/.mcp.json +8 -8
  68. package/templates/AGENTS.md +64 -45
  69. package/templates/cases/_template.md +57 -63
  70. package/templates/opencode.jsonc +16 -11
  71. package/templates/profiles.yaml +42 -29
  72. package/ui/dist/assets/{dist-BHaIOZdW.js → dist-BGbe0-Aa.js} +1 -1
  73. package/ui/dist/assets/{dist-CBV7_vDN.js → dist-Ba-HC662.js} +1 -1
  74. package/ui/dist/assets/{dist-BiE4suo9.js → dist-BtMLMthJ.js} +1 -1
  75. package/ui/dist/assets/{dist-gF2bFn-5.js → dist-C-Oyo1ZT.js} +1 -1
  76. package/ui/dist/assets/{dist-DX8knLVr.js → dist-C8MdhqgG.js} +1 -1
  77. package/ui/dist/assets/{dist-DHCY9KpV.js → dist-CG1pHn5m.js} +1 -1
  78. package/ui/dist/assets/{dist-BwYRbe5s.js → dist-CmpteMYY.js} +1 -1
  79. package/ui/dist/assets/{dist-D_I45xgJ.js → dist-DSy3dVf4.js} +1 -1
  80. package/ui/dist/assets/{dist-BqAROxzI.js → dist-Dq6sW5Ur.js} +1 -1
  81. package/ui/dist/assets/{dist-BSJMM_jp.js → dist-jjxQutEm.js} +1 -1
  82. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  83. package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
  84. package/ui/dist/help/ai-tools.png +0 -0
  85. package/ui/dist/help/case-explore.png +0 -0
  86. package/ui/dist/help/case-generate.png +0 -0
  87. package/ui/dist/help/cases-memo.png +0 -0
  88. package/ui/dist/help/profile-form.png +0 -0
  89. package/ui/dist/help/profiles-login.png +0 -0
  90. package/ui/dist/help/reports.png +0 -0
  91. package/ui/dist/help/run-tests.png +0 -0
  92. package/ui/dist/index.html +22 -16
  93. package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
  94. 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,79 @@
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
+ `<framework>` for a project is `node_modules/@asrunjs/asrun`. In this repository it is the root folder:
8
+ - `lib/` fixtures (`profile`, session guard) and assertion helpers; specs import them as
9
+ `@asrunjs/asrun/<module>` (for example `@asrunjs/asrun/fixtures`, `@asrunjs/asrun/values`)
10
+ - `scripts/` user-facing commands, see README.md
11
+ - `templates/` the files `pnpm asrun init` writes into a workspace
12
+ - `ui/` local web UI (`pnpm ui`). It may only run the fixed actions in `ui/server/actions.ts`
13
+ and must never serve anything under `.auth/`.
14
+ - `docs/theme-guide.html` the Inkstone theme guide of the web UI: color tokens, buttons, badges, icons, surfaces
15
+ - `examples/demo/` a workspace for developing the framework itself against a local Docker stack (Conduit);
16
+ it is committed (`.auth/` and `reports/` never are), see its README
17
+
18
+ ## Working on the framework itself
19
+ - The framework repository holds no workspace content at the root: no `profiles.yaml`, `cases/`, `tests/` or
20
+ reports there. The one workspace is `examples/demo`, which is committed because it runs its own local site; its
21
+ `.auth/`, `reports/` and other run output are not. Develop against it: `pnpm demo:up` and `pnpm demo:seed` start
22
+ and fill the local stack, `pnpm asrun init --root examples/demo` (it also writes the framework's `.env` with
23
+ `ASRUN_ROOT=examples/demo`), then `pnpm asrun --env staging --read-only`.
24
+ - Everything the framework reads for a project goes through `ROOT` in `lib/paths.ts` (the workspace root); its own
25
+ assets (templates, docs, skills, its `node_modules`) go through `FRAMEWORK_ROOT`. Never write into
26
+ `FRAMEWORK_ROOT` at run time.
27
+ - `templates/` is the source of the files a workspace gets. When a template changes, bump `version` in
28
+ `package.json` so `pnpm asrun doctor` in a project shows that it is behind.
29
+ - `init` owns two lists: `GENERATED` (rewritten by `--update`; the commands and the Playwright files) and `ONCE`
30
+ (written only when missing; `AGENTS.md`, `CLAUDE.md`, the configs). `AGENTS.md`, `CLAUDE.md` and
31
+ `.claude/skills/**` belong to the project: `init --force` refuses them unless `--reset-rules` is given, so a
32
+ framework update can never overwrite a project's rules.
33
+ - A project's rules never flow back here. The framework reads a workspace only through `ROOT` at run time; nothing
34
+ under `templates/` or `.claude/skills/` is generated from a project.
35
+
36
+ ## The commands and the two agent CLIs
37
+ - Claude Code reads `.claude/commands/*` and `.claude/skills/<name>/SKILL.md`.
38
+ - OpenCode reads `.opencode/commands/*`, `.claude/skills/<name>/SKILL.md`, `.opencode/skills/<name>/SKILL.md` and
39
+ `.agents/skills/<name>/SKILL.md`. It does **not** read `.claude/commands`, and it does **not** read a flat
40
+ `.claude/skills/<skill>.md` as a skill (that file is a project override, read by the command and by the skill).
41
+ - `GENERATED` renders the same four command texts into both `.claude/commands/` and `.opencode/commands/`, so edit
42
+ `templates/.claude/commands/*.md` once. In this repository the eight command files are written by hand: keep
43
+ `.claude/commands/` and `.opencode/commands/` identical (`diff -r .claude/commands .opencode/commands`).
44
+ - OpenCode discovers skills by walking up from the working directory to the git worktree, so a workspace sees only
45
+ its own skills, not the framework ones under `node_modules`: the command's `Load the framework skill <path>` is a
46
+ plain file read. That is why the skill itself must read the project override (`.claude/skills/<skill>.md`).
47
+
48
+ ## Web UI (`ui/web`)
49
+ Before adding or changing any UI element, read `docs/theme-guide.html` (open the file; it is static HTML) and follow it.
50
+ Its rules are binding, in particular:
51
+ - Icons: Feather via the `react-feather` package only (`import {ChevronDown} from 'react-feather'`). Never draw, paste
52
+ or hand-write SVGs, and never add another icon library. Size 14 in buttons, 16-20 elsewhere; decorative icons get
53
+ `aria-hidden="true"`, icon-only buttons get an `aria-label`. Do not use Unicode symbols (`▲ ▼ ◀ ▶ ⤢`) as icons.
54
+ - Colors: only the tokens in `ui/web/src/index.css` (the default Tailwind palette is removed). Pick the color by impact.
55
+ - Buttons and badges: use `buttonClass()` and `Badge` from `ui/web/src/components/status.tsx`, never hand-style them.
56
+ - Dropdowns: use `Select` from the same file, never a bare `<select>` or hand-copied classes (native arrow and height
57
+ differ). A required dropdown that is empty gets `invalid` (red). See the "Dropdowns" section of the theme guide.
58
+ - A list the user has to search in (a case id, a file name) is `SearchSelect` from
59
+ `ui/web/src/components/search-select.tsx`: an input with a filtered list below it, which a native `<select>` cannot
60
+ do. It keeps `Select`'s height, border and focus ring, so the two look like one family; do not build a third variant.
61
+ - If a change adds or alters a token, update `ui/web/src/index.css` and `docs/theme-guide.html` together.
62
+ Before reaching for a new dependency or writing a helper component, grep `package.json` and `ui/web/src` for what the
63
+ project already uses.
64
+
65
+ ### Copy (`ui/web/src`)
66
+
67
+ Every lead, label and tooltip is written for the person using the UI, never for the person who built it. Four rules:
68
+
69
+ - **What → what you do → next.** A page, panel or section explains three things, in that order: what it does, what the
70
+ user has to do, and what comes next (state it only when something does). A run that needs nothing from the user says so.
71
+ - **Short.** A lead is at most three sentences. A longer explanation becomes labeled paragraphs (`<b>While it runs</b> — …`)
72
+ or a bullet list, never one dense block. A tooltip is `# Title`, then at most three lines of one sentence each: it
73
+ wraps inside 320px (`ui/web/src/lib/tooltip.ts`).
74
+ - **No implementation in the copy.** File paths, directory names, CLI flags, skill or command names, and the agent's
75
+ internal syntax (`TODO`, `Answer:`, `RESULT: needs-input`, `- Recipe:`) never appear in a lead, a label or a tooltip.
76
+ They belong where the file, the command or the syntax is the object itself: the Spec view, the Jobs drawer, a
77
+ `CopyBlock`, or a folded `<details>` block titled "For the command line".
78
+ - **Say what the user sees.** "The run appears in the Jobs bar", not "the command runs in a one-shot session"; "the
79
+ browser shows a small card", not "the agent pauses on a `writes` step".