@asrunjs/asrun 0.0.0-stage → 0.2.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 (286) hide show
  1. package/.claude/commands/explore-case.md +15 -0
  2. package/.claude/commands/gen-spec.md +13 -0
  3. package/.claude/commands/heal-spec.md +23 -0
  4. package/.claude/commands/make-case.md +8 -0
  5. package/.claude/settings.json +5 -0
  6. package/.claude/skills/exploring-cases/SKILL.md +132 -0
  7. package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
  9. package/.claude/skills/exploring-cases/references/open-questions.md +112 -0
  10. package/.claude/skills/exploring-cases/references/readiness.md +137 -0
  11. package/.claude/skills/exploring-cases/references/user-handoff.md +147 -0
  12. package/.claude/skills/generating-specs/SKILL.md +207 -0
  13. package/.claude/skills/generating-specs/references/conventions.md +148 -0
  14. package/.claude/skills/making-cases/SKILL.md +64 -0
  15. package/.claude/skills/making-cases/references/gaps.md +36 -0
  16. package/.claude/skills/making-cases/references/normalizing.md +38 -0
  17. package/.claude/skills/making-cases/references/splitting.md +28 -0
  18. package/.claude/skills/making-cases/references/writes-and-email.md +36 -0
  19. package/AGENTS.md +154 -0
  20. package/CLAUDE.md +1 -0
  21. package/LICENSE +21 -0
  22. package/README.md +410 -3
  23. package/bin/asrun.mjs +49 -0
  24. package/dist/lib/account-lock.d.ts +6 -0
  25. package/dist/lib/account-lock.js +52 -0
  26. package/dist/lib/assertions.d.ts +14 -0
  27. package/dist/lib/assertions.js +39 -0
  28. package/dist/lib/baselines.d.ts +43 -0
  29. package/dist/lib/baselines.js +108 -0
  30. package/dist/lib/case-id.d.ts +11 -0
  31. package/dist/lib/case-id.js +17 -0
  32. package/dist/lib/case-ops.d.ts +26 -0
  33. package/dist/lib/case-ops.js +110 -0
  34. package/dist/lib/cases.d.ts +29 -0
  35. package/dist/lib/cases.js +124 -0
  36. package/dist/lib/cli.d.ts +10 -0
  37. package/dist/lib/cli.js +18 -0
  38. package/dist/lib/downloads.d.ts +42 -0
  39. package/dist/lib/downloads.js +84 -0
  40. package/dist/lib/email.d.ts +10 -0
  41. package/dist/lib/email.js +19 -0
  42. package/dist/lib/ffmpeg.d.ts +8 -0
  43. package/dist/lib/ffmpeg.js +50 -0
  44. package/dist/lib/fixtures.d.ts +40 -0
  45. package/dist/lib/fixtures.js +149 -0
  46. package/dist/lib/node-cli.d.ts +11 -0
  47. package/dist/lib/node-cli.js +26 -0
  48. package/dist/lib/paths.d.ts +25 -0
  49. package/dist/lib/paths.js +88 -0
  50. package/dist/lib/pdf.d.ts +71 -0
  51. package/dist/lib/pdf.js +140 -0
  52. package/dist/lib/profiles.d.ts +38 -0
  53. package/dist/lib/profiles.js +121 -0
  54. package/dist/lib/ready.d.ts +36 -0
  55. package/dist/lib/ready.js +53 -0
  56. package/dist/lib/records.d.ts +33 -0
  57. package/dist/lib/records.js +61 -0
  58. package/dist/lib/run-filters.d.ts +19 -0
  59. package/dist/lib/run-filters.js +30 -0
  60. package/dist/lib/run-summary.d.ts +90 -0
  61. package/dist/lib/run-summary.js +35 -0
  62. package/dist/lib/session.d.ts +18 -0
  63. package/dist/lib/session.js +45 -0
  64. package/dist/lib/summary-reporter.d.ts +16 -0
  65. package/dist/lib/summary-reporter.js +146 -0
  66. package/dist/lib/targets.d.ts +27 -0
  67. package/dist/lib/targets.js +51 -0
  68. package/dist/lib/ui-build.d.ts +10 -0
  69. package/dist/lib/ui-build.js +30 -0
  70. package/dist/lib/values.d.ts +29 -0
  71. package/dist/lib/values.js +85 -0
  72. package/dist/lib/write-gate.d.ts +41 -0
  73. package/dist/lib/write-gate.js +129 -0
  74. package/dist/playwright.config.d.ts +2 -0
  75. package/dist/playwright.config.js +92 -0
  76. package/dist/scripts/build.d.ts +1 -0
  77. package/dist/scripts/build.js +20 -0
  78. package/dist/scripts/check-cases.d.ts +1 -0
  79. package/dist/scripts/check-cases.js +81 -0
  80. package/dist/scripts/cleanup.d.ts +1 -0
  81. package/dist/scripts/cleanup.js +25 -0
  82. package/dist/scripts/cli.d.ts +1 -0
  83. package/dist/scripts/cli.js +28 -0
  84. package/dist/scripts/doctor.d.ts +1 -0
  85. package/dist/scripts/doctor.js +185 -0
  86. package/dist/scripts/explore.d.ts +1 -0
  87. package/dist/scripts/explore.js +54 -0
  88. package/dist/scripts/go.mjs +188 -0
  89. package/dist/scripts/heal-evidence.mjs +143 -0
  90. package/dist/scripts/init.d.ts +16 -0
  91. package/dist/scripts/init.js +284 -0
  92. package/dist/scripts/lint-specs.d.ts +1 -0
  93. package/dist/scripts/lint-specs.js +138 -0
  94. package/dist/scripts/login.d.ts +1 -0
  95. package/dist/scripts/login.js +47 -0
  96. package/dist/scripts/new-case.d.ts +1 -0
  97. package/dist/scripts/new-case.js +19 -0
  98. package/dist/scripts/render-pdf.d.ts +1 -0
  99. package/dist/scripts/render-pdf.js +44 -0
  100. package/dist/scripts/report.d.ts +1 -0
  101. package/dist/scripts/report.js +14 -0
  102. package/dist/scripts/review-baselines.d.ts +1 -0
  103. package/dist/scripts/review-baselines.js +30 -0
  104. package/dist/scripts/run.d.ts +1 -0
  105. package/dist/scripts/run.js +423 -0
  106. package/dist/scripts/status.d.ts +1 -0
  107. package/dist/scripts/status.js +31 -0
  108. package/dist/scripts/ui-build.d.ts +1 -0
  109. package/dist/scripts/ui-build.js +7 -0
  110. package/dist/scripts/ui-dev.d.ts +1 -0
  111. package/dist/scripts/ui-dev.js +3 -0
  112. package/dist/scripts/ui.d.ts +1 -0
  113. package/dist/scripts/ui.js +13 -0
  114. package/dist/ui/dev.d.ts +1 -0
  115. package/dist/ui/dev.js +36 -0
  116. package/dist/ui/server/actions.d.ts +91 -0
  117. package/dist/ui/server/actions.js +352 -0
  118. package/dist/ui/server/ai-stream.d.ts +41 -0
  119. package/dist/ui/server/ai-stream.js +202 -0
  120. package/dist/ui/server/app.d.ts +4 -0
  121. package/dist/ui/server/app.js +333 -0
  122. package/dist/ui/server/cases.d.ts +23 -0
  123. package/dist/ui/server/cases.js +170 -0
  124. package/dist/ui/server/files.d.ts +5 -0
  125. package/dist/ui/server/files.js +67 -0
  126. package/dist/ui/server/index.d.ts +1 -0
  127. package/dist/ui/server/index.js +33 -0
  128. package/dist/ui/server/jobs.d.ts +74 -0
  129. package/dist/ui/server/jobs.js +263 -0
  130. package/dist/ui/server/profiles.d.ts +16 -0
  131. package/dist/ui/server/profiles.js +195 -0
  132. package/dist/ui/server/watch-files.d.ts +4 -0
  133. package/dist/ui/server/watch-files.js +36 -0
  134. package/dist/ui/shared/ai-commands.d.ts +78 -0
  135. package/dist/ui/shared/ai-commands.js +154 -0
  136. package/dist/ui/shared/case-id.d.ts +1 -0
  137. package/dist/ui/shared/case-id.js +1 -0
  138. package/dist/ui/shared/jobs.d.ts +6 -0
  139. package/dist/ui/shared/jobs.js +7 -0
  140. package/dist/ui/shared/product.d.ts +3 -0
  141. package/dist/ui/shared/product.js +3 -0
  142. package/dist/ui/shared/run-filters.d.ts +1 -0
  143. package/dist/ui/shared/run-filters.js +1 -0
  144. package/dist/ui/shared/site-address.d.ts +6 -0
  145. package/dist/ui/shared/site-address.js +11 -0
  146. package/dist/ui/shared/types.d.ts +257 -0
  147. package/dist/ui/shared/types.js +1 -0
  148. package/docs/theme-guide.html +692 -0
  149. package/package.json +98 -3
  150. package/templates/.claude/commands/explore-case.md +21 -0
  151. package/templates/.claude/commands/gen-spec.md +20 -0
  152. package/templates/.claude/commands/heal-spec.md +30 -0
  153. package/templates/.claude/commands/make-case.md +12 -0
  154. package/templates/.claude/settings.json +5 -0
  155. package/templates/.mcp.json +8 -0
  156. package/templates/AGENTS.md +45 -0
  157. package/templates/CLAUDE.md +1 -0
  158. package/templates/cases/_template.md +63 -0
  159. package/templates/gitignore +13 -0
  160. package/templates/opencode.jsonc +11 -0
  161. package/templates/package.json +31 -0
  162. package/templates/playwright.config.ts +3 -0
  163. package/templates/pnpm-workspace.yaml +5 -0
  164. package/templates/profiles.yaml +29 -0
  165. package/templates/seed.spec.ts +9 -0
  166. package/templates/tsconfig.json +14 -0
  167. package/ui/dist/apple-touch-icon.png +0 -0
  168. package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
  169. package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
  170. package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
  171. package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
  172. package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
  173. package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
  174. package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
  175. package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
  176. package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
  177. package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
  178. package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
  179. package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
  180. package/ui/dist/assets/css-CnaYoQib.js +1 -0
  181. package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
  182. package/ui/dist/assets/d-DWkQyNnU.js +1 -0
  183. package/ui/dist/assets/diff-ChtP43wD.js +1 -0
  184. package/ui/dist/assets/dist-BHaIOZdW.js +1 -0
  185. package/ui/dist/assets/dist-BSJMM_jp.js +1 -0
  186. package/ui/dist/assets/dist-BiE4suo9.js +1 -0
  187. package/ui/dist/assets/dist-BqAROxzI.js +1 -0
  188. package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
  189. package/ui/dist/assets/dist-BwYRbe5s.js +9 -0
  190. package/ui/dist/assets/dist-BzulKuec.js +1 -0
  191. package/ui/dist/assets/dist-CBV7_vDN.js +2 -0
  192. package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
  193. package/ui/dist/assets/dist-DDJNvivM.js +1 -0
  194. package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
  195. package/ui/dist/assets/dist-DHCY9KpV.js +1 -0
  196. package/ui/dist/assets/dist-DX8knLVr.js +1 -0
  197. package/ui/dist/assets/dist-D_I45xgJ.js +11 -0
  198. package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
  199. package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
  200. package/ui/dist/assets/dist-gF2bFn-5.js +1 -0
  201. package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
  202. package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
  203. package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
  204. package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
  205. package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
  206. package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
  207. package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
  208. package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
  209. package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
  210. package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
  211. package/ui/dist/assets/forth-D6RzAl64.js +1 -0
  212. package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
  213. package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
  214. package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
  215. package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
  216. package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
  217. package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
  218. package/ui/dist/assets/http-CLVgA2GD.js +1 -0
  219. package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
  220. package/ui/dist/assets/index-DmasQ6Rq.css +2 -0
  221. package/ui/dist/assets/index-I7uU93kk.js +294 -0
  222. package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
  223. package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
  224. package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
  225. package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
  226. package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
  227. package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
  228. package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
  229. package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
  230. package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
  231. package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
  232. package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
  233. package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
  234. package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
  235. package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
  236. package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
  237. package/ui/dist/assets/oz-BwTct_5T.js +1 -0
  238. package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
  239. package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
  240. package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
  241. package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
  242. package/ui/dist/assets/properties-C357ku5U.js +1 -0
  243. package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
  244. package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
  245. package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
  246. package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
  247. package/ui/dist/assets/q-B9NhS7L_.js +1 -0
  248. package/ui/dist/assets/r-CxDmU1I4.js +1 -0
  249. package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
  250. package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
  251. package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
  252. package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
  253. package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
  254. package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
  255. package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
  256. package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
  257. package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
  258. package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
  259. package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
  260. package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
  261. package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
  262. package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
  263. package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
  264. package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
  265. package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
  266. package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
  267. package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
  268. package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
  269. package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
  270. package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
  271. package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
  272. package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
  273. package/ui/dist/assets/vb-BthdM_4N.js +1 -0
  274. package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
  275. package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
  276. package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
  277. package/ui/dist/assets/vhdl-kS471soi.js +1 -0
  278. package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
  279. package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
  280. package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
  281. package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
  282. package/ui/dist/favicon.ico +0 -0
  283. package/ui/dist/favicon.svg +11 -0
  284. package/ui/dist/index.html +17 -0
  285. package/ui/dist/logos/claude.svg +1 -0
  286. package/ui/dist/logos/opencode.svg +1 -0
