@asrunjs/asrun 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.claude/commands/explore-case.md +14 -5
  2. package/.claude/commands/gen-spec.md +12 -5
  3. package/.claude/commands/heal-spec.md +23 -8
  4. package/.claude/commands/make-case.md +1 -1
  5. package/.claude/skills/exploring-cases/SKILL.md +82 -37
  6. package/.claude/skills/exploring-cases/references/assertion-targets.md +8 -4
  7. package/.claude/skills/exploring-cases/references/case-rules.md +110 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +73 -11
  9. package/.claude/skills/exploring-cases/references/open-questions.md +60 -8
  10. package/.claude/skills/exploring-cases/references/readiness.md +4 -3
  11. package/.claude/skills/exploring-cases/references/tools.md +117 -0
  12. package/.claude/skills/exploring-cases/references/user-handoff.md +46 -17
  13. package/.claude/skills/generating-specs/SKILL.md +148 -55
  14. package/.claude/skills/generating-specs/references/conventions.md +4 -0
  15. package/.claude/skills/making-cases/SKILL.md +14 -7
  16. package/.claude/skills/making-cases/references/gaps.md +7 -4
  17. package/.claude/skills/making-cases/references/normalizing.md +1 -1
  18. package/AGENTS.md +68 -20
  19. package/README.md +8 -6
  20. package/bin/asrun.mjs +1 -0
  21. package/dist/lib/case-doc.d.ts +41 -4
  22. package/dist/lib/case-doc.js +109 -17
  23. package/dist/scripts/check-cases.js +89 -1
  24. package/dist/scripts/doctor.js +50 -3
  25. package/dist/scripts/markers-confirm.d.ts +1 -0
  26. package/dist/scripts/markers-confirm.js +139 -0
  27. package/dist/ui/server/actions.js +57 -18
  28. package/dist/ui/server/app.js +7 -2
  29. package/dist/ui/server/job-store.d.ts +60 -0
  30. package/dist/ui/server/job-store.js +198 -0
  31. package/dist/ui/server/jobs.d.ts +46 -4
  32. package/dist/ui/server/jobs.js +361 -59
  33. package/dist/ui/server/process-info.d.ts +17 -0
  34. package/dist/ui/server/process-info.js +126 -0
  35. package/dist/ui/server/result-state.d.ts +10 -0
  36. package/dist/ui/server/result-state.js +106 -0
  37. package/dist/ui/shared/ai-commands.d.ts +9 -3
  38. package/dist/ui/shared/ai-commands.js +25 -11
  39. package/dist/ui/shared/types.d.ts +18 -3
  40. package/docs/agents/case-format.md +39 -5
  41. package/docs/ai-workflow.md +80 -21
  42. package/docs/cases.md +28 -4
  43. package/docs/commands.md +6 -1
  44. package/docs/theme-guide.html +93 -5
  45. package/docs/troubleshooting.md +47 -3
  46. package/docs/web-ui.md +55 -10
  47. package/package.json +2 -1
  48. package/templates/.claude/commands/explore-case.md +14 -4
  49. package/templates/.claude/commands/gen-spec.md +12 -4
  50. package/templates/.claude/commands/heal-spec.md +27 -13
  51. package/templates/.claude/commands/make-case.md +1 -1
  52. package/templates/AGENTS.md +9 -6
  53. package/templates/cases/_template.md +7 -0
  54. package/templates/package.json +1 -0
  55. package/ui/dist/assets/{dist-ChBHX4rh.js → dist-2msWI-5M.js} +1 -1
  56. package/ui/dist/assets/{dist-DoChb6QQ.js → dist-4U_gPHXf.js} +1 -1
  57. package/ui/dist/assets/{dist-CJiwxr1r.js → dist-BMfJatHo.js} +1 -1
  58. package/ui/dist/assets/{dist-N-o4QIaZ.js → dist-BVvHVJ3N.js} +1 -1
  59. package/ui/dist/assets/{dist-BC_P1Gnf.js → dist-BmWaNjqO.js} +1 -1
  60. package/ui/dist/assets/{dist-Do_MhHKF.js → dist-BwNz_xW2.js} +1 -1
  61. package/ui/dist/assets/{dist-D6jmnDr-.js → dist-J36xayOv.js} +1 -1
  62. package/ui/dist/assets/{dist-CavmZIv7.js → dist-e-KhTWHJ.js} +1 -1
  63. package/ui/dist/assets/{dist-BpkTvixI.js → dist-sfZKBSMm.js} +1 -1
  64. package/ui/dist/assets/{dist-BgPbx2GZ.js → dist-wjB7g6OJ.js} +1 -1
  65. package/ui/dist/assets/index-CGbRJT3b.js +348 -0
  66. package/ui/dist/assets/index-CI0i0LWc.css +2 -0
  67. package/ui/dist/help/case-explore.png +0 -0
  68. package/ui/dist/index.html +2 -2
  69. package/ui/dist/assets/index-Cy2l91qV.css +0 -2
  70. package/ui/dist/assets/index-DGQ5logJ.js +0 -338
