@hover-dev/core 0.14.1 → 0.15.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 (65) hide show
  1. package/README.md +73 -1
  2. package/dist/agents/claude.d.ts.map +1 -1
  3. package/dist/agents/claude.js +14 -0
  4. package/dist/agents/codex.d.ts.map +1 -1
  5. package/dist/agents/codex.js +1 -0
  6. package/dist/agents/invoke.d.ts.map +1 -1
  7. package/dist/agents/invoke.js +10 -1
  8. package/dist/agents/types.d.ts +11 -0
  9. package/dist/agents/types.d.ts.map +1 -1
  10. package/dist/playwright/resolveMcpConfig.d.ts +5 -0
  11. package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
  12. package/dist/playwright/resolveMcpConfig.js +2 -1
  13. package/dist/runSession.d.ts +42 -0
  14. package/dist/runSession.d.ts.map +1 -0
  15. package/dist/runSession.js +76 -0
  16. package/dist/service/cdpHint.d.ts.map +1 -1
  17. package/dist/service/cdpHint.js +30 -14
  18. package/dist/service/conventions.d.ts +8 -0
  19. package/dist/service/conventions.d.ts.map +1 -0
  20. package/dist/service/conventions.js +42 -0
  21. package/dist/service/saveHandlers.d.ts +10 -13
  22. package/dist/service/saveHandlers.d.ts.map +1 -1
  23. package/dist/service/saveHandlers.js +9 -25
  24. package/dist/service/types.d.ts +5 -0
  25. package/dist/service/types.d.ts.map +1 -1
  26. package/dist/service.d.ts +7 -4
  27. package/dist/service.d.ts.map +1 -1
  28. package/dist/service.js +141 -104
  29. package/dist/skills/writeSkill.d.ts +12 -35
  30. package/dist/skills/writeSkill.d.ts.map +1 -1
  31. package/dist/skills/writeSkill.js +10 -166
  32. package/dist/specs/detectSharedFlows.d.ts +35 -0
  33. package/dist/specs/detectSharedFlows.d.ts.map +1 -0
  34. package/dist/specs/detectSharedFlows.js +171 -0
  35. package/dist/specs/extractPageObjects.d.ts +18 -0
  36. package/dist/specs/extractPageObjects.d.ts.map +1 -0
  37. package/dist/specs/extractPageObjects.js +98 -0
  38. package/dist/specs/generatePageObject.d.ts +29 -0
  39. package/dist/specs/generatePageObject.d.ts.map +1 -0
  40. package/dist/specs/generatePageObject.js +149 -0
  41. package/dist/specs/listSpecs.d.ts +12 -0
  42. package/dist/specs/listSpecs.d.ts.map +1 -1
  43. package/dist/specs/listSpecs.js +27 -2
  44. package/dist/specs/optimizationSuggestion.d.ts +26 -0
  45. package/dist/specs/optimizationSuggestion.d.ts.map +1 -0
  46. package/dist/specs/optimizationSuggestion.js +28 -0
  47. package/dist/specs/optimizeSpec.d.ts +42 -0
  48. package/dist/specs/optimizeSpec.d.ts.map +1 -0
  49. package/dist/specs/optimizeSpec.js +166 -0
  50. package/dist/specs/optimizeSpecWithAgent.d.ts +11 -0
  51. package/dist/specs/optimizeSpecWithAgent.d.ts.map +1 -0
  52. package/dist/specs/optimizeSpecWithAgent.js +40 -0
  53. package/dist/specs/pageObjectManifest.d.ts +20 -0
  54. package/dist/specs/pageObjectManifest.d.ts.map +1 -0
  55. package/dist/specs/pageObjectManifest.js +40 -0
  56. package/dist/specs/seeds.d.ts +36 -0
  57. package/dist/specs/seeds.d.ts.map +1 -0
  58. package/dist/specs/seeds.js +74 -0
  59. package/dist/specs/sidecar.d.ts +25 -0
  60. package/dist/specs/sidecar.d.ts.map +1 -0
  61. package/dist/specs/sidecar.js +38 -0
  62. package/dist/specs/writeSpec.d.ts +50 -0
  63. package/dist/specs/writeSpec.d.ts.map +1 -1
  64. package/dist/specs/writeSpec.js +249 -75
  65. package/package.json +1 -2
