sitelooper 0.3.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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +625 -0
  3. package/bin/sitelooper.js +6 -0
  4. package/dist/agent/llm.js +460 -0
  5. package/dist/agent/llm.js.map +1 -0
  6. package/dist/agent/loop.js +870 -0
  7. package/dist/agent/loop.js.map +1 -0
  8. package/dist/agent/prompt.js +40 -0
  9. package/dist/agent/prompt.js.map +1 -0
  10. package/dist/agent/report.js +545 -0
  11. package/dist/agent/report.js.map +1 -0
  12. package/dist/agent/tools.js +1147 -0
  13. package/dist/agent/tools.js.map +1 -0
  14. package/dist/cli.js +1692 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/daemon/browser.js +218 -0
  17. package/dist/daemon/browser.js.map +1 -0
  18. package/dist/daemon/codegen.js +241 -0
  19. package/dist/daemon/codegen.js.map +1 -0
  20. package/dist/daemon/dialogs.js +57 -0
  21. package/dist/daemon/dialogs.js.map +1 -0
  22. package/dist/daemon/diff.js +198 -0
  23. package/dist/daemon/diff.js.map +1 -0
  24. package/dist/daemon/fingerprint.js +98 -0
  25. package/dist/daemon/fingerprint.js.map +1 -0
  26. package/dist/daemon/inputs.js +134 -0
  27. package/dist/daemon/inputs.js.map +1 -0
  28. package/dist/daemon/recorder.js +1232 -0
  29. package/dist/daemon/recorder.js.map +1 -0
  30. package/dist/daemon/refs.js +194 -0
  31. package/dist/daemon/refs.js.map +1 -0
  32. package/dist/daemon/server.js +1724 -0
  33. package/dist/daemon/server.js.map +1 -0
  34. package/dist/daemon/state.js +239 -0
  35. package/dist/daemon/state.js.map +1 -0
  36. package/dist/doctor.js +90 -0
  37. package/dist/doctor.js.map +1 -0
  38. package/dist/shared/paths.js +80 -0
  39. package/dist/shared/paths.js.map +1 -0
  40. package/dist/shared/protocol.js +28 -0
  41. package/dist/shared/protocol.js.map +1 -0
  42. package/dist/shared/secrets.js +92 -0
  43. package/dist/shared/secrets.js.map +1 -0
  44. package/dist/shared/text.js +39 -0
  45. package/dist/shared/text.js.map +1 -0
  46. package/dist/skills/compile.js +1420 -0
  47. package/dist/skills/compile.js.map +1 -0
  48. package/dist/skills/components.js +456 -0
  49. package/dist/skills/components.js.map +1 -0
  50. package/dist/skills/flow.js +1041 -0
  51. package/dist/skills/flow.js.map +1 -0
  52. package/dist/skills/learn.js +406 -0
  53. package/dist/skills/learn.js.map +1 -0
  54. package/dist/skills/ledger.js +304 -0
  55. package/dist/skills/ledger.js.map +1 -0
  56. package/dist/skills/relabel.js +206 -0
  57. package/dist/skills/relabel.js.map +1 -0
  58. package/dist/skills/repair.js +570 -0
  59. package/dist/skills/repair.js.map +1 -0
  60. package/dist/skills/replay.js +1281 -0
  61. package/dist/skills/replay.js.map +1 -0
  62. package/dist/skills/store.js +147 -0
  63. package/dist/skills/store.js.map +1 -0
  64. package/dist/spec/check.js +428 -0
  65. package/dist/spec/check.js.map +1 -0
  66. package/dist/spec/diagnostics.js +58 -0
  67. package/dist/spec/diagnostics.js.map +1 -0
  68. package/dist/spec/emit.js +2084 -0
  69. package/dist/spec/emit.js.map +1 -0
  70. package/dist/spec/index.js +62 -0
  71. package/dist/spec/index.js.map +1 -0
  72. package/dist/spec/ir.js +216 -0
  73. package/dist/spec/ir.js.map +1 -0
  74. package/dist/spec/lift.js +162 -0
  75. package/dist/spec/lift.js.map +1 -0
  76. package/dist/spec/locators.js +270 -0
  77. package/dist/spec/locators.js.map +1 -0
  78. package/dist/spec/lower.js +124 -0
  79. package/dist/spec/lower.js.map +1 -0
  80. package/dist/spec/repair.js +657 -0
  81. package/dist/spec/repair.js.map +1 -0
  82. package/dist/spec/rerecord.js +169 -0
  83. package/dist/spec/rerecord.js.map +1 -0
  84. package/dist/spec/rethread.js +120 -0
  85. package/dist/spec/rethread.js.map +1 -0
  86. package/package.json +50 -0
  87. package/skills/sitelooper/SKILL.md +228 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1692 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import { clip } from './shared/text.js';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { AnthropicProvider, OpenAICompatProvider, globalConfigPath, resolveProviderConfig, writeGlobalConfig } from './agent/llm.js';
