saffron-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,47 @@
1
+ Saffron Free Use License
2
+ Version 1.0, July 2026
3
+
4
+ Copyright (c) 2026 Chathuranga Jayasinghe. All rights reserved.
5
+
6
+ 1. Grant. You are granted a free, worldwide, non-exclusive,
7
+ non-transferable license to install and use this software, including
8
+ for commercial purposes.
9
+
10
+ 2. Restrictions. Except to the extent a restriction is unenforceable
11
+ under applicable law, you may not: (a) redistribute, sublicense,
12
+ rent, sell, or otherwise make the software available to third
13
+ parties; (b) modify the software or create derivative works of it;
14
+ (c) reverse engineer, decompile, or disassemble the software except
15
+ where such acts cannot be prohibited by law; or (d) remove or alter
16
+ any proprietary notices.
17
+
18
+ 3. Your content. Feature files, caches, proposals, reports, and any
19
+ other artifacts the software produces from your inputs are yours.
20
+ This license claims no rights over them.
21
+
22
+ 4. Third-party components. The software depends on third-party
23
+ packages that are licensed under their own terms; nothing in this
24
+ license limits your rights under those terms.
25
+
26
+ 5. AI usage costs. The software can invoke AI services using
27
+ credentials you configure. You are responsible for those accounts
28
+ and any charges they incur.
29
+
30
+ 6. Ownership. The software is licensed, not sold. All rights not
31
+ expressly granted are reserved.
32
+
33
+ 7. Future versions. Future versions may be offered under different
34
+ terms, including open-source licenses.
35
+
36
+ 8. No warranty. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
37
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
38
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
39
+ NONINFRINGEMENT.
40
+
41
+ 9. Limitation of liability. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
42
+ ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
43
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
44
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
45
+
46
+ 10. Termination. This license terminates automatically if you breach
47
+ it. On termination you must stop using the software.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # Saffron
2
+
3
+ **Gherkin-native test runner with zero-token cached replay and runtime AI healing.**
4
+
5
+ Write tests as plain `.feature` files. On the first run, an AI agent (Claude Agent SDK + Playwright MCP) executes each scenario in a real browser and records what it did into a **cache** — a human-reviewable JSON action list committed to git. Every run after that replays the cache with plain Playwright: **zero AI calls, zero tokens, ~100ms per scenario**.
6
+
7
+ When the UI changes and a cached step fails at runtime, the agent takes over mid-execution with full scenario context, adapts, and finishes the run — then files a **cache proposal** you accept or reject, like a snapshot test update. Reports state exactly what was adapted and what it cost.
8
+
9
+ ## The rules that keep it honest
10
+
11
+ - **Assertions are sacred.** By default no `Then` is ever healed — the AI may help *reach* an assertion, never make it pass; enforced mechanically, not by prompt. Projects may opt into `adaptable-mid` for mid-scenario checkpoints, but the **final assertion block** of a scenario is strict under every policy, forever.
12
+ - **Three result states.** Green = cached pass. Yellow = passed with AI adaptation (pending your review). Red = failed.
13
+ - **Diagnose before adapting.** On a step failure the agent first decides: UI drift (heal) or application defect (fail with a diagnosis).
14
+ - **Caches are git artifacts.** Proposals show diffs — including a suggested `.feature` edit when the written steps no longer match reality — and nothing is committed without you.
15
+ - **Verified proposals.** Every recording/heal is proof-replayed zero-AI before it's filed (with one bounded refinement pass on failure), so proposals arrive stamped `verified ✓` or honestly `UNVERIFIED ✗`.
16
+ - **Honest cost reporting.** Reports break out prompt-cache reads/writes — the real bill of agent sessions — next to the in+out token count, per scenario and in totals.
17
+ - **Trend memory.** Every run appends to `.saffron/history.jsonl`; reports show deltas vs the previous run and 20-run sparklines, and Saffron flags **chronic scenarios** (healing repeatedly — re-record instead of paying again) plus recurring failure themes.
18
+
19
+ ## Step reuse: new scenarios get cheaper as your suite grows
20
+
21
+ Like a maturing Cucumber suite, most of a new feature file is steps you already have. Saffron derives a **step index** from committed caches and, when recording a new scenario, replays every seeded island of known steps zero-AI — the agent records only the genuinely novel steps, wherever they sit. Exact step text is the identity (Cucumber-style); quoted values may differ (`"admin"` seeds from `"bob"`); data-table steps seed on matching keys. Measured on a live site: a 6-step scenario with one novel step recorded for **$0.15 / 6 AI calls**, vs $2.07 for a full unseeded recording of comparable length.
22
+
23
+ **Step sets** (shipped): `.saffron` files — a superset dialect of Gherkin — add the `StepSet:` keyword for named, reusable step sequences invoked with `StepSet <name>` inside any scenario. Sets expand at parse time, so their steps cache and seed like ordinary steps; editing a set makes every invoking scenario honestly stale (re-recorded mostly seeded), and heal edits route to the set definition — one fix, every caller follows. Sets are **project-wide**: keep application-wide flows in a sets-only library file (convention: `features/shared.steps.saffron`) and invoke them from any feature.
24
+
25
+ **IDE integration & authoring** (shipped): `saffron steps` lists the project vocabulary with recorded/divergent/unrecorded badges (`--snippets` for native VS Code completion), `saffron mcp` serves it to AI assistants, `saffron lsp` brings completion/navigation/diagnostics to **JetBrains** (incl. Community editions via LSP4IJ) and Neovim, `saffron author` drafts feature files from prose in your own vocabulary, and duplicate wordings that record identical actions get **behavior-proven rename proposals**. The **Saffron VS Code extension** lives in the sibling repo `saffron-vscode`.
26
+
27
+ Data tables and doc strings are first-class: 2-column key/value tables parameterize the recording (`<table:username>`), multi-row record tables parameterize per cell (`<table:1:firstName>`), and `"""` doc strings record as `<docstring>` — so editing *values* or *content* replays at zero tokens, while structural changes (keys, headers, row counts) honestly re-record. Unambiguous params are scenario-wide, so a later assertion on a note's text follows content edits too.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ npm install --save-dev saffron-ai
33
+ npx playwright install chromium
34
+ npx saffron run
35
+ ```
36
+
37
+ CI tip: `saffron run --strict` treats yellow (passed-with-adaptation) as a
38
+ failure until its cache proposal is reviewed — cached-green-only builds.
39
+
40
+ ## Quick start (this repo)
41
+
42
+ ```bash
43
+ npm install
44
+ npm run build
45
+ npx playwright install chromium
46
+
47
+ # start the demo app
48
+ node examples/demo-app/server.mjs 4173 &
49
+
50
+ # replay the example suite from its committed caches — zero AI
51
+ node dist/cli/index.js -p examples run
52
+
53
+ # delete a cache and watch the agent re-record it (needs Claude Code auth
54
+ # or ANTHROPIC_API_KEY)
55
+ rm examples/.saffron/cache/login/successful-login.json
56
+ node dist/cli/index.js -p examples run
57
+ node dist/cli/index.js -p examples accept --all
58
+ ```
59
+
60
+ ## CLI
61
+
62
+ | Command | What it does |
63
+ |---|---|
64
+ | `saffron run [paths] [--headed] [--filter @tag] [--no-agent] [--strict] [--browser b] [--workers n] [--heal-model m] [--no-verify] [--no-reuse] [--model m] [--storage-state f]` | Run features. Cached replays are deterministic; misses/failures escalate to the agent (unless `--no-agent`). Replay cross-browser with `--browser firefox\|webkit`, parallelize with `--workers N`, heal on a cheaper model with `--heal-model`. Exit 1 on red (and on yellow with `--strict`). |
65
+ | `saffron accept [file \| --all] [--with-feature-edit] [--propagate]` | Promote cache proposals to committed caches; `--with-feature-edit` also rewrites the adapted steps in the `.feature` file (and keeps the cache in sync); `--propagate` applies the heal's locator fixes to every other cache using the same locator — one heal repairs N scenarios before they ever fail. No args: list pending proposals. |
66
+ | `saffron reject [file \| --all]` | Discard proposals; the agent will try again next run. |
67
+ | `saffron steps [search] [--json] [--snippets]` | List/search the step vocabulary (files + caches) with recorded/divergent/unrecorded badges and usage; `--snippets` writes `.vscode/saffron.code-snippets` for native VS Code completion. |
68
+ | `saffron mcp` | Serve the vocabulary to AI assistants over stdio MCP (`search_steps`, `list_step_sets`) — e.g. `claude mcp add saffron -- npx saffron mcp`. |
69
+ | `saffron author <prose-file> [-o out]` | Draft a `.saffron` feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI; reports how many lines are seedable). |
70
+ | `saffron lsp` | Run the Saffron language server over stdio — JetBrains (LSP4IJ/Ultimate), Neovim, any LSP editor: badge completion, StepSet go-to-definition, hover, diagnostics. |
71
+ | `saffron report` | Open the latest HTML report. |
72
+
73
+ Configuration lives in `saffron.config.json` at your project root:
74
+
75
+ ```json
76
+ {
77
+ "baseURL": "http://localhost:4173",
78
+ "features": "features",
79
+ "actionTimeoutMs": 5000,
80
+ "retries": 1,
81
+ "model": "claude-sonnet-5",
82
+ "maxTurns": 100,
83
+ "storageState": ".auth/state.json",
84
+ "strict": false,
85
+ "assertionPolicy": "strict",
86
+ "verifyProposals": true,
87
+ "reuseSteps": true,
88
+ "snapshotMode": "none",
89
+ "browser": "chromium",
90
+ "workers": 1,
91
+ "healModel": "claude-haiku-4-5"
92
+ }
93
+ ```
94
+
95
+ `storageState` (also `--storage-state`) points to a Playwright storage-state
96
+ JSON (cookies + localStorage) applied to every scenario's browser context, so
97
+ cached replays *and* the agent start authenticated. Generate one with
98
+ `npx playwright open --save-storage=.auth/state.json <url>`.
99
+
100
+ ## How it works
101
+
102
+ The full execution-model diagram is on the site: https://saffron-ai.lovable.app/docs
103
+
104
+ - Caches store per-step action lists with semantic targets (`role` + accessible name, `nameRegex` for volatile labels, CSS fallbacks) and `<param>` placeholders, so one cache serves every Examples row of a Scenario Outline.
105
+ - Dynamic-content vocabulary for real-world apps: `captureText` stores displayed values (prices, counters) under a name; `expectDiffers` compares against captured values; `expectMatches`/`expectAttribute` assert patterns instead of literals; `{date±N}` templates keep date-picker locators valid across days; the replayer follows links that open new tabs.
106
+ - The agent and the replayer share one browser over CDP, so healing continues from the exact page state where replay failed.
107
+ - Every recorded action carries a page fingerprint (hash of the normalized aria snapshot) for token-free drift detection.
108
+
109
+ ## Project layout
110
+
111
+ ```
112
+ .saffron/
113
+ cache/ committed replay caches (commit these)
114
+ proposals/ pending AI-generated cache updates (review these)
115
+ history.jsonl one line per run — trends (commit recommended)
116
+ reports/ latest.html / latest.json (gitignore these)
117
+ features/ your .feature files
118
+ ```
119
+
120
+ ## Roadmap
121
+
122
+ See the roadmap on **[the site](https://saffron-ai.lovable.app/#roadmap)** — the single source of truth for
123
+ milestones, statuses, and decision gates. Headlines:
124
+ action-vocabulary additions as real-world failures surface them, the
125
+ Level-2 shared step library (gated on Level-1 divergence data), LLM-less
126
+ MCP replay, multi-provider agents, parallel workers, public npm/GitHub
127
+ release, and the long-term open-core SaaS layer. Everything shipped so
128
+ far is in the roadmap's "Where we are" table.
129
+
130
+ ## Documentation
131
+
132
+ - [Website](https://saffron-ai.lovable.app) — what Saffron is, the honesty rules, economics, quickstart
133
+ - [Documentation](https://saffron-ai.lovable.app/docs) — the cache lifecycle, step sets & the `.saffron` dialect, the step vocabulary, and the guarantees — with diagrams
134
+ - [Issues & questions](https://github.com/s-chathuranga-j/saffron-ai/issues)
135
+ - **Docs site**: `npm run docs` builds a self-contained page at
136
+ ## License
137
+
138
+ Free to use — including commercially — under the Saffron Free Use License (see LICENSE). The source opens up later; a CLA will accompany that step.
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ import I from"node:fs";import w from"node:path";import{spawn as lr}from"node:child_process";import{Command as dr}from"commander";import nn from"node:fs";import rn from"node:path";import{AstBuilder as sn,GherkinClassicTokenMatcher as on,Parser as an}from"@cucumber/gherkin";import{IdGenerator as cn,StepKeywordType as Re}from"@cucumber/messages";function L(t,e){return t.replace(/<([^<>]+)>/g,(n,r)=>r in e?e[r]:n)}function _(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,c)=>{s[c]!==void 0&&(e[`table:${o+1}:${i}`]=s[c])})})}return e}function N(t){return t!==void 0?{docstring:t}:{}}function Y(t){let e=new Map;for(let r of t){let s={..._(r.resolvedTable??r.table),...N(r.resolvedDocString??r.docString)};for(let[o,i]of Object.entries(s)){let c=e.get(o);c||e.set(o,c=new Set),c.add(i)}}let n={};for(let[r,s]of e)s.size===1&&(n[r]=[...s][0]);return n}import ke from"node:fs";import Ve from"node:path";import{AstBuilder as Gt,GherkinClassicTokenMatcher as Qt,Parser as Kt}from"@cucumber/gherkin";import{IdGenerator as Jt}from"@cucumber/messages";var j=class extends Error{},Xt=/^(\s*)StepSet:\s*(.+?)\s*$/,Zt=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function $e(t){let e=t.split(`
3
+ `),n=new Map,r=new Map;return{source:e.map((o,i)=>{let c=o.match(Xt);if(c)return n.set(i+1,c[2]),`${c[1]}Scenario: ${c[2]}`;let p=o.match(Zt);return p?(r.set(i+1,p[2]),`${p[1]}* StepSet ${p[2]}`):o}).join(`
4
+ `),definitions:n,invocations:r}}function en(t){return new Kt(new Gt(Jt.uuid()),new Qt).parse(t)}function He(t,e){for(let n of t)n.scenario?e(n.scenario):"rule"in n&&n.rule&&He(n.rule.children,e)}function G(t,e){let n=new Map;for(let r of t){let s=Ve.relative(e,r),o=$e(ke.readFileSync(r,"utf8"));if(o.definitions.size===0)continue;let i=en(o.source);i.feature&&He(i.feature.children,c=>{let p=o.definitions.get(c.location.line);if(p===void 0)return;let d=n.get(p);if(d)throw new j(`StepSet "${p}" is defined twice: ${d.file}:${d.line} and ${s}:${c.location.line}. Set names are unique across the project. If line ${c.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${p}".`);if(c.examples.length>0)throw new j(`StepSet "${p}" (${s}:${c.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let u of c.steps)if(o.invocations.has(u.location.line))throw new j(`StepSet "${p}" (${s}:${c.location.line}) invokes another step set at line ${u.location.line} \u2014 step sets cannot be nested.`);n.set(p,{name:p,file:s,line:c.location.line,steps:[...c.steps]})})}return n}var tn=[".feature",".saffron"];function ee(t){return tn.some(e=>t.endsWith(e))}function oe(t){if(!ke.existsSync(t))return[];let e=[];for(let n of ke.readdirSync(t,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&e.push(Ve.join(t,r))}return e.sort()}function We(t,e,n){return t.map(({step:r,source:s})=>{let o;switch(r.keywordType){case Re.OUTCOME:o="assertion";break;case Re.CONTEXT:case Re.ACTION:o="action";break;default:o=n.value}n.value=o;let i=r.dataTable?r.dataTable.rows.map(p=>p.cells.map(d=>d.value)):void 0,c=r.docString?.content;return{keyword:r.keyword.trim(),kind:o,text:r.text,resolvedText:L(r.text,e),table:i,resolvedTable:i?.map(p=>p.map(d=>L(d,e))),docString:c,resolvedDocString:c!==void 0?L(c,e):void 0,source:s}})}function qe(t,e,n,r,s){let o=[];for(let i of t){let c=r.get(i.location.line);if(c===void 0){o.push({step:i,source:{file:e,line:i.location.line,fromBackground:n}});continue}let p=s?.get(c);if(!p)throw new j(`${e}:${i.location.line} invokes StepSet "${c}", which is not defined anywhere in the project.`);for(let d of p.steps)o.push({step:d,source:{file:p.file,line:d.location.line,fromStepSet:p.name,fromBackground:n}})}return o}function ln(t,e,n,r,s){let o=t.tags.map(c=>c.name),i=[];if(t.examples.length===0)i.push({params:{},label:""});else{let c=0;for(let p of t.examples){let d=p.tableHeader?.cells.map(u=>u.value)??[];for(let u of p.tableBody){c+=1;let l={};u.cells.forEach((a,f)=>{l[d[f]]=a.value}),i.push({params:l,label:` \u2014 example ${c}`})}}}return i.map(({params:c,label:p})=>{let d={value:"action"};return{featurePath:e,featureName:n,name:t.name,displayName:`${t.name}${p}`,tags:o,steps:[...We(r,c,d),...We(s,c,d)],params:c}})}function Q(t,e,n){let r=nn.readFileSync(t,"utf8"),o=t.endsWith(".saffron")?$e(r):{source:r,definitions:new Map,invocations:new Map},c=new an(new sn(cn.uuid()),new on).parse(o.source);if(!c.feature)return[];let p=rn.relative(e,t),d=c.feature.name,u=[],l=(a,f)=>{let h=f;for(let S of a)if(S.background)h=[...f,...qe(S.background.steps,p,!0,o.invocations,n)];else if(S.scenario){if(o.definitions.has(S.scenario.location.line))continue;u.push(...ln(S.scenario,p,d,h,qe(S.scenario.steps,p,!1,o.invocations,n)))}else"rule"in S&&S.rule&&l(S.rule.children,h)};return l(c.feature.children,[]),u}import tt from"node:fs";import Te from"node:path";import Qe from"node:fs";import Ke from"node:path";import ie from"node:fs";import te from"node:path";var Ye=".saffron";function ae(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function K(t,e,n){let r=ae(te.basename(e,".feature"));return te.join(t,Ye,"cache",r,`${ae(n)}.json`)}function Ge(t,e,n){let r=ae(te.basename(e,".feature"));return te.join(t,Ye,"proposals",r,`${ae(n)}.json`)}function D(t){if(!ie.existsSync(t))return;let e=JSON.parse(ie.readFileSync(t,"utf8"));if(e.version!==1)throw new Error(`Unsupported cache version ${e.version} in ${t}`);return e}function J(t,e){ie.mkdirSync(te.dirname(t),{recursive:!0}),ie.writeFileSync(t,`${JSON.stringify(e,null,2)}
5
+ `)}function Xe(t){let e=[];return{pattern:t.replace(/"((?:[^"\\]|\\.)*)"/g,(r,s)=>(e.push(s),`"\xAB${e.length-1}\xBB"`)),args:e}}function dn(t,e){let n=r=>JSON.stringify(r.map(({pageFingerprint:s,...o})=>o));return n(t)===n(e)}function pn(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 z(t){let e=Ke.join(t,".saffron","cache"),n=new Map,r=new Map,s=0;if(Qe.existsSync(e))for(let c of Qe.readdirSync(e,{recursive:!0})){let p=String(c);if(!p.endsWith(".json"))continue;let d=D(Ke.join(e,p));if(d){s++;for(let u of d.steps){let l=u.gherkin,a=JSON.stringify(u.actions.map(({pageFingerprint:S,...m})=>m)),f=r.get(l)??[];f.includes(a)||(f.push(a),r.set(l,f));let h=n.get(l);!h||d.recordedAt>h.recordedAt?n.set(l,{gherkin:u.gherkin,table:u.table,docString:u.docString,actions:u.actions,feature:d.feature,scenario:d.scenario,recordedAt:d.recordedAt,variants:f.length}):h.variants=f.length}}}let o=[...r.entries()].filter(([,c])=>c.length>1).map(([c])=>c),i=new Map;for(let c of n.values()){let{pattern:p,args:d}=Xe(c.gherkin);if(d.length===0)continue;let u=i.get(p);(!u||c.recordedAt>u.recordedAt)&&i.set(p,{...c,args:d})}return{steps:n,patterns:i,divergent:o,scannedCaches:s}}function Je(t,e,n){if(!!t.table!=!!e.table)return!1;if(t.table&&e.table){let o=Object.keys(_(t.table)),i=Object.keys(_(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,..._(e.resolvedTable??e.table),...N(e.resolvedDocString??e.docString)},s=pn({gherkin:t.gherkin,keyword:"",kind:"action",actions:t.actions});for(let o of s)if(!(o in r))return!1;return!0}function Ze(t,e,n){let r=t.steps.get(e.text);if(r)return Je(r,e,n)?r:void 0;let{pattern:s,args:o}=Xe(e.text);if(o.length===0)return;let i=t.patterns.get(s);if(!i)return;let c=new Map;for(let l=0;l<o.length;l++){let a=i.args[l],f=o[l];if(a===f)continue;let h=c.get(a);if(h!==void 0&&h!==f)return;c.set(a,f)}let p=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of c.keys())if(!i.actions.some(f=>[f.value,f.url,f.pattern,...p(f.target)].includes(l)))return;let d=l=>l!==void 0&&c.has(l)?c.get(l):l,u={...i,gherkin:e.text,actions:i.actions.map(({pageFingerprint:l,...a})=>({...a,value:d(a.value),url:d(a.url),pattern:d(a.pattern),target:a.target?{...a.target,name:d(a.target.name),nameRegex:d(a.target.nameRegex),label:d(a.target.label),text:d(a.target.text),placeholder:d(a.target.placeholder)}:void 0}))};return Je(u,e,n)?u:void 0}function et(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&&dn(r.actions,o.actions)){n.push({index:s,from:r.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),n}function un(t){if(!tt.existsSync(t))return[];let e=[];for(let n of tt.readdirSync(t,{recursive:!0})){let r=String(n);ee(r)&&e.push(Te.join(t,r))}return e.sort()}function B(t,e,n){let r=Te.isAbsolute(e)?e:Te.join(t,e),s=un(r),o=n??G(s.filter(a=>a.endsWith(".saffron")),t),i=new Map,c=new Map;for(let a of s)for(let f of Q(a,t,o)){let h=`${f.featurePath}::${f.name}`;for(let S of f.steps){let m=i.get(S.text);if(m||(m={text:S.text,keywords:new Map,kind:S.kind,scenarios:new Set,files:new Set,fromStepSet:S.source?.fromStepSet},i.set(S.text,m)),m.keywords.set(S.keyword,(m.keywords.get(S.keyword)??0)+1),m.scenarios.add(h),S.source&&m.files.add(S.source.file),S.source?.fromStepSet){let b=c.get(S.source.fromStepSet);b||c.set(S.source.fromStepSet,b=new Set),b.add(h)}}}let p=z(t),d=new Set(p.divergent),u=[...i.values()].map(a=>({text:a.text,keyword:[...a.keywords.entries()].sort((f,h)=>h[1]-f[1])[0][0],kind:a.kind,status:d.has(a.text)?"divergent":p.steps.has(a.text)?"recorded":"unrecorded",usage:a.scenarios.size,files:[...a.files].sort(),fromStepSet:a.fromStepSet})).sort((a,f)=>f.usage-a.usage||a.text.localeCompare(f.text)),l=[...o.values()].map(a=>({name:a.name,file:a.file,line:a.line,steps:a.steps.map(f=>f.text),usage:c.get(a.name)?.size??0})).sort((a,f)=>f.usage-a.usage||a.name.localeCompare(f.name));return{steps:u,stepSets:l,scannedFiles:s.length,scannedCaches:p.scannedCaches}}function ce(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 nt(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 fn}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as gn}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as mn,ListToolsRequestSchema as hn}from"@modelcontextprotocol/sdk/types.js";async function rt(t,e){let n=new fn({name:"saffron",version:"0.1.0"},{capabilities:{tools:{}}});n.setRequestHandler(hn,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(mn,async r=>{let s=B(t,e);if(r.params.name==="search_steps"){let o=r.params.arguments?.query,i=o?ce(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 gn)}import{query as Sn}from"@anthropic-ai/claude-agent-sdk";function yn(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
+ `)}`).join(`
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(s=>s!==void 0).join(`
12
+ `)}async function st(t,e,n){let r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},s="",o=Sn({prompt:yn(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 c of i.message.content)c.type==="text"&&(s+=c.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 le from"node:path";import{createConnection as bn,ProposedFeatures as xn,TextDocuments as vn,TextDocumentSyncKind as wn,CompletionItemKind as Ee,DiagnosticSeverity as Ae,MarkupKind as ot}from"vscode-languageserver/node";import{TextDocument as kn}from"vscode-languageserver-textdocument";var it=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,$n=/^\s*StepSet:\s*(.+?)\s*$/,Ce=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,Pe={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function Rn(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 at(t,e){let n=bn(xn.all,process.stdin,process.stdout),r=new vn(kn),s={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},o=new Map,i,c=()=>{try{let l=le.isAbsolute(e)?e:le.join(t,e);o=G(oe(l),t),s=B(t,e,o)}catch(l){n.console.warn(`saffron vocabulary rebuild: ${String(l)}`)}for(let l of r.all())u(l)},p=()=>{clearTimeout(i),i=setTimeout(c,300)};function d(l){return s.steps.find(a=>a.text===l)?.status}function u(l){if(!l.uri.endsWith(".saffron"))return;let a=[],f=l.getText().split(`
14
+ `),h=new Map;for(let S=0;S<f.length;S++){let m=f[S],b={start:{line:S,character:0},end:{line:S,character:m.length}},g=m.match(Ce);g&&!o.has(g[3])&&a.push({range:b,severity:Ae.Error,source:"saffron",message:`StepSet "${g[3]}" is not defined anywhere in the project.`});let x=m.match($n);if(x){let k=o.get(x[1]),v=le.relative(t,new URL(l.uri).pathname).replace(/\\/g,"/");(h.has(x[1])||k&&k.file!==v)&&a.push({range:b,severity:Ae.Error,source:"saffron",message:`StepSet "${x[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${x[1]}".`}),h.set(x[1],S)}let $=m.match(it);if($&&d($[3])!=="recorded"){for(let k of s.steps)if(!(k.text===$[3]||k.status==="unrecorded")&&Rn(k.text,$[3])>=.7){a.push({range:{start:{line:S,character:m.indexOf($[3])},end:{line:S,character:m.length}},severity:Ae.Information,source:"saffron",message:`Similar to the ${k.status} step "${k.text}" (\xD7${k.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}n.sendDiagnostics({uri:l.uri,diagnostics:a})}n.onInitialize(()=>(c(),{capabilities:{textDocumentSync:wn.Incremental,completionProvider:{triggerCharacters:[" "]},definitionProvider:!0,hoverProvider:!0}})),n.onCompletion(l=>{let a=r.get(l.textDocument.uri);if(!a)return[];let f=a.getText({start:{line:l.position.line,character:0},end:l.position});return/^\s*StepSet\s+/.test(f)&&a.uri.endsWith(".saffron")?s.stepSets.map(h=>({label:h.name,kind:Ee.Function,detail:`step set \xB7 ${h.steps.length} step(s) \xB7 \xD7${h.usage}`,documentation:h.steps.join(`
15
+ `)})):/^\s*(Given|When|Then|And|But|\*)\s+/.test(f)?s.steps.map((h,S)=>({label:h.text,kind:Ee.Text,detail:`${Pe[h.status]} \xB7 \xD7${h.usage}`,sortText:String(S).padStart(5,"0"),filterText:h.text})):/^\s*S\w*$/.test(f.trimEnd())&&a.uri.endsWith(".saffron")?s.stepSets.map(h=>({label:`StepSet ${h.name}`,kind:Ee.Function})):[]}),n.onDefinition(l=>{let a=r.get(l.textDocument.uri);if(!a)return;let h=a.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,"").match(Ce);if(!h)return;let S=o.get(h[3]);return S?{uri:`file://${le.join(t,S.file)}`,range:{start:{line:S.line-1,character:0},end:{line:S.line-1,character:0}}}:void 0}),n.onHover(l=>{let a=r.get(l.textDocument.uri);if(!a)return;let f=a.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,""),h=f.match(Ce);if(h){let m=s.stepSets.find(g=>g.name===h[3]);if(!m)return;let b=m.steps.map(g=>`- ${Pe[d(g)??"unrecorded"][0]} ${g}`).join(`
16
+ `);return{contents:{kind:ot.Markdown,value:`**StepSet ${m.name}** \u2014 expands to:
17
+
18
+ ${b}`}}}let S=f.match(it);if(S){let m=s.steps.find(b=>b.text===S[3]);return m?{contents:{kind:ot.Markdown,value:`${Pe[m.status]}
19
+
20
+ Used in ${m.usage} place(s): ${m.files.join(", ")}${m.fromStepSet?`
21
+
22
+ Defined in StepSet **${m.fromStepSet}**`:""}`}}:void 0}}),r.onDidOpen(l=>u(l.document)),r.onDidChangeContent(l=>u(l.document)),r.onDidSave(()=>p()),n.onDidChangeWatchedFiles(()=>p()),r.listen(n),n.listen()}import jn from"node:fs";import _n from"node:net";import ft from"node:path";import{chromium as On,firefox as Dn,webkit as Mn}from"playwright";import M from"node:fs";import de from"node:path";function ct(t,e){M.mkdirSync(de.dirname(t),{recursive:!0}),M.writeFileSync(t,`${JSON.stringify(e,null,2)}
23
+ `)}function pe(t){return JSON.parse(M.readFileSync(t,"utf8"))}function ne(t){let e=de.join(t,".saffron","proposals");if(!M.existsSync(e))return[];let n=[];for(let r of M.readdirSync(e)){let s=de.join(e,r);if(M.statSync(s).isDirectory())for(let o of M.readdirSync(s)){if(!o.endsWith(".json"))continue;let i=de.join(s,o);n.push({file:i,proposal:pe(i)})}}return n}function lt(t,e){let n=pe(e),r=K(t,n.cache.feature,n.cache.scenario);return J(r,n.cache),M.rmSync(e),r}function dt(t){M.rmSync(t)}var Tn=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function En(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 Fe(t,e=new Date){return t.replace(Tn,(n,r,s)=>{let o=new Date(e);return o.setDate(o.getDate()+(r?parseInt(r,10):0)),En(o,s??"YYYY-MM-DD")})}var An=/\b(\d{4})-(\d{2})-(\d{2})\b/g,Cn=400;function pt(t,e=new Date){let n=new Date(e.getFullYear(),e.getMonth(),e.getDate());return t.replace(An,(r,s,o,i)=>{let c=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(c.getTime()))return r;let p=Math.round((c.getTime()-n.getTime())/864e5);return Math.abs(p)>Cn?r:p===0?"{date}":`{date${p>0?"+":""}${p}}`})}import{createHash as Pn}from"node:crypto";async function V(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:${Pn("sha256").update(n).digest("hex")}`}var je=t=>new Promise(e=>setTimeout(e,t));function Fn(t,e,n){let r=o=>Fe(L(o,n)),s=[];if(e.role){let o=e.nameRegex?new RegExp(r(e.nameRegex),"i"):e.name?r(e.name):void 0;s.push(t.getByRole(e.role,{name:o,exact:!1}))}e.label&&s.push(t.getByLabel(r(e.label))),e.placeholder&&s.push(t.getByPlaceholder(r(e.placeholder))),e.text&&s.push(t.getByText(r(e.text))),e.testId&&s.push(t.getByTestId(r(e.testId)));for(let o of e.fallbackSelectors??[])s.push(t.locator(o));if(s.length===0)throw new Error("Action target has neither role nor fallback selectors");return s}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 je(100)}throw new Error(`Timed out waiting for ${n}${s?` (${String(s)})`:""}`)}async function P(t,e){let n;for(let r of t)try{await e(r.first());return}catch(s){n=s}throw n}var Ie=t=>t.replace(/\s+/g," ").trim();async function _e(t,e,n,r,s,o=new Map){let i=l=>Fe(L(l,n)),c=e.value!==void 0?i(e.value):void 0,p=e.url!==void 0?i(e.url):void 0,d=e.target?Fn(t,e.target,n):[],u={timeout:r};switch(e.action){case"goto":{if(!p)throw new Error("goto requires url");let l=/^https?:\/\//.test(p)?p:new URL(p,s).toString();await t.goto(l,u);return}case"click":return P(d,l=>l.click(u));case"fill":return P(d,l=>l.fill(c??"",u));case"press":return d.length>0?P(d,l=>l.press(c??"",u)):t.keyboard.press(c??"");case"selectOption":return P(d,async l=>{await l.selectOption(c??"",u)});case"check":return P(d,l=>l.check(u));case"uncheck":return P(d,l=>l.uncheck(u));case"hover":return P(d,l=>l.hover(u));case"waitFor":return d.length>0?P(d,l=>l.waitFor({state:"visible",timeout:r})):je(Math.min(Number(c??"0"),r));case"expectVisible":return P(d,l=>l.waitFor({state:"visible",timeout:r}));case"expectNotVisible":return P(d,l=>l.waitFor({state:"hidden",timeout:r}));case"expectText":return P(d,l=>H(async()=>(await l.textContent()??"").includes(c??""),r,`text "${c}"`));case"expectValue":return P(d,l=>H(async()=>await l.inputValue()===(c??""),r,`input value "${c}"`));case"expectUrl":{let l=p??c??"";return H(async()=>t.context().pages().some(a=>a.url().includes(l)),r,`URL containing "${l}" in any tab (current: ${t.context().pages().map(a=>a.url()).join(", ")})`)}case"captureText":{if(!e.saveAs)throw new Error("captureText requires saveAs");return P(d,l=>H(async()=>{let a=Ie(await l.textContent()??"");return a===""?!1:(o.set(e.saveAs,a),!0)},r,`non-empty text to capture as "${e.saveAs}"`))}case"expectDiffers":{if(!e.compareTo)throw new Error("expectDiffers requires compareTo");let l=o.get(e.compareTo);if(l===void 0)throw new Error(`expectDiffers: no captured value named "${e.compareTo}"`);if(d.length>0)return P(d,h=>H(async()=>Ie(await h.textContent()??"")!==l,r,`target text to differ from "${e.compareTo}" (${l})`));let a=c??"",f=o.get(a);if(f===void 0)throw new Error(`expectDiffers: no captured value named "${a}"`);if(f===l)throw new Error(`expectDiffers: "${a}" (${f}) equals "${e.compareTo}" (${l})`);return}case"expectAttribute":{if(!e.attribute)throw new Error("expectAttribute requires attribute");if(!e.pattern)throw new Error("expectAttribute requires pattern");let l=new RegExp(e.pattern,"i");return P(d,a=>H(async()=>l.test(await a.getAttribute(e.attribute)??""),r,`attribute ${e.attribute} to match /${e.pattern}/i`))}case"expectMatches":{if(!e.pattern)throw new Error("expectMatches requires pattern");let l=new RegExp(e.pattern,"i");return P(d,a=>H(async()=>l.test(Ie(await a.textContent()??"")),r,`target text to match /${e.pattern}/i`))}default:throw new Error(`Unknown action type: ${e.action}`)}}function ut(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 In(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 c=Object.keys(_(o)),p=Object.keys(_(i));if(c.length>0&&p.length>0){if(JSON.stringify(c)!==JSON.stringify(p))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 c=e.steps.some(d=>ut(d,"<docstring>")),p=e.steps.some(d=>ut(d,n.docString));if(!c||p)return!1}return!0})}function Oe(t,e){let n=t.steps[e],r=n?.resolvedTable??n?.table;return{...t.params,...Y(t.steps),..._(r),...N(n?.resolvedDocString??n?.docString)}}async function ue(t,e,n,r={}){let s=r.actionTimeoutMs??5e3,o=r.retries??1,i=[],c=r.vars??new Map,p=()=>Object.fromEntries(c),d=t,u=()=>{let l=t.context().pages(),a=l[l.length-1];a&&a!==d&&!a.isClosed()&&(d=a)};if(!In(e,n))return{status:"stale",stepResults:i,captured:p()};for(let l=0;l<n.steps.length;l++){let a=n.steps[l],f=Oe(e,l),h=!1;for(let S=0;S<a.actions.length;S++){let m=a.actions[S];u(),m.pageFingerprint&&await V(d)!==m.pageFingerprint&&(h=!0);let b,g=!1;for(let x=0;x<=o&&!g;x++){x>0&&(await je(250*x),u());try{await _e(d,m,f,s,r.baseURL,c),g=!0}catch($){b=$}}if(!g){let x=b instanceof Error?b.message:String(b);i.push({gherkin:a.gherkin,fromStepSet:a.fromStepSet,kind:a.kind,status:"failed",drift:h,error:x});for(let $=l+1;$<n.steps.length;$++)i.push({gherkin:n.steps[$].gherkin,kind:n.steps[$].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:l,actionIndex:S,stepText:a.gherkin,kind:a.kind,error:x},stepResults:i,captured:p()}}}i.push({gherkin:a.gherkin,fromStepSet:a.fromStepSet,kind:a.kind,status:"passed",drift:h})}return{status:"passed",stepResults:i,captured:p()}}var fe={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function Un(){return new Promise((t,e)=>{let n=_n.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>t(r))}),n.on("error",e)})}function re(t){let e=t.contexts().flatMap(n=>n.pages());return[...e].reverse().find(n=>n.url()!=="about:blank")??e.at(-1)}function St(t){let e=t.length;for(let n=t.length-1;n>=0&&t[n].kind==="assertion";n--)e=n;return e}function gt(t,e){return t[e]?.kind==="assertion"&&e>=St(t)}function mt(t,e,n=!1,r){let s=St(t);return t.map((o,i)=>{let c=e[i],p=c&&c.actions.length>0;return o.kind==="assertion"?n&&i<s&&p?c:o:p?r&&i===r.stepIndex&&r.actionIndex>0?{...c,actions:[...o.actions.slice(0,r.actionIndex),...c.actions]}:c:o})}function Ln(t,e){return t.map((n,r)=>{let s=e[r];return s&&s.actions.length>0?s:n})}function ht(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)}}var ge=class{constructor(e){this.options=e}options;browser;cdpEndpoint;stepIndex;getStepIndex(){if(!this.stepIndex&&(this.stepIndex=z(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,...Y(e.steps)},i=e.steps.map(g=>{let x=r.steps.size>0?Ze(r,g,o):void 0;return x?{gherkin:g.text,keyword:g.keyword,kind:g.kind,table:g.table,docString:g.docString,fromStepSet:g.source?.fromStepSet,actions:x.actions}:void 0}),c=e.steps.map(g=>({gherkin:g.text,keyword:g.keyword,kind:g.kind,table:g.table,docString:g.docString,fromStepSet:g.source?.fromStepSet,actions:[]})),p=new Map,d=[],u=[],l=[],a=[],f={...fe},h=0,S=0,m=0,b=g=>({success:g,narrative:u.join(" ")||"Composed entirely from seeded recordings.",adaptations:d,suggestedFeatureEdit:a.length>0?a.join(`
24
+ `):void 0,featureEdits:l,recordedSteps:c,announcedSteps:[],usage:f,seededCount:h});for(;m<s;){if(i[m]){let v=m;for(;v<s&&i[v];)v++;let y=i.slice(m,v),A={...e,steps:e.steps.slice(m,v)},F=await ue(n,A,this.buildCandidateCache(e,y),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,vars:p}),C=F.status==="passed"?v-m:F.failure?.stepIndex??0;for(let q=0;q<C;q++)c[m+q]=y[q];if(h+=C,F.status==="passed"){m=v;continue}let U=m+C;d.push(`Seeded recording for "${e.steps[U].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[U]=void 0,m=U;continue}if(!this.options.agent)return b(!1);S++;let g=m;for(;g<s&&!i[g];)g++;S>=3&&(g=s);let x=await 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(v=>v.resolvedText):void 0,recordOnly:g<s?{from:m,to:g-1}:void 0,takeFingerprint:async()=>V(re(this.browser)??n)});if(f=ht(f,x.usage),u.push(x.narrative),d.push(...x.adaptations),l.push(...x.featureEdits),x.suggestedFeatureEdit&&a.push(x.suggestedFeatureEdit),!x.success)return b(!1);let $=x.announcedSteps.filter(v=>v>=m&&v<s),k=Math.min(s-1,Math.max($.length>0?Math.max(...$):g-1,m));for(let v=m;v<=k;v++)c[v]=x.recordedSteps[v];m=k+1}return b(!0)}async start(){let e=this.options.browser??"chromium";if(e==="chromium"){let n=await Un();this.browser=await On.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${n}`]}),this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(e==="firefox"?Dn:Mn).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=ft.isAbsolute(this.options.storageState)?this.options.storageState:ft.join(this.options.projectRoot,this.options.storageState);if(!jn.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 ue(s,e,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries}),context:r,page:s}}async runScenario(e,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:c}=this.options,p=K(s,e.featurePath,e.name),d=D(p),u=await this.browser.newContext({storageState:this.resolveStorageState()}),l=await u.newPage();try{let a;if(d&&(a=await ue(l,e,d,{baseURL:o,actionTimeoutMs:i,retries:c}),a.status==="passed"))return{scenario:e,status:"green",source:"cache",stepResults:a.stepResults,adaptations:[],usage:fe,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:a?.stepResults??[],adaptations:[],usage:fe,durationMs:Date.now()-r,error:d?a?.status==="stale"?`Cache is stale (feature file changed) and ${b}`:`Replay failed and ${b} ${a?.failure?.error??""}`.trimEnd():`No cache exists and ${b}`}}let h=d!==void 0&&a!==void 0&&a.status==="failed",S=0,m;try{if(h)m=await this.options.agent.run({scenario:e,baseURL:o,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:a.stepResults.filter(b=>b.status==="passed").map(b=>b.gherkin),failedStep:{text:a.failure.stepText,kind:a.failure.kind,error:a.failure.error},takeFingerprint:async()=>{let b=re(this.browser)??l;return V(b)}});else if(await l.goto("about:blank"),this.options.reuseSteps!==!1){let b=await this.recordWithSeeding(e,l);S=b.seededCount,m=b}else m=await this.options.agent.run({scenario:e,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,takeFingerprint:async()=>{let b=re(this.browser)??l;return V(b)}})}catch(b){let g=b instanceof Error?b.message:String(b);return{scenario:e,status:"red",source:h?"agent-heal":"agent-record",stepResults:a?.stepResults??[],adaptations:[],usage:fe,durationMs:Date.now()-r,error:`Agent invocation failed: ${g.split(`
25
+ `)[0]}`}}if(m.success&&h&&a.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||gt(d.steps,a.failure.stepIndex))){let b=d.steps[a.failure.stepIndex],g=re(this.browser)??l,x=new Map(Object.entries(a.captured)),$=Oe(e,a.failure.stepIndex);try{for(let k of b.actions)await _e(g,k,$,this.options.actionTimeoutMs??5e3,o,x)}catch(k){let v=k instanceof Error?k.message:String(k);return{scenario:e,status:"red",source:"agent-heal",stepResults:a.stepResults,narrative:m.narrative,adaptations:m.adaptations,suggestedFeatureEdit:m.suggestedFeatureEdit,usage:m.usage,durationMs:Date.now()-r,error:`Assertion failed as written: "${b.gherkin}". Saffron never adapts assertions \u2014 if this change is intended, update the .feature file or re-record the scenario. Verification error: ${v.split(`
26
+ `)[0]}`}}}return await this.finalizeAgentRun(e,m,h,d,r,S,h?a?.failure:void 0)}finally{await u.close()}}async finalizeAgentRun(e,n,r,s,o,i=0,c){let p=r?"agent-heal":"agent-record",d=n.recordedSteps.map(g=>({gherkin:g.gherkin,kind:g.kind,status:n.success?"passed":"failed"}));if(!n.success)return{scenario:e,status:"red",source:p,stepResults:d,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-o,error:n.narrative};let u=r?mt(s.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",c?{stepIndex:c.stepIndex,actionIndex:c.actionIndex}:void 0):n.recordedSteps,l=[...n.adaptations],a={...n.usage},f,h;if(this.options.verifyProposals!==!1){let g=await this.proofReplay(e,this.buildCandidateCache(e,u));try{if(g.result.status!=="passed"&&g.result.failure){let x=g.result.failure;if(this.options.agent&&!(r&&x.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||gt(s.steps,x.stepIndex))))try{let k=await 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:g.result.stepResults.filter(v=>v.status==="passed").map(v=>v.gherkin),failedStep:{text:x.stepText,kind:x.kind,error:x.error},takeFingerprint:async()=>V(re(this.browser)??g.page)});a=ht(a,k.usage),k.success&&(l.push(...k.adaptations),u=r?mt(u,k.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):Ln(u,k.recordedSteps),await g.context.close(),g=await this.proofReplay(e,this.buildCandidateCache(e,u)))}catch{}}f=g.result.status==="passed",f||(h=g.result.failure?.error??`proof replay ${g.result.status}`)}finally{await g.context.close().catch(()=>{})}}let S=[...n.featureEdits];for(let g of et(u,z(this.options.projectRoot)))l.push(`duplicate wording: "${g.from}" recorded exactly the same actions as the existing step "${g.to}" (${g.feature} \u203A ${g.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),S.some(x=>x.index===g.index)||S.push({index:g.index,text:g.to});let m={meta:{createdAt:new Date().toISOString(),mode:r?"heal":"record",narrative:n.narrative,adaptations:l,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:S.length>0?S:void 0,verified:f,proofError:h,seededSteps:i>0?i:void 0,usage:a},cache:{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:u}},b=Ge(this.options.projectRoot,e.featurePath,e.name);return ct(b,m),{scenario:e,status:"yellow",source:p,stepResults:d,narrative:n.narrative,adaptations:l,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:S.length>0?S:void 0,verified:f,proofError:h,seededSteps:i>0?i:void 0,proposalFile:b,usage:a,durationMs:Date.now()-o}}};import Et from"node:path";import{createRequire as Yn}from"node:module";import{query as Gn}from"@anthropic-ai/claude-agent-sdk";var me=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:[]}))}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))}},X=t=>t.replace(/\\(['"\\])/g,"$1");function Nn(t){let e=t.match(/'((?:[^'\\]|\\.)*)'/);return e?X(e[1]):void 0}function zn(t){let e={},n=t.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(n){e.role=n[1];let o=(n[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);o&&(e.name=X(o[1]))}let r=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[o,i]of r){let c=t.match(o);c&&(e[i]=X(c[1]))}let s=t.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return s&&(e.fallbackSelectors=[X(s[1])]),Object.keys(e).length>0?e:void 0}function Bn(t,e){if(e&&t.startsWith(e)){let n=t.slice(e.length);return n.startsWith("/")?n:`/${n}`}return t}var Vn={click:"click",dblclick:"click",fill:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function Hn(t,e){let n=t.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:Bn(X(n[1]),e)};let r=t.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:X(r[1])};let s=t.match(/await\s+page\.(.+)\.(click|dblclick|fill|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!s)return;let[,o,i,c]=s,p=zn(o);if(!p)return;let d={action:Vn[i],target:p},u=Nn(c);return u!==void 0&&i!=="click"&&i!=="dblclick"&&(d.value=u),d}function yt(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(`
27
+ `)){let c=Hn(i.trim(),e);c&&s.push(c)}return s}function De(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 bt(t,e){if(Object.entries(e).filter(([,s])=>s.length>=3).length===0)return t;let r=s=>s===void 0?void 0:De(s,e);return t.map(s=>({...s,actions:s.actions.map(o=>({...o,value:r(o.value),url:r(o.url),target:o.target?{...o.target,name:r(o.target.name),label:r(o.target.label),text:r(o.target.text),placeholder:r(o.target.placeholder)}:void 0}))}))}function xt(t,e){let n=Y(e.steps);return t.map((r,s)=>{let o=e.steps[s],i=o?.resolvedTable??o?.table,c=o?.resolvedDocString??o?.docString,p=Object.entries({...n,..._(i),...N(c)}).filter(([,u])=>u.length>=3).sort((u,l)=>l[1].length-u[1].length);if(p.length===0)return r;let d=u=>{if(u===void 0)return;let l=u;for(let[a,f]of p)l=l.split(f).join(`<${a}>`);return l};return{...r,actions:r.actions.map(u=>({...u,value:d(u.value),url:d(u.url),target:u.target?{...u.target,name:d(u.target.name),label:d(u.target.label),text:d(u.target.text),placeholder:d(u.target.placeholder)}:void 0}))}})}function vt(t,e=new Date){let n=r=>r===void 0?void 0:pt(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}))}))}import{createSdkMcpServer as Wn,tool as he}from"@anthropic-ai/claude-agent-sdk";import{z as R}from"zod";var O=t=>({content:[{type:"text",text:t}]});function wt(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]),Object.keys(e).length>0?e:void 0}var kt={role:R.string().optional().describe("ARIA role of the element"),name:R.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:R.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:R.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:R.string().optional().describe("getByText locator text"),selector:R.string().optional().describe("CSS fallback selector")};function $t(t){return Wn({name:"saffron",version:"0.1.0",tools:[he("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:R.number().int().min(0),text:R.string(),adaptation:R.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}`),O(`Recording step ${e.index}: ${e.text}`))),he("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:R.string().describe("Name to store the captured text under"),...kt},async e=>{let n=wt(e);return n?(t.addActions([{action:"captureText",target:n,saveAs:e.saveAs}]),O(`Capture recorded as "${e.saveAs}".`)):O("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),he("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:R.enum(["visible","notVisible","text","url","value","differs","matches","attribute"]),...kt,expected:R.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:R.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:R.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:R.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:R.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)"),compareTo:R.string().optional().describe("kind=differs: name of the captured value to compare against")},async e=>{let n=wt(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 O("ERROR: kind=differs requires compareTo.");if(!e.capture&&!n)return O("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 O("ERROR: kind=matches requires pattern.");r={action:"expectMatches",target:n,pattern:e.pattern};break;case"attribute":if(!e.attribute||!e.pattern)return O("ERROR: kind=attribute requires attribute and pattern.");r={action:"expectAttribute",target:n,attribute:e.attribute,pattern:e.pattern};break}return!["url","differs"].includes(e.kind)&&!n?O("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(t.addActions([r]),O("Assertion recorded."))}),he("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:R.boolean(),narrative:R.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:R.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:R.array(R.object({index:R.number().int().min(0).describe("zero-based step index"),text:R.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??[],O("Run finalized.")))]})}function Rt(t={}){let e=t.deliberateSnapshots?`
28
+ 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.
29
+ 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.
30
+
31
+ Non-negotiable rules:
32
+ 1. Before performing each step, call saffron_step with the step's index and exact text. Only then perform the browser actions for that step.
33
+ 2. ${t.adaptableMidAssertions?"Steps marked [assertion:final] are sacred \u2014 they are the scenario's verdict. Verify them against a page snapshot; if the condition does not hold, the scenario FAILS: call saffron_done with success=false. Never manipulate the page to force them, never weaken or substitute them. Steps marked [assertion] (mid-scenario checkpoints) MAY be adapted under this project's policy when the written condition no longer matches a legitimately changed UI \u2014 record the equivalent verified condition via saffron_assert AND report the deviation via saffron_step's adaptation field plus featureEdits. An application defect is still a failure, not an adaptation.":'Steps marked [assertion] or [assertion:final] are sacred. Verify them against a page snapshot. If the condition holds, record it with saffron_assert. If it does not hold, the scenario FAILS: call saffron_done with success=false. Never click, navigate, or otherwise manipulate the page to force an assertion to become true, and never weaken or substitute the asserted condition for a "close enough" one.'}
34
+ 3. Steps marked [action] may be adapted if the written step does not match the real UI (e.g. a step refers to a control that does not exist, or a required intermediate step is missing). Adapt minimally to achieve the step's evident intent, and report the deviation via saffron_step's "adaptation" field. Before adapting, take a snapshot and decide: is this UI drift (adapt) or an application defect (fail with saffron_done success=false and explain)?
35
+ 4. Do not pursue goals beyond the scenario. Do not retry an assertion more than twice.
36
+ 5. Prefer stable, semantic element references: role + accessible name. Avoid positional or generated-id selectors. Use the SHORTEST stable name substring; when an accessible name embeds volatile content (prices, dates, counters), use nameRegex with the volatile part wildcarded instead.
37
+ 5a. For "X should link to Y" assertions, use saffron_assert kind=attribute (attribute='href', pattern for the URL) on the link element \u2014 do NOT click through and assert the page URL; the recorded assertion must be verifiable without navigation. NEVER bake volatile values into the recording. For steps that store a displayed value ('I record the price as "X"'), use saffron_capture. For comparisons ('X should differ from Y'), verify yourself from snapshots, then record with saffron_assert kind=differs referencing the captured names. For 'something dynamic should be visible', use kind=matches with a pattern (e.g. 'NOK [\\d,]+') instead of literal text.
38
+ 5b. Literal ISO dates (YYYY-MM-DD) in recorded locators are auto-templated to {date\xB1N} relative to today, so date-picker recordings stay valid tomorrow. For non-ISO date formats in names, use nameRegex.
39
+ 6. When all steps are done, call saffron_done exactly once. If you adapted steps, include BOTH suggestedFeatureEdit (human-readable corrected Gherkin) AND featureEdits (one {index, text} entry per adapted step, text without the keyword) so the correction can be applied automatically.
40
+ 7. Never ask the user questions; you are unattended.${e}`}function qn(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 Tt(t){let{scenario:e}=t,n=e.steps.map((s,o)=>{let i=`${o}. [${qn(e.steps,o)}] ${s.keyword} ${s.resolvedText}`,c=s.resolvedTable??s.table,p=s.resolvedDocString??s.docString,d=i;return c&&(d+=`
41
+ ${c.map(u=>` | ${u.join(" | ")} |`).join(`
42
+ `)}`),p!==void 0&&(d+=`
43
+ """
44
+ ${p.split(`
45
+ `).map(u=>` ${u}`).join(`
46
+ `)}
47
+ """`),d}).join(`
48
+ `),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:
49
+ ${(t.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
50
+ `)||" (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:
51
+ ${(t.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
52
+ `)||" (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):
53
+ ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
54
+ `)}`:"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(`
55
+ `),"",`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(`
56
+ `)}var Qn=Yn(import.meta.url);function Kn(){let t=Qn.resolve("@playwright/mcp/package.json");return Et.join(Et.dirname(t),"cli.js")}var Jn=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"]),Se=class{constructor(e={}){this.opts=e}opts;async run(e){let n=new me(e.scenario),r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},s="",o=Gn({prompt:Tt(e),options:{systemPrompt:Rt({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:[Kn(),"--cdp-endpoint",e.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:$t(n)},hooks:{PreToolUse:[{hooks:[async i=>{let c=i;if(Jn.has(c.tool_name)&&e.takeFingerprint&&!n.pendingFingerprint)try{n.pendingFingerprint=await e.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async i=>{let c=i;return c.tool_name.startsWith("mcp__playwright__")&&(n.addActions(yt(c.tool_response,e.baseURL)),n.pendingFingerprint=void 0),{continue:!0}}]}]}}});try{for await(let i of o)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),i.subtype==="success"&&(s=i.result))}catch(i){let c=i instanceof Error?i.message:String(i);n.done=!0,n.success=!1,n.narrative=`Agent session error: ${c}`}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:De(i.text,e.scenario.params)})),recordedSteps:vt(bt(xt(n.steps,e.scenario),e.scenario.params)),announcedSteps:[...n.announced].sort((i,c)=>i-c),usage:r}}};import At from"node:fs";import ye from"node:path";function be(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 Ct(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 c=0;c<o.length;c++){let p=o[c],d=i[c];if(p.action!==d.action||!p.target||!d.target)continue;let u=be(p.target);u!==be(d.target)&&n.set(u,{from:p.target,to:d.target})}}return[...n.values()]}function Xn(t){let e=ye.join(t,".saffron","cache");if(!At.existsSync(e))return[];let n=[];for(let r of At.readdirSync(e,{recursive:!0})){let s=String(r);s.endsWith(".json")&&n.push(ye.join(e,s))}return n}function Pt(t,e,n,r){if(e.length===0)return[];let s=new Map(e.map(i=>[be(i.from),i])),o=[];for(let i of Xn(t)){if(ye.resolve(i)===ye.resolve(n))continue;let c=D(i);if(!c)continue;let p=[],d=!1;for(let u of c.steps){let l=0;for(let a of u.actions){if(!a.target)continue;let f=s.get(be(a.target));f&&(l++,r||(a.target=structuredClone(f.to),d=!0))}l>0&&p.push({gherkin:u.gherkin,count:l})}p.length>0&&(d&&J(i,c),o.push({file:i,scenario:c.scenario,feature:c.feature,steps:p}))}return o}function Me(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 Ft from"node:fs";import Ue from"node:path";function It(t,e,n,r,s){let o={applied:[],skipped:[]},i=Q(Ue.join(t,e),t,s).find(d=>d.name===n);if(!i){for(let d of r)o.skipped.push({index:d.index,reason:`scenario "${n}" not found in ${e}`});return o}let c=new Map,p=new Set;for(let d of r){let u=i.steps[d.index];if(!u?.source){o.skipped.push({index:d.index,reason:`no step at index ${d.index}`});continue}let{file:l,line:a,fromStepSet:f,fromBackground:h}=u.source;if(h){o.skipped.push({index:d.index,reason:`"${u.text}" is a Background step shared across scenarios \u2014 edit it manually`});continue}let S=`${l}:${a}`;if(p.has(S))continue;let m=c.get(l);m||(m=Ft.readFileSync(Ue.join(t,l),"utf8").split(`
57
+ `),c.set(l,m));let b=m[a-1],g=b?.match(/^(\s*)(\S+)\s+(.*)$/);if(!g||g[3]!==u.text){o.skipped.push({index:d.index,reason:`${l}:${a} no longer matches the recorded step text`});continue}let x=`${g[1]}${g[2]} ${d.text}`;m[a-1]=x,p.add(S),o.applied.push({file:l,line:a,from:b.trim(),to:x.trim(),fromStepSet:f})}for(let[d,u]of c)o.applied.some(l=>l.file===d)&&Ft.writeFileSync(Ue.join(t,d),u.join(`
58
+ `));return o}import Le from"node:fs";import Ne from"node:path";var T=t=>t.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),Ot="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",Zn="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 er(t){return`<svg viewBox="0 0 48 48" width="${t}" height="${t}" aria-hidden="true">
59
+ <path d="${Ot}" fill="#F4A300"/>
60
+ <path d="${Zn}" fill="#E7E9EC"/>
61
+ </svg>`}function tr(t){return`<svg viewBox="8 1 32 15" height="${t}" aria-hidden="true"><path d="${Ot}" fill="#F4A300"/></svg>`}var nr={green:"passed",yellow:"adapted",red:"failed"};function xe(t){return t>=1e3?`${(t/1e3).toFixed(1)}s`:`${t}ms`}function se(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">
62
+ <div class="kpi-label">${t}</div>
63
+ <div class="kpi-value ${r}">${e}</div>
64
+ <div class="kpi-sub ${s}">${n}</div>${o}
65
+ </div>`}function jt(t,e){if(t.length<2)return"";let n=108,r=22,s=Math.min(...t),i=Math.max(...t)-s||1,c=t.map((p,d)=>`${(d/(t.length-1)*n).toFixed(1)},${(r-2-(p-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="${c}" fill="none" stroke="${e}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var _t=(t,e="",n=0)=>`${t>=0?"+":"\u2212"}${Math.abs(t).toFixed(n)}${e} vs prev`;function rr(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,c=r?.prev?`${_t(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,p=r?.prev?`${_t(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${xe(e)}`:`${xe(e)} total runtime`;return`<section class="kpis">
66
+ ${W("pass rate",`${s}<span class="unit">%</span>`,c,o,r?.prev?r.prev.passRatePp>=0?"green":"red":"",jt(r?.series.passRate??[],"var(--green)"))}
67
+ ${W("passed",String(n.green),"deterministic replay","green",n.green>0?"green":"")}
68
+ ${W("adapted",String(n.yellow),n.yellow>0?"\u25B2 proposals pending review":"no adaptations",n.yellow>0?"gold":"",n.yellow>0?"gold":"")}
69
+ ${W("failed",String(n.red),n.red>0?"\u25BC needs attention":"all clear",n.red>0?"red":"",n.red>0?"red":"green")}
70
+ ${W("ai calls",String(n.aiCalls),`${se(i)} in+out tokens`,n.aiCalls>0?"":"green")}
71
+ ${W("cache traffic",se(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${se(n.cacheReadTokens)} read \xB7 ${se(n.cacheCreationTokens)} written`:"zero \u2014 fully cached run","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
72
+ ${W("ai cost",`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,p,"","",jt(r?.series.costUsd??[],"var(--gold)"))}
73
+ </section>`}function sr(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 \u2014 stop paying for heals</div><ul>${e.chronic.map(r=>`<li>${T(r.feature)} \u203A ${T(r.scenario)} \u2014 ${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 \u2014 ${T(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
74
+ `)}</section>`:""}function or(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}">
75
+ ${r}
76
+ <div class="step-body">
77
+ <span class="step-text">${T(n.gherkin)}</span>
78
+ ${n.kind==="assertion"?'<span class="tag">assert</span>':""}
79
+ ${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition \u2014 edit once, every invoking scenario follows">from StepSet: ${T(n.fromStepSet)}</span>`:""}
80
+ ${n.drift?'<span class="tag gold" title="page fingerprint no longer matches the recording">drift</span>':""}
81
+ ${n.error?`<div class="step-error">${T(n.error)}</div>`:""}
82
+ </div>
83
+ </li>`}).join(`
84
+ `)}</ol>`}function ir(t){return t.featureEdits&&t.featureEdits.length>0?`<div class="panel">
85
+ <div class="panel-title gold">suggested .feature edit</div>
86
+ <div class="diff">${t.featureEdits.map(n=>{let r=t.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${T(r)}</div><div class="diff-line add">+ ${T(n.text)}</div>`}).join("")}</div>
87
+ <div class="panel-hint">apply with <code>saffron accept --with-feature-edit</code></div>
88
+ </div>`:t.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${T(t.suggestedFeatureEdit)}</pre></div>`:""}function ar(t){let e=t.usage.inputTokens+t.usage.outputTokens;if(t.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let n=t.usage.costUsd?` \xB7 $${t.usage.costUsd.toFixed(2)}`:"";return`<span class="pill gold">${t.usage.aiCalls} ai \xB7 ${se(e)} tok${n}</span>`}function cr(t){return`<details class="card" ${t.status!=="green"?"open":""}>
89
+ <summary>
90
+ <span class="pill ${t.status==="green"?"green":t.status==="yellow"?"gold":"red"}">${nr[t.status]}</span>
91
+ <span class="card-title">${T(t.scenario)}</span>
92
+ <span class="card-meta">
93
+ <span class="m">${T(t.feature)}</span>
94
+ <span class="m-sep">\xB7</span>
95
+ <span class="m">${T(t.source)}</span>
96
+ <span class="m-sep">\xB7</span>
97
+ <span class="m">${xe(t.durationMs)}</span>
98
+ ${ar(t)}
99
+ </span>
100
+ </summary>
101
+ <div class="card-body">
102
+ ${or(t)}
103
+ ${t.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${T(t.narrative)}</p></div>`:""}
104
+ ${t.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${t.adaptations.map(e=>`<li>${T(e)}</li>`).join("")}</ul></div>`:""}
105
+ ${ir(t)}
106
+ ${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">${T(t.proposalFile)}</p>${t.verified===!1&&t.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${T(t.proofError.split(`
107
+ `)[0])}</p>`:""}<div class="panel-hint">review with <code>saffron accept</code> / <code>saffron reject</code></div></div>`:""}
108
+ ${t.error&&t.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${T(t.error)}</p></div>`:""}
109
+ </div>
110
+ </details>`}function Dt(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>
111
+ <html lang="en">
112
+ <head>
113
+ <meta charset="utf-8"/>
114
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
115
+ <title>Saffron \u2014 run report</title>
116
+ <link rel="preconnect" href="https://fonts.googleapis.com">
117
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
118
+ <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700&family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
119
+ <style>
120
+ :root {
121
+ --bg: #131416;
122
+ --card: #1A1B1E;
123
+ --card-2: #16171A;
124
+ --border: #2A2B2E;
125
+ --border-2: #2D2E32;
126
+ --text: #D9DBDD;
127
+ --text-dim: #B4B8BD;
128
+ --muted: #7E8388;
129
+ --faint: #585C61;
130
+ --green: #6A9080;
131
+ --gold: #C9A862;
132
+ --brand: #F4A300;
133
+ --red: #B86F6F;
134
+ --green-soft: rgba(106, 144, 128, .13);
135
+ --gold-soft: rgba(201, 168, 98, .13);
136
+ --red-soft: rgba(184, 111, 111, .13);
137
+ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
138
+ --display: "Bricolage Grotesque", "Geist", system-ui, sans-serif;
139
+ }
140
+ * { box-sizing: border-box; }
141
+ body {
142
+ margin: 0; background: var(--bg); color: var(--text);
143
+ font: 14px/1.55 "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
144
+ }
145
+ .topline { height: 2px; background: linear-gradient(90deg, transparent, #F4A300 25%, #FFD37A 50%, #F4A300 75%, transparent); }
146
+ .wrap { max-width: 1060px; margin: 0 auto; padding: 22px 24px 56px; }
147
+
148
+ header.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding-bottom: 18px; border-bottom: 1px solid var(--border); }
149
+ .brand { display: flex; align-items: center; gap: 12px; }
150
+ .brand-name { font-family: var(--display); font-weight: 700; font-size: 19px; letter-spacing: .01em; color: #E7E9EC; }
151
+ .brand-sub { font-family: var(--mono); font-size: 9.5px; letter-spacing: .22em; text-transform: uppercase; color: var(--faint); margin-top: 2px; }
152
+ .run-meta { display: flex; align-items: center; gap: 10px; font-family: var(--mono); font-size: 11.5px; color: var(--muted); flex-wrap: wrap; }
153
+ .chip { border: 1px solid var(--border-2); background: var(--card); border-radius: 6px; padding: 3px 10px; }
154
+ .chip.base { color: var(--text-dim); }
155
+
156
+ .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-top: 22px; }
157
+ .kpi { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px 12px; }
158
+ .kpi-label { font-family: var(--mono); font-size: 9.5px; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); }
159
+ .kpi-value { font-family: var(--display); font-size: 30px; font-weight: 700; line-height: 1.15; margin-top: 6px; color: #E7E9EC; font-variant-numeric: tabular-nums; }
160
+ .kpi-value .unit { font-size: 17px; font-weight: 600; color: var(--muted); }
161
+ .kpi-value.green { color: var(--green); }
162
+ .kpi-value.gold { color: var(--gold); }
163
+ .kpi-value.red { color: var(--red); }
164
+ .kpi-sub { font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-top: 4px; }
165
+ .kpi-sub.green { color: var(--green); }
166
+ .kpi-sub.gold { color: var(--gold); }
167
+ .kpi-sub.red { color: var(--red); }
168
+ .spark { display: block; margin-top: 8px; opacity: .85; }
169
+ .trends { margin-top: 14px; }
170
+ .trends .panel { margin: 10px 0 0; }
171
+
172
+ .section-head { display: flex; align-items: baseline; justify-content: space-between; margin: 30px 4px 12px; }
173
+ .section-title { font-family: var(--mono); font-size: 10px; letter-spacing: .2em; text-transform: uppercase; color: var(--muted); }
174
+
175
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; margin: 10px 0; overflow: hidden; }
176
+ .card[open] { border-color: var(--border-2); }
177
+ summary { display: flex; align-items: center; gap: 12px; padding: 13px 16px; cursor: pointer; list-style: none; flex-wrap: wrap; }
178
+ summary::-webkit-details-marker { display: none; }
179
+ summary:hover { background: #1D1E22; }
180
+ .pill { flex: none; font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; border-radius: 5px; padding: 3px 9px; }
181
+ .pill.green { background: var(--green-soft); color: var(--green); }
182
+ .pill.gold { background: var(--gold-soft); color: var(--gold); }
183
+ .pill.red { background: var(--red-soft); color: var(--red); }
184
+ .card-title { font-weight: 600; font-size: 14.5px; color: #E7E9EC; }
185
+ .card-meta { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-family: var(--mono); font-size: 11px; color: var(--muted); }
186
+ .m-sep { color: var(--faint); }
187
+
188
+ .card-body { padding: 6px 18px 16px; border-top: 1px solid var(--border); }
189
+ .steps { list-style: none; margin: 12px 0; padding: 0; }
190
+ .step { display: flex; gap: 12px; padding: 4.5px 0; align-items: baseline; }
191
+ .dot { flex: none; width: 7px; height: 7px; border-radius: 50%; position: relative; top: -1px; }
192
+ .dot.green { background: var(--green); }
193
+ .dot.red { background: var(--red); box-shadow: 0 0 8px rgba(184,111,111,.5); }
194
+ .dot.idle { background: transparent; border: 1px solid var(--faint); }
195
+ .step-body { flex: 1; }
196
+ .step-text { color: var(--text-dim); font-size: 13.5px; }
197
+ .step.skipped .step-text { color: var(--faint); }
198
+ .step.failed .step-text { color: #E7E9EC; }
199
+ .tag { font-family: var(--mono); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--border-2); border-radius: 4px; padding: 1px 6px; margin-left: 8px; position: relative; top: -1px; }
200
+ .tag.gold { color: var(--gold); border-color: rgba(201,168,98,.35); }
201
+ .tag.indigo { color: #A9A3E8; border-color: rgba(99,92,199,.45); }
202
+ .step-error { font-family: var(--mono); font-size: 11.5px; color: var(--red); margin-top: 4px; white-space: pre-wrap; }
203
+
204
+ .panel { background: var(--card-2); border: 1px solid var(--border); border-radius: 10px; padding: 12px 15px; margin: 10px 0; font-size: 13px; color: var(--text-dim); }
205
+ .panel.accent-gold { border-left: 2px solid var(--gold); }
206
+ .panel.accent-red { border-left: 2px solid var(--red); }
207
+ .panel p, .panel ul { margin: 7px 0 2px; }
208
+ .panel ul { padding-left: 18px; }
209
+ .panel li { margin: 4px 0; }
210
+ .panel-title { font-family: var(--mono); font-size: 9.5px; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); }
211
+ .panel-title.gold { color: var(--gold); }
212
+ .panel-title.red { color: var(--red); }
213
+ .panel-hint { font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-top: 8px; }
214
+ .mono { font-family: var(--mono); font-size: 12px; }
215
+ .diff { font-family: var(--mono); font-size: 12px; margin-top: 8px; border-radius: 7px; overflow: hidden; border: 1px solid var(--border); }
216
+ .diff-line { padding: 4px 12px; }
217
+ .diff-line.del { background: var(--red-soft); color: var(--red); }
218
+ .diff-line.add { background: var(--green-soft); color: var(--green); }
219
+ pre { background: var(--bg); border: 1px solid var(--border); padding: 10px 12px; border-radius: 7px; overflow-x: auto; font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
220
+ code { font-family: var(--mono); font-size: 11px; background: var(--bg); border: 1px solid var(--border-2); padding: 1px 6px; border-radius: 4px; color: var(--text-dim); }
221
+
222
+ footer { margin-top: 42px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--faint); font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; }
223
+ </style>
224
+ </head>
225
+ <body>
226
+ <div class="topline"></div>
227
+ <div class="wrap">
228
+ <header class="top">
229
+ <div class="brand">
230
+ ${er(38)}
231
+ <div>
232
+ <div class="brand-name">saffron</div>
233
+ <div class="brand-sub">test run report</div>
234
+ </div>
235
+ </div>
236
+ <div class="run-meta">
237
+ <span>${T(s)}</span>
238
+ <span>\xB7</span>
239
+ <span>${xe(r)}</span>
240
+ ${t.baseURL?`<span class="chip base">${T(t.baseURL)}</span>`:""}
241
+ </div>
242
+ </header>
243
+
244
+ ${rr(t,r)}
245
+ ${sr(t)}
246
+
247
+ <div class="section-head">
248
+ <span class="section-title">scenarios \xB7 ${e.scenarios}</span>
249
+ <span class="section-title">saffron v${T(t.version)}</span>
250
+ </div>
251
+ ${t.scenarios.map(cr).join(`
252
+ `)}
253
+
254
+ <footer>${tr(11)} generated by saffron v${T(t.version)} \xB7 ${T(new Date(t.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
255
+ </div>
256
+ </body>
257
+ </html>
258
+ `}function Mt(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};for(let s of t)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{tool:"saffron",version:"0.1.0",startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:r,scenarios:t.map(s=>({feature:s.scenario.featurePath,scenario:s.scenario.displayName,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 Ut(t,e){let n=Ne.join(t,".saffron","reports");Le.mkdirSync(n,{recursive:!0});let r=Ne.join(n,"latest.json"),s=Ne.join(n,"latest.html");return Le.writeFileSync(r,`${JSON.stringify(e,null,2)}
259
+ `),Le.writeFileSync(s,Dt(e)),{json:r,html:s}}import ve from"node:fs";import Lt from"node:path";function Nt(t){return Lt.join(t,".saffron","history.jsonl")}function zt(t,e,n,r){let s=t.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,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(`
260
+ `)[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 Bt(t,e){let n=Nt(t);ve.mkdirSync(Lt.dirname(n),{recursive:!0}),ve.appendFileSync(n,`${JSON.stringify(e)}
261
+ `)}function Vt(t,e=50){let n=Nt(t);if(!ve.existsSync(n))return[];let r=ve.readFileSync(n,"utf8").trim().split(`
262
+ `).filter(Boolean),s=[];for(let o of r.slice(-e))try{s.push(JSON.parse(o))}catch{}return s}var ze=t=>{let e=t.green+t.yellow+t.red;return e===0?0:Math.round((t.green+t.yellow)/e*100)};function Ht(t,e){let n=[...t,e],r=n.slice(-20),s=n.slice(-10),o=t.at(-1),i=o?{passRatePp:ze(e)-ze(o),costUsd:e.costUsd-o.costUsd,aiCalls:e.aiCalls-o.aiCalls,red:e.red-o.red}:void 0,c=new Map;for(let l of s)for(let a of l.scenarios){let f=`${a.feature}::${a.scenario}`,h=c.get(f)??{feature:a.feature,scenario:a.scenario,heals:0,reds:0,runs:0};h.runs++,a.source==="agent-heal"&&a.status==="yellow"&&h.heals++,a.status==="red"&&h.reds++,c.set(f,h)}let p=[...c.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,a)=>a.heals+a.reds-(l.heals+l.reds)),d=new Map;for(let l of r)for(let a of l.scenarios)a.status==="red"&&a.error&&d.set(a.error,(d.get(a.error)??0)+1);let u=[...d.entries()].sort((l,a)=>a[1]-l[1]).slice(0,3).map(([l,a])=>({message:l,count:a}));return{prev:i,series:{passRate:r.map(ze),costUsd:r.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:r.map(l=>l.aiCalls)},chronic:p,divergentSteps:e.divergentSteps,topIssues:u}}function Z(t){let e=w.join(t,"saffron.config.json");return I.existsSync(e)?JSON.parse(I.readFileSync(e,"utf8")):{}}function pr(t,e){let n=e.length>0?e:["features"],r=[];for(let s of n){let o=w.isAbsolute(s)?s:w.join(t,s);if(I.existsSync(o))if(I.statSync(o).isDirectory())for(let i of I.readdirSync(o,{recursive:!0})){let c=String(i);ee(c)&&r.push(w.join(o,c))}else ee(o)&&r.push(o)}return r.sort()}function Wt(t,e,n){let r=w.isAbsolute(e.features??"features")?e.features:w.join(t,e.features??"features"),s=new Set(oe(r));for(let o of n)o.endsWith(".saffron")&&s.add(o);return G([...s].sort(),t)}function ur(){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&&(I.existsSync(w.join(e,".claude",".credentials.json"))||I.existsSync(w.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(`
263
+ `))}function we(t){console.error(`saffron: ${t.message}`),process.exit(2)}function Be(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}var fr={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},gr="\x1B[0m",E=new dr().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version("0.1.0").option("-p, --project-root <dir>","project root",process.cwd());E.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 <tag>","only run scenarios with this @tag").option("--no-agent","disable AI fallback (cache misses/failures go red)").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" \u2014 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=w.resolve(E.opts().projectRoot),r=Z(n),s=e.baseUrl??r.baseURL,o=pr(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&&ur();let i;try{let y=Wt(n,r,o);i=o.flatMap(A=>Q(A,n,y))}catch(y){throw y instanceof j&&we(y),y}if(e.filter){let y=e.filter.startsWith("@")?e.filter:`@${e.filter}`;i=i.filter(A=>A.tags.includes(y))}let c=new ge({projectRoot:n,baseURL:s,headed:e.headed,actionTimeoutMs:r.actionTimeoutMs,retries:r.retries,storageState:e.storageState??r.storageState,browser:(()=>{let y=e.browser??r.browser??"chromium";return y!=="chromium"&&y!=="firefox"&&y!=="webkit"&&(console.error(`invalid --browser "${y}" \u2014 use "chromium", "firefox", or "webkit"`),process.exit(2)),y})(),healModel:e.healModel??r.healModel,verifyProposals:e.verify===!1?!1:r.verifyProposals,reuseSteps:e.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let y=e.assertionPolicy??r.assertionPolicy??"strict";return y!=="strict"&&y!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${y}" \u2014 use "strict" or "adaptable-mid"`),process.exit(2)),y})(),agent:e.agent?new Se({model:e.model??r.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),p=new Date,d=Math.max(1,Number.parseInt(String(e.workers??r.workers??1),10)||1),u=new Array(i.length),l=y=>{let A=y.scenario,F=fr[y.status],C=y.usage.inputTokens+y.usage.outputTokens,U=y.usage.cacheReadTokens+y.usage.cacheCreationTokens,q=U>0?`, ${Be(U)} cache traffic`:"";console.log(`${F}${y.status.toUpperCase().padEnd(7)}${gr}${A.displayName} (${y.source}, ${y.durationMs}ms, ${y.usage.aiCalls} AI calls, ${C} tokens${q})`);for(let Yt of y.adaptations)console.log(` \u26A0 ${Yt}`);y.seededSteps&&console.log(` \u26A1 ${y.seededSteps}/${y.stepResults.length} steps seeded from existing recordings (zero AI)`),y.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):y.verified===!1&&console.log(` \u2717 proposal UNVERIFIED \u2014 proof replay failed: ${y.proofError?.split(`
264
+ `)[0]}`),y.error&&console.log(` ${y.error}`)};await c.start();try{if(d>1&&i.length>1){let y=i.map((F,C)=>({s:F,i:C})),A=[];await Promise.all(Array.from({length:Math.min(d,y.length)},async()=>{for(;;){let F=y.shift();if(!F)return;let C=await c.runScenario(F.s,{agentDisabled:!0});C.status==="red"?A.push(F):(u[F.i]=C,l(C))}}));for(let F of A.sort((C,U)=>C.i-U.i)){let C=await c.runScenario(F.s);u[F.i]=C,l(C)}}else for(let y=0;y<i.length;y++){let A=await c.runScenario(i[y]);u[y]=A,l(A)}}finally{await c.stop()}let a=Mt(u,p,s),f=z(n).divergent.length,h=zt(u,p,f,s),S=Vt(n);a.trends=Ht(S,h),Bt(n,h);let{html:m,json:b}=Ut(n,a),g=a.totals,x=g.cacheReadTokens+g.cacheCreationTokens;console.log(`
265
+ ${g.green} passed, ${g.yellow} adapted, ${g.red} failed \xB7 ${g.aiCalls} AI calls \xB7 ${(g.inputTokens+g.outputTokens).toLocaleString()} tokens in+out${x>0?` \xB7 ${Be(g.cacheReadTokens)} cache read \xB7 ${Be(g.cacheCreationTokens)} cache written`:""} \xB7 $${g.costUsd.toFixed(4)}`);let $=a.trends;if($?.prev){let y=$.prev.passRatePp,A=$.prev.costUsd;console.log(`vs previous run: pass rate ${y>=0?"+":""}${y}pp \xB7 cost ${A>=0?"+":"-"}$${Math.abs(A).toFixed(4)} \xB7 AI calls ${$.prev.aiCalls>=0?"+":""}${$.prev.aiCalls}`)}for(let y of $?.chronic??[])console.log(`\u26A0 chronic: ${y.feature} \u203A ${y.scenario} \u2014 ${y.heals} heal(s), ${y.reds} red(s) in last ${y.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${m}
266
+ ${b}`);let k=ne(w.resolve(E.opts().projectRoot));k.length>0&&console.log(`${k.length} cache proposal(s) pending \u2014 review with: saffron accept | saffron reject`);let v=e.strict??r.strict??!1;v&&g.yellow>0&&console.log(`strict mode: ${g.yellow} adapted scenario(s) treated as failure \u2014 review proposals to go green.`),process.exit(g.red>0||v&&g.yellow>0?1:0)});E.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").action((t,e)=>{let n=w.resolve(E.opts().projectRoot),r=Z(n),s=t?[w.resolve(t)]:e.all?ne(n).map(o=>o.file):[];if(s.length===0){qt(n);return}for(let o of s){let i=pe(o);i.meta.verified===!1&&console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${i.meta.proofError?.split(`
267
+ `)[0]}) \u2014 expect a heal or red on the next run.`);let c=D(K(n,i.cache.feature,i.cache.scenario)),p=lt(n,o);console.log(`accepted \u2192 ${p}`);let d=c?Ct(c,i.cache):[];if(d.length>0){let a=Pt(n,d,p,!e.propagate);for(let f of d)console.log(` locator fix: ${Me(f.from)} \u2192 ${Me(f.to)}`);if(a.length===0)console.log(" no other caches use this locator.");else if(e.propagate)for(let f of a)console.log(` propagated \u2192 ${f.feature} \u203A ${f.scenario} (${f.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${a.length} other cache(s) use the same locator \u2014 re-run with --propagate to fix them too:`);for(let f of a)console.log(` \xB7 ${f.feature} \u203A ${f.scenario}`)}}let u=i.meta.featureEdits??[];if(!e.withFeatureEdit||u.length===0)continue;let l;try{l=It(n,i.cache.feature,i.cache.scenario,u,Wt(n,r,[]))}catch(a){throw a instanceof j&&we(a),a}for(let a of l.applied){let f=a.fromStepSet?`${a.file}:${a.line} (StepSet "${a.fromStepSet}" \u2014 fixes every invoking scenario)`:`${a.file}:${a.line}`;console.log(` feature edit ${f}:`),console.log(` - ${a.from}`),console.log(` + ${a.to}`)}for(let a of l.skipped)console.log(` skipped edit [${a.index}]: ${a.reason}`);if(l.applied.length>0){let a=D(p);for(let f of u)a.steps[f.index]&&(a.steps[f.index].gherkin=f.text);J(p,a)}}});E.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=w.resolve(E.opts().projectRoot),r=Z(n),s;try{s=B(n,r.features??"features")}catch(i){throw i instanceof j&&we(i),i}if(t&&(s=ce(s,t)),e.json){console.log(JSON.stringify(s,null,2));return}if(e.snippets){let i=w.join(n,".vscode","saffron.code-snippets");I.mkdirSync(w.dirname(i),{recursive:!0}),I.writeFileSync(i,nt(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)) \u2014 ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let c=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${c}`)}console.log(`
268
+ ${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`)});E.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=w.resolve(E.opts().projectRoot),r=Z(n),s=I.readFileSync(w.resolve(t),"utf8"),o;try{o=B(n,r.features??"features")}catch(d){throw d instanceof j&&we(d),d}console.log(`authoring with ${o.steps.length} known step(s) and ${o.stepSets.length} step set(s) as vocabulary\u2026`);let i=await st(s,o,e.model??r.model),c=e.out?w.resolve(e.out):w.join(n,r.features??"features",`${w.basename(t).replace(/\.[^.]+$/,"")}.saffron`);I.existsSync(c)&&(console.error(`refusing to overwrite ${c} \u2014 pass --out for a new path`),process.exit(1)),I.mkdirSync(w.dirname(c),{recursive:!0}),I.writeFileSync(c,i.content);let p=i.content.split(`
269
+ `).map(d=>d.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(d=>o.steps.some(u=>u.text===d)).length;console.log(`wrote ${c}`),console.log(`${p} 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 "+w.relative(process.cwd(),c))});E.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let t=w.resolve(E.opts().projectRoot),e=Z(t);await rt(t,e.features??"features")});E.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics) \u2014 for JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{let t=w.resolve(E.opts().projectRoot),e=Z(t);at(t,e.features??"features")});E.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=w.resolve(E.opts().projectRoot),r=t?[w.resolve(t)]:e.all?ne(n).map(s=>s.file):[];if(r.length===0){qt(n);return}for(let s of r)dt(s),console.log(`rejected ${s}`)});E.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let t=w.resolve(E.opts().projectRoot),e=w.join(t,".saffron","reports","latest.html");I.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";lr(n,[e],{detached:!0,stdio:"ignore"}).unref()});function qt(t){let e=ne(t);if(e.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
270
+ `);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:
271
+ ${r.meta.suggestedFeatureEdit.split(`
272
+ `).map(o=>` ${o}`).join(`
273
+ `)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}E.parseAsync();
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "saffron-ai",
3
+ "version": "0.1.0",
4
+ "description": "Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing, honest reports",
5
+ "scripts": {
6
+ "test": "vitest run",
7
+ "build": "tsc",
8
+ "dev": "tsc --watch",
9
+ "e2e": "node scripts/e2e.mjs",
10
+ "prepublishOnly": "npm run build && npm test && npm run bundle",
11
+ "docs": "node scripts/build-docs.mjs",
12
+ "bundle": "esbuild src/cli/index.ts --bundle --platform=node --format=esm --packages=external --minify --legal-comments=none --outfile=dist-pkg/cli.js"
13
+ },
14
+ "keywords": [
15
+ "testing",
16
+ "gherkin",
17
+ "bdd",
18
+ "playwright",
19
+ "ai",
20
+ "self-healing",
21
+ "e2e"
22
+ ],
23
+ "author": "",
24
+ "license": "SEE LICENSE IN LICENSE",
25
+ "type": "module",
26
+ "bin": {
27
+ "saffron": "./dist-pkg/cli.js"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^26.1.0",
31
+ "esbuild": "^0.28.1",
32
+ "marked": "^18.0.5",
33
+ "typescript": "^6.0.3",
34
+ "vitest": "^4.1.9"
35
+ },
36
+ "dependencies": {
37
+ "@anthropic-ai/claude-agent-sdk": "^0.3.198",
38
+ "@cucumber/gherkin": "^41.0.0",
39
+ "@cucumber/messages": "^33.0.4",
40
+ "@modelcontextprotocol/sdk": "^1.29.0",
41
+ "@playwright/mcp": "^0.0.77",
42
+ "commander": "^15.0.0",
43
+ "playwright": "^1.61.1",
44
+ "vscode-languageserver": "^10.1.0",
45
+ "vscode-languageserver-textdocument": "^1.0.12",
46
+ "zod": "^4.4.3"
47
+ },
48
+ "homepage": "https://saffron-ai.lovable.app",
49
+ "bugs": {
50
+ "url": "https://github.com/s-chathuranga-j/saffron-ai/issues"
51
+ },
52
+ "engines": {
53
+ "node": ">=20"
54
+ },
55
+ "files": [
56
+ "dist-pkg",
57
+ "README.md",
58
+ "LICENSE"
59
+ ]
60
+ }