saffron-ai 0.5.3 → 0.5.4

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/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to Saffron, in one place: the `saffron-ai` runner (npm),
4
4
  the VS Code extension (`ChathurangaJayasinghe.saffron-vscode`) and the
5
5
  JetBrains plugin (`ai.saffron.jetbrains`). Dates are publish dates.
6
6
 
7
+ ## 0.5.4
8
+
9
+ Released 2026-09-17.
10
+
11
+ Runner
12
+
13
+ - `saffron status [--json]`: the project overview that IDE panels and
14
+ dashboards read: feature files and scenarios with cache state, tags,
15
+ pending proposals with narratives, last run, history, vocabulary health
16
+ (divergent and near-duplicate steps) and the effective config.
17
+ - `saffron accept` and `saffron reject` take several proposal files at
18
+ once.
19
+
20
+ Editors
21
+
22
+ - JetBrains plugin 0.2.1 tool window gains Proposals, Tags, Health and
23
+ Dashboard tabs; VS Code extension 0.2.2 gains Tags, Proposals and
24
+ Health sections and an in-editor dashboard. All read
25
+ `saffron status --json`.
26
+
7
27
  ## 0.5.3
8
28
 
9
29
  Released 2026-09-17.
@@ -38,6 +58,12 @@ Editors
38
58
  in the editor title, and Command Palette entries for run all, open the
39
59
  latest report and accept all proposals. Commands run in a "Saffron"
40
60
  terminal from the workspace folder.
61
+ - VS Code extension 0.2.1: a **Saffron side panel**: an activity
62
+ bar view listing feature files with their scenarios (click to jump),
63
+ inline Run and Replay-only buttons, checkboxes with Run selected and Run
64
+ all, replay-only and headed toggles that apply to every run from the
65
+ extension, the last run's totals with the report one click away, and
66
+ the proposals pending review with Accept all.
41
67
 
42
68
  ## 0.5.2
43
69
 
package/dist-pkg/cli.js CHANGED
@@ -1,40 +1,40 @@
1
1
  #!/usr/bin/env node