8
+ import { encodeFrame, LineDecoder } from './shared/protocol.js';
9
+ import { aliasLegacyEnv, sessionsDir, socketPath, validateSessionName } from './shared/paths.js';
10
+ import { candidateExpr } from './daemon/recorder.js';
11
+ import { fillParams } from './skills/compile.js';
12
+ import { SkillStore, skillsDir, successRate } from './skills/store.js';
13
+ import { listFlows, loadFlow, loadFlowFile, saveFlow } from './skills/flow.js';
14
+ import { drainDrift, llmProposer, triage } from './skills/repair.js';
15
+ import { compileFlow } from './spec/index.js';
16
+ import { foldTicketEvidence, mintVars, notConverged, reorderByEvidence } from './spec/repair.js';
17
+ import { emitFlowFile } from './spec/emit.js';
18
+ import { LiftError, liftFlowFile } from './spec/lift.js';
19
+ import { diffSpecChanges, foldPatchedVariants, reloadStaged, rerecordDiagnostics, stageRepair } from './spec/repair.js';
20
+ import { diagnosticLine, formatDiagnostic } from './spec/diagnostics.js';
21
+ import { runSpecCheck } from './spec/check.js';
22
+ import { backupFlowFile, formatRerecordDiagnostic, RerecordError, rerecordVerdict, stepLine, stepNote, stepOf, unpinStep, } from './spec/rerecord.js';
23
+ import os from 'node:os';
24
+ const USAGE = `sitelooper — agent-in-the-loop Playwright CLI
25
+
26
+ Usage:
27
+ sitelooper do "<instruction>" [--json] [--max-turns N] [--timeout S] [--turn-timeout S] [--provider P] [--model M]
28
+ [--fallback-model M | --no-escalate]
29
+ sitelooper open <url>
30
+ sitelooper brief <file.md> [--append]
31
+ sitelooper note "<text>"
32
+ sitelooper reset # clear the LLM conversation only (browser/cookies/briefing/notes kept)
33
+ sitelooper peek [--selector <sel>] [--interactive]
34
+ sitelooper script [out.spec.ts] [--title T] [--clear] # emit a Playwright spec from the recorded actions
35
+ sitelooper compile <flow-name-or-path> [--out <dir>] [--force] [--json]
36
+ # compile a converged flow to a standalone Playwright
37
+ # spec (Tier 2, no sitelooper runtime) — no daemon needed.
38
+ # Problems are printed first as diagnostics (what / why /
39
+ # fix). A step pinned to a DEMOTED skill is an error: its
40
+ # recording, not the app, is what is wrong, so nothing is
41
+ # written — re-record the step, or pass --force.
42
+ # --force: overwrite an existing .spec.ts AND compile a
43
+ # demoted pin anyway (the emitted file then carries the
44
+ # diagnostic above the step and in its failure message).
45
+ sitelooper skills list [--origin <origin>] # stored procedures (learning mode; no daemon needed)
46
+ sitelooper skills show <id>
47
+ sitelooper skills rm <id>
48
+ sitelooper skills clear --origin <origin> | --all
49
+ sitelooper skills repair --drift <run-drift.json> [--dry-run] [--model M]
50
+ # post-session repair of a drift sidecar, in a COLD browser
51
+ # (signed into nothing). Prefer "sitelooper repair" below,
52
+ # which drains the same tickets on the live, signed-in page.
53
+ sitelooper repair <name.flow.ts> [--var k=v ...] [--out <file>] [--converge <n>]
54
+ [--reset-cmd "<shell command>"] [--check-spec] [--dry-run]
55
+ [--model M] [--json]
56
+ # self-updating spec: replay a compiled flow file against
57
+ # the live app in an ISOLATED temp store, let the recovery
58
+ # ladder adapt it, fold the adaptation back into the owned
59
+ # .flow.ts and re-emit it. Never touches the .spec.ts.
60
+ # --converge n re-runs the repaired flow n more times
61
+ # (default 1) and refuses to write unless every step is a
62
+ # clean tier-A replay with no drift. Each of those runs is
63
+ # a REAL run against the app: give a record-creating flow
64
+ # a per-run name with {n} (--var runid=fix-{n} becomes
65
+ # fix-0, fix-1, ...), or reset the app between runs with
66
+ # --reset-cmd, which runs a shell command before run 1 and
67
+ # before every converge run and aborts if it exits non-zero
68
+ # (--reset-cmd "curl -s -X POST http://localhost:3000/__reset").
69
+ # --check-spec runs the sibling .spec.ts ONCE under plain
70
+ # @playwright/test after the file is written, because repair
71
+ # replays the IR through the daemon and so cannot see an
72
+ # EMITTER defect. A failed check does not un-write the file
73
+ # (the diff is still yours) but exits 4.
74
+ sitelooper rerecord <flow-name-or-path> <step-id> [--instruction "<text>"] [--var k=v ...]
75
+ [--runs n] [--reset-cmd "<cmd>"] [--json]
76
+ # re-record ONE step of a saved flow, when the step's
77
+ # recording is what is wrong (its pinned procedure is
78
+ # demoted, or the step only passes because the engine
79
+ # replays some other skill). Backs the flow file up as
80
+ # <file>.bak-<stamp>.json, throws that step's pin, params
81
+ # and recorded values away — keeping its outputs — and
82
+ # replays the flow --runs times (default 2) in learning
83
+ # mode, so the agent records the step afresh and the
84
+ # store's own re-pin rule decides whether to keep it.
85
+ # Succeeds only when the LAST run replays the step at
86
+ # tier A with the newly pinned procedure; otherwise it
87
+ # prints why and exits 1. Each run is a REAL run against
88
+ # the app: mint per-run values with {n} (--var
89
+ # runid=fix-{n} becomes fix-0, fix-1, ...) or reset the
90
+ # app with --reset-cmd, which runs before every run.
91
+ # --instruction replaces the step's instruction first,
92
+ # which is the fix when the recorded instruction asked
93
+ # for something the app is no longer in a state to do.
94
+ sitelooper check <name.flow.ts> [--var k=v ...] [--reset-cmd "<cmd>"] [--json]
95
+ # run the sibling .spec.ts once under plain @playwright/test
96
+ # (minimal config, headless, one worker, 60 s per test) and
97
+ # report pass/fail, the nearest @step to the failure, and any
98
+ # [sitelooper drift] lines. No daemon, no model. Exit 4 when
99
+ # the spec fails; skipped (exit 0) when @playwright/test
100
+ # cannot be resolved from the project.
101
+ sitelooper var <name>=<value> # EXPERIMENTAL: declare a run variable (becomes {{name}} in a flow)
102
+ sitelooper flow list | show <name> # EXPERIMENTAL: saved flows (recorded sessions you can replay with run)
103
+ sitelooper run <flow> [--var k=v ...] # EXPERIMENTAL: replay a saved flow, repairing drifted steps
104
+ sitelooper screenshot [path]
105
+ sitelooper session list
106
+ sitelooper stop [--all] [--save-flow <name>]
107
+ sitelooper doctor # diagnose an install: node, browser, provider, key (no daemon needed)
108
+ sitelooper config # show resolved provider/model/paths
109
+ sitelooper config set <key> <value> # persist a default (provider, model, fallbackModel, baseUrl, apiKey)
110
+
111
+ Sizing an instruction:
112
+ One \`do\` = one logical, verifiable step: a goal plus the check that it worked
113
+ ("create a project named X, fill any required fields, submit, and report the row
114
+ that appears"). Several UI actions inside one instruction is normal — that is the
115
+ point of the tool.
116
+ Too big: several unrelated goals or assertions in one string. The agent stalls on
117
+ planning and burns --max-turns. If a result comes back "blocked", split
118
+ it and retry the halves.
119
+ Too small: one click, one fill, one read. You pay for a whole agent loop to do what
120
+ \`peek\` gives you for free.
121
+ Do not drive the page by repeated \`peek\`/\`config\` polling. \`peek\` is for orienting
122
+ ONCE when a \`do\` reports something you did not expect. If you are about to issue the
123
+ same read a second time, issue a \`do\` instead.
124
+
125
+ Escalation:
126
+ When the routine model reports an instruction "blocked", it is retried once on a
127
+ stronger fallback model, on the same live browser and history (told to verify state
128
+ before repeating anything). Verified "failure" results are NOT retried. Disable with
129
+ --no-escalate, or set the fallback model to "none".
130
+
131
+ Learning (progressive automation):
132
+ Start a session with --learn (or SITELOOPER_SKILLS=1) and every instruction that
133
+ reports success is compiled into a stored procedure: its actions, durable locators
134
+ with fallbacks, the values it typed turned into parameters, and what each step
135
+ changed. On later instructions the procedures that start on the current page are
136
+ offered to the internal agent, which replays one deterministically (run_skill) and
137
+ only reasons when a step no longer works — the repair is stored as a variant. A
138
+ validated procedure whose template matches an instruction word for word is replayed
139
+ with no model call at all. Procedures live under ~/.sitelooper/skills/<origin>.json
140
+ (override with SITELOOPER_SKILLS_DIR); inspect with "sitelooper skills".
141
+
142
+ Global flags:
143
+ --session <name> session name (default "default"; one daemon+browser per session)
144
+ --verbose stream the internal agent's actions + token accounting while it works
145
+ --progress stream the agent's actions to stderr (composes with --json)
146
+ --headed launch the browser with a visible window (first call only)
147
+ --record record the session to webm, one file per tab; paths are printed
148
+ on stop, which is when Playwright writes them (first call only)
149
+ --script record every action as a replayable Playwright step (first call
150
+ only); write the spec out later with "sitelooper script"
151
+ --learn learning mode: compile successful instructions into stored
152
+ procedures and replay them on later instructions (first call only)
153
+ --json machine-readable output
154
+
155
+ Providers (presets; each field overridable by flag > env > config file):
156
+ zhipu (default) glm-5.2 @ api.z.ai key: GLM_API_KEY / ZHIPU_API_KEY
157
+ novita deepseek/deepseek-v4-flash @ novita.ai key: NOVITA_API_KEY
158
+ escalates to zai-org/glm-5.3 when blocked
159
+ openrouter z-ai/glm-5.2 @ openrouter.ai key: OPENROUTER_API_KEY
160
+ openai gpt-5-mini @ api.openai.com key: OPENAI_API_KEY
161
+ anthropic claude-sonnet-5 @ api.anthropic.com (native Messages API, not
162
+ OpenAI-compatible — its own adapter) key: ANTHROPIC_API_KEY
163
+
164
+ Environment:
165
+ SITELOOPER_PROVIDER provider preset name
166
+ SITELOOPER_MODEL model id override
167
+ SITELOOPER_FALLBACK_MODEL escalation model for blocked instructions ("none" disables)
168
+ SITELOOPER_BASE_URL any OpenAI-compatible base URL
169
+ SITELOOPER_API_KEY API key (works with any provider)
170
+ Secrets: write {{env:NAME}} in an instruction/briefing instead of a plaintext credential.
171
+ It resolves from the DAEMON's environment at the moment a tool runs — the model, transcript,
172
+ skills, and flows only ever carry the marker. Export NAME before the session's first call.
173
+ SITELOOPER_CHANNEL browser channel (default chrome, falls back to msedge)
174
+ SITELOOPER_HEADED=1 headed browser
175
+ SITELOOPER_RECORD=1 record session video to <session dir>/video
176
+ SITELOOPER_SCRIPT=1 record actions as a Playwright script (see the script command)
177
+ SITELOOPER_SKILLS=1 learning mode (see --learn); SITELOOPER_SKILLS_DIR relocates the store
178
+
179
+ Exit codes: 0 instruction succeeded · 1 failed/blocked · 2 infra error · 3 repair did not converge
180
+ · 4 the emitted .spec.ts failed its --check-spec run (the .flow.ts was still written)`;
181
+ function parseArgv(argv) {
182
+ const positional = [];
183
+ const flags = new Map();
184
+ const valueFlags = new Set([
185
+ 'session',
186
+ 'max-turns',
187
+ 'timeout',
188
+ 'turn-timeout',
189
+ 'provider',
190
+ 'model',
191
+ 'fallback-model',
192
+ 'base-url',
193
+ 'selector',
194
+ 'title',
195
+ 'origin',
196
+ 'save-flow',
197
+ 'recovery-model',
198
+ 'drift',
199
+ 'var',
200
+ 'out',
201
+ 'converge',
202
+ 'reset-cmd',
203
+ 'instruction',
204
+ 'runs',
205
+ ]);
206
+ /**
207
+ * Every flag that takes no value. Unknown options are rejected rather than
208
+ * assumed boolean: an unrecognised `--url http://…` used to set a phantom
209
+ * boolean and drop the URL into the positionals, where `do` appended it to
210
+ * the instruction. The run still worked, so nothing looked wrong — but the
211
+ * compiled skill's template carried the URL and a slot for it, and no later
212
+ * instruction could bind that template. A typo silently changing what the
213
+ * agent was asked to do is not a defensible default for a tool whose
214
+ * results are meant to be reproducible.
215
+ */
216
+ const booleanFlags = new Set([
217
+ 'all',
218
+ 'append',
219
+ 'clear',
220
+ 'dry-run',
221
+ 'force',
222
+ 'full-page',
223
+ 'headed',
224
+ 'help',
225
+ 'interactive',
226
+ 'check-spec',
227
+ 'json',
228
+ 'learn',
229
+ 'no-escalate',
230
+ 'progress',
231
+ 'record',
232
+ 'script',
233
+ 'verbose',
234
+ 'version',
235
+ ]);
236
+ for (let i = 0; i < argv.length; i++) {
237
+ const arg = argv[i];
238
+ if (arg.startsWith('--')) {
239
+ const name = arg.slice(2);
240
+ if (valueFlags.has(name)) {
241
+ flags.set(name, argv[++i] ?? '');
242
+ }
243
+ else if (booleanFlags.has(name)) {
244
+ flags.set(name, true);
245
+ }
246
+ else {
247
+ const known = [...valueFlags, ...booleanFlags].sort();
248
+ throw new Error(`unknown option "--${name}". Known options: ${known.map((f) => `--${f}`).join(' ')}`);
249
+ }
250
+ }
251
+ else {
252
+ positional.push(arg);
253
+ }
254
+ }
255
+ const command = positional.shift() ?? '';
256
+ return { command, positional, flags };
257
+ }
258
+ // --- daemon connection ---
259
+ function connect(sock, timeoutMs = 1000) {
260
+ return new Promise((resolve, reject) => {
261
+ const conn = net.connect(sock);
262
+ const timer = setTimeout(() => {
263
+ conn.destroy();
264
+ reject(new Error('connect timeout'));
265
+ }, timeoutMs);
266
+ conn.once('connect', () => {
267
+ clearTimeout(timer);
268
+ resolve(conn);
269
+ });
270
+ conn.once('error', (err) => {
271
+ clearTimeout(timer);
272
+ reject(err);
273
+ });
274
+ });
275
+ }
276
+ /** Connect and prove the daemon is alive with a ping round-trip (a pipe can
277
+ * still accept connections while its daemon is shutting down). */
278
+ async function connectValidated(sock) {
279
+ const conn = await connect(sock);
280
+ try {
281
+ await request(conn, 'ping', {}, undefined, 5_000);
282
+ return conn;
283
+ }
284
+ catch (err) {
285
+ conn.destroy();
286
+ throw err;
287
+ }
288
+ }
289
+ async function connectOrSpawn(session, opts) {
290
+ const sock = socketPath(session);
291
+ try {
292
+ return await connectValidated(sock);
293
+ }
294
+ catch {
295
+ // not running — spawn the daemon detached and wait for the pipe
296
+ }
297
+ const serverPath = fileURLToPath(new URL('./daemon/server.js', import.meta.url));
298
+ const args = [serverPath, '--session', session];
299
+ if (opts.headed)
300
+ args.push('--headed');
301
+ if (opts.record)
302
+ args.push('--record');
303
+ if (opts.script)
304
+ args.push('--script');
305
+ if (opts.learn)
306
+ args.push('--learn');
307
+ const child = spawn(process.execPath, args, {
308
+ detached: true,
309
+ stdio: 'ignore',
310
+ windowsHide: true,
311
+ env: process.env,
312
+ });
313
+ child.unref();
314
+ const deadline = Date.now() + 15_000;
315
+ let lastErr;
316
+ while (Date.now() < deadline) {
317
+ try {
318
+ return await connectValidated(sock);
319
+ }
320
+ catch (err) {
321
+ lastErr = err;
322
+ await new Promise((r) => setTimeout(r, 200));
323
+ }
324
+ }
325
+ throw new Error(`daemon did not come up for session "${session}": ${lastErr?.message}`);
326
+ }
327
+ /**
328
+ * `timeoutMs` guards the control commands (ping/stop): a daemon that is wedged
329
+ * — rather than merely busy — must not hang the CLI indefinitely. `do` passes
330
+ * no timeout; the daemon enforces its own instruction deadline.
331
+ */
332
+ function request(conn, command, args, onProgress, timeoutMs) {
333
+ return new Promise((resolve, reject) => {
334
+ const req = { id: Date.now() % 1_000_000, command, args };
335
+ const decoder = new LineDecoder();
336
+ const timer = timeoutMs
337
+ ? setTimeout(() => {
338
+ cleanup();
339
+ reject(new Error(`${command} timed out after ${timeoutMs}ms — daemon not responding`));
340
+ }, timeoutMs)
341
+ : undefined;
342
+ const cleanup = () => {
343
+ clearTimeout(timer);
344
+ conn.removeListener('data', onData);
345
+ conn.removeListener('error', onError);
346
+ conn.removeListener('close', onClose);
347
+ };
348
+ const onData = (chunk) => {
349
+ let frames;
350
+ try {
351
+ frames = decoder.push(chunk);
352
+ }
353
+ catch (err) {
354
+ cleanup();
355
+ return reject(err);
356
+ }
357
+ for (const frame of frames) {
358
+ if (frame.type === 'progress')
359
+ onProgress?.(frame.message);
360
+ else {
361
+ cleanup();
362
+ resolve(frame);
363
+ }
364
+ }
365
+ };
366
+ const onError = (err) => {
367
+ cleanup();
368
+ reject(err);
369
+ };
370
+ const onClose = () => {
371
+ cleanup();
372
+ reject(new Error('connection closed before result'));
373
+ };
374
+ conn.on('data', onData);
375
+ conn.on('error', onError);
376
+ conn.on('close', onClose);
377
+ conn.write(encodeFrame(req));
378
+ });
379
+ }
380
+ // --- output helpers ---
381
+ function fail(message, code = 2) {
382
+ console.error(`sitelooper: ${message}`);
383
+ process.exit(code);
384
+ }
385
+ function printResult(res, json) {
386
+ if (!res.ok)
387
+ fail(res.error ?? 'unknown error', res.errorKind === 'infra' ? 2 : 1);
388
+ if (json)
389
+ console.log(JSON.stringify(res.data, null, 2));
390
+ return res.data;
391
+ }
392
+ // --- main ---
393
+ async function main() {
394
+ aliasLegacyEnv(); // honor legacy BROWSER_PILOT_* env vars — see paths.ts
395
+ const { command, positional, flags } = parseArgv(process.argv.slice(2));
396
+ if (!command || flags.has('help') || command === 'help') {
397
+ console.log(USAGE);
398
+ process.exit(command ? 0 : 2);
399
+ }
400
+ const session = validateSessionName(String(flags.get('session') ?? 'default'));
401
+ const json = flags.has('json');
402
+ const verbose = flags.has('verbose');
403
+ // --progress streams the agent's actions to stderr without the full --verbose
404
+ // token accounting, so it composes with --json (JSON stays clean on stdout).
405
+ const onProgress = verbose || flags.has('progress') ? (m) => console.error(` · ${m}`) : undefined;
406
+ // Commands that don't need (or must not start) a daemon:
407
+ if (command === 'config' && positional[0] === 'set') {
408
+ const [, key, value] = positional;
409
+ if (!key || value === undefined)
410
+ fail('usage: config set <provider|model|fallbackModel|baseUrl|apiKey> <value>', 2);
411
+ const merged = writeGlobalConfig({ [key]: value });
412
+ const shown = { ...merged, ...(merged.apiKey ? { apiKey: '***' } : {}) };
413
+ console.log(`${globalConfigPath()}: ${JSON.stringify(shown)}`);
414
+ console.log('applies to the next instruction — running daemons re-read this file per call');
415
+ return;
416
+ }
417
+ if (command === 'doctor') {
418
+ const { runDoctor } = await import('./doctor.js');
419
+ process.exit(await runDoctor(json));
420
+ }
421
+ if (command === 'skills' && positional[0] === 'repair') {
422
+ await repairCommand(positional, flags, json);
423
+ return;
424
+ }
425
+ if (command === 'skills') {
426
+ skillsCommand(positional, flags, json);
427
+ return;
428
+ }
429
+ if (command === 'flow' && positional[0] !== undefined && positional[0] !== 'run') {
430
+ flowCommand(positional, json);
431
+ return;
432
+ }
433
+ if (command === 'compile') {
434
+ await compileCommand(positional, flags, json);
435
+ return;
436
+ }
437
+ if (command === 'repair') {
438
+ await repairFlowCommand(positional, flags, json, onProgress);
439
+ return;
440
+ }
441
+ if (command === 'check') {
442
+ checkSpecCommand(positional, flags, json, onProgress);
443
+ return;
444
+ }
445
+ if (command === 'rerecord') {
446
+ await rerecordFlowCommand(positional, flags, json, onProgress);
447
+ return;
448
+ }
449
+ if (command === 'session') {
450
+ if (positional[0] !== 'list')
451
+ fail(`unknown subcommand "session ${positional[0] ?? ''}" (try: session list)`);
452
+ await listSessions(json);
453
+ return;
454
+ }
455
+ if (command === 'stop') {
456
+ const names = flags.has('all') ? allSessionNames() : [session];
457
+ for (const name of names) {
458
+ let conn;
459
+ try {
460
+ conn = await connect(socketPath(name));
461
+ }
462
+ catch {
463
+ if (!flags.has('all'))
464
+ console.log(`not running: ${name}`);
465
+ continue;
466
+ }
467
+ try {
468
+ // Generous: the daemon aborts any in-flight instruction and lets it
469
+ // unwind before closing the browser. Reachable-but-unresponsive is a
470
+ // real failure worth reporting, not a silent "not running".
471
+ //
472
+ // A --save-flow stop is doing real work, not just unwinding: flow
473
+ // export includes the post-session relabel (an LLM call, time-boxed
474
+ // daemon-side), read-back pinning and the flow lint. fwod26 hit the
475
+ // old shared 20s budget mid-export — the client gave up, the sweep
476
+ // read "flow was never saved" and SKIPPED both replays, while the
477
+ // detached daemon finished writing a perfectly good flow seconds
478
+ // later. Reachable-and-working must be allowed to finish.
479
+ // 150s: the relabel pass inside stop may ride out a full OpenRouter
480
+ // rate-limit wait (its own 100s timebox) and the export still needs room.
481
+ const stopTimeout = flags.get('save-flow') ? 150_000 : 20_000;
482
+ const res = await request(conn, 'stop', { saveFlow: flags.get('save-flow') || undefined }, undefined, stopTimeout);
483
+ const data = res.data;
484
+ console.log(`stopped: ${name}${data?.preempted ? ' (interrupted a running instruction)' : ''}`);
485
+ for (const video of data?.videos ?? [])
486
+ console.log(` video: ${video}`);
487
+ if (data?.flow?.error)
488
+ console.error(` flow not saved: ${data.flow.error}`);
489
+ else if (data?.flow?.path) {
490
+ console.log(` flow "${data.flow.name}" saved: ${data.flow.steps} step(s)${data.flow.vars?.length ? `, vars ${data.flow.vars.join(', ')}` : ''} → ${data.flow.path}`);
491
+ for (const w of data.flow.warnings ?? [])
492
+ console.error(` warning: ${w}`);
493
+ }
494
+ }
495
+ catch (err) {
496
+ console.error(`sitelooper: could not stop ${name}: ${err.message}`);
497
+ }
498
+ finally {
499
+ conn.destroy();
500
+ }
501
+ }
502
+ return;
503
+ }
504
+ const conn = await connectOrSpawn(session, {
505
+ headed: flags.has('headed'),
506
+ record: flags.has('record'),
507
+ script: flags.has('script'),
508
+ learn: flags.has('learn'),
509
+ }).catch((err) => fail(err.message));
510
+ try {
511
+ switch (command) {
512
+ case 'do': {
513
+ const instruction = positional.join(' ').trim();
514
+ if (!instruction)
515
+ fail('do requires an instruction', 2);
516
+ const res = await request(conn, 'do', {
517
+ instruction,
518
+ maxTurns: flags.has('max-turns') ? Number(flags.get('max-turns')) : undefined,
519
+ timeoutS: flags.has('timeout') ? Number(flags.get('timeout')) : undefined,
520
+ turnTimeoutS: flags.has('turn-timeout') ? Number(flags.get('turn-timeout')) : undefined,
521
+ provider: flags.get('provider') || undefined,
522
+ model: flags.get('model') || undefined,
523
+ baseUrl: flags.get('base-url') || undefined,
524
+ fallbackModel: flags.get('fallback-model') || undefined,
525
+ escalate: flags.has('no-escalate') ? false : undefined,
526
+ }, onProgress);
527
+ if (!res.ok)
528
+ fail(res.error ?? 'unknown error', res.errorKind === 'infra' ? 2 : 1);
529
+ const data = res.data;
530
+ if (json) {
531
+ console.log(JSON.stringify(data, null, 2));
532
+ }
533
+ else {
534
+ const mark = data.report.status === 'success' ? 'OK' : data.report.status.toUpperCase();
535
+ console.log(`[${mark}] ${data.report.summary}`);
536
+ if (data.escalation) {
537
+ const e = data.escalation;
538
+ console.log(` escalated: ${e.from} blocked after ${e.firstAttempt.turns} turns → retried on ${e.to} (${e.rescued ? 'rescued' : 'still not resolved'})`);
539
+ console.log(` blocked because: ${e.reason}`);
540
+ }
541
+ if (data.skill?.invoked) {
542
+ const k = data.skill;
543
+ console.log(` skill: ${k.tier === 'A' ? 'replayed without the model' : 'replayed'} ${k.invoked} ${k.stepsReplayed}/${k.stepsTotal} steps${k.repaired ? ' — agent repaired the rest' : ''}${k.refused ? ' (refused)' : ''}`);
544
+ }
545
+ if (data.learned) {
546
+ const l = data.learned;
547
+ const bits = [
548
+ l.compiled ? `stored ${l.compiled}${l.variantOf ? ` (variant of ${l.variantOf})` : ''}` : '',
549
+ l.merged ? `merged into ${l.merged}` : '',
550
+ l.outcome ? `${l.outcome.skill} → ${l.outcome.status}` : '',
551
+ l.superseded ? `${l.superseded} superseded` : '',
552
+ ].filter(Boolean);
553
+ if (bits.length)
554
+ console.log(` learned: ${bits.join('; ')}`);
555
+ }
556
+ if (data.report.details)
557
+ console.log(data.report.details);
558
+ if (data.report.evidence?.values) {
559
+ for (const [k, v] of Object.entries(data.report.evidence.values))
560
+ console.log(` ${k}: ${v}`);
561
+ }
562
+ if (data.report.evidence?.capturedDialogs?.length) {
563
+ console.log(` dialogs: ${data.report.evidence.capturedDialogs.join(' | ')}`);
564
+ }
565
+ if (data.screenshots.length) {
566
+ console.log(` screenshots: ${data.screenshots.length}`);
567
+ for (const s of data.screenshots)
568
+ console.log(` ${s}`);
569
+ }
570
+ if (data.actions?.length) {
571
+ // On bail-out: the state-changing actions that ran, so you can verify
572
+ // before resuming rather than blindly repeating them.
573
+ console.log('--- actions taken (verify before resuming) ---');
574
+ for (const a of data.actions)
575
+ console.log(` ${a.ok ? '✓' : '✗'} ${a.tool} ${a.args}`);
576
+ }
577
+ if (data.transcriptTail?.length && !data.actions?.length) {
578
+ // Nothing ran — the agent's own reasoning is the only evidence there is.
579
+ console.log('--- transcript tail (no tool calls ran) ---');
580
+ for (const line of data.transcriptTail)
581
+ console.log(` ${line}`);
582
+ }
583
+ if (data.finalState) {
584
+ console.log(`--- browser left at: ${data.finalState.url}${data.finalState.title ? ` — "${data.finalState.title}"` : ''}`);
585
+ }
586
+ }
587
+ if (verbose) {
588
+ const u = data.usage;
589
+ const fresh = u.promptTokens - u.cachedTokens;
590
+ const hit = u.promptTokens ? Math.round((u.cachedTokens / u.promptTokens) * 100) : 0;
591
+ const models = data.escalation ? `${data.escalation.from} → ${data.escalation.to}` : data.model;
592
+ console.error(` · ${data.turns} turns, ${u.promptTokens} prompt (${u.cachedTokens} cached / ${fresh} fresh, ${hit}% hit) + ${u.completionTokens} completion tokens (${models})`);
593
+ }
594
+ process.exit(data.report.status === 'success' ? 0 : 1);
595
+ break;
596
+ }
597
+ case 'var': {
598
+ const spec = positional.join(' ');
599
+ const eq = spec.indexOf('=');
600
+ if (eq < 1)
601
+ fail('usage: var <name>=<value>', 2);
602
+ const data = printResult(await request(conn, 'var', { name: spec.slice(0, eq).trim(), value: spec.slice(eq + 1) }), json);
603
+ if (!json)
604
+ console.log(`vars: ${Object.entries(data.vars).map(([k, v]) => `${k}=${v}`).join(', ')}`);
605
+ break;
606
+ }
607
+ case 'run': {
608
+ const flowName = positional[0];
609
+ if (!flowName)
610
+ fail('run requires a flow name (see: flow list)', 2);
611
+ const vars = {};
612
+ // --var k=v may repeat; parseArgv keeps only the last, so re-scan argv.
613
+ for (let i = 0; i < process.argv.length - 1; i++) {
614
+ if (process.argv[i] === '--var') {
615
+ const kv = process.argv[i + 1];
616
+ const eq = kv.indexOf('=');
617
+ if (eq > 0)
618
+ vars[kv.slice(0, eq)] = kv.slice(eq + 1);
619
+ }
620
+ }
621
+ const res = await request(conn, 'run', {
622
+ name: flowName,
623
+ vars,
624
+ maxTurns: flags.has('max-turns') ? Number(flags.get('max-turns')) : undefined,
625
+ timeoutS: flags.has('timeout') ? Number(flags.get('timeout')) : undefined,
626
+ escalate: flags.has('no-escalate') ? false : undefined,
627
+ recoveryModel: flags.get('recovery-model') || undefined,
628
+ }, onProgress);
629
+ if (!res.ok)
630
+ fail(res.error ?? 'unknown error', res.errorKind === 'infra' ? 2 : 1);
631
+ const data = res.data;
632
+ if (json)
633
+ console.log(JSON.stringify(data, null, 2));
634
+ else {
635
+ for (const st of data.steps) {
636
+ const mark = st.status === 'success' ? 'OK' : st.status.toUpperCase();
637
+ // `satisfied` is not a cheaper replay, it is no replay at all: the
638
+ // page already showed this step's goal for this record.
639
+ const how = st.satisfied ? 'satisfied' : st.tier === 'A' ? 'replay' : st.replayed ? (st.repaired ? `replay+repair ${st.replayed}` : `replay ${st.replayed}`) : 'agent';
640
+ console.log(`[${mark}] ${st.id} (${how}${st.turns ? `, ${st.turns} turns` : ''})${st.repinned ? ` re-pinned ${st.repinned}` : ''}`);
641
+ if (st.status !== 'success' && st.summary)
642
+ console.log(` ${st.summary}`);
643
+ }
644
+ console.log(`${data.flow}: ${data.passed}/${data.total} steps, ${(data.wallMs / 1000).toFixed(1)}s${data.repinned ? `, ${data.repinned} step(s) re-pinned` : ''} — ${data.status}`);
645
+ }
646
+ process.exit(data.status === 'success' ? 0 : 1);
647
+ break;
648
+ }
649
+ case 'open': {
650
+ if (!positional[0])
651
+ fail('open requires a URL', 2);
652
+ const data = printResult(await request(conn, 'open', { url: positional[0] }, onProgress), json);
653
+ if (!json)
654
+ console.log(`${data.title} — ${data.url}`);
655
+ break;
656
+ }
657
+ case 'brief': {
658
+ const file = positional[0];
659
+ if (!file || !fs.existsSync(file))
660
+ fail(`brief requires an existing file (got: ${file ?? 'nothing'})`, 2);
661
+ const text = fs.readFileSync(path.resolve(file), 'utf8');
662
+ const data = printResult(await request(conn, 'brief', { text, append: flags.has('append') }), json);
663
+ if (!json)
664
+ console.log(`briefing loaded (${data.briefingChars} chars)`);
665
+ break;
666
+ }
667
+ case 'note': {
668
+ const text = positional.join(' ').trim();
669
+ if (!text)
670
+ fail('note requires text', 2);
671
+ const data = printResult(await request(conn, 'note', { text }), json);
672
+ if (!json)
673
+ console.log(`noted (${data.notes} notes in session)`);
674
+ break;
675
+ }
676
+ case 'reset': {
677
+ const data = printResult(await request(conn, 'reset', {}), json);
678
+ if (!json)
679
+ console.log(`conversation reset (${data.clearedMessages} message(s) cleared; browser, briefing, and notes kept)`);
680
+ break;
681
+ }
682
+ case 'peek': {
683
+ const data = printResult(await request(conn, 'peek', {
684
+ selector: flags.get('selector') || undefined,
685
+ interactiveOnly: flags.has('interactive'),
686
+ }), json);
687
+ if (!json) {
688
+ console.log(`${data.title} — ${data.url}`);
689
+ console.log(data.snapshot);
690
+ }
691
+ break;
692
+ }
693
+ case 'script': {
694
+ const data = printResult(await request(conn, 'script', {
695
+ path: positional[0],
696
+ title: flags.get('title') || undefined,
697
+ clear: flags.has('clear'),
698
+ }), json);
699
+ if (!json) {
700
+ if (data.path) {
701
+ console.log(`${data.path} (${data.steps} action(s), ${data.instructions ?? 0} instruction(s))`);
702
+ if (data.cleared)
703
+ console.log('recording cleared');
704
+ if (!data.recording) {
705
+ console.log('note: this session is not recording — generated from previously recorded actions');
706
+ }
707
+ }
708
+ else {
709
+ console.log(`recording cleared (${data.steps} action(s) discarded)`);
710
+ }
711
+ }
712
+ break;
713
+ }
714
+ case 'screenshot': {
715
+ const data = printResult(await request(conn, 'screenshot', { path: positional[0], fullPage: flags.has('full-page') }), json);
716
+ if (!json)
717
+ console.log(data.path);
718
+ break;
719
+ }
720
+ case 'config': {
721
+ const data = printResult(await request(conn, 'config', {}), true);
722
+ void data;
723
+ break;
724
+ }
725
+ default:
726
+ fail(`unknown command "${command}"\n\n${USAGE}`, 2);
727
+ }
728
+ }
729
+ finally {
730
+ conn.destroy();
731
+ }
732
+ process.exit(0);
733
+ }
734
+ // --- skills (reads the store directly; no daemon involved) ---
735
+ function skillsCommand(positional, flags, json) {
736
+ const store = new SkillStore();
737
+ const sub = positional[0] ?? 'list';
738
+ const origin = flags.get('origin') ? String(flags.get('origin')) : undefined;
739
+ switch (sub) {
740
+ case 'list': {
741
+ const skills = (origin ? store.list(origin) : store.all()).sort((a, b) => a.origin.localeCompare(b.origin) || b.stats.uses - a.stats.uses);
742
+ if (json) {
743
+ console.log(JSON.stringify(skills.map(skillSummary), null, 2));
744
+ return;
745
+ }
746
+ if (!skills.length) {
747
+ console.log(`no stored procedures${origin ? ` for ${origin}` : ''} (store: ${store.dir})`);
748
+ return;
749
+ }
750
+ let last = '';
751
+ for (const s of skills) {
752
+ if (s.origin !== last) {
753
+ console.log(`${s.origin}`);
754
+ last = s.origin;
755
+ }
756
+ const pct = Math.round(successRate(s) * 100);
757
+ console.log(` ${s.id} ${s.status.padEnd(11)} ${String(s.steps.length).padStart(2)} steps ${s.stats.successes}/${s.stats.uses} (${pct}%)${s.variantOf ? ` variant of ${s.variantOf}` : ''}`);
758
+ console.log(` ${clipText(s.template, 110)}`);
759
+ }
760
+ console.log(`store: ${store.dir}`);
761
+ return;
762
+ }
763
+ case 'show': {
764
+ const id = positional[1];
765
+ if (!id)
766
+ fail('usage: skills show <id>', 2);
767
+ const s = store.get(id);
768
+ if (!s)
769
+ fail(`no skill ${id}`, 1);
770
+ if (json) {
771
+ console.log(JSON.stringify(s, null, 2));
772
+ return;
773
+ }
774
+ console.log(`${s.id} ${s.status} ${s.origin}`);
775
+ console.log(`template: ${s.template}`);
776
+ console.log(`starts on: ${s.preconditions.urlPattern}`);
777
+ const params = Object.entries(s.params);
778
+ console.log(params.length ? `params: ${params.map(([k, p]) => `${k} = e.g. ${JSON.stringify(p.example)} (steps ${p.usedIn.join(',')})`).join('; ')}` : 'params: none');
779
+ console.log(`stats: ${s.stats.successes}/${s.stats.uses} ok, ${s.stats.partial} partial, ${s.stats.fallthroughs} locator fallthrough(s)${Object.keys(s.stats.failedAtStep).length ? `, failed at step ${Object.entries(s.stats.failedAtStep).map(([k, v]) => `${k}×${v}`).join(', ')}` : ''}; created ${s.provenance.created} in session ${s.provenance.session}${s.provenance.model ? ` by ${s.provenance.model}` : ''}`);
780
+ if (s.variantOf)
781
+ console.log(`variant of: ${s.variantOf}`);
782
+ console.log('steps:');
783
+ s.steps.forEach((st, i) => {
784
+ const target = st.locators.target?.[0] ? candidateExpr(st.locators.target[0]) : st.args.target ? String(st.args.target) : '';
785
+ const fallbacks = (st.locators.target?.length ?? 0) > 1 ? ` (+${st.locators.target.length - 1} fallback)` : '';
786
+ const args = Object.entries(st.args)
787
+ .filter(([k]) => k !== 'target' && k !== 'source')
788
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
789
+ .join(' ');
790
+ const literal = Object.entries(st.args)
791
+ .filter(([k, v]) => ['value', 'text', 'option'].includes(k) && typeof v === 'string' && !/\{\{v\d+\}\}/.test(v))
792
+ .map(([, v]) => JSON.stringify(v));
793
+ console.log(` ${String(i + 1).padStart(2)}. ${st.tool.padEnd(14)} ${target}${fallbacks}${args ? ' ' + args : ''}${st.label ? ` → ${st.label}` : ''}${literal.length ? ` [literal value ${literal.join(', ')} — not a parameter]` : ''}${st.via ? ` (via ${st.via.skill} #${st.via.step})` : ''}`);
794
+ if (st.expect?.urlPattern)
795
+ console.log(` expect url ${st.expect.urlPattern}`);
796
+ });
797
+ if (s.reportTemplate?.summary)
798
+ console.log(`report: ${clipText(fillParams(s.reportTemplate.summary, {}), 200)}`);
799
+ return;
800
+ }
801
+ case 'rm': {
802
+ const id = positional[1];
803
+ if (!id)
804
+ fail('usage: skills rm <id>', 2);
805
+ if (!store.remove(id))
806
+ fail(`no skill ${id}`, 1);
807
+ console.log(`removed ${id}`);
808
+ return;
809
+ }
810
+ case 'clear': {
811
+ if (flags.has('all')) {
812
+ let n = 0;
813
+ for (const o of store.origins())
814
+ n += store.clear(o);
815
+ console.log(`cleared ${n} skill(s) across all origins`);
816
+ return;
817
+ }
818
+ if (!origin)
819
+ fail('usage: skills clear --origin <origin> | --all', 2);
820
+ console.log(`cleared ${store.clear(origin)} skill(s) for ${origin}`);
821
+ return;
822
+ }
823
+ default:
824
+ fail(`unknown subcommand "skills ${sub}" (try: list, show <id>, rm <id>, clear)`, 2);
825
+ }
826
+ }
827
+ function skillSummary(s) {
828
+ return {
829
+ id: s.id,
830
+ origin: s.origin,
831
+ status: s.status,
832
+ template: s.template,
833
+ steps: s.steps.length,
834
+ params: Object.fromEntries(Object.entries(s.params).map(([k, p]) => [k, p.example])),
835
+ uses: s.stats.uses,
836
+ successes: s.stats.successes,
837
+ partial: s.stats.partial,
838
+ urlPattern: s.preconditions.urlPattern,
839
+ ...(s.variantOf ? { variantOf: s.variantOf } : {}),
840
+ created: s.provenance.created,
841
+ };
842
+ }
843
+ function clipText(text, max) {
844
+ return clip(text.replace(/\s+/g, ' '), max);
845
+ }
846
+ function flowCommand(positional, json) {
847
+ const op = positional[0] ?? 'list';
848
+ if (op === 'list') {
849
+ const flows = listFlows();
850
+ if (json)
851
+ console.log(JSON.stringify(flows.map((f) => ({ name: f.name, steps: f.steps.length, vars: f.vars, origin: f.origin })), null, 2));
852
+ else if (!flows.length)
853
+ console.log('no saved flows');
854
+ else
855
+ for (const f of flows)
856
+ console.log(`${f.name} ${f.steps.length} step(s) ${f.vars.length ? `vars ${f.vars.join(', ')}` : 'no vars'} ${f.origin}`);
857
+ return;
858
+ }
859
+ if (op === 'show') {
860
+ const flow = loadFlow(positional[1] ?? '');
861
+ if (!flow)
862
+ fail(`no flow "${positional[1] ?? ''}"`, 1);
863
+ if (json) {
864
+ console.log(JSON.stringify(flow, null, 2));
865
+ return;
866
+ }
867
+ console.log(`${flow.name} ${flow.origin} (recorded ${flow.provenance.created} in session ${flow.provenance.session})`);
868
+ console.log(`starts at: ${flow.startUrl}`);
869
+ console.log(flow.vars.length ? `vars: ${flow.vars.join(', ')}` : 'vars: none');
870
+ for (const st of flow.steps) {
871
+ console.log(` ${st.id}${st.skill ? ` [${st.skill}]` : ' [no skill]'}${st.outputs.length ? ` → ${st.outputs.join(', ')}` : ''}`);
872
+ console.log(` ${st.instruction.length > 120 ? st.instruction.slice(0, 120) + '…' : st.instruction}`);
873
+ }
874
+ return;
875
+ }
876
+ fail(`unknown "flow ${op}" (try: list, show <name>)`, 2);
877
+ }
878
+ // --- compile (reads the flow + skill store directly; no daemon involved) ---
879
+ async function compileCommand(positional, flags, json) {
880
+ const flowNameOrPath = positional[0];
881
+ if (!flowNameOrPath)
882
+ fail('usage: compile <flow-name-or-path> [--out <dir>] [--force] [--json]', 2);
883
+ const outDir = flags.get('out') ? String(flags.get('out')) : '.';
884
+ let result;
885
+ try {
886
+ result = compileFlow(flowNameOrPath, { outDir, force: flags.has('force') });
887
+ }
888
+ catch (err) {
889
+ fail(`compile failed: ${err.message}`, 2);
890
+ }
891
+ if (json) {
892
+ console.log(JSON.stringify(result, null, 2));
893
+ }
894
+ else {
895
+ // Diagnostics FIRST — what is wrong, the evidence, and the command that
896
+ // fixes it — ahead of the file list, which is not what a caller needs when
897
+ // the answer is "re-record 08-open".
898
+ for (const d of result.diagnostics)
899
+ console.error(formatDiagnostic(d));
900
+ if (result.diagnostics.length)
901
+ console.error('');
902
+ if (result.refused) {
903
+ console.error('nothing written: the error(s) above are about the RECORDING, not the app — a compiled spec would fail at a locator and read as drift.');
904
+ console.error('re-record the step(s) with the fix command above, or pass --force to compile the demoted pin anyway.');
905
+ }
906
+ else {
907
+ console.log(`flow: ${result.flowFile}`);
908
+ console.log(result.specFile ? `spec: ${result.specFile}` : 'spec: unchanged (already exists — pass --force to overwrite)');
909
+ }
910
+ // Anything the emitter said that no diagnostic above already carries.
911
+ const reported = new Set(result.diagnostics.map(diagnosticLine));
912
+ for (const w of result.warnings)
913
+ if (!reported.has(w))
914
+ console.error(` warning: ${w}`);
915
+ }
916
+ if (result.refused) {
917
+ fail('refused: a step is pinned to a demoted skill — see the diagnostics above (--force compiles it anyway)', 2);
918
+ }
919
+ if (!result.compilable) {
920
+ const missing = result.spec.steps.filter((s) => s.segments.length === 0).length;
921
+ fail(`not compilable: ${missing} step(s) have no converged procedure`, 2);
922
+ }
923
+ }
924
+ /**
925
+ * `sitelooper check <name.flow.ts>` — run the emitted spec once, as a user would.
926
+ *
927
+ * The standalone half of `repair --check-spec`, and the same code underneath.
928
+ * It exists on its own because the question ("does the compiled spec actually
929
+ * pass under plain Playwright?") is worth asking about a file nobody is
930
+ * repairing: after a `compile`, after a hand edit to the `.spec.ts`, or in CI
931
+ * next to the sitelooper-free artifacts it is supposed to have produced.
932
+ *
933
+ * No daemon and no model are involved: this spawns `npx playwright test` over
934
+ * a copy of the two files with a config of its own, and reports the report.
935
+ */
936
+ function checkSpecCommand(positional, flags, json, onProgress) {
937
+ const file = positional[0];
938
+ if (!file)
939
+ fail('usage: check <name.flow.ts> [--var k=v ...] [--reset-cmd "<cmd>"] [--json]', 2);
940
+ if (!fs.existsSync(file))
941
+ fail(`could not read ${file}`, 2);
942
+ const result = runSpecCheck({
943
+ flowFile: file,
944
+ vars: varFlags(),
945
+ resetCmd: flags.get('reset-cmd') ? String(flags.get('reset-cmd')) : undefined,
946
+ liveReplayPassed: false,
947
+ onProgress: onProgress ?? ((m) => console.error(m)),
948
+ });
949
+ if (json)
950
+ console.log(JSON.stringify({ file, specCheck: result }, null, 2));
951
+ else {
952
+ console.log(result.verdict);
953
+ for (const d of result.drift)
954
+ console.log(` ${d}`);
955
+ if (result.workspace)
956
+ console.log(` workspace: ${result.workspace}`);
957
+ }
958
+ // A skip is not a verdict about the spec, so it is not a failure either.
959
+ if (result.ran && !result.passed)
960
+ process.exit(4);
961
+ }
962
+ function allSessionNames() {
963
+ try {
964
+ return fs
965
+ .readdirSync(sessionsDir(), { withFileTypes: true })
966
+ .filter((d) => d.isDirectory())
967
+ .map((d) => d.name);
968
+ }
969
+ catch {
970
+ return [];
971
+ }
972
+ }
973
+ async function listSessions(json) {
974
+ const names = allSessionNames();
975
+ const rows = [];
976
+ for (const name of names) {
977
+ try {
978
+ const conn = await connect(socketPath(name), 500);
979
+ const res = await request(conn, 'ping', {}, undefined, 5_000);
980
+ conn.destroy();
981
+ const data = res.data;
982
+ rows.push({ session: name, running: true, pid: data.pid });
983
+ }
984
+ catch {
985
+ rows.push({ session: name, running: false });
986
+ }
987
+ }
988
+ if (json)
989
+ console.log(JSON.stringify(rows, null, 2));
990
+ else if (!rows.length)
991
+ console.log('no sessions');
992
+ else
993
+ for (const r of rows)
994
+ console.log(`${r.session} ${r.running ? `running (pid ${r.pid})` : 'stopped'}`);
995
+ }
996
+ main().catch((err) => fail(err?.message ?? String(err)));
997
+ // --- post-session repair (SLOW MODE) ---
998
+ /**
999
+ * Drain one run's drift tickets, after the timed run is over:
1000
+ * - localized drift that already self-healed (a fallback resolved) → promote
1001
+ * that fallback to primary in the stored skill. Cheap, deterministic.
1002
+ * - localized drift with a dead chain → ask the repair model to re-derive
1003
+ * the moved control's locator on the live page, verify it resolves, and
1004
+ * store the patched chain as a provisional VARIANT that must earn adoption
1005
+ * through the normal lifecycle.
1006
+ * - low similarity → broad redesign: flag for a fresh record run, never
1007
+ * patch selectors.
1008
+ */
1009
+ async function repairCommand(positional, flags, json) {
1010
+ const file = String(flags.get('drift') ?? positional[1] ?? '');
1011
+ if (!file)
1012
+ fail('usage: skills repair --drift <run-drift.json> [--dry-run] [--model M]', 2);
1013
+ let tickets;
1014
+ try {
1015
+ tickets = JSON.parse(fs.readFileSync(file, 'utf8'));
1016
+ }
1017
+ catch (err) {
1018
+ return fail(`could not read drift tickets from ${file}: ${err.message}`, 1);
1019
+ }
1020
+ const dryRun = flags.has('dry-run');
1021
+ const store = new SkillStore();
1022
+ // The COLD path: a browser of its own, signed into nothing. Kept for the
1023
+ // standalone case (a drift sidecar from a CI run, days later, with no
1024
+ // session to attach to) — `sitelooper repair <flow.ts>` drains the same
1025
+ // tickets inside the run's own session, on a page that is still signed in,
1026
+ // and should be preferred whenever the flow file is at hand.
1027
+ const wantsPage = !dryRun && triage(tickets).some((a) => a.kind === 'patch-segment');
1028
+ const model = flags.get('model') ? String(flags.get('model')) : undefined;
1029
+ let browser = null;
1030
+ let summary;
1031
+ try {
1032
+ let propose;
1033
+ let openPage;
1034
+ if (wantsPage) {
1035
+ const config = resolveProviderConfig({ model });
1036
+ const resolved = model ?? (config.fallbackModel && config.fallbackModel !== 'none' ? config.fallbackModel : config.model);
1037
+ const provider = config.provider === 'anthropic' ? new AnthropicProvider({ ...config, model: resolved }) : new OpenAICompatProvider({ ...config, model: resolved });
1038
+ propose = llmProposer(provider);
1039
+ const { BrowserSession } = await import('./daemon/browser.js');
1040
+ browser = new BrowserSession({ session: 'repair', persist: false });
1041
+ openPage = async (url) => {
1042
+ const page = await browser.getPage();
1043
+ await page.goto(url, { waitUntil: 'load', timeout: 30_000 }).catch(() => { });
1044
+ return page;
1045
+ };
1046
+ }
1047
+ summary = await drainDrift(store, tickets, { dryRun, model, propose, openPage });
1048
+ }
1049
+ finally {
1050
+ await browser?.close();
1051
+ }
1052
+ if (json) {
1053
+ console.log(JSON.stringify({ tickets: tickets.length, ...summary }, null, 2));
1054
+ return;
1055
+ }
1056
+ console.log(`${tickets.length} drift ticket(s) → ${summary.promoted.length} fallback(s) promoted, ${summary.patched.length} segment(s) patched, ${summary.reRecord.length} flagged for re-record, ${summary.skipped.length} skipped`);
1057
+ for (const p of summary.promoted)
1058
+ console.log(` promoted ${p.skill} step ${p.step}: ${p.to}${p.dryRun ? ' (dry run)' : ''}`);
1059
+ for (const p of summary.patched)
1060
+ console.log(` patched ${p.skill} step ${p.step} → variant ${p.variant} (${p.locator})`);
1061
+ for (const p of summary.reRecord)
1062
+ console.log(` re-record ${p.skill} (${p.flow}/${p.step}): ${p.why}`);
1063
+ for (const p of summary.skipped)
1064
+ console.log(` skipped ${p.skill}${p.step ? ` step ${p.step}` : ''}: ${p.why}`);
1065
+ }
1066
+ // --- repair on a compiled spec (PLAN-self-updating-spec.md, phase 4) ---
1067
+ /**
1068
+ * `--var k=v` may repeat, and `parseArgv` keeps only the last one, so the
1069
+ * repeats are re-scanned out of argv. Same rule `run` uses; shared so a flow
1070
+ * replayed by `repair` binds exactly what `run` would.
1071
+ */
1072
+ function varFlags() {
1073
+ const vars = {};
1074
+ for (let i = 0; i < process.argv.length - 1; i++) {
1075
+ if (process.argv[i] === '--var') {
1076
+ const kv = process.argv[i + 1];
1077
+ const eq = kv.indexOf('=');
1078
+ if (eq > 0)
1079
+ vars[kv.slice(0, eq)] = kv.slice(eq + 1);
1080
+ }
1081
+ }
1082
+ return vars;
1083
+ }
1084
+ /** Best-effort shutdown of a throwaway repair session (its browser is the only thing holding that profile open). */
1085
+ async function stopSessionQuietly(name) {
1086
+ try {
1087
+ const conn = await connect(socketPath(name));
1088
+ try {
1089
+ await request(conn, 'stop', {}, undefined, 30_000);
1090
+ }
1091
+ finally {
1092
+ conn.destroy();
1093
+ }
1094
+ }
1095
+ catch {
1096
+ /* already gone */
1097
+ }
1098
+ }
1099
+ /**
1100
+ * Replay a staged flow through the daemon exactly as `sitelooper run` does —
1101
+ * same spawn, same `run` command, same recovery ladder — with the skill store
1102
+ * pointed at the staged temp dir so the run's re-pins, learned variants and
1103
+ * candidate evidence land there and nowhere near `~/.sitelooper`.
1104
+ *
1105
+ * A FRESH session per run, deliberately: the flow's first step signs in, and a
1106
+ * session still signed in from the previous run would send that step to model
1107
+ * recovery and make the convergence gate measure the wrong thing. The session
1108
+ * is stopped on the way out so a converge loop does not leave one browser per
1109
+ * iteration running.
1110
+ */
1111
+ async function runStagedFlow(staged, vars, session, opts) {
1112
+ const prev = { skills: process.env.SITELOOPER_SKILLS, dir: process.env.SITELOOPER_SKILLS_DIR };
1113
+ process.env.SITELOOPER_SKILLS = '1';
1114
+ process.env.SITELOOPER_SKILLS_DIR = staged.skillsDir;
1115
+ try {
1116
+ const conn = await connectOrSpawn(session, { headed: opts.headed, record: false, script: false, learn: true });
1117
+ try {
1118
+ const res = await request(conn, 'run', { name: staged.flowFile, vars }, opts.onProgress);
1119
+ if (!res.ok)
1120
+ fail(res.error ?? 'the flow run failed', res.errorKind === 'infra' ? 2 : 1);
1121
+ const run = res.data;
1122
+ if (!opts.drain)
1123
+ return { run };
1124
+ const patched = await request(conn, 'patch', { tickets: run.driftTickets ?? [], dryRun: opts.drain.dryRun, model: opts.drain.model }, opts.onProgress);
1125
+ if (!patched.ok)
1126
+ fail(patched.error ?? 'the drift drain failed', patched.errorKind === 'infra' ? 2 : 1);
1127
+ return { run, drained: patched.data };
1128
+ }
1129
+ finally {
1130
+ conn.destroy();
1131
+ }
1132
+ }
1133
+ finally {
1134
+ if (prev.skills === undefined)
1135
+ delete process.env.SITELOOPER_SKILLS;
1136
+ else
1137
+ process.env.SITELOOPER_SKILLS = prev.skills;
1138
+ if (prev.dir === undefined)
1139
+ delete process.env.SITELOOPER_SKILLS_DIR;
1140
+ else
1141
+ process.env.SITELOOPER_SKILLS_DIR = prev.dir;
1142
+ await stopSessionQuietly(session);
1143
+ // A repair run's session is scratch: its browser profile exists for one
1144
+ // replay and a converge loop would otherwise leave one directory per
1145
+ // iteration behind. Best effort — a profile Chrome has not finished
1146
+ // releasing is left for the OS to clean up rather than failing the run.
1147
+ try {
1148
+ fs.rmSync(path.join(sessionsDir(), session), { recursive: true, force: true });
1149
+ }
1150
+ catch {
1151
+ /* still held open — harmless */
1152
+ }
1153
+ }
1154
+ }
1155
+ /**
1156
+ * Put the app back where every run of a converge loop expects to find it.
1157
+ *
1158
+ * `{n}`-minted vars solve half of the accumulation problem (each run works its
1159
+ * own records); they do not solve the other half, which is everything the
1160
+ * PREVIOUS run left behind — rows in a list a locator counts, a seeded fixture
1161
+ * a create step consumes, a queue that grows. A record-creating flow replayed
1162
+ * three times is three different apps unless something resets it, and the gate
1163
+ * would then be measuring the app's history rather than the spec's stability.
1164
+ *
1165
+ * Deliberately a shell command rather than anything sitelooper knows how to
1166
+ * do: the reset is the application's business (a fixture endpoint, a `docker
1167
+ * compose down -v`, a seed script), and the only thing this tool has an
1168
+ * opinion about is that a reset which FAILED must stop the run — a converge
1169
+ * pass over an un-reset app reports a verdict about nothing.
1170
+ */
1171
+ function runResetCmd(cmd, label, say) {
1172
+ if (!cmd)
1173
+ return;
1174
+ say(` reset (${label}): ${cmd}`);
1175
+ const res = spawnSync(cmd, { shell: true, stdio: 'pipe', encoding: 'utf8' });
1176
+ if (res.error)
1177
+ fail(`--reset-cmd could not run: ${res.error.message}`, 2);
1178
+ if (res.status !== 0) {
1179
+ if (res.stderr?.trim())
1180
+ console.error(res.stderr.trim());
1181
+ fail(`--reset-cmd exited ${res.status ?? 'by signal'} before ${label}; refusing to run against an app that was not reset`, 2);
1182
+ }
1183
+ }
1184
+ /**
1185
+ * `sitelooper repair <name.flow.ts>` — the self-updating half of the compiled
1186
+ * runner (PLAN-self-updating-spec.md, "The loop").
1187
+ *
1188
+ * Lift the owned file back to its IR, stage it into a THROWAWAY store and flow
1189
+ * file, replay it against the live app through the daemon (whose recovery
1190
+ * ladder is the agent adapting the flow — that is the point), drain the run's
1191
+ * drift tickets onto the staged store, re-derive the IR from what the run and
1192
+ * the drain left there, and print the difference as English a reviewer can
1193
+ * read. Only then, and only if the convergence gate passes, is the `.flow.ts`
1194
+ * re-emitted. The `.spec.ts` is never touched: it is the user's file.
1195
+ */
1196
+ async function repairFlowCommand(positional, flags, json, onProgress) {
1197
+ const file = positional[0];
1198
+ if (!file)
1199
+ fail('usage: repair <name.flow.ts> [--var k=v ...] [--out <file>] [--converge <n>] [--reset-cmd "<cmd>"] [--dry-run] [--model M] [--json]', 2);
1200
+ let source;
1201
+ try {
1202
+ source = fs.readFileSync(file, 'utf8');
1203
+ }
1204
+ catch (err) {
1205
+ return fail(`could not read ${file}: ${err.message}`, 2);
1206
+ }
1207
+ let before;
1208
+ try {
1209
+ before = liftFlowFile(source).spec;
1210
+ }
1211
+ catch (err) {
1212
+ if (err instanceof LiftError) {
1213
+ return fail(`this file was edited by hand or is not a sitelooper flow file; refusing to repair — ${err.message}`, 2);
1214
+ }
1215
+ throw err;
1216
+ }
1217
+ const vars = varFlags();
1218
+ const missingVars = before.vars.filter((v) => !(v in vars));
1219
+ if (missingVars.length)
1220
+ fail(`flow "${before.name}" needs --var for: ${missingVars.join(', ')}`, 2);
1221
+ const converge = flags.has('converge') ? Number(flags.get('converge')) : 1;
1222
+ if (!Number.isInteger(converge) || converge < 0)
1223
+ fail('--converge takes a non-negative integer', 2);
1224
+ const dryRun = flags.has('dry-run');
1225
+ const resetCmd = flags.get('reset-cmd') ? String(flags.get('reset-cmd')) : undefined;
1226
+ const outFile = flags.get('out') ? String(flags.get('out')) : file;
1227
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sitelooper-repair-'));
1228
+ const staged = stageRepair(before, dir);
1229
+ const stamp = Date.now().toString(36);
1230
+ // Progress is never silent, only redirected: under --json stdout has to stay
1231
+ // parseable, so every line goes to stderr instead of being dropped. sp4od's
1232
+ // repair.log had nothing but the final ticket lines because these were
1233
+ // discarded, and the per-step tier/status of each run is exactly what a
1234
+ // reader of that log needs.
1235
+ const say = (m) => {
1236
+ if (json)
1237
+ console.error(m);
1238
+ else
1239
+ console.log(m);
1240
+ };
1241
+ say(`repairing ${file} (${before.steps.length} step(s)) in ${dir}`);
1242
+ const runs = [];
1243
+ const noteRun = (label, r, ticketCount) => {
1244
+ runs.push({
1245
+ label,
1246
+ passed: r.passed,
1247
+ total: r.total,
1248
+ status: r.status,
1249
+ tickets: ticketCount,
1250
+ steps: r.steps.map((st) => ({
1251
+ id: st.id,
1252
+ status: st.status,
1253
+ tier: st.tier ?? null,
1254
+ ...(st.summary ? { summary: st.summary } : {}),
1255
+ recovered: Boolean(st.recovered),
1256
+ ...(st.fellBack ? { fellBack: st.fellBack } : {}),
1257
+ // WHICH procedure actually ran, not only how well it went. sp8od read
1258
+ // 9/9 tier A on a step whose pinned skill never ran at all, and the
1259
+ // run report had no field that could have said so.
1260
+ ...(st.replayed ? { replayed: st.replayed } : {}),
1261
+ ...(st.repinned ? { repinned: st.repinned } : {}),
1262
+ })),
1263
+ });
1264
+ };
1265
+ // Steps whose RECORDING is the problem — a demoted pin, or a pin the engine
1266
+ // silently replaced with another skill — by step id. Derived after run 1 and
1267
+ // after every converge run, because the pin can move under us and because
1268
+ // the evidence sentence names every run. See spec/repair.ts
1269
+ // `rerecordDiagnostics` for the rules and for the sp8od case that forced
1270
+ // them. Each one is printed FIRST, once, and refuses the write.
1271
+ const flagged = new Map();
1272
+ const refreshFlags = () => {
1273
+ let staleFlow;
1274
+ try {
1275
+ staleFlow = JSON.parse(fs.readFileSync(staged.flowFile, 'utf8'));
1276
+ }
1277
+ catch {
1278
+ return;
1279
+ }
1280
+ const fresh = rerecordDiagnostics({
1281
+ // The .flow.ts the reviewer owns, not the staged JSON the run used: the
1282
+ // fix command has to name a file that will still be there tomorrow.
1283
+ flowFile: file,
1284
+ steps: staleFlow.steps,
1285
+ runs,
1286
+ store: new SkillStore(staged.skillsDir),
1287
+ });
1288
+ for (const [id, d] of fresh) {
1289
+ const seen = flagged.get(id);
1290
+ flagged.set(id, d);
1291
+ // Once per step, however many runs re-observe it — but the LATEST
1292
+ // evidence wins, so the sentence names every run that has happened.
1293
+ if (!seen)
1294
+ say(formatDiagnostic(d));
1295
+ }
1296
+ };
1297
+ // Every line the evidence codemod produced, across run 1 and every converge
1298
+ // run. Kept beside the IR diff rather than inside it because a retirement is
1299
+ // an observation ABOUT a chain, not a shape change the diff can see: a
1300
+ // candidate that dropped to the back of a chain reads, in the emitted file,
1301
+ // as a reordering with no reason attached.
1302
+ const evidenceLines = [];
1303
+ // One retirement, one line, however many runs re-observe it.
1304
+ const retirementsReported = new Set();
1305
+ runResetCmd(resetCmd, 'run 1', say);
1306
+ const { run, drained } = await runStagedFlow(staged, mintVars(vars, 0), `repair-${stamp}-0`, {
1307
+ headed: flags.has('headed'),
1308
+ onProgress,
1309
+ // The drain runs even under --dry-run, but as TRIAGE only: the summary is
1310
+ // what a dry run is FOR, and it cannot be described without classifying
1311
+ // the tickets. --dry-run governs the irreversible things — reordering a
1312
+ // stored chain, storing a variant, writing the user's `.flow.ts`.
1313
+ drain: { dryRun, model: flags.get('model') ? String(flags.get('model')) : undefined },
1314
+ });
1315
+ const tickets = run.driftTickets ?? [];
1316
+ const summary = drained ?? { promoted: [], patched: [], reRecord: [], skipped: [] };
1317
+ noteRun('run 1', run, tickets.length);
1318
+ say(`run 1: ${run.passed}/${run.total} step(s) ${run.status}, ${tickets.length} drift ticket(s)`);
1319
+ for (const st of run.steps) {
1320
+ say(` [${st.status === 'success' ? 'OK' : st.status.toUpperCase()}] ${st.id} (tier ${st.tier ?? 'none'})${st.fellBack ? ` — fell back: ${st.fellBack}` : ''}${st.status === 'success' ? '' : ` — ${st.summary ?? ''}`}`);
1321
+ }
1322
+ // A patch the daemon verified on the live page is a proposal about THIS
1323
+ // spec, not a candidate for some future store's lifecycle: fold it into the
1324
+ // chain before anything reads the IR back, or the variant compiles as an
1325
+ // extra segment beside the drifted one it was meant to replace. The
1326
+ // convergence gate below is what it has to earn its place against.
1327
+ const folded = dryRun ? [] : foldPatchedVariants(staged.store, summary.patched);
1328
+ for (const line of folded)
1329
+ say(` folded ${line}`);
1330
+ // The cheap, no-model half of "what the agent is allowed to change": bank
1331
+ // the misses this run's tickets prove for the chains replay banks nothing
1332
+ // about (a dead chain, or one a structural path won), then reorder every
1333
+ // chain by that evidence. Run 1 files the first miss; the converge runs
1334
+ // below file the second, which is what `retired` needs.
1335
+ if (!dryRun) {
1336
+ foldTicketEvidence(staged.store, tickets);
1337
+ evidenceLines.push(...reorderByEvidence(staged.store, retirementsReported));
1338
+ for (const line of evidenceLines)
1339
+ say(` evidence ${line}`);
1340
+ }
1341
+ // Diagnostics before counts, before the change list, before anything: a
1342
+ // step the engine covered with someone else's skill makes every number
1343
+ // below it mean something different.
1344
+ refreshFlags();
1345
+ let diff = diffSpecChanges(before, reloadStaged(staged).spec);
1346
+ const printChanges = (heading, d) => {
1347
+ if (json)
1348
+ return;
1349
+ console.log(heading);
1350
+ for (const line of d.lines)
1351
+ console.log(` ${line}`);
1352
+ for (const line of evidenceLines)
1353
+ console.log(` ${line}`);
1354
+ };
1355
+ if (!json) {
1356
+ console.log(`${tickets.length} drift ticket(s) → ${summary.promoted.length} promoted, ${summary.patched.length} patched, ${summary.reRecord.length} need re-record, ${summary.skipped.length} skipped`);
1357
+ for (const p of summary.patched)
1358
+ console.log(` patched ${p.skill} step ${p.step} → variant ${p.variant} (${p.locator}) on ${p.url}`);
1359
+ for (const p of summary.reRecord)
1360
+ console.log(` needs re-record ${p.skill} (${p.flow}/${p.step}): ${p.why}`);
1361
+ for (const p of summary.skipped) {
1362
+ // A no-proposal is only meaningful next to what the model was looking
1363
+ // at: 6 interactive rows means it was shown the login page, 40 means the
1364
+ // page really had nothing that fits.
1365
+ const seen = p.snapshotRows === undefined ? '' : ` [saw ${p.snapshotRows} interactive row(s), ${p.snapshotBytes} bytes, on ${p.url}; model replied ${JSON.stringify(p.modelReply)}]`;
1366
+ console.log(` skipped ${p.skill}${p.step ? ` step ${p.step}` : ''}: ${p.why}${seen}`);
1367
+ }
1368
+ }
1369
+ printChanges('--- changes ---', diff);
1370
+ // Filled in only by --check-spec, after the owned file is written: it is a
1371
+ // verdict about the EMITTED spec, which does not exist until then.
1372
+ let specCheck = null;
1373
+ const report = () => ({
1374
+ file,
1375
+ flow: before.name,
1376
+ workspace: dir,
1377
+ run: { status: run.status, passed: run.passed, total: run.total, drift: tickets.length },
1378
+ // Every run, not just run 1: the gate is a verdict about the LAST run, and
1379
+ // "which step needed the model, on which pass, and why" is unanswerable
1380
+ // from a single aggregate.
1381
+ runs,
1382
+ // Every problem this repair found, in the shape every surface reports one
1383
+ // (spec/diagnostics.ts) — present in EVERY JSON shape the command prints,
1384
+ // refusal, gate failure, dry run and success alike.
1385
+ diagnostics: [...flagged.values()],
1386
+ // The tickets themselves, not just how many: "15 drift ticket(s)" cannot be
1387
+ // acted on, and the one question a stuck converge loop asks is WHICH
1388
+ // locator keeps missing and what won instead.
1389
+ tickets,
1390
+ ...summary,
1391
+ changes: [...diff.lines, ...evidenceLines],
1392
+ evidence: evidenceLines,
1393
+ droppedExpectations: diff.droppedExpectations,
1394
+ weakenedByVariant: diff.weakenedByVariant,
1395
+ specCheck,
1396
+ });
1397
+ // Never weaken an expectation: an assertion that no longer holds is a test
1398
+ // failure for a human, not drift (PLAN-self-updating-spec.md). The one
1399
+ // reported-not-refused case is a repair VARIANT — see SpecDiff for why that
1400
+ // is not a loophole.
1401
+ const gateExpectations = () => {
1402
+ for (const w of diff.weakenedByVariant)
1403
+ console.error(` review: ${w}`);
1404
+ if (!diff.droppedExpectations.length)
1405
+ return;
1406
+ if (json)
1407
+ console.log(JSON.stringify({ ...report(), wrote: null, refused: 'expectation dropped' }, null, 2));
1408
+ for (const d of diff.droppedExpectations)
1409
+ console.error(` expectation dropped: ${d}`);
1410
+ fail('refusing to write: the repair would drop an expectation — that is a test failure for a human, not drift', 1);
1411
+ };
1412
+ gateExpectations();
1413
+ /**
1414
+ * Refuse the write when a step's RECORDING is the problem.
1415
+ *
1416
+ * The step is not converged however clean its tier looked — sp8od went 9/9
1417
+ * tier A three times on a step whose demoted pin never ran once. No locator
1418
+ * edit reaches this, so there is nothing to write: the file stays as it was,
1419
+ * the exit code is the existing "needs re-record" 1, and what the reviewer
1420
+ * gets is the diagnostic block plus the command that fixes it.
1421
+ */
1422
+ const gateRerecord = () => {
1423
+ if (!flagged.size)
1424
+ return;
1425
+ const ds = [...flagged.values()];
1426
+ if (json) {
1427
+ console.log(JSON.stringify({ ...report(), wrote: null, converged: false, refused: 'needs re-record', notConverged: [...flagged.keys()] }, null, 2));
1428
+ }
1429
+ else {
1430
+ // Printed once more here, next to the refusal, because the block above
1431
+ // scrolled past several runs ago — and it is the whole reason for it.
1432
+ for (const d of ds)
1433
+ console.error(formatDiagnostic(d));
1434
+ printChanges('--- changes (not written) ---', diff);
1435
+ console.error(`refusing to write ${outFile}: ${ds.map((d) => d.step).join(', ')} need re-recording, not repair — the run only passed because another skill covered the step`);
1436
+ }
1437
+ process.exit(1);
1438
+ };
1439
+ let changed = [...diff.lines.filter((l) => !l.endsWith(': no change')), ...evidenceLines];
1440
+ if (!changed.length && summary.reRecord.length) {
1441
+ if (json)
1442
+ console.log(JSON.stringify({ ...report(), wrote: null, refused: 'needs re-record' }, null, 2));
1443
+ else
1444
+ console.log('nothing could be repaired without re-recording — re-record the segment(s) listed above and compile again');
1445
+ process.exit(1);
1446
+ }
1447
+ for (let i = 1; i <= converge; i++) {
1448
+ runResetCmd(resetCmd, `converge ${i}/${converge}`, say);
1449
+ const { run: check } = await runStagedFlow(staged, mintVars(vars, i), `repair-${stamp}-${i}`, { headed: flags.has('headed'), onProgress });
1450
+ const checkTickets = check.driftTickets ?? [];
1451
+ // Fold and reorder BEFORE gating, not after: this run's misses are part of
1452
+ // the evidence this run is judged on. A candidate whose second miss lands
1453
+ // here is retired here, and the ticket that reported it is then exactly
1454
+ // what the codemod just recorded — a fact about the spec, not drift.
1455
+ if (!dryRun) {
1456
+ foldTicketEvidence(staged.store, checkTickets);
1457
+ const moved = reorderByEvidence(staged.store, retirementsReported);
1458
+ evidenceLines.push(...moved);
1459
+ for (const line of moved)
1460
+ say(` evidence ${line}`);
1461
+ }
1462
+ noteRun(`converge ${i}/${converge}`, check, checkTickets.length);
1463
+ for (const st of check.steps) {
1464
+ say(` [${st.status === 'success' ? 'OK' : st.status.toUpperCase()}] ${st.id} (tier ${st.tier ?? 'none'})${st.fellBack ? ` — fell back: ${st.fellBack}` : ''}${st.status === 'success' ? '' : ` — ${st.summary ?? ''}`}`);
1465
+ }
1466
+ refreshFlags();
1467
+ const bad = notConverged(check, dryRun ? undefined : staged.store, flagged);
1468
+ // A flagged step is not converged, but it is not a CONVERGENCE failure
1469
+ // either: no further run can clear it and no locator edit can repair it,
1470
+ // so it exits through gateRerecord (1) below rather than the gate's own
1471
+ // exit 3 — with every converge run's evidence in the sentence, which is
1472
+ // why the loop is allowed to finish.
1473
+ const others = bad.filter((line) => ![...flagged.keys()].some((id) => line.startsWith(`${id} (`)));
1474
+ say(`converge ${i}/${converge}: ${check.passed}/${check.total} step(s) ${check.status}, ${checkTickets.length} drift ticket(s)${bad.length ? '' : ' — clean'}`);
1475
+ if (others.length) {
1476
+ if (json)
1477
+ console.log(JSON.stringify({ ...report(), wrote: null, converged: false, notConverged: bad, convergeTickets: checkTickets }, null, 2));
1478
+ // The tickets, not just the step ids: a gate failure is only actionable
1479
+ // if it names the locator that missed and what resolved instead.
1480
+ for (const t of checkTickets) {
1481
+ console.error(` ticket: ${t.step} ${t.skill}${t.atStep ? `/${t.atStep}` : ''} ${t.key ?? ''}: ${t.missedLocator ?? t.reason ?? t.fellBack ?? 'recovered'}${t.fallbackUsed ? ` → used ${t.fallbackUsed}` : ' → nothing resolved'}`);
1482
+ }
1483
+ console.error(`not converged: ${bad.join(', ')}`);
1484
+ process.exit(3);
1485
+ }
1486
+ }
1487
+ // A convergence run is not only a check: a patch-segment variant is stored
1488
+ // PROVISIONAL and becomes the step's pin only when a run adopts it, so the
1489
+ // IR can legitimately move again between the drain and here. Re-diff rather
1490
+ // than emit a summary that predates the adoption.
1491
+ const finalSpec = reloadStaged(staged).spec;
1492
+ const finalDiff = diffSpecChanges(before, finalSpec);
1493
+ if (finalDiff.lines.join('\n') !== diff.lines.join('\n')) {
1494
+ diff = finalDiff;
1495
+ changed = [...diff.lines.filter((l) => !l.endsWith(': no change')), ...evidenceLines];
1496
+ printChanges('--- changes (after the convergence run(s) adopted what the repair proposed) ---', diff);
1497
+ gateExpectations();
1498
+ }
1499
+ // The last word before the write, and ahead of --dry-run's own report: a
1500
+ // dry run that says "0 change(s), nothing written" about a flow with a
1501
+ // demoted pin is the same silence sp8od shipped.
1502
+ gateRerecord();
1503
+ if (dryRun) {
1504
+ if (json)
1505
+ console.log(JSON.stringify({ ...report(), wrote: null, dryRun: true }, null, 2));
1506
+ else
1507
+ console.log(`dry run: ${changed.length} change(s), nothing written (would have written ${outFile})`);
1508
+ return;
1509
+ }
1510
+ // Re-emit from the repaired IR — the owned file is generated in full, every
1511
+ // time, so a promoted candidate shows up in the diff as a reordered chain in
1512
+ // both the FLOW constant and the generated step body.
1513
+ const emitted = emitFlowFile(finalSpec, { tier: 'plain' });
1514
+ fs.writeFileSync(outFile, emitted.source);
1515
+ if (!json) {
1516
+ for (const w of emitted.warnings)
1517
+ console.error(` warning: ${w}`);
1518
+ console.log(`wrote ${outFile} (${changed.length} change(s); the .spec.ts was not touched)`);
1519
+ }
1520
+ // The blind spot this closes: everything above ran the IR through the
1521
+ // DAEMON, so a defect in the emitter — a chain that lowers fine for replay
1522
+ // and transpiles to a Playwright call that never resolves — passes every
1523
+ // gate and still ships a spec that fails on the first run. The only way to
1524
+ // see it is to run the emitted spec the way a user will. It is one more real
1525
+ // run against the app, so it gets its own {n} slot and its own reset.
1526
+ if (flags.has('check-spec')) {
1527
+ specCheck = runSpecCheck({
1528
+ flowFile: outFile,
1529
+ vars: mintVars(vars, converge + 1),
1530
+ resetCmd,
1531
+ liveReplayPassed: true,
1532
+ // A failure at a step whose recording is the problem is NOT an emitter
1533
+ // defect, whatever the live replay reported (sp8od's --check-spec said
1534
+ // exactly that, and was wrong). Unreachable while gateRerecord refuses
1535
+ // the write, and passed anyway so the claim can never be made by accident.
1536
+ flagged,
1537
+ onProgress: (m) => (json ? console.error(m) : console.log(m)),
1538
+ });
1539
+ }
1540
+ if (json)
1541
+ console.log(JSON.stringify({ ...report(), wrote: outFile, converged: true }, null, 2));
1542
+ else if (specCheck) {
1543
+ console.log(specCheck.verdict);
1544
+ for (const d of specCheck.drift)
1545
+ console.log(` ${d}`);
1546
+ if (specCheck.workspace)
1547
+ console.log(` workspace: ${specCheck.workspace}`);
1548
+ }
1549
+ // The file STAYS written — the diff is the reviewer's, and a repair that
1550
+ // adapted a locator correctly is not undone by the emitter mis-spelling it.
1551
+ // What changes is the exit code, so a script cannot mistake this for a clean
1552
+ // repair.
1553
+ if (specCheck?.ran && !specCheck.passed) {
1554
+ console.error(`the emitted spec fails under plain Playwright: ${specCheck.verdict}`);
1555
+ console.error(`${outFile} was still written — review the diff, then fix the emitter (not the app)`);
1556
+ process.exit(4);
1557
+ }
1558
+ }
1559
+ // --- rerecord one step of a saved flow (the recording is wrong, not the app) ---
1560
+ /**
1561
+ * `sitelooper rerecord <flow> <step>` — the fix half of the diagnostics.
1562
+ *
1563
+ * `compile` and `repair` can both now SAY that a step's recording is the
1564
+ * problem (a demoted pin, or a step that only passes because the engine
1565
+ * replays a different skill than the one it is pinned to). Neither could do
1566
+ * anything about it: repair adapts locators, and no amount of locator
1567
+ * adaptation fixes a procedure whose first action was recorded against a state
1568
+ * the flow no longer reaches. The only repair for a wrong recording is another
1569
+ * recording — this command takes one, for one step, without re-recording the
1570
+ * whole session by hand.
1571
+ *
1572
+ * It owns no cleverness of its own: it unpins the step (spec/rerecord.ts
1573
+ * `unpinStep`), replays the flow the ordinary way in learning mode, and lets
1574
+ * the store's re-pin rule decide. The verdict is the bar a compiled spec has
1575
+ * to clear — the last run replays the step at tier A, with the pin the
1576
+ * re-recording made.
1577
+ */
1578
+ async function rerecordFlowCommand(positional, flags, json, onProgress) {
1579
+ const usage = 'usage: rerecord <flow-name-or-path> <step-id> [--instruction "<text>"] [--var k=v ...] [--runs n] [--reset-cmd "<cmd>"] [--json]';
1580
+ const [nameOrPath, stepId] = positional;
1581
+ if (!nameOrPath || !stepId)
1582
+ fail(usage, 2);
1583
+ const loaded = loadFlowFile(nameOrPath);
1584
+ if (!loaded)
1585
+ fail(`no flow "${nameOrPath}" — pass a path to a flow .json, or a name from "sitelooper flow list"`, 2);
1586
+ const { flow, file } = loaded;
1587
+ const runsWanted = flags.has('runs') ? Number(flags.get('runs')) : 2;
1588
+ if (!Number.isInteger(runsWanted) || runsWanted < 1)
1589
+ fail('--runs takes a positive integer', 2);
1590
+ const instruction = flags.get('instruction') ? String(flags.get('instruction')) : undefined;
1591
+ const resetCmd = flags.get('reset-cmd') ? String(flags.get('reset-cmd')) : undefined;
1592
+ // Everything that can refuse, refuses BEFORE a browser starts: an unknown
1593
+ // step id or a missing --var costs a daemon spawn and a sign-in otherwise,
1594
+ // and this command's runs are real runs against the app.
1595
+ const previous = flow.steps.find((s) => s.id === stepId);
1596
+ let patched;
1597
+ try {
1598
+ patched = unpinStep(flow, stepId, instruction);
1599
+ }
1600
+ catch (err) {
1601
+ if (err instanceof RerecordError)
1602
+ return fail(err.message, 2);
1603
+ throw err;
1604
+ }
1605
+ const vars = varFlags();
1606
+ const missingVars = flow.vars.filter((v) => !(v in vars));
1607
+ if (missingVars.length)
1608
+ fail(`flow "${flow.name}" needs --var for: ${missingVars.join(', ')}`, 2);
1609
+ const say = (m) => {
1610
+ if (json)
1611
+ console.error(m);
1612
+ else
1613
+ console.log(m);
1614
+ };
1615
+ const stamp = Date.now().toString(36);
1616
+ let backup;
1617
+ try {
1618
+ backup = backupFlowFile(file, stamp);
1619
+ }
1620
+ catch (err) {
1621
+ if (err instanceof RerecordError)
1622
+ return fail(err.message, 2);
1623
+ throw err;
1624
+ }
1625
+ saveFlow(patched, file);
1626
+ say(`re-recording ${flow.name} step ${stepId} (${runsWanted} run(s))`);
1627
+ say(` unpinned ${previous?.skill ?? '(no procedure)'}${instruction ? ', with a new instruction' : ''}; old recording kept at ${backup}`);
1628
+ const runs = [];
1629
+ for (let i = 0; i < runsWanted; i++) {
1630
+ const label = `run ${i + 1}`;
1631
+ runResetCmd(resetCmd, label, say);
1632
+ // The same path `sitelooper run` takes — daemon, recovery ladder, learning
1633
+ // mode — pointed at the REAL skill store, because the whole point is that
1634
+ // the procedure this records survives into it.
1635
+ // Whatever the daemon says about THIS step (a re-pin refusal above all)
1636
+ // is printed whether or not --progress is on, and kept for the verdict.
1637
+ const notes = [];
1638
+ const { run } = await runStagedFlow({ flowFile: file, skillsDir: skillsDir() }, mintVars(vars, i), `rerecord-${stamp}-${i}`, {
1639
+ headed: flags.has('headed'),
1640
+ onProgress: (m) => {
1641
+ const note = stepNote(m, stepId);
1642
+ if (note) {
1643
+ notes.push(note);
1644
+ say(` ${label}: ${stepId} ${note}`);
1645
+ }
1646
+ onProgress?.(m);
1647
+ },
1648
+ });
1649
+ const entry = { label, step: stepOf(run.steps, stepId), ...(notes.length ? { notes } : {}) };
1650
+ runs.push(entry);
1651
+ say(stepLine(stepId, entry));
1652
+ say(` ${run.flow}: ${run.passed}/${run.total} step(s) ${run.status}`);
1653
+ }
1654
+ const verdict = rerecordVerdict({ file, stepId, runs });
1655
+ // The daemon writes re-pins back into the flow file it was given, so the
1656
+ // authoritative answer to "what is this step pinned to now" is on disk.
1657
+ const after = loadFlowFile(file)?.flow.steps.find((s) => s.id === stepId);
1658
+ const pinned = after?.skill ?? verdict.pinned;
1659
+ const skill = pinned ? new SkillStore().get(pinned) : null;
1660
+ const payload = {
1661
+ flow: flow.name,
1662
+ file,
1663
+ step: stepId,
1664
+ backup,
1665
+ ok: verdict.ok,
1666
+ pinned: pinned ?? null,
1667
+ skill: skill ? { id: skill.id, status: skill.status, steps: skill.steps.length } : null,
1668
+ runs: runs.map((r) => ({
1669
+ label: r.label,
1670
+ status: r.step?.status ?? 'not-reached',
1671
+ tier: r.step?.tier ?? null,
1672
+ replayed: r.step?.replayed ?? null,
1673
+ repinned: r.step?.repinned ?? null,
1674
+ turns: r.step?.turns ?? null,
1675
+ })),
1676
+ diagnostics: verdict.ok ? [] : [verdict.diagnostic],
1677
+ };
1678
+ // Diagnostics first, before the counts and the file paths.
1679
+ if (!verdict.ok)
1680
+ say(formatRerecordDiagnostic(verdict.diagnostic));
1681
+ if (json)
1682
+ console.log(JSON.stringify(payload, null, 2));
1683
+ else if (verdict.ok) {
1684
+ say(`${stepId}: pinned ${pinned}${skill ? ` (${skill.status}, ${skill.steps.length} action(s))` : ''}`);
1685
+ say(`${file} updated — the previous recording is at ${backup}`);
1686
+ }
1687
+ else {
1688
+ say(`${file} still holds the re-recorded step; restore the old one with: cp ${backup} ${file}`);
1689
+ }
1690
+ process.exit(verdict.ok ? 0 : 1);
1691
+ }
1692
+ //# sourceMappingURL=cli.js.map