@asrunjs/asrun 0.9.0 → 0.11.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.
- package/.claude/commands/explore-case.md +11 -6
- package/.claude/commands/gen-spec.md +10 -6
- package/.claude/commands/heal-spec.md +13 -9
- package/.claude/commands/make-case.md +1 -1
- package/.claude/skills/exploring-cases/SKILL.md +84 -63
- package/.claude/skills/exploring-cases/references/assertion-targets.md +8 -4
- package/.claude/skills/exploring-cases/references/case-rules.md +110 -0
- package/.claude/skills/exploring-cases/references/handoff-card.js +73 -11
- package/.claude/skills/exploring-cases/references/notes.md +87 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +58 -6
- package/.claude/skills/exploring-cases/references/readiness.md +7 -5
- package/.claude/skills/exploring-cases/references/tools.md +151 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +47 -18
- package/.claude/skills/generating-specs/SKILL.md +99 -54
- package/.claude/skills/generating-specs/references/conventions.md +4 -0
- package/.claude/skills/making-cases/SKILL.md +14 -7
- package/.claude/skills/making-cases/references/gaps.md +7 -4
- package/.claude/skills/making-cases/references/normalizing.md +1 -1
- package/AGENTS.md +61 -20
- package/README.md +10 -8
- package/bin/asrun.mjs +2 -0
- package/dist/lib/case-doc.d.ts +41 -4
- package/dist/lib/case-doc.js +109 -17
- package/dist/lib/explore-notes.d.ts +74 -0
- package/dist/lib/explore-notes.js +242 -0
- package/dist/lib/paths.d.ts +2 -0
- package/dist/lib/paths.js +2 -0
- package/dist/scripts/check-cases.js +89 -1
- package/dist/scripts/cli.js +9 -2
- package/dist/scripts/doctor.js +50 -3
- package/dist/scripts/explore-notes.d.ts +1 -0
- package/dist/scripts/explore-notes.js +131 -0
- package/dist/scripts/markers-confirm.d.ts +1 -0
- package/dist/scripts/markers-confirm.js +139 -0
- package/dist/ui/server/actions.js +50 -16
- package/dist/ui/server/app.js +7 -2
- package/dist/ui/server/job-store.d.ts +60 -0
- package/dist/ui/server/job-store.js +198 -0
- package/dist/ui/server/jobs.d.ts +46 -4
- package/dist/ui/server/jobs.js +361 -59
- package/dist/ui/server/process-info.d.ts +17 -0
- package/dist/ui/server/process-info.js +126 -0
- package/dist/ui/server/result-state.d.ts +10 -0
- package/dist/ui/server/result-state.js +106 -0
- package/dist/ui/shared/ai-commands.d.ts +4 -1
- package/dist/ui/shared/ai-commands.js +14 -4
- package/dist/ui/shared/types.d.ts +17 -2
- package/docs/agents/case-format.md +40 -5
- package/docs/agents/framework-dev.md +4 -0
- package/docs/ai-workflow.md +56 -16
- package/docs/cases.md +32 -8
- package/docs/commands.md +17 -1
- package/docs/theme-guide.html +56 -0
- package/docs/troubleshooting.md +40 -0
- package/docs/web-ui.md +59 -15
- package/package.json +3 -1
- package/templates/.claude/commands/explore-case.md +11 -6
- package/templates/.claude/commands/gen-spec.md +10 -5
- package/templates/.claude/commands/heal-spec.md +11 -7
- package/templates/.claude/commands/make-case.md +1 -1
- package/templates/AGENTS.md +9 -6
- package/templates/cases/_template.md +7 -0
- package/templates/package.json +2 -0
- package/ui/dist/assets/{dist-WvvCZeeu.js → dist--BXJInTq.js} +1 -1
- package/ui/dist/assets/{dist-B7HeglDd.js → dist-BMmNFIUH.js} +1 -1
- package/ui/dist/assets/{dist-DevASegc.js → dist-BXNaYHeh.js} +1 -1
- package/ui/dist/assets/{dist-Bb36rNUw.js → dist-ByLbEOlF.js} +1 -1
- package/ui/dist/assets/{dist-Dqc_tfzC.js → dist-C9ra6_BD.js} +1 -1
- package/ui/dist/assets/{dist-DfBpjgZT.js → dist-CK8mow8u.js} +1 -1
- package/ui/dist/assets/{dist-G9KNeh6u.js → dist-CQS0So-i.js} +1 -1
- package/ui/dist/assets/{dist-GvRUDL-2.js → dist-CTrwRc3Q.js} +1 -1
- package/ui/dist/assets/{dist-B9_GXR2G.js → dist-DTYj2vUD.js} +1 -1
- package/ui/dist/assets/{dist-Dq4Vnw5N.js → dist-mV0_nQZe.js} +1 -1
- package/ui/dist/assets/index-BzMqkmQ9.js +348 -0
- package/ui/dist/assets/index-DcJR-8Mt.css +2 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BJ5D2mFK.js +0 -346
- package/ui/dist/assets/index-BXY-eKBn.css +0 -2
package/dist/lib/case-doc.d.ts
CHANGED
|
@@ -183,7 +183,7 @@ export interface CaseQuestion {
|
|
|
183
183
|
region: TextRange;
|
|
184
184
|
}
|
|
185
185
|
export type MarkerKind = 'confirm' | 'unseen';
|
|
186
|
-
/** `🔍 to be confirmed
|
|
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
|
-
*
|
|
267
|
-
* parentheses, the parentheses go too (`(👁️ not observed)`); otherwise the marker and one separator leave
|
|
268
|
-
* `(observed, 🔍 to be confirmed
|
|
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. */
|
package/dist/lib/case-doc.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
810
|
-
|
|
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
|
-
*
|
|
881
|
-
* parentheses, the parentheses go too (`(👁️ not observed)`); otherwise the marker and one separator leave
|
|
882
|
-
* `(observed, 🔍 to be confirmed
|
|
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
|
|
885
|
-
const
|
|
886
|
-
const
|
|
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,
|
|
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
|
|
970
|
+
return { start: before, end: close + 1, text: '' };
|
|
892
971
|
}
|
|
893
972
|
}
|
|
894
|
-
const lead = /(?:[ \t]*[,;][ \t]*|[ \t]+)$/.exec(text.slice(0,
|
|
973
|
+
const lead = /(?:[ \t]*[,;][ \t]*|[ \t]+)$/.exec(text.slice(0, start));
|
|
895
974
|
if (lead) {
|
|
896
|
-
return
|
|
975
|
+
return { start: start - lead[0].length, end: markerEnd, text: '' };
|
|
897
976
|
}
|
|
898
|
-
const trail = /^[ \t]+/.exec(text.slice(
|
|
899
|
-
const end = trail ?
|
|
900
|
-
return
|
|
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));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local exploration note: what `/explore-case` saw on the page, in a form `/gen-spec` can read.
|
|
3
|
+
*
|
|
4
|
+
* It lives under `.explore/notes/<case-id>/<profile-id>.json`, one file per profile, so a run with `--profiles` never
|
|
5
|
+
* has to merge with an earlier one. `.explore/` is git-ignored local state: a note is optional, may be deleted at any
|
|
6
|
+
* time, and a fresh checkout simply has none (generation then replays the case as before).
|
|
7
|
+
*
|
|
8
|
+
* A note holds only what the case does not: locators the agent confirmed on the page, the data-call paths of the
|
|
9
|
+
* actions that load something, and the match count of a target card's element. Expected values stay in the case's
|
|
10
|
+
* `## Values`, ready signals in the case's `Ready when:` lines, and assertions nowhere near this file.
|
|
11
|
+
*/
|
|
12
|
+
export declare const NOTES_VERSION = 1;
|
|
13
|
+
export interface ExploreLocator {
|
|
14
|
+
/** What the locator is for, in words ("the header navigation link"). */
|
|
15
|
+
purpose: string;
|
|
16
|
+
/** A Playwright locator as `browser_generate_locator` returned it, for example `page.getByRole('link', {name: 'Home'})`. */
|
|
17
|
+
locator: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ExploreNoteStep {
|
|
20
|
+
/** The path the step runs on, relative to `baseURL` (for example `/documents/`). */
|
|
21
|
+
url?: string;
|
|
22
|
+
locators?: ExploreLocator[];
|
|
23
|
+
/** Data-call paths the step's action starts, path only (`/api/articles`). */
|
|
24
|
+
requests?: string[];
|
|
25
|
+
}
|
|
26
|
+
export interface ExploreNoteTarget {
|
|
27
|
+
locator: string;
|
|
28
|
+
/** How many elements the locator matched when it was confirmed (1 for a target that is not a question). */
|
|
29
|
+
matches?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface ExploreNote {
|
|
32
|
+
version: typeof NOTES_VERSION;
|
|
33
|
+
caseId: string;
|
|
34
|
+
profile: string;
|
|
35
|
+
env?: string;
|
|
36
|
+
/** sha256 of `cases/<case-id>.md` when the note was written; a different one makes the note stale. */
|
|
37
|
+
caseSha256: string;
|
|
38
|
+
capturedAt: string;
|
|
39
|
+
/** Set when a later `/gen-spec` refreshed an entry it confirmed on the page. */
|
|
40
|
+
updatedAt?: string;
|
|
41
|
+
steps: Record<string, ExploreNoteStep>;
|
|
42
|
+
targets?: Record<string, ExploreNoteTarget>;
|
|
43
|
+
}
|
|
44
|
+
export interface ExploreNoteError {
|
|
45
|
+
file: string;
|
|
46
|
+
problems: string[];
|
|
47
|
+
}
|
|
48
|
+
/** The folder that holds one case's notes; `<case-id>` may contain its group folder (`documents/landing-page`). */
|
|
49
|
+
export declare function exploreNotesDir(caseId: string): string;
|
|
50
|
+
/** The note of one profile: `.explore/notes/<case-id>/<profile-id>.json`. */
|
|
51
|
+
export declare function exploreNotesFile(caseId: string, profileId: string): string;
|
|
52
|
+
/** sha256 of the case document, or null when it does not exist. */
|
|
53
|
+
export declare function caseSha256(caseId: string): string | null;
|
|
54
|
+
export type NoteFreshness = 'fresh' | 'stale' | 'no-case';
|
|
55
|
+
/**
|
|
56
|
+
* Whether a note still describes the case. A stale note is not an error: `/gen-spec` replays the case and ignores it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function noteFreshness(note: ExploreNote): {
|
|
59
|
+
state: NoteFreshness;
|
|
60
|
+
reason?: string;
|
|
61
|
+
};
|
|
62
|
+
/** Every reason the note is not usable, in plain words. An empty list means it is valid. */
|
|
63
|
+
export declare function validateExploreNote(value: unknown, expected: {
|
|
64
|
+
caseId: string;
|
|
65
|
+
profile: string;
|
|
66
|
+
}): string[];
|
|
67
|
+
/**
|
|
68
|
+
* Reads every note of a case (or one profile's), parses and validates it. A malformed file is reported, never thrown:
|
|
69
|
+
* the caller decides whether it matters, and generation treats an unreadable note as a missing one.
|
|
70
|
+
*/
|
|
71
|
+
export declare function readExploreNotes(caseId: string, profileId?: string): {
|
|
72
|
+
notes: ExploreNote[];
|
|
73
|
+
errors: ExploreNoteError[];
|
|
74
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isValidCaseId } from './case-id.js';
|
|
5
|
+
import { CASES_DIR, EXPLORE_DIR } from './paths.js';
|
|
6
|
+
/**
|
|
7
|
+
* The local exploration note: what `/explore-case` saw on the page, in a form `/gen-spec` can read.
|
|
8
|
+
*
|
|
9
|
+
* It lives under `.explore/notes/<case-id>/<profile-id>.json`, one file per profile, so a run with `--profiles` never
|
|
10
|
+
* has to merge with an earlier one. `.explore/` is git-ignored local state: a note is optional, may be deleted at any
|
|
11
|
+
* time, and a fresh checkout simply has none (generation then replays the case as before).
|
|
12
|
+
*
|
|
13
|
+
* A note holds only what the case does not: locators the agent confirmed on the page, the data-call paths of the
|
|
14
|
+
* actions that load something, and the match count of a target card's element. Expected values stay in the case's
|
|
15
|
+
* `## Values`, ready signals in the case's `Ready when:` lines, and assertions nowhere near this file.
|
|
16
|
+
*/
|
|
17
|
+
export const NOTES_VERSION = 1;
|
|
18
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
19
|
+
const PROFILE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
20
|
+
const STEP_KEY = /^[1-9]\d*$/;
|
|
21
|
+
const TARGET_KEY = /^T[1-9]\d*$/;
|
|
22
|
+
// A request pattern is a path, never a URL with a host or a query string that could carry a token.
|
|
23
|
+
const UNSAFE_REQUEST = /[?#]|:\/\//;
|
|
24
|
+
const SECRET_PARAM = /(?:^|[/&])(?:token|cookie|password|authorization|secret|signature|sig|session)=/i;
|
|
25
|
+
const MAX_LOCATOR = 400;
|
|
26
|
+
const MAX_PURPOSE = 200;
|
|
27
|
+
const MAX_REQUEST = 200;
|
|
28
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
const isOneLine = (value) => !/[\r\n]/.test(value);
|
|
30
|
+
/** The folder that holds one case's notes; `<case-id>` may contain its group folder (`documents/landing-page`). */
|
|
31
|
+
export function exploreNotesDir(caseId) {
|
|
32
|
+
if (!isValidCaseId(caseId))
|
|
33
|
+
throw new Error(`"${caseId}" is not a valid case id`);
|
|
34
|
+
return path.join(EXPLORE_DIR, 'notes', ...caseId.split('/'));
|
|
35
|
+
}
|
|
36
|
+
/** The note of one profile: `.explore/notes/<case-id>/<profile-id>.json`. */
|
|
37
|
+
export function exploreNotesFile(caseId, profileId) {
|
|
38
|
+
if (!PROFILE_FILE.test(profileId))
|
|
39
|
+
throw new Error(`"${profileId}" is not a valid profile id`);
|
|
40
|
+
return path.join(exploreNotesDir(caseId), `${profileId}.json`);
|
|
41
|
+
}
|
|
42
|
+
/** sha256 of the case document, or null when it does not exist. */
|
|
43
|
+
export function caseSha256(caseId) {
|
|
44
|
+
if (!isValidCaseId(caseId))
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
return crypto.createHash('sha256').update(fs.readFileSync(path.join(CASES_DIR, `${caseId}.md`))).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether a note still describes the case. A stale note is not an error: `/gen-spec` replays the case and ignores it.
|
|
55
|
+
*/
|
|
56
|
+
export function noteFreshness(note) {
|
|
57
|
+
const current = caseSha256(note.caseId);
|
|
58
|
+
if (current === null)
|
|
59
|
+
return { state: 'no-case', reason: 'the case file does not exist' };
|
|
60
|
+
if (current !== note.caseSha256)
|
|
61
|
+
return { state: 'stale', reason: 'the case file changed after the note was written' };
|
|
62
|
+
return { state: 'fresh' };
|
|
63
|
+
}
|
|
64
|
+
function checkLocator(value, where, problems) {
|
|
65
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
66
|
+
problems.push(`${where} must be a locator string`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!isOneLine(value))
|
|
70
|
+
problems.push(`${where} must be one line`);
|
|
71
|
+
if (value.length > MAX_LOCATOR)
|
|
72
|
+
problems.push(`${where} is longer than ${MAX_LOCATOR} characters`);
|
|
73
|
+
if (!value.startsWith('page.'))
|
|
74
|
+
problems.push(`${where} must start with "page." (use browser_generate_locator)`);
|
|
75
|
+
if (value.includes('=>'))
|
|
76
|
+
problems.push(`${where} must be a locator, not code`);
|
|
77
|
+
}
|
|
78
|
+
function checkRequest(value, where, problems) {
|
|
79
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
80
|
+
problems.push(`${where} must be a request path`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!isOneLine(value))
|
|
84
|
+
problems.push(`${where} must be one line`);
|
|
85
|
+
if (value.length > MAX_REQUEST)
|
|
86
|
+
problems.push(`${where} is longer than ${MAX_REQUEST} characters`);
|
|
87
|
+
if (!value.startsWith('/') || value.startsWith('//'))
|
|
88
|
+
problems.push(`${where} must start with "/" (a path, not a URL)`);
|
|
89
|
+
if (UNSAFE_REQUEST.test(value))
|
|
90
|
+
problems.push(`${where} must have no host, query string or fragment`);
|
|
91
|
+
if (SECRET_PARAM.test(value))
|
|
92
|
+
problems.push(`${where} must not carry a token, cookie or session`);
|
|
93
|
+
}
|
|
94
|
+
function checkPurpose(value, where, problems) {
|
|
95
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
96
|
+
problems.push(`${where} must be a short description`);
|
|
97
|
+
else if (!isOneLine(value))
|
|
98
|
+
problems.push(`${where} must be one line`);
|
|
99
|
+
else if (value.length > MAX_PURPOSE)
|
|
100
|
+
problems.push(`${where} is longer than ${MAX_PURPOSE} characters`);
|
|
101
|
+
}
|
|
102
|
+
function checkSteps(value, problems) {
|
|
103
|
+
if (!isRecord(value)) {
|
|
104
|
+
problems.push('steps must be an object keyed by step number');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const [step, body] of Object.entries(value)) {
|
|
108
|
+
const where = `steps["${step}"]`;
|
|
109
|
+
if (!STEP_KEY.test(step))
|
|
110
|
+
problems.push(`${where}: the key must be a positive step number`);
|
|
111
|
+
if (!isRecord(body)) {
|
|
112
|
+
problems.push(`${where} must be an object`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (body.url !== undefined) {
|
|
116
|
+
const url = body.url;
|
|
117
|
+
if (typeof url !== 'string' || !url.startsWith('/'))
|
|
118
|
+
problems.push(`${where}.url must start with "/"`);
|
|
119
|
+
else if (UNSAFE_REQUEST.test(url))
|
|
120
|
+
problems.push(`${where}.url must have no host, query string or fragment`);
|
|
121
|
+
}
|
|
122
|
+
if (body.locators !== undefined) {
|
|
123
|
+
if (!Array.isArray(body.locators))
|
|
124
|
+
problems.push(`${where}.locators must be a list`);
|
|
125
|
+
else {
|
|
126
|
+
for (const [index, locator] of body.locators.entries()) {
|
|
127
|
+
const item = `${where}.locators[${index}]`;
|
|
128
|
+
if (!isRecord(locator)) {
|
|
129
|
+
problems.push(`${item} must be an object with "purpose" and "locator"`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
checkPurpose(locator.purpose, `${item}.purpose`, problems);
|
|
133
|
+
checkLocator(locator.locator, `${item}.locator`, problems);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (body.requests !== undefined) {
|
|
138
|
+
if (!Array.isArray(body.requests))
|
|
139
|
+
problems.push(`${where}.requests must be a list`);
|
|
140
|
+
else
|
|
141
|
+
body.requests.forEach((request, index) => checkRequest(request, `${where}.requests[${index}]`, problems));
|
|
142
|
+
}
|
|
143
|
+
const known = new Set(['url', 'locators', 'requests']);
|
|
144
|
+
for (const key of Object.keys(body))
|
|
145
|
+
if (!known.has(key))
|
|
146
|
+
problems.push(`${where}.${key} is not a known field`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function checkTargets(value, problems) {
|
|
150
|
+
if (!isRecord(value)) {
|
|
151
|
+
problems.push('targets must be an object keyed by target id');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const [id, target] of Object.entries(value)) {
|
|
155
|
+
const where = `targets["${id}"]`;
|
|
156
|
+
if (!TARGET_KEY.test(id))
|
|
157
|
+
problems.push(`${where}: the key must be a target id such as "T1"`);
|
|
158
|
+
if (!isRecord(target)) {
|
|
159
|
+
problems.push(`${where} must be an object`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
checkLocator(target.locator, `${where}.locator`, problems);
|
|
163
|
+
if (target.matches !== undefined && (!Number.isInteger(target.matches) || target.matches < 1)) {
|
|
164
|
+
problems.push(`${where}.matches must be a positive whole number`);
|
|
165
|
+
}
|
|
166
|
+
const known = new Set(['locator', 'matches']);
|
|
167
|
+
for (const key of Object.keys(target))
|
|
168
|
+
if (!known.has(key))
|
|
169
|
+
problems.push(`${where}.${key} is not a known field`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Every reason the note is not usable, in plain words. An empty list means it is valid. */
|
|
173
|
+
export function validateExploreNote(value, expected) {
|
|
174
|
+
const problems = [];
|
|
175
|
+
if (!isRecord(value))
|
|
176
|
+
return ['the note is not a JSON object'];
|
|
177
|
+
if (value.version !== NOTES_VERSION)
|
|
178
|
+
problems.push(`version must be ${NOTES_VERSION}`);
|
|
179
|
+
if (value.caseId !== expected.caseId)
|
|
180
|
+
problems.push(`caseId must be "${expected.caseId}"`);
|
|
181
|
+
if (value.profile !== expected.profile)
|
|
182
|
+
problems.push(`profile must be "${expected.profile}"`);
|
|
183
|
+
if (typeof value.profile !== 'string' || !PROFILE_FILE.test(value.profile)) {
|
|
184
|
+
problems.push('profile must be a profile id (letters, digits, ".", "_" and "-")');
|
|
185
|
+
}
|
|
186
|
+
if (typeof value.caseSha256 !== 'string' || !HASH.test(value.caseSha256)) {
|
|
187
|
+
problems.push('caseSha256 must be the sha256 of the case file (run "explore:notes <case-id> --hash")');
|
|
188
|
+
}
|
|
189
|
+
if (typeof value.capturedAt !== 'string' || value.capturedAt.trim() === '') {
|
|
190
|
+
problems.push('capturedAt must be an ISO date-time');
|
|
191
|
+
}
|
|
192
|
+
if (value.updatedAt !== undefined && typeof value.updatedAt !== 'string')
|
|
193
|
+
problems.push('updatedAt must be a string');
|
|
194
|
+
if (value.env !== undefined && (typeof value.env !== 'string' || value.env.trim() === '')) {
|
|
195
|
+
problems.push('env must be a non-empty string when it is present');
|
|
196
|
+
}
|
|
197
|
+
checkSteps(value.steps, problems);
|
|
198
|
+
if (value.targets !== undefined)
|
|
199
|
+
checkTargets(value.targets, problems);
|
|
200
|
+
const known = new Set(['version', 'caseId', 'profile', 'env', 'caseSha256', 'capturedAt', 'updatedAt', 'steps', 'targets']);
|
|
201
|
+
for (const key of Object.keys(value))
|
|
202
|
+
if (!known.has(key))
|
|
203
|
+
problems.push(`${key} is not a known field`);
|
|
204
|
+
return problems;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Reads every note of a case (or one profile's), parses and validates it. A malformed file is reported, never thrown:
|
|
208
|
+
* the caller decides whether it matters, and generation treats an unreadable note as a missing one.
|
|
209
|
+
*/
|
|
210
|
+
export function readExploreNotes(caseId, profileId) {
|
|
211
|
+
const errors = [];
|
|
212
|
+
const notes = [];
|
|
213
|
+
if (!isValidCaseId(caseId))
|
|
214
|
+
return { notes, errors: [{ file: caseId, problems: ['not a valid case id'] }] };
|
|
215
|
+
let files;
|
|
216
|
+
try {
|
|
217
|
+
files = fs.readdirSync(exploreNotesDir(caseId)).filter((file) => file.endsWith('.json'));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return { notes, errors };
|
|
221
|
+
}
|
|
222
|
+
for (const file of files.sort()) {
|
|
223
|
+
const profile = path.basename(file, '.json');
|
|
224
|
+
if (profileId !== undefined && profile !== profileId)
|
|
225
|
+
continue;
|
|
226
|
+
const full = path.join(exploreNotesDir(caseId), file);
|
|
227
|
+
let parsed;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
errors.push({ file: full, problems: [`not valid JSON: ${error.message}`] });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const problems = validateExploreNote(parsed, { caseId, profile });
|
|
236
|
+
if (problems.length > 0)
|
|
237
|
+
errors.push({ file: full, problems });
|
|
238
|
+
else
|
|
239
|
+
notes.push(parsed);
|
|
240
|
+
}
|
|
241
|
+
return { notes, errors };
|
|
242
|
+
}
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export declare const CASES_DIR: string;
|
|
|
25
25
|
export declare const TESTS_DIR: string;
|
|
26
26
|
export declare const REPORTS_DIR: string;
|
|
27
27
|
export declare const TMP_DIR: string;
|
|
28
|
+
/** Local agent output (reference screenshots, exploration notes), git-ignored and safe to delete. */
|
|
29
|
+
export declare const EXPLORE_DIR: string;
|
|
28
30
|
export declare const TEMPLATES_DIR: string;
|
|
29
31
|
export declare const SKILLS_DIR: string;
|
|
30
32
|
/**
|
package/dist/lib/paths.js
CHANGED
|
@@ -82,6 +82,8 @@ export const CASES_DIR = path.join(ROOT, 'cases');
|
|
|
82
82
|
export const TESTS_DIR = path.join(ROOT, 'tests');
|
|
83
83
|
export const REPORTS_DIR = path.join(ROOT, 'reports');
|
|
84
84
|
export const TMP_DIR = path.join(ROOT, '.tmp');
|
|
85
|
+
/** Local agent output (reference screenshots, exploration notes), git-ignored and safe to delete. */
|
|
86
|
+
export const EXPLORE_DIR = path.join(ROOT, '.explore');
|
|
85
87
|
export const TEMPLATES_DIR = path.join(FRAMEWORK_ROOT, 'templates');
|
|
86
88
|
export const SKILLS_DIR = path.join(FRAMEWORK_ROOT, '.claude', 'skills');
|
|
87
89
|
/**
|