package/README.md CHANGED
@@ -13,8 +13,8 @@ AsRun is a devDependency, not a service. Your profiles, cases, specs and baselin
13
13
  - **One spec, every profile.** Each entry in `profiles.yaml` becomes a Playwright project with its own base URL,
14
14
  saved login and screenshot baselines. Two accounts of one site give two independent runs.
15
15
  - **An AI workflow, not a prompt dump.** `/make-case` turns raw notes into cases, `/explore-case` checks them against
16
- the real pages, `/gen-spec` writes the spec, `/heal-spec` repairs it from the last report. Each command ends with
17
- `RESULT: ready` or `RESULT: needs-input`, so it works unattended.
16
+ the real pages, `/gen-spec` writes the spec, `/heal-spec` repairs it from the last report or refines a case and its
17
+ spec on request. Each command ends with `RESULT: ready` or `RESULT: needs-input`, so it works unattended.
18
18
  - **Evidence for every run.** One video per test (browser tabs joined), a trace with every action and request,
19
19
  screenshots on failure, and a report that puts the failure first.
20
20
  - **Safe by default.** A case is read-only unless it lists environments under `writes`; writing anywhere except
@@ -100,7 +100,8 @@ exempts `@asrunjs/asrun` from pnpm's 24-hour release age, so a fresh release ins
100
100
  notes with `/make-case notes.md`. Fill in the steps, what is expected and the exact values.
101
101
  4. **Check it against the real pages.** `/explore-case header-logo staging` opens the pages (with your saved session),
102
102
  writes what they show into the case and turns what it cannot know into questions. Answer them in the case file, or
103
- in the UI's **Answer** sheet, and run it again.
103
+ in the UI's **Answer** sheet, and run it again. With many profiles, `--profiles a,b` opens only those; a later run
104
+ with the others adds theirs, and the case keeps both.
104
105
  5. **Generate, run, read.** `/gen-spec header-logo staging` writes `tests/header-logo.spec.ts` and fills the case's
105
106
  `## Values`. Then:
106
107
 
@@ -132,12 +133,13 @@ It is read after the framework skill and before your project rules. Write only w
132
133
 
133
134
  Rules combine in this order, and a later layer wins where they conflict:
134
135
 
135
- 1. `F1`–`F6` of the framework baseline (safety, in every project). They cannot be changed.
136
+ 1. `F1`–`F6` and `R2` of the framework baseline (safety, in every project). They cannot be changed.
136
137
  2. The framework baseline, `node_modules/@asrunjs/asrun/AGENTS.md`.
137
138
  3. The framework skill.
138
139
  4. Your override, `.claude/skills/<skill>.md`.
139
- 5. Your own `AGENTS.md` §2: a table of rule ids (`F7`–`F10`) with `narrows` or `replaces`, for example the artifact
140
- language.
140
+ 5. Your own `AGENTS.md` §2: a table of rule ids (`F7`–`F10`, `R1`, `R3`, `R4`) with `narrows` or `replaces`, for example
141
+ the artifact language.
142
+ 6. A case's `## Rules`: how to run that one case, never what is true.
141
143
 
142
144
  `pnpm asrun doctor` validates the table and lists the overrides it sees. An update never rewrites your rules:
143
145
  `init --force` refuses `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` without `--reset-rules`. Never edit the
