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.
- package/LICENSE +21 -0
- package/README.md +625 -0
- package/bin/sitelooper.js +6 -0
- package/dist/agent/llm.js +460 -0
- package/dist/agent/llm.js.map +1 -0
- package/dist/agent/loop.js +870 -0
- package/dist/agent/loop.js.map +1 -0
- package/dist/agent/prompt.js +40 -0
- package/dist/agent/prompt.js.map +1 -0
- package/dist/agent/report.js +545 -0
- package/dist/agent/report.js.map +1 -0
- package/dist/agent/tools.js +1147 -0
- package/dist/agent/tools.js.map +1 -0
- package/dist/cli.js +1692 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon/browser.js +218 -0
- package/dist/daemon/browser.js.map +1 -0
- package/dist/daemon/codegen.js +241 -0
- package/dist/daemon/codegen.js.map +1 -0
- package/dist/daemon/dialogs.js +57 -0
- package/dist/daemon/dialogs.js.map +1 -0
- package/dist/daemon/diff.js +198 -0
- package/dist/daemon/diff.js.map +1 -0
- package/dist/daemon/fingerprint.js +98 -0
- package/dist/daemon/fingerprint.js.map +1 -0
- package/dist/daemon/inputs.js +134 -0
- package/dist/daemon/inputs.js.map +1 -0
- package/dist/daemon/recorder.js +1232 -0
- package/dist/daemon/recorder.js.map +1 -0
- package/dist/daemon/refs.js +194 -0
- package/dist/daemon/refs.js.map +1 -0
- package/dist/daemon/server.js +1724 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/state.js +239 -0
- package/dist/daemon/state.js.map +1 -0
- package/dist/doctor.js +90 -0
- package/dist/doctor.js.map +1 -0
- package/dist/shared/paths.js +80 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/protocol.js +28 -0
- package/dist/shared/protocol.js.map +1 -0
- package/dist/shared/secrets.js +92 -0
- package/dist/shared/secrets.js.map +1 -0
- package/dist/shared/text.js +39 -0
- package/dist/shared/text.js.map +1 -0
- package/dist/skills/compile.js +1420 -0
- package/dist/skills/compile.js.map +1 -0
- package/dist/skills/components.js +456 -0
- package/dist/skills/components.js.map +1 -0
- package/dist/skills/flow.js +1041 -0
- package/dist/skills/flow.js.map +1 -0
- package/dist/skills/learn.js +406 -0
- package/dist/skills/learn.js.map +1 -0
- package/dist/skills/ledger.js +304 -0
- package/dist/skills/ledger.js.map +1 -0
- package/dist/skills/relabel.js +206 -0
- package/dist/skills/relabel.js.map +1 -0
- package/dist/skills/repair.js +570 -0
- package/dist/skills/repair.js.map +1 -0
- package/dist/skills/replay.js +1281 -0
- package/dist/skills/replay.js.map +1 -0
- package/dist/skills/store.js +147 -0
- package/dist/skills/store.js.map +1 -0
- package/dist/spec/check.js +428 -0
- package/dist/spec/check.js.map +1 -0
- package/dist/spec/diagnostics.js +58 -0
- package/dist/spec/diagnostics.js.map +1 -0
- package/dist/spec/emit.js +2084 -0
- package/dist/spec/emit.js.map +1 -0
- package/dist/spec/index.js +62 -0
- package/dist/spec/index.js.map +1 -0
- package/dist/spec/ir.js +216 -0
- package/dist/spec/ir.js.map +1 -0
- package/dist/spec/lift.js +162 -0
- package/dist/spec/lift.js.map +1 -0
- package/dist/spec/locators.js +270 -0
- package/dist/spec/locators.js.map +1 -0
- package/dist/spec/lower.js +124 -0
- package/dist/spec/lower.js.map +1 -0
- package/dist/spec/repair.js +657 -0
- package/dist/spec/repair.js.map +1 -0
- package/dist/spec/rerecord.js +169 -0
- package/dist/spec/rerecord.js.map +1 -0
- package/dist/spec/rethread.js +120 -0
- package/dist/spec/rethread.js.map +1 -0
- package/package.json +50 -0
- 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
|