@hover-dev/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +59 -0
  3. package/dist/agents/argv.d.ts +11 -0
  4. package/dist/agents/argv.d.ts.map +1 -0
  5. package/dist/agents/argv.js +23 -0
  6. package/dist/agents/claude.d.ts +3 -0
  7. package/dist/agents/claude.d.ts.map +1 -0
  8. package/dist/agents/claude.js +145 -0
  9. package/dist/agents/detect.d.ts +16 -0
  10. package/dist/agents/detect.d.ts.map +1 -0
  11. package/dist/agents/detect.js +34 -0
  12. package/dist/agents/index.d.ts +6 -0
  13. package/dist/agents/index.d.ts.map +1 -0
  14. package/dist/agents/index.js +5 -0
  15. package/dist/agents/invoke.d.ts +10 -0
  16. package/dist/agents/invoke.d.ts.map +1 -0
  17. package/dist/agents/invoke.js +70 -0
  18. package/dist/agents/registry.d.ts +12 -0
  19. package/dist/agents/registry.d.ts.map +1 -0
  20. package/dist/agents/registry.js +15 -0
  21. package/dist/agents/types.d.ts +88 -0
  22. package/dist/agents/types.d.ts.map +1 -0
  23. package/dist/agents/types.js +23 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +2 -0
  27. package/dist/playwright/cdpStatus.d.ts +29 -0
  28. package/dist/playwright/cdpStatus.d.ts.map +1 -0
  29. package/dist/playwright/cdpStatus.js +96 -0
  30. package/dist/playwright/launchChrome.d.ts +29 -0
  31. package/dist/playwright/launchChrome.d.ts.map +1 -0
  32. package/dist/playwright/launchChrome.js +137 -0
  33. package/dist/playwright/preflight.d.ts +31 -0
  34. package/dist/playwright/preflight.d.ts.map +1 -0
  35. package/dist/playwright/preflight.js +71 -0
  36. package/dist/scripts/start-chrome.d.ts +3 -0
  37. package/dist/scripts/start-chrome.d.ts.map +1 -0
  38. package/dist/scripts/start-chrome.js +23 -0
  39. package/dist/service.d.ts +22 -0
  40. package/dist/service.d.ts.map +1 -0
  41. package/dist/service.js +485 -0
  42. package/dist/skills/writeSkill.d.ts +50 -0
  43. package/dist/skills/writeSkill.d.ts.map +1 -0
  44. package/dist/skills/writeSkill.js +169 -0
  45. package/dist/specs/humanSteps.d.ts +25 -0
  46. package/dist/specs/humanSteps.d.ts.map +1 -0
  47. package/dist/specs/humanSteps.js +97 -0
  48. package/dist/specs/writeCaseCsv.d.ts +28 -0
  49. package/dist/specs/writeCaseCsv.d.ts.map +1 -0
  50. package/dist/specs/writeCaseCsv.js +140 -0
  51. package/dist/specs/writeSpec.d.ts +27 -0
  52. package/dist/specs/writeSpec.d.ts.map +1 -0
  53. package/dist/specs/writeSpec.js +265 -0
  54. package/mcp.config.json +12 -0
  55. package/package.json +78 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Save a completed Hover session as a Claude Code skill.
