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
@@ -0,0 +1,1041 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { rootDir } from '../shared/paths.js';
4
+ import { escapeRe, urlParts } from './compile.js';
5
+ import { idPositionPart, identifierLike } from './ledger.js';
6
+ /**
7
+ * Outputs a later run may substitute as a LITERAL when the producing step did
8
+ * not republish them: `{{sid.out}}` → the recorded value.
9
+ *
10
+ * One demonstration of difference is permanent. A value that changed once
11
+ * names a record, and being wrong in that direction is the silent failure —
12
+ * a step acting on the recording run's record while reporting success —
13
+ * whereas being wrong the other way costs a recovery turn. So `differed` is a
14
+ * veto no amount of later agreement lifts.
15
+ */
16
+ export function stableOutputs(flow) {
17
+ const out = {};
18
+ for (const step of flow.steps) {
19
+ for (const [name, ev] of Object.entries(step.outputEvidence ?? {})) {
20
+ if (ev.differed > 0 || ev.same < 1)
21
+ continue;
22
+ const value = step.recorded?.[name];
23
+ if (typeof value === 'string' && value)
24
+ out[`${step.id}.${name}`] = value;
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+ /**
30
+ * Record what a replay of `step` produced, against what the recording run saw.
31
+ * Only outputs BOTH runs reported can be compared — a tier-A replay honestly
32
+ * drops what it could not re-observe, and silence is not disagreement.
33
+ * Returns the names whose verdict changed, for progress reporting.
34
+ */
35
+ export function noteOutputEvidence(step, reported) {
36
+ const changed = [];
37
+ for (const [name, recorded] of Object.entries(step.recorded ?? {})) {
38
+ const seen = reported[name];
39
+ if (typeof seen !== 'string' || !seen || typeof recorded !== 'string' || !recorded)
40
+ continue;
41
+ const ev = (step.outputEvidence ??= {})[name] ?? { same: 0, differed: 0 };
42
+ const agrees = seen.trim() === recorded.trim();
43
+ const wasStable = ev.differed === 0 && ev.same >= 1;
44
+ if (agrees)
45
+ ev.same += 1;
46
+ else
47
+ ev.differed += 1;
48
+ step.outputEvidence[name] = ev;
49
+ if (wasStable !== (ev.differed === 0 && ev.same >= 1))
50
+ changed.push(name);
51
+ else if (ev.same + ev.differed === 1)
52
+ changed.push(name);
53
+ }
54
+ return changed;
55
+ }
56
+ export function flowsDir() {
57
+ return process.env.SITELOOPER_FLOWS_DIR || path.join(rootDir(), 'flows');
58
+ }
59
+ function flowFile(name) {
60
+ const safe = name.replace(/[^A-Za-z0-9._-]+/g, '_');
61
+ return path.join(flowsDir(), `${safe}.json`);
62
+ }
63
+ /**
64
+ * Write a flow. `file` defaults to the flows dir under the flow's own name;
65
+ * a run that loaded the flow from somewhere else passes that path back so
66
+ * evidence and re-pins land where they came from. Tmp + rename, so a reader
67
+ * never sees a half-written file.
68
+ */
69
+ export function saveFlow(flow, file = flowFile(flow.name)) {
70
+ fs.mkdirSync(path.dirname(file), { recursive: true });
71
+ const tmp = `${file}.${process.pid}.tmp`;
72
+ fs.writeFileSync(tmp, JSON.stringify(flow, null, 2));
73
+ fs.renameSync(tmp, file);
74
+ return file;
75
+ }
76
+ /**
77
+ * A flow the export REFUSED, written where nothing will replay it.
78
+ *
79
+ * Refusing is right — a flow carrying a run value in a locator quietly does
80
+ * its work on the wrong record — but throwing the recording away with it is
81
+ * not. fwrd23l cost 37 minutes and $0.26 to record and left nothing at all
82
+ * behind, and a cloud run is dearer. The `.rejected.json` suffix keeps it out
83
+ * of listFlows — which is excluded explicitly, since `.rejected.json` ends in
84
+ * `.json` too — while leaving it for verify-artifacts and for a human to read.
85
+ */
86
+ export function saveRejectedFlow(flow, reason) {
87
+ const dir = flowsDir();
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ const file = flowFile(flow.name).replace(/\.json$/, '.rejected.json');
90
+ fs.writeFileSync(file, JSON.stringify({ rejected: reason, flow }, null, 2));
91
+ return file;
92
+ }
93
+ /** A flow and the file it was read from — a path first, then a name in the flows dir. */
94
+ export function loadFlowFile(nameOrPath) {
95
+ const candidates = [nameOrPath, flowFile(nameOrPath)];
96
+ for (const file of candidates) {
97
+ try {
98
+ return { flow: JSON.parse(fs.readFileSync(file, 'utf8')), file };
99
+ }
100
+ catch {
101
+ /* try next */
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+ export function loadFlow(nameOrPath) {
107
+ return loadFlowFile(nameOrPath)?.flow ?? null;
108
+ }
109
+ export function listFlows() {
110
+ let names;
111
+ try {
112
+ names = fs.readdirSync(flowsDir()).filter((n) => n.endsWith('.json') && !n.endsWith('.rejected.json'));
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ return names.map((n) => loadFlow(path.join(flowsDir(), n))).filter((f) => Boolean(f));
118
+ }
119
+ /**
120
+ * Build a flow from one session's recording.
121
+ *
122
+ * References are resolved in two passes so a step can never reference a value
123
+ * that has not been produced yet:
124
+ * 1. Declared run variables (`vars`) → `{{name}}`, everywhere they occur.
125
+ * 2. A value that equals an *earlier* step's named output → `{{stepId.output}}`.
126
+ * Anything left literal is a constant the caller typed, and stays literal.
127
+ */
128
+ export function buildFlow(entries, opts) {
129
+ const groups = resolveGroups(groupByInstruction(entries));
130
+ if (!groups.length)
131
+ return null;
132
+ const steps = [];
133
+ const warnings = [];
134
+ const produced = [];
135
+ const seenUrl = new Set(urlParts(opts.startUrl).map((p) => p.value));
136
+ const varEntries = Object.entries(opts.vars).filter(([, v]) => v.length >= 2).sort((a, b) => b[1].length - a[1].length);
137
+ let prevId;
138
+ let prevGroup;
139
+ groups.forEach((g, i) => {
140
+ const id = stepId(g.instruction.text, i);
141
+ // Read the RAW instruction, before references go in: a substituted
142
+ // {{02-create.quotation_ref}} shifts every later word and can push the
143
+ // ask outside the scan window.
144
+ const noop = noopStepWarning(id, g);
145
+ if (noop)
146
+ warnings.push(noop);
147
+ if (prevId && prevGroup) {
148
+ const contradiction = contradictionWarning(prevId, prevGroup, id, g);
149
+ if (contradiction)
150
+ warnings.push(contradiction);
151
+ }
152
+ prevId = id;
153
+ prevGroup = g;
154
+ let text = g.instruction.text;
155
+ for (const [name, value] of varEntries)
156
+ text = replaceToken(text, value, `{{${name}}}`);
157
+ // Reference earlier outputs (longest values first so nested ids resolve).
158
+ for (const p of [...produced].sort((a, b) => b.value.length - a.value.length)) {
159
+ if (p.value.length >= 2 && !coincidental(text, p.value))
160
+ text = replaceToken(text, p.value, `{{${p.stepId}.${p.output}}}`);
161
+ }
162
+ const outputs = Object.keys(g.report?.values ?? {});
163
+ // Capture the skill's slot bindings, referencized like the instruction, so
164
+ // replay binds params from the flow rather than re-parsing the wording.
165
+ let params;
166
+ if (g.report?.skill && opts.bind) {
167
+ const raw = opts.bind(g.report.skill, g.instruction.text);
168
+ if (raw) {
169
+ params = {};
170
+ for (const [k, v] of Object.entries(raw)) {
171
+ let rv = v;
172
+ for (const [name, value] of varEntries)
173
+ rv = replaceToken(rv, value, `{{${name}}}`);
174
+ for (const pr of [...produced].sort((a, b) => b.value.length - a.value.length)) {
175
+ if (pr.value.length >= 2 && !coincidental(rv, pr.value))
176
+ rv = replaceToken(rv, pr.value, `{{${pr.stepId}.${pr.output}}}`);
177
+ }
178
+ params[k] = rv;
179
+ }
180
+ }
181
+ }
182
+ steps.push({
183
+ id,
184
+ instruction: text,
185
+ ...(g.report?.skill ? { skill: g.report.skill } : {}),
186
+ ...(params ? { params } : {}),
187
+ outputs,
188
+ recorded: g.report?.values ?? {},
189
+ ...(g.adopted ? { adopted: true } : {}),
190
+ });
191
+ // Provenance (PLAN-replay-v2): url parts this step MINTED (absent from
192
+ // every earlier url) are outputs too — a later step's recorded literal
193
+ // equal to one becomes {{stepId.url.<part>}}, re-bound each run from
194
+ // where the replay's own browser lands. Same guards as report outputs:
195
+ // length >= 4, first appearance wins.
196
+ //
197
+ // Url parts go into `produced` BEFORE report values, and a report value
198
+ // that duplicates a minted part is not referencized under its report
199
+ // name: a zero-model (tier-A) replay synthesizes its report from live
200
+ // read-backs only and honestly DROPS recorded values it could not
201
+ // re-observe, so a {{step.reportName}} ref dies exactly when the replay
202
+ // is at its best — while {{step.url.<part>}} is published by every
203
+ // replay unconditionally. fwgr-n2/n3 halted on precisely this: the
204
+ // recorded instructions referenced {{02-create.dashboard_uid}}, the
205
+ // tier-A replay's report legitimately omitted it, and recovery ran with
206
+ // the uid blanked until it turn-capped.
207
+ const minted = [];
208
+ if (g.endUrl) {
209
+ // The WHOLE url, not only its parts. A step's params often carry it
210
+ // entire ("On ticket {{v1}} (url {{v2}})"), and without provenance that
211
+ // literal gets referencized under whatever the RECORDING run's report
212
+ // happened to name it. fwrd21l shows the cost: 02-add's model report
213
+ // named it `url`, so the flow said {{02-add.url}} — then on replay 02-add
214
+ // went tier A, synthesizeReport honestly dropped a recorded url it could
215
+ // not re-observe, the ref went unresolved, and FOUR later steps skipped
216
+ // the zero-model path entirely. Exactly the fwgr-n2/n3 failure the parts
217
+ // loop below was written for, one level up.
218
+ if (!produced.some((p) => p.value === g.endUrl) && !varEntries.some(([, v]) => v === g.endUrl)) {
219
+ minted.push({ stepId: id, output: 'url', value: g.endUrl });
220
+ }
221
+ for (const part of urlParts(g.endUrl)) {
222
+ const fresh = !seenUrl.has(part.value);
223
+ seenUrl.add(part.value);
224
+ // Looser than compile-level derived params, which demand a digit:
225
+ // grafana mints digitless uids ("cfwcsdxqdjabkf" sank fwgr2), so
226
+ // identifierLike() accepts a long word too. It still refuses short
227
+ // route words — "tickets" out of a url was being substituted into
228
+ // fwrd8's verify prose ("on the {{01-open.url.h0}} list").
229
+ // Position is evidence here too: a `q.id` part is a record id
230
+ // whatever its length — odoo's `#id=44` failed the shape test and the
231
+ // recording's record rode into fwod27's replays (see idPositionPart).
232
+ if (!fresh || (!identifierLike(part.value) && !idPositionPart(part)))
233
+ continue;
234
+ if (produced.some((p) => p.value === part.value) || varEntries.some(([, v]) => v === part.value))
235
+ continue;
236
+ minted.push({ stepId: id, output: `url.${part.label}`, value: part.value });
237
+ }
238
+ }
239
+ produced.push(...minted);
240
+ for (const [output, value] of Object.entries(g.report?.values ?? {})) {
241
+ if (typeof value !== 'string' || !value)
242
+ continue;
243
+ if (minted.some((m) => m.value === value))
244
+ continue;
245
+ // A value the step's own instruction handed it is an INPUT the report
246
+ // echoed, not something the step observed: a later step quoting it
247
+ // resolves the way this step did — a var, an earlier reference, or a
248
+ // constant of the flow — never through this step's output, which a
249
+ // tier-A replay has no read to republish. fwgr23 05-open referenced
250
+ // {{04-open.tag}} for the "bench" its own instruction typed; the
251
+ // zero-model replay of 04-open could not publish it, and 05-open went
252
+ // to the model on every replay (19–44 turns).
253
+ if (replaceToken(g.instruction.text, value, ' ') !== g.instruction.text)
254
+ continue;
255
+ // EVERY reported value becomes a reference. Run 1 makes no judgement
256
+ // about which of them name a record, because it cannot: "New (unsaved)"
257
+ // and "S00021" are both just strings a step reported, and the question
258
+ // — does the app produce this again, or was it specific to this run? —
259
+ // is about behaviour ACROSS runs.
260
+ //
261
+ // A previous cut of this gated on identifierLike, which reads the
262
+ // characters. That is the failure this plan exists to remove: a record
263
+ // id that does not look like one would be left literal and every replay
264
+ // would act on run 1's record while reporting success.
265
+ //
266
+ // So reference everything, which is the safe default (an unresolved
267
+ // reference costs a recovery turn, never a wrong record), and let run 2
268
+ // demote the ones it demonstrates are app furniture — see
269
+ // noteOutputEvidence/stableOutputs and PLAN-evidence-over-shape.md.
270
+ produced.push({ stepId: id, output, value });
271
+ // An id can be minted where no url ever carries it: an app that saves
272
+ // over its own API answers with JSON, and the run reads that answer
273
+ // back rather than navigating. fwgr5 created its dashboard exactly so —
274
+ // the uid existed only inside the response body — and every later step
275
+ // kept n1's literal uid, which is what made those steps re-derive it on
276
+ // the cheap model on every replay. Publish the JSON's scalar leaves
277
+ // under `{{step.output#path}}`: a tier-A replay re-observes the read,
278
+ // so the path re-reads THIS run's value.
279
+ for (const leaf of jsonLeaves(value)) {
280
+ if (produced.some((p) => p.value === leaf.value) || varEntries.some(([, v]) => v === leaf.value))
281
+ continue;
282
+ produced.push({ stepId: id, output: `${output}#${leaf.path}`, value: leaf.value });
283
+ }
284
+ }
285
+ });
286
+ return {
287
+ name: opts.name,
288
+ origin: opts.origin,
289
+ startUrl: opts.startUrl,
290
+ vars: Object.keys(opts.vars),
291
+ steps,
292
+ provenance: { session: opts.session, created: opts.now ?? new Date().toISOString(), ...(opts.model ? { model: opts.model } : {}) },
293
+ ...(warnings.length ? { warnings } : {}),
294
+ };
295
+ }
296
+ function groupByInstruction(entries) {
297
+ const groups = [];
298
+ for (const e of entries) {
299
+ if (e.k === 'instruction') {
300
+ // An escalation continuation (recorded under the original wording,
301
+ // marked `resume`) is the same instruction still in flight: keep the
302
+ // predecessor's group open so its clean start context survives and the
303
+ // continuation's report/endUrl land on it. A resume with no same-text
304
+ // predecessor (truncated recording) stands alone.
305
+ const prev = groups[groups.length - 1];
306
+ if (e.resume && prev?.instruction.text === e.text)
307
+ continue;
308
+ groups.push({ instruction: e, mutations: 0, mutationsDiffed: 0, mutationsEffective: 0 });
309
+ }
310
+ else if (e.k === 'report' && groups.length)
311
+ groups[groups.length - 1].report = e;
312
+ else if (e.k === 'step' && groups.length) {
313
+ const g = groups[groups.length - 1];
314
+ if (e.diff?.url)
315
+ g.endUrl = e.diff.url;
316
+ if (!g.firstTool)
317
+ g.firstTool = e.tool;
318
+ if (MUTATING_TOOLS.has(e.tool)) {
319
+ g.mutations += 1;
320
+ if (e.diff) {
321
+ g.mutationsDiffed += 1;
322
+ const moved = Boolean(e.diff.url) && Boolean(g.instruction.url) && e.diff.url !== g.instruction.url;
323
+ if (e.diff.added?.length || e.diff.alerts?.length || moved)
324
+ g.mutationsEffective += 1;
325
+ }
326
+ }
327
+ }
328
+ }
329
+ return groups;
330
+ }
331
+ /** Same page, ignoring query and hash — view state, not location. */
332
+ function samePage(a, b) {
333
+ if (!a || !b)
334
+ return false;
335
+ try {
336
+ const ua = new URL(a);
337
+ const ub = new URL(b);
338
+ return ua.origin === ub.origin && decodeURIComponent(ua.pathname) === decodeURIComponent(ub.pathname);
339
+ }
340
+ catch {
341
+ return false;
342
+ }
343
+ }
344
+ /**
345
+ * The groups that ARE the resolved path, in order.
346
+ *
347
+ * Success groups, obviously. But dropping every other group loses work the
348
+ * session provably built on: fwgr14's "create a NEW dashboard. Add a Stat
349
+ * panel..." blocked on the turn budget, then failed on escalation — yet both
350
+ * attempts HAD created the dashboard, and the very next (successful)
351
+ * instruction began "The browser is on an unsaved new Grafana dashboard..."
352
+ * and saved it. The exported flow started on in-memory state no replay could
353
+ * reach and scored 1/6 on every replay. Same class as fwod27, where the
354
+ * dropped create meant no step produced the record and its id could not be
355
+ * referencized.
356
+ *
357
+ * So a non-success group is ADOPTED when the recording itself testifies its
358
+ * work is part of the path:
359
+ * 1. it changed the app (ran mutating tools) — an observing group that
360
+ * blocked contributed nothing a replay needs;
361
+ * 2. the next kept group picked up exactly where it left off — issued on
362
+ * the same page the group ended on, and not opening with a `goto`
363
+ * (a successor that navigates away first is the workaround case, where
364
+ * the drop is correct).
365
+ * Scanned right-to-left so a chain of continuations adopts as a chain.
366
+ *
367
+ * Marks `adopted` on the group (unbankedMutations reads it) and returns the
368
+ * kept groups.
369
+ */
370
+ function resolveGroups(groups) {
371
+ const kept = groups.map((g) => g.report?.status === 'success');
372
+ for (let i = groups.length - 2; i >= 0; i--) {
373
+ if (kept[i])
374
+ continue;
375
+ const g = groups[i];
376
+ const next = groups[i + 1];
377
+ if (!g.report || !g.mutations || !kept[i + 1])
378
+ continue;
379
+ if (next.firstTool === 'goto')
380
+ continue;
381
+ if (!samePage(g.endUrl, next.instruction.url))
382
+ continue;
383
+ g.adopted = true;
384
+ kept[i] = true;
385
+ }
386
+ return groups.filter((_, i) => kept[i]);
387
+ }
388
+ /**
389
+ * Verbs whose presence makes an instruction MUTATING BY INTENT — the caller
390
+ * asked for the app to be different afterwards, not merely observed.
391
+ */
392
+ const MUTATING_VERBS = [
393
+ 'create', 'add', 'delete', 'remove', 'cancel', 'confirm', 'change', 'update',
394
+ 'set', 'save', 'submit', 'move', 'archive', 'rename', 'upload',
395
+ ];
396
+ /**
397
+ * One verb, in the shapes an orchestrator actually writes it. Spelled out
398
+ * rather than a `verb\w{0,4}` wildcard on purpose: the wildcard makes "set"
399
+ * match "settings" and "settled", and every false verb here becomes a false
400
+ * warning against a step that is fine.
401
+ */
402
+ function verbForms(verb) {
403
+ const noE = verb.replace(/e$/, '');
404
+ const last = verb[verb.length - 1];
405
+ return [verb, `${verb}s`, `${verb}es`, `${verb}d`, `${verb}ed`, `${verb}ing`, `${noE}ing`, `${verb}${last}ed`, `${verb}${last}ing`];
406
+ }
407
+ const MUTATING_VERB_RE = new RegExp(`^(?:${MUTATING_VERBS.flatMap(verbForms).join('|')})$`, 'i');
408
+ /** A word that turns the verb after it into an instruction NOT to do the thing. */
409
+ const NEGATOR_RE = /^(?:not|never|no|without|cannot|don't|dont|doesn't|isn't|avoid|skip)$/i;
410
+ /**
411
+ * An instruction that says, anywhere, that it changes nothing. fwod34's
412
+ * 07-open and 09-change both do ("this is a read-only check", "Read-only
413
+ * check, do not change anything") while both quote a mutating verb — 09's
414
+ * step id is literally `09-change`. Cheap, exact, and it costs only warnings
415
+ * we would rather not have made.
416
+ */
417
+ const READ_ONLY_RE = /read[- ]?only|do(?: not|n't|nt) (?:change|modify|edit|alter)|without (?:chang|modify|edit)/i;
418
+ /**
419
+ * The verb this instruction asks for, or null if it asks for nothing that
420
+ * changes the app.
421
+ *
422
+ * Scans the opening of the instruction — where the orchestrator states the
423
+ * job, before the how-to prose and the reporting boilerplate. The window is
424
+ * WIDE (40 words) because real wording puts the ask late: fwod34's 08-open
425
+ * spends nineteen words identifying the record ("The sales order S00021
426
+ * (model sale.order, record id 21) is currently in 'Sales Order' status and
427
+ * needs to be") before it says "cancelled". A wide window is safe only
428
+ * because both guards above run first — a negated verb and a self-declared
429
+ * read-only instruction are dropped whatever the window.
430
+ */
431
+ export function mutatingIntent(instruction) {
432
+ if (READ_ONLY_RE.test(instruction))
433
+ return null;
434
+ const words = instruction.split(/\s+/).slice(0, 40).map((w) => w.replace(/^[^A-Za-z']+|[^A-Za-z']+$/g, ''));
435
+ for (let i = 0; i < words.length; i++) {
436
+ if (!MUTATING_VERB_RE.test(words[i]))
437
+ continue;
438
+ if (words.slice(Math.max(0, i - 6), i).some((w) => NEGATOR_RE.test(w)))
439
+ continue;
440
+ const lower = words[i].toLowerCase();
441
+ const verb = MUTATING_VERBS.find((v) => verbForms(v).some((f) => f === lower));
442
+ if (verb)
443
+ return verb;
444
+ }
445
+ return null;
446
+ }
447
+ /**
448
+ * A value this instruction reported that the page was ALREADY showing before
449
+ * it ran — the strongest single line of evidence that the step's outcome was
450
+ * not the step's doing.
451
+ *
452
+ * Conservative by construction: single-line values only (a status bar's whole
453
+ * multi-line text is never a fair substring test), at least three characters,
454
+ * and an exact match against the pre-state snapshot. When nothing qualifies
455
+ * the caller omits the clause rather than guessing.
456
+ */
457
+ function alreadyShown(g) {
458
+ const before = g.instruction.startText;
459
+ if (!before)
460
+ return null;
461
+ for (const value of Object.values(g.report?.values ?? {})) {
462
+ if (typeof value !== 'string')
463
+ continue;
464
+ const v = value.trim();
465
+ if (v.length < 3 || v.includes('\n'))
466
+ continue;
467
+ if (before.includes(v))
468
+ return v;
469
+ }
470
+ return null;
471
+ }
472
+ /**
473
+ * A step whose instruction asked for a change and whose recording shows none.
474
+ *
475
+ * This is the record-time half of the fwod34 08-open failure. The recording
476
+ * orchestrator wrote an instruction to cancel an order a previous instruction
477
+ * had already been told to cancel; the step reported success, and every later
478
+ * replay halts there because the Cancel button its skill clicks does not
479
+ * exist once the order is cancelled. Every fact needed to say "re-record
480
+ * 08-open" was already in the recording at export time — this reads them.
481
+ *
482
+ * Two shapes count as "changed nothing", and both need the report to say
483
+ * SUCCESS (a blocked or failed instruction is reported elsewhere, and its
484
+ * emptiness is expected rather than suspicious):
485
+ *
486
+ * 1. the instruction ran no state-changing tool at all; or
487
+ * 2. it ran them and NONE of them moved the page — no signature line added,
488
+ * no alert, no navigation. fwod34's 08-open is this one: five clicks and
489
+ * an Escape against a form whose cancel dialog the previous instruction
490
+ * had already left open, every diff empty on all three counts, while its
491
+ * five genuinely mutating siblings (02-create through 05-open) each show
492
+ * one to eighteen effective diffs.
493
+ *
494
+ * Shape 2 demands that diffs were being captured at all (`mutationsDiffed`),
495
+ * so a recording made without them cannot be read as a flow of no-ops.
496
+ *
497
+ * Warn-level, and worded as a suspicion: the flow still exports. What it buys
498
+ * is that whoever can cheaply re-record is told, and that the warning rides
499
+ * on the flow (`Flow.warnings`) for compile to raise again later.
500
+ */
501
+ function noopStepWarning(id, g) {
502
+ if (g.report?.status !== 'success')
503
+ return null;
504
+ const verb = mutatingIntent(g.instruction.text);
505
+ if (!verb)
506
+ return null;
507
+ const noAction = g.mutations === 0;
508
+ const noEffect = g.mutations > 0 && g.mutationsDiffed > 0 && g.mutationsEffective === 0;
509
+ if (!noAction && !noEffect)
510
+ return null;
511
+ const evidence = noAction
512
+ ? 'the recording made no state-changing action'
513
+ : `the recording's ${g.mutations} state-changing action${g.mutations === 1 ? '' : 's'} left the page unchanged`;
514
+ const shown = alreadyShown(g);
515
+ const clause = shown ? `, and the page already showed '${shown}' before it ran` : '';
516
+ return `noop-step: ${id} changed nothing: its instruction asks to ${verb}, ${evidence}${clause}. The step may be redundant.`;
517
+ }
518
+ /**
519
+ * A mutating step's report contradicted by the very next read-only step.
520
+ *
521
+ * fwod34's 06-open reported "Cancelled" for the sales order; 07-open, a
522
+ * read-only step immediately after it, read the same order's status back as
523
+ * "Sales Order" — the value the order carries whenever it is NOT cancelled.
524
+ * Nothing in the flow said this out loud: compile just kept both facts and
525
+ * let a much later step (08-open, told to cancel an order that was already
526
+ * cancelled) take the blame. The contradiction is visible at export time —
527
+ * this reads it directly off the two instructions' report values, the same
528
+ * way `noopStepWarning` reads a step's own report against its own pre-state.
529
+ *
530
+ * Scope is deliberately narrow: `i` must be mutating by intent and report
531
+ * success (a step that failed or was never asked to change anything cannot
532
+ * be "contradicted" — there is nothing for the next read to disagree with),
533
+ * and `j` must immediately follow `i` with no gap and be read-only by intent
534
+ * (a second mutating step is expected to change what the first one did, so
535
+ * comparing it would be noise, not a contradiction).
536
+ *
537
+ * Values are matched by label first (the natural case — the same field read
538
+ * twice), falling back to any label pair where BOTH names look like a status
539
+ * or state field, since an orchestrator's wording for the same field drifts
540
+ * step to step ("order_status" vs "current_status"). Only the first line of
541
+ * each value is compared: a status bar lists every reachable state on one
542
+ * line each, so the first line is the CURRENT one and the rest is noise the
543
+ * same way `alreadyShown` treats it. Containment either way counts as
544
+ * agreement (a single-line report next to a status bar's fuller line, or a
545
+ * value that reappeared verbatim, is not a contradiction) — only two first
546
+ * lines that share nothing warrant a warning.
547
+ */
548
+ function contradictionWarning(idI, gi, idJ, gj) {
549
+ if (gi.report?.status !== 'success')
550
+ return null;
551
+ if (!mutatingIntent(gi.instruction.text))
552
+ return null;
553
+ if (mutatingIntent(gj.instruction.text))
554
+ return null;
555
+ const iValues = gi.report?.values ?? {};
556
+ const jValues = gj.report?.values ?? {};
557
+ for (const [label, jRaw] of Object.entries(jValues)) {
558
+ if (typeof jRaw !== 'string')
559
+ continue;
560
+ let iLabel = typeof iValues[label] === 'string' ? label : undefined;
561
+ if (!iLabel && /status|state/i.test(label)) {
562
+ iLabel = Object.keys(iValues).find((k) => typeof iValues[k] === 'string' && /status|state/i.test(k));
563
+ }
564
+ if (!iLabel)
565
+ continue;
566
+ const iRaw = iValues[iLabel];
567
+ const jLine = jRaw.split('\n')[0].trim();
568
+ const iLine = iRaw.split('\n')[0].trim();
569
+ if (!jLine || !iLine)
570
+ continue;
571
+ const lj = jLine.toLowerCase();
572
+ const li = iLine.toLowerCase();
573
+ if (lj.includes(li) || li.includes(lj))
574
+ continue;
575
+ return `contradicted-step: ${idJ} read ${label} "${jLine}" right after ${idI} reported "${iLine}"; ${idI}'s change may not have landed and a later step may be retrying it. Re-record ${idI}.`;
576
+ }
577
+ return null;
578
+ }
579
+ /** Tools that CHANGE the app, as opposed to observing it. */
580
+ const MUTATING_TOOLS = new Set(['click', 'dblclick', 'right_click', 'modifier_click', 'fill', 'type', 'press', 'select', 'check', 'drag', 'upload']);
581
+ /**
582
+ * Instructions that CHANGED the app but did not report success AND were not
583
+ * adopted, so their work contributed nothing to the flow.
584
+ *
585
+ * resolveGroups now adopts the fwgr13/fwgr14 shape (the next kept group
586
+ * carried straight on from the blocked work), so what remains here is the
587
+ * genuinely dropped case: mutating work the session abandoned or worked
588
+ * around. That drop is right — but it must not be silent, because whether the
589
+ * workaround actually replaced the work is a judgement only the person
590
+ * reading the export can make.
591
+ */
592
+ /**
593
+ * Flow instructions that quote a DATABASE ID this recording minted.
594
+ *
595
+ * The one channel no leak guard reads is the instruction prose itself. In
596
+ * fwod27 the recording-time orchestrator wrote "You are on an Odoo contact
597
+ * form for res.partner id 44" — the id of the record ITS run created (in a
598
+ * blocked instruction, so no flow step produces the value and nothing can be
599
+ * referencized). Locator and navigation guards all passed; both replays
600
+ * navigated to record 44, which the reset had deleted, and halted at step 2
601
+ * with 0/6.
602
+ *
603
+ * Scans EVERY recorded url (blocked instructions included — that is where
604
+ * fwod27's id was minted) for id-position parts, then flags any flow
605
+ * instruction that still quotes one as a literal id. Warn-level: the flow
606
+ * still exports, but the person who can re-record is told while it is cheap.
607
+ */
608
+ export function staleInstructionIds(entries, flow) {
609
+ const minted = new Set();
610
+ for (const e of entries) {
611
+ const url = e.k === 'step' ? e.diff?.url : e.k === 'instruction' ? e.url : undefined;
612
+ if (!url)
613
+ continue;
614
+ for (const part of urlParts(url))
615
+ if (idPositionPart(part))
616
+ minted.add(part.value);
617
+ }
618
+ if (!minted.size)
619
+ return [];
620
+ const out = [];
621
+ const seen = new Set();
622
+ for (const step of flow.steps) {
623
+ for (const m of step.instruction.matchAll(/\bid\s*[:#]?\s*(\d{1,10})\b/gi)) {
624
+ if (!minted.has(m[1]) || seen.has(`${step.id}:${m[1]}`))
625
+ continue;
626
+ seen.add(`${step.id}:${m[1]}`);
627
+ out.push(`${step.id}'s instruction quotes record id ${m[1]} — a database id this recording minted, so every replay ` +
628
+ `will act on the RECORDING run's record (deleted by the next reset). Re-record with instructions that ` +
629
+ `name records by what is on screen (a name or reference), never by internal id.`);
630
+ }
631
+ }
632
+ return out;
633
+ }
634
+ export function unbankedMutations(entries) {
635
+ const groups = groupByInstruction(entries);
636
+ resolveGroups(groups); // marks `adopted` in place
637
+ const out = [];
638
+ for (const g of groups) {
639
+ if (g.report?.status === 'success' || g.adopted || !g.mutations)
640
+ continue;
641
+ const text = g.instruction.text;
642
+ out.push(`instruction "${text.slice(0, 70)}${text.length > 70 ? '…' : ''}" ran ${g.mutations} state-changing step(s) ` +
643
+ `but reported ${g.report ? g.report.status : 'nothing'} — its work is NOT in the flow`);
644
+ }
645
+ return out;
646
+ }
647
+ function stepId(text, i) {
648
+ const verb = (/\b(sign in|log ?in|create|add|edit|change|set|delete|remove|archive|open|verify|find|report)\b/i.exec(text)?.[1] ?? 'step')
649
+ .toLowerCase()
650
+ .replace(/\s+/g, '');
651
+ return `${String(i + 1).padStart(2, '0')}-${verb}`;
652
+ }
653
+ /** Replace a value on token boundaries, leaving substrings of longer words alone. */
654
+ /**
655
+ * The value only LOOKS like this reference: a common word matching inside a
656
+ * hyphenated compound that means something else.
657
+ *
658
+ * fwgr8 reported `tags: "bench"` and its dashboard slug was
659
+ * `fwgr8-n1-bench-dashboard`, so four later steps had their url rewritten to
660
+ * `{{runid}}-{{04-open.tags}}-dashboard`. The "bench" in that slug comes from
661
+ * the dashboard's NAME, not from its tags; the two agreed by coincidence on
662
+ * the recording run and would not on any other. Every one of those refs then
663
+ * failed to resolve and cost its step the zero-model path.
664
+ *
665
+ * Hyphens deliberately do not bind in replaceToken, because a runid prefix in
666
+ * `x7-bench-dashboard` IS worth threading. The distinction is what the value
667
+ * is: a minted identifier is specific enough that matching inside a compound
668
+ * is evidence, while a common word is not. So a non-identifier must stand
669
+ * alone to be referencized.
670
+ */
671
+ function coincidental(text, value) {
672
+ if (identifierLike(value))
673
+ return false;
674
+ return new RegExp(`(?<![A-Za-z0-9_-])${escapeRe(value)}(?![A-Za-z0-9_-])`).test(text) === false;
675
+ }
676
+ function replaceToken(text, value, marker) {
677
+ if (!value)
678
+ return text;
679
+ // Underscores bind: `o_form_view_group` is ONE identifier, so a var whose
680
+ // value is "form" must not rewrite its middle (fwod5 shipped exactly that
681
+ // corruption). Hyphens do not bind — a runid prefix in "x7-bench-dashboard"
682
+ // is a reference worth threading.
683
+ const re = new RegExp(`(?<![A-Za-z0-9_])${escapeRe(value)}(?![A-Za-z0-9_])`, 'g');
684
+ // Never substitute INSIDE a reference already placed by an earlier pass: a
685
+ // provenance value that happens to be a common word ("form") rewrote the
686
+ // middle of an output NAME, and fwod5 shipped steps referencing
687
+ // `{{02-create.o_{{01-open.url.q.view_type}}_view_o_group_tabl}}` — a ref
688
+ // that can never resolve. Split on markers, rewrite only the gaps.
689
+ return text
690
+ .split(/(\{\{[^{}]*\}\})/g)
691
+ .map((piece) => (piece.startsWith('{{') && piece.endsWith('}}') ? piece : piece.replace(re, marker)))
692
+ .join('');
693
+ }
694
+ /**
695
+ * Name the cause of a flow step's recovery, for the progress line and drift
696
+ * telemetry. ALL causes now run cheap-first with the strong model as
697
+ * escalation-on-blocked: the fwrd4l sweep showed the session model rescuing
698
+ * every recovery — replay-failed ones included — at a fraction of the strong
699
+ * model's rate ($0.041 warm vs $0.104 when the same steps routed straight to
700
+ * the strong tier), and a replay refusal is usually a binding problem (a
701
+ * stale template), not the genuine drift the straight-to-strong route was
702
+ * priced for. The strong model is still one blocked report away.
703
+ */
704
+ export function recoveryRoute(step, unresolved) {
705
+ if (step.adopted && !step.skill)
706
+ return { easy: true, cause: 'adopted' };
707
+ if (!step.skill)
708
+ return { easy: true, cause: 'no-skill' };
709
+ if (unresolved)
710
+ return { easy: true, cause: 'unthreaded-ref' };
711
+ return { easy: true, cause: 'replay-failed' };
712
+ }
713
+ /**
714
+ * Export-time reference lint (PLAN-no-skill-steps case 4a): find every
715
+ * `{{stepId.output}}` reference whose producing step cannot re-publish the
716
+ * value deterministically on replay, and say so while the author can still do
717
+ * something about it. `url` and its `url.*` parts are exempt — every replay re-binds them
718
+ * from where its own browser lands. `publishes` answers, for a skill id, which
719
+ * output names a tier-A replay re-observes (labelled reads + param-derived
720
+ * report values); null when the skill is not in the store. Advisory only:
721
+ * replay behaviour is unchanged — an unthreaded ref already routes to cheap
722
+ * recovery — this surfaces the debt at build time instead of replay time.
723
+ */
724
+ export function lintFlowRefs(flow, publishes) {
725
+ const byId = new Map(flow.steps.map((s) => [s.id, s]));
726
+ const warnings = [];
727
+ const seen = new Set();
728
+ for (const step of flow.steps) {
729
+ const texts = [step.instruction, ...Object.values(step.params ?? {})];
730
+ for (const text of texts) {
731
+ for (const m of text.matchAll(/\{\{([\w-]+)\.([\w.#-]+)\}\}/g)) {
732
+ const [, sid, out] = m;
733
+ if (out === 'url' || out.startsWith('url.'))
734
+ continue;
735
+ const producer = byId.get(sid);
736
+ if (!producer || seen.has(`${sid}.${out}`))
737
+ continue;
738
+ seen.add(`${sid}.${out}`);
739
+ const pubs = producer.skill ? publishes(producer.skill) : [];
740
+ // A JSON-path ref (`body#dashboard.uid`) lives or dies with the read
741
+ // that publishes `body`; the path itself is applied after the fact.
742
+ if (pubs === null || pubs.includes(out.split('#')[0]))
743
+ continue;
744
+ warnings.push(`{{${sid}.${out}}} (used by ${step.id}) can only be re-observed by model recovery — ` +
745
+ `consider re-recording so the value is read from the page.`);
746
+ }
747
+ }
748
+ }
749
+ return warnings;
750
+ }
751
+ /**
752
+ * One step output, with support for a JSON path suffix: `body#dashboard.uid`
753
+ * reads the `body` output, parses it as JSON, and walks the path. That is how
754
+ * an id an app only ever returned in a response body gets threaded — see the
755
+ * jsonLeaves() publication in buildFlow.
756
+ */
757
+ /**
758
+ * The outputs a step's END URL publishes: the whole url, plus every part
759
+ * specific enough to be a reference.
760
+ *
761
+ * ONE function, because the producer and the consumer disagreeing is a silent
762
+ * dead reference. buildFlow mints `{{step.url.h1}}` for any part that is
763
+ * `identifierLike` (three characters is enough — repair-desk's ids are "t15"),
764
+ * while the daemon published parts at `length >= 4`. So every flow that named
765
+ * a three-character record id minted a ref nothing would ever resolve, and the
766
+ * four steps depending on it skipped the zero-model path on every replay.
767
+ */
768
+ export function urlOutputs(url) {
769
+ const out = { url };
770
+ for (const part of urlParts(url)) {
771
+ const key = `url.${part.label}`;
772
+ if (identifierLike(part.value) && !(key in out))
773
+ out[key] = part.value;
774
+ }
775
+ return out;
776
+ }
777
+ /**
778
+ * Which of each step's `url.*` outputs some OTHER step actually consumes
779
+ * (`{{03-open.url.q.id}}` in an instruction or a param). The flow runner
780
+ * captures a step's end-url outputs in one snapshot — but an SPA can update
781
+ * its URL a beat AFTER the page itself settles, and a structural replay is
782
+ * fast enough to finish inside that beat. Odoo does exactly this with the
783
+ * `id=` of a freshly saved record: fwod30's replays finished 03-open before
784
+ * the hash carried the id, `{{03-open.url.q.id}}` went unresolved, and every
785
+ * consumer of it fell to full recovery. Knowing which url outputs are
786
+ * consumed lets the capture wait for them, bounded, instead of snapshotting
787
+ * whatever the URL happened to say. The whole-url output (`url`) is always
788
+ * present, so only dotted parts are listed.
789
+ */
790
+ export function consumedUrlOutputs(steps) {
791
+ const wanted = new Map();
792
+ for (const s of steps) {
793
+ for (const text of [s.instruction, ...Object.values(s.params ?? {})]) {
794
+ for (const m of text.matchAll(/\{\{([\w-]+)\.(url\.[\w.-]+?)(#[\w.-]+)?\}\}/g)) {
795
+ if (m[1] === s.id)
796
+ continue; // own-step refs resolve after its capture regardless
797
+ const set = wanted.get(m[1]) ?? new Set();
798
+ set.add(m[2]);
799
+ wanted.set(m[1], set);
800
+ }
801
+ }
802
+ }
803
+ return wanted;
804
+ }
805
+ export function lookupOutput(outputs, sid, out) {
806
+ const hash = out.indexOf('#');
807
+ if (hash < 0)
808
+ return outputs[sid]?.[out];
809
+ const base = outputs[sid]?.[out.slice(0, hash)];
810
+ if (base === undefined)
811
+ return undefined;
812
+ let node;
813
+ try {
814
+ node = JSON.parse(base);
815
+ }
816
+ catch {
817
+ return undefined;
818
+ }
819
+ for (const key of out.slice(hash + 1).split('.')) {
820
+ if (node === null || typeof node !== 'object')
821
+ return undefined;
822
+ node = node[key];
823
+ }
824
+ return typeof node === 'string' || typeof node === 'number' ? String(node) : undefined;
825
+ }
826
+ /** One reference's value: a `{{var}}` from the run's vars, a `{{step.output}}` from prior outputs, else the stable literal. */
827
+ function lookupRef(ref, src) {
828
+ if (!ref.includes('.'))
829
+ return ref in src.vars ? src.vars[ref] : undefined;
830
+ const dot = ref.indexOf('.');
831
+ const v = lookupOutput(src.outputs, ref.slice(0, dot), ref.slice(dot + 1));
832
+ // Demonstrated stable by an earlier run: the app produced this exact value
833
+ // again, so it is furniture and the recorded literal is right. Anything not
834
+ // demonstrated stays missing and goes to recovery.
835
+ if (v === undefined && ref in src.stable)
836
+ return src.stable[ref];
837
+ return v;
838
+ }
839
+ /**
840
+ * Fill every `{{ref}}` in `text`. ONE substitution for instructions and
841
+ * params alike — a reference that resolves in one and not the other is a
842
+ * silent way to send a step to recovery. `missing` names what could not be
843
+ * filled; an unfilled reference is left in place (`keep`) or blanked.
844
+ */
845
+ export function resolveRefs(text, src, unresolved = 'keep') {
846
+ const missing = [];
847
+ const filled = text.replace(/\{\{([\w.#-]+)\}\}/g, (m, ref) => {
848
+ const v = lookupRef(ref, src);
849
+ if (v !== undefined)
850
+ return v;
851
+ missing.push(ref);
852
+ return unresolved === 'keep' ? m : '';
853
+ });
854
+ return { text: filled, missing };
855
+ }
856
+ /** Fill {{var}} and {{step.output}} references from run vars and prior outputs. */
857
+ export function resolveInstruction(step, vars, outputs, stable = {}) {
858
+ return resolveRefs(step.instruction, { vars, outputs, stable });
859
+ }
860
+ /**
861
+ * Like resolveInstruction, but for the recovery path: fill every reference that
862
+ * can be filled and blank the rest (rather than leaving `{{...}}` in the text),
863
+ * so the strong model gets a readable instruction built from what IS known —
864
+ * e.g. the ticket title even when its id could not be threaded.
865
+ */
866
+ export function softResolveInstruction(step, vars, outputs, stable = {}) {
867
+ return resolveRefs(step.instruction, { vars, outputs, stable }, 'blank')
868
+ .text.replace(/[ \t]{2,}/g, ' ')
869
+ .trim();
870
+ }
871
+ /** Resolve a step's stored param bindings from run vars and prior outputs. */
872
+ /**
873
+ * Which of a step's unresolved references its pinned skill can do without.
874
+ *
875
+ * A reference lives in the step's wording and, sometimes, in a param. The
876
+ * pinned procedure is fixed: only a param some step TYPES or LOCATES by, or
877
+ * that names the record the skill must find (requireText), can change what
878
+ * it does. A reference that reaches nothing else — a tag the instruction
879
+ * mentions for context, a price quoted from the recording — cannot alter a
880
+ * zero-model replay, so its absence is no reason to skip one. fwgr23 05-open
881
+ * went to 19–44 model turns on both replays because `{{04-open.tag}}` was
882
+ * blank, bound to a param no step used.
883
+ */
884
+ export function ignorableRefs(missing, step, skill) {
885
+ if (!skill)
886
+ return [];
887
+ const needed = new Set();
888
+ for (const [name, p] of Object.entries(skill.params))
889
+ if (p.usedIn.length)
890
+ needed.add(name);
891
+ for (const marker of skill.preconditions.requireText ?? [])
892
+ for (const m of marker.matchAll(/\{\{(v\d+)\}\}/g))
893
+ needed.add(m[1]);
894
+ return [...new Set(missing)].filter((ref) => {
895
+ const token = `{{${ref}}}`;
896
+ return !Object.entries(step.params ?? {}).some(([name, tmpl]) => needed.has(name) && tmpl.includes(token));
897
+ });
898
+ }
899
+ /**
900
+ * Slot bindings for a step that has just been re-pinned onto another skill.
901
+ * The old bindings are keyed by the OLD skill's slot names, which mean
902
+ * nothing to the new skill — rpat1-r1 re-pinned 04-add and kept
903
+ * `v2: "{{runid}}"` where the new skill's v2 was the project name, so r2
904
+ * bound the wrong values and the delete step was refused for a missing
905
+ * slot. Bindings are re-derived by VALUE: each new slot's example (the value
906
+ * the recovery actually used) is matched against what this run knew —
907
+ * the old bindings as resolved, the declared vars, earlier steps' outputs —
908
+ * and the matching template is carried over; a value containing a known
909
+ * value is templated on that part; anything else stays literal, which is
910
+ * what the recovery typed. `inherited` are the bindings a sibling step
911
+ * that already pins this skill stores, used for slots with no origin.
912
+ */
913
+ export function remapParams(skill, inherited = {}) {
914
+ // A binding key names where a value comes from: "runid" / "var:runid" (a
915
+ // declared var), "01-open.landed_page" / "output:01-open:landed_page" (an
916
+ // earlier step's output), "02-create.url.p1" / "url:02-create:p1" (a url
917
+ // part a step minted). All of them are a {{ref}} the flow resolves.
918
+ const templateOf = (key) => {
919
+ const m = /^(var|url|output|input)(?::(.*))?$/.exec(key);
920
+ if (!m)
921
+ return `{{${key}}}`;
922
+ if (m[1] === 'var')
923
+ return `{{${m[2]}}}`;
924
+ if (m[1] === 'url') {
925
+ const [step, label] = String(m[2]).split(':');
926
+ return step && label ? `{{${step}.url.${label}}}` : null;
927
+ }
928
+ if (m[1] === 'output') {
929
+ const [step, name] = String(m[2]).split(':');
930
+ return step && name ? `{{${step}.${name}}}` : null;
931
+ }
932
+ return null; // 'input': the run typed it — the example is the value
933
+ };
934
+ // Slots with an origin, as (example → template) pairs; a slot whose
935
+ // example is a composite ("<runid> MTP Bench Project") is templated on the
936
+ // bound slots' examples — this skill's own run, so the values line up.
937
+ const bound = [];
938
+ for (const p of Object.values(skill.params)) {
939
+ const t = p.binding ? templateOf(p.binding) : null;
940
+ if (t && p.example)
941
+ bound.push({ value: String(p.example), template: t });
942
+ }
943
+ bound.sort((a, b) => b.value.length - a.value.length);
944
+ const params = {};
945
+ const unbound = [];
946
+ for (const [name, p] of Object.entries(skill.params)) {
947
+ const ex = String(p.example ?? '');
948
+ const direct = p.binding ? templateOf(p.binding) : null;
949
+ if (direct) {
950
+ params[name] = direct;
951
+ continue;
952
+ }
953
+ // A slot the skill recorded no origin for, but which another step of
954
+ // the same flow already binds (the flow's `params` for that step are
955
+ // {{ref}} templates that resolve on every run): inherit that binding.
956
+ // rr2od's 08-open was covered by 07-open's read-only status check,
957
+ // whose store entry predates slot origins; the flow knew them all along.
958
+ if (inherited[name] !== undefined) {
959
+ params[name] = inherited[name];
960
+ continue;
961
+ }
962
+ let text = ex;
963
+ for (const b of bound)
964
+ if (b.value && text.includes(b.value))
965
+ text = text.split(b.value).join(b.template);
966
+ params[name] = text;
967
+ // A value that identifies the record (known) and could not be templated
968
+ // would replay as the LEARNING run's literal — the re-pin is not safe.
969
+ if (p.known && text === ex)
970
+ unbound.push(name);
971
+ }
972
+ return { params, unbound };
973
+ }
974
+ export function resolveStepParams(step, vars, outputs, stable = {}) {
975
+ if (!step.params)
976
+ return null;
977
+ const params = {};
978
+ const missing = [];
979
+ for (const [k, tmpl] of Object.entries(step.params)) {
980
+ const r = resolveRefs(tmpl, { vars, outputs, stable });
981
+ params[k] = r.text;
982
+ missing.push(...r.missing);
983
+ }
984
+ return { params, missing };
985
+ }
986
+ /** Cap on JSON leaves published per read value, and how deep to walk. */
987
+ const MAX_JSON_LEAVES = 12;
988
+ const MAX_JSON_DEPTH = 4;
989
+ /**
990
+ * Url parts of `url` that are identifier-like and that this session has not
991
+ * seen before — the values it just minted. First appearance wins, so the
992
+ * start url's own parts (and anything already banked) never qualify.
993
+ */
994
+ export function freshUrlIds(url, seen) {
995
+ const out = [];
996
+ for (const part of urlParts(url)) {
997
+ // Three characters, not four: repair-desk's record ids are "t15", and at
998
+ // a four-character floor fwrd16 left a literal `#/tickets/t15` in six
999
+ // flow steps. identifierLike() still does the real work — a three-letter
1000
+ // route word carries no digit and no separator, so it never qualifies.
1001
+ if (seen.has(part.value) || !identifierLike(part.value))
1002
+ continue;
1003
+ seen.add(part.value);
1004
+ out.push(part);
1005
+ }
1006
+ return out;
1007
+ }
1008
+ /** Scalar leaves of a JSON read value, as `path` (dot/index joined) + value. */
1009
+ export function jsonLeaves(text) {
1010
+ const trimmed = text.trim();
1011
+ if (!(trimmed.startsWith('{') || trimmed.startsWith('[')))
1012
+ return [];
1013
+ let root;
1014
+ try {
1015
+ root = JSON.parse(trimmed);
1016
+ }
1017
+ catch {
1018
+ return [];
1019
+ }
1020
+ const out = [];
1021
+ const walk = (node, path, depth) => {
1022
+ if (out.length >= MAX_JSON_LEAVES || depth > MAX_JSON_DEPTH)
1023
+ return;
1024
+ if (node !== null && typeof node === 'object') {
1025
+ for (const [k, v] of Object.entries(node))
1026
+ walk(v, path ? `${path}.${k}` : k, depth + 1);
1027
+ return;
1028
+ }
1029
+ if (typeof node !== 'string' && typeof node !== 'number')
1030
+ return;
1031
+ const value = String(node);
1032
+ if (value.length > 120 || !identifierLike(value))
1033
+ return;
1034
+ if (!path)
1035
+ return;
1036
+ out.push({ path, value });
1037
+ };
1038
+ walk(root, '', 0);
1039
+ return out;
1040
+ }
1041
+ //# sourceMappingURL=flow.js.map