2
- import _ from"node:fs";import T from"node:path";import{spawn as ys}from"node:child_process";import Ss from"node:readline";import{Command as xs}from"commander";import Zn from"node:fs";import er from"node:path";import{AstBuilder as tr,GherkinClassicTokenMatcher as nr,Parser as rr}from"@cucumber/gherkin";import{IdGenerator as sr,StepKeywordType as Ue}from"@cucumber/messages";function H(t,e){return t.replace(/<([^<>]+)>/g,(n,r)=>r in e?e[r]:n)}function N(t){if(!t||t.length===0)return{};let e={};if(t.every(n=>n.length===2)){for(let[n,r]of t)e[`table:${n}`]=r;return e}if(t.length>=2){let[n,...r]=t;if(n.some(o=>!o))return{};r.forEach((o,s)=>{n.forEach((i,a)=>{o[a]!==void 0&&(e[`table:${s+1}:${i}`]=o[a])})})}return e}function W(t){return t!==void 0?{docstring:t}:{}}function Q(t){let e=new Map;for(let r of t){let o={...N(r.resolvedTable??r.table),...W(r.resolvedDocString??r.docString)};for(let[s,i]of Object.entries(o)){let a=e.get(s);a||e.set(s,a=new Set),a.add(i)}}let n={};for(let[r,o]of e)o.size===1&&(n[r]=[...o][0]);return n}import Me from"node:fs";import ut from"node:path";import{AstBuilder as Hn,GherkinClassicTokenMatcher as Wn,Parser as Gn}from"@cucumber/gherkin";import{IdGenerator as Yn}from"@cucumber/messages";var M=class extends Error{},Jn=/^(\s*)StepSet:\s*(.+?)\s*$/,Kn=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function Ne(t){let e=t.split(`
3
- `),n=new Map,r=new Map;return{source:e.map((s,i)=>{let a=s.match(Jn);if(a)return n.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let d=s.match(Kn);return d?(r.set(i+1,d[2]),`${d[1]}* StepSet ${d[2]}`):s}).join(`
4
- `),definitions:n,invocations:r}}function Qn(t){return new Gn(new Hn(Yn.uuid()),new Wn).parse(t)}function ft(t,e){for(let n of t)n.scenario?e(n.scenario):"rule"in n&&n.rule&&ft(n.rule.children,e)}function X(t,e){let n=new Map;for(let r of t){let o=ut.relative(e,r),s=Ne(Me.readFileSync(r,"utf8"));if(s.definitions.size===0)continue;let i=Qn(s.source);i.feature&&ft(i.feature.children,a=>{let d=s.definitions.get(a.location.line);if(d===void 0)return;let p=n.get(d);if(p)throw new M(`StepSet "${d}" is defined twice: ${p.file}:${p.line} and ${o}:${a.location.line}. Set names are unique across the project. If line ${a.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${d}".`);if(a.examples.length>0)throw new M(`StepSet "${d}" (${o}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let m of a.steps)if(s.invocations.has(m.location.line))throw new M(`StepSet "${d}" (${o}:${a.location.line}) invokes another step set at line ${m.location.line} \u2014 step sets cannot be nested.`);n.set(d,{name:d,file:o,line:a.location.line,steps:[...a.steps]})})}return n}var Xn=[".feature",".saffron"];function ae(t){return Xn.some(e=>t.endsWith(e))}function ye(t){if(!Me.existsSync(t))return[];let e=[];for(let n of Me.readdirSync(t,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&e.push(ut.join(t,r))}return e.sort()}function gt(t,e,n){return t.map(({step:r,source:o})=>{let s;switch(r.keywordType){case Ue.OUTCOME:s="assertion";break;case Ue.CONTEXT:case Ue.ACTION:s="action";break;default:s=n.value}n.value=s;let i=r.dataTable?r.dataTable.rows.map(d=>d.cells.map(p=>p.value)):void 0,a=r.docString?.content;return{keyword:r.keyword.trim(),kind:s,text:r.text,resolvedText:H(r.text,e),table:i,resolvedTable:i?.map(d=>d.map(p=>H(p,e))),docString:a,resolvedDocString:a!==void 0?H(a,e):void 0,source:o}})}function mt(t,e,n,r,o){let s=[];for(let i of t){let a=r.get(i.location.line);if(a===void 0){s.push({step:i,source:{file:e,line:i.location.line,fromBackground:n}});continue}let d=o?.get(a);if(!d)throw new M(`${e}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let p of d.steps)s.push({step:p,source:{file:d.file,line:p.location.line,fromStepSet:d.name,fromBackground:n}})}return s}function or(t,e,n,r,o){let s=t.tags.map(a=>a.name),i=[];if(t.examples.length===0)i.push({params:{},label:""});else{let a=0;for(let d of t.examples){let p=d.tableHeader?.cells.map(m=>m.value)??[];for(let m of d.tableBody){a+=1;let c={};m.cells.forEach((l,u)=>{c[p[u]]=l.value}),i.push({params:c,label:` (example ${a})`})}}}return i.map(({params:a,label:d})=>{let p={value:"action"};return{featurePath:e,featureName:n,name:t.name,displayName:`${t.name}${d}`,tags:s,steps:[...gt(r,a,p),...gt(o,a,p)],params:a}})}function Z(t,e,n){let r=Zn.readFileSync(t,"utf8"),s=t.endsWith(".saffron")?Ne(r):{source:r,definitions:new Map,invocations:new Map},a=new rr(new tr(sr.uuid()),new nr).parse(s.source);if(!a.feature)return[];let d=er.relative(e,t),p=a.feature.name,m=[],c=(l,u)=>{let h=u;for(let f of l)if(f.background)h=[...u,...mt(f.background.steps,d,!0,s.invocations,n)];else if(f.scenario){if(s.definitions.has(f.scenario.location.line))continue;m.push(...or(f.scenario,d,p,h,mt(f.scenario.steps,d,!1,s.invocations,n)))}else"rule"in f&&f.rule&&c(f.rule.children,h)};return c(a.feature.children,[]),m}import kt from"node:fs";import Be from"node:path";import yt from"node:fs";import St from"node:path";import Se from"node:fs";import ce from"node:path";var ht=".saffron";function xe(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function q(t,e,n){let r=xe(ce.basename(e,".feature"));return ce.join(t,ht,"cache",r,`${xe(n)}.json`)}function Le(t,e,n){let r=xe(ce.basename(e,".feature"));return ce.join(t,ht,"proposals",r,`${xe(n)}.json`)}function L(t){if(!Se.existsSync(t))return;let e=JSON.parse(Se.readFileSync(t,"utf8"));if(e.version!==1)throw new Error(`Unsupported cache version ${e.version} in ${t}`);return e}function ee(t,e){Se.mkdirSync(ce.dirname(t),{recursive:!0}),Se.writeFileSync(t,`${JSON.stringify(e,null,2)}
5
- `)}function bt(t){let e=[];return{pattern:t.replace(/"((?:[^"\\]|\\.)*)"/g,(r,o)=>(e.push(o),`"\xAB${e.length-1}\xBB"`)),args:e}}function ir(t,e){let n=r=>JSON.stringify(r.map(({pageFingerprint:o,...s})=>s));return n(t)===n(e)}function ar(t){let e=new Set,n=r=>{if(r)for(let o of r.matchAll(/<([^<>]+)>/g))e.add(o[1])};for(let r of t.actions)n(r.value),n(r.url),n(r.pattern),n(r.target?.name),n(r.target?.nameRegex),n(r.target?.label),n(r.target?.text),n(r.target?.placeholder);return n(t.gherkin),e}function G(t){let e=St.join(t,".saffron","cache"),n=new Map,r=new Map,o=0;if(yt.existsSync(e))for(let a of yt.readdirSync(e,{recursive:!0})){let d=String(a);if(!d.endsWith(".json"))continue;let p=L(St.join(e,d));if(p){o++;for(let m of p.steps){let c=m.gherkin,l=JSON.stringify(m.actions.map(({pageFingerprint:f,...g})=>g)),u=r.get(c)??[];u.includes(l)||(u.push(l),r.set(c,u));let h=n.get(c);!h||p.recordedAt>h.recordedAt?n.set(c,{gherkin:m.gherkin,table:m.table,docString:m.docString,actions:m.actions,feature:p.feature,scenario:p.scenario,recordedAt:p.recordedAt,variants:u.length}):h.variants=u.length}}}let s=[...r.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of n.values()){let{pattern:d,args:p}=bt(a.gherkin);if(p.length===0)continue;let m=i.get(d);(!m||a.recordedAt>m.recordedAt)&&i.set(d,{...a,args:p})}return{steps:n,patterns:i,divergent:s,scannedCaches:o}}function xt(t,e,n){if(!!t.table!=!!e.table)return!1;if(t.table&&e.table){let s=Object.keys(N(t.table)),i=Object.keys(N(e.table));if(s.length>0&&i.length>0){if(JSON.stringify(s)!==JSON.stringify(i))return!1}else if(JSON.stringify(t.table)!==JSON.stringify(e.table))return!1}if(t.docString!==void 0!=(e.docString!==void 0)||t.docString!==void 0&&e.docString!==void 0&&t.docString!==e.docString&&!t.actions.some(s=>[s.value,s.url,s.pattern].some(i=>i?.includes("<docstring>"))))return!1;let r={...n,...N(e.resolvedTable??e.table),...W(e.resolvedDocString??e.docString)},o=ar({gherkin:t.gherkin,keyword:"",kind:"action",actions:t.actions});for(let s of o)if(!(s in r))return!1;return!0}function vt(t,e,n){let r=t.steps.get(e.text);if(r)return xt(r,e,n)?r:void 0;let{pattern:o,args:s}=bt(e.text);if(s.length===0)return;let i=t.patterns.get(o);if(!i)return;let a=new Map;for(let c=0;c<s.length;c++){let l=i.args[c],u=s[c];if(l===u)continue;let h=a.get(l);if(h!==void 0&&h!==u)return;a.set(l,u)}let d=c=>[c?.name,c?.nameRegex,c?.label,c?.text,c?.placeholder];for(let c of a.keys())if(!i.actions.some(u=>[u.value,u.url,u.pattern,...d(u.target)].includes(c)))return;let p=c=>c!==void 0&&a.has(c)?a.get(c):c,m={...i,gherkin:e.text,actions:i.actions.map(({pageFingerprint:c,...l})=>({...l,value:p(l.value),url:p(l.url),pattern:p(l.pattern),target:l.target?{...l.target,name:p(l.target.name),nameRegex:p(l.target.nameRegex),label:p(l.target.label),text:p(l.target.text),placeholder:p(l.target.placeholder)}:void 0}))};return xt(m,e,n)?m:void 0}function wt(t,e){let n=[];return t.forEach((r,o)=>{if(r.actions.length!==0&&!e.steps.has(r.gherkin)){for(let s of e.steps.values())if(!(s.variants>1)&&s.actions.length!==0&&ir(r.actions,s.actions)){n.push({index:o,from:r.gherkin,to:s.gherkin,feature:s.feature,scenario:s.scenario});break}}}),n}function cr(t){if(!kt.existsSync(t))return[];let e=[];for(let n of kt.readdirSync(t,{recursive:!0})){let r=String(n);ae(r)&&e.push(Be.join(t,r))}return e.sort()}function Y(t,e,n){let r=Be.isAbsolute(e)?e:Be.join(t,e),o=cr(r),s=n??X(o.filter(l=>l.endsWith(".saffron")),t),i=new Map,a=new Map;for(let l of o)for(let u of Z(l,t,s)){let h=`${u.featurePath}::${u.name}`;for(let f of u.steps){let g=i.get(f.text);if(g||(g={text:f.text,keywords:new Map,kind:f.kind,scenarios:new Set,files:new Set,fromStepSet:f.source?.fromStepSet},i.set(f.text,g)),g.keywords.set(f.keyword,(g.keywords.get(f.keyword)??0)+1),g.scenarios.add(h),f.source&&g.files.add(f.source.file),f.source?.fromStepSet){let v=a.get(f.source.fromStepSet);v||a.set(f.source.fromStepSet,v=new Set),v.add(h)}}}let d=G(t),p=new Set(d.divergent),m=[...i.values()].map(l=>({text:l.text,keyword:[...l.keywords.entries()].sort((u,h)=>h[1]-u[1])[0][0],kind:l.kind,status:p.has(l.text)?"divergent":d.steps.has(l.text)?"recorded":"unrecorded",usage:l.scenarios.size,files:[...l.files].sort(),fromStepSet:l.fromStepSet})).sort((l,u)=>u.usage-l.usage||l.text.localeCompare(u.text)),c=[...s.values()].map(l=>({name:l.name,file:l.file,line:l.line,steps:l.steps.map(u=>u.text),usage:a.get(l.name)?.size??0})).sort((l,u)=>u.usage-l.usage||l.name.localeCompare(u.name));return{steps:m,stepSets:c,scannedFiles:o.length,scannedCaches:d.scannedCaches}}function be(t,e){let n=e.toLowerCase();return{...t,steps:t.steps.filter(r=>r.text.toLowerCase().includes(n)),stepSets:t.stepSets.filter(r=>r.name.toLowerCase().includes(n)||r.steps.some(o=>o.toLowerCase().includes(n)))}}function $t(t){let e={},n="saffron,feature,gherkin,cucumber";for(let r of t.steps)e[`step: ${r.text}`]={scope:n,prefix:r.text,body:[r.text.replace(/\$/g,"\\$")],description:`${r.status} \xB7 used in ${r.usage} scenario(s)`};for(let r of t.stepSets)e[`StepSet: ${r.name}`]={scope:n,prefix:`StepSet ${r.name}`,body:[`StepSet ${r.name}`],description:`step set (${r.steps.length} steps) \u2014 ${r.file}`};return JSON.stringify(e,null,2)+`
6
- `}import{Server as lr}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as dr}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as pr,ListToolsRequestSchema as ur}from"@modelcontextprotocol/sdk/types.js";async function Rt(t,e){let n=new lr({name:"saffron",version:"0.1.0"},{capabilities:{tools:{}}});n.setRequestHandler(ur,async()=>({tools:[{name:"search_steps",description:"Search the project's step vocabulary. Returns known Gherkin steps with status (recorded = replays at zero tokens, divergent, unrecorded), usage counts, and source files. ALWAYS reuse an existing wording exactly instead of inventing a near-duplicate \u2014 exact step text is the replay-cache identity, so a new wording costs a fresh AI recording.",inputSchema:{type:"object",properties:{query:{type:"string",description:"Case-insensitive substring filter over step text; omit to list everything."}}}},{name:"list_step_sets",description:"List the project's StepSet definitions (named reusable step sequences from .saffron files) with their steps, defining file, and usage. Invoke one inside a scenario with a line reading exactly: StepSet <name>",inputSchema:{type:"object",properties:{}}}]})),n.setRequestHandler(pr,async r=>{let o=Y(t,e);if(r.params.name==="search_steps"){let s=r.params.arguments?.query,i=s?be(o,s):o;return{content:[{type:"text",text:JSON.stringify({steps:i.steps,scannedFiles:i.scannedFiles,scannedCaches:i.scannedCaches},null,2)}]}}return r.params.name==="list_step_sets"?{content:[{type:"text",text:JSON.stringify({stepSets:o.stepSets},null,2)}]}:{content:[{type:"text",text:`Unknown tool: ${r.params.name}`}],isError:!0}}),await n.connect(new dr)}import{query as fr}from"@anthropic-ai/claude-agent-sdk";function gr(t,e){let n=e.steps.map(o=>`- [${o.status}] ${o.keyword} ${o.text}`).join(`
7
- `),r=e.stepSets.map(o=>`- StepSet ${o.name}
8
- ${o.steps.map(s=>` ${s}`).join(`
2
+ import O from"node:fs";import E from"node:path";import{spawn as Fs}from"node:child_process";import js from"node:readline";import{Command as Is}from"commander";import lr from"node:fs";import dr from"node:path";import{AstBuilder as pr,GherkinClassicTokenMatcher as ur,Parser as fr}from"@cucumber/gherkin";import{IdGenerator as gr,StepKeywordType as We}from"@cucumber/messages";function Z(t,e){return t.replace(/<([^<>]+)>/g,(n,r)=>r in e?e[r]:n)}function U(t){if(!t||t.length===0)return{};let e={};if(t.every(n=>n.length===2)){for(let[n,r]of t)e[`table:${n}`]=r;return e}if(t.length>=2){let[n,...r]=t;if(n.some(s=>!s))return{};r.forEach((s,o)=>{n.forEach((i,a)=>{s[a]!==void 0&&(e[`table:${o+1}:${i}`]=s[a])})})}return e}function ee(t){return t!==void 0?{docstring:t}:{}}function ce(t){let e=new Map;for(let r of t){let s={...U(r.resolvedTable??r.table),...ee(r.resolvedDocString??r.docString)};for(let[o,i]of Object.entries(s)){let a=e.get(o);a||e.set(o,a=new Set),a.add(i)}}let n={};for(let[r,s]of e)s.size===1&&(n[r]=[...s][0]);return n}import qe from"node:fs";import bt from"node:path";import{AstBuilder as tr,GherkinClassicTokenMatcher as nr,Parser as rr}from"@cucumber/gherkin";import{IdGenerator as sr}from"@cucumber/messages";var N=class extends Error{},or=/^(\s*)StepSet:\s*(.+?)\s*$/,ir=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function He(t){let e=t.split(`
3
+ `),n=new Map,r=new Map;return{source:e.map((o,i)=>{let a=o.match(or);if(a)return n.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let d=o.match(ir);return d?(r.set(i+1,d[2]),`${d[1]}* StepSet ${d[2]}`):o}).join(`
4
+ `),definitions:n,invocations:r}}function ar(t){return new rr(new tr(sr.uuid()),new nr).parse(t)}function vt(t,e){for(let n of t)n.scenario?e(n.scenario):"rule"in n&&n.rule&&vt(n.rule.children,e)}function J(t,e){let n=new Map;for(let r of t){let s=bt.relative(e,r),o=He(qe.readFileSync(r,"utf8"));if(o.definitions.size===0)continue;let i=ar(o.source);i.feature&&vt(i.feature.children,a=>{let d=o.definitions.get(a.location.line);if(d===void 0)return;let p=n.get(d);if(p)throw new N(`StepSet "${d}" is defined twice: ${p.file}:${p.line} and ${s}:${a.location.line}. Set names are unique across the project. If line ${a.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${d}".`);if(a.examples.length>0)throw new N(`StepSet "${d}" (${s}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let g of a.steps)if(o.invocations.has(g.location.line))throw new N(`StepSet "${d}" (${s}:${a.location.line}) invokes another step set at line ${g.location.line} \u2014 step sets cannot be nested.`);n.set(d,{name:d,file:s,line:a.location.line,steps:[...a.steps]})})}return n}var cr=[".feature",".saffron"];function te(t){return cr.some(e=>t.endsWith(e))}function le(t){if(!qe.existsSync(t))return[];let e=[];for(let n of qe.readdirSync(t,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&e.push(bt.join(t,r))}return e.sort()}function wt(t,e,n){return t.map(({step:r,source:s})=>{let o;switch(r.keywordType){case We.OUTCOME:o="assertion";break;case We.CONTEXT:case We.ACTION:o="action";break;default:o=n.value}n.value=o;let i=r.dataTable?r.dataTable.rows.map(d=>d.cells.map(p=>p.value)):void 0,a=r.docString?.content;return{keyword:r.keyword.trim(),kind:o,text:r.text,resolvedText:Z(r.text,e),table:i,resolvedTable:i?.map(d=>d.map(p=>Z(p,e))),docString:a,resolvedDocString:a!==void 0?Z(a,e):void 0,source:s}})}function kt(t,e,n,r,s){let o=[];for(let i of t){let a=r.get(i.location.line);if(a===void 0){o.push({step:i,source:{file:e,line:i.location.line,fromBackground:n}});continue}let d=s?.get(a);if(!d)throw new N(`${e}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let p of d.steps)o.push({step:p,source:{file:d.file,line:p.location.line,fromStepSet:d.name,fromBackground:n}})}return o}function mr(t,e,n,r,s){let o=t.tags.map(a=>a.name),i=[];if(t.examples.length===0)i.push({params:{},label:""});else{let a=0;for(let d of t.examples){let p=d.tableHeader?.cells.map(g=>g.value)??[];for(let g of d.tableBody){a+=1;let l={};g.cells.forEach((c,u)=>{l[p[u]]=c.value}),i.push({params:l,label:` (example ${a})`})}}}return i.map(({params:a,label:d})=>{let p={value:"action"};return{featurePath:e,featureName:n,name:t.name,displayName:`${t.name}${d}`,tags:o,steps:[...wt(r,a,p),...wt(s,a,p)],params:a}})}function K(t,e,n){let r=lr.readFileSync(t,"utf8"),o=t.endsWith(".saffron")?He(r):{source:r,definitions:new Map,invocations:new Map},a=new fr(new pr(gr.uuid()),new ur).parse(o.source);if(!a.feature)return[];let d=dr.relative(e,t),p=a.feature.name,g=[],l=(c,u)=>{let h=u;for(let f of c)if(f.background)h=[...u,...kt(f.background.steps,d,!0,o.invocations,n)];else if(f.scenario){if(o.definitions.has(f.scenario.location.line))continue;g.push(...mr(f.scenario,d,p,h,kt(f.scenario.steps,d,!1,o.invocations,n)))}else"rule"in f&&f.rule&&l(f.rule.children,h)};return l(a.feature.children,[]),g}import Ft from"node:fs";import Ge from"node:path";import Rt from"node:fs";import Tt from"node:path";import Ee from"node:fs";import ye from"node:path";var $t=".saffron";function Ae(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function V(t,e,n){let r=Ae(ye.basename(e,".feature"));return ye.join(t,$t,"cache",r,`${Ae(n)}.json`)}function Se(t,e,n){let r=Ae(ye.basename(e,".feature"));return ye.join(t,$t,"proposals",r,`${Ae(n)}.json`)}function B(t){if(!Ee.existsSync(t))return;let e=JSON.parse(Ee.readFileSync(t,"utf8"));if(e.version!==1)throw new Error(`Unsupported cache version ${e.version} in ${t}`);return e}function de(t,e){Ee.mkdirSync(ye.dirname(t),{recursive:!0}),Ee.writeFileSync(t,`${JSON.stringify(e,null,2)}
5
+ `)}function At(t){let e=[];return{pattern:t.replace(/"((?:[^"\\]|\\.)*)"/g,(r,s)=>(e.push(s),`"\xAB${e.length-1}\xBB"`)),args:e}}function hr(t,e){let n=r=>JSON.stringify(r.map(({pageFingerprint:s,...o})=>o));return n(t)===n(e)}function yr(t){let e=new Set,n=r=>{if(r)for(let s of r.matchAll(/<([^<>]+)>/g))e.add(s[1])};for(let r of t.actions)n(r.value),n(r.url),n(r.pattern),n(r.target?.name),n(r.target?.nameRegex),n(r.target?.label),n(r.target?.text),n(r.target?.placeholder);return n(t.gherkin),e}function ne(t){let e=Tt.join(t,".saffron","cache"),n=new Map,r=new Map,s=0;if(Rt.existsSync(e))for(let a of Rt.readdirSync(e,{recursive:!0})){let d=String(a);if(!d.endsWith(".json"))continue;let p=B(Tt.join(e,d));if(p){s++;for(let g of p.steps){let l=g.gherkin,c=JSON.stringify(g.actions.map(({pageFingerprint:f,...m})=>m)),u=r.get(l)??[];u.includes(c)||(u.push(c),r.set(l,u));let h=n.get(l);!h||p.recordedAt>h.recordedAt?n.set(l,{gherkin:g.gherkin,table:g.table,docString:g.docString,actions:g.actions,feature:p.feature,scenario:p.scenario,recordedAt:p.recordedAt,variants:u.length}):h.variants=u.length}}}let o=[...r.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of n.values()){let{pattern:d,args:p}=At(a.gherkin);if(p.length===0)continue;let g=i.get(d);(!g||a.recordedAt>g.recordedAt)&&i.set(d,{...a,args:p})}return{steps:n,patterns:i,divergent:o,scannedCaches:s}}function Et(t,e,n){if(!!t.table!=!!e.table)return!1;if(t.table&&e.table){let o=Object.keys(U(t.table)),i=Object.keys(U(e.table));if(o.length>0&&i.length>0){if(JSON.stringify(o)!==JSON.stringify(i))return!1}else if(JSON.stringify(t.table)!==JSON.stringify(e.table))return!1}if(t.docString!==void 0!=(e.docString!==void 0)||t.docString!==void 0&&e.docString!==void 0&&t.docString!==e.docString&&!t.actions.some(o=>[o.value,o.url,o.pattern].some(i=>i?.includes("<docstring>"))))return!1;let r={...n,...U(e.resolvedTable??e.table),...ee(e.resolvedDocString??e.docString)},s=yr({gherkin:t.gherkin,keyword:"",kind:"action",actions:t.actions});for(let o of s)if(!(o in r))return!1;return!0}function Pt(t,e,n){let r=t.steps.get(e.text);if(r)return Et(r,e,n)?r:void 0;let{pattern:s,args:o}=At(e.text);if(o.length===0)return;let i=t.patterns.get(s);if(!i)return;let a=new Map;for(let l=0;l<o.length;l++){let c=i.args[l],u=o[l];if(c===u)continue;let h=a.get(c);if(h!==void 0&&h!==u)return;a.set(c,u)}let d=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of a.keys())if(!i.actions.some(u=>[u.value,u.url,u.pattern,...d(u.target)].includes(l)))return;let p=l=>l!==void 0&&a.has(l)?a.get(l):l,g={...i,gherkin:e.text,actions:i.actions.map(({pageFingerprint:l,...c})=>({...c,value:p(c.value),url:p(c.url),pattern:p(c.pattern),target:c.target?{...c.target,name:p(c.target.name),nameRegex:p(c.target.nameRegex),label:p(c.target.label),text:p(c.target.text),placeholder:p(c.target.placeholder)}:void 0}))};return Et(g,e,n)?g:void 0}function Ct(t,e){let n=[];return t.forEach((r,s)=>{if(r.actions.length!==0&&!e.steps.has(r.gherkin)){for(let o of e.steps.values())if(!(o.variants>1)&&o.actions.length!==0&&hr(r.actions,o.actions)){n.push({index:s,from:r.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),n}function Sr(t){if(!Ft.existsSync(t))return[];let e=[];for(let n of Ft.readdirSync(t,{recursive:!0})){let r=String(n);te(r)&&e.push(Ge.join(t,r))}return e.sort()}function z(t,e,n){let r=Ge.isAbsolute(e)?e:Ge.join(t,e),s=Sr(r),o=n??J(s.filter(c=>c.endsWith(".saffron")),t),i=new Map,a=new Map;for(let c of s)for(let u of K(c,t,o)){let h=`${u.featurePath}::${u.name}`;for(let f of u.steps){let m=i.get(f.text);if(m||(m={text:f.text,keywords:new Map,kind:f.kind,scenarios:new Set,files:new Set,fromStepSet:f.source?.fromStepSet},i.set(f.text,m)),m.keywords.set(f.keyword,(m.keywords.get(f.keyword)??0)+1),m.scenarios.add(h),f.source&&m.files.add(f.source.file),f.source?.fromStepSet){let w=a.get(f.source.fromStepSet);w||a.set(f.source.fromStepSet,w=new Set),w.add(h)}}}let d=ne(t),p=new Set(d.divergent),g=[...i.values()].map(c=>({text:c.text,keyword:[...c.keywords.entries()].sort((u,h)=>h[1]-u[1])[0][0],kind:c.kind,status:p.has(c.text)?"divergent":d.steps.has(c.text)?"recorded":"unrecorded",usage:c.scenarios.size,files:[...c.files].sort(),fromStepSet:c.fromStepSet})).sort((c,u)=>u.usage-c.usage||c.text.localeCompare(u.text)),l=[...o.values()].map(c=>({name:c.name,file:c.file,line:c.line,steps:c.steps.map(u=>u.text),usage:a.get(c.name)?.size??0})).sort((c,u)=>u.usage-c.usage||c.name.localeCompare(u.name));return{steps:g,stepSets:l,scannedFiles:s.length,scannedCaches:d.scannedCaches}}function Pe(t,e){let n=e.toLowerCase();return{...t,steps:t.steps.filter(r=>r.text.toLowerCase().includes(n)),stepSets:t.stepSets.filter(r=>r.name.toLowerCase().includes(n)||r.steps.some(s=>s.toLowerCase().includes(n)))}}function jt(t){let e={},n="saffron,feature,gherkin,cucumber";for(let r of t.steps)e[`step: ${r.text}`]={scope:n,prefix:r.text,body:[r.text.replace(/\$/g,"\\$")],description:`${r.status} \xB7 used in ${r.usage} scenario(s)`};for(let r of t.stepSets)e[`StepSet: ${r.name}`]={scope:n,prefix:`StepSet ${r.name}`,body:[`StepSet ${r.name}`],description:`step set (${r.steps.length} steps) \u2014 ${r.file}`};return JSON.stringify(e,null,2)+`
6
+ `}import{Server as xr}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as br}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as vr,ListToolsRequestSchema as wr}from"@modelcontextprotocol/sdk/types.js";async function It(t,e){let n=new xr({name:"saffron",version:"0.1.0"},{capabilities:{tools:{}}});n.setRequestHandler(wr,async()=>({tools:[{name:"search_steps",description:"Search the project's step vocabulary. Returns known Gherkin steps with status (recorded = replays at zero tokens, divergent, unrecorded), usage counts, and source files. ALWAYS reuse an existing wording exactly instead of inventing a near-duplicate \u2014 exact step text is the replay-cache identity, so a new wording costs a fresh AI recording.",inputSchema:{type:"object",properties:{query:{type:"string",description:"Case-insensitive substring filter over step text; omit to list everything."}}}},{name:"list_step_sets",description:"List the project's StepSet definitions (named reusable step sequences from .saffron files) with their steps, defining file, and usage. Invoke one inside a scenario with a line reading exactly: StepSet <name>",inputSchema:{type:"object",properties:{}}}]})),n.setRequestHandler(vr,async r=>{let s=z(t,e);if(r.params.name==="search_steps"){let o=r.params.arguments?.query,i=o?Pe(s,o):s;return{content:[{type:"text",text:JSON.stringify({steps:i.steps,scannedFiles:i.scannedFiles,scannedCaches:i.scannedCaches},null,2)}]}}return r.params.name==="list_step_sets"?{content:[{type:"text",text:JSON.stringify({stepSets:s.stepSets},null,2)}]}:{content:[{type:"text",text:`Unknown tool: ${r.params.name}`}],isError:!0}}),await n.connect(new br)}import{query as kr}from"@anthropic-ai/claude-agent-sdk";function $r(t,e){let n=e.steps.map(s=>`- [${s.status}] ${s.keyword} ${s.text}`).join(`
7
+ `),r=e.stepSets.map(s=>`- StepSet ${s.name}
8
+ ${s.steps.map(o=>` ${o}`).join(`
9
9
  `)}`).join(`
10
10
  `);return["Draft a Saffron feature file from the requirements below.","","EXISTING STEP VOCABULARY \u2014 reuse these wordings EXACTLY (character for character) wherever they express what a step needs to do. Steps marked [recorded] or [divergent] already have replay recordings: reusing them makes the new file replay almost for free. Only invent a new wording when nothing in the vocabulary expresses the intent, and then follow the same style:",n.length>0?n:"(none yet \u2014 this is the project's first file)","",e.stepSets.length>0?`EXISTING STEP SETS \u2014 invoke with a line reading exactly \`StepSet <name>\` instead of repeating their steps:
11
- ${r}`:void 0,"","RULES:","1. Output ONLY the feature file content \u2014 no prose, no code fences, no explanations.","2. Gherkin/.saffron syntax: Feature:, Scenario:, Scenario Outline: + Examples, Given/When/Then/And/But. Prefer concrete scenarios; use an Outline only when the requirements list variations of the same flow.","3. Then steps are assertions \u2014 state exactly what must be true, one condition per step. End every scenario with at least one Then.","4. If the same 3+ step sequence would appear in two or more scenarios, define it once as a StepSet (first step a guard, last step an exit assertion) and invoke it. Definition uses a colon (StepSet: Name), invocation does not (StepSet Name).",'5. Use 2-column data tables for key/value input, header tables for lists of records, and """ doc strings for free text.',"6. Steps are natural language for an AI browser agent \u2014 never CSS selectors or code.","","REQUIREMENTS:",t.trim()].filter(o=>o!==void 0).join(`
12
- `)}async function Tt(t,e,n){let r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},o="",s=fr({prompt:gr(t,e),options:{model:n,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],allowedTools:[],systemPrompt:"You are Saffron's feature-file author. You write clean, assertive Gherkin that reuses the project's exact step vocabulary. You output only file content."}});for await(let i of s)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(o+=a.text);else i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0));return o=o.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:o+`
13
- `,usage:r}}import At from"node:fs";import te from"node:path";import{createConnection as mr,ProposedFeatures as hr,TextDocuments as yr,TextDocumentSyncKind as Sr,CompletionItemKind as le,InsertTextFormat as Et,DiagnosticSeverity as ve,MarkupKind as Pt}from"vscode-languageserver/node";import{TextDocument as xr}from"vscode-languageserver-textdocument";var Ct=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,br=/^\s*StepSet:\s*(.+?)\s*$/,ze=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,Ve={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function vr(t,e){let n=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(t),o=n(e);if(r.size===0||o.size===0)return 0;let s=0;for(let i of r)o.has(i)&&s++;return s/(r.size+o.size-s)}function jt(t,e){let n=mr(hr.all,process.stdin,process.stdout),r=new yr(xr),o={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},s=new Map,i,a=()=>{try{let c=te.isAbsolute(e)?e:te.join(t,e);s=X(ye(c),t),o=Y(t,e,s)}catch(c){n.console.warn(`saffron vocabulary rebuild: ${String(c)}`)}for(let c of r.all())m(c)},d=()=>{clearTimeout(i),i=setTimeout(a,300)};function p(c){return o.steps.find(l=>l.text===c)?.status}function m(c){if(!c.uri.endsWith(".saffron"))return;let l=[],u=c.getText().split(`
14
- `),h=new Map;for(let f=0;f<u.length;f++){let g=u[f],v={start:{line:f,character:0},end:{line:f,character:g.length}},S=g.match(ze);S&&!s.has(S[3])&&l.push({range:v,severity:ve.Error,source:"saffron",message:`StepSet "${S[3]}" is not defined anywhere in the project.`});let y=g.match(br);if(y){let b=s.get(y[1]),F=te.relative(t,new URL(c.uri).pathname).replace(/\\/g,"/");(h.has(y[1])||b&&b.file!==F)&&l.push({range:v,severity:ve.Error,source:"saffron",message:`StepSet "${y[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${y[1]}".`}),h.set(y[1],f)}let A=g.trim();if(A&&!S&&!y){for(let[b]of s)if(A.toLowerCase()===b.toLowerCase()){l.push({range:v,severity:ve.Warning,source:"saffron",message:`Did you mean "StepSet ${b}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let w=g.match(Ct);if(w&&p(w[3])!=="recorded"){for(let b of o.steps)if(!(b.text===w[3]||b.status==="unrecorded")&&vr(b.text,w[3])>=.7){l.push({range:{start:{line:f,character:g.indexOf(w[3])},end:{line:f,character:g.length}},severity:ve.Information,source:"saffron",message:`Similar to the ${b.status} step "${b.text}" (\xD7${b.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}n.sendDiagnostics({uri:c.uri,diagnostics:l})}n.onInitialize(()=>(a(),{capabilities:{textDocumentSync:Sr.Incremental,completionProvider:{triggerCharacters:[" "]},definitionProvider:!0,hoverProvider:!0}})),n.onCompletion(c=>{let l=r.get(c.textDocument.uri);if(!l)return[];let u=l.getText({start:{line:c.position.line,character:0},end:c.position});return wr(l.uri,u,c.position.line,o)}),n.onDefinition(c=>{let l=r.get(c.textDocument.uri);if(!l)return;let u=l.getText({start:{line:c.position.line,character:0},end:{line:c.position.line+1,character:0}}).replace(/\n$/,""),h=Ft(u,c.position.character);if(h){let S=It(t,h.name);return S?{uri:`file://${S.file}`,range:{start:{line:S.line,character:0},end:{line:S.line,character:0}}}:void 0}let f=u.match(ze);if(!f)return;let g=s.get(f[3]);return g?{uri:`file://${te.join(t,g.file)}`,range:{start:{line:g.line-1,character:0},end:{line:g.line-1,character:0}}}:void 0}),n.onHover(c=>{let l=r.get(c.textDocument.uri);if(!l)return;{let g=l.getText({start:{line:c.position.line,character:0},end:{line:c.position.line+1,character:0}}).replace(/\n$/,""),v=Ft(g,c.position.character);if(v){let S=It(t,v.name),y=S?`defined in ${te.relative(t,S.file)} (line ${S.line+1}); the value is resolved at replay and never shown here`:"not found in .env or .env.example; export it in the shell or add it to a git-ignored .env";return{contents:{kind:"markdown",value:`**{env:${v.name}}** ${y}.`}}}}let u=l.getText({start:{line:c.position.line,character:0},end:{line:c.position.line+1,character:0}}).replace(/\n$/,""),h=u.match(ze);if(h){let g=o.stepSets.find(S=>S.name===h[3]);if(!g)return;let v=g.steps.map(S=>`- ${Ve[p(S)??"unrecorded"][0]} ${S}`).join(`
15
- `);return{contents:{kind:Pt.Markdown,value:`**StepSet ${g.name}** \u2014 expands to:
11
+ ${r}`:void 0,"","RULES:","1. Output ONLY the feature file content \u2014 no prose, no code fences, no explanations.","2. Gherkin/.saffron syntax: Feature:, Scenario:, Scenario Outline: + Examples, Given/When/Then/And/But. Prefer concrete scenarios; use an Outline only when the requirements list variations of the same flow.","3. Then steps are assertions \u2014 state exactly what must be true, one condition per step. End every scenario with at least one Then.","4. If the same 3+ step sequence would appear in two or more scenarios, define it once as a StepSet (first step a guard, last step an exit assertion) and invoke it. Definition uses a colon (StepSet: Name), invocation does not (StepSet Name).",'5. Use 2-column data tables for key/value input, header tables for lists of records, and """ doc strings for free text.',"6. Steps are natural language for an AI browser agent \u2014 never CSS selectors or code.","","REQUIREMENTS:",t.trim()].filter(s=>s!==void 0).join(`
12
+ `)}async function _t(t,e,n){let r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},s="",o=kr({prompt:$r(t,e),options:{model:n,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],allowedTools:[],systemPrompt:"You are Saffron's feature-file author. You write clean, assertive Gherkin that reuses the project's exact step vocabulary. You output only file content."}});for await(let i of o)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(s+=a.text);else i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0));return s=s.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:s+`
13
+ `,usage:r}}import Dt from"node:fs";import pe from"node:path";import{createConnection as Rr,ProposedFeatures as Tr,TextDocuments as Er,TextDocumentSyncKind as Ar,CompletionItemKind as xe,InsertTextFormat as Ot,DiagnosticSeverity as Ce,MarkupKind as Mt}from"vscode-languageserver/node";import{TextDocument as Pr}from"vscode-languageserver-textdocument";var Nt=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,Cr=/^\s*StepSet:\s*(.+?)\s*$/,Ye=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,Je={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function Ke(t,e){let n=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(t),s=n(e);if(r.size===0||s.size===0)return 0;let o=0;for(let i of r)s.has(i)&&o++;return o/(r.size+s.size-o)}function Vt(t,e){let n=Rr(Tr.all,process.stdin,process.stdout),r=new Er(Pr),s={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},o=new Map,i,a=()=>{try{let l=pe.isAbsolute(e)?e:pe.join(t,e);o=J(le(l),t),s=z(t,e,o)}catch(l){n.console.warn(`saffron vocabulary rebuild: ${String(l)}`)}for(let l of r.all())g(l)},d=()=>{clearTimeout(i),i=setTimeout(a,300)};function p(l){return s.steps.find(c=>c.text===l)?.status}function g(l){if(!l.uri.endsWith(".saffron"))return;let c=[],u=l.getText().split(`
14
+ `),h=new Map;for(let f=0;f<u.length;f++){let m=u[f],w={start:{line:f,character:0},end:{line:f,character:m.length}},x=m.match(Ye);x&&!o.has(x[3])&&c.push({range:w,severity:Ce.Error,source:"saffron",message:`StepSet "${x[3]}" is not defined anywhere in the project.`});let b=m.match(Cr);if(b){let S=o.get(b[1]),T=pe.relative(t,new URL(l.uri).pathname).replace(/\\/g,"/");(h.has(b[1])||S&&S.file!==T)&&c.push({range:w,severity:Ce.Error,source:"saffron",message:`StepSet "${b[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${b[1]}".`}),h.set(b[1],f)}let C=m.trim();if(C&&!x&&!b){for(let[S]of o)if(C.toLowerCase()===S.toLowerCase()){c.push({range:w,severity:Ce.Warning,source:"saffron",message:`Did you mean "StepSet ${S}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let y=m.match(Nt);if(y&&p(y[3])!=="recorded"){for(let S of s.steps)if(!(S.text===y[3]||S.status==="unrecorded")&&Ke(S.text,y[3])>=.7){c.push({range:{start:{line:f,character:m.indexOf(y[3])},end:{line:f,character:m.length}},severity:Ce.Information,source:"saffron",message:`Similar to the ${S.status} step "${S.text}" (\xD7${S.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}n.sendDiagnostics({uri:l.uri,diagnostics:c})}n.onInitialize(()=>(a(),{capabilities:{textDocumentSync:Ar.Incremental,completionProvider:{triggerCharacters:[" "]},definitionProvider:!0,hoverProvider:!0}})),n.onCompletion(l=>{let c=r.get(l.textDocument.uri);if(!c)return[];let u=c.getText({start:{line:l.position.line,character:0},end:l.position});return Fr(c.uri,u,l.position.line,s)}),n.onDefinition(l=>{let c=r.get(l.textDocument.uri);if(!c)return;let u=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,""),h=Ut(u,l.position.character);if(h){let x=Lt(t,h.name);return x?{uri:`file://${x.file}`,range:{start:{line:x.line,character:0},end:{line:x.line,character:0}}}:void 0}let f=u.match(Ye);if(!f)return;let m=o.get(f[3]);return m?{uri:`file://${pe.join(t,m.file)}`,range:{start:{line:m.line-1,character:0},end:{line:m.line-1,character:0}}}:void 0}),n.onHover(l=>{let c=r.get(l.textDocument.uri);if(!c)return;{let m=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,""),w=Ut(m,l.position.character);if(w){let x=Lt(t,w.name),b=x?`defined in ${pe.relative(t,x.file)} (line ${x.line+1}); the value is resolved at replay and never shown here`:"not found in .env or .env.example; export it in the shell or add it to a git-ignored .env";return{contents:{kind:"markdown",value:`**{env:${w.name}}** ${b}.`}}}}let u=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,""),h=u.match(Ye);if(h){let m=s.stepSets.find(x=>x.name===h[3]);if(!m)return;let w=m.steps.map(x=>`- ${Je[p(x)??"unrecorded"][0]} ${x}`).join(`
15
+ `);return{contents:{kind:Mt.Markdown,value:`**StepSet ${m.name}** \u2014 expands to:
16
16
 
17
- ${v}`}}}let f=u.match(Ct);if(f){let g=o.steps.find(v=>v.text===f[3]);return g?{contents:{kind:Pt.Markdown,value:`${Ve[g.status]}
17
+ ${w}`}}}let f=u.match(Nt);if(f){let m=s.steps.find(w=>w.text===f[3]);return m?{contents:{kind:Mt.Markdown,value:`${Je[m.status]}
18
18
 
19
- Used in ${g.usage} place(s): ${g.files.join(", ")}${g.fromStepSet?`
19
+ Used in ${m.usage} place(s): ${m.files.join(", ")}${m.fromStepSet?`
20
20
 
21
- Defined in StepSet **${g.fromStepSet}**`:""}`}}:void 0}}),r.onDidOpen(c=>m(c.document)),r.onDidChangeContent(c=>m(c.document)),r.onDidSave(()=>d()),n.onDidChangeWatchedFiles(()=>d()),r.listen(n),n.listen()}function Ft(t,e){let n=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,r;for(;r=n.exec(t);)if(e>=r.index&&e<=r.index+r[0].length)return{name:r[1],start:r.index,end:r.index+r[0].length}}function It(t,e){for(let n of[".env",".env.example"]){let r=te.join(t,n);if(!At.existsSync(r))continue;let s=At.readFileSync(r,"utf8").split(`
22
- `).findIndex(i=>new RegExp(`^\\s*(?:export\\s+)?${e}\\s*=`).test(i));if(s!==-1)return{file:r,line:s}}}function wr(t,e,n,r){let o=t.endsWith(".saffron");if(/^\s*StepSet\s+/.test(e)&&o)return r.stepSets.map(a=>({label:a.name,kind:le.Function,detail:`step set \xB7 ${a.steps.length} step(s) \xB7 \xD7${a.usage}`,documentation:a.steps.join(`
23
- `)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(e))return r.steps.map((a,d)=>({label:a.text,kind:le.Text,detail:`${Ve[a.status]} \xB7 \xD7${a.usage}`,sortText:String(d).padStart(5,"0"),filterText:a.text}));let i=e.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(i.length>0&&("stepset:".startsWith(i)||i.startsWith("stepset"))&&o){let a={start:{line:n,character:e.length-e.replace(/^\s*/,"").length},end:{line:n,character:e.length}},d=[{label:"StepSet: \u2026",kind:le.Keyword,detail:"define a reusable step set",documentation:`StepSet: Name
21
+ Defined in StepSet **${m.fromStepSet}**`:""}`}}:void 0}}),r.onDidOpen(l=>g(l.document)),r.onDidChangeContent(l=>g(l.document)),r.onDidSave(()=>d()),n.onDidChangeWatchedFiles(()=>d()),r.listen(n),n.listen()}function Ut(t,e){let n=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,r;for(;r=n.exec(t);)if(e>=r.index&&e<=r.index+r[0].length)return{name:r[1],start:r.index,end:r.index+r[0].length}}function Lt(t,e){for(let n of[".env",".env.example"]){let r=pe.join(t,n);if(!Dt.existsSync(r))continue;let o=Dt.readFileSync(r,"utf8").split(`
22
+ `).findIndex(i=>new RegExp(`^\\s*(?:export\\s+)?${e}\\s*=`).test(i));if(o!==-1)return{file:r,line:o}}}function Fr(t,e,n,r){let s=t.endsWith(".saffron");if(/^\s*StepSet\s+/.test(e)&&s)return r.stepSets.map(a=>({label:a.name,kind:xe.Function,detail:`step set \xB7 ${a.steps.length} step(s) \xB7 \xD7${a.usage}`,documentation:a.steps.join(`
23
+ `)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(e))return r.steps.map((a,d)=>({label:a.text,kind:xe.Text,detail:`${Je[a.status]} \xB7 \xD7${a.usage}`,sortText:String(d).padStart(5,"0"),filterText:a.text}));let i=e.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(i.length>0&&("stepset:".startsWith(i)||i.startsWith("stepset"))&&s){let a={start:{line:n,character:e.length-e.replace(/^\s*/,"").length},end:{line:n,character:e.length}},d=[{label:"StepSet: \u2026",kind:xe.Keyword,detail:"define a reusable step set",documentation:`StepSet: Name
24
24
  Given \u2026
25
25
  When \u2026
26
26
 
27
- Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:Et.Snippet,textEdit:{range:a,newText:"StepSet: ${1:Name}\n "}}];r.stepSets.length===0&&d.push({label:"StepSet \u2026",kind:le.Keyword,detail:"invoke a step set (none defined yet)",filterText:"step set stepset",sortText:"1",insertTextFormat:Et.Snippet,textEdit:{range:a,newText:"StepSet ${1:Name}"}});for(let p of r.stepSets)d.push({label:`StepSet ${p.name}`,kind:le.Function,detail:`invoke step set \xB7 ${p.steps.length} step(s)`,documentation:p.steps.join(`
28
- `),filterText:`step set ${p.name}`,sortText:`1${p.name}`,textEdit:{range:a,newText:`StepSet ${p.name}`}});return d}return[]}import _t from"node:fs";import kr from"node:path";var Ot=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function de(t){return t.replace(Ot,(e,n)=>{let r=process.env[n];if(r===void 0)throw new Error(`environment variable ${n} is not set (referenced as {env:${n}}) \u2014 export it or add it to .env`);return r})}function qe(t){let e=new Set;for(let n of t)if(n)for(let r of n.matchAll(Ot))e.add(r[1]);return e}function He(t){let e=[];for(let n of t.steps){e.push(n.text,n.resolvedText),n.docString&&e.push(n.docString);for(let r of n.table??[])e.push(...r)}return e}function pe(t){let e=new Map;for(let n of qe(He(t))){let r=process.env[n];r!==void 0&&e.set(n,r)}return e}function ne(t,e){let n=t;for(let[r,o]of[...e.entries()].sort((s,i)=>i[1].length-s[1].length))o.length<4||(n=n.split(o).join(`{env:${r}}`));return n}function Dt(t){let e=kr.join(t,".env");if(_t.existsSync(e))for(let n of _t.readFileSync(e,"utf8").split(`
29
- `)){let r=n.trim();if(!r||r.startsWith("#"))continue;let o=r.indexOf("=");if(o<=0)continue;let s=r.slice(0,o).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s))continue;let i=r.slice(o+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[s]===void 0&&(process.env[s]=i)}}import Zt from"node:fs";import Mr from"node:net";import en from"node:path";import{chromium as Nr,firefox as Ur,webkit as Lr}from"playwright";import B from"node:fs";import we from"node:path";function Mt(t,e){B.mkdirSync(we.dirname(t),{recursive:!0}),B.writeFileSync(t,`${JSON.stringify(e,null,2)}
30
- `)}function re(t){return JSON.parse(B.readFileSync(t,"utf8"))}function ue(t){let e=we.join(t,".saffron","proposals");if(!B.existsSync(e))return[];let n=[];for(let r of B.readdirSync(e)){let o=we.join(e,r);if(B.statSync(o).isDirectory())for(let s of B.readdirSync(o)){if(!s.endsWith(".json"))continue;let i=we.join(o,s);n.push({file:i,proposal:re(i)})}}return n}function Nt(t,e){let n=re(e),r=q(t,n.cache.feature,n.cache.scenario);return ee(r,n.cache),B.rmSync(e),r}function Ut(t){B.rmSync(t)}import Bt from"node:path";var $r=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function Rr(t,e){let n=String(t.getFullYear()),r=String(t.getMonth()+1).padStart(2,"0"),o=String(t.getDate()).padStart(2,"0");return e.replaceAll("YYYY",n).replaceAll("MM",r).replaceAll("DD",o).replaceAll("M",String(t.getMonth()+1)).replaceAll("D",String(t.getDate()))}function We(t,e=new Date){return t.replace($r,(n,r,o)=>{let s=new Date(e);return s.setDate(s.getDate()+(r?parseInt(r,10):0)),Rr(s,o??"YYYY-MM-DD")})}var Tr=/\b(\d{4})-(\d{2})-(\d{2})\b/g,Ar=400;function Lt(t,e=new Date){let n=new Date(e.getFullYear(),e.getMonth(),e.getDate());return t.replace(Tr,(r,o,s,i)=>{let a=new Date(Number(o),Number(s)-1,Number(i));if(Number.isNaN(a.getTime()))return r;let d=Math.round((a.getTime()-n.getTime())/864e5);return Math.abs(d)>Ar?r:d===0?"{date}":`{date${d>0?"+":""}${d}}`})}import{createHash as Er}from"node:crypto";async function J(t){let e;try{e=await t.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let n=e.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${Er("sha256").update(n).digest("hex")}`}var Je=t=>new Promise(e=>setTimeout(e,t)),Ye=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(e,n){this.context=e,this.listener=r=>{let o={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(o),n){let s=r.headers()["content-type"]??"";/json|text|xml/i.test(s)&&r.text().then(i=>{o.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},e.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(e=>e.seq>this.prevStepSeq)}all(){return this.entries}};function Pr(t,e){if(!e)return t>=200&&t<300;let n=/^([1-5])xx$/i.exec(e.trim());if(n){let r=Number(n[1])*100;return t>=r&&t<r+100}return t===Number(e.trim())}function zt(t,e,n,r,o){return!(!e.test(t.url)||n&&t.method.toUpperCase()!==n.toUpperCase()||!Pr(t.status,r)||o&&!o.test(t.body??""))}var Cr=(t,e)=>`response matching ${t.method??"any-method"} /${e}/ status ${t.status??"2xx"}${t.pattern?` body /${t.pattern}/`:""}`;function Vt(t,e,n,r=!1){let o=a=>de(We(H(a,n))),s=[],i=a=>{if(e.role){let d=e.nameRegex?new RegExp(o(e.nameRegex),"i"):e.name?o(e.name):void 0;s.push(a.getByRole(e.role,{name:d,exact:!1}))}e.label&&s.push(a.getByLabel(o(e.label))),e.placeholder&&s.push(a.getByPlaceholder(o(e.placeholder))),e.text&&s.push(a.getByText(o(e.text))),e.testId&&s.push(a.getByTestId(o(e.testId)));for(let d of e.fallbackSelectors??[])s.push(a.locator(d))};if(e.frame)i(t.frameLocator(o(e.frame)));else if(i(t),r)for(let a of t.frames())a!==t.mainFrame()&&i(a);if(s.length===0)throw new Error("Action target has neither role nor fallback selectors");return s}async function z(t,e,n){let r=Date.now()+e,o;for(;Date.now()<r;){try{if(await t())return}catch(s){o=s}await Je(100)}throw new Error(`Timed out waiting for ${n}${o?` (${String(o)})`:""}`)}async function U(t,e){let n;for(let r of t)try{await e(r.first());return}catch(o){n=o}throw n}var Ge=t=>t.replace(/\s+/g," ").trim();async function Ke(t,e,n,r,o,s=new Map,i){let a=f=>de(We(H(f,n))),d=e.value!==void 0?a(e.value):void 0,p=e.url!==void 0?a(e.url):void 0,m=e.action==="expectVisible"||e.action==="expectText"||e.action==="expectValue"||e.action==="expectMatches"||e.action==="expectAttribute"||e.action==="captureText",c=e.target?Vt(t,e.target,n,m):[],l={timeout:r},u={timeout:Math.min(1e3,r)},h=f=>async()=>{for(let g of c)try{if(await f(g.first()))return!0}catch{}return!1};switch(e.action){case"goto":{if(!p)throw new Error("goto requires url");let f=/^https?:\/\//.test(p)?p:new URL(p,o).toString();await t.goto(f,l);return}case"click":return U(c,f=>f.click(l));case"fill":return U(c,f=>f.fill(d??"",l));case"press":return c.length>0?U(c,f=>f.press(d??"",l)):t.keyboard.press(d??"");case"selectOption":return U(c,async f=>{await f.selectOption(d??"",l)});case"check":return U(c,f=>f.check(l));case"uncheck":return U(c,f=>f.uncheck(l));case"hover":return U(c,f=>f.hover(l));case"waitFor":return c.length>0?U(c,f=>f.waitFor({state:"visible",timeout:r})):Je(Math.min(Number(d??"0"),r));case"expectVisible":return z(h(f=>f.isVisible()),r,"target to be visible");case"expectNotVisible":return U(c,f=>f.waitFor({state:"hidden",timeout:r}));case"expectText":return z(h(async f=>(await f.textContent(u)??"").includes(d??"")),r,`text "${d}"`);case"expectValue":return z(h(async f=>await f.inputValue(u)===(d??"")),r,`input value "${d}"`);case"expectUrl":{let f=p??d??"";return z(async()=>t.context().pages().some(g=>g.url().includes(f)),r,`URL containing "${f}" in any tab (current: ${t.context().pages().map(g=>g.url()).join(", ")})`)}case"handleDialog":{let f=(d??"accept")!=="dismiss",g=e.promptText?a(e.promptText):void 0;t.once("dialog",v=>{f?v.accept(g):v.dismiss()});return}case"uploadFile":{let f=(d??"").split(`
31
- `).filter(Boolean).map(g=>Bt.isAbsolute(g)?g:Bt.resolve(g));if(f.length===0)throw new Error("uploadFile requires a path");if(c.length>0)return U(c,g=>g.setInputFiles(f,l));t.once("filechooser",g=>{g.setFiles(f)});return}case"dragTo":{if(!e.toTarget)throw new Error("dragTo requires toTarget");let f=Vt(t,e.toTarget,n);return U(c,g=>g.dragTo(f[0].first(),l))}case"captureText":{if(!e.saveAs)throw new Error("captureText requires saveAs");return z(h(async f=>{let g=Ge(await f.textContent(u)??"");return g===""?!1:(s.set(e.saveAs,g),!0)}),r,`non-empty text to capture as "${e.saveAs}"`)}case"expectDiffers":{if(!e.compareTo)throw new Error("expectDiffers requires compareTo");let f=s.get(e.compareTo);if(f===void 0)throw new Error(`expectDiffers: no captured value named "${e.compareTo}"`);if(c.length>0)return z(h(async S=>Ge(await S.textContent(u)??"")!==f),r,`target text to differ from "${e.compareTo}" (${f})`);let g=d??"",v=s.get(g);if(v===void 0)throw new Error(`expectDiffers: no captured value named "${g}"`);if(v===f)throw new Error(`expectDiffers: "${g}" (${v}) equals "${e.compareTo}" (${f})`);return}case"expectAttribute":{if(!e.attribute)throw new Error("expectAttribute requires attribute");if(!e.pattern)throw new Error("expectAttribute requires pattern");let f=new RegExp(e.pattern,"i");return z(h(async g=>f.test(await g.getAttribute(e.attribute,u)??"")),r,`attribute ${e.attribute} to match /${e.pattern}/i`)}case"expectMatches":{if(!e.pattern)throw new Error("expectMatches requires pattern");let f=new RegExp(e.pattern,"i");return z(h(async g=>f.test(Ge(await g.textContent(u)??""))),r,`target text to match /${e.pattern}/i`)}case"waitForResponse":case"expectResponse":{if(!e.urlPattern)throw new Error(`${e.action} requires urlPattern`);let f=new RegExp(a(e.urlPattern)),g=e.pattern?new RegExp(a(e.pattern)):void 0;if(!i){await t.waitForResponse(S=>zt({seq:0,url:S.url(),method:S.request().method(),status:S.status()},f,e.method,e.status,void 0),l);return}let v=()=>e.action==="expectResponse"?i.all():i.sincePreviousStep();return z(async()=>v().some(S=>zt(S,f,e.method,e.status,g)),r,Cr(e,e.urlPattern))}default:throw new Error(`Unknown action type: ${e.action}`)}}function qt(t,e){return t.actions.some(n=>[n.value,n.url,n.pattern,n.target?.name,n.target?.nameRegex,n.target?.label,n.target?.text,n.target?.placeholder].some(r=>r?.includes(e)))}function Fr(t,e){return e.steps.length!==t.steps.length?!1:e.steps.every((n,r)=>{let o=t.steps[r];if(n.gherkin!==o.text)return!1;let s=n.table,i=o.table;if(!!s!=!!i)return!1;if(s&&i){let a=Object.keys(N(s)),d=Object.keys(N(i));if(a.length>0&&d.length>0){if(JSON.stringify(a)!==JSON.stringify(d))return!1}else if(JSON.stringify(s)!==JSON.stringify(i))return!1}if(n.docString!==void 0!=(o.docString!==void 0))return!1;if(n.docString!==void 0&&o.docString!==void 0&&n.docString!==o.docString){let a=e.steps.some(p=>qt(p,"<docstring>")),d=e.steps.some(p=>qt(p,n.docString));if(!a||d)return!1}return!0})}function Qe(t,e){let n=t.steps[e],r=n?.resolvedTable??n?.table;return{...t.params,...Q(t.steps),...N(r),...W(n?.resolvedDocString??n?.docString)}}async function fe(t,e,n,r={}){let o=r.actionTimeoutMs??5e3,s=r.retries??1,i=[],a=r.vars??new Map,d=()=>Object.fromEntries(a),p=n.steps.some(u=>u.actions.some(h=>(h.action==="waitForResponse"||h.action==="expectResponse")&&h.pattern!==void 0)),m=new Ye;m.attach(t.context(),p);let c=t,l=()=>{let u=t.context().pages(),h=u[u.length-1];h&&h!==c&&!h.isClosed()&&(c=h)};if(!Fr(e,n))return m.detach(),{status:"stale",stepResults:i,captured:d()};try{for(let u=0;u<n.steps.length;u++){let h=n.steps[u],f=Qe(e,u);m.markStep();let g=!1,v=0;for(let S=0;S<h.actions.length;S++){let y=h.actions[S];l(),y.pageFingerprint&&await J(c)!==y.pageFingerprint&&(g=!0);let A,w=!1;for(let b=0;b<=s&&!w;b++){b>0&&(v++,await Je(250*b),l());try{await Ke(c,y,f,o,r.baseURL,a,m),w=!0}catch(F){A=F}}if(!w){let b=A instanceof Error?A.message:String(A);i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"failed",drift:g,...v?{retries:v}:{},error:b});for(let F=u+1;F<n.steps.length;F++)i.push({gherkin:n.steps[F].gherkin,kind:n.steps[F].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:u,actionIndex:S,stepText:h.gherkin,kind:h.kind,error:b},stepResults:i,captured:d()}}}i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"passed",drift:g,...v?{retries:v}:{}})}return{status:"passed",stepResults:i,captured:d()}}finally{m.detach()}}var Ht=new Set(["expectVisible","expectNotVisible","expectText","expectUrl","expectValue","expectDiffers","expectMatches","expectAttribute","expectResponse"]);var ke=class{steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(e){this.steps=e.steps.map(n=>({gherkin:n.text,keyword:n.keyword,kind:n.kind,table:n.table,docString:n.docString,fromStepSet:n.source?.fromStepSet,actions:[]}))}addArmedAction(e){if(this.done)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r||r.actions.some(i=>i.action===e.action&&i.value===e.value&&i.promptText===e.promptText))return;let s=Math.max(r.actions.length-1,0);r.actions.splice(s,0,e)}addActions(e){if(this.done||e.length===0)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];r&&(this.pendingFingerprint&&e[0]&&(e[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),r.actions.push(...e))}},K=t=>t.replace(/\\(['"\\])/g,"$1");function Ir(t){let e=t.match(/'((?:[^'\\]|\\.)*)'/);return e?K(e[1]):void 0}function Xe(t){let e={},n=t.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(e.frame=K(n[1]??n[2]),t=t.slice((n.index??0)+n[0].length));let r=t.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(r){e.role=r[1];let i=(r[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(e.name=K(i[1]))}let o=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of o){let d=t.match(i);d&&(e[a]=K(d[1]))}let s=t.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return s&&(e.fallbackSelectors=[K(s[1])]),Object.keys(e).length>0?e:void 0}function jr(t,e){if(e&&t.startsWith(e)){let n=t.slice(e.length);return n.startsWith("/")?n:`/${n}`}return t}var _r={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function Or(t,e){let n=t.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:jr(K(n[1]),e)};let r=t.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:K(r[1])};let o=t.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(o){let l=Xe(o[1]),u=Xe(o[2]);if(l&&u)return{action:"dragTo",target:l,toTarget:u}}let s=t.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!s)return;let[,i,a,d]=s,p=Xe(i);if(!p)return;let m={action:_r[a],target:p},c=Ir(d);return c!==void 0&&a!=="click"&&a!=="dblclick"&&(m.value=c),m}function Wt(t,e){let n=[],r=Array.isArray(t)?t:t?.content;if(Array.isArray(r))for(let s of r){let i=s?.text;typeof i=="string"&&n.push(i)}else typeof t=="string"&&n.push(t);let o=[];for(let s of n)for(let i of s.split(`
32
- `)){let a=Or(i.trim(),e);a&&o.push(a)}return Dr(o)}function Dr(t){return t.filter((e,n)=>{let r=t[n+1];return!(e.action==="fill"&&(e.value??"")===""&&r?.action==="fill"&&JSON.stringify(r.target)===JSON.stringify(e.target))})}function Gt(t){let e=[];for(let n of t)if(/"[^"]+"|<[^<>]+>/.test(n.gherkin)){for(let o of n.actions)if(o.action==="fill"&&(o.value??"")===""){e.push(`Step "${n.gherkin}" recorded a fill with an empty value although the step passes one; the typed value was lost. Re-record with: saffron run --rerecord`);break}}return e}function Ze(t,e){let n=t;for(let[r,o]of Object.entries(e).filter(([,s])=>s.length>=3).sort((s,i)=>i[1].length-s[1].length))n=n.split(o).join(`<${r}>`);return n}function Yt(t,e){let n=Object.entries(e).filter(([,i])=>i.length>=3);if(n.length===0)return t;let r=i=>i===void 0?void 0:Ze(i,e),o=[...n].sort((i,a)=>a[1].length-i[1].length),s=i=>{if(i!==void 0){for(let[a,d]of o)if(i.includes(d))return`<${a}>`;return i}};return t.map(i=>({...i,actions:i.actions.map(a=>{let d=Ht.has(a.action)&&a.action!=="expectValue";return{...a,value:d?s(a.value):r(a.value),url:r(a.url),target:a.target?{...a.target,name:d?s(a.target.name):r(a.target.name),label:r(a.target.label),text:d?s(a.target.text):r(a.target.text),placeholder:r(a.target.placeholder)}:void 0}})}))}function Jt(t,e){let n=Q(e.steps);return t.map((r,o)=>{let s=e.steps[o],i=s?.resolvedTable??s?.table,a=s?.resolvedDocString??s?.docString,d=Object.entries({...n,...N(i),...W(a)}).filter(([,m])=>m.length>=3).sort((m,c)=>c[1].length-m[1].length);if(d.length===0)return r;let p=m=>{if(m===void 0)return;let c=m;for(let[l,u]of d)c=c.split(u).join(`<${l}>`);return c};return{...r,actions:r.actions.map(m=>({...m,value:p(m.value),url:p(m.url),target:m.target?{...m.target,name:p(m.target.name),label:p(m.target.label),text:p(m.target.text),placeholder:p(m.target.placeholder)}:void 0}))}})}function Kt(t,e=new Date){let n=r=>r===void 0?void 0:Lt(r,e);return t.map(r=>({...r,actions:r.actions.map(o=>({...o,value:n(o.value),url:n(o.url),target:o.target?{...o.target,name:n(o.target.name),nameRegex:n(o.target.nameRegex),label:n(o.target.label),text:n(o.target.text)}:void 0}))}))}function Qt(t,e){if(e.size===0)return t;let n=r=>r===void 0?void 0:ne(r,e);return t.map(r=>({...r,actions:r.actions.map(o=>({...o,value:n(o.value),url:n(o.url),pattern:n(o.pattern),target:o.target?{...o.target,name:n(o.target.name),nameRegex:n(o.target.nameRegex),label:n(o.target.label),text:n(o.target.text),placeholder:n(o.target.placeholder)}:void 0}))}))}function Xt(t,e){let n=e??{};if(t==="mcp__playwright__browser_handle_dialog"){let r={action:"handleDialog",value:n.accept?"accept":"dismiss"};return typeof n.promptText=="string"&&n.promptText&&(r.promptText=n.promptText),r}if(t==="mcp__playwright__browser_file_upload"){let r=Array.isArray(n.paths)?n.paths:[];if(r.length===0)return;let o=process.cwd()+"/";return{action:"uploadFile",value:r.map(i=>typeof i=="string"&&i.startsWith(o)?i.slice(o.length):i).join(`
33
- `)}}}var se={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function Br(){return new Promise((t,e)=>{let n=Mr.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>t(r))}),n.on("error",e)})}function ge(t){let e=t.contexts().flatMap(n=>n.pages());return[...e].reverse().find(n=>n.url()!=="about:blank")??e.at(-1)}function sn(t){let e=t.length;for(let n=t.length-1;n>=0&&t[n].kind==="assertion";n--)e=n;return e}function tn(t,e){return t[e]?.kind==="assertion"&&e>=sn(t)}function nn(t,e,n=!1,r){let o=sn(t);return t.map((s,i)=>{let a=e[i],d=a&&a.actions.length>0;return s.kind==="assertion"?n&&i<o&&d?a:s:d?r&&i===r.stepIndex&&r.actionIndex>0?{...a,actions:[...s.actions.slice(0,r.actionIndex),...a.actions]}:a:s})}function zr(t,e){return t.map((n,r)=>{let o=e[r];return o&&o.actions.length>0?o:n})}function rn(t,e){return{aiCalls:t.aiCalls+e.aiCalls,inputTokens:t.inputTokens+e.inputTokens,outputTokens:t.outputTokens+e.outputTokens,cacheReadTokens:t.cacheReadTokens+e.cacheReadTokens,cacheCreationTokens:t.cacheCreationTokens+e.cacheCreationTokens,costUsd:(t.costUsd??0)+(e.costUsd??0),models:t.models||e.models?[...new Set([...t.models??[],...e.models??[]])]:void 0}}var $e=class{constructor(e){this.options=e}options;browser;cdpEndpoint;stepIndex;get agent(){return this.options.agent}getStepIndex(){if(!this.stepIndex&&(this.stepIndex=G(this.options.projectRoot),this.stepIndex.steps.size>0)){let e=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${e}`)}return this.stepIndex}async recordWithSeeding(e,n){let r=this.getStepIndex(),o=e.steps.length,s={...e.params,...Q(e.steps)},i=e.steps.map(S=>{let y=r.steps.size>0?vt(r,S,s):void 0;return y?{gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:y.actions}:void 0}),a=e.steps.map(S=>({gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:[]})),d=new Map,p=[],m=[],c=[],l=[],u={...se},h=0,f=0,g=0,v=S=>({success:S,narrative:m.join(" ")||"Composed entirely from seeded recordings.",adaptations:p,suggestedFeatureEdit:l.length>0?l.join(`
34
- `):void 0,featureEdits:c,recordedSteps:a,announcedSteps:[],usage:u,seededCount:h});for(;g<o;){if(i[g]){let b=g;for(;b<o&&i[b];)b++;let F=i.slice(g,b),x={...e,steps:e.steps.slice(g,b)},R=await fe(n,x,this.buildCandidateCache(e,F),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,vars:d}),E=R.status==="passed"?b-g:R.failure?.stepIndex??0;for(let O=0;O<E;O++)a[g+O]=F[O];if(h+=E,R.status==="passed"){g=b;continue}let j=g+E;p.push(`Seeded recording for "${e.steps[j].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[j]=void 0,g=j;continue}if(!this.options.agent)return v(!1);f++;let S=g;for(;S<o&&!i[S];)S++;f>=3&&(S=o);let y=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:g>0?e.steps.slice(0,g).map(b=>b.resolvedText):void 0,recordOnly:S<o?{from:g,to:S-1}:void 0,takeFingerprint:async()=>J(ge(this.browser)??n)}));if(u=rn(u,y.usage),m.push(y.narrative),p.push(...y.adaptations),c.push(...y.featureEdits),y.suggestedFeatureEdit&&l.push(y.suggestedFeatureEdit),!y.success)return v(!1);let A=y.announcedSteps.filter(b=>b>=g&&b<o),w=Math.min(o-1,Math.max(A.length>0?Math.max(...A):S-1,g));for(let b=g;b<=w;b++)a[b]=y.recordedSteps[b];g=w+1}return v(!0)}async withDialogsVisible(e){let n=()=>{},r=[],o=[],s=i=>{i.on("dialog",n),r.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",s),o.push(i);for(let a of i.pages())s(a)}try{return await e()}finally{for(let i of o)i.off("page",s);for(let i of r)try{i.off("dialog",n)}catch{}}}async start(){let e=this.options.browser??"chromium";if(e==="chromium"){let n=await Br();this.browser=await Nr.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${n}`]}),this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(e==="firefox"?Ur:Lr).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let e=en.isAbsolute(this.options.storageState)?this.options.storageState:en.join(this.options.projectRoot,this.options.storageState);if(!Zt.existsSync(e))throw new Error(`storageState file not found: ${e}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return e}buildCandidateCache(e,n){return{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n}}async proofReplay(e,n){let r=await this.browser.newContext({storageState:this.resolveStorageState()}),o=await r.newPage();return{result:await fe(o,e,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries}),context:r,page:o}}async runScenario(e,n={}){let r=await this.runScenarioInner(e,n),o=pe(e);if(o.size>0){let s=i=>i===void 0?i:ne(i,o);r.error=s(r.error),r.narrative=s(r.narrative),r.proofError=s(r.proofError),r.suggestedFeatureEdit=s(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(i=>ne(i,o));for(let i of r.stepResults)i.error=s(i.error)}return r}async runScenarioInner(e,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:o,baseURL:s,actionTimeoutMs:i,retries:a}=this.options,d=q(o,e.featurePath,e.name),p=L(d),m=Le(o,e.featurePath,e.name),c=!p&&Zt.existsSync(m)?re(m):void 0,l=await this.browser.newContext({storageState:this.resolveStorageState()}),u=await l.newPage();try{let h;if(c){let y=await fe(u,e,c.cache,{baseURL:s,actionTimeoutMs:i,retries:a}),A=c.meta.recordedFor??e.name;if(y.status==="passed")return{scenario:e,status:"yellow",source:"agent-record",stepResults:y.stepResults,narrative:`Replayed the pending proposal recorded for "${A}" at zero AI; accept it to go green.`,adaptations:[],verified:!0,proposalFile:m,usage:se,durationMs:Date.now()-r};if(Object.keys(e.params).length>0&&A!==e.displayName){let b=y.failure;return{scenario:e,status:"red",source:"agent-record",stepResults:y.stepResults,adaptations:[],proposalFile:m,usage:se,durationMs:Date.now()-r,error:`The pending proposal recorded for "${A}" does not replay for this Examples row`+(b?` (step "${b.stepText}": ${b.error.split(`
35
- `)[0]})`:"")+". A row value is probably baked in as a literal instead of a <placeholder>; inspect the proposal, reject it, and re-record with saffron run --rerecord."}}await u.goto("about:blank")}if(p&&(h=await fe(u,e,p,{baseURL:s,actionTimeoutMs:i,retries:a}),h.status==="passed"))return{scenario:e,status:"green",source:"cache",stepResults:h.stepResults,adaptations:[],usage:se,durationMs:Date.now()-r};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let y=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!n.agentDisabled?`recording/healing requires Chromium (the agent attaches over CDP). Record with --browser chromium (the default), then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:e,status:"red",source:"cache",stepResults:h?.stepResults??[],adaptations:[],usage:se,durationMs:Date.now()-r,error:p?h?.status==="stale"?`Cache is stale (feature file changed) and ${y}`:`Replay failed and ${y} ${h?.failure?.error??""}`.trimEnd():`No cache exists and ${y}`}}let g=p!==void 0&&h!==void 0&&h.status==="failed",v=0,S;try{if(g)S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:s,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:h.stepResults.filter(y=>y.status==="passed").map(y=>y.gherkin),failedStep:{text:h.failure.stepText,kind:h.failure.kind,error:h.failure.error},takeFingerprint:async()=>{let y=ge(this.browser)??u;return J(y)}}));else if(await u.goto("about:blank"),this.options.reuseSteps!==!1){let y=await this.recordWithSeeding(e,u);v=y.seededCount,S=y}else S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:s,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,takeFingerprint:async()=>{let y=ge(this.browser)??u;return J(y)}}))}catch(y){let A=y instanceof Error?y.message:String(y);return{scenario:e,status:"red",source:g?"agent-heal":"agent-record",stepResults:h?.stepResults??[],adaptations:[],usage:se,durationMs:Date.now()-r,error:`Agent invocation failed: ${A.split(`
36
- `)[0]}`}}if(S.success&&g&&h.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||tn(p.steps,h.failure.stepIndex))){let y=p.steps[h.failure.stepIndex],A=ge(this.browser)??u,w=new Map(Object.entries(h.captured)),b=Qe(e,h.failure.stepIndex);try{for(let F of y.actions)await Ke(A,F,b,this.options.actionTimeoutMs??5e3,s,w)}catch(F){let x=F instanceof Error?F.message:String(F);return{scenario:e,status:"red",source:"agent-heal",stepResults:h.stepResults,narrative:S.narrative,adaptations:S.adaptations,suggestedFeatureEdit:S.suggestedFeatureEdit,usage:S.usage,durationMs:Date.now()-r,error:`Assertion failed as written: "${y.gherkin}". Saffron never adapts assertions. If this change is intended, update the .feature file or re-record the scenario (saffron run --rerecord --filter @tag). If the recording itself looks wrong (an empty value, a wrong target), re-record it the same way. Verification error: ${x.split(`
37
- `)[0]}`}}}return await this.finalizeAgentRun(e,S,g,p,r,v,g?h?.failure:void 0)}finally{await l.close()}}async finalizeAgentRun(e,n,r,o,s,i=0,a){let d=r?"agent-heal":"agent-record",p=n.recordedSteps.map(w=>({gherkin:w.gherkin,kind:w.kind,status:n.success?"passed":"failed"}));if(!n.success)return{scenario:e,status:"red",source:d,stepResults:p,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-s,error:n.narrative};let m=r?nn(o.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,c=[...n.adaptations],l={...n.usage},u,h;if(this.options.verifyProposals!==!1){let w=await this.proofReplay(e,this.buildCandidateCache(e,m));try{if(w.result.status!=="passed"&&w.result.failure){let b=w.result.failure;if(this.options.agent&&!(r&&b.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||tn(o.steps,b.stepIndex))))try{let x=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"refine",model:r?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:w.result.stepResults.filter(R=>R.status==="passed").map(R=>R.gherkin),failedStep:{text:b.stepText,kind:b.kind,error:b.error},takeFingerprint:async()=>J(ge(this.browser)??w.page)}));l=rn(l,x.usage),x.success&&(c.push(...x.adaptations),m=r?nn(m,x.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):zr(m,x.recordedSteps),await w.context.close(),w=await this.proofReplay(e,this.buildCandidateCache(e,m)))}catch{}}u=w.result.status==="passed",u||(h=w.result.failure?.error??`proof replay ${w.result.status}`)}finally{await w.context.close().catch(()=>{})}}let f=[...n.featureEdits];for(let w of wt(m,G(this.options.projectRoot)))c.push(`duplicate wording: "${w.from}" recorded exactly the same actions as the existing step "${w.to}" (${w.feature} \u203A ${w.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),f.some(b=>b.index===w.index)||f.push({index:w.index,text:w.to});let g=pe(e),v=w=>g.size>0?ne(w,g):w;c=c.map(v);let S={meta:{createdAt:new Date().toISOString(),mode:r?"heal":"record",narrative:v(n.narrative),adaptations:c,suggestedFeatureEdit:n.suggestedFeatureEdit?v(n.suggestedFeatureEdit):void 0,featureEdits:f.length>0?f:void 0,verified:u,proofError:h?v(h):void 0,seededSteps:i>0?i:void 0,recordedFor:e.displayName,usage:l},cache:{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:m}},y=Gt(m);y.length>0&&(u=!1,h=[h,...y].filter(Boolean).join(" "));let A=Le(this.options.projectRoot,e.featurePath,e.name);return Mt(A,S),{scenario:e,status:"yellow",source:d,stepResults:p,narrative:n.narrative,adaptations:c,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:f.length>0?f:void 0,verified:u,proofError:h,seededSteps:i>0?i:void 0,proposalFile:A,usage:l,durationMs:Date.now()-s}}};import un from"node:path";import{createRequire as Yr}from"node:module";import{query as Jr}from"@anthropic-ai/claude-agent-sdk";import{createSdkMcpServer as Vr,tool as oe}from"@anthropic-ai/claude-agent-sdk";import{z as k}from"zod";var D=t=>({content:[{type:"text",text:t}]});function on(t){let e={};return t.role&&(e.role=t.role),t.name&&(e.name=t.name),t.nameRegex&&(e.nameRegex=t.nameRegex),t.label&&(e.label=t.label),t.text&&(e.text=t.text),t.selector&&(e.fallbackSelectors=[t.selector]),t.frame&&(e.frame=t.frame),Object.keys(e).length>0?e:void 0}var an={role:k.string().optional().describe("ARIA role of the element"),name:k.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:k.string().optional().describe("Regex for the accessible name when it embeds volatile content, e.g. 'Select \\\\{date\\\\+1\\\\} as checkin' cannot work \u2014 instead use nameRegex 'Select .* as checkin'"),label:k.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:k.string().optional().describe("getByText locator text"),selector:k.string().optional().describe("CSS fallback selector"),frame:k.string().optional().describe(`CSS selector of the iframe hosting the element, e.g. 'iframe[title="Feedback widget"]' \u2014 REQUIRED whenever the element lives inside an iframe; without it replay looks only at the top-level page`)};function cn(t){return Vr({name:"saffron",version:"0.1.0",tools:[oe("saffron_step","Announce the Gherkin step you are about to perform. MUST be called before executing browser actions for each step, with the step's zero-based index and exact text.",{index:k.number().int().min(0),text:k.string(),adaptation:k.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence.")},async e=>(t.currentStepIndex=e.index,t.announced.add(e.index),e.adaptation&&t.adaptations.push(`Step ${e.index+1} ("${e.text}"): ${e.adaptation}`),D(`Recording step ${e.index}: ${e.text}`))),oe("saffron_capture",`Record a captureText action for the current step: at replay time, the target element's text is read and stored under saveAs for later expectDiffers comparisons. Use for steps like 'I record the displayed price as "X"'. The target must select exactly the element whose text is the value.`,{saveAs:k.string().describe("Name to store the captured text under"),...an},async e=>{let n=on(e);return n?(t.addActions([{action:"captureText",target:n,saveAs:e.saveAs}]),D(`Capture recorded as "${e.saveAs}".`)):D("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),oe("saffron_dialog","Record how a native dialog (alert/confirm/prompt) must be handled at replay. Call this IMMEDIATELY whenever an action triggers a native dialog \u2014 whether you handled it via browser_handle_dialog or the browser reported it as already handled. At replay Saffron arms this handler BEFORE the triggering action, so without this call the replay will auto-dismiss the dialog and diverge from what you saw.",{accept:k.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:k.string().optional().describe("Text typed into a prompt() before accepting")},async e=>(t.addArmedAction({action:"handleDialog",value:e.accept?"accept":"dismiss",...e.promptText?{promptText:e.promptText}:{}}),D(`Dialog handling recorded (${e.accept?"accept":"dismiss"}).`))),oe("saffron_wait_api","Record a network wait for the current step: replay will block until a matching API response is observed (responses from the triggering step count). Call AFTER you verified via browser_network_requests that the response actually occurred with the expected status/body. Use a path-only urlPattern regex and never literal volatile IDs (/api/orders/\\d+, not /api/orders/8842).",{urlPattern:k.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:k.string().optional().describe("HTTP method to match (any if omitted)"),status:k.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:k.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async e=>(t.addActions([{action:"waitForResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}}]),D("Network wait recorded."))),oe("saffron_assert","Record a verified assertion for the current Then step. Call ONLY after you have confirmed the condition holds via a page snapshot. If the condition does NOT hold, do not call this \u2014 call saffron_done with success=false instead. NEVER record volatile content (prices, dates, counters) as literal expected text \u2014 use kind=matches with a pattern, or kind=differs against captured values.",{kind:k.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response"]),...an,expected:k.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:k.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:k.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:k.string().optional().describe("kind=attribute: attribute name to check, e.g. 'href' for link-target assertions \u2014 use this for 'X should link to Y' instead of clicking through"),capture:k.string().optional().describe("kind=differs: name of the captured value on the LEFT of the comparison (omit when comparing the target element's live text)"),urlPattern:k.string().optional().describe("kind=response: regex matched against the response URL \u2014 path-only for portability, never literal volatile IDs (use /api/orders/\\d+)"),method:k.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:k.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:k.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),compareTo:k.string().optional().describe("kind=differs: name of the captured value to compare against")},async e=>{let n=on(e),r;switch(e.kind){case"visible":r={action:"expectVisible",target:n};break;case"notVisible":r={action:"expectNotVisible",target:n};break;case"text":r={action:"expectText",target:n,value:e.expected??""};break;case"value":r={action:"expectValue",target:n,value:e.expected??""};break;case"url":r={action:"expectUrl",url:e.url??""};break;case"differs":if(!e.compareTo)return D("ERROR: kind=differs requires compareTo.");if(!e.capture&&!n)return D("ERROR: kind=differs needs either capture (a stored name) or a target element.");r={action:"expectDiffers",target:n,value:e.capture,compareTo:e.compareTo};break;case"matches":if(!e.pattern)return D("ERROR: kind=matches requires pattern.");r={action:"expectMatches",target:n,pattern:e.pattern};break;case"attribute":if(!e.attribute||!e.pattern)return D("ERROR: kind=attribute requires attribute and pattern.");r={action:"expectAttribute",target:n,attribute:e.attribute,pattern:e.pattern};break;case"response":if(!e.urlPattern)return D("ERROR: kind=response requires urlPattern.");r={action:"expectResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}};break}return!["url","differs","response"].includes(e.kind)&&!n?D("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(t.addActions([r]),D("Assertion recorded."))}),oe("saffron_done","Finalize the run. Call exactly once, after the final step: success=true only if every step's intent was achieved and every assertion verified.",{success:k.boolean(),narrative:k.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:k.string().optional().describe("If you adapted any steps: the corrected Gherkin for those steps, as you would rewrite them in the .feature file (free text, for the human report)."),featureEdits:k.array(k.object({index:k.number().int().min(0).describe("zero-based step index"),text:k.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async e=>(t.done=!0,t.success=e.success,t.narrative=e.narrative,t.suggestedFeatureEdit=e.suggestedFeatureEdit,t.featureEdits=e.featureEdits??[],D("Run finalized.")))]})}function ln(t={}){let e=t.deliberateSnapshots?`
27
+ Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:Ot.Snippet,textEdit:{range:a,newText:"StepSet: ${1:Name}\n "}}];r.stepSets.length===0&&d.push({label:"StepSet \u2026",kind:xe.Keyword,detail:"invoke a step set (none defined yet)",filterText:"step set stepset",sortText:"1",insertTextFormat:Ot.Snippet,textEdit:{range:a,newText:"StepSet ${1:Name}"}});for(let p of r.stepSets)d.push({label:`StepSet ${p.name}`,kind:xe.Function,detail:`invoke step set \xB7 ${p.steps.length} step(s)`,documentation:p.steps.join(`
28
+ `),filterText:`step set ${p.name}`,sortText:`1${p.name}`,textEdit:{range:a,newText:`StepSet ${p.name}`}});return d}return[]}import Bt from"node:fs";import jr from"node:path";var zt=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function be(t){return t.replace(zt,(e,n)=>{let r=process.env[n];if(r===void 0)throw new Error(`environment variable ${n} is not set (referenced as {env:${n}}) \u2014 export it or add it to .env`);return r})}function Qe(t){let e=new Set;for(let n of t)if(n)for(let r of n.matchAll(zt))e.add(r[1]);return e}function Xe(t){let e=[];for(let n of t.steps){e.push(n.text,n.resolvedText),n.docString&&e.push(n.docString);for(let r of n.table??[])e.push(...r)}return e}function ve(t){let e=new Map;for(let n of Qe(Xe(t))){let r=process.env[n];r!==void 0&&e.set(n,r)}return e}function ue(t,e){let n=t;for(let[r,s]of[...e.entries()].sort((o,i)=>i[1].length-o[1].length))s.length<4||(n=n.split(s).join(`{env:${r}}`));return n}function qt(t){let e=jr.join(t,".env");if(Bt.existsSync(e))for(let n of Bt.readFileSync(e,"utf8").split(`
29
+ `)){let r=n.trim();if(!r||r.startsWith("#"))continue;let s=r.indexOf("=");if(s<=0)continue;let o=r.slice(0,s).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(o))continue;let i=r.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}import cn from"node:fs";import Wr from"node:net";import ln from"node:path";import{chromium as Gr,firefox as Yr,webkit as Jr}from"playwright";import q from"node:fs";import Fe from"node:path";function Ht(t,e){q.mkdirSync(Fe.dirname(t),{recursive:!0}),q.writeFileSync(t,`${JSON.stringify(e,null,2)}
30
+ `)}function fe(t){return JSON.parse(q.readFileSync(t,"utf8"))}function re(t){let e=Fe.join(t,".saffron","proposals");if(!q.existsSync(e))return[];let n=[];for(let r of q.readdirSync(e)){let s=Fe.join(e,r);if(q.statSync(s).isDirectory())for(let o of q.readdirSync(s)){if(!o.endsWith(".json"))continue;let i=Fe.join(s,o);n.push({file:i,proposal:fe(i)})}}return n}function Wt(t,e){let n=fe(e),r=V(t,n.cache.feature,n.cache.scenario);return de(r,n.cache),q.rmSync(e),r}function Gt(t){q.rmSync(t)}import Jt from"node:path";var Ir=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function _r(t,e){let n=String(t.getFullYear()),r=String(t.getMonth()+1).padStart(2,"0"),s=String(t.getDate()).padStart(2,"0");return e.replaceAll("YYYY",n).replaceAll("MM",r).replaceAll("DD",s).replaceAll("M",String(t.getMonth()+1)).replaceAll("D",String(t.getDate()))}function Ze(t,e=new Date){return t.replace(Ir,(n,r,s)=>{let o=new Date(e);return o.setDate(o.getDate()+(r?parseInt(r,10):0)),_r(o,s??"YYYY-MM-DD")})}var Dr=/\b(\d{4})-(\d{2})-(\d{2})\b/g,Or=400;function Yt(t,e=new Date){let n=new Date(e.getFullYear(),e.getMonth(),e.getDate());return t.replace(Dr,(r,s,o,i)=>{let a=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(a.getTime()))return r;let d=Math.round((a.getTime()-n.getTime())/864e5);return Math.abs(d)>Or?r:d===0?"{date}":`{date${d>0?"+":""}${d}}`})}import{createHash as Mr}from"node:crypto";async function se(t){let e;try{e=await t.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let n=e.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${Mr("sha256").update(n).digest("hex")}`}var nt=t=>new Promise(e=>setTimeout(e,t)),tt=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(e,n){this.context=e,this.listener=r=>{let s={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(s),n){let o=r.headers()["content-type"]??"";/json|text|xml/i.test(o)&&r.text().then(i=>{s.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},e.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(e=>e.seq>this.prevStepSeq)}all(){return this.entries}};function Nr(t,e){if(!e)return t>=200&&t<300;let n=/^([1-5])xx$/i.exec(e.trim());if(n){let r=Number(n[1])*100;return t>=r&&t<r+100}return t===Number(e.trim())}function Kt(t,e,n,r,s){return!(!e.test(t.url)||n&&t.method.toUpperCase()!==n.toUpperCase()||!Nr(t.status,r)||s&&!s.test(t.body??""))}var Ur=(t,e)=>`response matching ${t.method??"any-method"} /${e}/ status ${t.status??"2xx"}${t.pattern?` body /${t.pattern}/`:""}`;function Qt(t,e,n,r=!1){let s=a=>be(Ze(Z(a,n))),o=[],i=a=>{if(e.role){let d=e.nameRegex?new RegExp(s(e.nameRegex),"i"):e.name?s(e.name):void 0;o.push(a.getByRole(e.role,{name:d,exact:!1}))}e.label&&o.push(a.getByLabel(s(e.label))),e.placeholder&&o.push(a.getByPlaceholder(s(e.placeholder))),e.text&&o.push(a.getByText(s(e.text))),e.testId&&o.push(a.getByTestId(s(e.testId)));for(let d of e.fallbackSelectors??[])o.push(a.locator(d))};if(e.frame)i(t.frameLocator(s(e.frame)));else if(i(t),r)for(let a of t.frames())a!==t.mainFrame()&&i(a);if(o.length===0)throw new Error("Action target has neither role nor fallback selectors");return o}async function H(t,e,n){let r=Date.now()+e,s;for(;Date.now()<r;){try{if(await t())return}catch(o){s=o}await nt(100)}throw new Error(`Timed out waiting for ${n}${s?` (${String(s)})`:""}`)}async function L(t,e){let n;for(let r of t)try{await e(r.first());return}catch(s){n=s}throw n}var et=t=>t.replace(/\s+/g," ").trim();async function rt(t,e,n,r,s,o=new Map,i){let a=f=>be(Ze(Z(f,n))),d=e.value!==void 0?a(e.value):void 0,p=e.url!==void 0?a(e.url):void 0,g=e.action==="expectVisible"||e.action==="expectText"||e.action==="expectValue"||e.action==="expectMatches"||e.action==="expectAttribute"||e.action==="captureText",l=e.target?Qt(t,e.target,n,g):[],c={timeout:r},u={timeout:Math.min(1e3,r)},h=f=>async()=>{for(let m of l)try{if(await f(m.first()))return!0}catch{}return!1};switch(e.action){case"goto":{if(!p)throw new Error("goto requires url");let f=/^https?:\/\//.test(p)?p:new URL(p,s).toString();await t.goto(f,c);return}case"click":return L(l,f=>f.click(c));case"fill":return L(l,f=>f.fill(d??"",c));case"press":return l.length>0?L(l,f=>f.press(d??"",c)):t.keyboard.press(d??"");case"selectOption":return L(l,async f=>{await f.selectOption(d??"",c)});case"check":return L(l,f=>f.check(c));case"uncheck":return L(l,f=>f.uncheck(c));case"hover":return L(l,f=>f.hover(c));case"waitFor":return l.length>0?L(l,f=>f.waitFor({state:"visible",timeout:r})):nt(Math.min(Number(d??"0"),r));case"expectVisible":return H(h(f=>f.isVisible()),r,"target to be visible");case"expectNotVisible":return L(l,f=>f.waitFor({state:"hidden",timeout:r}));case"expectText":return H(h(async f=>(await f.textContent(u)??"").includes(d??"")),r,`text "${d}"`);case"expectValue":return H(h(async f=>await f.inputValue(u)===(d??"")),r,`input value "${d}"`);case"expectUrl":{let f=p??d??"";return H(async()=>t.context().pages().some(m=>m.url().includes(f)),r,`URL containing "${f}" in any tab (current: ${t.context().pages().map(m=>m.url()).join(", ")})`)}case"handleDialog":{let f=(d??"accept")!=="dismiss",m=e.promptText?a(e.promptText):void 0;t.once("dialog",w=>{f?w.accept(m):w.dismiss()});return}case"uploadFile":{let f=(d??"").split(`
31
+ `).filter(Boolean).map(m=>Jt.isAbsolute(m)?m:Jt.resolve(m));if(f.length===0)throw new Error("uploadFile requires a path");if(l.length>0)return L(l,m=>m.setInputFiles(f,c));t.once("filechooser",m=>{m.setFiles(f)});return}case"dragTo":{if(!e.toTarget)throw new Error("dragTo requires toTarget");let f=Qt(t,e.toTarget,n);return L(l,m=>m.dragTo(f[0].first(),c))}case"captureText":{if(!e.saveAs)throw new Error("captureText requires saveAs");return H(h(async f=>{let m=et(await f.textContent(u)??"");return m===""?!1:(o.set(e.saveAs,m),!0)}),r,`non-empty text to capture as "${e.saveAs}"`)}case"expectDiffers":{if(!e.compareTo)throw new Error("expectDiffers requires compareTo");let f=o.get(e.compareTo);if(f===void 0)throw new Error(`expectDiffers: no captured value named "${e.compareTo}"`);if(l.length>0)return H(h(async x=>et(await x.textContent(u)??"")!==f),r,`target text to differ from "${e.compareTo}" (${f})`);let m=d??"",w=o.get(m);if(w===void 0)throw new Error(`expectDiffers: no captured value named "${m}"`);if(w===f)throw new Error(`expectDiffers: "${m}" (${w}) equals "${e.compareTo}" (${f})`);return}case"expectAttribute":{if(!e.attribute)throw new Error("expectAttribute requires attribute");if(!e.pattern)throw new Error("expectAttribute requires pattern");let f=new RegExp(e.pattern,"i");return H(h(async m=>f.test(await m.getAttribute(e.attribute,u)??"")),r,`attribute ${e.attribute} to match /${e.pattern}/i`)}case"expectMatches":{if(!e.pattern)throw new Error("expectMatches requires pattern");let f=new RegExp(e.pattern,"i");return H(h(async m=>f.test(et(await m.textContent(u)??""))),r,`target text to match /${e.pattern}/i`)}case"waitForResponse":case"expectResponse":{if(!e.urlPattern)throw new Error(`${e.action} requires urlPattern`);let f=new RegExp(a(e.urlPattern)),m=e.pattern?new RegExp(a(e.pattern)):void 0;if(!i){await t.waitForResponse(x=>Kt({seq:0,url:x.url(),method:x.request().method(),status:x.status()},f,e.method,e.status,void 0),c);return}let w=()=>e.action==="expectResponse"?i.all():i.sincePreviousStep();return H(async()=>w().some(x=>Kt(x,f,e.method,e.status,m)),r,Ur(e,e.urlPattern))}default:throw new Error(`Unknown action type: ${e.action}`)}}function Xt(t,e){return t.actions.some(n=>[n.value,n.url,n.pattern,n.target?.name,n.target?.nameRegex,n.target?.label,n.target?.text,n.target?.placeholder].some(r=>r?.includes(e)))}function Lr(t,e){return e.steps.length!==t.steps.length?!1:e.steps.every((n,r)=>{let s=t.steps[r];if(n.gherkin!==s.text)return!1;let o=n.table,i=s.table;if(!!o!=!!i)return!1;if(o&&i){let a=Object.keys(U(o)),d=Object.keys(U(i));if(a.length>0&&d.length>0){if(JSON.stringify(a)!==JSON.stringify(d))return!1}else if(JSON.stringify(o)!==JSON.stringify(i))return!1}if(n.docString!==void 0!=(s.docString!==void 0))return!1;if(n.docString!==void 0&&s.docString!==void 0&&n.docString!==s.docString){let a=e.steps.some(p=>Xt(p,"<docstring>")),d=e.steps.some(p=>Xt(p,n.docString));if(!a||d)return!1}return!0})}function st(t,e){let n=t.steps[e],r=n?.resolvedTable??n?.table;return{...t.params,...ce(t.steps),...U(r),...ee(n?.resolvedDocString??n?.docString)}}async function we(t,e,n,r={}){let s=r.actionTimeoutMs??5e3,o=r.retries??1,i=[],a=r.vars??new Map,d=()=>Object.fromEntries(a),p=n.steps.some(u=>u.actions.some(h=>(h.action==="waitForResponse"||h.action==="expectResponse")&&h.pattern!==void 0)),g=new tt;g.attach(t.context(),p);let l=t,c=()=>{let u=t.context().pages(),h=u[u.length-1];h&&h!==l&&!h.isClosed()&&(l=h)};if(!Lr(e,n))return g.detach(),{status:"stale",stepResults:i,captured:d()};try{for(let u=0;u<n.steps.length;u++){let h=n.steps[u],f=st(e,u);g.markStep();let m=!1,w=0;for(let x=0;x<h.actions.length;x++){let b=h.actions[x];c(),b.pageFingerprint&&await se(l)!==b.pageFingerprint&&(m=!0);let C,y=!1;for(let S=0;S<=o&&!y;S++){S>0&&(w++,await nt(250*S),c());try{await rt(l,b,f,s,r.baseURL,a,g),y=!0}catch(T){C=T}}if(!y){let S=C instanceof Error?C.message:String(C);i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"failed",drift:m,...w?{retries:w}:{},error:S});for(let T=u+1;T<n.steps.length;T++)i.push({gherkin:n.steps[T].gherkin,kind:n.steps[T].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:u,actionIndex:x,stepText:h.gherkin,kind:h.kind,error:S},stepResults:i,captured:d()}}}i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"passed",drift:m,...w?{retries:w}:{}})}return{status:"passed",stepResults:i,captured:d()}}finally{g.detach()}}var Zt=new Set(["expectVisible","expectNotVisible","expectText","expectUrl","expectValue","expectDiffers","expectMatches","expectAttribute","expectResponse"]);var je=class{steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(e){this.steps=e.steps.map(n=>({gherkin:n.text,keyword:n.keyword,kind:n.kind,table:n.table,docString:n.docString,fromStepSet:n.source?.fromStepSet,actions:[]}))}addArmedAction(e){if(this.done)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r||r.actions.some(i=>i.action===e.action&&i.value===e.value&&i.promptText===e.promptText))return;let o=Math.max(r.actions.length-1,0);r.actions.splice(o,0,e)}addActions(e){if(this.done||e.length===0)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];r&&(this.pendingFingerprint&&e[0]&&(e[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),r.actions.push(...e))}},oe=t=>t.replace(/\\(['"\\])/g,"$1");function Vr(t){let e=t.match(/'((?:[^'\\]|\\.)*)'/);return e?oe(e[1]):void 0}function ot(t){let e={},n=t.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(e.frame=oe(n[1]??n[2]),t=t.slice((n.index??0)+n[0].length));let r=t.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(r){e.role=r[1];let i=(r[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(e.name=oe(i[1]))}let s=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of s){let d=t.match(i);d&&(e[a]=oe(d[1]))}let o=t.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return o&&(e.fallbackSelectors=[oe(o[1])]),Object.keys(e).length>0?e:void 0}function Br(t,e){if(e&&t.startsWith(e)){let n=t.slice(e.length);return n.startsWith("/")?n:`/${n}`}return t}var zr={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function qr(t,e){let n=t.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:Br(oe(n[1]),e)};let r=t.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:oe(r[1])};let s=t.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let c=ot(s[1]),u=ot(s[2]);if(c&&u)return{action:"dragTo",target:c,toTarget:u}}let o=t.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,d]=o,p=ot(i);if(!p)return;let g={action:zr[a],target:p},l=Vr(d);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(g.value=l),g}function en(t,e){let n=[],r=Array.isArray(t)?t:t?.content;if(Array.isArray(r))for(let o of r){let i=o?.text;typeof i=="string"&&n.push(i)}else typeof t=="string"&&n.push(t);let s=[];for(let o of n)for(let i of o.split(`
32
+ `)){let a=qr(i.trim(),e);a&&s.push(a)}return Hr(s)}function Hr(t){return t.filter((e,n)=>{let r=t[n+1];return!(e.action==="fill"&&(e.value??"")===""&&r?.action==="fill"&&JSON.stringify(r.target)===JSON.stringify(e.target))})}function tn(t){let e=[];for(let n of t)if(/"[^"]+"|<[^<>]+>/.test(n.gherkin)){for(let s of n.actions)if(s.action==="fill"&&(s.value??"")===""){e.push(`Step "${n.gherkin}" recorded a fill with an empty value although the step passes one; the typed value was lost. Re-record with: saffron run --rerecord`);break}}return e}function it(t,e){let n=t;for(let[r,s]of Object.entries(e).filter(([,o])=>o.length>=3).sort((o,i)=>i[1].length-o[1].length))n=n.split(s).join(`<${r}>`);return n}function nn(t,e){let n=Object.entries(e).filter(([,i])=>i.length>=3);if(n.length===0)return t;let r=i=>i===void 0?void 0:it(i,e),s=[...n].sort((i,a)=>a[1].length-i[1].length),o=i=>{if(i!==void 0){for(let[a,d]of s)if(i.includes(d))return`<${a}>`;return i}};return t.map(i=>({...i,actions:i.actions.map(a=>{let d=Zt.has(a.action)&&a.action!=="expectValue";return{...a,value:d?o(a.value):r(a.value),url:r(a.url),target:a.target?{...a.target,name:d?o(a.target.name):r(a.target.name),label:r(a.target.label),text:d?o(a.target.text):r(a.target.text),placeholder:r(a.target.placeholder)}:void 0}})}))}function rn(t,e){let n=ce(e.steps);return t.map((r,s)=>{let o=e.steps[s],i=o?.resolvedTable??o?.table,a=o?.resolvedDocString??o?.docString,d=Object.entries({...n,...U(i),...ee(a)}).filter(([,g])=>g.length>=3).sort((g,l)=>l[1].length-g[1].length);if(d.length===0)return r;let p=g=>{if(g===void 0)return;let l=g;for(let[c,u]of d)l=l.split(u).join(`<${c}>`);return l};return{...r,actions:r.actions.map(g=>({...g,value:p(g.value),url:p(g.url),target:g.target?{...g.target,name:p(g.target.name),label:p(g.target.label),text:p(g.target.text),placeholder:p(g.target.placeholder)}:void 0}))}})}function sn(t,e=new Date){let n=r=>r===void 0?void 0:Yt(r,e);return t.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text)}:void 0}))}))}function on(t,e){if(e.size===0)return t;let n=r=>r===void 0?void 0:ue(r,e);return t.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),pattern:n(s.pattern),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text),placeholder:n(s.target.placeholder)}:void 0}))}))}function an(t,e){let n=e??{};if(t==="mcp__playwright__browser_handle_dialog"){let r={action:"handleDialog",value:n.accept?"accept":"dismiss"};return typeof n.promptText=="string"&&n.promptText&&(r.promptText=n.promptText),r}if(t==="mcp__playwright__browser_file_upload"){let r=Array.isArray(n.paths)?n.paths:[];if(r.length===0)return;let s=process.cwd()+"/";return{action:"uploadFile",value:r.map(i=>typeof i=="string"&&i.startsWith(s)?i.slice(s.length):i).join(`
33
+ `)}}}var ge={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function Kr(){return new Promise((t,e)=>{let n=Wr.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>t(r))}),n.on("error",e)})}function ke(t){let e=t.contexts().flatMap(n=>n.pages());return[...e].reverse().find(n=>n.url()!=="about:blank")??e.at(-1)}function fn(t){let e=t.length;for(let n=t.length-1;n>=0&&t[n].kind==="assertion";n--)e=n;return e}function dn(t,e){return t[e]?.kind==="assertion"&&e>=fn(t)}function pn(t,e,n=!1,r){let s=fn(t);return t.map((o,i)=>{let a=e[i],d=a&&a.actions.length>0;return o.kind==="assertion"?n&&i<s&&d?a:o:d?r&&i===r.stepIndex&&r.actionIndex>0?{...a,actions:[...o.actions.slice(0,r.actionIndex),...a.actions]}:a:o})}function Qr(t,e){return t.map((n,r)=>{let s=e[r];return s&&s.actions.length>0?s:n})}function un(t,e){return{aiCalls:t.aiCalls+e.aiCalls,inputTokens:t.inputTokens+e.inputTokens,outputTokens:t.outputTokens+e.outputTokens,cacheReadTokens:t.cacheReadTokens+e.cacheReadTokens,cacheCreationTokens:t.cacheCreationTokens+e.cacheCreationTokens,costUsd:(t.costUsd??0)+(e.costUsd??0),models:t.models||e.models?[...new Set([...t.models??[],...e.models??[]])]:void 0}}var Ie=class{constructor(e){this.options=e}options;browser;cdpEndpoint;stepIndex;get agent(){return this.options.agent}getStepIndex(){if(!this.stepIndex&&(this.stepIndex=ne(this.options.projectRoot),this.stepIndex.steps.size>0)){let e=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${e}`)}return this.stepIndex}async recordWithSeeding(e,n){let r=this.getStepIndex(),s=e.steps.length,o={...e.params,...ce(e.steps)},i=e.steps.map(x=>{let b=r.steps.size>0?Pt(r,x,o):void 0;return b?{gherkin:x.text,keyword:x.keyword,kind:x.kind,table:x.table,docString:x.docString,fromStepSet:x.source?.fromStepSet,actions:b.actions}:void 0}),a=e.steps.map(x=>({gherkin:x.text,keyword:x.keyword,kind:x.kind,table:x.table,docString:x.docString,fromStepSet:x.source?.fromStepSet,actions:[]})),d=new Map,p=[],g=[],l=[],c=[],u={...ge},h=0,f=0,m=0,w=x=>({success:x,narrative:g.join(" ")||"Composed entirely from seeded recordings.",adaptations:p,suggestedFeatureEdit:c.length>0?c.join(`
34
+ `):void 0,featureEdits:l,recordedSteps:a,announcedSteps:[],usage:u,seededCount:h});for(;m<s;){if(i[m]){let S=m;for(;S<s&&i[S];)S++;let T=i.slice(m,S),v={...e,steps:e.steps.slice(m,S)},k=await we(n,v,this.buildCandidateCache(e,T),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,vars:d}),P=k.status==="passed"?S-m:k.failure?.stepIndex??0;for(let D=0;D<P;D++)a[m+D]=T[D];if(h+=P,k.status==="passed"){m=S;continue}let j=m+P;p.push(`Seeded recording for "${e.steps[j].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[j]=void 0,m=j;continue}if(!this.options.agent)return w(!1);f++;let x=m;for(;x<s&&!i[x];)x++;f>=3&&(x=s);let b=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:m>0?e.steps.slice(0,m).map(S=>S.resolvedText):void 0,recordOnly:x<s?{from:m,to:x-1}:void 0,takeFingerprint:async()=>se(ke(this.browser)??n)}));if(u=un(u,b.usage),g.push(b.narrative),p.push(...b.adaptations),l.push(...b.featureEdits),b.suggestedFeatureEdit&&c.push(b.suggestedFeatureEdit),!b.success)return w(!1);let C=b.announcedSteps.filter(S=>S>=m&&S<s),y=Math.min(s-1,Math.max(C.length>0?Math.max(...C):x-1,m));for(let S=m;S<=y;S++)a[S]=b.recordedSteps[S];m=y+1}return w(!0)}async withDialogsVisible(e){let n=()=>{},r=[],s=[],o=i=>{i.on("dialog",n),r.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await e()}finally{for(let i of s)i.off("page",o);for(let i of r)try{i.off("dialog",n)}catch{}}}async start(){let e=this.options.browser??"chromium";if(e==="chromium"){let n=await Kr();this.browser=await Gr.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${n}`]}),this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(e==="firefox"?Yr:Jr).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let e=ln.isAbsolute(this.options.storageState)?this.options.storageState:ln.join(this.options.projectRoot,this.options.storageState);if(!cn.existsSync(e))throw new Error(`storageState file not found: ${e}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return e}buildCandidateCache(e,n){return{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n}}async proofReplay(e,n){let r=await this.browser.newContext({storageState:this.resolveStorageState()}),s=await r.newPage();return{result:await we(s,e,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries}),context:r,page:s}}async runScenario(e,n={}){let r=await this.runScenarioInner(e,n),s=ve(e);if(s.size>0){let o=i=>i===void 0?i:ue(i,s);r.error=o(r.error),r.narrative=o(r.narrative),r.proofError=o(r.proofError),r.suggestedFeatureEdit=o(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(i=>ue(i,s));for(let i of r.stepResults)i.error=o(i.error)}return r}async runScenarioInner(e,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:a}=this.options,d=V(s,e.featurePath,e.name),p=B(d),g=Se(s,e.featurePath,e.name),l=!p&&cn.existsSync(g)?fe(g):void 0,c=await this.browser.newContext({storageState:this.resolveStorageState()}),u=await c.newPage();try{let h;if(l){let b=await we(u,e,l.cache,{baseURL:o,actionTimeoutMs:i,retries:a}),C=l.meta.recordedFor??e.name;if(b.status==="passed")return{scenario:e,status:"yellow",source:"agent-record",stepResults:b.stepResults,narrative:`Replayed the pending proposal recorded for "${C}" at zero AI; accept it to go green.`,adaptations:[],verified:!0,proposalFile:g,usage:ge,durationMs:Date.now()-r};if(Object.keys(e.params).length>0&&C!==e.displayName){let S=b.failure;return{scenario:e,status:"red",source:"agent-record",stepResults:b.stepResults,adaptations:[],proposalFile:g,usage:ge,durationMs:Date.now()-r,error:`The pending proposal recorded for "${C}" does not replay for this Examples row`+(S?` (step "${S.stepText}": ${S.error.split(`
35
+ `)[0]})`:"")+". A row value is probably baked in as a literal instead of a <placeholder>; inspect the proposal, reject it, and re-record with saffron run --rerecord."}}await u.goto("about:blank")}if(p&&(h=await we(u,e,p,{baseURL:o,actionTimeoutMs:i,retries:a}),h.status==="passed"))return{scenario:e,status:"green",source:"cache",stepResults:h.stepResults,adaptations:[],usage:ge,durationMs:Date.now()-r};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let b=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!n.agentDisabled?`recording/healing requires Chromium (the agent attaches over CDP). Record with --browser chromium (the default), then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:e,status:"red",source:"cache",stepResults:h?.stepResults??[],adaptations:[],usage:ge,durationMs:Date.now()-r,error:p?h?.status==="stale"?`Cache is stale (feature file changed) and ${b}`:`Replay failed and ${b} ${h?.failure?.error??""}`.trimEnd():`No cache exists and ${b}`}}let m=p!==void 0&&h!==void 0&&h.status==="failed",w=0,x;try{if(m)x=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:h.stepResults.filter(b=>b.status==="passed").map(b=>b.gherkin),failedStep:{text:h.failure.stepText,kind:h.failure.kind,error:h.failure.error},takeFingerprint:async()=>{let b=ke(this.browser)??u;return se(b)}}));else if(await u.goto("about:blank"),this.options.reuseSteps!==!1){let b=await this.recordWithSeeding(e,u);w=b.seededCount,x=b}else x=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,takeFingerprint:async()=>{let b=ke(this.browser)??u;return se(b)}}))}catch(b){let C=b instanceof Error?b.message:String(b);return{scenario:e,status:"red",source:m?"agent-heal":"agent-record",stepResults:h?.stepResults??[],adaptations:[],usage:ge,durationMs:Date.now()-r,error:`Agent invocation failed: ${C.split(`
36
+ `)[0]}`}}if(x.success&&m&&h.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||dn(p.steps,h.failure.stepIndex))){let b=p.steps[h.failure.stepIndex],C=ke(this.browser)??u,y=new Map(Object.entries(h.captured)),S=st(e,h.failure.stepIndex);try{for(let T of b.actions)await rt(C,T,S,this.options.actionTimeoutMs??5e3,o,y)}catch(T){let v=T instanceof Error?T.message:String(T);return{scenario:e,status:"red",source:"agent-heal",stepResults:h.stepResults,narrative:x.narrative,adaptations:x.adaptations,suggestedFeatureEdit:x.suggestedFeatureEdit,usage:x.usage,durationMs:Date.now()-r,error:`Assertion failed as written: "${b.gherkin}". Saffron never adapts assertions. If this change is intended, update the .feature file or re-record the scenario (saffron run --rerecord --filter @tag). If the recording itself looks wrong (an empty value, a wrong target), re-record it the same way. Verification error: ${v.split(`
37
+ `)[0]}`}}}return await this.finalizeAgentRun(e,x,m,p,r,w,m?h?.failure:void 0)}finally{await c.close()}}async finalizeAgentRun(e,n,r,s,o,i=0,a){let d=r?"agent-heal":"agent-record",p=n.recordedSteps.map(y=>({gherkin:y.gherkin,kind:y.kind,status:n.success?"passed":"failed"}));if(!n.success)return{scenario:e,status:"red",source:d,stepResults:p,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-o,error:n.narrative};let g=r?pn(s.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,l=[...n.adaptations],c={...n.usage},u,h;if(this.options.verifyProposals!==!1){let y=await this.proofReplay(e,this.buildCandidateCache(e,g));try{if(y.result.status!=="passed"&&y.result.failure){let S=y.result.failure;if(this.options.agent&&!(r&&S.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||dn(s.steps,S.stepIndex))))try{let v=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"refine",model:r?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:y.result.stepResults.filter(k=>k.status==="passed").map(k=>k.gherkin),failedStep:{text:S.stepText,kind:S.kind,error:S.error},takeFingerprint:async()=>se(ke(this.browser)??y.page)}));c=un(c,v.usage),v.success&&(l.push(...v.adaptations),g=r?pn(g,v.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):Qr(g,v.recordedSteps),await y.context.close(),y=await this.proofReplay(e,this.buildCandidateCache(e,g)))}catch{}}u=y.result.status==="passed",u||(h=y.result.failure?.error??`proof replay ${y.result.status}`)}finally{await y.context.close().catch(()=>{})}}let f=[...n.featureEdits];for(let y of Ct(g,ne(this.options.projectRoot)))l.push(`duplicate wording: "${y.from}" recorded exactly the same actions as the existing step "${y.to}" (${y.feature} \u203A ${y.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),f.some(S=>S.index===y.index)||f.push({index:y.index,text:y.to});let m=ve(e),w=y=>m.size>0?ue(y,m):y;l=l.map(w);let x={meta:{createdAt:new Date().toISOString(),mode:r?"heal":"record",narrative:w(n.narrative),adaptations:l,suggestedFeatureEdit:n.suggestedFeatureEdit?w(n.suggestedFeatureEdit):void 0,featureEdits:f.length>0?f:void 0,verified:u,proofError:h?w(h):void 0,seededSteps:i>0?i:void 0,recordedFor:e.displayName,usage:c},cache:{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:g}},b=tn(g);b.length>0&&(u=!1,h=[h,...b].filter(Boolean).join(" "));let C=Se(this.options.projectRoot,e.featurePath,e.name);return Ht(C,x),{scenario:e,status:"yellow",source:d,stepResults:p,narrative:n.narrative,adaptations:l,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:f.length>0?f:void 0,verified:u,proofError:h,seededSteps:i>0?i:void 0,proposalFile:C,usage:c,durationMs:Date.now()-o}}};import bn from"node:path";import{createRequire as rs}from"node:module";import{query as ss}from"@anthropic-ai/claude-agent-sdk";import{createSdkMcpServer as Xr,tool as me}from"@anthropic-ai/claude-agent-sdk";import{z as $}from"zod";var M=t=>({content:[{type:"text",text:t}]});function gn(t){let e={};return t.role&&(e.role=t.role),t.name&&(e.name=t.name),t.nameRegex&&(e.nameRegex=t.nameRegex),t.label&&(e.label=t.label),t.text&&(e.text=t.text),t.selector&&(e.fallbackSelectors=[t.selector]),t.frame&&(e.frame=t.frame),Object.keys(e).length>0?e:void 0}var mn={role:$.string().optional().describe("ARIA role of the element"),name:$.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:$.string().optional().describe("Regex for the accessible name when it embeds volatile content, e.g. 'Select \\\\{date\\\\+1\\\\} as checkin' cannot work \u2014 instead use nameRegex 'Select .* as checkin'"),label:$.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:$.string().optional().describe("getByText locator text"),selector:$.string().optional().describe("CSS fallback selector"),frame:$.string().optional().describe(`CSS selector of the iframe hosting the element, e.g. 'iframe[title="Feedback widget"]' \u2014 REQUIRED whenever the element lives inside an iframe; without it replay looks only at the top-level page`)};function hn(t){return Xr({name:"saffron",version:"0.1.0",tools:[me("saffron_step","Announce the Gherkin step you are about to perform. MUST be called before executing browser actions for each step, with the step's zero-based index and exact text.",{index:$.number().int().min(0),text:$.string(),adaptation:$.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence.")},async e=>(t.currentStepIndex=e.index,t.announced.add(e.index),e.adaptation&&t.adaptations.push(`Step ${e.index+1} ("${e.text}"): ${e.adaptation}`),M(`Recording step ${e.index}: ${e.text}`))),me("saffron_capture",`Record a captureText action for the current step: at replay time, the target element's text is read and stored under saveAs for later expectDiffers comparisons. Use for steps like 'I record the displayed price as "X"'. The target must select exactly the element whose text is the value.`,{saveAs:$.string().describe("Name to store the captured text under"),...mn},async e=>{let n=gn(e);return n?(t.addActions([{action:"captureText",target:n,saveAs:e.saveAs}]),M(`Capture recorded as "${e.saveAs}".`)):M("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),me("saffron_dialog","Record how a native dialog (alert/confirm/prompt) must be handled at replay. Call this IMMEDIATELY whenever an action triggers a native dialog \u2014 whether you handled it via browser_handle_dialog or the browser reported it as already handled. At replay Saffron arms this handler BEFORE the triggering action, so without this call the replay will auto-dismiss the dialog and diverge from what you saw.",{accept:$.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:$.string().optional().describe("Text typed into a prompt() before accepting")},async e=>(t.addArmedAction({action:"handleDialog",value:e.accept?"accept":"dismiss",...e.promptText?{promptText:e.promptText}:{}}),M(`Dialog handling recorded (${e.accept?"accept":"dismiss"}).`))),me("saffron_wait_api","Record a network wait for the current step: replay will block until a matching API response is observed (responses from the triggering step count). Call AFTER you verified via browser_network_requests that the response actually occurred with the expected status/body. Use a path-only urlPattern regex and never literal volatile IDs (/api/orders/\\d+, not /api/orders/8842).",{urlPattern:$.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:$.string().optional().describe("HTTP method to match (any if omitted)"),status:$.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:$.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async e=>(t.addActions([{action:"waitForResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}}]),M("Network wait recorded."))),me("saffron_assert","Record a verified assertion for the current Then step. Call ONLY after you have confirmed the condition holds via a page snapshot. If the condition does NOT hold, do not call this \u2014 call saffron_done with success=false instead. NEVER record volatile content (prices, dates, counters) as literal expected text \u2014 use kind=matches with a pattern, or kind=differs against captured values.",{kind:$.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response"]),...mn,expected:$.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:$.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:$.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:$.string().optional().describe("kind=attribute: attribute name to check, e.g. 'href' for link-target assertions \u2014 use this for 'X should link to Y' instead of clicking through"),capture:$.string().optional().describe("kind=differs: name of the captured value on the LEFT of the comparison (omit when comparing the target element's live text)"),urlPattern:$.string().optional().describe("kind=response: regex matched against the response URL \u2014 path-only for portability, never literal volatile IDs (use /api/orders/\\d+)"),method:$.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:$.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:$.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),compareTo:$.string().optional().describe("kind=differs: name of the captured value to compare against")},async e=>{let n=gn(e),r;switch(e.kind){case"visible":r={action:"expectVisible",target:n};break;case"notVisible":r={action:"expectNotVisible",target:n};break;case"text":r={action:"expectText",target:n,value:e.expected??""};break;case"value":r={action:"expectValue",target:n,value:e.expected??""};break;case"url":r={action:"expectUrl",url:e.url??""};break;case"differs":if(!e.compareTo)return M("ERROR: kind=differs requires compareTo.");if(!e.capture&&!n)return M("ERROR: kind=differs needs either capture (a stored name) or a target element.");r={action:"expectDiffers",target:n,value:e.capture,compareTo:e.compareTo};break;case"matches":if(!e.pattern)return M("ERROR: kind=matches requires pattern.");r={action:"expectMatches",target:n,pattern:e.pattern};break;case"attribute":if(!e.attribute||!e.pattern)return M("ERROR: kind=attribute requires attribute and pattern.");r={action:"expectAttribute",target:n,attribute:e.attribute,pattern:e.pattern};break;case"response":if(!e.urlPattern)return M("ERROR: kind=response requires urlPattern.");r={action:"expectResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}};break}return!["url","differs","response"].includes(e.kind)&&!n?M("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(t.addActions([r]),M("Assertion recorded."))}),me("saffron_done","Finalize the run. Call exactly once, after the final step: success=true only if every step's intent was achieved and every assertion verified.",{success:$.boolean(),narrative:$.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:$.string().optional().describe("If you adapted any steps: the corrected Gherkin for those steps, as you would rewrite them in the .feature file (free text, for the human report)."),featureEdits:$.array($.object({index:$.number().int().min(0).describe("zero-based step index"),text:$.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async e=>(t.done=!0,t.success=e.success,t.narrative=e.narrative,t.suggestedFeatureEdit=e.suggestedFeatureEdit,t.featureEdits=e.featureEdits??[],M("Run finalized.")))]})}function yn(t={}){let e=t.deliberateSnapshots?`
38
38
  8. Tool responses do NOT include a page snapshot. Take snapshots deliberately with browser_snapshot, and only when you actually need to see the page: after navigation, before locating an element you haven't seen yet, and when verifying an assertion. Do NOT snapshot after actions whose outcome is predictable (filling a field you just located, pressing Enter, a click whose result you will verify later anyway). Every snapshot is expensive.
39
39
  9. Minimize round-trips: batch tool calls in a single response whenever they don't depend on each other's results \u2014 saffron_step together with that step's browser actions, several fills in one turn, bookkeeping calls (saffron_capture, saffron_assert) alongside the actions they belong to. Only stop and wait when you genuinely need a result before deciding your next call. Do not write narration between tool calls.`:"";return`You are Saffron's test execution agent. You execute Gherkin scenarios in a real browser using the playwright MCP tools, while Saffron records your actions into a deterministic replay cache.
40
40
 
@@ -52,78 +52,78 @@ Non-negotiable rules:
52
52
  5c. Real-world page furniture is supported: elements inside IFRAMES record and replay fine (interact normally) \u2014 but when a saffron_assert targets an element INSIDE an iframe, you MUST pass the iframe's CSS selector as the 'frame' argument (e.g. frame='iframe[title="Feedback widget"]'); replay cannot see into frames otherwise. File uploads: use browser_file_upload with the file path. Drag-and-drop: use browser_drag. All of these are recorded and replay deterministically.
53
53
  5d. NATIVE DIALOGS (alert/confirm/prompt): when an action opens one, the page blocks and the snapshot reports a modal state \u2014 resolve it with browser_handle_dialog (accept true/false; promptText for prompt()). That call is recorded automatically as an armed handler replay places BEFORE the triggering action, so do NOT also call saffron_dialog. After handling, verify the post-dialog state as usual. If a step's wording says to confirm/accept, accept; if it says cancel/dismiss, dismiss.
54
54
  5e. NETWORK WAITS: when a step says to wait for an API call ("wait for the order API to succeed", "until the job API reports READY"), do it verify-first: perform the trigger, check via browser_network_requests that the matching response actually occurred with the expected status/body, then record it with saffron_wait_api. For Then steps about API contracts ("the request should have returned 201") verify the same way and record with saffron_assert kind=response. urlPattern is a path-only regex \u2014 NEVER a literal volatile ID (/api/orders/\\d+, not /api/orders/8842) and never the host.
55
- 7. Never ask the user questions; you are unattended.${e}`}function qr(t,e){if(t[e].kind!=="assertion")return"action";let n=t.length;for(let r=t.length-1;r>=0&&t[r].kind==="assertion";r--)n=r;return e>=n?"assertion:final":"assertion"}function dn(t){let{scenario:e}=t,n=e.steps.map((o,s)=>{let i=c=>de(c),a=`${s}. [${qr(e.steps,s)}] ${o.keyword} ${i(o.resolvedText)}`,d=o.resolvedTable??o.table,p=o.resolvedDocString??o.docString,m=a;return d&&(m+=`
56
- ${d.map(c=>` | ${c.map(i).join(" | ")} |`).join(`
57
- `)}`),p!==void 0&&(m+=`
55
+ 7. Never ask the user questions; you are unattended.${e}`}function Zr(t,e){if(t[e].kind!=="assertion")return"action";let n=t.length;for(let r=t.length-1;r>=0&&t[r].kind==="assertion";r--)n=r;return e>=n?"assertion:final":"assertion"}function Sn(t){let{scenario:e}=t,n=e.steps.map((s,o)=>{let i=l=>be(l),a=`${o}. [${Zr(e.steps,o)}] ${s.keyword} ${i(s.resolvedText)}`,d=s.resolvedTable??s.table,p=s.resolvedDocString??s.docString,g=a;return d&&(g+=`
56
+ ${d.map(l=>` | ${l.map(i).join(" | ")} |`).join(`
57
+ `)}`),p!==void 0&&(g+=`
58
58
  """
59
59
  ${i(p).split(`
60
- `).map(c=>` ${c}`).join(`
60
+ `).map(l=>` ${l}`).join(`
61
61
  `)}
62
- """`),m}).join(`
62
+ """`),g}).join(`
63
63
  `),r=[`Feature: ${e.featureName} (${e.featurePath})`,`Scenario: ${e.displayName}`,t.baseURL?`Base URL: ${t.baseURL}`:void 0,"","Steps (index. [kind] keyword text):",n,""];if(t.mode==="refine")r.push("MODE: REFINE. You (or a previous agent session) just recorded this scenario and it completed successfully \u2014 but the deterministic zero-AI replay of that recording FAILED at the step below. The application is working; the RECORDING is not replayable.","The browser tab shows the state where the proof replay stopped.","",`Steps that replayed fine:
64
- ${(t.completedSteps??[]).map(o=>` \u2713 ${o}`).join(`
64
+ ${(t.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
65
65
  `)||" (none)"}`,"",`Failed step: [${t.failedStep?.kind}] ${t.failedStep?.text}`,`Replay failure: ${t.failedStep?.error}`,"","Typical causes: a locator that only matched during your session (volatile accessible name \u2014 use nameRegex or a shorter stable name), a literal dynamic value baked into an assertion (use kind=matches/differs/captureText), or an assertion that cannot be checked without navigation (use kind=attribute for link targets).","Re-perform and re-record the failed step and every step after it in a REPLAYABLE form via saffron_step + browser tools / saffron_capture / saffron_assert. Express the SAME conditions the steps state \u2014 do not weaken them.","If a step genuinely cannot be expressed replayably, call saffron_done success=false and explain why.");else if(t.mode==="heal")r.push("MODE: HEAL. A cached deterministic replay of this scenario failed mid-run.","The browser tab already holds the page state reached so far \u2014 do NOT restart from step 0; verify where you are with a snapshot and continue from the failed step.","",`Steps already completed by replay:
66
- ${(t.completedSteps??[]).map(o=>` \u2713 ${o}`).join(`
67
- `)||" (none)"}`,"",`Failed step: [${t.failedStep?.kind}] ${t.failedStep?.text}`,`Failure: ${t.failedStep?.error}`,"","First diagnose: did the UI legitimately change (heal: adapt the actions, record via saffron_step + browser tools), or is the application broken (fail: saffron_done success=false with your diagnosis)?",t.assertionPolicy==="adaptable-mid"?"If the failed step is an [assertion:final]: you may re-verify the SAME condition once against the live page (timing), but if it does not hold AS WRITTEN, the run FAILS \u2014 call saffron_done success=false; never substitute it. If the failed step is a mid-scenario [assertion]: this project's policy allows adapting it \u2014 verify the equivalent condition on the legitimately-changed UI, record it via saffron_assert, and report the deviation via saffron_step's adaptation field plus featureEdits. An application defect still fails.":"If the failed step is an [assertion] or [assertion:final]: you may re-verify the SAME condition once against the live page (it may have been a timing issue). If the condition AS WRITTEN does not hold, the run FAILS \u2014 call saffron_done success=false. Do NOT substitute a different element, text, or URL that 'means the same thing'; suggesting a corrected assertion belongs in suggestedFeatureEdit of your failure report, never in saffron_assert.","Announce (saffron_step) and perform only the failed step and the steps after it.");else if(t.mode==="record"&&t.recordOnly){let{from:o,to:s}=t.recordOnly;r.push("MODE: RECORD (segment). This scenario is being composed from existing recordings plus your work.",t.completedSteps&&t.completedSteps.length>0?`Already executed against the live browser (do NOT redo):
66
+ ${(t.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
67
+ `)||" (none)"}`,"",`Failed step: [${t.failedStep?.kind}] ${t.failedStep?.text}`,`Failure: ${t.failedStep?.error}`,"","First diagnose: did the UI legitimately change (heal: adapt the actions, record via saffron_step + browser tools), or is the application broken (fail: saffron_done success=false with your diagnosis)?",t.assertionPolicy==="adaptable-mid"?"If the failed step is an [assertion:final]: you may re-verify the SAME condition once against the live page (timing), but if it does not hold AS WRITTEN, the run FAILS \u2014 call saffron_done success=false; never substitute it. If the failed step is a mid-scenario [assertion]: this project's policy allows adapting it \u2014 verify the equivalent condition on the legitimately-changed UI, record it via saffron_assert, and report the deviation via saffron_step's adaptation field plus featureEdits. An application defect still fails.":"If the failed step is an [assertion] or [assertion:final]: you may re-verify the SAME condition once against the live page (it may have been a timing issue). If the condition AS WRITTEN does not hold, the run FAILS \u2014 call saffron_done success=false. Do NOT substitute a different element, text, or URL that 'means the same thing'; suggesting a corrected assertion belongs in suggestedFeatureEdit of your failure report, never in saffron_assert.","Announce (saffron_step) and perform only the failed step and the steps after it.");else if(t.mode==="record"&&t.recordOnly){let{from:s,to:o}=t.recordOnly;r.push("MODE: RECORD (segment). This scenario is being composed from existing recordings plus your work.",t.completedSteps&&t.completedSteps.length>0?`Already executed against the live browser (do NOT redo):
68
68
  ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
69
- `)}`:"You are starting from a blank page.","",`Record ONLY steps ${o} through ${s} (inclusive).`,`CRITICAL STOP RULE: the steps AFTER step ${s} already have recordings and will be replayed automatically the moment you stop. After completing step ${s}, immediately call saffron_done \u2014 do NOT perform, verify, or navigate into any later step. Executing a later step yourself would make the automatic replay run it TWICE.`)}else if(t.completedSteps&&t.completedSteps.length>0){let o=t.completedSteps.length;r.push("MODE: RECORD (seeded). No cache exists for this scenario yet, but its first steps matched existing recordings and were ALREADY EXECUTED against the live browser by zero-AI replay:",t.completedSteps.map(s=>` \u2713 ${s}`).join(`
70
- `),"",`The browser currently shows the state after those ${o} step(s) (verify with a snapshot if unsure). Do NOT redo them and do NOT navigate away from the current state.`,`Start with saffron_step for step index ${o} and record from there to the end.`)}else r.push("MODE: RECORD. No cache exists for this scenario yet.","Execute every step in order, starting at step 0. Navigate to the base URL first if the scenario implies a starting page.");return r.filter(o=>o!==void 0).join(`
71
- `)}import{query as Hr}from"@anthropic-ai/claude-agent-sdk";var Wr="usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";async function et(t,e=8e3){let n=t?.[Wr];if(typeof n!="function")return;let r;try{let o=await Promise.race([n.call(t),new Promise(s=>{r=setTimeout(()=>s(void 0),e),r.unref?.()})]);return Gr(o)}catch{return}finally{r&&clearTimeout(r)}}function Gr(t){if(!t||!t.rate_limits_available||!t.subscription_type)return;let e=t.rate_limits?.five_hour;return{subscriptionType:t.subscription_type,utilization:typeof e?.utilization=="number"?e.utilization:void 0,resetsAt:e?.resets_at??void 0}}async function pn(){let t=()=>{},e=new Promise(o=>t=o);async function*n(){await e}let r;try{let o=Hr({prompt:n(),options:{maxTurns:1,tools:[],settingSources:[],persistSession:!1}});r=o;let s=(async()=>{try{for await(let a of o);}catch{}})(),i=await et(o);return t(),r.close?.(),await s,i}catch{t(),r?.close?.();return}}var Kr=Yr(import.meta.url);function Qr(){let t=Kr.resolve("@playwright/mcp/package.json");return un.join(un.dirname(t),"cli.js")}var Xr=new Set(["mcp__playwright__browser_navigate","mcp__playwright__browser_click","mcp__playwright__browser_type","mcp__playwright__browser_fill_form","mcp__playwright__browser_select_option","mcp__playwright__browser_press_key"]),Re=class{constructor(e={}){this.opts=e}opts;planBefore;async planUsage(){if(!this.planBefore)return;let e=await this.planBefore;if(!e)return;let n=await pn();return{subscriptionType:e.subscriptionType,fiveHourBefore:e.utilization,fiveHourAfter:n?.utilization,resetsAt:n?.resetsAt??e.resetsAt}}async run(e){let n=new ke(e.scenario),r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},o="",s=Jr({prompt:dn(e),options:{systemPrompt:ln({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:e.assertionPolicy==="adaptable-mid"}),model:e.model??this.opts.model,maxTurns:this.opts.maxTurns??100,tools:[],permissionMode:"bypassPermissions",allowDangerouslySkipPermissions:!0,persistSession:!1,settingSources:[],mcpServers:{playwright:{type:"stdio",command:process.execPath,args:[Qr(),"--cdp-endpoint",e.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:cn(n)},hooks:{PreToolUse:[{hooks:[async i=>{let a=i;if(Xr.has(a.tool_name)&&e.takeFingerprint&&!n.pendingFingerprint)try{n.pendingFingerprint=await e.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async i=>{let a=i,d=Xt(a.tool_name,a.tool_input);return d&&n.addArmedAction(d),a.tool_name.startsWith("mcp__playwright__")&&(n.addActions(Wt(a.tool_response,e.baseURL)),n.pendingFingerprint=void 0),{continue:!0}}]}]}}});this.planBefore||(this.planBefore=et(s));try{for await(let i of s)i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"modelUsage"in i&&i.modelUsage&&(r.models=Object.keys(i.modelUsage)),"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0),i.subtype==="success"&&(o=i.result))}catch(i){let a=i instanceof Error?i.message:String(i);n.done=!0,n.success=!1,n.narrative=`Agent session error: ${a}`}return n.done||(n.success=!1,n.narrative=n.narrative||`Agent session ended without finalizing the run. Last output: ${o.slice(0,500)}`),{success:n.success,narrative:n.narrative,adaptations:n.adaptations,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:n.featureEdits.map(i=>({index:i.index,text:Ze(i.text,e.scenario.params)})),recordedSteps:Qt(Kt(Yt(Jt(n.steps,e.scenario),e.scenario.params)),pe(e.scenario)),announcedSteps:[...n.announced].sort((i,a)=>i-a),usage:r}}};function Te(t){return`$${t.toFixed(t>=1?2:4)}`}function tt(t,e){let n=t===void 0?"?":`${Math.round(t)}%`,r=e===void 0?"?":`${Math.round(e)}%`;return`${n} \u2192 ${r}`}function fn(t){return t.plan?` \xB7 5-hour window ${tt(t.plan.fiveHourBefore,t.plan.fiveHourAfter)} (${t.plan.subscriptionType} plan) \xB7 \u2248 ${Te(t.costUsd)} at API rates`:` \xB7 ${Te(t.costUsd)}`}function gn(t,e){return t?e?`\u2248 ${Te(t)}`:Te(t):""}function mn(t){if(!t)return"";let e=new Date(t);return Number.isNaN(e.getTime())?"":`resets ${e.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}import hn from"node:fs";import Ae from"node:path";function Ee(t){return JSON.stringify([t.role??null,t.name??null,t.nameRegex??null,t.label??null,t.text??null,t.testId??null,t.placeholder??null])}function yn(t,e){let n=new Map,r=Math.min(t.steps.length,e.steps.length);for(let o=0;o<r;o++){let s=t.steps[o].actions,i=e.steps[o].actions;if(s.length===i.length)for(let a=0;a<s.length;a++){let d=s[a],p=i[a];if(d.action!==p.action||!d.target||!p.target)continue;let m=Ee(d.target);m!==Ee(p.target)&&n.set(m,{from:d.target,to:p.target})}}return[...n.values()]}function Zr(t){let e=Ae.join(t,".saffron","cache");if(!hn.existsSync(e))return[];let n=[];for(let r of hn.readdirSync(e,{recursive:!0})){let o=String(r);o.endsWith(".json")&&n.push(Ae.join(e,o))}return n}function Sn(t,e,n,r){if(e.length===0)return[];let o=new Map(e.map(i=>[Ee(i.from),i])),s=[];for(let i of Zr(t)){if(Ae.resolve(i)===Ae.resolve(n))continue;let a=L(i);if(!a)continue;let d=[],p=!1;for(let m of a.steps){let c=0;for(let l of m.actions){if(!l.target)continue;let u=o.get(Ee(l.target));u&&(c++,r||(l.target=structuredClone(u.to),p=!0))}c>0&&d.push({gherkin:m.gherkin,count:c})}d.length>0&&(p&&ee(i,a),s.push({file:i,scenario:a.scenario,feature:a.feature,steps:d}))}return s}function nt(t){return t.role?`${t.role} "${t.name??t.nameRegex??""}"`:t.label?`label "${t.label}"`:t.text?`text "${t.text}"`:t.testId?`testId "${t.testId}"`:(t.fallbackSelectors??[]).join(",")||"(empty target)"}import xn from"node:fs";import rt from"node:path";function bn(t,e,n,r,o){let s={applied:[],skipped:[]},i=Z(rt.join(t,e),t,o).find(p=>p.name===n);if(!i){for(let p of r)s.skipped.push({index:p.index,reason:`scenario "${n}" not found in ${e}`});return s}let a=new Map,d=new Set;for(let p of r){let m=i.steps[p.index];if(!m?.source){s.skipped.push({index:p.index,reason:`no step at index ${p.index}`});continue}let{file:c,line:l,fromStepSet:u,fromBackground:h}=m.source;if(h){s.skipped.push({index:p.index,reason:`"${m.text}" is a Background step shared across scenarios; edit it manually`});continue}let f=`${c}:${l}`;if(d.has(f))continue;let g=a.get(c);g||(g=xn.readFileSync(rt.join(t,c),"utf8").split(`
72
- `),a.set(c,g));let v=g[l-1],S=v?.match(/^(\s*)(\S+)\s+(.*)$/);if(!S||S[3]!==m.text){s.skipped.push({index:p.index,reason:`${c}:${l} no longer matches the recorded step text`});continue}let y=`${S[1]}${S[2]} ${p.text}`;g[l-1]=y,d.add(f),s.applied.push({file:c,line:l,from:v.trim(),to:y.trim(),fromStepSet:u})}for(let[p,m]of a)s.applied.some(c=>c.file===p)&&xn.writeFileSync(rt.join(t,p),m.join(`
73
- `));return s}import it from"node:fs";import st from"node:fs";import Pe from"node:path";import{fileURLToPath as es}from"node:url";function Ce(){let t=Pe.dirname(es(import.meta.url));for(let e=0;e<6;e++){let n=Pe.join(t,"package.json");if(st.existsSync(n))try{if(JSON.parse(st.readFileSync(n,"utf8")).name==="saffron-ai")return t}catch{}t=Pe.dirname(t)}}function Fe(){let t=Ce();if(!t)return"unknown";try{return JSON.parse(st.readFileSync(Pe.join(t,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import at from"node:path";var C=t=>t.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),wn="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",ts="M24.085 45.646L24.085 45.646Q20.719 45.646 18.407 44.575Q16.095 43.504 14.905 41.651Q13.715 39.798 13.715 37.384L13.715 37.384L19.121 37.384Q19.121 38.336 19.648 39.152Q20.175 39.968 21.263 40.461Q22.351 40.954 24.085 40.954L24.085 40.954Q25.649 40.954 26.703 40.546Q27.757 40.138 28.301 39.407Q28.845 38.676 28.845 37.724L28.845 37.724Q28.845 36.534 27.825 35.837Q26.805 35.140 24.527 34.936L24.527 34.936L22.657 34.766Q18.951 34.460 16.741 32.454Q14.531 30.448 14.531 27.150L14.531 27.150Q14.531 24.770 15.670 23.036Q16.809 21.302 18.849 20.367Q20.889 19.432 23.677 19.432L23.677 19.432Q26.601 19.432 28.692 20.435Q30.783 21.438 31.905 23.257Q33.027 25.076 33.027 27.524L33.027 27.524L27.587 27.524Q27.587 26.606 27.145 25.841Q26.703 25.076 25.836 24.600Q24.969 24.124 23.677 24.124L23.677 24.124Q22.453 24.124 21.620 24.532Q20.787 24.940 20.379 25.637Q19.971 26.334 19.971 27.150L19.971 27.150Q19.971 28.204 20.719 28.986Q21.467 29.768 23.167 29.904L23.167 29.904L25.071 30.074Q27.791 30.312 29.865 31.247Q31.939 32.182 33.112 33.797Q34.285 35.412 34.285 37.724L34.285 37.724Q34.285 40.104 33.044 41.889Q31.803 43.674 29.525 44.660Q27.247 45.646 24.085 45.646Z";function ns(t){return`<svg viewBox="0 0 48 48" width="${t}" height="${t}" aria-hidden="true">
74
- <path d="${wn}" fill="#F4A300"/>
75
- <path d="${ts}" fill="#E7E9EC"/>
76
- </svg>`}function rs(t){return`<svg viewBox="8 1 32 15" height="${t}" aria-hidden="true"><path d="${wn}" fill="#F4A300"/></svg>`}var ss={green:"passed",yellow:"pending review",red:"failed"};function Ie(t){return t>=1e3?`${(t/1e3).toFixed(1)}s`:`${t}ms`}function me(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}function V(t,e,n,r="",o="",s=""){return`<div class="kpi">
69
+ `)}`:"You are starting from a blank page.","",`Record ONLY steps ${s} through ${o} (inclusive).`,`CRITICAL STOP RULE: the steps AFTER step ${o} already have recordings and will be replayed automatically the moment you stop. After completing step ${o}, immediately call saffron_done \u2014 do NOT perform, verify, or navigate into any later step. Executing a later step yourself would make the automatic replay run it TWICE.`)}else if(t.completedSteps&&t.completedSteps.length>0){let s=t.completedSteps.length;r.push("MODE: RECORD (seeded). No cache exists for this scenario yet, but its first steps matched existing recordings and were ALREADY EXECUTED against the live browser by zero-AI replay:",t.completedSteps.map(o=>` \u2713 ${o}`).join(`
70
+ `),"",`The browser currently shows the state after those ${s} step(s) (verify with a snapshot if unsure). Do NOT redo them and do NOT navigate away from the current state.`,`Start with saffron_step for step index ${s} and record from there to the end.`)}else r.push("MODE: RECORD. No cache exists for this scenario yet.","Execute every step in order, starting at step 0. Navigate to the base URL first if the scenario implies a starting page.");return r.filter(s=>s!==void 0).join(`
71
+ `)}import{query as es}from"@anthropic-ai/claude-agent-sdk";var ts="usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";async function at(t,e=8e3){let n=t?.[ts];if(typeof n!="function")return;let r;try{let s=await Promise.race([n.call(t),new Promise(o=>{r=setTimeout(()=>o(void 0),e),r.unref?.()})]);return ns(s)}catch{return}finally{r&&clearTimeout(r)}}function ns(t){if(!t||!t.rate_limits_available||!t.subscription_type)return;let e=t.rate_limits?.five_hour;return{subscriptionType:t.subscription_type,utilization:typeof e?.utilization=="number"?e.utilization:void 0,resetsAt:e?.resets_at??void 0}}async function xn(){let t=()=>{},e=new Promise(s=>t=s);async function*n(){await e}let r;try{let s=es({prompt:n(),options:{maxTurns:1,tools:[],settingSources:[],persistSession:!1}});r=s;let o=(async()=>{try{for await(let a of s);}catch{}})(),i=await at(s);return t(),r.close?.(),await o,i}catch{t(),r?.close?.();return}}var os=rs(import.meta.url);function is(){let t=os.resolve("@playwright/mcp/package.json");return bn.join(bn.dirname(t),"cli.js")}var as=new Set(["mcp__playwright__browser_navigate","mcp__playwright__browser_click","mcp__playwright__browser_type","mcp__playwright__browser_fill_form","mcp__playwright__browser_select_option","mcp__playwright__browser_press_key"]),_e=class{constructor(e={}){this.opts=e}opts;planBefore;async planUsage(){if(!this.planBefore)return;let e=await this.planBefore;if(!e)return;let n=await xn();return{subscriptionType:e.subscriptionType,fiveHourBefore:e.utilization,fiveHourAfter:n?.utilization,resetsAt:n?.resetsAt??e.resetsAt}}async run(e){let n=new je(e.scenario),r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},s="",o=ss({prompt:Sn(e),options:{systemPrompt:yn({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:e.assertionPolicy==="adaptable-mid"}),model:e.model??this.opts.model,maxTurns:this.opts.maxTurns??100,tools:[],permissionMode:"bypassPermissions",allowDangerouslySkipPermissions:!0,persistSession:!1,settingSources:[],mcpServers:{playwright:{type:"stdio",command:process.execPath,args:[is(),"--cdp-endpoint",e.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:hn(n)},hooks:{PreToolUse:[{hooks:[async i=>{let a=i;if(as.has(a.tool_name)&&e.takeFingerprint&&!n.pendingFingerprint)try{n.pendingFingerprint=await e.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async i=>{let a=i,d=an(a.tool_name,a.tool_input);return d&&n.addArmedAction(d),a.tool_name.startsWith("mcp__playwright__")&&(n.addActions(en(a.tool_response,e.baseURL)),n.pendingFingerprint=void 0),{continue:!0}}]}]}}});this.planBefore||(this.planBefore=at(o));try{for await(let i of o)i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"modelUsage"in i&&i.modelUsage&&(r.models=Object.keys(i.modelUsage)),"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0),i.subtype==="success"&&(s=i.result))}catch(i){let a=i instanceof Error?i.message:String(i);n.done=!0,n.success=!1,n.narrative=`Agent session error: ${a}`}return n.done||(n.success=!1,n.narrative=n.narrative||`Agent session ended without finalizing the run. Last output: ${s.slice(0,500)}`),{success:n.success,narrative:n.narrative,adaptations:n.adaptations,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:n.featureEdits.map(i=>({index:i.index,text:it(i.text,e.scenario.params)})),recordedSteps:on(sn(nn(rn(n.steps,e.scenario),e.scenario.params)),ve(e.scenario)),announcedSteps:[...n.announced].sort((i,a)=>i-a),usage:r}}};function De(t){return`$${t.toFixed(t>=1?2:4)}`}function ct(t,e){let n=t===void 0?"?":`${Math.round(t)}%`,r=e===void 0?"?":`${Math.round(e)}%`;return`${n} \u2192 ${r}`}function vn(t){return t.plan?` \xB7 5-hour window ${ct(t.plan.fiveHourBefore,t.plan.fiveHourAfter)} (${t.plan.subscriptionType} plan) \xB7 \u2248 ${De(t.costUsd)} at API rates`:` \xB7 ${De(t.costUsd)}`}function wn(t,e){return t?e?`\u2248 ${De(t)}`:De(t):""}function kn(t){if(!t)return"";let e=new Date(t);return Number.isNaN(e.getTime())?"":`resets ${e.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}import $n from"node:fs";import Oe from"node:path";function Me(t){return JSON.stringify([t.role??null,t.name??null,t.nameRegex??null,t.label??null,t.text??null,t.testId??null,t.placeholder??null])}function Rn(t,e){let n=new Map,r=Math.min(t.steps.length,e.steps.length);for(let s=0;s<r;s++){let o=t.steps[s].actions,i=e.steps[s].actions;if(o.length===i.length)for(let a=0;a<o.length;a++){let d=o[a],p=i[a];if(d.action!==p.action||!d.target||!p.target)continue;let g=Me(d.target);g!==Me(p.target)&&n.set(g,{from:d.target,to:p.target})}}return[...n.values()]}function cs(t){let e=Oe.join(t,".saffron","cache");if(!$n.existsSync(e))return[];let n=[];for(let r of $n.readdirSync(e,{recursive:!0})){let s=String(r);s.endsWith(".json")&&n.push(Oe.join(e,s))}return n}function Tn(t,e,n,r){if(e.length===0)return[];let s=new Map(e.map(i=>[Me(i.from),i])),o=[];for(let i of cs(t)){if(Oe.resolve(i)===Oe.resolve(n))continue;let a=B(i);if(!a)continue;let d=[],p=!1;for(let g of a.steps){let l=0;for(let c of g.actions){if(!c.target)continue;let u=s.get(Me(c.target));u&&(l++,r||(c.target=structuredClone(u.to),p=!0))}l>0&&d.push({gherkin:g.gherkin,count:l})}d.length>0&&(p&&de(i,a),o.push({file:i,scenario:a.scenario,feature:a.feature,steps:d}))}return o}function lt(t){return t.role?`${t.role} "${t.name??t.nameRegex??""}"`:t.label?`label "${t.label}"`:t.text?`text "${t.text}"`:t.testId?`testId "${t.testId}"`:(t.fallbackSelectors??[]).join(",")||"(empty target)"}import En from"node:fs";import dt from"node:path";function An(t,e,n,r,s){let o={applied:[],skipped:[]},i=K(dt.join(t,e),t,s).find(p=>p.name===n);if(!i){for(let p of r)o.skipped.push({index:p.index,reason:`scenario "${n}" not found in ${e}`});return o}let a=new Map,d=new Set;for(let p of r){let g=i.steps[p.index];if(!g?.source){o.skipped.push({index:p.index,reason:`no step at index ${p.index}`});continue}let{file:l,line:c,fromStepSet:u,fromBackground:h}=g.source;if(h){o.skipped.push({index:p.index,reason:`"${g.text}" is a Background step shared across scenarios; edit it manually`});continue}let f=`${l}:${c}`;if(d.has(f))continue;let m=a.get(l);m||(m=En.readFileSync(dt.join(t,l),"utf8").split(`
72
+ `),a.set(l,m));let w=m[c-1],x=w?.match(/^(\s*)(\S+)\s+(.*)$/);if(!x||x[3]!==g.text){o.skipped.push({index:p.index,reason:`${l}:${c} no longer matches the recorded step text`});continue}let b=`${x[1]}${x[2]} ${p.text}`;m[c-1]=b,d.add(f),o.applied.push({file:l,line:c,from:w.trim(),to:b.trim(),fromStepSet:u})}for(let[p,g]of a)o.applied.some(l=>l.file===p)&&En.writeFileSync(dt.join(t,p),g.join(`
73
+ `));return o}import ft from"node:fs";import pt from"node:fs";import Ne from"node:path";import{fileURLToPath as ls}from"node:url";function Ue(){let t=Ne.dirname(ls(import.meta.url));for(let e=0;e<6;e++){let n=Ne.join(t,"package.json");if(pt.existsSync(n))try{if(JSON.parse(pt.readFileSync(n,"utf8")).name==="saffron-ai")return t}catch{}t=Ne.dirname(t)}}function he(){let t=Ue();if(!t)return"unknown";try{return JSON.parse(pt.readFileSync(Ne.join(t,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import gt from"node:path";var I=t=>t.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),Cn="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",ds="M24.085 45.646L24.085 45.646Q20.719 45.646 18.407 44.575Q16.095 43.504 14.905 41.651Q13.715 39.798 13.715 37.384L13.715 37.384L19.121 37.384Q19.121 38.336 19.648 39.152Q20.175 39.968 21.263 40.461Q22.351 40.954 24.085 40.954L24.085 40.954Q25.649 40.954 26.703 40.546Q27.757 40.138 28.301 39.407Q28.845 38.676 28.845 37.724L28.845 37.724Q28.845 36.534 27.825 35.837Q26.805 35.140 24.527 34.936L24.527 34.936L22.657 34.766Q18.951 34.460 16.741 32.454Q14.531 30.448 14.531 27.150L14.531 27.150Q14.531 24.770 15.670 23.036Q16.809 21.302 18.849 20.367Q20.889 19.432 23.677 19.432L23.677 19.432Q26.601 19.432 28.692 20.435Q30.783 21.438 31.905 23.257Q33.027 25.076 33.027 27.524L33.027 27.524L27.587 27.524Q27.587 26.606 27.145 25.841Q26.703 25.076 25.836 24.600Q24.969 24.124 23.677 24.124L23.677 24.124Q22.453 24.124 21.620 24.532Q20.787 24.940 20.379 25.637Q19.971 26.334 19.971 27.150L19.971 27.150Q19.971 28.204 20.719 28.986Q21.467 29.768 23.167 29.904L23.167 29.904L25.071 30.074Q27.791 30.312 29.865 31.247Q31.939 32.182 33.112 33.797Q34.285 35.412 34.285 37.724L34.285 37.724Q34.285 40.104 33.044 41.889Q31.803 43.674 29.525 44.660Q27.247 45.646 24.085 45.646Z";function ps(t){return`<svg viewBox="0 0 48 48" width="${t}" height="${t}" aria-hidden="true">
74
+ <path d="${Cn}" fill="#F4A300"/>
75
+ <path d="${ds}" fill="#E7E9EC"/>
76
+ </svg>`}function us(t){return`<svg viewBox="8 1 32 15" height="${t}" aria-hidden="true"><path d="${Cn}" fill="#F4A300"/></svg>`}var fs={green:"passed",yellow:"pending review",red:"failed"};function Le(t){return t>=1e3?`${(t/1e3).toFixed(1)}s`:`${t}ms`}function $e(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}function W(t,e,n,r="",s="",o=""){return`<div class="kpi">
77
77
  <div class="kpi-label">${t}</div>
78
78
  <div class="kpi-value ${r}">${e}</div>
79
- <div class="kpi-sub ${o}">${n}</div>${s}
80
- </div>`}function ot(t,e){if(t.length<2)return"";let n=108,r=22,o=Math.min(...t),i=Math.max(...t)-o||1,a=t.map((d,p)=>`${(p/(t.length-1)*n).toFixed(1)},${(r-2-(d-o)/i*(r-4)).toFixed(1)}`).join(" ");return`<svg class="spark" viewBox="0 0 ${n} ${r}" width="${n}" height="${r}" aria-hidden="true"><polyline points="${a}" fill="none" stroke="${e}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var vn=(t,e="",n=0)=>`${t>=0?"+":"\u2212"}${Math.abs(t).toFixed(n)}${e} vs prev`;function os(t,e){let n=t.totals,r=t.trends,o=n.scenarios===0?0:Math.round((n.green+n.yellow)/n.scenarios*100),s=n.red>0?"red":n.yellow>0?"gold":"green",i=n.inputTokens+n.outputTokens,a=r?.prev?`${vn(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,d=r?.prev?`${vn(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${Ie(e)}`:`${Ie(e)} total runtime`;return`<section class="kpis">
81
- ${V("pass rate",`${o}<span class="unit">%</span>`,a,s,r?.prev?r.prev.passRatePp>=0?"green":"red":"",ot(r?.series.passRate??[],"var(--green)"))}
82
- ${V("passed",String(n.green),"deterministic replay","green",n.green>0?"green":"")}
83
- ${V("pending",String(n.yellow),n.yellow>0?"\u25B2 proposals to review":"nothing to review",n.yellow>0?"gold":"",n.yellow>0?"gold":"")}
84
- ${V("failed",String(n.red),n.red>0?"\u25BC needs attention":"all clear",n.red>0?"red":"",n.red>0?"red":"green")}
85
- ${V("ai calls",String(n.aiCalls),`${me(i)} in+out tokens`,n.aiCalls>0?"":"green")}
86
- ${V("cache traffic",me(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${me(n.cacheReadTokens)} read \xB7 ${me(n.cacheCreationTokens)} written`:"zero (fully cached run)","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
87
- ${n.plan?V("plan usage",`${tt(n.plan.fiveHourBefore,n.plan.fiveHourAfter)}`,[`5-hour window \xB7 ${n.plan.subscriptionType} plan`,mn(n.plan.resetsAt)].filter(Boolean).join(" \xB7 "),"gold","")+V("api-equivalent",`<span class="unit">\u2248 $</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,`what this run costs on an API key \xB7 ${d}`,"","",ot(r?.series.costUsd??[],"var(--gold)")):V("ai cost",`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,d,"","",ot(r?.series.costUsd??[],"var(--gold)"))}
88
- </section>`}function is(t){let e=t.trends;if(!e)return"";let n=[];return e.chronic.length>0&&n.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios: stop paying for heals</div><ul>${e.chronic.map(r=>`<li>${C(r.feature)} \u203A ${C(r.scenario)}: ${r.heals} heal(s), ${r.reds} red(s) in the last ${r.runs} run(s). Re-record it or fix the feature text.</li>`).join("")}</ul></div>`),e.divergentSteps>0&&n.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${e.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),e.topIssues.length>0&&n.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${e.topIssues.map(r=>`<li>${r.count}\xD7: ${C(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
89
- `)}</section>`:""}function as(t){return`<ol class="steps">${t.steps.map(n=>{let r=n.status==="passed"?'<span class="dot green"></span>':n.status==="failed"?'<span class="dot red"></span>':'<span class="dot idle"></span>';return`<li class="step ${n.status}">
79
+ <div class="kpi-sub ${s}">${n}</div>${o}
80
+ </div>`}function ut(t,e){if(t.length<2)return"";let n=108,r=22,s=Math.min(...t),i=Math.max(...t)-s||1,a=t.map((d,p)=>`${(p/(t.length-1)*n).toFixed(1)},${(r-2-(d-s)/i*(r-4)).toFixed(1)}`).join(" ");return`<svg class="spark" viewBox="0 0 ${n} ${r}" width="${n}" height="${r}" aria-hidden="true"><polyline points="${a}" fill="none" stroke="${e}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var Pn=(t,e="",n=0)=>`${t>=0?"+":"\u2212"}${Math.abs(t).toFixed(n)}${e} vs prev`;function gs(t,e){let n=t.totals,r=t.trends,s=n.scenarios===0?0:Math.round((n.green+n.yellow)/n.scenarios*100),o=n.red>0?"red":n.yellow>0?"gold":"green",i=n.inputTokens+n.outputTokens,a=r?.prev?`${Pn(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,d=r?.prev?`${Pn(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${Le(e)}`:`${Le(e)} total runtime`;return`<section class="kpis">
81
+ ${W("pass rate",`${s}<span class="unit">%</span>`,a,o,r?.prev?r.prev.passRatePp>=0?"green":"red":"",ut(r?.series.passRate??[],"var(--green)"))}
82
+ ${W("passed",String(n.green),"deterministic replay","green",n.green>0?"green":"")}
83
+ ${W("pending",String(n.yellow),n.yellow>0?"\u25B2 proposals to review":"nothing to review",n.yellow>0?"gold":"",n.yellow>0?"gold":"")}
84
+ ${W("failed",String(n.red),n.red>0?"\u25BC needs attention":"all clear",n.red>0?"red":"",n.red>0?"red":"green")}
85
+ ${W("ai calls",String(n.aiCalls),`${$e(i)} in+out tokens`,n.aiCalls>0?"":"green")}
86
+ ${W("cache traffic",$e(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${$e(n.cacheReadTokens)} read \xB7 ${$e(n.cacheCreationTokens)} written`:"zero (fully cached run)","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
87
+ ${n.plan?W("plan usage",`${ct(n.plan.fiveHourBefore,n.plan.fiveHourAfter)}`,[`5-hour window \xB7 ${n.plan.subscriptionType} plan`,kn(n.plan.resetsAt)].filter(Boolean).join(" \xB7 "),"gold","")+W("api-equivalent",`<span class="unit">\u2248 $</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,`what this run costs on an API key \xB7 ${d}`,"","",ut(r?.series.costUsd??[],"var(--gold)")):W("ai cost",`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,d,"","",ut(r?.series.costUsd??[],"var(--gold)"))}
88
+ </section>`}function ms(t){let e=t.trends;if(!e)return"";let n=[];return e.chronic.length>0&&n.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios: stop paying for heals</div><ul>${e.chronic.map(r=>`<li>${I(r.feature)} \u203A ${I(r.scenario)}: ${r.heals} heal(s), ${r.reds} red(s) in the last ${r.runs} run(s). Re-record it or fix the feature text.</li>`).join("")}</ul></div>`),e.divergentSteps>0&&n.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${e.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),e.topIssues.length>0&&n.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${e.topIssues.map(r=>`<li>${r.count}\xD7: ${I(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
89
+ `)}</section>`:""}function hs(t){return`<ol class="steps">${t.steps.map(n=>{let r=n.status==="passed"?'<span class="dot green"></span>':n.status==="failed"?'<span class="dot red"></span>':'<span class="dot idle"></span>';return`<li class="step ${n.status}">
90
90
  ${r}
91
91
  <div class="step-body">
92
- <span class="step-text">${C(n.gherkin)}</span>
92
+ <span class="step-text">${I(n.gherkin)}</span>
93
93
  ${n.kind==="assertion"?'<span class="tag">assert</span>':""}
94
- ${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition: edit once, every invoking scenario follows">from StepSet: ${C(n.fromStepSet)}</span>`:""}
94
+ ${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition: edit once, every invoking scenario follows">from StepSet: ${I(n.fromStepSet)}</span>`:""}
95
95
  ${n.drift?'<span class="tag gold" title="page fingerprint no longer matches the recording">drift</span>':""}
96
96
  ${n.retries?`<span class="tag gold" title="an action failed and was re-attempted with backoff before the step settled">retried \xD7${n.retries}</span>`:""}
97
- ${n.error?`<div class="step-error">${C(n.error)}</div>`:""}
97
+ ${n.error?`<div class="step-error">${I(n.error)}</div>`:""}
98
98
  </div>
99
99
  </li>`}).join(`
100
- `)}</ol>`}function cs(t){return t.featureEdits&&t.featureEdits.length>0?`<div class="panel">
100
+ `)}</ol>`}function ys(t){return t.featureEdits&&t.featureEdits.length>0?`<div class="panel">
101
101
  <div class="panel-title gold">suggested .feature edit</div>
102
- <div class="diff">${t.featureEdits.map(n=>{let r=t.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${C(r)}</div><div class="diff-line add">+ ${C(n.text)}</div>`}).join("")}</div>
102
+ <div class="diff">${t.featureEdits.map(n=>{let r=t.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${I(r)}</div><div class="diff-line add">+ ${I(n.text)}</div>`}).join("")}</div>
103
103
  <div class="panel-hint">apply with <code>saffron accept --with-feature-edit</code></div>
104
- </div>`:t.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${C(t.suggestedFeatureEdit)}</pre></div>`:""}function ls(t,e){let n=t.usage.inputTokens+t.usage.outputTokens;if(t.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let r=gn(t.usage.costUsd,e),o=r?` \xB7 ${r}`:"";return`<span class="pill gold">${t.usage.aiCalls} ai \xB7 ${me(n)} tok${o}</span>`}function ds(t,e){return`<details class="card" ${t.status!=="green"?"open":""}>
104
+ </div>`:t.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${I(t.suggestedFeatureEdit)}</pre></div>`:""}function Ss(t,e){let n=t.usage.inputTokens+t.usage.outputTokens;if(t.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let r=wn(t.usage.costUsd,e),s=r?` \xB7 ${r}`:"";return`<span class="pill gold">${t.usage.aiCalls} ai \xB7 ${$e(n)} tok${s}</span>`}function xs(t,e){return`<details class="card" ${t.status!=="green"?"open":""}>
105
105
  <summary>
106
- <span class="pill ${t.status==="green"?"green":t.status==="yellow"?"gold":"red"}">${ss[t.status]}</span>
107
- <span class="card-title">${C(t.scenario)}</span>
106
+ <span class="pill ${t.status==="green"?"green":t.status==="yellow"?"gold":"red"}">${fs[t.status]}</span>
107
+ <span class="card-title">${I(t.scenario)}</span>
108
108
  <span class="card-meta">
109
- <span class="m">${C(t.feature)}</span>
109
+ <span class="m">${I(t.feature)}</span>
110
110
  <span class="m-sep">\xB7</span>
111
- <span class="m">${C(t.source)}</span>
111
+ <span class="m">${I(t.source)}</span>
112
112
  <span class="m-sep">\xB7</span>
113
- <span class="m">${Ie(t.durationMs)}</span>
114
- ${ls(t,e)}
113
+ <span class="m">${Le(t.durationMs)}</span>
114
+ ${Ss(t,e)}
115
115
  </span>
116
116
  </summary>
117
117
  <div class="card-body">
118
- ${as(t)}
119
- ${t.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${C(t.narrative)}</p></div>`:""}
120
- ${t.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${t.adaptations.map(n=>`<li>${C(n)}</li>`).join("")}</ul></div>`:""}
121
- ${cs(t)}
122
- ${t.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${t.verified===!0?' <span class="pill green">verified</span>':t.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${C(t.proposalFile)}</p>${t.verified===!1&&t.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${C(t.proofError.split(`
118
+ ${hs(t)}
119
+ ${t.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${I(t.narrative)}</p></div>`:""}
120
+ ${t.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${t.adaptations.map(n=>`<li>${I(n)}</li>`).join("")}</ul></div>`:""}
121
+ ${ys(t)}
122
+ ${t.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${t.verified===!0?' <span class="pill green">verified</span>':t.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${I(t.proposalFile)}</p>${t.verified===!1&&t.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${I(t.proofError.split(`
123
123
  `)[0])}</p>`:""}<div class="panel-hint">review with <code>saffron accept</code> / <code>saffron reject</code></div></div>`:""}
124
- ${t.error&&t.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${C(t.error)}</p></div>`:""}
124
+ ${t.error&&t.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${I(t.error)}</p></div>`:""}
125
125
  </div>
126
- </details>`}function kn(t){let e=t.totals,n=new Date(t.startedAt),r=new Date(t.finishedAt).getTime()-n.getTime(),o=n.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
126
+ </details>`}function Fn(t){let e=t.totals,n=new Date(t.startedAt),r=new Date(t.finishedAt).getTime()-n.getTime(),s=n.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
127
127
  <html lang="en">
128
128
  <head>
129
129
  <meta charset="utf-8"/>
@@ -243,61 +243,63 @@ ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
243
243
  <div class="wrap">
244
244
  <header class="top">
245
245
  <div class="brand">
246
- ${ns(38)}
246
+ ${ps(38)}
247
247
  <div>
248
248
  <div class="brand-name">saffron</div>
249
249
  <div class="brand-sub">test run report</div>
250
250
  </div>
251
251
  </div>
252
252
  <div class="run-meta">
253
- <span>${C(o)}</span>
253
+ <span>${I(s)}</span>
254
254
  <span>\xB7</span>
255
- <span>${Ie(r)}</span>
256
- ${t.baseURL?`<span class="chip base">${C(t.baseURL)}</span>`:""}
255
+ <span>${Le(r)}</span>
256
+ ${t.baseURL?`<span class="chip base">${I(t.baseURL)}</span>`:""}
257
257
  </div>
258
258
  </header>
259
259
 
260
- ${os(t,r)}
261
- ${is(t)}
260
+ ${gs(t,r)}
261
+ ${ms(t)}
262
262
 
263
263
  <div class="section-head">
264
264
  <span class="section-title">scenarios \xB7 ${e.scenarios}</span>
265
- <span class="section-title">saffron v${C(t.version)}</span>
265
+ <span class="section-title">saffron v${I(t.version)}</span>
266
266
  </div>
267
- ${t.scenarios.map(s=>ds(s,!!t.totals.plan)).join(`
267
+ ${t.scenarios.map(o=>xs(o,!!t.totals.plan)).join(`
268
268
  `)}
269
269
 
270
- <footer>${rs(11)} generated by saffron v${C(t.version)} \xB7 ${C(new Date(t.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
270
+ <footer>${us(11)} generated by saffron v${I(t.version)} \xB7 ${I(new Date(t.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
271
271
  </div>
272
272
  </body>
273
273
  </html>
274
- `}function $n(t,e,n){let r={scenarios:t.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},o=new Set;for(let s of t){for(let i of s.usage.models??[])o.add(i);r[s.status]+=1,r.aiCalls+=s.usage.aiCalls,r.inputTokens+=s.usage.inputTokens,r.outputTokens+=s.usage.outputTokens,r.cacheReadTokens+=s.usage.cacheReadTokens,r.cacheCreationTokens+=s.usage.cacheCreationTokens??0,r.costUsd+=s.usage.costUsd??0}return o.size>0&&(r.models=[...o].sort()),{tool:"saffron",version:Fe(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:r,scenarios:t.map(s=>({feature:s.scenario.featurePath,scenario:s.scenario.displayName,tags:[...s.scenario.tags],status:s.status,source:s.source,durationMs:s.durationMs,usage:s.usage,steps:s.stepResults,narrative:s.narrative,adaptations:s.adaptations,suggestedFeatureEdit:s.suggestedFeatureEdit,featureEdits:s.featureEdits,verified:s.verified,proofError:s.proofError,proposalFile:s.proposalFile,error:s.error}))}}function Rn(t,e){let n=at.join(t,".saffron","reports");it.mkdirSync(n,{recursive:!0});let r=at.join(n,"latest.json"),o=at.join(n,"latest.html");return it.writeFileSync(r,`${JSON.stringify(e,null,2)}
275
- `),it.writeFileSync(o,kn(e)),{json:r,html:o}}function Tn(t){let e=t===void 0?[]:Array.isArray(t)?t:[t],n=new Set;for(let r of e)for(let o of r.split(/[\s,|]+/)){let s=o.trim();s&&n.add(s.startsWith("@")?s:`@${s}`)}return[...n]}function An(t,e){return e.length===0?t:t.filter(n=>n.tags.some(r=>e.includes(r)))}function En(t,e){return[...e??[],t]}import je from"node:fs";import Pn from"node:path";function Cn(t){return Pn.join(t,".saffron","history.jsonl")}function Fn(t,e,n,r){let o=t.map(s=>({feature:s.scenario.featurePath,scenario:s.scenario.displayName,tags:[...s.scenario.tags],status:s.status,source:s.source,durationMs:s.durationMs,verified:s.verified,seededSteps:s.seededSteps,adaptations:s.adaptations.length,driftSteps:s.stepResults.filter(i=>i.drift).length,aiCalls:s.usage.aiCalls,tokensInOut:s.usage.inputTokens+s.usage.outputTokens,cacheRead:s.usage.cacheReadTokens,cacheWrite:s.usage.cacheCreationTokens,costUsd:s.usage.costUsd??0,error:s.error?.split(`
276
- `)[0]}));return{runId:e.toISOString(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:o.filter(s=>s.status==="green").length,yellow:o.filter(s=>s.status==="yellow").length,red:o.filter(s=>s.status==="red").length,aiCalls:o.reduce((s,i)=>s+i.aiCalls,0),costUsd:o.reduce((s,i)=>s+i.costUsd,0),divergentSteps:n,scenarios:o}}function In(t,e){let n=Cn(t);je.mkdirSync(Pn.dirname(n),{recursive:!0}),je.appendFileSync(n,`${JSON.stringify(e)}
277
- `)}function jn(t,e=50){let n=Cn(t);if(!je.existsSync(n))return[];let r=je.readFileSync(n,"utf8").trim().split(`
278
- `).filter(Boolean),o=[];for(let s of r.slice(-e))try{o.push(JSON.parse(s))}catch{}return o}var ct=t=>{let e=t.green+t.yellow+t.red;return e===0?0:Math.round((t.green+t.yellow)/e*100)};function _n(t,e){let n=[...t,e],r=n.slice(-20),o=n.slice(-10),s=t.at(-1),i=s?{passRatePp:ct(e)-ct(s),costUsd:e.costUsd-s.costUsd,aiCalls:e.aiCalls-s.aiCalls,red:e.red-s.red}:void 0,a=new Map;for(let c of o)for(let l of c.scenarios){let u=`${l.feature}::${l.scenario}`,h=a.get(u)??{feature:l.feature,scenario:l.scenario,heals:0,reds:0,runs:0};h.runs++,l.source==="agent-heal"&&l.status==="yellow"&&h.heals++,l.status==="red"&&h.reds++,a.set(u,h)}let d=[...a.values()].filter(c=>c.heals>=2||c.reds>=3).sort((c,l)=>l.heals+l.reds-(c.heals+c.reds)),p=new Map;for(let c of r)for(let l of c.scenarios)l.status==="red"&&l.error&&p.set(l.error,(p.get(l.error)??0)+1);let m=[...p.entries()].sort((c,l)=>l[1]-c[1]).slice(0,3).map(([c,l])=>({message:c,count:l}));return{prev:i,series:{passRate:r.map(ct),costUsd:r.map(c=>Number(c.costUsd.toFixed(4))),aiCalls:r.map(c=>c.aiCalls)},chronic:d,divergentSteps:e.divergentSteps,topIssues:m}}import $ from"node:fs";import P from"node:path";var he={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},ps=["claude","agents"],Mn="<!-- saffron:start -->",dt="<!-- saffron:end -->";function us(){return[Mn,"## Saffron end-to-end tests","","UI tests are plain-Gherkin `.feature` / `.saffron` files under `features/`,","run by Saffron (`npx saffron run`). No step definitions exist: an AI agent","records each scenario once; later runs replay at zero tokens.","","- Load the `saffron` skill before writing or editing scenarios.","- Exact step text is the cache identity: run `npx saffron steps` and reuse"," recorded wordings instead of inventing synonyms.","- `Then` steps are never healed: write them as the verdict, last.","- Never edit `.saffron/cache/` or `.saffron/proposals/` by hand; change the"," scenario text and re-run. Review proposals with `npx saffron accept`.","- Secrets go in `{env:VAR}` tokens, never in files.",dt,""].join(`
279
- `)}function On(t,e){if(!$.existsSync(t))return $.writeFileSync(t,e),"created";let n=$.readFileSync(t,"utf8"),r=n.indexOf(Mn),o=n.indexOf(dt);if(r!==-1&&o!==-1&&o>r){let i=n.slice(0,r)+e.trimEnd()+n.slice(o+dt.length);return i===n?"unchanged":($.writeFileSync(t,i),"updated")}let s=n.endsWith(`
274
+ `}function jn(t,e,n){let r={scenarios:t.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},s=new Set;for(let o of t){for(let i of o.usage.models??[])s.add(i);r[o.status]+=1,r.aiCalls+=o.usage.aiCalls,r.inputTokens+=o.usage.inputTokens,r.outputTokens+=o.usage.outputTokens,r.cacheReadTokens+=o.usage.cacheReadTokens,r.cacheCreationTokens+=o.usage.cacheCreationTokens??0,r.costUsd+=o.usage.costUsd??0}return s.size>0&&(r.models=[...s].sort()),{tool:"saffron",version:he(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:r,scenarios:t.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,tags:[...o.scenario.tags],status:o.status,source:o.source,durationMs:o.durationMs,usage:o.usage,steps:o.stepResults,narrative:o.narrative,adaptations:o.adaptations,suggestedFeatureEdit:o.suggestedFeatureEdit,featureEdits:o.featureEdits,verified:o.verified,proofError:o.proofError,proposalFile:o.proposalFile,error:o.error}))}}function In(t,e){let n=gt.join(t,".saffron","reports");ft.mkdirSync(n,{recursive:!0});let r=gt.join(n,"latest.json"),s=gt.join(n,"latest.html");return ft.writeFileSync(r,`${JSON.stringify(e,null,2)}
275
+ `),ft.writeFileSync(s,Fn(e)),{json:r,html:s}}function _n(t){let e=t===void 0?[]:Array.isArray(t)?t:[t],n=new Set;for(let r of e)for(let s of r.split(/[\s,|]+/)){let o=s.trim();o&&n.add(o.startsWith("@")?o:`@${o}`)}return[...n]}function Dn(t,e){return e.length===0?t:t.filter(n=>n.tags.some(r=>e.includes(r)))}function On(t,e){return[...e??[],t]}import Mn from"node:fs";import bs from"node:path";function Q(t){qt(t);let e=bs.join(t,"saffron.config.json");return Mn.existsSync(e)?JSON.parse(Mn.readFileSync(e,"utf8")):{}}var vs={features:"features",actionTimeoutMs:5e3,retries:1,maxTurns:100,strict:!1,verifyProposals:!0,snapshotMode:"none",reuseSteps:!0,browser:"chromium",workers:1,assertionPolicy:"strict"};function Nn(t){return{...vs,...Object.fromEntries(Object.entries(t).filter(([,e])=>e!==void 0))}}import G from"node:fs";import Y from"node:path";import Ve from"node:fs";import Un from"node:path";function Ln(t){return Un.join(t,".saffron","history.jsonl")}function Vn(t,e,n,r){let s=t.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,tags:[...o.scenario.tags],status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(i=>i.drift).length,aiCalls:o.usage.aiCalls,tokensInOut:o.usage.inputTokens+o.usage.outputTokens,cacheRead:o.usage.cacheReadTokens,cacheWrite:o.usage.cacheCreationTokens,costUsd:o.usage.costUsd??0,error:o.error?.split(`
276
+ `)[0]}));return{runId:e.toISOString(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:n,scenarios:s}}function Bn(t,e){let n=Ln(t);Ve.mkdirSync(Un.dirname(n),{recursive:!0}),Ve.appendFileSync(n,`${JSON.stringify(e)}
277
+ `)}function Be(t,e=50){let n=Ln(t);if(!Ve.existsSync(n))return[];let r=Ve.readFileSync(n,"utf8").trim().split(`
278
+ `).filter(Boolean),s=[];for(let o of r.slice(-e))try{s.push(JSON.parse(o))}catch{}return s}var mt=t=>{let e=t.green+t.yellow+t.red;return e===0?0:Math.round((t.green+t.yellow)/e*100)};function zn(t,e){let n=[...t,e],r=n.slice(-20),s=n.slice(-10),o=t.at(-1),i=o?{passRatePp:mt(e)-mt(o),costUsd:e.costUsd-o.costUsd,aiCalls:e.aiCalls-o.aiCalls,red:e.red-o.red}:void 0,a=new Map;for(let l of s)for(let c of l.scenarios){let u=`${c.feature}::${c.scenario}`,h=a.get(u)??{feature:c.feature,scenario:c.scenario,heals:0,reds:0,runs:0};h.runs++,c.source==="agent-heal"&&c.status==="yellow"&&h.heals++,c.status==="red"&&h.reds++,a.set(u,h)}let d=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,c)=>c.heals+c.reds-(l.heals+l.reds)),p=new Map;for(let l of r)for(let c of l.scenarios)c.status==="red"&&c.error&&p.set(c.error,(p.get(c.error)??0)+1);let g=[...p.entries()].sort((l,c)=>c[1]-l[1]).slice(0,3).map(([l,c])=>({message:l,count:c}));return{prev:i,series:{passRate:r.map(mt),costUsd:r.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:r.map(l=>l.aiCalls)},chronic:d,divergentSteps:e.divergentSteps,topIssues:g}}var ws=/^\s*(?:Scenario(?: Outline| Template)?|Example):\s*(.+?)\s*$/,ks=/^\s*StepSet:/;function ht(t,e){return Y.relative(t,e).split(Y.sep).join("/")}function $s(t,e){let n={green:0,yellow:1,red:2};return!t||n[e]>n[t]?e:t}function qn(t,e){return`${t} :: ${e}`}function Hn(t,e,n={}){let r=e.features??"features",s=Y.isAbsolute(r)?r:Y.join(t,r),o=Y.join(t,".saffron","reports","latest.json"),i=Y.join(t,".saffron","reports","latest.html"),a;try{a=JSON.parse(G.readFileSync(o,"utf8"))}catch{a=void 0}let d=new Map;for(let y of a?.scenarios??[]){let S=y.scenario.replace(/ \(example \d+\)$/,""),T=qn(y.feature,S);d.set(T,$s(d.get(T),y.status))}let p=new Map,g;try{p=J(le(s),t)}catch(y){g=y instanceof Error?y.message:String(y)}let l=G.existsSync(s)?[...G.readdirSync(s,{recursive:!0})].map(String).filter(te).map(y=>Y.join(s,y)).sort():[],c=[],u=new Map;for(let y of l){let S=ht(t,y),T="";try{T=G.readFileSync(y,"utf8")}catch(A){c.push({path:S,name:S,scenarios:[],stepSets:0,error:String(A)});continue}let v=T.split(`
279
+ `),k=new Map,P=0;v.forEach((A,ie)=>{let ae=A.match(ws);ae&&!k.has(ae[1])?k.set(ae[1],ie+1):ks.test(A)&&P++});let j=v.find(A=>/^\s*Feature:/.test(A)),D=j?j.replace(/^\s*Feature:\s*/,"").trim():S,Te;try{Te=K(y,t,p)}catch(A){c.push({path:S,name:D,scenarios:[],stepSets:P,error:g??(A instanceof Error?A.message:String(A))});continue}let X=new Map;for(let A of Te){let ie=X.get(A.name);if(ie){ie.rows+=1;continue}X.set(A.name,{name:A.name,line:k.get(A.name),tags:[...A.tags],outline:Object.keys(A.params).length>0,rows:1,cached:G.existsSync(V(t,A.featurePath,A.name)),proposal:G.existsSync(Se(t,A.featurePath,A.name)),lastStatus:d.get(qn(A.featurePath,A.name))});for(let ae of A.tags)u.set(ae,(u.get(ae)??0)+1)}c.push({path:S,name:D,scenarios:[...X.values()],stepSets:P})}let h=re(t).map(({file:y,proposal:S})=>({file:ht(t,y),feature:S.cache.feature,scenario:S.cache.scenario,mode:S.meta.mode,createdAt:S.meta.createdAt,verified:S.meta.verified,proofError:S.meta.proofError,adaptations:S.meta.adaptations??[],narrative:S.meta.narrative,suggestedFeatureEdit:S.meta.suggestedFeatureEdit,recordedFor:S.meta.recordedFor,aiCalls:S.meta.usage?.aiCalls??0,costUsd:S.meta.usage?.costUsd})).sort((y,S)=>y.file.localeCompare(S.file)),f;try{f=z(t,r,p)}catch{f={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0}}let m=n.nearDuplicateThreshold??.7,w=[],x=f.steps.slice(0,600);for(let y=0;y<x.length;y++)for(let S=y+1;S<x.length;S++){if(x[y].status==="unrecorded"&&x[S].status==="unrecorded")continue;let T=Ke(x[y].text,x[S].text);T>=m&&T<1&&w.push({a:x[y].text,b:x[S].text,similarity:Math.round(T*100)/100})}w.sort((y,S)=>S.similarity-y.similarity);let b=Be(t,n.historyLimit??30).map(y=>({startedAt:y.startedAt,green:y.green,yellow:y.yellow,red:y.red,aiCalls:y.aiCalls,costUsd:y.costUsd})),C=Y.join(t,"saffron.config.json");return{tool:"saffron",version:he(),projectRoot:t,packageInstalled:G.existsSync(Y.join(t,"node_modules","saffron-ai")),config:{file:G.existsSync(C)?"saffron.config.json":void 0,effective:Nn(e)},features:c,tags:[...u.entries()].map(([y,S])=>({tag:y,scenarios:S})).sort((y,S)=>y.tag.localeCompare(S.tag)),proposals:h,lastRun:a?{startedAt:a.startedAt,finishedAt:a.finishedAt,totals:a.totals,reportHtml:G.existsSync(i)?ht(t,i):void 0}:void 0,history:b,vocabulary:{steps:f.steps.length,recorded:f.steps.filter(y=>y.status==="recorded").length,unrecorded:f.steps.filter(y=>y.status==="unrecorded").length,stepSets:f.stepSets.length,divergent:f.steps.filter(y=>y.status==="divergent").map(y=>y.text),nearDuplicates:w.slice(0,50)}}}function Wn(t){let e=t.features.reduce((s,o)=>s+o.scenarios.length,0),n=t.features.reduce((s,o)=>s+o.scenarios.filter(i=>i.cached).length,0);console.log(`saffron ${t.version} \xB7 ${t.features.length} feature file(s) \xB7 ${e} scenario(s), ${n} cached`);for(let s of t.features){let o=s.scenarios.map(i=>i.proposal?"\u25D0":i.cached?"\u25CF":"\u25CB").join("");console.log(` ${s.path} ${o}${s.error?` \u2717 ${s.error.split(`
280
+ `)[0]}`:""}`)}if(t.tags.length&&console.log(`tags: ${t.tags.map(s=>`${s.tag} (${s.scenarios})`).join(", ")}`),console.log(t.proposals.length?`${t.proposals.length} proposal(s) pending review: ${t.proposals.map(s=>s.file).join(", ")}`:"no proposals pending review"),t.lastRun){let s=t.lastRun.totals;console.log(`last run ${t.lastRun.finishedAt}: ${s.green} passed \xB7 ${s.yellow} pending review \xB7 ${s.red} failed \xB7 $${s.costUsd.toFixed(2)}`)}let r=t.vocabulary;console.log(`vocabulary: ${r.steps} steps (${r.recorded} recorded, ${r.unrecorded} unrecorded), ${r.stepSets} step sets, ${r.divergent.length} divergent, ${r.nearDuplicates.length} near-duplicate pair(s)`),console.log(`config: ${t.config.file??"(defaults, no saffron.config.json)"}`)}import R from"node:fs";import F from"node:path";var Re={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},Rs=["claude","agents"],Jn="<!-- saffron:start -->",St="<!-- saffron:end -->";function Ts(){return[Jn,"## Saffron end-to-end tests","","UI tests are plain-Gherkin `.feature` / `.saffron` files under `features/`,","run by Saffron (`npx saffron run`). No step definitions exist: an AI agent","records each scenario once; later runs replay at zero tokens.","","- Load the `saffron` skill before writing or editing scenarios.","- Exact step text is the cache identity: run `npx saffron steps` and reuse"," recorded wordings instead of inventing synonyms.","- `Then` steps are never healed: write them as the verdict, last.","- Never edit `.saffron/cache/` or `.saffron/proposals/` by hand; change the"," scenario text and re-run. Review proposals with `npx saffron accept`.","- Secrets go in `{env:VAR}` tokens, never in files.",St,""].join(`
281
+ `)}function Gn(t,e){if(!R.existsSync(t))return R.writeFileSync(t,e),"created";let n=R.readFileSync(t,"utf8"),r=n.indexOf(Jn),s=n.indexOf(St);if(r!==-1&&s!==-1&&s>r){let i=n.slice(0,r)+e.trimEnd()+n.slice(s+St.length);return i===n?"unchanged":(R.writeFileSync(t,i),"updated")}let o=n.endsWith(`
280
282
  `)?`
281
283
  `:`
282
284
 
283
- `;return $.writeFileSync(t,n+s+e),"updated"}var lt={command:"npx",args:["saffron","mcp"]},fs={claude:{file:".mcp.json",key:"mcpServers",entry:lt},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:lt},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...lt}}},Dn={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function gs(t,e){let n=P.join(t,e.file),r={},o=$.existsSync(n);if(o)try{r=JSON.parse($.readFileSync(n,"utf8"))}catch{throw new Error(`${e.file} is not valid JSON: fix it or remove it, then re-run`)}let s=r[e.key]??{};return JSON.stringify(s.saffron)===JSON.stringify(e.entry)?"unchanged":(s.saffron=e.entry,r[e.key]=s,$.mkdirSync(P.dirname(n),{recursive:!0}),$.writeFileSync(n,JSON.stringify(r,null,2)+`
284
- `),o?"updated":"created")}function Nn(t,e){$.mkdirSync(e,{recursive:!0});for(let n of $.readdirSync(t,{withFileTypes:!0})){let r=P.join(t,n.name),o=P.join(e,n.name);n.isDirectory()?Nn(r,o):$.copyFileSync(r,o)}}function Un(t){let e=P.join(t.packageRoot,"skills","saffron");if(!$.existsSync(P.join(e,"SKILL.md")))throw new Error(`bundled skill not found at ${e}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=t.targets??ps;for(let o of r){let s=P.join(t.projectRoot,he[o],"saffron");$.existsSync(s)&&$.rmSync(s,{recursive:!0,force:!0}),Nn(e,s),n.skillDirs.push(P.relative(t.projectRoot,s))}if(t.mcp!==!1)for(let o of r){let s=fs[o];s?n.mcpFiles.push({file:s.file,outcome:gs(t.projectRoot,s)}):Dn[o]&&n.mcpHints.push(Dn[o])}if(t.instructions!==!1){let o=us(),s=P.join(t.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:On(s,o)});let i=P.join(t.projectRoot,"CLAUDE.md");$.existsSync(i)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:On(i,o)})}if(t.examples){n.examples=ms(t.projectRoot,t.packageRoot);for(let o of n.examples.files)n.scaffolded.push(o)}if(t.scaffold!==!1){let o=P.join(t.projectRoot,"saffron.config.json");$.existsSync(o)||($.writeFileSync(o,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
285
- `),n.scaffolded.push("saffron.config.json"));let s=P.join(t.projectRoot,"features");$.existsSync(s)||($.mkdirSync(s,{recursive:!0}),n.scaffolded.push("features/"));let i=P.join(t.projectRoot,".gitignore"),a=t.examples?[".saffron/reports/",".env"]:[".saffron/reports/"],d=$.existsSync(i)?$.readFileSync(i,"utf8"):"",p=[];for(let m of a)d.split(`
286
- `).some(c=>c.trim()===m)||(d+=(d&&!d.endsWith(`
285
+ `;return R.writeFileSync(t,n+o+e),"updated"}var yt={command:"npx",args:["saffron","mcp"]},Es={claude:{file:".mcp.json",key:"mcpServers",entry:yt},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:yt},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...yt}}},Yn={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function As(t,e){let n=F.join(t,e.file),r={},s=R.existsSync(n);if(s)try{r=JSON.parse(R.readFileSync(n,"utf8"))}catch{throw new Error(`${e.file} is not valid JSON: fix it or remove it, then re-run`)}let o=r[e.key]??{};return JSON.stringify(o.saffron)===JSON.stringify(e.entry)?"unchanged":(o.saffron=e.entry,r[e.key]=o,R.mkdirSync(F.dirname(n),{recursive:!0}),R.writeFileSync(n,JSON.stringify(r,null,2)+`
286
+ `),s?"updated":"created")}function Kn(t,e){R.mkdirSync(e,{recursive:!0});for(let n of R.readdirSync(t,{withFileTypes:!0})){let r=F.join(t,n.name),s=F.join(e,n.name);n.isDirectory()?Kn(r,s):R.copyFileSync(r,s)}}function Qn(t){let e=F.join(t.packageRoot,"skills","saffron");if(!R.existsSync(F.join(e,"SKILL.md")))throw new Error(`bundled skill not found at ${e}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=t.targets??Rs;for(let s of r){let o=F.join(t.projectRoot,Re[s],"saffron");R.existsSync(o)&&R.rmSync(o,{recursive:!0,force:!0}),Kn(e,o),n.skillDirs.push(F.relative(t.projectRoot,o))}if(t.mcp!==!1)for(let s of r){let o=Es[s];o?n.mcpFiles.push({file:o.file,outcome:As(t.projectRoot,o)}):Yn[s]&&n.mcpHints.push(Yn[s])}if(t.instructions!==!1){let s=Ts(),o=F.join(t.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:Gn(o,s)});let i=F.join(t.projectRoot,"CLAUDE.md");R.existsSync(i)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:Gn(i,s)})}if(t.examples){n.examples=Ps(t.projectRoot,t.packageRoot);for(let s of n.examples.files)n.scaffolded.push(s)}if(t.scaffold!==!1){let s=F.join(t.projectRoot,"saffron.config.json");R.existsSync(s)||(R.writeFileSync(s,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
287
+ `),n.scaffolded.push("saffron.config.json"));let o=F.join(t.projectRoot,"features");R.existsSync(o)||(R.mkdirSync(o,{recursive:!0}),n.scaffolded.push("features/"));let i=F.join(t.projectRoot,".gitignore"),a=t.examples?[".saffron/reports/",".env"]:[".saffron/reports/"],d=R.existsSync(i)?R.readFileSync(i,"utf8"):"",p=[];for(let g of a)d.split(`
288
+ `).some(l=>l.trim()===g)||(d+=(d&&!d.endsWith(`
287
289
  `)?`
288
- `:"")+m+`
289
- `,p.push(m));p.length>0&&($.writeFileSync(i,d),n.scaffolded.push(`.gitignore (+ ${p.join(", ")})`))}return n}function ms(t,e){let n=P.join(e,"templates","saucedemo");if(!$.existsSync(P.join(n,"features")))return{installed:!1,files:[],reason:`bundled examples not found at ${n}`};let r=P.join(t,"features");if($.existsSync(r)&&$.readdirSync(r).length>0)return{installed:!1,files:[],reason:"features/ is not empty; examples are only installed into an empty features directory"};let o=[];$.mkdirSync(r,{recursive:!0});for(let a of $.readdirSync(P.join(n,"features")))$.copyFileSync(P.join(n,"features",a),P.join(r,a)),o.push(`features/${a}`);let s=P.join(t,"saffron.config.json");$.existsSync(s)||($.copyFileSync(P.join(n,"saffron.config.json"),s),o.push("saffron.config.json (baseURL https://www.saucedemo.com, retries 1)"));let i=P.join(t,".env.example");return $.existsSync(i)||($.copyFileSync(P.join(n,".env.example"),i),o.push(".env.example")),{installed:!0,files:o}}import hs from"node:path";function Ln(t){return["saffron lsp is a language server: it speaks LSP over stdio and is meant","to be launched by your editor, not run by hand (nothing happens here).","","JetBrains (IntelliJ, WebStorm, PyCharm, Rider, incl. Community):"," 1. Completion, diagnostics, hover, go-to-definition: install the free"," LSP4IJ plugin, then Settings \u2192 Languages & Frameworks \u2192 Language"," Servers \u2192 +: command `npx saffron lsp`, working dir = project root,"," file patterns *.saffron and *.feature."," 2. Syntax highlighting comes from a TextMate bundle, not the LSP:"," Settings \u2192 Editor \u2192 TextMate Bundles \u2192 + and pick this folder:",` ${t?hs.join(t,"textmate","saffron"):"node_modules/saffron-ai/textmate/saffron"}`,"","Neovim / other LSP editors: cmd = { 'npx', 'saffron', 'lsp' },","filetypes = { 'saffron', 'feature' }.","","VS Code: install the extension instead:"," code --install-extension ChathurangaJayasinghe.saffron-vscode"].join(`
290
- `)}function ie(t){Dt(t);let e=T.join(t,"saffron.config.json");return _.existsSync(e)?JSON.parse(_.readFileSync(e,"utf8")):{}}function bs(t,e){let n=e.length>0?e:["features"],r=[];for(let o of n){let s=T.isAbsolute(o)?o:T.join(t,o);if(_.existsSync(s))if(_.statSync(s).isDirectory())for(let i of _.readdirSync(s,{recursive:!0})){let a=String(i);ae(a)&&r.push(T.join(s,a))}else ae(s)&&r.push(s)}return r.sort()}function Bn(t,e,n){let r=T.isAbsolute(e.features??"features")?e.features:T.join(t,e.features??"features"),o=new Set(ye(r));for(let s of n)s.endsWith(".saffron")&&o.add(s);return X([...o].sort(),t)}function vs(){let t=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,e=process.env.HOME??"",n=e&&(_.existsSync(T.join(e,".claude",".credentials.json"))||_.existsSync(T.join(e,".claude.json")));t||n||console.error(["note: no Claude credentials detected. Cached scenarios replay fine","without AI, but recording and healing need one of:"," \xB7 ANTHROPIC_API_KEY=sk-ant-... (console.anthropic.com \u2192 API keys)"," \xB7 a Claude Code login on this machine","Replay-only runs: add --no-agent to silence this note.",""].join(`
291
- `))}async function ws(t){let e=Ss.createInterface({input:process.stdin,output:process.stdout});try{let n=await new Promise(r=>e.question(t,r));return/^y(es)?$/i.test(n.trim())}finally{e.close()}}function _e(t){console.error(`saffron: ${t.message}`),process.exit(2)}function pt(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}var ks={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},$s="\x1B[0m",I=new xs().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(Fe()).option("-p, --project-root <dir>","project root",process.cwd());I.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]","feature files or directories (default: features/)").option("--base-url <url>","base URL of the app under test").option("--headed","run the browser headed").option("--filter <tags>",'only run scenarios carrying any of these @tags: comma-separated ("@smoke,@WA-TC1") or repeat the flag',En).option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--rerecord","discard the committed caches of the selected scenarios and let the agent record them fresh (the honest fix for a wrong recording)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'replay browser: "chromium" (default), "firefox", or "webkit", recording/healing always use Chromium').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(t,e)=>{let n=T.resolve(I.opts().projectRoot),r=ie(n),o=e.baseUrl??r.baseURL,s=bs(n,t.length>0?t:r.features?[r.features]:[]);s.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),e.agent!==!1&&vs();let i;try{let x=Bn(n,r,s);i=s.flatMap(R=>Z(R,n,x))}catch(x){throw x instanceof M&&_e(x),x}let a=Tn(e.filter);if(a.length>0&&(i=An(i,a),i.length===0&&(console.error(`No scenarios match --filter ${a.join(",")} in ${s.length} feature file(s).`),process.exit(2))),e.rerecord){e.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));let x=0;for(let R of i){let E=q(n,R.featurePath,R.name);_.existsSync(E)&&(_.rmSync(E),x++)}console.log(`rerecord: removed ${x} cache file(s) for ${i.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}{let x=new Map;for(let R of i){let E=He(R),j=L(q(n,R.featurePath,R.name));j&&E.push(JSON.stringify(j.steps));for(let O of qe(E))process.env[O]===void 0&&!x.has(O)&&x.set(O,R.displayName)}if(x.size>0){for(let[R,E]of x)console.error(`missing environment variable ${R} (referenced as {env:${R}} by "${E}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let d=new $e({projectRoot:n,baseURL:o,headed:e.headed,actionTimeoutMs:r.actionTimeoutMs,retries:r.retries,storageState:e.storageState??r.storageState,browser:(()=>{let x=e.browser??r.browser??"chromium";return x!=="chromium"&&x!=="firefox"&&x!=="webkit"&&(console.error(`invalid --browser "${x}": use "chromium", "firefox", or "webkit"`),process.exit(2)),x})(),healModel:e.healModel??r.healModel,verifyProposals:e.verify===!1?!1:r.verifyProposals,reuseSteps:e.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let x=e.assertionPolicy??r.assertionPolicy??"strict";return x!=="strict"&&x!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${x}": use "strict" or "adaptable-mid"`),process.exit(2)),x})(),agent:e.agent?new Re({model:e.model??r.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),p=new Date,m=Math.max(1,Number.parseInt(String(e.workers??r.workers??1),10)||1),c=new Array(i.length),l=x=>{let R=x.scenario,E=ks[x.status],j=x.usage.inputTokens+x.usage.outputTokens,O=x.usage.cacheReadTokens+x.usage.cacheCreationTokens,Vn=O>0?`, ${pt(O)} cache traffic`:"";console.log(`${E}${x.status.toUpperCase().padEnd(7)}${$s}${R.displayName} (${x.source}, ${x.durationMs}ms, ${x.usage.aiCalls} AI calls, ${j} tokens${Vn})`);for(let De of x.adaptations)console.log(` \u26A0 ${De}`);x.seededSteps&&console.log(` \u26A1 ${x.seededSteps}/${x.stepResults.length} steps seeded from existing recordings (zero AI)`);let Oe=x.stepResults.reduce((De,qn)=>De+(qn.retries??0),0);Oe&&console.log(` \u21BB ${Oe} action ${Oe===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),x.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):x.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${x.proofError?.split(`
292
- `)[0]}`),x.error&&console.log(` ${x.error}`)};await d.start();try{if(m>1&&i.length>1){let x=i.map((E,j)=>({s:E,i:j})),R=[];await Promise.all(Array.from({length:Math.min(m,x.length)},async()=>{for(;;){let E=x.shift();if(!E)return;let j=await d.runScenario(E.s,{agentDisabled:!0});j.status==="red"?R.push(E):(c[E.i]=j,l(j))}}));for(let E of R.sort((j,O)=>j.i-O.i)){let j=await d.runScenario(E.s);c[E.i]=j,l(j)}}else for(let x=0;x<i.length;x++){let R=await d.runScenario(i[x]);c[x]=R,l(R)}}finally{await d.stop()}let u=$n(c,p,o),h=G(n).divergent.length,f=Fn(c,p,h,o),g=jn(n);u.trends=_n(g,f),In(n,f),u.totals.aiCalls>0&&(u.totals.plan=await d.agent?.planUsage?.());let{html:v,json:S}=Rn(n,u),y=u.totals,A=y.cacheReadTokens+y.cacheCreationTokens;console.log(`
293
- ${y.green} passed, ${y.yellow} pending review, ${y.red} failed \xB7 ${y.aiCalls} AI calls \xB7 ${(y.inputTokens+y.outputTokens).toLocaleString()} tokens in+out${A>0?` \xB7 ${pt(y.cacheReadTokens)} cache read \xB7 ${pt(y.cacheCreationTokens)} cache written`:""}${fn(y)}${y.models?.length?` \xB7 ${y.models.join(", ")}`:""}`);let w=u.trends;if(w?.prev){let x=w.prev.passRatePp,R=w.prev.costUsd;console.log(`vs previous run: pass rate ${x>=0?"+":""}${x}pp \xB7 cost ${R>=0?"+":"-"}$${Math.abs(R).toFixed(4)} \xB7 AI calls ${w.prev.aiCalls>=0?"+":""}${w.prev.aiCalls}`)}for(let x of w?.chronic??[])console.log(`\u26A0 chronic: ${x.feature} \u203A ${x.scenario}: ${x.heals} heal(s), ${x.reds} red(s) in last ${x.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${v}
294
- ${S}`);let b=ue(T.resolve(I.opts().projectRoot));b.length>0&&console.log(`${b.length} cache proposal(s) pending. Review with: saffron accept | saffron reject`);let F=e.strict??r.strict??!1;F&&y.yellow>0&&console.log(`strict mode: ${y.yellow} scenario(s) pending review treated as failure. Review proposals to go green.`),process.exit(y.red>0||F&&y.yellow>0?1:0)});I.command("accept").description("Promote pending cache proposals to committed caches").argument("[file]","proposal file (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").option("--include-unverified","with --all: also accept proposals whose proof replay failed").action((t,e)=>{let n=T.resolve(I.opts().projectRoot),r=ie(n),o=t?[T.resolve(t)]:e.all?ue(n).map(s=>s.file):[];if(o.length===0){zn(n);return}for(let s of o){let i=re(s);if(i.meta.verified===!1){if(e.all&&!e.includeUnverified){console.log(`skipped UNVERIFIED ${T.basename(s)} (${i.meta.proofError?.split(`
290
+ `:"")+g+`
291
+ `,p.push(g));p.length>0&&(R.writeFileSync(i,d),n.scaffolded.push(`.gitignore (+ ${p.join(", ")})`))}return n}function Ps(t,e){let n=F.join(e,"templates","saucedemo");if(!R.existsSync(F.join(n,"features")))return{installed:!1,files:[],reason:`bundled examples not found at ${n}`};let r=F.join(t,"features");if(R.existsSync(r)&&R.readdirSync(r).length>0)return{installed:!1,files:[],reason:"features/ is not empty; examples are only installed into an empty features directory"};let s=[];R.mkdirSync(r,{recursive:!0});for(let a of R.readdirSync(F.join(n,"features")))R.copyFileSync(F.join(n,"features",a),F.join(r,a)),s.push(`features/${a}`);let o=F.join(t,"saffron.config.json");R.existsSync(o)||(R.copyFileSync(F.join(n,"saffron.config.json"),o),s.push("saffron.config.json (baseURL https://www.saucedemo.com, retries 1)"));let i=F.join(t,".env.example");return R.existsSync(i)||(R.copyFileSync(F.join(n,".env.example"),i),s.push(".env.example")),{installed:!0,files:s}}import Cs from"node:path";function Xn(t){return["saffron lsp is a language server: it speaks LSP over stdio and is meant","to be launched by your editor, not run by hand (nothing happens here).","","JetBrains (IntelliJ, WebStorm, PyCharm, Rider, incl. Community):"," 1. Completion, diagnostics, hover, go-to-definition: install the free"," LSP4IJ plugin, then Settings \u2192 Languages & Frameworks \u2192 Language"," Servers \u2192 +: command `npx saffron lsp`, working dir = project root,"," file patterns *.saffron and *.feature."," 2. Syntax highlighting comes from a TextMate bundle, not the LSP:"," Settings \u2192 Editor \u2192 TextMate Bundles \u2192 + and pick this folder:",` ${t?Cs.join(t,"textmate","saffron"):"node_modules/saffron-ai/textmate/saffron"}`,"","Neovim / other LSP editors: cmd = { 'npx', 'saffron', 'lsp' },","filetypes = { 'saffron', 'feature' }.","","VS Code: install the extension instead:"," code --install-extension ChathurangaJayasinghe.saffron-vscode"].join(`
292
+ `)}function _s(t,e){let n=e.length>0?e:["features"],r=[];for(let s of n){let o=E.isAbsolute(s)?s:E.join(t,s);if(O.existsSync(o))if(O.statSync(o).isDirectory())for(let i of O.readdirSync(o,{recursive:!0})){let a=String(i);te(a)&&r.push(E.join(o,a))}else te(o)&&r.push(o)}return r.sort()}function Zn(t,e,n){let r=E.isAbsolute(e.features??"features")?e.features:E.join(t,e.features??"features"),s=new Set(le(r));for(let o of n)o.endsWith(".saffron")&&s.add(o);return J([...s].sort(),t)}function Ds(){let t=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,e=process.env.HOME??"",n=e&&(O.existsSync(E.join(e,".claude",".credentials.json"))||O.existsSync(E.join(e,".claude.json")));t||n||console.error(["note: no Claude credentials detected. Cached scenarios replay fine","without AI, but recording and healing need one of:"," \xB7 ANTHROPIC_API_KEY=sk-ant-... (console.anthropic.com \u2192 API keys)"," \xB7 a Claude Code login on this machine","Replay-only runs: add --no-agent to silence this note.",""].join(`
293
+ `))}async function Os(t){let e=js.createInterface({input:process.stdin,output:process.stdout});try{let n=await new Promise(r=>e.question(t,r));return/^y(es)?$/i.test(n.trim())}finally{e.close()}}function ze(t){console.error(`saffron: ${t.message}`),process.exit(2)}function xt(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}var Ms={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},Ns="\x1B[0m",_=new Is().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(he()).option("-p, --project-root <dir>","project root",process.cwd());_.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]","feature files or directories (default: features/)").option("--base-url <url>","base URL of the app under test").option("--headed","run the browser headed").option("--filter <tags>",'only run scenarios carrying any of these @tags: comma-separated ("@smoke,@WA-TC1") or repeat the flag',On).option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--rerecord","discard the committed caches of the selected scenarios and let the agent record them fresh (the honest fix for a wrong recording)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'replay browser: "chromium" (default), "firefox", or "webkit", recording/healing always use Chromium').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(t,e)=>{let n=E.resolve(_.opts().projectRoot),r=Q(n),s=e.baseUrl??r.baseURL,o=_s(n,t.length>0?t:r.features?[r.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),e.agent!==!1&&Ds();let i;try{let v=Zn(n,r,o);i=o.flatMap(k=>K(k,n,v))}catch(v){throw v instanceof N&&ze(v),v}let a=_n(e.filter);if(a.length>0&&(i=Dn(i,a),i.length===0&&(console.error(`No scenarios match --filter ${a.join(",")} in ${o.length} feature file(s).`),process.exit(2))),e.rerecord){e.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));let v=0;for(let k of i){let P=V(n,k.featurePath,k.name);O.existsSync(P)&&(O.rmSync(P),v++)}console.log(`rerecord: removed ${v} cache file(s) for ${i.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}{let v=new Map;for(let k of i){let P=Xe(k),j=B(V(n,k.featurePath,k.name));j&&P.push(JSON.stringify(j.steps));for(let D of Qe(P))process.env[D]===void 0&&!v.has(D)&&v.set(D,k.displayName)}if(v.size>0){for(let[k,P]of v)console.error(`missing environment variable ${k} (referenced as {env:${k}} by "${P}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let d=new Ie({projectRoot:n,baseURL:s,headed:e.headed,actionTimeoutMs:r.actionTimeoutMs,retries:r.retries,storageState:e.storageState??r.storageState,browser:(()=>{let v=e.browser??r.browser??"chromium";return v!=="chromium"&&v!=="firefox"&&v!=="webkit"&&(console.error(`invalid --browser "${v}": use "chromium", "firefox", or "webkit"`),process.exit(2)),v})(),healModel:e.healModel??r.healModel,verifyProposals:e.verify===!1?!1:r.verifyProposals,reuseSteps:e.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let v=e.assertionPolicy??r.assertionPolicy??"strict";return v!=="strict"&&v!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${v}": use "strict" or "adaptable-mid"`),process.exit(2)),v})(),agent:e.agent?new _e({model:e.model??r.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),p=new Date,g=Math.max(1,Number.parseInt(String(e.workers??r.workers??1),10)||1),l=new Array(i.length),c=v=>{let k=v.scenario,P=Ms[v.status],j=v.usage.inputTokens+v.usage.outputTokens,D=v.usage.cacheReadTokens+v.usage.cacheCreationTokens,Te=D>0?`, ${xt(D)} cache traffic`:"";console.log(`${P}${v.status.toUpperCase().padEnd(7)}${Ns}${k.displayName} (${v.source}, ${v.durationMs}ms, ${v.usage.aiCalls} AI calls, ${j} tokens${Te})`);for(let A of v.adaptations)console.log(` \u26A0 ${A}`);v.seededSteps&&console.log(` \u26A1 ${v.seededSteps}/${v.stepResults.length} steps seeded from existing recordings (zero AI)`);let X=v.stepResults.reduce((A,ie)=>A+(ie.retries??0),0);X&&console.log(` \u21BB ${X} action ${X===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),v.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):v.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${v.proofError?.split(`
294
+ `)[0]}`),v.error&&console.log(` ${v.error}`)};await d.start();try{if(g>1&&i.length>1){let v=i.map((P,j)=>({s:P,i:j})),k=[];await Promise.all(Array.from({length:Math.min(g,v.length)},async()=>{for(;;){let P=v.shift();if(!P)return;let j=await d.runScenario(P.s,{agentDisabled:!0});j.status==="red"?k.push(P):(l[P.i]=j,c(j))}}));for(let P of k.sort((j,D)=>j.i-D.i)){let j=await d.runScenario(P.s);l[P.i]=j,c(j)}}else for(let v=0;v<i.length;v++){let k=await d.runScenario(i[v]);l[v]=k,c(k)}}finally{await d.stop()}let u=jn(l,p,s),h=ne(n).divergent.length,f=Vn(l,p,h,s),m=Be(n);u.trends=zn(m,f),Bn(n,f),u.totals.aiCalls>0&&(u.totals.plan=await d.agent?.planUsage?.());let{html:w,json:x}=In(n,u),b=u.totals,C=b.cacheReadTokens+b.cacheCreationTokens;console.log(`
295
+ ${b.green} passed, ${b.yellow} pending review, ${b.red} failed \xB7 ${b.aiCalls} AI calls \xB7 ${(b.inputTokens+b.outputTokens).toLocaleString()} tokens in+out${C>0?` \xB7 ${xt(b.cacheReadTokens)} cache read \xB7 ${xt(b.cacheCreationTokens)} cache written`:""}${vn(b)}${b.models?.length?` \xB7 ${b.models.join(", ")}`:""}`);let y=u.trends;if(y?.prev){let v=y.prev.passRatePp,k=y.prev.costUsd;console.log(`vs previous run: pass rate ${v>=0?"+":""}${v}pp \xB7 cost ${k>=0?"+":"-"}$${Math.abs(k).toFixed(4)} \xB7 AI calls ${y.prev.aiCalls>=0?"+":""}${y.prev.aiCalls}`)}for(let v of y?.chronic??[])console.log(`\u26A0 chronic: ${v.feature} \u203A ${v.scenario}: ${v.heals} heal(s), ${v.reds} red(s) in last ${v.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${w}
296
+ ${x}`);let S=re(E.resolve(_.opts().projectRoot));S.length>0&&console.log(`${S.length} cache proposal(s) pending. Review with: saffron accept | saffron reject`);let T=e.strict??r.strict??!1;T&&b.yellow>0&&console.log(`strict mode: ${b.yellow} scenario(s) pending review treated as failure. Review proposals to go green.`),process.exit(b.red>0||T&&b.yellow>0?1:0)});_.command("accept").description("Promote pending cache proposals to committed caches").argument("[files...]","proposal file(s) (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").option("--include-unverified","with --all: also accept proposals whose proof replay failed").action((t,e)=>{let n=E.resolve(_.opts().projectRoot),r=Q(n),s=t.length>0?t.map(o=>E.resolve(o)):e.all?re(n).map(o=>o.file):[];if(s.length===0){er(n);return}for(let o of s){let i=fe(o);if(i.meta.verified===!1){if(e.all&&!e.includeUnverified){console.log(`skipped UNVERIFIED ${E.basename(o)} (${i.meta.proofError?.split(`
295
297
  `)[0]}). Re-record it (saffron run --rerecord), or accept it explicitly by file, or pass --include-unverified.`);continue}console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${i.meta.proofError?.split(`
296
- `)[0]}): expect a heal or red on the next run.`)}let a=L(q(n,i.cache.feature,i.cache.scenario)),d=Nt(n,s);console.log(`accepted \u2192 ${d}`);let p=a?yn(a,i.cache):[];if(p.length>0){let l=Sn(n,p,d,!e.propagate);for(let u of p)console.log(` locator fix: ${nt(u.from)} \u2192 ${nt(u.to)}`);if(l.length===0)console.log(" no other caches use this locator.");else if(e.propagate)for(let u of l)console.log(` propagated \u2192 ${u.feature} \u203A ${u.scenario} (${u.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${l.length} other cache(s) use the same locator: re-run with --propagate to fix them too:`);for(let u of l)console.log(` \xB7 ${u.feature} \u203A ${u.scenario}`)}}let m=i.meta.featureEdits??[];if(!e.withFeatureEdit||m.length===0)continue;let c;try{c=bn(n,i.cache.feature,i.cache.scenario,m,Bn(n,r,[]))}catch(l){throw l instanceof M&&_e(l),l}for(let l of c.applied){let u=l.fromStepSet?`${l.file}:${l.line} (StepSet "${l.fromStepSet}", fixes every invoking scenario)`:`${l.file}:${l.line}`;console.log(` feature edit ${u}:`),console.log(` - ${l.from}`),console.log(` + ${l.to}`)}for(let l of c.skipped)console.log(` skipped edit [${l.index}]: ${l.reason}`);if(c.applied.length>0){let l=L(d);for(let u of m)l.steps[u.index]&&(l.steps[u.index].gherkin=u.text);ee(d,l)}}});I.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((t,e)=>{let n=T.resolve(I.opts().projectRoot),r=ie(n),o;try{o=Y(n,r.features??"features")}catch(i){throw i instanceof M&&_e(i),i}if(t&&(o=be(o,t)),e.json){console.log(JSON.stringify(o,null,2));return}if(e.snippets){let i=T.join(n,".vscode","saffron.code-snippets");_.mkdirSync(T.dirname(i),{recursive:!0}),_.writeFileSync(i,$t(o)),console.log(`wrote ${o.steps.length} step + ${o.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let s={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(o.stepSets.length>0){console.log("step sets:");for(let i of o.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)), ${i.file}:${i.line}`);console.log("")}for(let i of o.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${s[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
297
- ${o.steps.length} step(s) across ${o.scannedFiles} file(s) \xB7 ${o.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});I.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(t,e)=>{let n=T.resolve(I.opts().projectRoot),r=ie(n),o=_.readFileSync(T.resolve(t),"utf8"),s;try{s=Y(n,r.features??"features")}catch(p){throw p instanceof M&&_e(p),p}console.log(`authoring with ${s.steps.length} known step(s) and ${s.stepSets.length} step set(s) as vocabulary\u2026`);let i=await Tt(o,s,e.model??r.model),a=e.out?T.resolve(e.out):T.join(n,r.features??"features",`${T.basename(t).replace(/\.[^.]+$/,"")}.saffron`);_.existsSync(a)&&(console.error(`refusing to overwrite ${a}: pass --out for a new path`),process.exit(1)),_.mkdirSync(T.dirname(a),{recursive:!0}),_.writeFileSync(a,i.content);let d=i.content.split(`
298
- `).map(p=>p.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(p=>s.steps.some(m=>m.text===p)).length;console.log(`wrote ${a}`),console.log(`${d} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+T.relative(process.cwd(),a))});I.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let t=T.resolve(I.opts().projectRoot),e=ie(t);await Rt(t,e.features??"features")});I.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics). For JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{process.stdin.isTTY&&(console.error(Ln(Ce())),process.exit(2));let t=T.resolve(I.opts().projectRoot),e=ie(t);jt(t,e.features??"features")});I.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[file]","proposal file").option("--all","reject all pending proposals").action((t,e)=>{let n=T.resolve(I.opts().projectRoot),r=t?[T.resolve(t)]:e.all?ue(n).map(o=>o.file):[];if(r.length===0){zn(n);return}for(let o of r)Ut(o),console.log(`rejected ${o}`)});I.command("init").description("Make the project agent-ready: install the bundled Saffron skill into agent directories, add instructions to AGENTS.md, scaffold config").option("--agents <list>",`comma-separated targets: ${Object.keys(he).join(", ")} (default: claude,agents)`).option("--no-instructions","do not touch AGENTS.md / CLAUDE.md").option("--no-scaffold","do not create saffron.config.json, features/, .gitignore entry").option("--no-mcp","do not register `saffron mcp` in project MCP configs (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)").option("--examples","install the Saucedemo example suite (features, config, .env.example) for a guided first run").option("--no-examples","never install the example suite (skips the prompt)").action(async t=>{let e=T.resolve(I.opts().projectRoot),n=Ce();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=t.agents?String(t.agents).split(",").map(i=>i.trim()).filter(Boolean):void 0;for(let i of r??[])i in he||(console.error(`saffron: unknown agent target "${i}" (expected one of ${Object.keys(he).join(", ")})`),process.exit(2));let o=t.examples===!0;t.examples===void 0&&process.stdin.isTTY&&process.stdout.isTTY&&(o=await ws("Install the Saucedemo example tests (login, cart, checkout) into features/ for a guided first run? [y/N] "));let s=Un({projectRoot:e,packageRoot:n,targets:r,instructions:t.instructions,scaffold:t.scaffold,mcp:t.mcp,examples:o});for(let i of s.skillDirs)console.log(`skill \u2192 ${i}/`);for(let i of s.mcpFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (saffron mcp server)`);for(let i of s.mcpHints)console.log(`mcp hint \u2192 ${i}`);for(let i of s.instructionFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (managed Saffron block)`);for(let i of s.scaffolded)console.log(`created \u2192 ${i}`);s.examples&&!s.examples.installed&&console.log(`examples \u2192 not installed: ${s.examples.reason}`),console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it."),s.examples?.installed&&console.log(["","Your guided first run (recording needs Claude credentials; replays never do):"," cp .env.example .env # public demo credentials, resolved at replay"," npx playwright install chromium"," npx saffron run --filter @smoke # the agent records two scenarios (about $3 of AI usage), files proposals"," npx saffron accept --all # review, then promote them to caches"," npx saffron run --filter @smoke # replays at zero tokens","Then try the rest: npx saffron run (login errors outline, checkout with a data table)"].join(`
299
- `))});I.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let t=T.resolve(I.opts().projectRoot),e=T.join(t,".saffron","reports","latest.html");_.existsSync(e)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(e);let n=process.platform==="darwin"?"open":"xdg-open";ys(n,[e],{detached:!0,stdio:"ignore"}).unref()});function zn(t){let e=ue(t);if(e.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
300
- `);for(let{file:n,proposal:r}of e){console.log(` ${n}`);let o=r.meta.verified===!0?" \xB7 verified \u2713":r.meta.verified===!1?" \xB7 UNVERIFIED \u2717":"";console.log(` ${r.cache.feature} \u203A ${r.cache.scenario} (${r.meta.mode}, ${r.meta.createdAt}${o})`),console.log(` ${r.meta.narrative}`);for(let s of r.meta.adaptations)console.log(` \u26A0 ${s}`);r.meta.suggestedFeatureEdit&&console.log(` suggested .feature edit:
298
+ `)[0]}): expect a heal or red on the next run.`)}let a=B(V(n,i.cache.feature,i.cache.scenario)),d=Wt(n,o);console.log(`accepted \u2192 ${d}`);let p=a?Rn(a,i.cache):[];if(p.length>0){let c=Tn(n,p,d,!e.propagate);for(let u of p)console.log(` locator fix: ${lt(u.from)} \u2192 ${lt(u.to)}`);if(c.length===0)console.log(" no other caches use this locator.");else if(e.propagate)for(let u of c)console.log(` propagated \u2192 ${u.feature} \u203A ${u.scenario} (${u.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${c.length} other cache(s) use the same locator: re-run with --propagate to fix them too:`);for(let u of c)console.log(` \xB7 ${u.feature} \u203A ${u.scenario}`)}}let g=i.meta.featureEdits??[];if(!e.withFeatureEdit||g.length===0)continue;let l;try{l=An(n,i.cache.feature,i.cache.scenario,g,Zn(n,r,[]))}catch(c){throw c instanceof N&&ze(c),c}for(let c of l.applied){let u=c.fromStepSet?`${c.file}:${c.line} (StepSet "${c.fromStepSet}", fixes every invoking scenario)`:`${c.file}:${c.line}`;console.log(` feature edit ${u}:`),console.log(` - ${c.from}`),console.log(` + ${c.to}`)}for(let c of l.skipped)console.log(` skipped edit [${c.index}]: ${c.reason}`);if(l.applied.length>0){let c=B(d);for(let u of g)c.steps[u.index]&&(c.steps[u.index].gherkin=u.text);de(d,c)}}});_.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((t,e)=>{let n=E.resolve(_.opts().projectRoot),r=Q(n),s;try{s=z(n,r.features??"features")}catch(i){throw i instanceof N&&ze(i),i}if(t&&(s=Pe(s,t)),e.json){console.log(JSON.stringify(s,null,2));return}if(e.snippets){let i=E.join(n,".vscode","saffron.code-snippets");O.mkdirSync(E.dirname(i),{recursive:!0}),O.writeFileSync(i,jt(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)), ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
299
+ ${s.steps.length} step(s) across ${s.scannedFiles} file(s) \xB7 ${s.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});_.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(t,e)=>{let n=E.resolve(_.opts().projectRoot),r=Q(n),s=O.readFileSync(E.resolve(t),"utf8"),o;try{o=z(n,r.features??"features")}catch(p){throw p instanceof N&&ze(p),p}console.log(`authoring with ${o.steps.length} known step(s) and ${o.stepSets.length} step set(s) as vocabulary\u2026`);let i=await _t(s,o,e.model??r.model),a=e.out?E.resolve(e.out):E.join(n,r.features??"features",`${E.basename(t).replace(/\.[^.]+$/,"")}.saffron`);O.existsSync(a)&&(console.error(`refusing to overwrite ${a}: pass --out for a new path`),process.exit(1)),O.mkdirSync(E.dirname(a),{recursive:!0}),O.writeFileSync(a,i.content);let d=i.content.split(`
300
+ `).map(p=>p.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(p=>o.steps.some(g=>g.text===p)).length;console.log(`wrote ${a}`),console.log(`${d} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+E.relative(process.cwd(),a))});_.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let t=E.resolve(_.opts().projectRoot),e=Q(t);await It(t,e.features??"features")});_.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics). For JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{process.stdin.isTTY&&(console.error(Xn(Ue())),process.exit(2));let t=E.resolve(_.opts().projectRoot),e=Q(t);Vt(t,e.features??"features")});_.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[files...]","proposal file(s)").option("--all","reject all pending proposals").action((t,e)=>{let n=E.resolve(_.opts().projectRoot),r=t.length>0?t.map(s=>E.resolve(s)):e.all?re(n).map(s=>s.file):[];if(r.length===0){er(n);return}for(let s of r)Gt(s),console.log(`rejected ${s}`)});_.command("init").description("Make the project agent-ready: install the bundled Saffron skill into agent directories, add instructions to AGENTS.md, scaffold config").option("--agents <list>",`comma-separated targets: ${Object.keys(Re).join(", ")} (default: claude,agents)`).option("--no-instructions","do not touch AGENTS.md / CLAUDE.md").option("--no-scaffold","do not create saffron.config.json, features/, .gitignore entry").option("--no-mcp","do not register `saffron mcp` in project MCP configs (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)").option("--examples","install the Saucedemo example suite (features, config, .env.example) for a guided first run").option("--no-examples","never install the example suite (skips the prompt)").action(async t=>{let e=E.resolve(_.opts().projectRoot),n=Ue();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=t.agents?String(t.agents).split(",").map(i=>i.trim()).filter(Boolean):void 0;for(let i of r??[])i in Re||(console.error(`saffron: unknown agent target "${i}" (expected one of ${Object.keys(Re).join(", ")})`),process.exit(2));let s=t.examples===!0;t.examples===void 0&&process.stdin.isTTY&&process.stdout.isTTY&&(s=await Os("Install the Saucedemo example tests (login, cart, checkout) into features/ for a guided first run? [y/N] "));let o=Qn({projectRoot:e,packageRoot:n,targets:r,instructions:t.instructions,scaffold:t.scaffold,mcp:t.mcp,examples:s});for(let i of o.skillDirs)console.log(`skill \u2192 ${i}/`);for(let i of o.mcpFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (saffron mcp server)`);for(let i of o.mcpHints)console.log(`mcp hint \u2192 ${i}`);for(let i of o.instructionFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (managed Saffron block)`);for(let i of o.scaffolded)console.log(`created \u2192 ${i}`);o.examples&&!o.examples.installed&&console.log(`examples \u2192 not installed: ${o.examples.reason}`),console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it."),o.examples?.installed&&console.log(["","Your guided first run (recording needs Claude credentials; replays never do):"," cp .env.example .env # public demo credentials, resolved at replay"," npx playwright install chromium"," npx saffron run --filter @smoke # the agent records two scenarios (about $3 of AI usage), files proposals"," npx saffron accept --all # review, then promote them to caches"," npx saffron run --filter @smoke # replays at zero tokens","Then try the rest: npx saffron run (login errors outline, checkout with a data table)"].join(`
301
+ `))});_.command("status").description("Project overview for dashboards and IDE panels: feature files and scenarios with cache state, tags, pending proposals, last run, history, vocabulary health, effective config").option("--json","machine-readable output").action(t=>{let e=E.resolve(_.opts().projectRoot),n=Q(e),r=Hn(e,n);if(t.json){console.log(JSON.stringify(r,null,2));return}Wn(r)});_.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let t=E.resolve(_.opts().projectRoot),e=E.join(t,".saffron","reports","latest.html");O.existsSync(e)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(e);let n=process.platform==="darwin"?"open":"xdg-open";Fs(n,[e],{detached:!0,stdio:"ignore"}).unref()});function er(t){let e=re(t);if(e.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
302
+ `);for(let{file:n,proposal:r}of e){console.log(` ${n}`);let s=r.meta.verified===!0?" \xB7 verified \u2713":r.meta.verified===!1?" \xB7 UNVERIFIED \u2717":"";console.log(` ${r.cache.feature} \u203A ${r.cache.scenario} (${r.meta.mode}, ${r.meta.createdAt}${s})`),console.log(` ${r.meta.narrative}`);for(let o of r.meta.adaptations)console.log(` \u26A0 ${o}`);r.meta.suggestedFeatureEdit&&console.log(` suggested .feature edit:
301
303
  ${r.meta.suggestedFeatureEdit.split(`
302
- `).map(s=>` ${s}`).join(`
303
- `)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}I.parseAsync();
304
+ `).map(o=>` ${o}`).join(`
305
+ `)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}_.parseAsync();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saffron-ai",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing, honest reports",
5
5
  "scripts": {
6
6
  "test": "vitest run",
@@ -24,7 +24,7 @@
24
24
  "license": "SEE LICENSE IN LICENSE",
25
25
  "type": "module",
26
26
  "bin": {
27
- "saffron": "./dist-pkg/cli.js"
27
+ "saffron": "dist-pkg/cli.js"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^26.1.0",