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,657 @@
1
+ // The reviewer-facing half of `sitelooper repair <name.flow.ts>`: what the
2
+ // repair pass actually changed, said in the vocabulary of the owned file the
3
+ // reviewer is about to see a diff of.
4
+ //
5
+ // Everything here is PURE — two `SpecFlow`s in, lines of English out, plus
6
+ // the small amount of filesystem staging the CLI needs to hand a lifted spec
7
+ // to the existing replay/repair machinery. No model, no browser, no daemon:
8
+ // the run happens in cli.ts, and this module only ever reads the before/after
9
+ // IR it produced. That split is what makes the summary unit-testable at all
10
+ // (test/spec-repair.test.ts) — the interesting cases (a fallback promoted, a
11
+ // model-proposed locator, a step re-pinned to a variant) are all reachable by
12
+ // hand-building two IRs, and none of them need a live app.
13
+ //
14
+ // The one rule this module enforces rather than merely reports is
15
+ // PLAN-self-updating-spec.md's "never weaken an expectation": a repair that
16
+ // dropped a step's `expect` is a refusal, not a diff line, and
17
+ // `droppedExpectations` is what the CLI gates the write on.
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { candidateExpr, positionalExpr } from '../daemon/recorder.js';
21
+ import { retired, stepByTag } from '../skills/repair.js';
22
+ import { structural } from '../skills/replay.js';
23
+ import { SkillStore } from '../skills/store.js';
24
+ import { mutates, selectCandidates } from '../skills/learn.js';
25
+ import { rerecordFix } from './diagnostics.js';
26
+ import { flowToSpec } from './ir.js';
27
+ import { stageForReplay } from './lower.js';
28
+ /**
29
+ * Every locator-bearing step of a segment, loop bodies included, in the order
30
+ * replay walks them. Loop bodies matter here because a chain that drifted
31
+ * inside a loop is exactly the case `triage` canonicalises to ONE ticket —
32
+ * so it is also the case where exactly one line of summary is owed.
33
+ */
34
+ function flatten(steps, prefix = '') {
35
+ const out = [];
36
+ steps.forEach((step, i) => {
37
+ const tag = `${prefix}${i + 1}`;
38
+ out.push({ tag, step });
39
+ if (step.body?.length)
40
+ out.push(...flatten(step.body, `${tag}.body.`));
41
+ });
42
+ return out;
43
+ }
44
+ /** Chains a step carries, keyed by the name a reviewer would recognise. */
45
+ function chainsOf(step) {
46
+ const out = Object.entries(step.locators ?? {}).filter(([, c]) => Array.isArray(c));
47
+ if (step.while?.length)
48
+ out.push(['while', step.while]);
49
+ return out;
50
+ }
51
+ const exprs = (chain) => chain.map((c) => candidateExpr(c));
52
+ /**
53
+ * How one step's `expect` weakened, if it did.
54
+ *
55
+ * Only ever LOSS: a repair that ADDS an expectation is fine (it observed
56
+ * something new), and a changed url pattern is reported as a change, not a
57
+ * weakening — the pattern names where the app went, and the app moving is the
58
+ * drift being repaired. Dropping the whole clause, or dropping members of
59
+ * `addedContains`, is the thing PLAN-self-updating-spec.md forbids: it turns
60
+ * a red build green by asserting less.
61
+ */
62
+ function expectationLoss(before, after) {
63
+ const b = before.expect;
64
+ if (!b)
65
+ return null;
66
+ const a = after.expect;
67
+ if (!a)
68
+ return 'the step no longer asserts anything about the page it produced';
69
+ const lost = [];
70
+ if (b.urlPattern && !a.urlPattern)
71
+ lost.push(`url ${b.urlPattern}`);
72
+ if (b.alertContains && !a.alertContains)
73
+ lost.push(`alert ${JSON.stringify(b.alertContains)}`);
74
+ for (const line of b.addedContains ?? []) {
75
+ if (!(a.addedContains ?? []).includes(line))
76
+ lost.push(`page text ${JSON.stringify(line)}`);
77
+ }
78
+ return lost.length ? `no longer asserts ${lost.join(', ')}` : null;
79
+ }
80
+ /**
81
+ * Compare two compiled IRs step by step and say, in one line per observation,
82
+ * what the repair pass did.
83
+ *
84
+ * Matching is positional inside a step (segment i to segment i, skill step i
85
+ * to skill step i) because that is what `promoteFallback` and `patchSegment`
86
+ * preserve: neither ever inserts or removes a gesture, they only reorder a
87
+ * chain, prepend a candidate to it, or clone the whole procedure into a
88
+ * variant. Anything that does NOT match up positionally is therefore a
89
+ * structural change worth its own line rather than a mis-alignment to paper
90
+ * over — hence the shape lines below.
91
+ */
92
+ export function diffSpecChanges(before, after) {
93
+ const lines = [];
94
+ const droppedExpectations = [];
95
+ const weakenedByVariant = [];
96
+ const afterById = new Map(after.steps.map((s) => [s.id, s]));
97
+ for (const b of before.steps) {
98
+ const a = afterById.get(b.id);
99
+ const own = [];
100
+ if (!a) {
101
+ lines.push(`${b.id}: step is gone from the repaired flow`);
102
+ continue;
103
+ }
104
+ own.push(...diffStep(b, a, droppedExpectations, weakenedByVariant));
105
+ lines.push(...own.map((l) => `${b.id}: ${l}`));
106
+ if (!own.length)
107
+ lines.push(`${b.id}: no change`);
108
+ }
109
+ for (const a of after.steps) {
110
+ if (!before.steps.some((b) => b.id === a.id))
111
+ lines.push(`${a.id}: new step`);
112
+ }
113
+ return { lines, droppedExpectations, weakenedByVariant };
114
+ }
115
+ /** `diffSpecChanges`, as the plain list of lines the CLI prints. */
116
+ export function describeSpecChanges(before, after) {
117
+ return diffSpecChanges(before, after).lines;
118
+ }
119
+ function diffStep(b, a, droppedExpectations, weakenedByVariant) {
120
+ const out = [];
121
+ if (a.segments.length !== b.segments.length) {
122
+ out.push(`procedure now has ${a.segments.length} segment(s) (was ${b.segments.length})`);
123
+ }
124
+ const n = Math.min(a.segments.length, b.segments.length);
125
+ for (let i = 0; i < n; i++) {
126
+ const bs = b.segments[i];
127
+ const as = a.segments[i];
128
+ // A re-pin: the flow step points at a different skill than it did, which
129
+ // for repair means the provisional VARIANT patchSegment stored was adopted
130
+ // by the run that followed. Reported first, because every locator line
131
+ // under it is then a line about the variant, not about the original.
132
+ if (as.id !== bs.id)
133
+ out.push(`step re-pinned to variant ${as.id} (was ${bs.id})`);
134
+ out.push(...diffSegment(b.id, bs, as, as.id !== bs.id, droppedExpectations, weakenedByVariant));
135
+ }
136
+ return out;
137
+ }
138
+ function diffSegment(stepId, bs, as, isVariant, droppedExpectations, weakenedByVariant) {
139
+ const out = [];
140
+ const bSteps = flatten(bs.steps);
141
+ const aSteps = flatten(as.steps);
142
+ const aByTag = new Map(aSteps.map((s) => [s.tag, s]));
143
+ if (aSteps.length !== bSteps.length) {
144
+ out.push(`${as.id} now has ${aSteps.length} step(s) (was ${bSteps.length})`);
145
+ }
146
+ for (const bStep of bSteps) {
147
+ const aStep = aByTag.get(bStep.tag);
148
+ if (!aStep)
149
+ continue;
150
+ const where = `${as.id} step ${bStep.tag}`;
151
+ const loss = expectationLoss(bStep.step, aStep.step);
152
+ if (loss) {
153
+ const line = `${where}: ${loss}`;
154
+ (isVariant ? weakenedByVariant : droppedExpectations).push(`${stepId}: ${line}`);
155
+ out.push(`${isVariant ? 'REVIEW — the repair variant no longer asserts what the old control produced' : 'EXPECTATION DROPPED'} — ${line}`);
156
+ }
157
+ const aChains = new Map(chainsOf(aStep.step));
158
+ for (const [k, bChain] of chainsOf(bStep.step)) {
159
+ const aChain = aChains.get(k);
160
+ if (!aChain?.length || !bChain.length)
161
+ continue;
162
+ const bExprs = exprs(bChain);
163
+ const aExprs = exprs(aChain);
164
+ if (aExprs[0] === bExprs[0])
165
+ continue;
166
+ const wasAt = bExprs.indexOf(aExprs[0]);
167
+ if (wasAt > 0) {
168
+ out.push(`candidate promoted: ${aExprs[0]} now primary (was #${wasAt}) — ${where} ${k}`);
169
+ }
170
+ else if (wasAt < 0) {
171
+ const how = isVariant ? `model-proposed variant ${as.id}` : 'model-proposed';
172
+ out.push(`new locator: ${aExprs[0]} (${how}) — ${where} ${k}`);
173
+ }
174
+ else {
175
+ out.push(`chain reordered — ${where} ${k}`);
176
+ }
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+ /**
182
+ * Lower a lifted spec into a throwaway store + flow file under `dir`.
183
+ *
184
+ * The point of the isolation is that the run this stages is a REAL sitelooper
185
+ * run — it re-pins, it stores repair variants, it folds candidate evidence
186
+ * back — and none of that may touch `~/.sitelooper`. The spec is the source of
187
+ * truth (PLAN-self-updating-spec.md's one design decision); the store is a
188
+ * scratch buffer that exists for the length of one repair.
189
+ */
190
+ export function stageRepair(spec, dir) {
191
+ const skillsDir = path.join(dir, 'skills');
192
+ fs.mkdirSync(skillsDir, { recursive: true });
193
+ const store = new SkillStore(skillsDir);
194
+ const flow = stageForReplay(spec, store);
195
+ const flowFile = path.join(dir, `${spec.name.replace(/[^A-Za-z0-9._-]+/g, '_') || 'flow'}.json`);
196
+ fs.writeFileSync(flowFile, JSON.stringify(flow, null, 2));
197
+ return { skillsDir, store, flowFile, flow };
198
+ }
199
+ /**
200
+ * Read a staged workspace back after a run has rewritten it.
201
+ *
202
+ * `runFlow` writes re-pins and output evidence back into the flow FILE it
203
+ * loaded, and the drain writes promoted chains and variants into the store,
204
+ * so the repaired IR is exactly what `flowToSpec` makes of those two on disk
205
+ * — not something this process has to reconstruct from what it remembers
206
+ * doing.
207
+ */
208
+ export function reloadStaged(staged) {
209
+ const flow = JSON.parse(fs.readFileSync(staged.flowFile, 'utf8'));
210
+ return flowToSpec(flow, new SkillStore(staged.skillsDir));
211
+ }
212
+ /**
213
+ * Per-run values for a converge loop. Every converge iteration is a REAL run
214
+ * against the app, so a flow that creates a record named after a var finds
215
+ * the previous iteration's record next time and the gate fails for a reason
216
+ * that is not drift. A `{n}` token in a --var value is replaced by the run
217
+ * number (0 for the repair run, 1..n for the converge runs) so each run
218
+ * works its own records without the app being reset in between.
219
+ */
220
+ export function mintVars(vars, n) {
221
+ return Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, v.split('{n}').join(String(n))]));
222
+ }
223
+ /**
224
+ * Fold a patch-segment VARIANT back into the chain it was cloned from.
225
+ *
226
+ * `patchSegment` stores its proposal as a provisional variant skill, to be
227
+ * adopted (or not) by the normal replay lifecycle over later runs. That is
228
+ * right for a long-lived store and wrong here, for two reasons.
229
+ *
230
+ * First, correctness: the variant is a clone, `seq` included, so a mid-chain
231
+ * segment's variant claims the SAME chain slot as its original and
232
+ * `flowToSpec` then compiles both — fwrd42's sign-in step went from three
233
+ * segments to four, with the drifted one still first. Second, purpose: in the
234
+ * spec loop the store is a scratch buffer, the `.flow.ts` is the artifact, and
235
+ * the thing that makes an adaptation safe is not a provisional status nobody
236
+ * will ever look at — it is the convergence gate plus a human reading the
237
+ * diff. So the model's locator goes to the FRONT of the real chain, every
238
+ * candidate that was already there stays behind it (drift can revert), the
239
+ * step's expectations are untouched, and the variant is dropped.
240
+ *
241
+ * Returns one line per fold, for the summary.
242
+ */
243
+ export function foldPatchedVariants(store, patched) {
244
+ const lines = [];
245
+ for (const row of patched) {
246
+ const variantId = typeof row.variant === 'string' ? row.variant : null;
247
+ const originalId = typeof row.skill === 'string' ? row.skill : null;
248
+ if (!variantId || !originalId)
249
+ continue;
250
+ const variant = store.get(variantId);
251
+ const original = store.get(originalId);
252
+ const tag = typeof row.step === 'string' ? row.step : undefined;
253
+ const key = typeof row.key === 'string' ? row.key : 'target';
254
+ const vstep = variant ? stepByTag(variant, tag) : null;
255
+ const ostep = original ? stepByTag(original, tag) : null;
256
+ const proposed = vstep?.locators[key]?.[0];
257
+ const chain = ostep?.locators[key];
258
+ if (!variant || !original || !proposed || !chain) {
259
+ lines.push(`could not fold ${variantId} into ${originalId}: the patched step is no longer there`);
260
+ continue;
261
+ }
262
+ const expr = candidateExpr(proposed);
263
+ if (chain.length && candidateExpr(chain[0]) === expr) {
264
+ store.remove(variantId);
265
+ continue;
266
+ }
267
+ // Never a replacement: the dead candidate keeps its place behind the new
268
+ // one, because an app that drifts back should still be found.
269
+ chain.unshift(proposed);
270
+ store.put(original);
271
+ store.remove(variantId);
272
+ lines.push(`${originalId} step ${tag ?? '?'} ${key}: ${expr} folded in as primary (from variant ${variantId})`);
273
+ }
274
+ return lines;
275
+ }
276
+ // --- evidence codemod --------------------------------------------------------
277
+ //
278
+ // PLAN-self-updating-spec.md, "what the agent is allowed to change": reorder
279
+ // candidates and retire a candidate are the CHEAP, no-model edits — "always a
280
+ // pure codemod from sidecar evidence". Everything below is that codemod. It
281
+ // reads only `seen` (the hit/miss counters replay banks) and the run's own
282
+ // drift tickets, and it never invents a hit: the only counter it writes is a
283
+ // miss the run demonstrably observed.
284
+ /**
285
+ * Does `expr` (a drift ticket's `missedLocator`, recorded with this run's
286
+ * parameters already filled in) name this stored candidate?
287
+ *
288
+ * Stored chains carry `{{v4}}` slots, so a literal comparison misses exactly
289
+ * the candidates that identify a record — the ones the evidence rule most
290
+ * needs to reach. A slotted expression is matched as a pattern instead: the
291
+ * literal parts must line up, the slots may be anything.
292
+ */
293
+ export function candidateMatchesExpr(c, expr) {
294
+ const own = candidateExpr(c);
295
+ if (own === expr)
296
+ return true;
297
+ if (!own.includes('{{'))
298
+ return false;
299
+ const escaped = own
300
+ .split(/\{\{[^}]*\}\}/)
301
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
302
+ .join('[\\s\\S]*');
303
+ return new RegExp(`^${escaped}$`).test(expr);
304
+ }
305
+ /** Every locator chain in a stored skill, addressed the way a ticket addresses one. */
306
+ function chainsOfSkill(skill) {
307
+ const out = [];
308
+ for (const { tag, step } of flatten(skill.steps)) {
309
+ for (const [key, chain] of chainsOf(step))
310
+ out.push({ tag, key, chain });
311
+ }
312
+ return out;
313
+ }
314
+ /**
315
+ * Bank the misses a run's drift tickets prove, for the chains replay itself
316
+ * banks nothing about.
317
+ *
318
+ * `replay.ts` records per-candidate evidence ONLY when a NON-STRUCTURAL
319
+ * candidate won — deliberately, because banking a structural win would
320
+ * confirm "whatever sorted into that slot" and retire the anchors it beat
321
+ * (fwrd26l). The consequence is a blind spot with a name: a chain whose
322
+ * winner is structural, and a chain where NOTHING resolved, both leave the
323
+ * missed candidate with an empty `seen` for ever. It misses on every run, it
324
+ * is never retired, and it files an identical drift ticket every run — which
325
+ * is precisely why the convergence gate on fwrd42 could not clear.
326
+ *
327
+ * So the miss is banked from the ticket. The miss is a real observation; the
328
+ * win, in these two cases, is not one we are willing to trust, and no hit is
329
+ * ever written here. That asymmetry IS the rule.
330
+ */
331
+ export function foldTicketEvidence(store, tickets) {
332
+ let banked = 0;
333
+ // One bump per (skill, step, key, candidate) per RUN: a loop body that
334
+ // missed on nine iterations saw one bad locator once, not nine times, and
335
+ // `retired`'s "two independent runs" threshold means what it says.
336
+ const done = new Set();
337
+ for (const t of tickets) {
338
+ if (!t.missedLocator || !t.atStep)
339
+ continue;
340
+ // A non-structural fallback that won is already banked by replay; banking
341
+ // it again here would double-count and retire on one run instead of two.
342
+ if (t.fallbackUsed !== null && !positionalExpr(t.fallbackUsed))
343
+ continue;
344
+ const skill = store.get(t.skill);
345
+ if (!skill)
346
+ continue;
347
+ const step = stepByTag(skill, t.atStep);
348
+ const chain = step?.locators[t.key ?? 'target'];
349
+ if (!chain)
350
+ continue;
351
+ const named = chain.filter((c) => candidateMatchesExpr(c, t.missedLocator));
352
+ if (named.length !== 1)
353
+ continue;
354
+ const key = `${t.skill}|${t.atStep}|${t.key ?? 'target'}|${candidateExpr(named[0])}`;
355
+ if (done.has(key))
356
+ continue;
357
+ done.add(key);
358
+ named[0].seen = { hit: named[0].seen?.hit ?? 0, miss: (named[0].seen?.miss ?? 0) + 1 };
359
+ store.put(skill);
360
+ banked++;
361
+ }
362
+ return banked;
363
+ }
364
+ /**
365
+ * Where a candidate sorts, by evidence: 0 it has resolved at least once, 1 no
366
+ * verdict yet, 2 demonstrated volatile (`retired`: never hit, missed twice).
367
+ */
368
+ function evidenceRank(c) {
369
+ if ((c.seen?.hit ?? 0) > 0)
370
+ return 0;
371
+ return retired(c) ? 2 : 1;
372
+ }
373
+ /** identity / handle / path, as `specOf` classes them. */
374
+ function classRank(c) {
375
+ if (c.kind === 'scoped')
376
+ return 0;
377
+ return structural(c) ? 2 : 1;
378
+ }
379
+ /**
380
+ * One chain, reordered by what the evidence says — the whole rule in one
381
+ * function, so the unit tests can state it directly.
382
+ *
383
+ * Evidence outranks kind, because kind is a PRIOR about what a candidate is
384
+ * and evidence is a measurement of whether it works (the same reason
385
+ * `resolveChain` sorts `byEvidence` inside each class). Ties keep both the
386
+ * recorded order and `specOf`'s class order, so nothing shuffles for free.
387
+ *
388
+ * The one thing evidence may NOT do is float a structural path over an
389
+ * identity or handle candidate that has actually resolved: a css path names
390
+ * no element, only a position, and promoting one on evidence is how a chain
391
+ * quietly stops testing the control it was recorded against. Structural
392
+ * candidates are therefore clamped out of the top rank whenever any
393
+ * non-structural candidate in the chain has a hit.
394
+ */
395
+ export function orderByEvidence(chain) {
396
+ const anchored = chain.some((c) => !structural(c) && (c.seen?.hit ?? 0) > 0);
397
+ const rank = (c) => {
398
+ const r = evidenceRank(c);
399
+ return structural(c) && anchored ? Math.max(r, 1) : r;
400
+ };
401
+ return chain
402
+ .map((c, i) => ({ c, i }))
403
+ .sort((a, b) => rank(a.c) - rank(b.c) || classRank(a.c) - classRank(b.c) || a.i - b.i)
404
+ .map((x) => x.c);
405
+ }
406
+ /**
407
+ * Apply `orderByEvidence` to every chain in the staged store, in place, and
408
+ * say what moved in the vocabulary the change list uses.
409
+ *
410
+ * Retirements get their own line because they are the interesting half: a
411
+ * reviewer reading the diff should see WHY a locator dropped to the back of
412
+ * its chain, and "missed 3 run(s), never hit" is the whole argument. They are
413
+ * reported even when NOTHING moves — a chain of one candidate cannot reorder,
414
+ * and that is precisely the chain whose retirement the reader most needs told
415
+ * about, because from the next run on it stops counting as drift.
416
+ *
417
+ * `reported` is how a repair pass that calls this after every run says each
418
+ * retirement once. Pass one set for the whole invocation; the default makes
419
+ * the function stand alone for a single pass.
420
+ */
421
+ export function reorderByEvidence(store, reported = new Set()) {
422
+ const lines = [];
423
+ for (const skill of store.all()) {
424
+ let touched = false;
425
+ for (const { tag, key, chain } of chainsOfSkill(skill)) {
426
+ const where = `${skill.id} step ${tag} ${key}`;
427
+ for (const c of chain) {
428
+ if (!retired(c))
429
+ continue;
430
+ const id = `${where}|${candidateExpr(c)}`;
431
+ if (reported.has(id))
432
+ continue;
433
+ reported.add(id);
434
+ lines.push(`candidate retired: ${candidateExpr(c)} — missed ${c.seen?.miss ?? 0} run(s), never hit; now last — ${where}`);
435
+ }
436
+ const ordered = orderByEvidence(chain);
437
+ if (ordered.every((c, i) => c === chain[i]))
438
+ continue;
439
+ // Only a candidate that EARNED the front gets a line of its own. One
440
+ // that merely inherited it, because the candidate above it was retired,
441
+ // is already accounted for by the retirement line above.
442
+ const head = ordered[0];
443
+ const from = chain.indexOf(head);
444
+ if (from > 0 && (head.seen?.hit ?? 0) > 0) {
445
+ lines.push(`candidate reordered: ${candidateExpr(head)} now primary (was #${from}) on ${head.seen?.hit ?? 0} hit(s) — ${where}`);
446
+ }
447
+ chain.splice(0, chain.length, ...ordered);
448
+ touched = true;
449
+ }
450
+ if (touched)
451
+ store.put(skill);
452
+ }
453
+ return lines;
454
+ }
455
+ /**
456
+ * Is this drift ticket NEW information?
457
+ *
458
+ * A fallthrough (or a dead chain) whose missed candidate the evidence has
459
+ * ALREADY retired is not drift: the store has recorded the verdict, the
460
+ * codemod has moved that candidate to the back of its chain, and the run is
461
+ * simply re-observing a fact the spec already carries. Counting it would make
462
+ * `--converge n` unclearable on any flow with one chronically volatile
463
+ * candidate — which is exactly what fwrd42's 06-report did.
464
+ *
465
+ * Anything else still counts. A first or second miss is news; a miss on a
466
+ * candidate that has ever resolved is news; a recovery with no locator to
467
+ * blame is very much news.
468
+ */
469
+ export function ticketIsNews(store, t) {
470
+ const skill = store.get(t.skill);
471
+ const step = t.atStep && skill ? stepByTag(skill, t.atStep) : null;
472
+ const chain = step?.locators[t.key ?? 'target'];
473
+ // A step recorded with no locator at all cannot drift: nothing was ever
474
+ // findable, so its ticket says the same thing every run and triage skips it
475
+ // for the same reason (notAControlWhy). The gate must agree with the drain,
476
+ // or a flow with one unlabelled read-back can never converge.
477
+ if (chain && !chain.length)
478
+ return false;
479
+ if (!t.missedLocator || !t.atStep)
480
+ return true;
481
+ if (!chain)
482
+ return true;
483
+ const named = chain.filter((c) => candidateMatchesExpr(c, t.missedLocator));
484
+ if (named.length !== 1)
485
+ return true;
486
+ return !retired(named[0]);
487
+ }
488
+ /**
489
+ * Steps that are not "clean tier A", in the convergence gate's own vocabulary:
490
+ * a step that did not succeed, a step that needed the model (any tier but A),
491
+ * or a step that succeeded but still filed a drift ticket. A run that halted
492
+ * reports its unreached steps too — silence about them would read as success.
493
+ *
494
+ * A tier that is not A is only half an answer: sp4od's `06-open (tier B)` said
495
+ * nothing about the pinned skill having refused the page it was handed. When
496
+ * the run recorded WHY it fell back, that reason is named here too.
497
+ *
498
+ * Lives beside the rest of the repair vocabulary rather than in cli.ts so it
499
+ * can be unit-tested without spawning the CLI (importing cli.ts runs main()).
500
+ */
501
+ export function notConverged(run, store, flagged) {
502
+ const bad = new Map();
503
+ for (const st of run.steps) {
504
+ if (st.status !== 'success')
505
+ bad.set(st.id, st.status);
506
+ else if (st.tier !== 'A')
507
+ bad.set(st.id, `tier ${st.tier ?? 'none'}${st.fellBack ? ` — ${st.fellBack}` : ''}`);
508
+ }
509
+ for (const t of run.driftTickets ?? []) {
510
+ // Not every ticket is drift. A fallthrough whose missed candidate the
511
+ // evidence has already RETIRED is the run re-observing something the spec
512
+ // now records — the codemod has moved that candidate to the back of its
513
+ // chain, and there is nothing left to learn from it. Counting it would
514
+ // leave `--converge n` permanently unclearable on any flow with one
515
+ // chronically volatile locator (fwrd42's 06-report). See ticketIsNews.
516
+ if (store && !ticketIsNews(store, t))
517
+ continue;
518
+ if (!bad.has(t.step))
519
+ bad.set(t.step, `drift (${t.missedLocator ?? t.reason ?? t.fellBack ?? 'recovered'})`);
520
+ }
521
+ if (run.steps.length < run.total)
522
+ bad.set('(unreached)', `${run.total - run.steps.length} step(s) the run never got to`);
523
+ // A step whose RECORDING is the problem is not converged however clean its
524
+ // tier looks — that is the whole sp8od lesson: 9/9 tier A on a step the pin
525
+ // never ran. The diagnostic's reason replaces whatever weaker one is here,
526
+ // because "tier A" is the misleading half of the answer.
527
+ for (const [id, d] of flagged ?? [])
528
+ bad.set(id, `needs re-record — ${d.what}`);
529
+ return [...bad].map(([id, why]) => `${id} (${why})`);
530
+ }
531
+ /**
532
+ * The skill id a run reported replaying, when it reported one.
533
+ *
534
+ * The daemon currently puts a `"3/7"` steps-replayed FRACTION in this field,
535
+ * not an id (server.ts). That is not this module's to change, and it must not
536
+ * be read as a skill either — so a fraction reads as "the run did not name a
537
+ * skill" and the covering skill is derived from the store instead.
538
+ */
539
+ export function replayedSkillId(replayed) {
540
+ if (!replayed)
541
+ return null;
542
+ return /^\d+\s*\/\s*\d+$/.test(replayed) ? null : replayed;
543
+ }
544
+ /**
545
+ * Which skill the engine would actually run for this step — by asking the
546
+ * engine's own selector, not by guessing.
547
+ *
548
+ * `selectCandidates` is the function the flow runner uses: the pin is a hint
549
+ * that defines the family, candidates are every NON-DEMOTED skill that binds
550
+ * the instruction or shares the hint's procedure, best track record first. So
551
+ * when the pin is demoted, the head of this list is precisely the skill that
552
+ * silently covered the step.
553
+ */
554
+ export function coveringSkill(store, step) {
555
+ const picked = selectCandidates(store.all(), step.skill, step.instruction, step.params)[0];
556
+ return picked ? picked.skill.id : null;
557
+ }
558
+ /** The store's own account of why a pin is untrustworthy: status, stats, where it fails. */
559
+ export function pinReason(store, id) {
560
+ const skill = store.get(id);
561
+ if (!skill)
562
+ return `${id} is not in the store`;
563
+ const st = skill.stats;
564
+ const bits = [`${st.uses} use(s), ${st.successes} success(es)`];
565
+ const worst = Object.entries(st.failedAtStep ?? {}).sort((a, b) => b[1] - a[1])[0];
566
+ if (worst)
567
+ bits.push(`failed at step ${worst[0]} on ${worst[1]} replay(s)`);
568
+ if (st.lastUsed)
569
+ bits.push(`last used ${st.lastUsed}`);
570
+ return `${id} is ${skill.status} — ${bits.join(', ')}`;
571
+ }
572
+ /** One step's clause in the evidence sentence: `converge 1/2: success, tier A`. */
573
+ function runPhrase(label, f) {
574
+ if (!f)
575
+ return `${label}: never reached`;
576
+ const tier = `tier ${f.tier ?? 'none'}`;
577
+ const named = replayedSkillId(f.replayed);
578
+ const who = named ? ` via ${named}` : '';
579
+ const how = f.recovered ? ` — recovered${f.fellBack ? `: ${clip(f.fellBack, 160)}` : ''}` : '';
580
+ const pin = f.repinned ? ` — re-pinned ${f.repinned}` : '';
581
+ return `${label}: ${f.status}, ${tier}${who}${how}${pin}`;
582
+ }
583
+ /** Cap a reason: replay writes paragraphs into `fellBack`, and this is one clause of one sentence. */
584
+ function clip(text, n = 240) {
585
+ const flat = text.replace(/\s+/g, ' ').trim();
586
+ return flat.length > n ? `${flat.slice(0, n - 1)}…` : flat;
587
+ }
588
+ /**
589
+ * The steps whose RECORDING is the problem, one `needs-rerecord` Diagnostic
590
+ * each — repair's verdict that no locator edit can help.
591
+ *
592
+ * A step is flagged when any of these hold:
593
+ *
594
+ * 1. Its pin is DEMOTED. `selectCandidates` will never run it again, so
595
+ * whatever passed the step was something else, and the compiled spec —
596
+ * which emits the PIN's procedure — has nothing to fall back to.
597
+ * 2. Every converge run named a skill other than the pin as the one it
598
+ * replayed. The same conclusion, reached from the run report rather than
599
+ * from the store.
600
+ * 3. The pin refused or failed and the skill that covered it is READ-ONLY
601
+ * while the pin MUTATES. This is the fwrd14l-n2 failure mode reported
602
+ * rather than merely prevented: a read chain resolving on a plausible
603
+ * page and reporting success is a green run that changed nothing.
604
+ *
605
+ * Ordinary recovery is NOT any of these. A pin that fell back once and was
606
+ * covered by a skill doing the same (mutating) work is exactly what the repair
607
+ * loop is for, and it stays the repair loop's business.
608
+ */
609
+ export function rerecordDiagnostics(input) {
610
+ const out = new Map();
611
+ const [first, ...rest] = input.runs;
612
+ if (!first)
613
+ return out;
614
+ const converge = rest.length ? rest : input.runs;
615
+ for (const step of input.steps) {
616
+ const pin = step.skill;
617
+ if (!pin)
618
+ continue;
619
+ const pinned = input.store.get(pin);
620
+ const demoted = pinned?.status === 'demoted';
621
+ const factOf = (r) => r.steps.find((s) => s.id === step.id);
622
+ // Named coverage: the run itself said it replayed something else, on every
623
+ // converge run. One run out of three is a fallback, not a substitution.
624
+ const named = converge.map((r) => replayedSkillId(factOf(r)?.replayed));
625
+ const coveredByName = named.length > 0 && named.every((id) => Boolean(id) && id !== pin);
626
+ // Did the pin fail to carry the step anywhere? A recovery, a non-A tier, or
627
+ // any non-success is the pin not doing its job under its own power.
628
+ const refused = input.runs.some((r) => {
629
+ const f = factOf(r);
630
+ return Boolean(f && (f.recovered || f.status !== 'success' || f.tier !== 'A'));
631
+ });
632
+ if (!demoted && !coveredByName && !refused)
633
+ continue;
634
+ // Unnamed coverage: the daemon does not put an id in `replayed` yet, so ask
635
+ // the engine's own selector who would have run instead.
636
+ const cover = named.find((id) => Boolean(id) && id !== pin) ?? coveringSkill(input.store, step);
637
+ const covering = cover && cover !== pin ? cover : null;
638
+ const readOnlyCover = Boolean(covering && !mutates(input.store, covering) && mutates(input.store, pin));
639
+ if (!demoted && !coveredByName && !readOnlyCover)
640
+ continue;
641
+ const kind = covering ? (mutates(input.store, covering) ? 'mutating' : 'read-only') : null;
642
+ const what = covering
643
+ ? `${step.id} only passes because the engine replays ${covering} (${kind}) instead of its ${demoted ? 'demoted ' : ''}pin ${pin}; a compiled spec halts here`
644
+ : `${step.id}'s pinned skill ${pin} is ${pinned?.status ?? 'missing'} and no other procedure covers it; a compiled spec halts here`;
645
+ const evidence = input.runs.map((r) => runPhrase(r.label, factOf(r))).join('; ');
646
+ out.set(step.id, {
647
+ code: 'needs-rerecord',
648
+ step: step.id,
649
+ what,
650
+ why: clip(`${evidence}. ${pinReason(input.store, pin)}.`, 700),
651
+ fix: rerecordFix(input.flowFile, step.id),
652
+ severity: 'error',
653
+ });
654
+ }
655
+ return out;
656
+ }
657
+ //# sourceMappingURL=repair.js.map