3
+ *
4
+ * Writes a SKILL.md under `<devRoot>/.claude/skills/<slug>/`. When the agent
5
+ * is later spawned with `cwd: devRoot`, Claude Code auto-discovers the skill
6
+ * and can replay it when the user describes the same task in natural language
7
+ * (e.g. "run the login-and-add-todo skill").
8
+ *
9
+ * Two reasons this is just-good-enough for v1:
10
+ * - The exact tool calls (with args) become numbered steps the agent can
11
+ * replay literally. Same dev server, same selectors → same outcome.
12
+ * - The original user prompt + AI outcome are preserved as prose. If the
13
+ * page changed and the literal selectors no longer apply, the agent has
14
+ * enough context to adapt rather than fail.
15
+ */
16
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
17
+ import { existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ export class SkillExistsError extends Error {
20
+ slug;
21
+ path;
22
+ constructor(slug, path) {
23
+ super(`Skill "${slug}" already exists at ${path}`);
24
+ this.slug = slug;
25
+ this.path = path;
26
+ this.name = 'SkillExistsError';
27
+ }
28
+ }
29
+ export async function writeSkill(opts) {
30
+ const slug = slugify(opts.name);
31
+ if (!slug) {
32
+ throw new Error('skill name must contain at least one alphanumeric character');
33
+ }
34
+ if (!opts.steps.some(s => s.kind === 'step')) {
35
+ throw new Error('skill must contain at least one tool_use step to replay');
36
+ }
37
+ const dir = join(opts.devRoot, '.claude', 'skills', slug);
38
+ const path = join(dir, 'SKILL.md');
39
+ if (!opts.overwrite && existsSync(path)) {
40
+ throw new SkillExistsError(slug, path);
41
+ }
42
+ await mkdir(dir, { recursive: true });
43
+ const md = renderSkill(slug, opts.description ?? '', opts.steps);
44
+ await writeFile(path, md, 'utf-8');
45
+ return { path, slug };
46
+ }
47
+ function slugify(name) {
48
+ return name
49
+ .toLowerCase()
50
+ .trim()
51
+ .replace(/[^a-z0-9]+/g, '-')
52
+ .replace(/^-+|-+$/g, '');
53
+ }
54
+ function renderSkill(slug, description, steps) {
55
+ const userMsg = steps.find(s => s.kind === 'user');
56
+ const doneMsg = [...steps].reverse().find(s => s.kind === 'done');
57
+ const toolSteps = steps.filter(s => s.kind === 'step');
58
+ const out = [];
59
+ out.push('---');
60
+ out.push(`name: ${slug}`);
61
+ // YAML description — quote if it contains anything that could confuse the parser
62
+ out.push(`description: ${yamlString(description || slug)}`);
63
+ out.push('---');
64
+ out.push('');
65
+ if (userMsg?.text) {
66
+ out.push('## Original intent');
67
+ out.push('');
68
+ out.push(blockquote(userMsg.text));
69
+ out.push('');
70
+ }
71
+ out.push('## Replay steps');
72
+ out.push('');
73
+ out.push('Replay these steps using the `mcp__playwright` tools, in order. ' +
74
+ 'If a literal selector id (e.g. `e15`) no longer matches, interpret the ' +
75
+ 'natural-language element description instead — selector ids regenerate on every snapshot.');
76
+ out.push('');
77
+ out.push('Do not narrate each step, do not summarize at the end. Hover surfaces ' +
78
+ 'tool calls + the final result to the user automatically — extra commentary is noise.');
79
+ out.push('');
80
+ toolSteps.forEach((step, i) => {
81
+ const tool = step.tool ?? '(unknown)';
82
+ const inputStr = JSON.stringify(step.input ?? {});
83
+ const truncated = inputStr.length > 240 ? inputStr.slice(0, 237) + '…' : inputStr;
84
+ out.push(`${i + 1}. \`${tool}\` — \`${truncated}\``);
85
+ });
86
+ out.push('');
87
+ if (doneMsg?.summary) {
88
+ out.push('## Original outcome');
89
+ out.push('');
90
+ out.push(doneMsg.summary.trim());
91
+ out.push('');
92
+ }
93
+ return out.join('\n');
94
+ }
95
+ function yamlString(s) {
96
+ // Cheap quoting — if it has YAML-significant chars, double-quote and escape.
97
+ if (/[:#\n"'\\]/.test(s)) {
98
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
99
+ }
100
+ return s;
101
+ }
102
+ function blockquote(s) {
103
+ return s
104
+ .split('\n')
105
+ .map(line => `> ${line}`)
106
+ .join('\n');
107
+ }
108
+ /**
109
+ * List skills under <devRoot>/.claude/skills/, reading the YAML frontmatter
110
+ * of each SKILL.md for `name` and `description`. Malformed entries are
111
+ * silently skipped — better to show 9 valid skills than refuse to render
112
+ * because one is broken. Hand-edited skills are first-class.
113
+ */
114
+ export async function listSkills(devRoot) {
115
+ const root = join(devRoot, '.claude', 'skills');
116
+ let entries;
117
+ try {
118
+ entries = await readdir(root);
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ const skills = [];
124
+ for (const slug of entries) {
125
+ if (slug.startsWith('.'))
126
+ continue;
127
+ const path = join(root, slug, 'SKILL.md');
128
+ let content;
129
+ try {
130
+ content = await readFile(path, 'utf-8');
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ const fm = parseFrontmatter(content);
136
+ skills.push({
137
+ slug,
138
+ name: fm.name ?? slug,
139
+ description: fm.description ?? '',
140
+ path,
141
+ });
142
+ }
143
+ // Sort newest-first by mtime would require fs.stat; alpha is fine and stable.
144
+ skills.sort((a, b) => a.slug.localeCompare(b.slug));
145
+ return skills;
146
+ }
147
+ function parseFrontmatter(content) {
148
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
149
+ if (!match)
150
+ return {};
151
+ const out = {};
152
+ for (const line of match[1].split('\n')) {
153
+ const m = line.match(/^(\w+)\s*:\s*(.+)$/);
154
+ if (!m)
155
+ continue;
156
+ let value = m[2].trim();
157
+ // Strip wrapping quotes if present (yamlString() in renderSkill quotes
158
+ // strings with YAML-significant chars).
159
+ if ((value.startsWith('"') && value.endsWith('"')) ||
160
+ (value.startsWith("'") && value.endsWith("'"))) {
161
+ value = value
162
+ .slice(1, -1)
163
+ .replace(/\\"/g, '"')
164
+ .replace(/\\\\/g, '\\');
165
+ }
166
+ out[m[1]] = value;
167
+ }
168
+ return out;
169
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Translate the captured `browser_*` tool calls into plain English.
3
+ *
4
+ * Used by:
5
+ * - writeSpec.ts — to enrich the generated `.spec.ts` JSDoc with a
6
+ * numbered "Steps:" block that QA / PMs can read without grokking
7
+ * `getByRole(...)`.
8
+ * - writeCaseCsv.ts — to populate the Step column of an
9
+ * Xray-compatible test case CSV, so the same prose travels into
10
+ * Jira / Xray / Zephyr.
11
+ *
12
+ * Mirrors the tool dispatch table in writeSpec.ts:translateStep — when
13
+ * a new replayable browser action is added there, add it here too.
14
+ */
15
+ import type { SkillStep } from '../skills/writeSkill.js';
16
+ /** A single human-readable line for one tool call, or null to skip. */
17
+ export declare function humanStep(tool: string, rawInput: unknown): string | null;
18
+ /**
19
+ * Walk a captured session's step events and return a flat list of
20
+ * human-readable lines, with consecutive identical sentences collapsed
21
+ * into "<sentence> (× N)". Empty array if the session had no replayable
22
+ * tool calls (only diagnostics / text / done).
23
+ */
24
+ export declare function humanSteps(steps: SkillStep[]): string[];
25
+ //# sourceMappingURL=humanSteps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"humanSteps.d.ts","sourceRoot":"","sources":["../../src/specs/humanSteps.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD,uEAAuE;AACvE,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAwDxE;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,CAmBvD"}
@@ -0,0 +1,97 @@
1
+ /** A single human-readable line for one tool call, or null to skip. */
2
+ export function humanStep(tool, rawInput) {
3
+ const input = (rawInput ?? {});
4
+ switch (tool) {
5
+ case 'browser_navigate': {
6
+ const url = String(input.url ?? '').trim();
7
+ return url ? `Open ${url}` : null;
8
+ }
9
+ case 'browser_click':
10
+ return `Click ${describe(input.element)}`;
11
+ case 'browser_double_click':
12
+ return `Double-click ${describe(input.element)}`;
13
+ case 'browser_hover':
14
+ return `Hover over ${describe(input.element)}`;
15
+ case 'browser_type': {
16
+ const text = String(input.text ?? '');
17
+ return `Type ${quote(text)} into ${describe(input.element)}`;
18
+ }
19
+ case 'browser_fill_form': {
20
+ const fields = input.fields ?? [];
21
+ if (fields.length === 0)
22
+ return null;
23
+ // Join multi-field fills into one sentence to keep the Steps block
24
+ // compact on long forms. Per-field bullets would balloon a 7-step
25
+ // flow into 30 lines.
26
+ const parts = fields.map(raw => {
27
+ const f = raw;
28
+ const target = f.name ?? f.element ?? 'field';
29
+ return `${target}=${quote(String(f.value ?? ''))}`;
30
+ });
31
+ return `Fill ${parts.join(', ')}`;
32
+ }
33
+ case 'browser_select_option': {
34
+ const target = describe(input.element);
35
+ const values = input.values;
36
+ const val = (values && values.length > 0 ? values[0] : input.value) ?? '';
37
+ return `Select ${quote(String(val))} in ${target}`;
38
+ }
39
+ case 'browser_press_key': {
40
+ const key = String(input.key ?? '');
41
+ return key ? `Press ${key}` : null;
42
+ }
43
+ // Diagnostic / read-only — same skip list as writeSpec.translateStep.
44
+ case 'browser_wait_for':
45
+ case 'browser_tabs':
46
+ case 'browser_snapshot':
47
+ case 'browser_take_screenshot':
48
+ case 'browser_resize':
49
+ case 'browser_evaluate':
50
+ case 'browser_console_messages':
51
+ case 'browser_network_requests':
52
+ return null;
53
+ default:
54
+ // Unknown tools shouldn't pollute the prose; the spec emitter
55
+ // already drops a TODO comment in the code for these, that's
56
+ // enough signal for the developer.
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * Walk a captured session's step events and return a flat list of
62
+ * human-readable lines, with consecutive identical sentences collapsed
63
+ * into "<sentence> (× N)". Empty array if the session had no replayable
64
+ * tool calls (only diagnostics / text / done).
65
+ */
66
+ export function humanSteps(steps) {
67
+ const out = [];
68
+ let lastSentence = null;
69
+ let repeatCount = 0;
70
+ for (const s of steps) {
71
+ if (s.kind !== 'step' || !s.tool)
72
+ continue;
73
+ const sentence = humanStep(s.tool, s.input);
74
+ if (sentence == null)
75
+ continue;
76
+ if (sentence === lastSentence) {
77
+ repeatCount += 1;
78
+ // Re-write the previous line with an incremented multiplier.
79
+ out[out.length - 1] = `${sentence} (× ${repeatCount + 1})`;
80
+ }
81
+ else {
82
+ out.push(sentence);
83
+ lastSentence = sentence;
84
+ repeatCount = 0;
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+ // ───────── helpers ─────────
90
+ function describe(raw) {
91
+ const s = String(raw ?? '').trim();
92
+ return s.length > 0 ? s : 'the target element';
93
+ }
94
+ /** Wrap in double-quotes for prose; escape internal quotes. */
95
+ function quote(s) {
96
+ return `"${s.replace(/"/g, '\\"')}"`;
97
+ }
@@ -0,0 +1,28 @@
1
+ import type { SkillStep } from '../skills/writeSkill.js';
2
+ import type { SpecAssertion } from './writeSpec.js';
3
+ export declare class CaseCsvExistsError extends Error {
4
+ readonly slug: string;
5
+ readonly path: string;
6
+ constructor(slug: string, path: string);
7
+ }
8
+ export interface WriteCaseCsvOptions {
9
+ devRoot: string;
10
+ name: string;
11
+ description?: string;
12
+ steps: SkillStep[];
13
+ assertions?: SpecAssertion[];
14
+ /** Optional Jira project key prefix (e.g. "PROJ"). Goes into Labels so
15
+ * the importer can route the test cases without rewriting the CSV.
16
+ * Stripped of whitespace; if empty, no project label is added. */
17
+ jiraProjectKey?: string;
18
+ /** Free-form labels appended after the default "hover" label. Split
19
+ * on commas/whitespace and lowercased. */
20
+ labels?: string;
21
+ overwrite?: boolean;
22
+ }
23
+ export interface WriteCaseCsvResult {
24
+ path: string;
25
+ slug: string;
26
+ }
27
+ export declare function writeCaseCsv(opts: WriteCaseCsvOptions): Promise<WriteCaseCsvResult>;
28
+ //# sourceMappingURL=writeCaseCsv.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"writeCaseCsv.d.ts","sourceRoot":"","sources":["../../src/specs/writeCaseCsv.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpD,qBAAa,kBAAmB,SAAQ,KAAK;aACf,IAAI,EAAE,MAAM;aAAkB,IAAI,EAAE,MAAM;gBAA1C,IAAI,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAIvE;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,UAAU,CAAC,EAAE,aAAa,EAAE,CAAC;IAC7B;;uEAEmE;IACnE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;+CAC2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;CAAE;AAEnE,wBAAsB,YAAY,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAiBzF"}
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Save a completed Hover session as an Xray-compatible test case CSV
3
+ * (one file per session, multi-row layout — one row per replayable step).
4
+ *
5
+ * Target: Atlassian Marketplace's #1 test management for Jira (Xray,
6
+ * ~10M users, ~100M test cases / month). The same CSV imports cleanly
7
+ * into Zephyr Scale and the original Jira issue importer with minor
8
+ * column mapping, so this is the broadest single-format hand-off into
9
+ * a team's test management.
10
+ *
11
+ * Schema (Xray Test Case Importer — multi-row layout):
12
+ *
13
+ * Issue Id unique grouping key, repeated on every row of the
14
+ * same test case. We use the slug.
15
+ * Summary the test case title; set on the FIRST row only.
16
+ * Test Type "Manual" for everything Hover emits.
17
+ * Priority "Medium" by default; PMs can edit post-import.
18
+ * Labels space-separated; "hover" plus the user-supplied set.
19
+ * Action one human-readable imperative per row. Reuses the
20
+ * humanSteps helper that also feeds the .spec.ts JSDoc.
21
+ * Expected Result attached to the LAST row of the case. Carries
22
+ * assertion hints if present, else the agent's
23
+ * done-summary first sentence.
24
+ *
25
+ * The "Issue Id" column is what tells Xray's importer that consecutive
26
+ * rows belong to the same test, even though only the first row has a
27
+ * Summary. The "Test Type" column tells it to instantiate the Manual
28
+ * Test issue type and use the Step / Expected Result fields.
29
+ */
30
+ import { mkdir, writeFile } from 'node:fs/promises';
31
+ import { existsSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { humanSteps } from './humanSteps.js';
34
+ export class CaseCsvExistsError extends Error {
35
+ slug;
36
+ path;
37
+ constructor(slug, path) {
38
+ super(`Test case CSV "${slug}" already exists at ${path}`);
39
+ this.slug = slug;
40
+ this.path = path;
41
+ this.name = 'CaseCsvExistsError';
42
+ }
43
+ }
44
+ export async function writeCaseCsv(opts) {
45
+ const slug = slugify(opts.name);
46
+ if (!slug)
47
+ throw new Error('case name must contain at least one alphanumeric character');
48
+ if (!opts.steps.some(s => s.kind === 'step')) {
49
+ throw new Error('case must contain at least one tool step to describe');
50
+ }
51
+ const dir = join(opts.devRoot, '__vibe_tests__');
52
+ const path = join(dir, `${slug}.case.csv`);
53
+ if (!opts.overwrite && existsSync(path)) {
54
+ throw new CaseCsvExistsError(slug, path);
55
+ }
56
+ await mkdir(dir, { recursive: true });
57
+ const csv = renderCsv(slug, opts);
58
+ await writeFile(path, csv, 'utf-8');
59
+ return { path, slug };
60
+ }
61
+ // ───────── helpers ─────────
62
+ function slugify(name) {
63
+ return name
64
+ .toLowerCase()
65
+ .trim()
66
+ .replace(/[^a-z0-9]+/g, '-')
67
+ .replace(/^-+|-+$/g, '');
68
+ }
69
+ function renderCsv(slug, opts) {
70
+ const rows = buildRows(slug, opts);
71
+ // CRLF row terminator — what Excel / Numbers / Xray's importer all
72
+ // assume by default. Comma column delimiter, fields with commas or
73
+ // newlines get wrapped in double-quotes (escaped by doubling).
74
+ const header = ['Issue Id', 'Summary', 'Test Type', 'Priority', 'Labels', 'Action', 'Expected Result'];
75
+ const lines = [header.map(escapeField).join(',')];
76
+ for (const r of rows)
77
+ lines.push(r.map(escapeField).join(','));
78
+ return lines.join('\r\n') + '\r\n';
79
+ }
80
+ function buildRows(slug, opts) {
81
+ const actions = humanSteps(opts.steps);
82
+ const summary = opts.description?.trim() || opts.name;
83
+ const expectedTail = expectedFor(opts.assertions ?? [], opts.steps);
84
+ const labels = buildLabels(opts.jiraProjectKey, opts.labels);
85
+ // Multi-row layout: one row per Action. First row carries the
86
+ // test-case-level fields (Summary, Test Type, Priority, Labels); the
87
+ // rest carry only the Issue Id + Action so Xray groups them.
88
+ if (actions.length === 0) {
89
+ // Defensive — writeCaseCsv() already throws on no replayable steps,
90
+ // but keep a single-row fallback so the file is still well-formed.
91
+ return [[slug, summary, 'Manual', 'Medium', labels, '(no replayable steps were captured)', expectedTail]];
92
+ }
93
+ const rows = [];
94
+ actions.forEach((action, i) => {
95
+ const isFirst = i === 0;
96
+ const isLast = i === actions.length - 1;
97
+ rows.push([
98
+ slug,
99
+ isFirst ? summary : '',
100
+ isFirst ? 'Manual' : '',
101
+ isFirst ? 'Medium' : '',
102
+ isFirst ? labels : '',
103
+ action,
104
+ isLast ? expectedTail : '',
105
+ ]);
106
+ });
107
+ return rows;
108
+ }
109
+ function expectedFor(assertions, steps) {
110
+ if (assertions.length > 0) {
111
+ return assertions.map(a => `• ${a.hint ?? a.code}`).join('\n');
112
+ }
113
+ const done = [...steps].reverse().find(s => s.kind === 'done');
114
+ if (done?.summary) {
115
+ return done.summary.split(/(?<=[.!?])\s+/)[0]?.trim() ?? done.summary.trim();
116
+ }
117
+ return '';
118
+ }
119
+ function buildLabels(jiraProjectKey, labels) {
120
+ const set = new Set(['hover']);
121
+ if (jiraProjectKey?.trim())
122
+ set.add(jiraProjectKey.trim().toLowerCase());
123
+ if (labels?.trim()) {
124
+ labels.split(/[\s,]+/).filter(Boolean).forEach(l => set.add(l.toLowerCase()));
125
+ }
126
+ // Xray and Jira both accept space-separated labels in a single cell.
127
+ return [...set].join(' ');
128
+ }
129
+ /**
130
+ * RFC 4180 escaping: any field containing a quote, comma, CR, or LF
131
+ * gets wrapped in double-quotes; embedded quotes are doubled.
132
+ */
133
+ function escapeField(value) {
134
+ if (value === '')
135
+ return '';
136
+ if (/[",\r\n]/.test(value)) {
137
+ return `"${value.replace(/"/g, '""')}"`;
138
+ }
139
+ return value;
140
+ }
@@ -0,0 +1,27 @@
1
+ import type { SkillStep } from '../skills/writeSkill.js';
2
+ export type SpecStep = SkillStep;
3
+ export interface SpecAssertion {
4
+ /** Generated Playwright code (single line, no leading "await "). */
5
+ code: string;
6
+ /** Short human description for the spec comments. */
7
+ hint?: string;
8
+ }
9
+ export declare class SpecExistsError extends Error {
10
+ readonly slug: string;
11
+ readonly path: string;
12
+ constructor(slug: string, path: string);
13
+ }
14
+ export interface WriteSpecOptions {
15
+ devRoot: string;
16
+ name: string;
17
+ description?: string;
18
+ steps: SpecStep[];
19
+ assertions?: SpecAssertion[];
20
+ overwrite?: boolean;
21
+ }
22
+ export interface WriteSpecResult {
23
+ path: string;
24
+ slug: string;
25
+ }
26
+ export declare function writeSpec(opts: WriteSpecOptions): Promise<WriteSpecResult>;
27
+ //# sourceMappingURL=writeSpec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"writeSpec.d.ts","sourceRoot":"","sources":["../../src/specs/writeSpec.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAGzD,MAAM,MAAM,QAAQ,GAAG,SAAS,CAAC;AAEjC,MAAM,WAAW,aAAa;IAC5B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,eAAgB,SAAQ,KAAK;aACZ,IAAI,EAAE,MAAM;aAAkB,IAAI,EAAE,MAAM;gBAA1C,IAAI,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAIvE;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,UAAU,CAAC,EAAE,aAAa,EAAE,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;CAAE;AAEhE,wBAAsB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAkBhF"}