package/dist/service.d.ts CHANGED
@@ -4,13 +4,16 @@ export interface ServiceOptions {
4
4
  agentId?: string;
5
5
  model?: string;
6
6
  maxBudgetUsd?: number;
7
+ /** How the optimization pass (F7) surfaces in the widget. Default 'suggest'.
8
+ * 'off' = no nudge, 'suggest' = ✦ hint, 'on' = auto-run after Save-as-spec. */
9
+ optimizeMode?: 'off' | 'suggest' | 'on';
7
10
  mcpConfig?: string;
8
11
  /** CDP URL to preflight before each command (default http://localhost:9222). */
9
12
  cdpUrl?: string;
10
- /** Working directory for the spawned agent. Also where skills are saved
11
- * ('<devRoot>/.claude/skills/<slug>/SKILL.md'). Defaults to process.cwd().
12
- * In Vite plugin context, set to `server.config.root` so Claude
13
- * auto-discovers skills the user previously saved from this project. */
13
+ /** Working directory for the spawned agent. Also the root under which saved
14
+ * specs (`__vibe_tests__/`), sidecars + seeds (`.hover/`) live. Defaults to
15
+ * process.cwd(); in Vite plugin context, set to `server.config.root` so the
16
+ * agent runs against the project (and Claude Code reads its CLAUDE.md). */
14
17
  devRoot?: string;
15
18
  /** Plugins contributed by the bundler-plugin wrapper. Each manifest can
16
19
  * add a widget mode, MCP servers, Chrome flags, and lifecycle hooks.
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AA8EA,OAAO,EAEL,KAAK,mBAAmB,EAEzB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;6EAGyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;uBAImB;IACnB,OAAO,CAAC,EAAE,mBAAmB,EAAE,CAAC;IAChC;;;;;;uEAMmE;IACnE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;wDAEoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B;4EACwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAiED,wBAAsB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAs2B/E"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AA6EA,OAAO,EAEL,KAAK,mBAAmB,EAEzB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;oFACgF;IAChF,YAAY,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,IAAI,CAAC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;gFAG4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;uBAImB;IACnB,OAAO,CAAC,EAAE,mBAAmB,EAAE,CAAC;IAChC;;;;;;uEAMmE;IACnE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;wDAEoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B;4EACwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAiED,wBAAsB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CA64B/E"}
package/dist/service.js CHANGED
@@ -10,10 +10,8 @@
10
10
  * { type: 'hello', payload: { agentId, model, version } }
11
11
  * { type: 'event', payload: InvokeEvent } // see agents/types.ts
12
12
  * { type: 'cdp-status', payload: { state, reason?, matchingTabUrl?, browser?, launching? } }
13
- * { type: 'skill-saved', payload: { name, path } }
14
- * { type: 'skill-exists', payload: { slug, existingPath } }
15
- * { type: 'skills-list', payload: { skills: SkillSummary[] } }
16
13
  * { type: 'specs-list', payload: { specs: SpecSummary[] } }
14
+ * { type: 'seeds-list', payload: { seeds: { name, note, signature, code, source }[] } }
17
15
  * { type: 'spec-saved', payload: { name, path } }
18
16
  * { type: 'spec-exists', payload: { slug, existingPath } }
19
17
  * { type: 'case-csv-saved', payload: { name, path } }
@@ -31,11 +29,10 @@
31
29
  * { type: 'check-cdp', payload: { pageUrl } } // "is this widget in the debug Chrome?"
32
30
  * { type: 'launch-chrome', payload: { pageUrl } } // start debug Chrome, navigate to pageUrl
33
31
  * { type: 'focus-debug', payload: { pageUrl } } // bringToFront the matching tab in debug Chrome
34
- * { type: 'save-skill', payload: { name, description, steps, overwrite? } }
35
32
  * { type: 'save-spec', payload: { name, description, steps, assertions?, overwrite? } }
36
33
  * { type: 'save-case-csv', payload: { name, description, steps, assertions?, jiraProjectKey?, labels?, overwrite? } }
37
- * { type: 'list-skills' }
38
34
  * { type: 'list-specs' } // ask for every spec under __vibe_tests__/, with parsed JSDoc headers
35
+ * { type: 'list-seeds' } // ask for built-in + .hover/rules/ translation seeds (read-only)
39
36
  * { type: 'list-agents' } // ask for the full agent registry + install status
40
37
  * { type: 'switch-agent', payload: { agentId } } // set the service's current agent; broadcasts to all connections
41
38
  *
@@ -49,18 +46,21 @@
49
46
  * { type: 'list-modes' }
50
47
  */
51
48
  import { WebSocketServer, WebSocket } from 'ws';
52
- import { invokeAgent } from './agents/invoke.js';
49
+ import { runSession } from './runSession.js';
50
+ import { readConventions } from './service/conventions.js';
51
+ import { optimizeSpecWithAgent } from './specs/optimizeSpecWithAgent.js';
52
+ import { promoteOptimized, discardOptimized } from './specs/optimizeSpec.js';
53
53
  import { listAgentAvailability, pickPrimaryAgent, } from './agents/detect.js';
54
54
  import { getAgent } from './agents/registry.js';
55
55
  import { getPreflight, invalidatePreflight } from './playwright/preflightCache.js';
56
56
  import { resolveMcpConfig } from './playwright/resolveMcpConfig.js';
57
57
  import { launchDebugChrome } from './playwright/launchChrome.js';
58
- import { listSkills } from './skills/writeSkill.js';
59
58
  import { listSpecs } from './specs/listSpecs.js';
59
+ import { readSeeds, BUILTIN_SEEDS } from './specs/seeds.js';
60
60
  import { send, sendIfOpen } from './service/types.js';
61
61
  import { buildCdpHint, buildCdpHintResume } from './service/cdpHint.js';
62
62
  import { handleCheckCdp, handleLaunchChrome, handleFocusDebug, } from './service/cdpHandlers.js';
63
- import { handleSaveArtifact, SKILL_CONFIG, SPEC_CONFIG, CASE_CSV_CONFIG, } from './service/saveHandlers.js';
63
+ import { handleSaveArtifact, SPEC_CONFIG, CASE_CSV_CONFIG, } from './service/saveHandlers.js';
64
64
  import { CURRENT_API_VERSION, } from './plugin-api.js';
65
65
  // ClientMessage + send moved to ./service/types.ts so the cdp + save
66
66
  // handler modules can share them. See those files for the wire shape.
@@ -129,6 +129,11 @@ export async function startService(opts) {
129
129
  const preferred = opts.agentId ?? process.env.HOVER_AGENT;
130
130
  const primary = await pickPrimaryAgent(preferred);
131
131
  let currentAgentId = primary?.descriptor.id ?? preferred ?? 'claude';
132
+ // Optional model API key the widget supplied (set-api-key). Held in memory
133
+ // for this service's lifetime only — never written to disk, never logged.
134
+ // Injected into the spawned CLI's env so a user without a logged-in
135
+ // subscription can drive Hover on their own key.
136
+ let currentApiKey = process.env.ANTHROPIC_API_KEY ?? process.env.OPENAI_API_KEY ?? undefined;
132
137
  if (!primary) {
133
138
  // Nothing installed — still bind so the widget can show a helpful
134
139
  // "install one of these" dialog. Commands will fail with
@@ -147,6 +152,7 @@ export async function startService(opts) {
147
152
  // so the user can hit Stop when they've seen enough. Pass maxBudgetUsd
148
153
  // explicitly (or via the Vite plugin option) if a hard ceiling is needed.
149
154
  const maxBudgetUsd = opts.maxBudgetUsd;
155
+ const optimizeMode = opts.optimizeMode ?? 'suggest';
150
156
  const cdpUrl = opts.cdpUrl ?? 'http://localhost:9222';
151
157
  const devRoot = opts.devRoot ?? process.cwd();
152
158
  const wss = await pickAndBind('127.0.0.1', requestedPort, PORT_RETRIES);
@@ -384,7 +390,7 @@ export async function startService(opts) {
384
390
  wss.on('connection', ws => {
385
391
  send(ws, {
386
392
  type: 'hello',
387
- payload: { agentId: currentAgentId, model, version: PROTOCOL_VERSION },
393
+ payload: { agentId: currentAgentId, model, version: PROTOCOL_VERSION, optimizeMode },
388
394
  });
389
395
  // Send the agent list as a follow-up event so the widget can render the
390
396
  // dropdown immediately on connect / reconnect (e.g. after HMR). The
@@ -531,13 +537,14 @@ export async function startService(opts) {
531
537
  await broadcastAgents();
532
538
  return;
533
539
  }
534
- if (msg.type === 'save-skill') {
535
- await handleSaveArtifact(ws, msg, devRoot, SKILL_CONFIG);
536
- return;
537
- }
538
- if (msg.type === 'list-skills') {
539
- const skills = await listSkills(devRoot);
540
- send(ws, { type: 'skills-list', payload: { skills } });
540
+ if (msg.type === 'set-api-key') {
541
+ // The widget supplies (or clears) a model API key. Stored in memory
542
+ // only and injected into the spawned CLI's env at invoke time — never
543
+ // persisted, never logged, never echoed back. Empty/missing clears it.
544
+ const key = msg.payload?.key;
545
+ currentApiKey = typeof key === 'string' && key.trim() ? key.trim() : undefined;
546
+ const envVar = getAgent(currentAgentId)?.apiKeyEnv;
547
+ send(ws, { type: 'api-key-status', payload: { hasKey: !!currentApiKey, envVar } });
541
548
  return;
542
549
  }
543
550
  if (msg.type === 'list-specs') {
@@ -549,6 +556,21 @@ export async function startService(opts) {
549
556
  send(ws, { type: 'specs-list', payload: { specs } });
550
557
  return;
551
558
  }
559
+ if (msg.type === 'list-seeds') {
560
+ // Widget's Seeds tab: show which translation seeds Hover sees — the
561
+ // built-in set + whatever the user dropped in <devRoot>/.hover/rules/.
562
+ // Read-only; users add seeds by hand (no download path).
563
+ const builtinNames = new Set(BUILTIN_SEEDS.map(s => s.name));
564
+ const seeds = (await readSeeds(devRoot)).map(s => ({
565
+ name: s.name,
566
+ note: s.note ?? '',
567
+ signature: s.signature,
568
+ code: s.example?.code ?? '',
569
+ source: builtinNames.has(s.name) ? 'builtin' : 'project',
570
+ }));
571
+ send(ws, { type: 'seeds-list', payload: { seeds } });
572
+ return;
573
+ }
552
574
  if (msg.type === 'save-spec') {
553
575
  await handleSaveArtifact(ws, msg, devRoot, SPEC_CONFIG);
554
576
  return;
@@ -557,6 +579,55 @@ export async function startService(opts) {
557
579
  await handleSaveArtifact(ws, msg, devRoot, CASE_CSV_CONFIG);
558
580
  return;
559
581
  }
582
+ // Stage 7 (F7) widget flow: optimize a saved spec, then promote/discard
583
+ // the candidate after the human reviews the diff. optimizeSpecWithAgent
584
+ // spawns the codegen LLM (no browser, no MCP); the original spec is never
585
+ // touched until an explicit promote.
586
+ if (msg.type === 'optimize-spec') {
587
+ const slug = msg.payload?.slug;
588
+ if (typeof slug !== 'string' || !slug) {
589
+ send(ws, { type: 'error', payload: { message: 'optimize-spec: slug is required' } });
590
+ return;
591
+ }
592
+ try {
593
+ const res = await optimizeSpecWithAgent(devRoot, slug, {
594
+ agentId: currentAgentId, model, maxBudgetUsd, apiKey: currentApiKey,
595
+ });
596
+ send(ws, { type: 'optimize-result', payload: { slug, original: res.original, candidate: res.code } });
597
+ }
598
+ catch (err) {
599
+ const reason = err instanceof Error ? err.message : String(err);
600
+ send(ws, { type: 'optimize-failed', payload: { slug, reason } });
601
+ }
602
+ return;
603
+ }
604
+ if (msg.type === 'promote-optimized') {
605
+ const slug = msg.payload?.slug;
606
+ if (typeof slug !== 'string' || !slug) {
607
+ send(ws, { type: 'error', payload: { message: 'promote-optimized: slug is required' } });
608
+ return;
609
+ }
610
+ try {
611
+ const path = await promoteOptimized(devRoot, slug);
612
+ send(ws, { type: 'optimized-promoted', payload: { slug, path } });
613
+ send(ws, { type: 'specs-list', payload: { specs: await listSpecs(devRoot) } });
614
+ }
615
+ catch (err) {
616
+ const m = err instanceof Error ? err.message : String(err);
617
+ send(ws, { type: 'error', payload: { message: `promote-optimized: ${m}` } });
618
+ }
619
+ return;
620
+ }
621
+ if (msg.type === 'discard-optimized') {
622
+ const slug = msg.payload?.slug;
623
+ if (typeof slug !== 'string' || !slug) {
624
+ send(ws, { type: 'error', payload: { message: 'discard-optimized: slug is required' } });
625
+ return;
626
+ }
627
+ await discardOptimized(devRoot, slug);
628
+ send(ws, { type: 'optimized-discarded', payload: { slug } });
629
+ return;
630
+ }
560
631
  // v0.12 — plugin-contributed save handlers. Lookup is O(plugins),
561
632
  // which is fine because there's at most a handful of plugins ever
562
633
  // loaded. Each plugin's manifest declares `saveHandlers[].type`
@@ -609,11 +680,11 @@ export async function startService(opts) {
609
680
  ? msg.payload.sessionId
610
681
  : undefined;
611
682
  // Re-record mode: when the client (widget Specs tab or hover CLI)
612
- // passes `reRecord: { slug }`, we collect tool_use events server-side
613
- // into a SkillStep[] and, on session_end with no error, overwrite the
614
- // existing __vibe_tests__/<slug>.spec.ts. This is the same flow the
615
- // widget uses for "Save as Spec", but the spec already exists and is
616
- // being regenerated for the current UI.
683
+ // passes `reRecord: { slug }`, runSession collects the tool_use events
684
+ // into a SpecStep[] and, on a clean finish, we overwrite the existing
685
+ // __vibe_tests__/<slug>.spec.ts. Same flow the widget uses for "Save as
686
+ // Spec", but the spec already exists and is being regenerated for the
687
+ // current UI.
617
688
  const reRecordSlug = msg.payload && typeof msg.payload === 'object' && 'reRecord' in msg.payload
618
689
  ? msg.payload.reRecord?.slug
619
690
  : undefined;
@@ -629,16 +700,6 @@ export async function startService(opts) {
629
700
  busy = true;
630
701
  cancelled = false;
631
702
  inflight = new AbortController();
632
- // Re-record step collector — populated as tool_use events stream by,
633
- // consumed at session_end to overwrite the original spec. Empty unless
634
- // reRecordSlug is set on this command. We seed with a synthetic
635
- // `user` step so writeSpec's JSDoc Original-prompt: line carries the
636
- // text the agent was actually given (which is the prompt we read out
637
- // of the existing spec — the same one we're regenerating).
638
- const reRecordSteps = [];
639
- if (reRecordSlug) {
640
- reRecordSteps.push({ kind: 'user', text });
641
- }
642
703
  try {
643
704
  // Build the MCP config first — it's pure local file IO and lets
644
705
  // us assert plugin-contributed servers landed in the config even
@@ -682,6 +743,15 @@ export async function startService(opts) {
682
743
  let appendSystemPrompt = resumeSessionId
683
744
  ? buildCdpHintResume(cdp.tabs)
684
745
  : buildCdpHint(cdp.tabs);
746
+ // Knowledge layer (F5): on the first turn, fold in the project's
747
+ // .hover/conventions.md (static, like cdpHint's rules — skipped on
748
+ // resume to keep the prompt cache intact). The service reads the file;
749
+ // the agent never gains filesystem access (D2).
750
+ if (!resumeSessionId) {
751
+ const conventions = await readConventions(devRoot);
752
+ if (conventions)
753
+ appendSystemPrompt = `${appendSystemPrompt}\n\n${conventions}`;
754
+ }
685
755
  // Add plugin-contributed prompt additions whose scope includes the
686
756
  // current mode (or '*' for always-on). Walks ALL loaded plugins,
687
757
  // not just the active-mode plugin — a plugin that contributes
@@ -714,16 +784,9 @@ export async function startService(opts) {
714
784
  }
715
785
  // Snapshot the agent id so a switch-agent message during the run
716
786
  // can't smear two agents across one invocation. (We also gate
717
- // switch-agent on `busy`, but defense in depth.)
787
+ // switch-agent on `busy`, but defense in depth.) runSession gates the
788
+ // allow/deny lists on the agent's sandboxStrength internally.
718
789
  const invokedAgentId = currentAgentId;
719
- const invokedDescriptor = getAgent(invokedAgentId);
720
- // Only Claude's `--allowedTools`/`--disallowedTools` flags are
721
- // honoured — passing them to a soft-sandbox agent like codex is a
722
- // no-op (its buildArgs ignores them). We still gate at the service
723
- // layer for clarity: a hard-sandbox agent gets the tight allowlist,
724
- // a soft one gets nothing and relies on its descriptor's built-in
725
- // sandbox flags + developer_instructions.
726
- const isHardSandbox = invokedDescriptor?.sandboxStrength === 'hard';
727
790
  // Active mode's plugin-contributed MCP server ids — added to the
728
791
  // hard-sandbox allow list so Claude can actually call them. Claude
729
792
  // sanitises non-alphanumeric chars in the id when forming tool
@@ -743,87 +806,61 @@ export async function startService(opts) {
743
806
  }
744
807
  }
745
808
  }
746
- for await (const ev of invokeAgent({
809
+ const runResult = await runSession({
747
810
  agentId: invokedAgentId,
748
811
  prompt: text,
749
812
  sessionId: resumeSessionId,
750
813
  mcpConfig,
751
- // cwd = devRoot so Claude Code auto-discovers `.claude/skills/`
752
- // saved from this project (and CLAUDE.md, if any).
814
+ // cwd = devRoot so the agent runs against the project (and Claude
815
+ // Code reads its CLAUDE.md, if any).
753
816
  cwd: devRoot,
754
817
  appendSystemPrompt,
755
- // Skill stays in the allow list so saved skills under
756
- // <devRoot>/.claude/skills/ can be invoked. mcp__playwright covers
757
- // every browser tool. Plugin-contributed MCPs are appended when
758
- // the corresponding mode is active.
759
- allowedTools: isHardSandbox
760
- ? ['mcp__playwright', 'Skill', ...activePluginMcpIds]
761
- : undefined,
762
- disallowedTools: isHardSandbox
763
- ? (invokedDescriptor?.defaultDisallowedTools
764
- ? [...invokedDescriptor.defaultDisallowedTools]
765
- : undefined)
766
- : undefined,
818
+ // mcp__playwright covers every browser tool; active-mode plugin MCP
819
+ // servers are appended. (Save-as-Skill retired → no Skill tool.)
820
+ allowedToolsExtra: activePluginMcpIds,
767
821
  maxBudgetUsd,
768
822
  model,
823
+ apiKey: currentApiKey,
769
824
  signal: inflight.signal,
770
- })) {
825
+ }, (ev) => {
771
826
  if (cancelled || ws.readyState !== WebSocket.OPEN)
772
827
  return;
773
828
  send(ws, { type: 'event', payload: ev });
774
- // Re-record collection. Mirror what widget client.js does on the
775
- // way past tool_use events: accumulate into a SkillStep[] so we
776
- // can write a fresh spec when the session ends. We do this only
777
- // when this command was launched in re-record mode; ordinary
778
- // commands don't need server-side step retention (widget owns
779
- // that for normal saves).
780
- if (reRecordSlug && ev.kind === 'tool_use') {
781
- reRecordSteps.push({
782
- kind: 'step',
783
- tool: ev.tool,
784
- input: ev.input,
829
+ });
830
+ // Re-record: write a fresh spec from the steps runSession accumulated
831
+ // (`user` `step`* `done`). Only on a clean, non-cancelled finish
832
+ // a cancelled/aborted run throws out of runSession into the catch
833
+ // below, and an errored agent leaves the original spec untouched.
834
+ if (reRecordSlug && !cancelled) {
835
+ if (runResult.isError) {
836
+ sendIfOpen(ws, {
837
+ type: 'error',
838
+ payload: {
839
+ message: `Re-record failed: ${runResult.summary || 'agent reported an error'}. ` +
840
+ `Original spec left unchanged.`,
841
+ },
785
842
  });
786
843
  }
787
- if (reRecordSlug && ev.kind === 'session_end') {
788
- // Cancelled or errored runs: don't overwrite — the existing
789
- // spec is still valid. Tell the client what happened.
790
- if (ev.isError) {
844
+ else {
845
+ try {
846
+ const { writeSpec } = await import('./specs/writeSpec.js');
847
+ const written = await writeSpec({
848
+ devRoot,
849
+ name: reRecordSlug,
850
+ steps: runResult.steps,
851
+ overwrite: true,
852
+ });
791
853
  sendIfOpen(ws, {
792
- type: 'error',
793
- payload: {
794
- message: `Re-record failed: ${ev.summary ?? 'agent reported an error'}. ` +
795
- `Original spec left unchanged.`,
796
- },
854
+ type: 'spec-saved',
855
+ payload: { name: reRecordSlug, path: written.path },
797
856
  });
798
857
  }
799
- else {
800
- // Snapshot the agent's final summary into a synthetic `done`
801
- // step so writeSpec's `Outcome:` header reflects the new run.
802
- if (ev.summary) {
803
- reRecordSteps.push({ kind: 'done', summary: ev.summary });
804
- }
805
- // Overwrite. writeSpec uses the slug to name the file; we
806
- // pass the original slug verbatim so the path is stable.
807
- try {
808
- const { writeSpec } = await import('./specs/writeSpec.js');
809
- const result = await writeSpec({
810
- devRoot,
811
- name: reRecordSlug,
812
- steps: reRecordSteps,
813
- overwrite: true,
814
- });
815
- sendIfOpen(ws, {
816
- type: 'spec-saved',
817
- payload: { name: reRecordSlug, path: result.path },
818
- });
819
- }
820
- catch (e) {
821
- const m = e instanceof Error ? e.message : String(e);
822
- sendIfOpen(ws, {
823
- type: 'error',
824
- payload: { message: `Re-record could not write spec: ${m}` },
825
- });
826
- }
858
+ catch (e) {
859
+ const m = e instanceof Error ? e.message : String(e);
860
+ sendIfOpen(ws, {
861
+ type: 'error',
862
+ payload: { message: `Re-record could not write spec: ${m}` },
863
+ });
827
864
  }
828
865
  }
829
866
  }
@@ -1,8 +1,15 @@
1
- export declare class SkillExistsError extends Error {
2
- readonly slug: string;
3
- readonly path: string;
4
- constructor(slug: string, path: string);
5
- }
1
+ /**
2
+ * Captured-step type.
3
+ *
4
+ * NOTE: Save-as-Skill (writing `.claude/skills/<slug>/SKILL.md` for agent
5
+ * replay) was retired — `spec` + ⟳ Re-record covers intent-driven replay, and
6
+ * "skill" collided with Claude Code's own skills concept. All that remains
7
+ * here is `SkillStep`: the serialized message shape from the widget's
8
+ * localStorage, which the whole spec pipeline (writeSpec, sidecar, listSpecs,
9
+ * Page-Object extraction) consumes as `SpecStep`. The file keeps its path so
10
+ * the many `import { SkillStep } from '../skills/writeSkill.js'` call sites
11
+ * don't churn; renaming to a neutral module is a separate mechanical pass.
12
+ */
6
13
  /**
7
14
  * Serialized message shape from the widget's localStorage. Matches the
8
15
  * `state.messages` schema in packages/widget-bootstrap/src/widget/client.js.
@@ -17,34 +24,4 @@ export interface SkillStep {
17
24
  costUsd?: number;
18
25
  summary?: string;
19
26
  }
20
- export interface WriteSkillOptions {
21
- /** Directory under which `.claude/skills/<slug>/` is created. Usually the
22
- * Vite project root (`server.config.root`). */
23
- devRoot: string;
24
- name: string;
25
- description?: string;
26
- steps: SkillStep[];
27
- /** If false (default), throws SkillExistsError when a skill with the same
28
- * slug already exists. If true, overwrites unconditionally. The widget
29
- * uses the two paths to give the user a confirm dialog. */
30
- overwrite?: boolean;
31
- }
32
- export interface WriteSkillResult {
33
- path: string;
34
- slug: string;
35
- }
36
- export declare function writeSkill(opts: WriteSkillOptions): Promise<WriteSkillResult>;
37
- export interface SkillSummary {
38
- slug: string;
39
- name: string;
40
- description: string;
41
- path: string;
42
- }
43
- /**
44
- * List skills under <devRoot>/.claude/skills/, reading the YAML frontmatter
45
- * of each SKILL.md for `name` and `description`. Malformed entries are
46
- * silently skipped — better to show 9 valid skills than refuse to render
47
- * because one is broken. Hand-edited skills are first-class.
48
- */
49
- export declare function listSkills(devRoot: string): Promise<SkillSummary[]>;
50
27
  //# sourceMappingURL=writeSkill.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"writeSkill.d.ts","sourceRoot":"","sources":["../../src/skills/writeSkill.ts"],"names":[],"mappings":"AAmBA,qBAAa,gBAAiB,SAAQ,KAAK;aACb,IAAI,EAAE,MAAM;aAAkB,IAAI,EAAE,MAAM;gBAA1C,IAAI,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAIvE;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC;IACjD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC;oDACgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB;;gEAE4D;IAC5D,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoBnF;AA8ED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CA8BzE"}
1
+ {"version":3,"file":"writeSkill.d.ts","sourceRoot":"","sources":["../../src/skills/writeSkill.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC;IACjD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
@@ -1,169 +1,13 @@
1
1
  /**
2
- * Save a completed Hover session as a Claude Code skill.
2
+ * Captured-step type.
3
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.
4
+ * NOTE: Save-as-Skill (writing `.claude/skills/<slug>/SKILL.md` for agent
5
+ * replay) was retired `spec` + Re-record covers intent-driven replay, and
6
+ * "skill" collided with Claude Code's own skills concept. All that remains
7
+ * here is `SkillStep`: the serialized message shape from the widget's
8
+ * localStorage, which the whole spec pipeline (writeSpec, sidecar, listSpecs,
9
+ * Page-Object extraction) consumes as `SpecStep`. The file keeps its path so
10
+ * the many `import { SkillStep } from '../skills/writeSkill.js'` call sites
11
+ * don't churn; renaming to a neutral module is a separate mechanical pass.
113
12
  */
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
- }
13
+ export {};