@@ -0,0 +1,195 @@
1
+ import fs from 'node:fs';
2
+ import { chromium } from '@playwright/test';
3
+ import { isMap, isSeq, parseDocument } from 'yaml';
4
+ import { listEnvs, listProfiles, loadProfiles, PROFILES_FILE } from '../../lib/profiles.js';
5
+ import { authFile } from '../../lib/paths.js';
6
+ import { checkSession } from '../../lib/session.js';
7
+ import { withScheme } from '../shared/site-address.js';
8
+ // Keyed by profile id.
9
+ const verifications = new Map();
10
+ const verifying = new Set();
11
+ /** Only looks at file timestamps; the content of the session file is never read here. */
12
+ function savedAt(profileId) {
13
+ const file = authFile(profileId);
14
+ return fs.existsSync(file) ? fs.statSync(file).mtime.toISOString() : null;
15
+ }
16
+ const toInfo = (profile) => {
17
+ const saved = savedAt(profile.id);
18
+ const verification = verifications.get(profile.id);
19
+ let status;
20
+ if (!saved)
21
+ status = 'missing';
22
+ else if (!verification)
23
+ status = 'unverified';
24
+ else
25
+ status = verification.status === 'missing' ? 'unverified' : verification.status;
26
+ return {
27
+ id: profile.id,
28
+ name: profile.name,
29
+ env: profile.env,
30
+ baseURL: profile.baseURL,
31
+ account: profile.account,
32
+ tags: profile.tags,
33
+ status,
34
+ verifying: verifying.has(profile.id),
35
+ savedAt: saved,
36
+ checkedAt: verification?.checkedAt ?? null,
37
+ error: verification?.error,
38
+ };
39
+ };
40
+ export function listProfileRows() {
41
+ return { profiles: listProfiles().map(toInfo), envs: listEnvs(), verifying: verifying.size > 0 };
42
+ }
43
+ /** Opens each saved session on the real site (read-only) and records whether it is still logged in. */
44
+ export async function verifyProfiles(only) {
45
+ const profiles = listProfiles().filter((profile) => (!only?.ids || only.ids.includes(profile.id)) && (!only?.env || only.env === profile.env) && !verifying.has(profile.id));
46
+ const withSession = profiles.filter((profile) => savedAt(profile.id));
47
+ for (const profile of profiles) {
48
+ if (!savedAt(profile.id))
49
+ verifications.delete(profile.id);
50
+ }
51
+ if (withSession.length === 0)
52
+ return;
53
+ for (const profile of withSession)
54
+ verifying.add(profile.id);
55
+ let browser;
56
+ try {
57
+ browser = await chromium.launch();
58
+ await Promise.all(withSession.map(async (profile) => {
59
+ try {
60
+ const status = await checkSession(browser, profile);
61
+ verifications.set(profile.id, { status, checkedAt: new Date().toISOString() });
62
+ }
63
+ catch (error) {
64
+ verifications.set(profile.id, {
65
+ status: 'error',
66
+ checkedAt: new Date().toISOString(),
67
+ error: error.message.split('\n')[0],
68
+ });
69
+ }
70
+ finally {
71
+ verifying.delete(profile.id);
72
+ }
73
+ }));
74
+ }
75
+ catch (error) {
76
+ for (const profile of withSession) {
77
+ verifications.set(profile.id, {
78
+ status: 'error',
79
+ checkedAt: new Date().toISOString(),
80
+ error: error.message.split('\n')[0],
81
+ });
82
+ verifying.delete(profile.id);
83
+ }
84
+ }
85
+ finally {
86
+ await browser?.close();
87
+ }
88
+ }
89
+ const PROFILE_ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
90
+ const ENV_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
91
+ export class ProfileError extends Error {
92
+ status;
93
+ constructor(message, status) {
94
+ super(message);
95
+ this.status = status;
96
+ }
97
+ }
98
+ function parseSiteAddress(text, label) {
99
+ let url;
100
+ try {
101
+ // A typed address without a scheme (`example.com`) is completed with https://.
102
+ url = new URL(withScheme(text));
103
+ }
104
+ catch {
105
+ throw new ProfileError(`${label} must be a full URL, e.g. https://www.example.com.`, 400);
106
+ }
107
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
108
+ throw new ProfileError(`${label} must start with https:// (or http://).`, 400);
109
+ }
110
+ if (url.pathname !== '/' || url.search || url.hash || url.username || url.password) {
111
+ throw new ProfileError(`${label} must be only the site address, without a path, query or credentials.`, 400);
112
+ }
113
+ return url;
114
+ }
115
+ const normalTags = (tags) => [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
116
+ /**
117
+ * Appends one profile to profiles.yaml. The file is edited as a YAML document, so comments and the
118
+ * existing entries stay as they are.
119
+ */
120
+ export function addProfile(request, file = PROFILES_FILE) {
121
+ const id = request.id.trim();
122
+ const name = request.name.trim();
123
+ const env = request.env.trim();
124
+ const account = request.account?.trim() || undefined;
125
+ const sessionCheckPath = request.sessionCheckPath?.trim() || undefined;
126
+ const tags = normalTags(request.tags);
127
+ if (!PROFILE_ID.test(id)) {
128
+ throw new ProfileError('Profile id must be lowercase letters and digits, separated by single dashes (e.g. "example-staging").', 400);
129
+ }
130
+ if (!name)
131
+ throw new ProfileError('Name is required.', 400);
132
+ if (!ENV_NAME.test(env)) {
133
+ throw new ProfileError('Environment must be lowercase letters and digits, separated by single dashes (e.g. "production", "staging").', 400);
134
+ }
135
+ if (env === 'production' && request.email === true) {
136
+ throw new ProfileError('email: true is not allowed. Tests never send email in production.', 400);
137
+ }
138
+ const baseURL = parseSiteAddress(request.baseURL, 'Base URL').origin;
139
+ if (sessionCheckPath && (!sessionCheckPath.startsWith('/') || /\s/.test(sessionCheckPath))) {
140
+ throw new ProfileError('Session check path must start with "/" and contain no spaces.', 400);
141
+ }
142
+ if (loadProfiles(file).some((profile) => profile.id === id)) {
143
+ throw new ProfileError(`Profile "${id}" already exists.`, 409);
144
+ }
145
+ const original = fs.readFileSync(file, 'utf8');
146
+ const doc = parseDocument(original, { merge: true });
147
+ if (doc.errors.length > 0) {
148
+ throw new ProfileError('profiles.yaml has syntax errors; fix it by hand first.', 400);
149
+ }
150
+ const profiles = doc.get('profiles');
151
+ if (!isSeq(profiles))
152
+ throw new ProfileError('profiles.yaml has no "profiles" list.', 400);
153
+ // `profiles: []` is a flow sequence; adding a map entry to it would render `[ { ... } ]`. Keep the file block-style.
154
+ profiles.flow = false;
155
+ const hadEntries = profiles.items.length > 0;
156
+ const entry = doc.createNode({
157
+ id,
158
+ name,
159
+ env,
160
+ baseURL,
161
+ ...(account && { account }),
162
+ ...(tags.length > 0 && { tags }),
163
+ ...(sessionCheckPath && { sessionCheckPath }),
164
+ // Never send email from a new profile until a human has checked that a mailbox is connected.
165
+ email: request.email === true ? true : false,
166
+ });
167
+ if (isMap(entry)) {
168
+ entry.spaceBefore = hadEntries;
169
+ entry.flow = false;
170
+ }
171
+ profiles.add(entry);
172
+ const temp = `${file}.${process.pid}.tmp`;
173
+ fs.writeFileSync(temp, doc.toString());
174
+ fs.renameSync(temp, file);
175
+ // Never leave a file behind that the test runner cannot load.
176
+ try {
177
+ loadProfiles(file);
178
+ }
179
+ catch (error) {
180
+ fs.writeFileSync(file, original);
181
+ throw new ProfileError(`Could not add the profile: ${error.message}`, 400);
182
+ }
183
+ return {
184
+ id,
185
+ name,
186
+ env,
187
+ baseURL,
188
+ account: account ?? null,
189
+ tags,
190
+ status: 'missing',
191
+ verifying: false,
192
+ savedAt: null,
193
+ checkedAt: null,
194
+ };
195
+ }
@@ -0,0 +1,4 @@
1
+ import { EventEmitter } from 'node:events';
2
+ /** Emits `files` when a case file or a spec changes on disk, whoever changed it (the UI, an agent, an editor). */
3
+ export declare const fileEvents: EventEmitter<[never]>;
4
+ export declare function startFileWatcher(): void;
@@ -0,0 +1,36 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import fs from 'node:fs';
3
+ import { CASES_DIR, TESTS_DIR } from '../../lib/paths.js';
4
+ const DEBOUNCE_MS = 200;
5
+ /** Emits `files` when a case file or a spec changes on disk, whoever changed it (the UI, an agent, an editor). */
6
+ export const fileEvents = new EventEmitter();
7
+ fileEvents.setMaxListeners(0);
8
+ export function startFileWatcher() {
9
+ const pending = { cases: false, specs: false };
10
+ let timer = null;
11
+ const schedule = (kind) => {
12
+ pending[kind] = true;
13
+ timer ??= setTimeout(() => {
14
+ timer = null;
15
+ const change = { ...pending };
16
+ pending.cases = false;
17
+ pending.specs = false;
18
+ fileEvents.emit('files', change);
19
+ }, DEBOUNCE_MS);
20
+ };
21
+ const watch = (dir, kind, suffix) => {
22
+ try {
23
+ const watcher = fs.watch(dir, { recursive: true }, (_event, filename) => {
24
+ if (filename && filename.toString().endsWith(suffix))
25
+ schedule(kind);
26
+ });
27
+ watcher.on('error', () => watcher.close());
28
+ watcher.unref();
29
+ }
30
+ catch {
31
+ // The folder does not exist (yet) or cannot be watched; the pages still refresh when the window gets focus.
32
+ }
33
+ };
34
+ watch(CASES_DIR, 'cases', '.md');
35
+ watch(TESTS_DIR, 'specs', '.spec.ts');
36
+ }
@@ -0,0 +1,78 @@
1
+ export declare const AI_TOOLS: readonly ['claude', 'opencode'];
2
+ export type AiTool = (typeof AI_TOOLS)[number];
3
+ export declare const DEFAULT_AI_TOOL: AiTool;
4
+ export declare const AI_TOOL_LABEL: Record<AiTool, string>;
5
+ export declare const AI_COMMANDS: readonly ['explore-case', 'gen-spec', 'heal-spec'];
6
+ export type AiCommand = (typeof AI_COMMANDS)[number];
7
+ export declare const AI_COMMAND_LABEL: Record<AiCommand, string>;
8
+ /**
9
+ * What a one-shot run of a command may change, so the permissions can be as narrow as possible.
10
+ * `case`: only cases/*.md. `spec`: also the spec, but it never runs the tests.
11
+ * `files`: also snapshots, and it runs the tests.
12
+ */
13
+ export type AiWrites = 'case' | 'spec' | 'files';
14
+ export declare const AI_COMMAND_WRITES: Record<AiCommand, AiWrites>;
15
+ /**
16
+ * Every command opens real pages, so every command needs an environment and there is no default.
17
+ * Agents never write data in any of them: a person does those steps.
18
+ */
19
+ export declare const AI_ENV_HINT: Record<AiCommand, string>;
20
+ /** What a run of a command on a case may be told besides the case id. */
21
+ export interface AiRunOptions {
22
+ /** The environment to explore. There is no default. */
23
+ env: string;
24
+ /** `gen-spec` only: regenerate and overwrite an existing spec instead of leaving it alone. */
25
+ force?: boolean;
26
+ /** `explore-case` only: a person is at the machine, so steps that write data are handed to them in the browser. */
27
+ attended?: boolean;
28
+ /** `heal-spec` only: the profiles to heal. Omitted = every profile that is logged in in `env`. */
29
+ profiles?: string[];
30
+ /** `heal-spec` only: file (relative to the project root) with the user's description and clues. */
31
+ hints?: string;
32
+ /** `heal-spec` only: the report (folder name under reports/) to read first instead of the newest one. */
33
+ report?: string;
34
+ }
35
+ export declare const slashCommand: (command: AiCommand, caseId: string, options: AiRunOptions) => string;
36
+ /**
37
+ * The conversation of a one-shot run. The server picks the id, so a later turn can resume it: the first turn
38
+ * creates the session, every following one (`resume`) continues it with the same permissions.
39
+ */
40
+ export interface AiSession {
41
+ id: string;
42
+ resume: boolean;
43
+ }
44
+ /** The prompt of a one-shot run. `heal-spec` has no questions of its own, so it keeps the plain slash command. */
45
+ export declare function headlessPrompt(command: AiCommand, caseId: string, options: AiRunOptions): string;
46
+ /** Executable and arguments of the one-shot (non-interactive) run. */
47
+ export declare function headlessInvocation(tool: AiTool, command: AiCommand, caseId: string, options: AiRunOptions, session?: AiSession): {
48
+ file: string;
49
+ args: string[];
50
+ };
51
+ /** The one-shot command as it is typed in a shell. */
52
+ export declare function headlessCommandLine(tool: AiTool, command: AiCommand, caseId: string, options: AiRunOptions, session?: AiSession): string;
53
+ /** Folder (relative to the AsRun root, git-ignored) where the clues typed into the Heal dialog are stored. */
54
+ export declare const HINTS_DIR = ".tmp/hints";
55
+ export declare const HINTS_MAX_CHARS = 8000;
56
+ export declare const REPLY_MAX_CHARS = 4000;
57
+ export declare const USE_DEFAULTS_REPLY = "Accept the suggestion of every open question: delete its TODO block and keep the sentence above it as confirmed text.";
58
+ /**
59
+ * The next turn of a run that ended with `RESULT: needs-input`: the user's answers. They go into the case file, which
60
+ * stays the record of truth, and the task of the original command is finished.
61
+ */
62
+ export declare function replyPrompt(command: AiCommand, reply: string): string;
63
+ /** The next turn of an earlier run, in its session and with exactly the permissions of the first turn. */
64
+ export declare function continueInvocation(tool: AiTool, command: AiCommand, reply: string, sessionId: string): {
65
+ file: string;
66
+ args: string[];
67
+ };
68
+ export declare const continueCommandLine: (tool: AiTool, command: AiCommand, reply: string, sessionId: string) => string;
69
+ /** Folder (relative to the AsRun root, git-ignored) where memos from the Cases page are stored. */
70
+ export declare const MEMO_DIR = ".tmp/memos";
71
+ export declare const MEMO_MAX_CHARS = 100000;
72
+ /** /make-case is interactive by design; a one-shot run cannot answer questions, so it must use the defaults. */
73
+ export declare const makeCasePrompt: (memoPath: string) => string;
74
+ export declare const makeCaseInvocation: (tool: AiTool, memoPath: string) => {
75
+ file: string;
76
+ args: string[];
77
+ };
78
+ export declare const makeCaseCommandLine: (tool: AiTool, memoPath: string) => string;
@@ -0,0 +1,154 @@
1
+ export const AI_TOOLS = ['claude', 'opencode'];
2
+ export const DEFAULT_AI_TOOL = 'claude';
3
+ export const AI_TOOL_LABEL = { claude: 'Claude', opencode: 'OpenCode' };
4
+ export const AI_COMMANDS = ['explore-case', 'gen-spec', 'heal-spec'];
5
+ export const AI_COMMAND_LABEL = {
6
+ 'explore-case': 'Explore case',
7
+ 'gen-spec': 'Generate spec',
8
+ 'heal-spec': 'Heal spec',
9
+ };
10
+ export const AI_COMMAND_WRITES = {
11
+ 'explore-case': 'case',
12
+ 'gen-spec': 'spec',
13
+ 'heal-spec': 'files',
14
+ };
15
+ /**
16
+ * Every command opens real pages, so every command needs an environment and there is no default.
17
+ * Agents never write data in any of them: a person does those steps.
18
+ */
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.',
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.',
23
+ };
24
+ export const slashCommand = (command, caseId, options) => {
25
+ const { env, force, attended, profiles, hints, report } = options;
26
+ const heal = command === 'heal-spec';
27
+ return (`/${command} ${caseId} ${env}${force && command === 'gen-spec' ? ' --force' : ''}` +
28
+ `${attended && command === 'explore-case' ? ' --attended' : ''}` +
29
+ `${heal && profiles?.length ? ` --profiles ${profiles.join(',')}` : ''}` +
30
+ `${heal && report ? ` --report ${report}` : ''}` +
31
+ `${heal && hints ? ` --hints ${hints}` : ''}`);
32
+ };
33
+ /** The MCP tools of the Playwright server that agents explore with. */
34
+ const PLAYWRIGHT_MCP = 'mcp__playwright-test__*';
35
+ // Claude Code asks before using an MCP tool or running a command; a one-shot run cannot answer, so what the agent
36
+ // needs is allowed up front. Edit rules are limited to the folders the command may change.
37
+ function claudeAllowedTools(writes) {
38
+ // `node --import tsx` is the same check without tsx's CLI, which needs a local socket that Claude's sandbox refuses.
39
+ // The scripts sit in the framework folder (a submodule, so the path varies), hence the open prefix.
40
+ const nodeTsx = 'Bash(node --import tsx:*)';
41
+ const check = ['Bash(pnpm case:check:*)', nodeTsx];
42
+ const common = [PLAYWRIGHT_MCP, 'Bash(pnpm status:*)', 'Bash(pnpm pdf:render:*)', ...check];
43
+ if (writes === 'case')
44
+ return [...common, 'Edit(/cases/**)'];
45
+ if (writes === 'cases-new')
46
+ return [...check, 'Edit(/cases/**)'];
47
+ const spec = [
48
+ ...common,
49
+ 'Edit(/cases/**)',
50
+ 'Edit(/tests/**)',
51
+ 'Bash(pnpm typecheck:*)',
52
+ 'Bash(pnpm spec:lint:*)',
53
+ nodeTsx,
54
+ 'Bash(pnpm baselines:review:*)',
55
+ ];
56
+ // Running specs is a separate process: only healing does it. `pnpm asrun` runs outside Claude's sandbox via
57
+ // `sandbox.excludedCommands` in .claude/settings.json, because tsx and the browser need local sockets and network.
58
+ // Healing reads the existing report first (`pnpm heal:evidence`) and reruns only when that is not enough.
59
+ return writes === 'spec' ? spec : [...spec, 'Bash(pnpm asrun:*)', 'Bash(pnpm heal:evidence:*)'];
60
+ }
61
+ // Never allowed in a one-shot run, whatever the command: AGENTS.md forbids agents to lift the write confirmation.
62
+ const CLAUDE_DENIED_TOOLS = ['Bash(*--allow-writes*)', 'Bash(*ASRUN_ALLOW_WRITES*)'];
63
+ /**
64
+ * Arguments that make the CLI print what it does as it goes (one JSON event per line) instead of the final answer only,
65
+ * so the job log shows which tool call is running. The server turns the events into log lines (`ai-stream.ts`).
66
+ */
67
+ const STREAM_ARGS = {
68
+ claude: ['--output-format', 'stream-json', '--verbose'],
69
+ opencode: ['--format', 'json'],
70
+ };
71
+ function invocation(tool, prompt, writes, session) {
72
+ // OpenCode creates the session when the id does not exist yet, and continues it when it does.
73
+ if (tool === 'opencode')
74
+ return { file: 'opencode', args: ['run', '--auto', ...STREAM_ARGS.opencode, ...(session ? ['-s', session.id] : []), prompt] };
75
+ return {
76
+ file: 'claude',
77
+ args: [
78
+ '-p',
79
+ prompt,
80
+ ...STREAM_ARGS.claude,
81
+ ...(session ? [session.resume ? '--resume' : '--session-id', session.id] : []),
82
+ // `acceptEdits` approves edits anywhere in the folder, which would void the narrow Edit rules below.
83
+ ...(writes === 'files' || writes === 'spec' ? ['--permission-mode', 'acceptEdits'] : []),
84
+ '--allowedTools',
85
+ ...claudeAllowedTools(writes),
86
+ '--disallowedTools',
87
+ ...CLAUDE_DENIED_TOOLS,
88
+ ],
89
+ };
90
+ }
91
+ const commandLine = ({ file, args }) => [file, ...args.map((arg) => (/^[\w@=,.:/-]+$/.test(arg) ? arg : JSON.stringify(arg)))].join(' ');
92
+ /**
93
+ * Said in every one-shot run: nobody is there to answer, so questions go into the case file and the last line
94
+ * is a result the UI can read. The skills define the details.
95
+ */
96
+ const NON_INTERACTIVE = 'Non-interactive run: nobody can answer questions, so do not stop to ask. Write open questions into the case file ' +
97
+ 'as the skill describes and end with the RESULT: line.';
98
+ /**
99
+ * An attended exploration: nobody answers questions in the chat, but a person sits at the machine and works in the
100
+ * browser window, so the agent hands them the steps that write data and waits, as user-handoff.md describes.
101
+ */
102
+ 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 ' +
104
+ 'questions into the case file as the skill describes and end with the RESULT: line.';
105
+ /** The prompt of a one-shot run. `heal-spec` has no questions of its own, so it keeps the plain slash command. */
106
+ export function headlessPrompt(command, caseId, options) {
107
+ const slash = slashCommand(command, caseId, options);
108
+ if (command === 'heal-spec')
109
+ return slash;
110
+ if (command === 'explore-case' && options.attended)
111
+ return `${slash} ${ATTENDED}`;
112
+ return `${slash} ${NON_INTERACTIVE}`;
113
+ }
114
+ /** Executable and arguments of the one-shot (non-interactive) run. */
115
+ export function headlessInvocation(tool, command, caseId, options, session) {
116
+ return invocation(tool, headlessPrompt(command, caseId, options), AI_COMMAND_WRITES[command], session);
117
+ }
118
+ /** The one-shot command as it is typed in a shell. */
119
+ export function headlessCommandLine(tool, command, caseId, options, session) {
120
+ return commandLine(headlessInvocation(tool, command, caseId, options, session));
121
+ }
122
+ /** Folder (relative to the AsRun root, git-ignored) where the clues typed into the Heal dialog are stored. */
123
+ export const HINTS_DIR = '.tmp/hints';
124
+ export const HINTS_MAX_CHARS = 8000;
125
+ export const REPLY_MAX_CHARS = 4000;
126
+ 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
+ /**
128
+ * 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.
130
+ */
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` +
133
+ 'Fold every answer into the case file as the skill describes: write the answer into the sentence above the ' +
134
+ '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
+ `Then finish the task of /${command} and end with the RESULT: line.`);
136
+ }
137
+ /** 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 });
140
+ }
141
+ export const continueCommandLine = (tool, command, reply, sessionId) => commandLine(continueInvocation(tool, command, reply, sessionId));
142
+ /** Folder (relative to the AsRun root, git-ignored) where memos from the Cases page are stored. */
143
+ export const MEMO_DIR = '.tmp/memos';
144
+ export const MEMO_MAX_CHARS = 100_000;
145
+ /** /make-case is interactive by design; a one-shot run cannot answer questions, so it must use the defaults. */
146
+ export const makeCasePrompt = (memoPath) => `/make-case ${memoPath} ` +
147
+ 'Non-interactive run: nobody can answer questions, so do not stop to ask. ' +
148
+ 'Do not open a browser or explore pages. Write the suggested default of every gap as a normal sentence and put a ' +
149
+ 'TODO block with an empty Answer: line under it, as open-questions.md describes (every marker starts with its emoji). ' +
150
+ 'Never overwrite an existing case: choose a new id instead. ' +
151
+ '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
+ '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));
@@ -0,0 +1 @@
1
+ export * from '../../lib/case-id.js';
@@ -0,0 +1 @@
1
+ export * from '../../lib/case-id.js';
@@ -0,0 +1,6 @@
1
+ import type { JobInfo } from './types.js';
2
+ /**
3
+ * The run that took over the answers of a `needs-input` run. A continuation that failed or was cancelled does not
4
+ * count: the questions are still open, so the earlier run can be continued again.
5
+ */
6
+ export declare function findContinuation(jobs: readonly JobInfo[], jobId: string): JobInfo | undefined;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The run that took over the answers of a `needs-input` run. A continuation that failed or was cancelled does not
3
+ * count: the questions are still open, so the earlier run can be continued again.
4
+ */
5
+ export function findContinuation(jobs, jobId) {
6
+ return jobs.find((job) => job.continuesJobId === jobId && (job.status === 'running' || job.status === 'succeeded'));
7
+ }
@@ -0,0 +1,3 @@
1
+ /** The product name shown to people. The command, the package and the environment variables stay lowercase:
2
+ * `asrun`, `@asrunjs/asrun`, `ASRUN_ROOT`. */
3
+ export declare const PRODUCT_NAME = "AsRun";
@@ -0,0 +1,3 @@
1
+ /** The product name shown to people. The command, the package and the environment variables stay lowercase:
2
+ * `asrun`, `@asrunjs/asrun`, `ASRUN_ROOT`. */
3
+ export const PRODUCT_NAME = 'AsRun';
@@ -0,0 +1 @@
1
+ export { casePattern } from '../../lib/run-filters.js';
@@ -0,0 +1 @@
1
+ export { casePattern } from '../../lib/run-filters.js';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Completes a site address without a URL scheme: `example.com` becomes `https://example.com`.
3
+ * Anything that already starts with a scheme (`https://`, `http://`, `ftp://`, ...) is left alone,
4
+ * so the caller still decides whether the scheme is allowed.
5
+ */
6
+ export declare function withScheme(value: string): string;
@@ -0,0 +1,11 @@
1
+ /** `https://`, `http://` and any other scheme the user typed: kept exactly as given. */
2
+ const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
3
+ /**
4
+ * Completes a site address without a URL scheme: `example.com` becomes `https://example.com`.
5
+ * Anything that already starts with a scheme (`https://`, `http://`, `ftp://`, ...) is left alone,
6
+ * so the caller still decides whether the scheme is allowed.
7
+ */
8
+ export function withScheme(value) {
9
+ const text = value.trim();
10
+ return HAS_SCHEME.test(text) ? text : `https://${text}`;
11
+ }