@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
@@ -1,8 +1,14 @@
1
1
  /** `--grep` pattern that matches a tag exactly (`@smoke` must not match `@smoke-extra`). */
2
2
  const tagPattern = (name) => `@${name}(?![\\w-])`;
3
3
  const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4
+ /**
5
+ * Playwright matches `--grep` against a title that holds the spec file with the separator of this platform
6
+ * (`conduit/header-navigation.spec.ts` on macOS and Linux, `conduit\header-navigation.spec.ts` on Windows), so a
7
+ * `/` in a case id or in the user's search has to match either one. `\/` is left alone: it is already a plain `/`.
8
+ */
9
+ const bothSeparators = (text) => text.replace(/(?<!\\)\//g, '[\\\\/]');
4
10
  /** Matches the spec file of one case (`documents/published-flow` must not match `documents/published-flow-2`). */
5
- export const casePattern = (id) => `(?:^|\\s)${escapeRegExp(id)}\\.spec\\.ts`;
11
+ export const casePattern = (id) => `(?:^|\\s)${bothSeparators(escapeRegExp(id))}\\.spec\\.ts`;
6
12
  /**
7
13
  * The Playwright arguments that decide WHICH tests run. Shared by `pnpm asrun` and by the web UI's
8
14
  * "what would this run do" preview, so both always agree.
@@ -15,7 +21,9 @@ export function filterArgs(filters) {
15
21
  grepParts.push(`(?:${filters.suites.map(tagPattern).join('|')})`);
16
22
  }
17
23
  if (filters.grep) {
18
- grepParts.push(`(?:${filters.grep})`);
24
+ // A case id (`conduit/header-navigation`) and a part of one (`conduit/head`) both have to match the spec file
25
+ // in Playwright's title, whatever separator this platform puts in it.
26
+ grepParts.push(`(?:${bothSeparators(filters.grep)})`);
19
27
  }
20
28
  if (filters.only && filters.only.length > 0) {
21
29
  grepParts.push(`(?:${filters.only.map(casePattern).join('|')})`);
@@ -1,5 +1,5 @@
1
1
  import type { Browser } from '@playwright/test';
2
- import type { Profile } from './profiles.js';
2
+ import { type Profile, type SessionCheck } from './profiles.js';
3
3
  export type SessionStatus = 'ok' | 'missing' | 'expired';
4
4
  /**
5
5
  * Set by `pnpm asrun` after its pre-run login check: `profile-id:status,profile-id:status`
@@ -7,7 +7,19 @@ export type SessionStatus = 'ok' | 'missing' | 'expired';
7
7
  */
8
8
  export declare const NOT_LOGGED_IN_ENV = "ASRUN_NOT_LOGGED_IN";
9
9
  export declare const hasSessionFile: (profile: Profile) => boolean;
10
+ /** True when the address is (or starts below) the address a site sends a signed-out visitor to. */
11
+ export declare function isLoggedOutUrl(url: string, check?: SessionCheck): boolean;
12
+ /** Kept for callers that only ask about the conventional `/login` address. */
10
13
  export declare const isLoginUrl: (url: string) => boolean;
14
+ /**
15
+ * Opens `sessionCheckPath` with the saved session and decides whether the profile is still signed in.
16
+ *
17
+ * A site can show "signed out" in more than one way, so every signal the profile names is watched at the
18
+ * same time: a redirect to `sessionCheck.loggedOutUrl` (server side or, in a single-page app, after the app
19
+ * has started), a 401/403 response, `loggedOutSelector`, or `loggedInSelector` proving the opposite. The
20
+ * page is read only after a signal arrived, never right after the navigation (which would still show the
21
+ * page before a client-side redirect happened).
22
+ */
11
23
  export declare function checkSession(browser: Browser, profile: Profile): Promise<SessionStatus>;
12
24
  export declare function parseNotLoggedIn(value?: string | undefined): Map<string, SessionStatus>;
13
25
  export declare const formatNotLoggedIn: (entries: Map<string, SessionStatus>) => string;
@@ -1,35 +1,101 @@
1
1
  import fs from 'node:fs';
2
+ import { DEFAULT_SESSION_CHECK } from './profiles.js';
2
3
  import { authFile } from './paths.js';
3
4
  /**
4
5
  * Set by `pnpm asrun` after its pre-run login check: `profile-id:status,profile-id:status`
5
6
  * for every profile that is not logged in. Lets the config and fixtures skip a second check.
6
7
  */
7
8
  export const NOT_LOGGED_IN_ENV = 'ASRUN_NOT_LOGGED_IN';
9
+ /** A response that means "you are not allowed in", for sites that answer with a status instead of a redirect. */
10
+ const EXPIRED_STATUSES = new Set([401, 403]);
8
11
  export const hasSessionFile = (profile) => fs.existsSync(authFile(profile.id));
9
- export const isLoginUrl = (url) => {
12
+ /** True when the address is (or starts below) the address a site sends a signed-out visitor to. */
13
+ export function isLoggedOutUrl(url, check = DEFAULT_SESSION_CHECK) {
14
+ let target;
10
15
  try {
11
- return new URL(url).pathname.startsWith('/login');
16
+ target = new URL(url);
12
17
  }
13
18
  catch {
14
19
  return false;
15
20
  }
16
- };
21
+ if (/^https?:\/\//i.test(check.loggedOutUrl)) {
22
+ try {
23
+ const expected = new URL(check.loggedOutUrl);
24
+ return target.origin === expected.origin && underPath(target.pathname, expected.pathname);
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ return underPath(target.pathname, check.loggedOutUrl);
31
+ }
32
+ const underPath = (pathname, expected) => pathname === expected || pathname.startsWith(expected.endsWith('/') ? expected : `${expected}/`);
33
+ /** Kept for callers that only ask about the conventional `/login` address. */
34
+ export const isLoginUrl = (url) => isLoggedOutUrl(url);
35
+ /**
36
+ * Opens `sessionCheckPath` with the saved session and decides whether the profile is still signed in.
37
+ *
38
+ * A site can show "signed out" in more than one way, so every signal the profile names is watched at the
39
+ * same time: a redirect to `sessionCheck.loggedOutUrl` (server side or, in a single-page app, after the app
40
+ * has started), a 401/403 response, `loggedOutSelector`, or `loggedInSelector` proving the opposite. The
41
+ * page is read only after a signal arrived, never right after the navigation (which would still show the
42
+ * page before a client-side redirect happened).
43
+ */
17
44
  export async function checkSession(browser, profile) {
18
- if (!hasSessionFile(profile)) {
45
+ if (!hasSessionFile(profile))
19
46
  return 'missing';
20
- }
47
+ const check = profile.sessionCheck;
21
48
  const context = await browser.newContext({ storageState: authFile(profile.id) });
22
49
  try {
23
50
  const page = await context.newPage();
24
- await page.goto(new URL(profile.sessionCheckPath, profile.baseURL).href, {
51
+ const response = await page.goto(new URL(profile.sessionCheckPath, profile.baseURL).href, {
25
52
  waitUntil: 'domcontentloaded',
26
53
  });
27
- return isLoginUrl(page.url()) ? 'expired' : 'ok';
54
+ if (response && EXPIRED_STATUSES.has(response.status()))
55
+ return 'expired';
56
+ if (isLoggedOutUrl(page.url(), check))
57
+ return 'expired';
58
+ const signal = await firstSignal(page, check);
59
+ if (signal)
60
+ return signal;
61
+ // Nothing showed up: fall back to the address, which is all a plain redirect leaves behind. A profile
62
+ // that named a signed-in marker gets an error instead, so a wrong check path is visible instead of "ok".
63
+ if (isLoggedOutUrl(page.url(), check))
64
+ return 'expired';
65
+ if (check.loggedInSelector) {
66
+ throw new Error(`${profile.sessionCheckPath} neither showed \`${check.loggedInSelector}\` nor went to ${check.loggedOutUrl} ` +
67
+ `within ${check.timeoutMs} ms, so the session could not be checked. Fix "sessionCheck" in profiles.yaml.`);
68
+ }
69
+ return 'ok';
28
70
  }
29
71
  finally {
30
72
  await context.close();
31
73
  }
32
74
  }
75
+ /** The first signal that arrives wins; null when every one of them times out. */
76
+ async function firstSignal(page, check) {
77
+ const timeout = check.timeoutMs;
78
+ const signals = [
79
+ page
80
+ .waitForURL((url) => isLoggedOutUrl(url.href, check), { timeout, waitUntil: 'commit' })
81
+ .then(() => 'expired'),
82
+ ];
83
+ if (check.loggedOutSelector) {
84
+ signals.push(page
85
+ .waitForSelector(check.loggedOutSelector, { state: 'visible', timeout })
86
+ .then(() => 'expired'));
87
+ }
88
+ if (check.loggedInSelector) {
89
+ signals.push(page.waitForSelector(check.loggedInSelector, { state: 'visible', timeout }).then(() => 'ok'));
90
+ }
91
+ try {
92
+ return await Promise.any(signals);
93
+ }
94
+ catch {
95
+ // Every signal timed out: the caller decides what an inconclusive page means.
96
+ return null;
97
+ }
98
+ }
33
99
  export function parseNotLoggedIn(value = process.env[NOT_LOGGED_IN_ENV]) {
34
100
  const result = new Map();
35
101
  for (const entry of (value ?? '').split(',').filter(Boolean)) {
@@ -1,21 +1,13 @@
1
+ import { type CaseTargetCard } from './case-doc.js';
1
2
  /**
2
3
  * An assertion target card: the exact image, text, file or place that a check looks at, written in the case under
3
4
  * `## Targets` as `### T<n> <name>` with `- What:`, `- Where:`, `- Identified by:` and `- Reference <profile-id>:` bullets.
5
+ * Parsing lives in `lib/case-doc.ts` (pure), so the web panel reads the same cards.
4
6
  */
5
- export interface TargetCard {
6
- /** `T1`, `T2`, ... */
7
- id: string;
8
- name: string;
9
- /** Field name (lower case, e.g. `what`, `where`, `identified by`, `not`, `ready when`, `kind`) to its text. */
10
- fields: Record<string, string>;
11
- /** `- Reference <profile-id>: <path>` bullets, keyed by what is written after `Reference`. */
12
- references: Record<string, string>;
13
- /** The card still has an open `🚫 TODO [blocking]` block: the target is not settled and no check may be built on it. */
14
- blocking: boolean;
15
- }
7
+ export type TargetCard = CaseTargetCard;
16
8
  export declare const TARGET_REF: RegExp;
17
- export declare const REQUIRED_FIELDS: readonly ['what', 'where', 'identified by'];
18
- export declare function parseTargets(text: string): TargetCard[];
9
+ export declare const REQUIRED_FIELDS: readonly ["what", "where", "identified by"];
10
+ export declare const parseTargets: (text: string) => TargetCard[];
19
11
  export declare const readCaseText: (id: string) => string;
20
12
  /** The target ids that a text mentions as `[T1]`. */
21
13
  export declare const targetRefsIn: (text: string) => string[];
@@ -1,35 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { caseSectionText, parseTargetCards, TARGET_REQUIRED_FIELDS } from './case-doc.js';
3
4
  import { CASES_DIR } from './paths.js';
4
5
  export const TARGET_REF = /\[(T\d+)\]/g;
5
- export const REQUIRED_FIELDS = ['what', 'where', 'identified by'];
6
- const section = (text, name) => new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(text)?.[1] ?? '';
7
- export function parseTargets(text) {
8
- const body = section(text, 'Targets');
9
- const cards = [];
10
- const parts = body.split(/^### /m).slice(1);
11
- for (const part of parts) {
12
- const [heading, ...lines] = part.split('\n');
13
- const head = /^(T\d+)\b[\s:.-]*(.*)$/.exec(heading.trim());
14
- if (!head)
15
- continue;
16
- const card = { id: head[1], name: head[2], fields: {}, references: {}, blocking: false };
17
- for (const line of lines) {
18
- if (/^\s*-\s*🚫 TODO \[blocking\]/.test(line))
19
- card.blocking = true;
20
- const reference = /^-\s+Reference\s+([^:]+):\s*(.*)$/.exec(line);
21
- if (reference) {
22
- card.references[reference[1].trim()] = reference[2].trim();
23
- continue;
24
- }
25
- const field = /^-\s+([A-Za-z][A-Za-z ]*?):\s*(.*)$/.exec(line);
26
- if (field)
27
- card.fields[field[1].trim().toLowerCase()] = field[2].trim();
28
- }
29
- cards.push(card);
30
- }
31
- return cards;
32
- }
6
+ export const REQUIRED_FIELDS = TARGET_REQUIRED_FIELDS;
7
+ export const parseTargets = (text) => parseTargetCards(text);
33
8
  export const readCaseText = (id) => fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8');
34
9
  /** The target ids that a text mentions as `[T1]`. */
35
10
  export const targetRefsIn = (text) => [...new Set([...text.matchAll(TARGET_REF)].map((match) => match[1]))];
@@ -38,7 +13,7 @@ export const NEEDS_TARGET = /\b(logo|image|icon|screenshot|snapshot|pdf|download
38
13
  export function checksNeedingTarget(text) {
39
14
  const found = [];
40
15
  for (const name of ['Expected', 'Visual checks']) {
41
- for (const line of section(text, name).split('\n')) {
16
+ for (const line of caseSectionText(text, name).split('\n')) {
42
17
  // A bullet that is only a question or an answer is part of a TODO block, not a check.
43
18
  if (!/^\s*-\s/.test(line) || /TODO|Answer:/.test(line))
44
19
  continue;
@@ -1,14 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parse } from 'yaml';
4
+ import { caseSectionText } from './case-doc.js';
4
5
  import { CASES_DIR } from './paths.js';
5
6
  import { loadProfiles } from './profiles.js';
6
- const section = (text, name) => new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(text)?.[1] ?? '';
7
7
  const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
8
8
  const caseFile = (caseId) => path.join(CASES_DIR, `${caseId}.md`);
9
9
  /** Parses and validates the `## Values` block. Throws an Error that starts with `label` and says what to fix. */
10
10
  export function parseCaseValues(text, label = 'case document') {
11
- const body = section(text, 'Values').trim();
11
+ const body = caseSectionText(text, 'Values').trim();
12
12
  if (body === '')
13
13
  return {};
14
14
  const block = /```ya?ml\r?\n([\s\S]*?)```/i.exec(body);
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
+ import { COPIED_SCRIPTS } from '../lib/build-artifacts.js';
4
5
  import { commandScript } from '../lib/node-cli.js';
5
6
  import { FRAMEWORK_ROOT } from '../lib/paths.js';
6
7
  // Compiles the framework to `dist/`: JavaScript and type declarations that Node and Playwright can load from a
@@ -12,7 +13,7 @@ const result = spawnSync(process.execPath, [tsc, '-p', path.join(FRAMEWORK_ROOT,
12
13
  });
13
14
  if ((result.status ?? 1) !== 0)
14
15
  process.exit(result.status ?? 1);
15
- for (const file of ['go.mjs', 'heal-evidence.mjs']) {
16
+ for (const file of COPIED_SCRIPTS) {
16
17
  const from = path.join(FRAMEWORK_ROOT, 'scripts', file);
17
18
  const to = path.join(FRAMEWORK_ROOT, 'dist', 'scripts', file);
18
19
  fs.mkdirSync(path.dirname(to), { recursive: true });
@@ -1,18 +1,82 @@
1
1
  import fs from 'node:fs';
2
2
  import { CASES_DIR } from '../lib/paths.js';
3
3
  import { listCaseFileIds, readCaseMeta } from '../lib/cases.js';
4
+ import { parseCaseDocument } from '../lib/case-doc.js';
4
5
  import path from 'node:path';
5
6
  import { ROOT } from '../lib/paths.js';
6
7
  import { checksNeedingTarget, parseTargets, readCaseText, REQUIRED_FIELDS, targetRefsIn } from '../lib/targets.js';
7
8
  import { parseCaseValues } from '../lib/values.js';
8
- /** Step numbers of the lines marked `[input]` or `[writes]` in "## Steps" (a person does them), and the numbers that "## Inputs" has a bullet for. */
9
+ /** Step numbers of the lines marked `[input]` or `[writes]` in "## Steps" (a person does them), and the numbers that have a `- Recipe:` line under them (or an old "## Inputs" line). */
9
10
  function inputSteps(id) {
10
- const text = fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8');
11
- const section = (name) => new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(text)?.[1] ?? '';
12
- const marked = [...section('Steps').matchAll(/^(\d+)\.\s.*\[(?:input|writes)\]/gm)].map((match) => Number(match[1]));
13
- const recorded = [...section('Inputs').matchAll(/^- Step (\d+)\b/gm)].map((match) => Number(match[1]));
11
+ const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
12
+ const marked = doc.steps
13
+ .filter((step) => step.tags.includes('input') || step.tags.includes('writes'))
14
+ .map((step) => step.number);
15
+ const recorded = [
16
+ ...doc.steps.flatMap((step) => step.recipes.map(() => step.number)),
17
+ ...doc.inputs.map((input) => input.step).filter((step) => step !== null),
18
+ ];
14
19
  return { marked, recorded };
15
20
  }
21
+ /**
22
+ * Warnings about the question blocks and the recipe lines of a case: a block nobody can answer, an answer that is not
23
+ * the last line (so the agent cannot tell what it belongs to), a question with no text to keep, a nesting that Markdown
24
+ * will not render inside its step, and a recipe under a step that is not tagged (all `warn`: old cases keep working).
25
+ */
26
+ function questionProblems(id) {
27
+ const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
28
+ const warnings = [];
29
+ for (const question of doc.questions) {
30
+ if (!question.hasAnswerLine) {
31
+ warnings.push(`the question on line ${question.line} has no "✍️ Answer:" line; add one so it can be answered in place`);
32
+ }
33
+ else if (question.answerLine !== question.lastLine) {
34
+ warnings.push(`the "✍️ Answer:" line of the question on line ${question.line} is not the last line of its block`);
35
+ }
36
+ if (!question.parent) {
37
+ warnings.push(`the question on line ${question.line} has no text above it: accepting it would delete a claim nobody sees`);
38
+ continue;
39
+ }
40
+ const step = doc.steps.find((candidate) => candidate.line === question.parent?.line);
41
+ if (step && question.indent < 4) {
42
+ warnings.push(`the question on line ${question.line} is indented by ${question.indent} space(s) under step ${step.number}; 4 renders it inside the step`);
43
+ }
44
+ }
45
+ for (const step of doc.steps) {
46
+ if (step.recipes.length === 0)
47
+ continue;
48
+ if (!step.tags.includes('input') && !step.tags.includes('writes')) {
49
+ warnings.push(`step ${step.number} has a "- Recipe:" line but is not tagged [input] or [writes]`);
50
+ }
51
+ }
52
+ for (const input of doc.inputs) {
53
+ if (input.step === null)
54
+ continue;
55
+ const step = doc.steps.find((candidate) => candidate.number === input.step);
56
+ if (!step) {
57
+ warnings.push(`"## Inputs" line ${input.line} (old format) belongs to step ${input.step}, which this case does not have`);
58
+ }
59
+ else if (!step.tags.includes('input') && !step.tags.includes('writes')) {
60
+ warnings.push(`"## Inputs" line ${input.line} (old format) belongs to step ${input.step}, which is not tagged [input] or [writes]`);
61
+ }
62
+ }
63
+ return warnings;
64
+ }
65
+ /**
66
+ * Warnings for the old layout. `## Data` and `## Inputs` are deprecated: the one-sentence summary belongs in
67
+ * `writes-note`, the cleanup in steps, and a person's recipe under its step. Cases keep working, so this never fails.
68
+ */
69
+ function formatProblems(id) {
70
+ const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
71
+ const warnings = [];
72
+ if (doc.sections.some((section) => section.name === 'Data')) {
73
+ warnings.push('has a "## Data" section, which is no longer part of the case format: move it into "writes-note", the delete steps or "## Notes"');
74
+ }
75
+ if (doc.inputs.length > 0) {
76
+ warnings.push('uses the old "## Inputs" section: move each line under its step as "- Recipe (phase): ..."');
77
+ }
78
+ return warnings;
79
+ }
16
80
  /** Problems with the target cards (`## Targets`) of a case: `fail` stops, `warn` is what `/explore-case` still has to settle. */
17
81
  function targetProblems(id) {
18
82
  const text = readCaseText(id);
@@ -70,8 +134,12 @@ for (const id of ids) {
70
134
  const { marked, recorded } = inputSteps(id);
71
135
  const missing = marked.filter((step) => !recorded.includes(step));
72
136
  if (missing.length > 0) {
73
- console.log(`warn ${id}: step ${missing.join(', ')} is marked [input] or [writes] but has no recipe under "## Inputs" (run /explore-case --attended)`);
137
+ console.log(`warn ${id}: step ${missing.join(', ')} is marked [input] or [writes] but has no "- Recipe:" line under it (run /explore-case --attended)`);
74
138
  }
139
+ for (const message of formatProblems(id))
140
+ console.log(`warn ${id}: ${message}`);
141
+ for (const message of questionProblems(id))
142
+ console.log(`warn ${id}: ${message}`);
75
143
  }
76
144
  catch (error) {
77
145
  problems++;
@@ -2,7 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
4
4
  import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
5
+ import { spawnTarget } from '../lib/command.js';
5
6
  import { GENERATED, frameworkPath, frameworkVersion, renderTemplate } from './init.js';
7
+ import { builtFile, COPIED_SCRIPTS } from '../lib/build-artifacts.js';
6
8
  /**
7
9
  * Checks that a workspace is complete and consistent with the framework it points at. It never writes anything.
8
10
  * Errors exit with 1; warnings do not.
@@ -26,6 +28,45 @@ function nonNegotiableRules() {
26
28
  }
27
29
  return ids;
28
30
  }
31
+ /** Every rule id the framework baseline defines (`F1`..`F10`), so a project cannot name one that does not exist. */
32
+ function frameworkRuleIds() {
33
+ const ids = new Set();
34
+ for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
35
+ const match = /^- \*\*(F\d+)\b/.exec(line.trim());
36
+ if (match)
37
+ ids.add(match[1]);
38
+ }
39
+ return ids;
40
+ }
41
+ /**
42
+ * The framework sources that `pnpm build` compiles into `dist/` and are older than it, or missing from it. Only the
43
+ * framework repository has those sources; an installed package ships `dist/` alone, so the list is empty there.
44
+ */
45
+ function staleDistFiles() {
46
+ const stale = [];
47
+ const compare = (source) => {
48
+ const compiled = path.join(FRAMEWORK_ROOT, builtFile(source));
49
+ if (!fs.existsSync(compiled))
50
+ stale.push(`${builtFile(source)} is missing`);
51
+ else if (fs.statSync(path.join(FRAMEWORK_ROOT, source)).mtimeMs > fs.statSync(compiled).mtimeMs)
52
+ stale.push(source);
53
+ };
54
+ const walk = (dir) => {
55
+ if (!fs.existsSync(path.join(FRAMEWORK_ROOT, dir)))
56
+ return;
57
+ for (const entry of fs.readdirSync(path.join(FRAMEWORK_ROOT, dir), { withFileTypes: true })) {
58
+ const source = `${dir}/${entry.name}`;
59
+ if (entry.isDirectory())
60
+ walk(source);
61
+ // The plain `.mjs` tools are copied as they are; every other file is TypeScript and becomes JavaScript.
62
+ else if (source.endsWith('.ts') || COPIED_SCRIPTS.includes(entry.name))
63
+ compare(source);
64
+ }
65
+ };
66
+ walk('lib');
67
+ walk('scripts');
68
+ return stale;
69
+ }
29
70
  export async function main(args) {
30
71
  if (args.includes('--help')) {
31
72
  console.log('Usage: pnpm asrun doctor');
@@ -66,6 +107,14 @@ export async function main(args) {
66
107
  }
67
108
  }
68
109
  const framework = recordedPath ?? actualPath;
110
+ // The framework repository compiles itself into `dist/`, and `templates/playwright.config.ts` makes every workspace
111
+ // load the config from there. A build older than the sources therefore keeps runs on the previous code.
112
+ const stale = staleDistFiles();
113
+ if (stale.length > 0) {
114
+ const shown = stale.slice(0, 3).join(', ');
115
+ warnings.push(`dist/ is out of date (${shown}${stale.length > 3 ? `, and ${stale.length - 3} more` : ''}): run "pnpm build". ` +
116
+ 'Workspaces load playwright.config.ts from dist, so they keep the previous configuration until then.');
117
+ }
69
118
  // Profiles: they are the only source of environments, and a broken file stops every command.
70
119
  try {
71
120
  const profiles = loadProfiles();
@@ -124,7 +173,19 @@ export async function main(args) {
124
173
  const skillsDir = path.join(ROOT, '.claude', 'skills');
125
174
  const overrides = fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir).filter((name) => name.endsWith('.md')) : [];
126
175
  facts.push(`skills: ${SKILLS.join(', ')}${overrides.length > 0 ? ` (project overrides: ${overrides.map((name) => name.replace(/\.md$/, '')).join(', ')})` : ''}`);
127
- // Project rules may only narrow the baseline, never a rule marked [non-negotiable].
176
+ for (const name of overrides) {
177
+ const text = read(path.join(skillsDir, name)) ?? '';
178
+ // An override refines a skill's procedure; redefining a safety rule is never an override.
179
+ if (/^\s*[-*]?\s*\*{0,2}F[1-6]\b/m.test(text)) {
180
+ errors.push(`.claude/skills/${name} redefines a safety rule (F1–F6); project rules never change those.`);
181
+ }
182
+ const frameworkSkill = read(path.join(SKILLS_DIR, name.replace(/\.md$/, ''), 'SKILL.md'));
183
+ if (frameworkSkill !== null && text.trim() === frameworkSkill.trim()) {
184
+ warnings.push(`.claude/skills/${name} is a copy of the framework skill; delete it, or it will never see an update.`);
185
+ }
186
+ }
187
+ // Project rules: a project may narrow or replace a baseline rule, never one of F1–F6. Rows are
188
+ // `| <id> | <the project's rule> | <narrows|replaces> |`.
128
189
  const agents = read(path.join(ROOT, 'AGENTS.md'));
129
190
  if (agents === null) {
130
191
  warnings.push('AGENTS.md is missing: run "pnpm asrun init" and fill in the project facts.');
@@ -134,11 +195,29 @@ export async function main(args) {
134
195
  errors.push(`AGENTS.md does not point at the framework baseline (${framework}/AGENTS.md).`);
135
196
  }
136
197
  const locked = nonNegotiableRules();
198
+ const known = frameworkRuleIds();
199
+ let fenced = false;
137
200
  for (const line of agents.split('\n')) {
138
- const match = /^\|\s*(F\d+)\s*\|/.exec(line.trim());
139
- if (match && locked.has(match[1])) {
140
- errors.push(`AGENTS.md narrows ${match[1]}, which the framework marks [non-negotiable].`);
201
+ // A fenced block is documentation (an example table), not the project's rules.
202
+ if (line.trim().startsWith('```')) {
203
+ fenced = !fenced;
204
+ continue;
141
205
  }
206
+ if (fenced)
207
+ continue;
208
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
209
+ if (!/^F\d+$/.test(cells[0]))
210
+ continue;
211
+ const id = cells[0];
212
+ const effect = (cells[2] ?? '').toLowerCase();
213
+ if (!known.has(id))
214
+ errors.push(`AGENTS.md names ${id}, which is not a rule of the framework baseline.`);
215
+ else if (locked.has(id))
216
+ errors.push(`AGENTS.md changes ${id}, which the framework marks [non-negotiable].`);
217
+ else if (effect === '')
218
+ warnings.push(`AGENTS.md rule ${id} has no Effect column: add "narrows" or "replaces" (a table written before 0.3.0 predates it).`);
219
+ else if (effect !== 'narrows' && effect !== 'replaces')
220
+ errors.push(`AGENTS.md rule ${id} has Effect "${cells[2]}"; use "narrows" or "replaces".`);
142
221
  }
143
222
  }
144
223
  // The install and the commands the docs use.
@@ -168,6 +247,38 @@ export async function main(args) {
168
247
  if (scripts !== null && !scripts[name])
169
248
  warnings.push(`package.json has no "${name}" script: run "pnpm asrun init".`);
170
249
  }
250
+ // The agent CLIs the AI commands start. Only those commands need one, so a missing one is a warning, not an error.
251
+ // Checked the way the web UI starts them, so a Windows `.cmd` shim is reported instead of failing later.
252
+ const tools = ['claude', 'opencode']
253
+ .map((name) => ({ name, target: spawnTarget(name) }))
254
+ .filter((tool) => tool.target !== null);
255
+ if (tools.length === 0) {
256
+ warnings.push('Neither "claude" (Claude Code) nor "opencode" was found on PATH: the AI commands (make cases, explore a case, generate a spec) cannot start.');
257
+ }
258
+ else {
259
+ facts.push(`ai tools: ${tools
260
+ .map((tool) => `${tool.name} -> ${tool.target.path}${tool.target.throughShell ? ' (through cmd.exe)' : ''}`)
261
+ .join(', ')}`);
262
+ }
263
+ // The MCP servers the AI commands explore with: OpenCode reads only `opencode.jsonc`, Claude Code only `.mcp.json`.
264
+ for (const [file, tool] of [
265
+ ['.mcp.json', 'Claude Code'],
266
+ ['opencode.jsonc', 'OpenCode'],
267
+ ]) {
268
+ const text = read(path.join(ROOT, file));
269
+ if (text === null) {
270
+ warnings.push(`${file} is missing: ${tool} has no "playwright-test" MCP server. Run "pnpm asrun init".`);
271
+ continue;
272
+ }
273
+ if (!text.includes('playwright-test')) {
274
+ warnings.push(`${file} does not define the "playwright-test" MCP server: run "pnpm asrun init --force ${file}".`);
275
+ continue;
276
+ }
277
+ const command = /"command"\s*:\s*(?:\[\s*)?"([^"]*playwright[^"]*)"/.exec(text)?.[1];
278
+ if (command !== undefined && !fs.existsSync(path.resolve(ROOT, command)) && !fs.existsSync(path.resolve(ROOT, `${command}.cmd`))) {
279
+ warnings.push(`${file} starts the MCP server with "${command}", which does not exist: run "pnpm asrun init --force ${file}".`);
280
+ }
281
+ }
171
282
  const settings = read(path.join(ROOT, '.claude', 'settings.json'));
172
283
  if (settings === null) {
173
284
  warnings.push('.claude/settings.json is missing: run "pnpm asrun init" (Claude Code needs its sandbox exclusions for pnpm asrun).');
@@ -51,4 +51,25 @@ context.on('page', watchPage);
51
51
  const page = await context.newPage();
52
52
  await page.goto(new URL(pathname, profile.baseURL).href);
53
53
  console.log(`Opened ${profileLabel(profile)}. Close the browser window to exit.`);
54
- await new Promise((resolve) => browser.on('disconnected', resolve));
54
+ // Chromium keeps its process alive after the last window is closed, so waiting for `disconnected` alone
55
+ // never finishes. Closing the last window does close its pages, and that is the signal to stop.
56
+ await new Promise((resolve) => {
57
+ let settled = false;
58
+ const done = () => {
59
+ if (settled)
60
+ return;
61
+ settled = true;
62
+ resolve();
63
+ };
64
+ browser.on('disconnected', done);
65
+ context.on('close', done);
66
+ const watch = (open) => open.on('close', () => {
67
+ if (context.pages().length === 0)
68
+ done();
69
+ });
70
+ context.pages().forEach(watch);
71
+ context.on('page', watch);
72
+ });
73
+ // The window is gone, but its browser process can still be running: end it so no orphan process is left.
74
+ await browser.close().catch(() => { });
75
+ console.log('Browser closed.');