specweave 2.0.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.md +21 -11
  3. package/bin/specweave.js +28 -0
  4. package/dist/dashboard/assets/index-BeKUULf0.css +1 -0
  5. package/dist/dashboard/assets/index-Bf5TF0_l.js +13 -0
  6. package/dist/dashboard/index.html +2 -2
  7. package/dist/src/cli/commands/jev-helpers.d.ts +148 -0
  8. package/dist/src/cli/commands/jev-helpers.d.ts.map +1 -0
  9. package/dist/src/cli/commands/jev-helpers.js +582 -0
  10. package/dist/src/cli/commands/jev-helpers.js.map +1 -0
  11. package/dist/src/cli/commands/jev.d.ts +21 -0
  12. package/dist/src/cli/commands/jev.d.ts.map +1 -0
  13. package/dist/src/cli/commands/jev.js +350 -0
  14. package/dist/src/cli/commands/jev.js.map +1 -0
  15. package/dist/src/cli/commands/sync.d.ts.map +1 -1
  16. package/dist/src/cli/commands/sync.js +23 -5
  17. package/dist/src/cli/commands/sync.js.map +1 -1
  18. package/dist/src/cli/helpers/init/instruction-file-writer.d.ts +4 -0
  19. package/dist/src/cli/helpers/init/instruction-file-writer.d.ts.map +1 -1
  20. package/dist/src/cli/helpers/init/instruction-file-writer.js +11 -6
  21. package/dist/src/cli/helpers/init/instruction-file-writer.js.map +1 -1
  22. package/dist/src/core/auto/completion-evaluator.d.ts +22 -1
  23. package/dist/src/core/auto/completion-evaluator.d.ts.map +1 -1
  24. package/dist/src/core/auto/completion-evaluator.js +99 -9
  25. package/dist/src/core/auto/completion-evaluator.js.map +1 -1
  26. package/dist/src/core/config/types.d.ts +39 -1
  27. package/dist/src/core/config/types.d.ts.map +1 -1
  28. package/dist/src/core/config/types.js +1 -0
  29. package/dist/src/core/config/types.js.map +1 -1
  30. package/dist/src/core/doctor/checkers/hooks-checker.d.ts +1 -1
  31. package/dist/src/core/doctor/checkers/hooks-checker.js +1 -1
  32. package/dist/src/core/hooks/handlers/pre-tool-use.d.ts +28 -5
  33. package/dist/src/core/hooks/handlers/pre-tool-use.d.ts.map +1 -1
  34. package/dist/src/core/hooks/handlers/pre-tool-use.js +162 -7
  35. package/dist/src/core/hooks/handlers/pre-tool-use.js.map +1 -1
  36. package/dist/src/core/hooks/handlers/session-start.d.ts.map +1 -1
  37. package/dist/src/core/hooks/handlers/session-start.js +20 -0
  38. package/dist/src/core/hooks/handlers/session-start.js.map +1 -1
  39. package/dist/src/core/intent/portable-context.d.ts +4 -0
  40. package/dist/src/core/intent/portable-context.d.ts.map +1 -0
  41. package/dist/src/core/intent/portable-context.js +68 -0
  42. package/dist/src/core/intent/portable-context.js.map +1 -0
  43. package/dist/src/core/intent/types.d.ts +28 -0
  44. package/dist/src/core/intent/types.d.ts.map +1 -0
  45. package/dist/src/core/intent/types.js +3 -0
  46. package/dist/src/core/intent/types.js.map +1 -0
  47. package/dist/src/core/intent/validation.d.ts +5 -0
  48. package/dist/src/core/intent/validation.d.ts.map +1 -0
  49. package/dist/src/core/intent/validation.js +39 -0
  50. package/dist/src/core/intent/validation.js.map +1 -0
  51. package/dist/src/core/jev/browse.d.ts +56 -0
  52. package/dist/src/core/jev/browse.d.ts.map +1 -0
  53. package/dist/src/core/jev/browse.js +721 -0
  54. package/dist/src/core/jev/browse.js.map +1 -0
  55. package/dist/src/core/jev/client.d.ts +134 -0
  56. package/dist/src/core/jev/client.d.ts.map +1 -0
  57. package/dist/src/core/jev/client.js +329 -0
  58. package/dist/src/core/jev/client.js.map +1 -0
  59. package/dist/src/core/jev/config.d.ts +69 -0
  60. package/dist/src/core/jev/config.d.ts.map +1 -0
  61. package/dist/src/core/jev/config.js +181 -0
  62. package/dist/src/core/jev/config.js.map +1 -0
  63. package/dist/src/core/jev/decide.d.ts +160 -0
  64. package/dist/src/core/jev/decide.d.ts.map +1 -0
  65. package/dist/src/core/jev/decide.js +325 -0
  66. package/dist/src/core/jev/decide.js.map +1 -0
  67. package/dist/src/core/jev/index.d.ts +20 -0
  68. package/dist/src/core/jev/index.d.ts.map +1 -0
  69. package/dist/src/core/jev/index.js +20 -0
  70. package/dist/src/core/jev/index.js.map +1 -0
  71. package/dist/src/core/jev/questions.d.ts +56 -0
  72. package/dist/src/core/jev/questions.d.ts.map +1 -0
  73. package/dist/src/core/jev/questions.js +159 -0
  74. package/dist/src/core/jev/questions.js.map +1 -0
  75. package/dist/src/core/jev/redact.d.ts +44 -0
  76. package/dist/src/core/jev/redact.d.ts.map +1 -0
  77. package/dist/src/core/jev/redact.js +102 -0
  78. package/dist/src/core/jev/redact.js.map +1 -0
  79. package/dist/src/core/jev/usage.d.ts +38 -0
  80. package/dist/src/core/jev/usage.d.ts.map +1 -0
  81. package/dist/src/core/jev/usage.js +70 -0
  82. package/dist/src/core/jev/usage.js.map +1 -0
  83. package/dist/src/core/session/handoff-pointer.d.ts +4 -0
  84. package/dist/src/core/session/handoff-pointer.d.ts.map +1 -0
  85. package/dist/src/core/session/handoff-pointer.js +60 -0
  86. package/dist/src/core/session/handoff-pointer.js.map +1 -0
  87. package/dist/src/core/session/work-handoff.d.ts.map +1 -1
  88. package/dist/src/core/session/work-handoff.js +10 -3
  89. package/dist/src/core/session/work-handoff.js.map +1 -1
  90. package/dist/src/dashboard/server/dashboard-server.d.ts +1 -0
  91. package/dist/src/dashboard/server/dashboard-server.d.ts.map +1 -1
  92. package/dist/src/dashboard/server/dashboard-server.js +10 -90
  93. package/dist/src/dashboard/server/dashboard-server.js.map +1 -1
  94. package/dist/src/dashboard/server/data/cost-aggregator.d.ts +10 -5
  95. package/dist/src/dashboard/server/data/cost-aggregator.d.ts.map +1 -1
  96. package/dist/src/dashboard/server/data/cost-aggregator.js +31 -33
  97. package/dist/src/dashboard/server/data/cost-aggregator.js.map +1 -1
  98. package/dist/src/dashboard/server/data/dashboard-data-aggregator.d.ts +0 -1
  99. package/dist/src/dashboard/server/data/dashboard-data-aggregator.d.ts.map +1 -1
  100. package/dist/src/dashboard/server/data/dashboard-data-aggregator.js +14 -66
  101. package/dist/src/dashboard/server/data/dashboard-data-aggregator.js.map +1 -1
  102. package/dist/src/dashboard/server/data/dashboard-task-board.d.ts +4 -0
  103. package/dist/src/dashboard/server/data/dashboard-task-board.d.ts.map +1 -0
  104. package/dist/src/dashboard/server/data/dashboard-task-board.js +56 -0
  105. package/dist/src/dashboard/server/data/dashboard-task-board.js.map +1 -0
  106. package/dist/src/dashboard/server/data/intent-store.d.ts +19 -0
  107. package/dist/src/dashboard/server/data/intent-store.d.ts.map +1 -0
  108. package/dist/src/dashboard/server/data/intent-store.js +268 -0
  109. package/dist/src/dashboard/server/data/intent-store.js.map +1 -0
  110. package/dist/src/dashboard/server/data/local-sessions.d.ts +15 -0
  111. package/dist/src/dashboard/server/data/local-sessions.d.ts.map +1 -0
  112. package/dist/src/dashboard/server/data/local-sessions.js +197 -0
  113. package/dist/src/dashboard/server/data/local-sessions.js.map +1 -0
  114. package/dist/src/dashboard/server/data/work-projection.d.ts +8 -0
  115. package/dist/src/dashboard/server/data/work-projection.d.ts.map +1 -0
  116. package/dist/src/dashboard/server/data/work-projection.js +175 -0
  117. package/dist/src/dashboard/server/data/work-projection.js.map +1 -0
  118. package/dist/src/dashboard/server/file-watcher.d.ts.map +1 -1
  119. package/dist/src/dashboard/server/file-watcher.js +2 -1
  120. package/dist/src/dashboard/server/file-watcher.js.map +1 -1
  121. package/dist/src/dashboard/server/local-request.d.ts +4 -0
  122. package/dist/src/dashboard/server/local-request.d.ts.map +1 -0
  123. package/dist/src/dashboard/server/local-request.js +29 -0
  124. package/dist/src/dashboard/server/local-request.js.map +1 -0
  125. package/dist/src/dashboard/server/routes/work-routes.d.ts +5 -0
  126. package/dist/src/dashboard/server/routes/work-routes.d.ts.map +1 -0
  127. package/dist/src/dashboard/server/routes/work-routes.js +37 -0
  128. package/dist/src/dashboard/server/routes/work-routes.js.map +1 -0
  129. package/dist/src/dashboard/types.d.ts +3 -3
  130. package/dist/src/dashboard/types.d.ts.map +1 -1
  131. package/dist/src/dashboard/work-types.d.ts +39 -0
  132. package/dist/src/dashboard/work-types.d.ts.map +1 -0
  133. package/dist/src/dashboard/work-types.js +3 -0
  134. package/dist/src/dashboard/work-types.js.map +1 -0
  135. package/dist/src/sync/external-change-puller.d.ts +19 -0
  136. package/dist/src/sync/external-change-puller.d.ts.map +1 -1
  137. package/dist/src/sync/external-change-puller.js +87 -77
  138. package/dist/src/sync/external-change-puller.js.map +1 -1
  139. package/dist/src/utils/model-selection.d.ts +49 -0
  140. package/dist/src/utils/model-selection.d.ts.map +1 -1
  141. package/dist/src/utils/model-selection.js +70 -0
  142. package/dist/src/utils/model-selection.js.map +1 -1
  143. package/package.json +3 -3
  144. package/plugins/PLUGINS-INDEX.md +14 -4
  145. package/plugins/specweave/.claude-plugin/plugin.json +5 -3
  146. package/plugins/specweave/PLUGIN.md +16 -4
  147. package/plugins/specweave/hooks/hooks.json +1 -24
  148. package/plugins/specweave/hooks/run-worker.mjs +37 -2
  149. package/plugins/specweave/hooks/run.mjs +54 -1
  150. package/plugins/specweave/marketplace.json +6 -0
  151. package/plugins/specweave/skills/increment/SKILL.md +7 -5
  152. package/plugins/specweave/skills/jev/SKILL.md +201 -0
  153. package/src/templates/AGENTS.md.template +27 -5
  154. package/src/templates/CLAUDE.md.template +27 -5
  155. package/dist/dashboard/assets/index-BiJhGDZV.js +0 -13
  156. package/dist/dashboard/assets/index-heYRtWFf.css +0 -1