package/bin/asrun.mjs CHANGED
@@ -26,6 +26,7 @@ const SCRIPTS = {
26
26
  cleanup: 'cleanup.js',
27
27
  'case:new': 'new-case.js',
28
28
  'case:check': 'check-cases.js',
29
+ 'markers:confirm': 'markers-confirm.js',
29
30
  'spec:lint': 'lint-specs.js',
30
31
  'baselines:review': 'review-baselines.js',
31
32
  'pdf:render': 'render-pdf.js',
@@ -183,7 +183,7 @@ export interface CaseQuestion {
183
183
  region: TextRange;
184
184
  }
185
185
  export type MarkerKind = 'confirm' | 'unseen';
186
- /** `🔍 to be confirmed by QA` and `👁️ not observed`: markers of what was seen but not checked by a person. */
186
+ /** `🔍 to be confirmed` and `👁️ not observed`: markers of what was seen but not checked by a person. */
187
187
  export interface CaseMarker {
188
188
  kind: MarkerKind;
189
189
  /** The marker as written, including its emoji. */
@@ -193,6 +193,21 @@ export interface CaseMarker {
193
193
  sentence: string;
194
194
  section: string;
195
195
  line: number;
196
+ /**
197
+ * What a person has to decide, written after the marker's colon inside the same parentheses
198
+ * (`🔍 to be confirmed: is this the label users see?`). Null in the old form, which only says "shown by QA".
199
+ */
200
+ detail: string | null;
201
+ /** A screenshot a person can look at instead of opening the page again: a `.explore/` image path. */
202
+ evidence: string | null;
203
+ /** The marker plus its `: <reason>` and `evidence: <path>` parts: what deleting the marker removes. */
204
+ removeRange: TextRange;
205
+ }
206
+ /** What a marked line feeds: an assertion, the wording of a step, a method rule, or only context. */
207
+ export interface CaseMarkerFeed {
208
+ kind: 'assertion' | 'step' | 'rule' | 'note';
209
+ /** Short text for the panel, e.g. `## Expected` or `a step (the run checks it)`. */
210
+ label: string;
196
211
  }
197
212
  export type CaseValues = Record<string, Record<string, unknown>>;
198
213
  export interface CaseValuesBlock {
@@ -225,6 +240,12 @@ export interface CaseDocument {
225
240
  frontMatter: CaseFrontMatter | null;
226
241
  sections: CaseSection[];
227
242
  steps: CaseStep[];
243
+ /**
244
+ * The top-level bullets of `## Rules`: case-local instructions on *how* to run the case (a page quirk, a mandatory
245
+ * read-only preparation, a control to avoid). They never define what is true: that stays in Steps, Expected, Values
246
+ * and Targets (framework baseline `R1`).
247
+ */
248
+ rules: CaseBullet[];
228
249
  questions: CaseQuestion[];
229
250
  markers: CaseMarker[];
230
251
  values: CaseValuesBlock | null;
@@ -243,6 +264,18 @@ export declare function endWithBreak(text: string, end: number): number;
243
264
  export declare const afterLine: (text: string, line: CaseLine) => number;
244
265
  /** The front matter block: its parsed data plus the range of every key, so the panel can edit one key at a time. */
245
266
  export declare function parseCaseFrontMatter(text: string): CaseFrontMatter | null;
267
+ /**
268
+ * What a marked line feeds. Only the first two kinds can be wrong and still pass: an assertion, and the wording of an
269
+ * expectation. A step, a rule and a note are checked by the run itself, so a person does not have to re-read them.
270
+ */
271
+ export declare function markerFeed(doc: CaseDocument, marker: CaseMarker): CaseMarkerFeed;
272
+ /** How many markers of a case feed an assertion, i.e. how many are worth a person's attention. */
273
+ export declare function assertionMarkerCount(doc: CaseDocument): number;
274
+ /**
275
+ * The line a marker sits on, without the marker, its reason and its evidence: the sentence a person actually reads.
276
+ * The `(observed on ...)` part stays, because where it was seen is the context they need.
277
+ */
278
+ export declare function markerSentence(text: string, marker: CaseMarker): string;
246
279
  /** A prose section (`## Goal`, `## Notes`): the panel renders it read-only and reveals a line on click. */
247
280
  export declare function parseProse(text: string, name: string): CaseProse | null;
248
281
  /** The top-level bullets of a list section, with the blocks (a wrapped text, a TODO block) that belong to each. */
@@ -263,11 +296,15 @@ export declare function setQuestionAnswer(text: string, question: CaseQuestion,
263
296
  /** Accepts the suggestion: deletes the whole question block, the answer line included. */
264
297
  export declare function acceptQuestion(text: string, question: CaseQuestion): string;
265
298
  /**
266
- * Removes a `🔍 to be confirmed by QA` / `👁️ not observed` marker. When the marker is the only content of its
267
- * parentheses, the parentheses go too (`(👁️ not observed)`); otherwise the marker and one separator leave
268
- * `(observed, 🔍 to be confirmed by QA)` as `(observed)`.
299
+ * What deleting a `🔍 to be confirmed` / `👁️ not observed` marker removes. When the marker is the only content
300
+ * of its parentheses, the parentheses go too (`(👁️ not observed)`); otherwise the marker and one separator leave
301
+ * `(observed, 🔍 to be confirmed)` as `(observed)`.
269
302
  */
303
+ export declare function markerEdits(text: string, marker: CaseMarker): TextEdit;
304
+ /** Removes one marker, its punctuation and, when it was alone, its parentheses. */
270
305
  export declare function removeMarker(text: string, marker: CaseMarker): string;
306
+ /** Removes several markers in one edit, so the panel can confirm a whole list with one undo step. */
307
+ export declare function removeMarkers(text: string, markers: CaseMarker[]): string;
271
308
  /** Sets one value of one profile; `null` removes it (an empty cell in the panel means "not set here"). */
272
309
  export declare function setCaseValue(text: string, profile: string, key: string, value: string | null): string;
273
310
  /** Adds a key to the `default` values of the case. */
@@ -465,8 +465,41 @@ function parseQuestions(text, sections) {
465
465
  }
466
466
  /* Kept in step with `ui/web/src/lib/highlight-keywords.ts` (same words, same emoji prefixes). */
467
467
  const MARKER_RE = /(?:\u{1F50D}\uFE0F?[ \t]*)?(?:\bto be confirmed(?:\s+by\s+QA)?\b|\bTBC\b|\bTBD\b|\bneeds?\s+confirmation\b)|(?:\u{1F441}\uFE0F?[ \t]*)?\bnot observed\b/giu;
468
- function parseMarkers(text, sections) {
468
+ /** A screenshot named in the same parentheses as a marker: the evidence a person looks at instead of the page. */
469
+ const MARKER_EVIDENCE_RE = /\bevidence:[ \t]*([^\s;)]+\.(?:png|jpe?g|webp))/i;
470
+ /**
471
+ * What the parentheses around a marker say beyond the marker itself: the one thing a person has to decide (`: ...`)
472
+ * and the evidence image (`evidence: <path>`). Both are optional, so the old `(observed, 🔍 to be confirmed by QA)`
473
+ * still reads; it just says less. `removeEnd` runs to the end of the reason and the evidence, so deleting the marker
474
+ * leaves the observation alone.
475
+ */
476
+ function markerParts(text, start, end) {
477
+ const open = text.lastIndexOf('(', start);
478
+ const close = text.indexOf(')', end);
479
+ if (open < 0 || close < 0 || close < end || /[\r\n]/.test(text.slice(open, close))) {
480
+ return { detail: null, evidence: null, removeEnd: end };
481
+ }
482
+ const inside = text.slice(open + 1, close);
483
+ const evidenceMatch = MARKER_EVIDENCE_RE.exec(inside);
484
+ const evidence = evidenceMatch?.[1] ?? null;
485
+ const tail = text.slice(end, close);
486
+ const colon = /^[ \t]*:[ \t]*/.exec(tail);
487
+ const raw = colon ? tail.slice(colon[0].length) : '';
488
+ const detail = colon ? raw.replace(/[;,][ \t]*evidence:[\s\S]*$/i, '').trim() : '';
489
+ const removeEnd = evidenceMatch
490
+ ? open + 1 + evidenceMatch.index + evidenceMatch[0].length
491
+ : colon
492
+ ? end + colon[0].length + raw.trimEnd().length
493
+ : end;
494
+ return { detail: detail === '' ? null : detail, evidence, removeEnd };
495
+ }
496
+ /**
497
+ * Markers of `text`. A marker written inside a question block is not one: the question already says that a person has
498
+ * to decide, and counting it twice would put the same claim in "Open questions" and "To confirm".
499
+ */
500
+ function parseMarkers(text, sections, questions) {
469
501
  const markers = [];
502
+ const blocks = questions.map((question) => question.blockRange);
470
503
  let fenced = false;
471
504
  for (const line of splitLines(text)) {
472
505
  if (isFence(line.text)) {
@@ -478,18 +511,59 @@ function parseMarkers(text, sections) {
478
511
  for (const match of line.text.matchAll(MARKER_RE)) {
479
512
  if (match[0] === '')
480
513
  continue;
514
+ const start = line.start + (match.index ?? 0);
515
+ const end = start + match[0].length;
516
+ if (blocks.some((block) => start >= block.start && start <= block.end))
517
+ continue;
518
+ const parts = markerParts(text, start, end);
481
519
  markers.push({
482
520
  kind: /not observed/i.test(match[0]) ? 'unseen' : 'confirm',
483
521
  text: match[0],
484
- range: { start: line.start + (match.index ?? 0), end: line.start + (match.index ?? 0) + match[0].length },
522
+ range: { start, end },
523
+ removeRange: { start, end: parts.removeEnd },
485
524
  sentence: line.text.trim(),
486
525
  section: sectionAt(sections, line.start),
487
526
  line: line.number,
527
+ detail: parts.detail,
528
+ evidence: parts.evidence,
488
529
  });
489
530
  }
490
531
  }
491
532
  return markers;
492
533
  }
534
+ /**
535
+ * What a marked line feeds. Only the first two kinds can be wrong and still pass: an assertion, and the wording of an
536
+ * expectation. A step, a rule and a note are checked by the run itself, so a person does not have to re-read them.
537
+ */
538
+ export function markerFeed(doc, marker) {
539
+ if (marker.section === 'Values' || marker.section === 'Expected' || marker.section === 'Visual checks') {
540
+ return { kind: 'assertion', label: `## ${marker.section}` };
541
+ }
542
+ if (marker.section === 'Steps')
543
+ return { kind: 'step', label: 'a step (the run checks it)' };
544
+ if (marker.section === 'Rules')
545
+ return { kind: 'rule', label: 'a method rule (never an assertion)' };
546
+ return { kind: 'note', label: marker.section === '' ? 'the case' : `## ${marker.section}` };
547
+ }
548
+ /** How many markers of a case feed an assertion, i.e. how many are worth a person's attention. */
549
+ export function assertionMarkerCount(doc) {
550
+ return doc.markers.filter((marker) => markerFeed(doc, marker).kind === 'assertion').length;
551
+ }
552
+ /**
553
+ * The line a marker sits on, without the marker, its reason and its evidence: the sentence a person actually reads.
554
+ * The `(observed on ...)` part stays, because where it was seen is the context they need.
555
+ */
556
+ export function markerSentence(text, marker) {
557
+ const lineStart = text.lastIndexOf('\n', marker.range.start) + 1;
558
+ const lineBreak = text.indexOf('\n', marker.range.start);
559
+ const lineEnd = lineBreak === -1 ? text.length : lineBreak;
560
+ return (text.slice(lineStart, marker.range.start) + text.slice(marker.removeRange.end, lineEnd))
561
+ .replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/, '')
562
+ .replace(/[,;][ \t]*\)/g, ')')
563
+ .replace(/\([ \t]*\)/g, '')
564
+ .replace(/[ \t]{2,}/g, ' ')
565
+ .trim();
566
+ }
493
567
  const VALUES_OPEN_RE = /^[ \t]*```ya?ml[ \t]*$/i;
494
568
  const VALUES_CLOSE_RE = /^[ \t]*```[ \t]*$/;
495
569
  const isPlaceholderValue = (value) => value === null || value === undefined || value === '' || (typeof value === 'string' && /^\s*TODO\b/i.test(value));
@@ -801,13 +875,16 @@ export function caseMetaProblems(id, data, knownEnvs, label = 'case document') {
801
875
  export function parseCaseDocument(text) {
802
876
  const lines = splitLines(text);
803
877
  const sections = sectionsOf(text, lines);
878
+ // Questions first: a marker written inside one belongs to that question, not to "To confirm".
879
+ const questions = parseQuestions(text, sections);
804
880
  return {
805
881
  text,
806
882
  frontMatter: parseCaseFrontMatter(text),
807
883
  sections,
808
884
  steps: parseSteps(text, sections.find((section) => section.name === 'Steps')),
809
- questions: parseQuestions(text, sections),
810
- markers: parseMarkers(text, sections),
885
+ rules: parseBullets(text, 'Rules'),
886
+ questions,
887
+ markers: parseMarkers(text, sections, questions),
811
888
  values: parseValues(text, sections.find((section) => section.name === 'Values')),
812
889
  targets: parseTargetCards(text),
813
890
  inputs: parseInputs(text, sections.find((section) => section.name === 'Inputs')),
@@ -877,27 +954,42 @@ export function acceptQuestion(text, question) {
877
954
  return patchText(text, [{ start: question.blockRange.start, end: endWithBreak(text, question.blockRange.end), text: '' }]);
878
955
  }
879
956
  /**
880
- * Removes a `🔍 to be confirmed by QA` / `👁️ not observed` marker. When the marker is the only content of its
881
- * parentheses, the parentheses go too (`(👁️ not observed)`); otherwise the marker and one separator leave
882
- * `(observed, 🔍 to be confirmed by QA)` as `(observed)`.
957
+ * What deleting a `🔍 to be confirmed` / `👁️ not observed` marker removes. When the marker is the only content
958
+ * of its parentheses, the parentheses go too (`(👁️ not observed)`); otherwise the marker and one separator leave
959
+ * `(observed, 🔍 to be confirmed)` as `(observed)`.
883
960
  */
884
- export function removeMarker(text, marker) {
885
- const open = text.lastIndexOf('(', marker.range.start);
886
- const close = text.indexOf(')', marker.range.end);
961
+ export function markerEdits(text, marker) {
962
+ const start = marker.range.start;
963
+ const markerEnd = marker.removeRange.end;
964
+ const open = text.lastIndexOf('(', start);
965
+ const close = text.indexOf(')', markerEnd);
887
966
  if (open >= 0 && close >= 0 && !/[\r\n]/.test(text.slice(open, close))) {
888
- const rest = text.slice(open + 1, marker.range.start) + text.slice(marker.range.end, close);
967
+ const rest = text.slice(open + 1, start) + text.slice(markerEnd, close);
889
968
  if (/^[\s,;:—-]*$/.test(rest)) {
890
969
  const before = text[open - 1] === ' ' && /[.,;:!?]/.test(text[close + 1] ?? '') ? open - 1 : open;
891
- return patchText(text, [{ start: before, end: close + 1, text: '' }]);
970
+ return { start: before, end: close + 1, text: '' };
892
971
  }
893
972
  }
894
- const lead = /(?:[ \t]*[,;][ \t]*|[ \t]+)$/.exec(text.slice(0, marker.range.start));
973
+ const lead = /(?:[ \t]*[,;][ \t]*|[ \t]+)$/.exec(text.slice(0, start));
895
974
  if (lead) {
896
- return patchText(text, [{ start: marker.range.start - lead[0].length, end: marker.range.end, text: '' }]);
975
+ return { start: start - lead[0].length, end: markerEnd, text: '' };
897
976
  }
898
- const trail = /^[ \t]+/.exec(text.slice(marker.range.end));
899
- const end = trail ? marker.range.end + trail[0].length : marker.range.end;
900
- return patchText(text, [{ start: marker.range.start, end, text: '' }]);
977
+ const trail = /^[ \t]+/.exec(text.slice(markerEnd));
978
+ const end = trail ? markerEnd + trail[0].length : markerEnd;
979
+ return { start, end, text: '' };
980
+ }
981
+ /** Removes one marker, its punctuation and, when it was alone, its parentheses. */
982
+ export function removeMarker(text, marker) {
983
+ return patchText(text, [markerEdits(text, marker)]);
984
+ }
985
+ /** Removes several markers in one edit, so the panel can confirm a whole list with one undo step. */
986
+ export function removeMarkers(text, markers) {
987
+ if (markers.length === 0)
988
+ return text;
989
+ const edits = [...markers]
990
+ .sort((a, b) => b.range.start - a.range.start)
991
+ .map((marker) => markerEdits(text, marker));
992
+ return patchText(text, edits);
901
993
  }
902
994
  function replaceValues(text, block, values) {
903
995
  const yaml = stringifyYaml(values, { lineWidth: 0 }).replace(/\r?\n/g, newlineOf(text));
@@ -1,11 +1,35 @@
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
+ import { parseCaseDocument, splitLines } from '../lib/case-doc.js';
5
5
  import path from 'node:path';
6
6
  import { ROOT } from '../lib/paths.js';
7
7
  import { checksNeedingTarget, parseTargets, readCaseText, REQUIRED_FIELDS, targetRefsIn } from '../lib/targets.js';
8
8
  import { parseCaseValues } from '../lib/values.js';
9
+ const LIST_ITEM = /^[ \t]*(?:[-*+]|\d+\.)[ \t]+\S/;
10
+ const HEADING = /^##[ \t]+\S/;
11
+ /** The words the panel and `case:check` treat as a marker: one claim carries one signal. */
12
+ const MARKER_WORD = /\b(?:to be confirmed(?:\s+by\s+QA)?|TBC|TBD|not observed)\b/i;
13
+ const indentOf = (text) => text.length - text.trimStart().length;
14
+ /**
15
+ * The line a question immediately hangs under: the nearest line above it that is indented less and can carry text.
16
+ * `null` when that line is a paragraph (the model extends a paragraph upwards on purpose) or nothing qualifies.
17
+ */
18
+ function immediateAnchor(lines, questionLine, indent) {
19
+ const at = lines.findIndex((line) => line.number === questionLine);
20
+ for (let index = at - 1; index >= 0; index--) {
21
+ const text = lines[index].text;
22
+ if (text.trim() === '' || HEADING.test(text) || /^[ \t]*```/.test(text))
23
+ return null;
24
+ if (indentOf(text) >= indent)
25
+ continue;
26
+ // Another question at a shallower depth is not the sentence this one stands for.
27
+ if (/^[ \t]*[-*+][ \t]+(?:\u{1F6AB}|\u{2753}|\u270D)/u.test(text))
28
+ continue;
29
+ return LIST_ITEM.test(text) ? lines[index].number : null;
30
+ }
31
+ return null;
32
+ }
9
33
  /** 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). */
10
34
  function inputSteps(id) {
11
35
  const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
@@ -41,6 +65,11 @@ function questionProblems(id) {
41
65
  if (step && question.indent < 4) {
42
66
  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
67
  }
68
+ const anchor = immediateAnchor(splitLines(doc.text), question.line, question.indent);
69
+ if (anchor !== null && anchor !== question.parent.line) {
70
+ const above = splitLines(doc.text).find((line) => line.number === anchor)?.text.trim() ?? '';
71
+ warnings.push(`the question on line ${question.line} hangs under "${above.slice(0, 80)}", but the panel shows "${question.parent.text.slice(0, 80)}" as the text above it; indent it under that sentence or lift that sentence to a top-level bullet`);
72
+ }
44
73
  }
45
74
  for (const step of doc.steps) {
46
75
  if (step.recipes.length === 0)
@@ -62,6 +91,58 @@ function questionProblems(id) {
62
91
  }
63
92
  return warnings;
64
93
  }
94
+ /**
95
+ * Problems with the markers (`🔍 to be confirmed by QA`, `👁️ not observed`): one claim carries one signal, and a
96
+ * marker says what a person has to decide. A marker inside a question block is a failure; the old form without a
97
+ * "why" is a warning, so old cases keep working.
98
+ */
99
+ function markerProblems(id) {
100
+ const text = fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8');
101
+ const doc = parseCaseDocument(text);
102
+ const fail = [];
103
+ const warn = [];
104
+ for (const question of doc.questions) {
105
+ const end = question.hasAnswerLine ? question.answerRange.start : question.blockRange.end;
106
+ if (MARKER_WORD.test(text.slice(question.blockRange.start, end))) {
107
+ fail.push(`the question on line ${question.line} holds a marker word ("to be confirmed" / "not observed"): one claim carries one signal, so keep the question and drop the marker`);
108
+ }
109
+ }
110
+ if (doc.values?.hasBlock && MARKER_WORD.test(text.slice(doc.values.range.start, doc.values.range.end))) {
111
+ fail.push('a marker inside the "## Values" yaml block would become part of the expected value: put it on the prose line above the block');
112
+ }
113
+ const parentLines = new Set(doc.questions.map((question) => question.parent?.line).filter((line) => line !== undefined));
114
+ for (const marker of doc.markers) {
115
+ if (parentLines.has(marker.line)) {
116
+ fail.push(`line ${marker.line} carries a marker and a question under it: keep the question (drop the marker) or the marker (drop the question)`);
117
+ }
118
+ if (marker.detail === null) {
119
+ warn.push(`the marker on line ${marker.line} does not say what a person must confirm; write "🔍 to be confirmed: <the one thing to decide>"` +
120
+ (marker.evidence === null
121
+ ? ', plus "evidence: .explore/observations/<case-id>/<image>.png" when a screenshot says it better'
122
+ : ''));
123
+ }
124
+ }
125
+ return { fail, warn };
126
+ }
127
+ /**
128
+ * Problems with the `## Rules` bullets: a rule may name the step it belongs to (`Step 3:`, `Before step 3:` or
129
+ * `After step 3:`). A reference to a step the case does not have is a failure, because the rule would silently lose its
130
+ * scope. An unprefixed rule applies to the whole case and is always valid.
131
+ */
132
+ function ruleProblems(id) {
133
+ const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
134
+ const failures = [];
135
+ for (const rule of doc.rules) {
136
+ const match = /^(?:(?:before|after)[ \t]+)?step[ \t]+(\d+)\b/i.exec(rule.text);
137
+ if (!match)
138
+ continue;
139
+ const number = Number(match[1]);
140
+ if (!doc.steps.some((step) => step.number === number)) {
141
+ failures.push(`the rule on line ${rule.line} refers to step ${number}, which this case does not have`);
142
+ }
143
+ }
144
+ return failures;
145
+ }
65
146
  /**
66
147
  * Warnings for the old layout. `## Data` and `## Inputs` are deprecated: the one-sentence summary belongs in
67
148
  * `writes-note`, the cleanup in steps, and a person's recipe under its step. Cases keep working, so this never fails.
@@ -131,6 +212,8 @@ for (const id of ids) {
131
212
  console.log(`warn ${id}: ${message}`);
132
213
  for (const message of targets.fail)
133
214
  throw new Error(message);
215
+ for (const message of ruleProblems(id))
216
+ throw new Error(message);
134
217
  const { marked, recorded } = inputSteps(id);
135
218
  const missing = marked.filter((step) => !recorded.includes(step));
136
219
  if (missing.length > 0) {
@@ -140,6 +223,11 @@ for (const id of ids) {
140
223
  console.log(`warn ${id}: ${message}`);
141
224
  for (const message of questionProblems(id))
142
225
  console.log(`warn ${id}: ${message}`);
226
+ const markers = markerProblems(id);
227
+ for (const message of markers.warn)
228
+ console.log(`warn ${id}: ${message}`);
229
+ for (const message of markers.fail)
230
+ throw new Error(message);
143
231
  }
144
232
  catch (error) {
145
233
  problems++;
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { createRequire } from 'node:module';
3
4
  import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
4
5
  import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
5
6
  import { spawnTarget } from '../lib/command.js';
@@ -25,17 +26,20 @@ function read(file) {
25
26
  function nonNegotiableRules() {
26
27
  const ids = new Set();
27
28
  for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
28
- const match = /^- \*\*(F\d+)\b/.exec(line.trim());
29
+ const match = /^- \*\*([A-Z]\d+)\b/.exec(line.trim());
29
30
  if (match && line.includes('[non-negotiable]'))
30
31
  ids.add(match[1]);
31
32
  }
32
33
  return ids;
33
34
  }
34
- /** Every rule id the framework baseline defines (`F1`..`F10`), so a project cannot name one that does not exist. */
35
+ /**
36
+ * Every rule id the framework baseline defines (`F1`..`F10`, `B1`..`B4`, `A1`..`A6`, `R1`..`R4`), so a project cannot
37
+ * name one that does not exist.
38
+ */
35
39
  function frameworkRuleIds() {
36
40
  const ids = new Set();
37
41
  for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
38
- const match = /^- \*\*(F\d+)\b/.exec(line.trim());
42
+ const match = /^- \*\*([A-Z]\d+)\b/.exec(line.trim());
39
43
  if (match)
40
44
  ids.add(match[1]);
41
45
  }
@@ -70,6 +74,38 @@ function staleDistFiles() {
70
74
  walk('scripts');
71
75
  return stale;
72
76
  }
77
+ /**
78
+ * The browser tools the workspace's `playwright-test` MCP can actually run, and the Playwright version it comes from.
79
+ *
80
+ * `playwright run-test-mcp-server` advertises every browser tool Playwright has, but a call runs in a paused test whose
81
+ * backend installs only the `core` and `testing` capabilities and skips the `skillOnly` tools; anything else answers
82
+ * `Tool "<name>" not found`. The skills' `references/tools.md` documents the substitutes, so this tells a person when
83
+ * an upgrade moved a tool out of that set before an exploration discovers it. `null` when the install cannot be
84
+ * inspected (then the check says nothing rather than guessing).
85
+ */
86
+ function runnableBrowserTools() {
87
+ try {
88
+ const requireFrom = createRequire(path.join(ROOT, 'package.json'));
89
+ const testManifest = requireFrom.resolve('@playwright/test/package.json');
90
+ const playwrightManifest = requireFrom.resolve('playwright/package.json', { paths: [path.dirname(testManifest)] });
91
+ const coreManifest = requireFrom.resolve('playwright-core/package.json', { paths: [path.dirname(playwrightManifest)] });
92
+ const bundle = requireFrom(path.join(path.dirname(coreManifest), 'lib', 'coreBundle.js'));
93
+ const tools = bundle.tools?.filteredTools?.({ capabilities: ['testing'] });
94
+ if (!tools)
95
+ return null;
96
+ const version = JSON.parse(read(playwrightManifest) ?? '{}').version ?? 'unknown';
97
+ return { version, tools: tools.map((tool) => tool.schema.name) };
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ /** Every tool the skills' `tools.md` lists under `## Runnable`, i.e. the ones an exploration is told to use. */
104
+ function documentedBrowserTools() {
105
+ const text = read(path.join(SKILLS_DIR, 'exploring-cases', 'references', 'tools.md')) ?? '';
106
+ const section = /^## Runnable\n([\s\S]*?)^## /m.exec(text)?.[1] ?? '';
107
+ return [...new Set(section.match(/browser_[a-z0-9_]+/g) ?? [])];
108
+ }
73
109
  export async function main(args) {
74
110
  if (args.includes('--help')) {
75
111
  console.log('Usage: pnpm asrun doctor');
@@ -326,6 +362,17 @@ export async function main(args) {
326
362
  warnings.push(`${file} starts the MCP server with "${command}", which does not exist: run "pnpm asrun init --force ${file}".`);
327
363
  }
328
364
  }
365
+ // The browser tools the `playwright-test` MCP can really run: the skills' `tools.md` must not name one it cannot.
366
+ const runnable = runnableBrowserTools();
367
+ if (runnable !== null) {
368
+ facts.push(`playwright-test mcp: ${runnable.tools.length} runnable browser tools (Playwright ${runnable.version})`);
369
+ const missing = documentedBrowserTools().filter((tool) => !runnable.tools.includes(tool));
370
+ if (missing.length > 0) {
371
+ warnings.push(`Playwright ${runnable.version} cannot run ${missing.join(', ')} in this MCP, but ` +
372
+ '.claude/skills/exploring-cases/references/tools.md names it as runnable: the test-runner backend changed ' +
373
+ 'its capabilities. Update that file (and the substitute for each tool) before exploring.');
374
+ }
375
+ }
329
376
  const settings = read(path.join(ROOT, '.claude', 'settings.json'));
330
377
  if (settings === null) {
331
378
  warnings.push('.claude/settings.json is missing: run "pnpm asrun init" (Claude Code needs its sandbox exclusions for pnpm asrun).');
@@ -0,0 +1 @@
1
+ export {};