@@ -0,0 +1,721 @@
1
+ /**
2
+ * Jev headless browser delegation.
3
+ *
4
+ * A mechanical navigation loop where Jev picks the next action from an enumerated set
5
+ * of things visible on the page. The loop is always HEADLESS: it never launches a
6
+ * visible window, never types text the model chose (values come from `inputs` only),
7
+ * never leaves `allowDomains`, and hands control back to the agent the moment the page
8
+ * needs judgement (login, captcha, payment, free text).
9
+ *
10
+ * Playwright is loaded lazily — it is not a dependency of this package.
11
+ *
12
+ * @module core/jev/browse
13
+ */
14
+ import { spawnSync } from 'child_process';
15
+ import * as fs from 'fs';
16
+ import { createRequire } from 'module';
17
+ import * as path from 'path';
18
+ import { createJevClient } from './client.js';
19
+ import { JEV_DEFAULTS, loadJevConfig } from './config.js';
20
+ /* ------------------------------------------------------------------ constants */
21
+ /** Interactive things worth enumerating. Everything else is page furniture. */
22
+ const INTERACTIVE_SELECTOR = 'a[href], button, [role=button], [role=link], [role=tab], [role=menuitem], input, select, textarea, summary, [onclick]';
23
+ /** Controls we never click on the agent's behalf unless `allowSensitive` is set. */
24
+ const SENSITIVE = /delete|remove|pay|purchase|buy|checkout|confirm order|send|submit|sign in|log in|password|unsubscribe|transfer/i;
25
+ /**
26
+ * The same judgement for a link's destination: a harmless label ("Manage", "Preferences")
27
+ * can hide `/account/subscription/cancel?confirm=1`, and a GET performs it.
28
+ */
29
+ const SENSITIVE_URL = /(^|[/_.-])(delete|destroy|remove|cancel|unsubscribe|opt-out|logout|signout|checkout|purchase|payments?|transfers?|refunds?)([/_.?&=-]|$)|[?&](confirm|delete|cancel|unsubscribe)=/i;
30
+ /** Stamped on every described element so an action binds to it, not to its ordinal. */
31
+ const REF_ATTR = 'data-jev-ref';
32
+ const MAX_ELEMENTS = 120;
33
+ const MAX_NAME_CHARS = 80;
34
+ const MAX_EXCERPT_CHARS = 1500;
35
+ const MAX_PAGE_TEXT_CHARS = 4000;
36
+ const HARD_MAX_STEPS = 40;
37
+ const DEFAULT_MAX_MS = 60000;
38
+ const DEFAULT_MIN_CONFIDENCE = 0.55;
39
+ const HISTORY_STEPS = 8;
40
+ const ACTION_TIMEOUT_MS = 10000;
41
+ /** Grace for a click that kicks off its navigation from JS (SPA routing, deferred assign). */
42
+ const SETTLE_MS = 150;
43
+ const PLAYWRIGHT_HINT = 'npm i -g playwright && npx playwright install chromium';
44
+ const FIXED_ACTIONS = {
45
+ SCROLL_DOWN: 'Scroll further down this page to reveal content that is not visible yet.',
46
+ SCROLL_UP: 'Scroll back up this page to content that has scrolled out of view.',
47
+ BACK: 'Go back to the previous page; this page is a dead end or the wrong turn.',
48
+ WAIT: 'The page is still loading or updating; wait briefly and look again.',
49
+ DONE: 'The goal is fully achieved and visible on this page; nothing further is needed.',
50
+ BLOCKED: 'The goal cannot be reached from here: the path does not exist, the page is an error, or the site refuses.',
51
+ NEEDS_AGENT: 'This step needs login, a captcha, a payment, a file upload, free text, or a judgement only the agent can make.',
52
+ };
53
+ const NEXT_INSTRUCTIONS = 'Given the goal and the page described in the state, which single action moves closest to achieving the goal? Pick exactly one.';
54
+ const GOAL_REACHED = {
55
+ type: 'noul',
56
+ instructions: 'Given the goal and the current page, is the requested end state already visible?',
57
+ criteria: {
58
+ true: 'the page currently shows the end state the goal describes',
59
+ false: 'the end state is not visible yet, or only a link towards it is visible',
60
+ },
61
+ };
62
+ /* ------------------------------------------------------------------- utilities */
63
+ function collapse(text) {
64
+ return text.replace(/\s+/g, ' ').trim();
65
+ }
66
+ function sleep(ms) {
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+ function messageOf(error) {
70
+ return error?.message ?? String(error);
71
+ }
72
+ function hostOf(url) {
73
+ try {
74
+ return new URL(url).hostname.toLowerCase();
75
+ }
76
+ catch {
77
+ return '';
78
+ }
79
+ }
80
+ /** Host-suffix match: `docs.example.com` is inside `example.com`. */
81
+ function hostAllowed(host, allow) {
82
+ if (!host)
83
+ return false;
84
+ return allow.some((raw) => {
85
+ const d = raw.replace(/^\./, '').trim().toLowerCase();
86
+ if (!d)
87
+ return false;
88
+ return host === d || host.endsWith(`.${d}`);
89
+ });
90
+ }
91
+ /**
92
+ * The project's `jev.browse` settings, or the built-in defaults when the config cannot
93
+ * be read. Never throws: browsing degrades to the defaults rather than failing.
94
+ */
95
+ function browseConfig(projectRoot) {
96
+ try {
97
+ return loadJevConfig(projectRoot).browse;
98
+ }
99
+ catch {
100
+ return { allowDomains: [...JEV_DEFAULTS.browse.allowDomains], maxSteps: JEV_DEFAULTS.browse.maxSteps };
101
+ }
102
+ }
103
+ /**
104
+ * Let an action's navigation actually commit before anyone reads `page.url()`.
105
+ *
106
+ * `click()` resolves as soon as the click is dispatched: a `history.pushState` route
107
+ * change or a `fetch().then(() => location.assign(...))` has not happened yet, so the
108
+ * URL still reads as the previous (allowed) page. Sleep first so the handler can start
109
+ * the navigation, then wait for the new document to commit.
110
+ */
111
+ async function settle(page) {
112
+ await sleep(SETTLE_MS);
113
+ try {
114
+ if (typeof page.waitForLoadState === 'function') {
115
+ await page.waitForLoadState('domcontentloaded', { timeout: ACTION_TIMEOUT_MS });
116
+ }
117
+ }
118
+ catch {
119
+ /* no navigation to wait for is the normal case — carry on */
120
+ }
121
+ }
122
+ /** Best-effort retreat from a page we are not allowed to be on. */
123
+ async function retreat(page) {
124
+ try {
125
+ await page.goBack({ waitUntil: 'domcontentloaded', timeout: ACTION_TIMEOUT_MS });
126
+ }
127
+ catch {
128
+ /* best effort — we are leaving anyway */
129
+ }
130
+ }
131
+ /**
132
+ * Why this link must never be offered or followed (null = fine). The label is only half
133
+ * the story: the target decides what a click actually does.
134
+ */
135
+ function linkRefusal(href, base, allowSensitive) {
136
+ const raw = (href ?? '').trim();
137
+ if (!raw)
138
+ return null;
139
+ let target;
140
+ try {
141
+ target = new URL(raw, base || undefined);
142
+ }
143
+ catch {
144
+ return `unreadable link target "${raw.slice(0, 60)}"`;
145
+ }
146
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
147
+ return `link target is not a web page (${target.protocol})`;
148
+ }
149
+ if (allowSensitive)
150
+ return null;
151
+ let where = `${target.pathname}${target.search}`;
152
+ try {
153
+ where = decodeURIComponent(where);
154
+ }
155
+ catch {
156
+ /* keep the encoded form — it is still worth matching */
157
+ }
158
+ if (SENSITIVE.test(where) || SENSITIVE_URL.test(where)) {
159
+ return `link target looks destructive (${where.slice(0, 60)})`;
160
+ }
161
+ return null;
162
+ }
163
+ /** Lazy Playwright: injected → local install → global npm root. Never throws. */
164
+ async function loadPlaywright(injected) {
165
+ const pick = (mod) => {
166
+ const m = mod;
167
+ if (m && typeof m === 'object' && m.chromium)
168
+ return m;
169
+ if (m && typeof m === 'object' && m.default?.chromium)
170
+ return m.default;
171
+ return null;
172
+ };
173
+ const direct = pick(injected);
174
+ if (direct)
175
+ return direct;
176
+ const specs = ['playwright', 'playwright-core'];
177
+ for (const spec of specs) {
178
+ try {
179
+ const found = pick(await import(spec));
180
+ if (found)
181
+ return found;
182
+ }
183
+ catch {
184
+ /* not installed next to us — try the next strategy */
185
+ }
186
+ }
187
+ try {
188
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
189
+ const res = spawnSync(npm, ['root', '-g'], { encoding: 'utf-8', timeout: 15000 });
190
+ const root = String(res.stdout ?? '').trim().split(/\r?\n/).pop() ?? '';
191
+ if (root) {
192
+ const req = createRequire(path.join(root, 'noop.js'));
193
+ for (const spec of specs) {
194
+ try {
195
+ const found = pick(req(spec));
196
+ if (found)
197
+ return found;
198
+ }
199
+ catch {
200
+ /* keep looking */
201
+ }
202
+ }
203
+ }
204
+ }
205
+ catch {
206
+ /* npm missing or sandboxed — fall through to the hint */
207
+ }
208
+ return null;
209
+ }
210
+ /**
211
+ * Runs in the page: describe every interactive element we could act on.
212
+ *
213
+ * With `stamp` it also writes `data-jev-ref="<i>"` on each described element (and clears
214
+ * it from the ones it skips), so the action taken after the Jev round trip is aimed at
215
+ * the element itself instead of at an ordinal the page is free to shift meanwhile.
216
+ */
217
+ function describeElements(els, stamp) {
218
+ const out = [];
219
+ els.forEach((el, i) => {
220
+ const any = el;
221
+ const unstamp = () => {
222
+ if (!stamp)
223
+ return;
224
+ try {
225
+ any.removeAttribute?.('data-jev-ref');
226
+ }
227
+ catch {
228
+ /* nothing we can do from in here */
229
+ }
230
+ };
231
+ let visible = true;
232
+ try {
233
+ const rect = any.getBoundingClientRect();
234
+ const view = any.ownerDocument?.defaultView;
235
+ const style = view?.getComputedStyle ? view.getComputedStyle(el) : null;
236
+ visible =
237
+ rect.width > 0 &&
238
+ rect.height > 0 &&
239
+ (!style || (style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'));
240
+ }
241
+ catch {
242
+ visible = true;
243
+ }
244
+ if (!visible) {
245
+ unstamp();
246
+ return;
247
+ }
248
+ const tag = String(any.tagName ?? '').toLowerCase();
249
+ const type = String(any.getAttribute?.('type') ?? '').toLowerCase();
250
+ const explicitRole = String(any.getAttribute?.('role') ?? '').toLowerCase();
251
+ const role = explicitRole ||
252
+ (tag === 'a' ? 'link' : tag === 'input' ? (type || 'text') + ' input' : tag || 'element');
253
+ const raw = any.getAttribute?.('aria-label') ||
254
+ (typeof any.innerText === 'string' ? any.innerText : '') ||
255
+ any.getAttribute?.('placeholder') ||
256
+ any.getAttribute?.('title') ||
257
+ (typeof any.value === 'string' ? any.value : '') ||
258
+ any.getAttribute?.('name') ||
259
+ '';
260
+ const name = String(raw).replace(/\s+/g, ' ').trim().slice(0, 80);
261
+ if (!name) {
262
+ unstamp();
263
+ return;
264
+ }
265
+ const textish = ['text', 'search', 'email', 'url', 'tel', 'password', 'number', ''];
266
+ const kind = tag === 'textarea' || (tag === 'input' && textish.indexOf(type) >= 0)
267
+ ? 'input'
268
+ : tag === 'select'
269
+ ? 'select'
270
+ : 'clickable';
271
+ const href = tag === 'a' ? String(any.getAttribute?.('href') ?? '') : '';
272
+ if (stamp) {
273
+ try {
274
+ any.setAttribute?.('data-jev-ref', String(i));
275
+ }
276
+ catch {
277
+ /* an element we cannot stamp is one we will refuse to act on later */
278
+ }
279
+ }
280
+ out.push(href ? { i, role, name, href, kind } : { i, role, name, kind });
281
+ });
282
+ return out;
283
+ }
284
+ async function observe(page) {
285
+ const url = page.url();
286
+ let title = '';
287
+ try {
288
+ title = typeof page.title === 'function' ? String(await page.title()) : '';
289
+ }
290
+ catch {
291
+ title = '';
292
+ }
293
+ let bodyText = '';
294
+ try {
295
+ bodyText = String((await page.evaluate(() => (document.body ? document.body.innerText : ''))) ?? '');
296
+ }
297
+ catch {
298
+ bodyText = '';
299
+ }
300
+ const pageText = collapse(bodyText).slice(0, MAX_PAGE_TEXT_CHARS);
301
+ let elements = [];
302
+ try {
303
+ const raw = (await page.locator(INTERACTIVE_SELECTOR).evaluateAll(describeElements, true));
304
+ const seen = new Set();
305
+ for (const el of Array.isArray(raw) ? raw : []) {
306
+ if (!el || typeof el.i !== 'number' || !el.name)
307
+ continue;
308
+ const key = `${el.role}|${el.name.toLowerCase()}`;
309
+ if (seen.has(key))
310
+ continue;
311
+ seen.add(key);
312
+ elements.push({ ...el, name: el.name.slice(0, MAX_NAME_CHARS) });
313
+ if (elements.length >= MAX_ELEMENTS)
314
+ break;
315
+ }
316
+ }
317
+ catch {
318
+ elements = [];
319
+ }
320
+ return { url, title, pageText, textExcerpt: pageText.slice(0, MAX_EXCERPT_CHARS), elements };
321
+ }
322
+ /** Enumerate the answers: one option per thing Jev is allowed to do here. */
323
+ function buildCandidates(elements, inputs, allowSensitive, baseUrl) {
324
+ const candidates = new Map();
325
+ const criteria = {};
326
+ const inputKeys = Object.keys(inputs);
327
+ for (const el of elements) {
328
+ if (!allowSensitive && SENSITIVE.test(el.name))
329
+ continue;
330
+ if (linkRefusal(el.href, baseUrl, allowSensitive))
331
+ continue;
332
+ const key = `a${el.i}`;
333
+ if (el.kind === 'clickable') {
334
+ const label = `Click ${el.role} '${el.name}'`;
335
+ candidates.set(key, { key, label, index: el.i, role: el.role, name: el.name });
336
+ criteria[key] = label;
337
+ }
338
+ else if (el.kind === 'input') {
339
+ const match = inputKeys.find((k) => k.toLowerCase() === el.name.toLowerCase());
340
+ if (!match)
341
+ continue;
342
+ const label = `Type the provided value into '${el.name}'`;
343
+ candidates.set(key, { key, label, index: el.i, input: inputs[match], role: el.role, name: el.name });
344
+ criteria[key] = label;
345
+ }
346
+ }
347
+ for (const [key, description] of Object.entries(FIXED_ACTIONS)) {
348
+ candidates.set(key, { key, label: key });
349
+ criteria[key] = description;
350
+ }
351
+ return { candidates, criteria };
352
+ }
353
+ /**
354
+ * Bind the action to the element, not to its ordinal. Between `observe()` and here sits a
355
+ * full Jev round trip, and anything the page does meanwhile — a lazily loaded row, an ad,
356
+ * a cookie banner, an SPA re-render — shifts the ordinals. We re-read the element by its
357
+ * `data-jev-ref` stamp and refuse when it is not the one Jev was shown, or when it has
358
+ * become a control we are not allowed to touch. Returns a refusal, or null when it is safe.
359
+ */
360
+ async function recheckTarget(page, chosen, allowSensitive, baseUrl) {
361
+ let live = null;
362
+ try {
363
+ const raw = (await page
364
+ .locator(`[${REF_ATTR}="${chosen.index}"]`)
365
+ .evaluateAll(describeElements, false));
366
+ live = Array.isArray(raw) && raw.length > 0 ? raw[0] : null;
367
+ }
368
+ catch {
369
+ return 'could not re-check the chosen element before acting';
370
+ }
371
+ if (!live || !live.name)
372
+ return 'the chosen element is no longer on the page';
373
+ const name = String(live.name).slice(0, MAX_NAME_CHARS);
374
+ if (live.role !== chosen.role || name !== chosen.name) {
375
+ return `the element changed after it was chosen (now ${live.role} '${name}')`;
376
+ }
377
+ if (!allowSensitive && SENSITIVE.test(name))
378
+ return 'the element is now a sensitive control';
379
+ const refusal = linkRefusal(live.href, baseUrl, allowSensitive);
380
+ return refusal ? `refusing to act: ${refusal}` : null;
381
+ }
382
+ function fingerprint(url, elements) {
383
+ return `${url}::${elements.map((e) => `${e.i}${e.role}${e.name}`).join('|')}`;
384
+ }
385
+ async function capture(page, dir, n) {
386
+ if (!dir)
387
+ return null;
388
+ const file = path.join(dir, `step-${n}.png`);
389
+ try {
390
+ await page.screenshot({ path: file, fullPage: false });
391
+ return file;
392
+ }
393
+ catch {
394
+ return null;
395
+ }
396
+ }
397
+ function screenshotDirFor(opts) {
398
+ try {
399
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
400
+ const dir = opts.screenshotDir ??
401
+ path.join(opts.projectRoot ?? process.cwd(), '.specweave', 'state', 'jev-browse', stamp);
402
+ fs.mkdirSync(dir, { recursive: true });
403
+ return dir;
404
+ }
405
+ catch {
406
+ return null;
407
+ }
408
+ }
409
+ function errorResult(error, extra) {
410
+ return {
411
+ status: 'error',
412
+ url: '',
413
+ title: '',
414
+ steps: [],
415
+ pageText: '',
416
+ screenshots: [],
417
+ elapsedMs: 0,
418
+ cost: 0,
419
+ error,
420
+ ...extra,
421
+ };
422
+ }
423
+ /* ----------------------------------------------------------------------- loop */
424
+ /**
425
+ * Run the headless browse loop. Always resolves — failures come back as
426
+ * `status: 'error'` so an agent can keep going without a try/catch.
427
+ */
428
+ export async function runBrowse(opts) {
429
+ const started = Date.now();
430
+ const goal = (opts.goal ?? '').trim();
431
+ if (!goal)
432
+ return errorResult('a goal is required');
433
+ const startUrl = (opts.url ?? '').trim();
434
+ if (!startUrl)
435
+ return errorResult('a start url is required');
436
+ if (!hostOf(startUrl))
437
+ return errorResult(`not a browsable url: ${startUrl}`);
438
+ // Flags win over `jev.browse` in .specweave/config.json, which wins over "this host only".
439
+ const cfgBrowse = browseConfig(opts.projectRoot);
440
+ const allow = opts.allowDomains?.length
441
+ ? opts.allowDomains
442
+ : cfgBrowse.allowDomains.length
443
+ ? cfgBrowse.allowDomains
444
+ : [hostOf(startUrl)];
445
+ // Nothing is fetched, screenshotted or sent to Jev from an origin the operator excluded
446
+ // — including the very first page, which used to be loaded unconditionally.
447
+ if (!hostAllowed(hostOf(startUrl), allow)) {
448
+ return errorResult(`start url is outside the allowed origins (${hostOf(startUrl)} not in ${allow.join(', ')})`, { status: 'blocked', handoff: `${goal} — start url ${startUrl} is not an allowed origin` });
449
+ }
450
+ const client = opts.client ?? createJevClient(opts.projectRoot);
451
+ if (!client)
452
+ return errorResult('jev unavailable');
453
+ const pw = await loadPlaywright(opts.playwright);
454
+ if (!pw)
455
+ return errorResult(`playwright is not installed — ${PLAYWRIGHT_HINT}`);
456
+ const maxSteps = Math.max(1, Math.min(opts.maxSteps ?? cfgBrowse.maxSteps, HARD_MAX_STEPS));
457
+ const maxMs = opts.maxMs ?? DEFAULT_MAX_MS;
458
+ const minConfidence = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
459
+ const inputs = opts.inputs ?? {};
460
+ const allowSensitive = opts.allowSensitive === true;
461
+ const dir = screenshotDirFor(opts);
462
+ const steps = [];
463
+ const screenshots = [];
464
+ const history = [];
465
+ let status = 'step_limit';
466
+ let error;
467
+ let cost = 0;
468
+ let url = startUrl;
469
+ let title = '';
470
+ let pageText = '';
471
+ let prevSignature = '';
472
+ let unconfirmedDone = 0;
473
+ // HEADLESS IS NOT AN OPTION — there is deliberately no way to open a window here.
474
+ let browser;
475
+ try {
476
+ browser = await pw.chromium.launch({ headless: true });
477
+ }
478
+ catch (e) {
479
+ return errorResult(`could not launch headless chromium: ${messageOf(e)} — ${PLAYWRIGHT_HINT}`, {
480
+ elapsedMs: Date.now() - started,
481
+ handoff: `${goal} — browser unavailable`,
482
+ });
483
+ }
484
+ let page = null;
485
+ try {
486
+ page = await browser.newPage();
487
+ await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: ACTION_TIMEOUT_MS * 3 });
488
+ for (let n = 1; n <= maxSteps; n++) {
489
+ if (Date.now() - started > maxMs) {
490
+ status = 'budget';
491
+ break;
492
+ }
493
+ // Backstop for a navigation that committed after the previous step's check (SPA
494
+ // routing, a deferred `location.assign`). Checked BEFORE we observe or screenshot,
495
+ // so an off-origin page is never scraped or sent to Jev.
496
+ const landedUrl = page.url();
497
+ const landedHost = hostOf(landedUrl);
498
+ if (!hostAllowed(landedHost, allow)) {
499
+ steps.push({
500
+ n,
501
+ url: landedUrl,
502
+ action: 'origin check',
503
+ choice: '',
504
+ confidence: 0,
505
+ apiMs: 0,
506
+ executed: false,
507
+ reason: `navigated outside the allowed origins (${landedHost || landedUrl})`,
508
+ });
509
+ await retreat(page);
510
+ url = page.url();
511
+ status = 'blocked';
512
+ break;
513
+ }
514
+ const obs = await observe(page);
515
+ url = obs.url;
516
+ title = obs.title;
517
+ pageText = obs.pageText;
518
+ const shot = await capture(page, dir, n);
519
+ if (shot)
520
+ screenshots.push(shot);
521
+ const { candidates, criteria } = buildCandidates(obs.elements, inputs, allowSensitive, obs.url);
522
+ const state = {
523
+ goal,
524
+ url: obs.url,
525
+ title: obs.title,
526
+ textExcerpt: obs.textExcerpt,
527
+ elements: obs.elements.map((e) => ({ i: e.i, role: e.role, name: e.name, href: e.href ?? null })),
528
+ history: history.slice(-HISTORY_STEPS),
529
+ };
530
+ let answerChoice = '';
531
+ let confidence = 0;
532
+ let goalReached = 0;
533
+ let apiMs = 0;
534
+ try {
535
+ const response = await client.ask(state, { next: { type: 'choice', instructions: NEXT_INSTRUCTIONS, criteria }, goal_reached: GOAL_REACHED }, { kind: 'browse' });
536
+ cost += response.usage.cost ?? 0;
537
+ apiMs = response.latencyMs;
538
+ const next = response.answers.next;
539
+ if (next && next.type === 'choice') {
540
+ answerChoice = next.choice;
541
+ confidence = next.confidence;
542
+ }
543
+ const reached = response.answers.goal_reached;
544
+ goalReached = reached && reached.type === 'noul' ? reached.noul : 0;
545
+ }
546
+ catch (e) {
547
+ status = 'error';
548
+ error = messageOf(e);
549
+ steps.push({ n, url: obs.url, action: 'ask', choice: '', confidence: 0, apiMs: 0, executed: false, reason: error });
550
+ break;
551
+ }
552
+ const chosen = candidates.get(answerChoice);
553
+ const action = chosen?.label ?? answerChoice ?? 'unknown';
554
+ const step = { n, url: obs.url, action, choice: answerChoice, confidence, apiMs, executed: false };
555
+ if (!chosen) {
556
+ step.reason = `Jev returned an unknown option "${answerChoice}"`;
557
+ steps.push(step);
558
+ status = 'blocked';
559
+ break;
560
+ }
561
+ if (confidence < minConfidence) {
562
+ step.reason = `confidence ${confidence.toFixed(2)} below minimum ${minConfidence} (goal_reached ${goalReached.toFixed(2)})`;
563
+ steps.push(step);
564
+ status = 'low_confidence';
565
+ break;
566
+ }
567
+ const signature = `${answerChoice}::${fingerprint(obs.url, obs.elements)}`;
568
+ switch (answerChoice) {
569
+ case 'DONE': {
570
+ if (goalReached >= 0.8) {
571
+ step.executed = true;
572
+ step.reason = `goal visible (${goalReached.toFixed(2)})`;
573
+ steps.push(step);
574
+ status = 'done';
575
+ }
576
+ else if (unconfirmedDone === 0) {
577
+ unconfirmedDone++;
578
+ step.reason = `done claimed but the goal is not visible (${goalReached.toFixed(2)}) — looking again`;
579
+ steps.push(step);
580
+ history.push({ action, outcome: 'done not confirmed' });
581
+ prevSignature = signature;
582
+ continue;
583
+ }
584
+ else {
585
+ step.reason = 'done claimed twice without the goal being visible';
586
+ steps.push(step);
587
+ status = 'blocked';
588
+ }
589
+ break;
590
+ }
591
+ case 'BLOCKED': {
592
+ step.reason = 'Jev reports the goal is unreachable from here';
593
+ steps.push(step);
594
+ status = 'blocked';
595
+ break;
596
+ }
597
+ case 'NEEDS_AGENT': {
598
+ step.reason = 'the page needs a human or agent judgement (login, captcha, payment, free text)';
599
+ steps.push(step);
600
+ status = 'needs_agent';
601
+ break;
602
+ }
603
+ default: {
604
+ if (signature === prevSignature) {
605
+ step.reason = 'no progress: the same action on an unchanged page';
606
+ steps.push(step);
607
+ status = 'blocked';
608
+ break;
609
+ }
610
+ prevSignature = signature;
611
+ try {
612
+ if (answerChoice === 'SCROLL_DOWN') {
613
+ await page.evaluate((d) => window.scrollBy(0, Math.round(window.innerHeight * 0.9) * d), 1);
614
+ }
615
+ else if (answerChoice === 'SCROLL_UP') {
616
+ await page.evaluate((d) => window.scrollBy(0, Math.round(window.innerHeight * 0.9) * d), -1);
617
+ }
618
+ else if (answerChoice === 'BACK') {
619
+ await page.goBack({ waitUntil: 'domcontentloaded', timeout: ACTION_TIMEOUT_MS });
620
+ }
621
+ else if (answerChoice === 'WAIT') {
622
+ await sleep(1000);
623
+ }
624
+ else if (chosen.index !== undefined) {
625
+ // Act on the element itself, never on its ordinal: `observe()` stamped it,
626
+ // and this re-check refuses the step if the page swapped it meanwhile.
627
+ const stale = await recheckTarget(page, chosen, allowSensitive, obs.url);
628
+ if (stale) {
629
+ step.reason = stale;
630
+ steps.push(step);
631
+ history.push({ action, outcome: 'skipped: the page changed under the choice' });
632
+ continue;
633
+ }
634
+ const target = page.locator(`[${REF_ATTR}="${chosen.index}"]`).nth(0);
635
+ if (chosen.input !== undefined) {
636
+ await target.fill(chosen.input, { timeout: ACTION_TIMEOUT_MS });
637
+ }
638
+ else {
639
+ await target.click({ timeout: ACTION_TIMEOUT_MS });
640
+ }
641
+ }
642
+ step.executed = true;
643
+ }
644
+ catch (e) {
645
+ step.reason = `action failed: ${messageOf(e)}`;
646
+ steps.push(step);
647
+ history.push({ action, outcome: 'action failed' });
648
+ continue;
649
+ }
650
+ // Wait for any navigation the action started to commit — `page.url()` read
651
+ // straight after `click()` still reports the previous, allowed page.
652
+ await settle(page);
653
+ const landed = page.url();
654
+ if (!hostAllowed(hostOf(landed), allow)) {
655
+ step.reason = `navigated outside the allowed origins (${hostOf(landed) || landed})`;
656
+ steps.push(step);
657
+ await retreat(page);
658
+ url = page.url();
659
+ status = 'blocked';
660
+ break;
661
+ }
662
+ steps.push(step);
663
+ history.push({ action, outcome: `now at ${landed}` });
664
+ url = landed;
665
+ continue;
666
+ }
667
+ }
668
+ break;
669
+ }
670
+ if (status !== 'error') {
671
+ try {
672
+ // Only re-read the page when we are still somewhere we are allowed to be: the
673
+ // final observation ends up in `pageText`, and that must never be off-origin.
674
+ if (hostAllowed(hostOf(page.url()), allow)) {
675
+ const final = await observe(page);
676
+ url = final.url;
677
+ title = final.title;
678
+ pageText = final.pageText;
679
+ }
680
+ }
681
+ catch {
682
+ /* keep the last known observation */
683
+ }
684
+ }
685
+ }
686
+ catch (e) {
687
+ status = 'error';
688
+ error = messageOf(e);
689
+ }
690
+ finally {
691
+ try {
692
+ if (page?.close)
693
+ await page.close();
694
+ }
695
+ catch {
696
+ /* ignore */
697
+ }
698
+ try {
699
+ await browser.close();
700
+ }
701
+ catch {
702
+ /* ignore */
703
+ }
704
+ }
705
+ const result = {
706
+ status,
707
+ url,
708
+ title,
709
+ steps,
710
+ pageText,
711
+ screenshots,
712
+ elapsedMs: Date.now() - started,
713
+ cost,
714
+ };
715
+ if (error)
716
+ result.error = error;
717
+ if (status !== 'done')
718
+ result.handoff = `${goal} — unfinished at ${url} (${status})`;
719
+ return result;
720
+ }
721
+ //# sourceMappingURL=browse.js.map