staysfixed 0.6.2 → 0.7.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.
@@ -292,18 +292,50 @@ function storeFor(ctx) {
292
292
  // The list
293
293
  // ---------------------------------------------------------------------------
294
294
 
295
+ /**
296
+ * What each tool DOES to the machine, in the four flags the protocol defines for it.
297
+ *
298
+ * These are not decoration. A client uses them to decide what it may run without stopping
299
+ * to ask a person, and an agent uses them to tell a question apart from an action. Getting
300
+ * them wrong in either direction is a real cost: mark a tool read-only when it is not and
301
+ * something runs unasked; mark a harmless question as an action and every session opens
302
+ * with a prompt nobody needed.
303
+ *
304
+ * They are set honestly here rather than optimistically. `staysfixed_check` reads nothing
305
+ * but it OPENS YOUR PRODUCT — twice — so it is not read-only, and two runs of it can
306
+ * legitimately answer differently, so it is not idempotent either. `staysfixed_prove` puts
307
+ * files back to the reference for one run and restores them afterwards; nothing survives
308
+ * it, and an agent should still know it touches the working tree.
309
+ *
310
+ * `openWorldHint` is false on every one of them, and that is the whole design in one flag:
311
+ * nothing here reaches a server, an account or the internet. There is nowhere to sign up.
312
+ *
313
+ * @param {{title: string, readOnly?: boolean, destructive?: boolean, idempotent?: boolean}} a
314
+ * @returns {Record<string, unknown>}
315
+ */
316
+ function behaves({ title, readOnly = false, destructive = false, idempotent = false }) {
317
+ return { title, readOnlyHint: readOnly, destructiveHint: destructive, idempotentHint: idempotent, openWorldHint: false };
318
+ }
319
+
295
320
  /**
296
321
  * The `tools/list` payload. Static, and it never touches disk: an agent listing
297
322
  * tools in a project that is not set up must still see
298
323
  * `staysfixed_capabilities`, which is the tool that explains why nothing else
299
324
  * will work yet.
300
325
  *
301
- * @returns {{name: string, description: string, inputSchema: Record<string, any>}[]}
326
+ * Every entry carries three things an agent reads before it calls anything: a short
327
+ * `title` a person would recognise in a permission prompt, a description long enough to
328
+ * say WHEN to call it and not merely what it is, and `annotations` saying what it does to
329
+ * the machine. See `behaves`.
330
+ *
331
+ * @returns {{name: string, title: string, description: string, inputSchema: Record<string, any>, annotations: Record<string, unknown>}[]}
302
332
  */
303
333
  export function toolDefinitions() {
304
334
  return [
305
335
  {
306
336
  name: 'staysfixed_capabilities',
337
+ title: 'What can be checked here',
338
+ annotations: behaves({ title: 'What can be checked here', readOnly: true, idempotent: true }),
307
339
  description:
308
340
  'CALL THIS FIRST, once per session. What Stays Fixed can check on this machine right now, what it cannot and why, what is missing that would unlock more, which other machines it can already reach, and the exact shape of every reply you will get back. It runs nothing and changes nothing. After this call you should not need to read any documentation about this tool.',
309
341
  inputSchema: {
@@ -319,6 +351,8 @@ export function toolDefinitions() {
319
351
  },
320
352
  {
321
353
  name: 'staysfixed_intent',
354
+ title: 'Seal what you meant to change',
355
+ annotations: behaves({ title: 'Seal what you meant to change' }),
322
356
  description:
323
357
  'Seal what you MEANT to change, BEFORE you run a check. One plain sentence, the files or areas you expect to affect, and the differences you expect to see. This is what makes a later "that one was me" claim checkable instead of a story: you cannot waive a difference outside what you sealed, and you cannot seal an intent after seeing what broke. Call it once per change, right before or right after you edit.',
324
358
  inputSchema: {
@@ -338,6 +372,8 @@ export function toolDefinitions() {
338
372
  },
339
373
  {
340
374
  name: 'staysfixed_check',
375
+ title: 'Check what changed',
376
+ annotations: behaves({ title: 'Check what changed' }),
341
377
  description:
342
378
  'Run it. Puts the build you just changed through the same steps as the build that was last shipped, twice, and reports only the differences that are left after the product\'s own wobble is subtracted. Covers what the screen says a control does, what calls go out, what files are written, what errors appear, what the program prints, and what the code exposes. You get back ONLY what you did not account for, ranked with the differences furthest from your edit at the top, because those are side effects. Everything unchanged is silent. Seal an intent first.',
343
379
  inputSchema: {
@@ -349,7 +385,11 @@ export function toolDefinitions() {
349
385
  type: 'boolean',
350
386
  description: 'Boot the old build live and walk it from the start instead of trusting the stored record. Slower and much stronger. Use it before a release, and on the first run of a product with no stored record.',
351
387
  },
352
- journeys: { type: 'string', description: "Where the steps come from: 'suite', 'code', 'recorded', or a path to a journeys file." },
388
+ journeys: {
389
+ type: 'string',
390
+ description:
391
+ "Where the steps come from. 'code' is the default and needs nothing: each adapter reads your source and offers what it finds - routes, commands, screens, message channels. The other value is a path to a journeys file naming steps by hand. 'suite' (harvest your own test suite) and 'recorded' (replay a recorded session) are written and not yet wired into a run: ask for either and it says so rather than checking something else.",
392
+ },
353
393
  surface: {
354
394
  type: 'string',
355
395
  enum: ['auto', 'cli', 'library', 'server', 'web', 'electron', 'android', 'ios'],
@@ -370,6 +410,8 @@ export function toolDefinitions() {
370
410
  },
371
411
  {
372
412
  name: 'staysfixed_explain',
413
+ title: 'One finding in full',
414
+ annotations: behaves({ title: 'One finding in full', readOnly: true, idempotent: true }),
373
415
  description:
374
416
  'One finding, in depth: every address that moved, both values in full, what class it is in, how far it sits from your edit, and the evidence. This is where the heavy material lives - it is never pushed into a check reply, so ask for it on the two or three findings you actually intend to act on.',
375
417
  inputSchema: {
@@ -388,6 +430,8 @@ export function toolDefinitions() {
388
430
  },
389
431
  {
390
432
  name: 'staysfixed_prove',
433
+ title: 'Prove what caused it',
434
+ annotations: behaves({ title: 'Prove what caused it' }),
391
435
  description:
392
436
  'Test a causal claim by undoing a change and running again. You believe your edit to a particular file caused a finding: this puts that file back to the reference, re-runs, and tells you whether the difference went away. If it survives the revert, your edit did not cause it and you were about to fix the wrong thing. Nothing is left reverted.',
393
437
  inputSchema: {
@@ -402,6 +446,8 @@ export function toolDefinitions() {
402
446
  },
403
447
  {
404
448
  name: 'staysfixed_waive',
449
+ title: 'Record a difference as intended',
450
+ annotations: behaves({ title: 'Record a difference as intended' }),
405
451
  description:
406
452
  'Record that a difference was intended. This is NOT approval and it makes nothing the new normal - only shipping does that. Four rules are enforced and cannot be argued with: differences touching money, signing in, losing data, a crash, or a named guard can never be waived; the difference has to fall inside what you sealed with staysfixed_intent before the run; five between one ship and the next; and every waiver dies the moment the reference moves. If a waiver is refused, that is the answer - fix the code instead.',
407
453
  inputSchema: {
@@ -416,6 +462,8 @@ export function toolDefinitions() {
416
462
  },
417
463
  {
418
464
  name: 'staysfixed_coverage',
465
+ title: 'What was not checked',
466
+ annotations: behaves({ title: 'What was not checked', readOnly: true, idempotent: true }),
419
467
  description:
420
468
  'What was NOT checked. The ways in that no journey has ever opened, the surfaces this machine cannot reach at all, anything refused because doing it twice would not have been reversible, and the things this tool can never see on any machine. Read it before you tell anyone a change is safe: a clean check only covers what was walked, and this is the list of what was not.',
421
469
  inputSchema: {
@@ -708,6 +756,21 @@ async function toolCheck(ctx, input) {
708
756
  const limit = positive(input.limit) ?? DEFAULT_LIMIT;
709
757
  const offset = positive(input.offset) ?? 0;
710
758
 
759
+ // A value the engine does not understand must be refused BY NAME. `suite` and
760
+ // `recorded` are real ideas with real code behind them in src/v2/journeys/, and
761
+ // nothing on the check path calls that code yet - so passing either one down reaches
762
+ // the engine as the name of a file, and comes back as "there is no journeys file at
763
+ // .../suite". That error sends an agent looking for a file it never asked for. The
764
+ // day the harvest is wired, this refusal is what has to be deleted.
765
+ const wantedJourneys = text(input.journeys);
766
+ if (wantedJourneys === 'suite' || wantedJourneys === 'recorded') {
767
+ return problem(
768
+ wantedJourneys === 'suite'
769
+ ? 'Harvesting your own test suite as journeys is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, or pass the path to a journeys file. Saying this rather than quietly checking something else is deliberate: a clean result about the wrong steps is worse than no result.'
770
+ : 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, or pass the path to a journeys file.'
771
+ );
772
+ }
773
+
711
774
  const surface = text(input.surface);
712
775
  const at = text(input.at);
713
776
  const aimed = (surface !== null && surface !== 'auto') || at !== null;
@@ -725,7 +788,7 @@ async function toolCheck(ctx, input) {
725
788
  configFile: undefined,
726
789
  against: text(input.against) ?? undefined,
727
790
  paired: input.paired === true,
728
- journeys: text(input.journeys) ?? undefined,
791
+ journeys: wantedJourneys ?? undefined,
729
792
  only: stringList(input.only) ?? [],
730
793
  surface: surface && surface !== 'auto' ? surface : undefined,
731
794
  at: at ?? undefined,
@@ -893,7 +956,12 @@ function renderCheck({ result, unaccounted, page, offset, limit, waived, expired
893
956
  // same otherwise, and one of those is a broken tool reporting success.
894
957
  /** @type {string[]} */
895
958
  const arithmetic = [];
896
- if (result?.coverage) arithmetic.push(`${result.coverage.journeys} ${result.coverage.journeys === 1 ? 'way in was' : 'ways in were'} walked`);
959
+ // "journeys", never "ways in". A DOOR is a way in a route, a command, an exported
960
+ // name — and the coverage sentence directly below this one counts doors. Calling both
961
+ // of them "ways in" put "2 ways in were walked" one line above "2 of the 2 ways into
962
+ // this product have never been walked through", which is a flat contradiction on screen
963
+ // even though both numbers are right. Two different things need two different words.
964
+ if (result?.coverage) arithmetic.push(`${result.coverage.journeys} ${result.coverage.journeys === 1 ? 'journey was' : 'journeys were'} walked`);
897
965
  if (typeof result?.differencesNoise === 'number' && result.differencesNoise > 0) arithmetic.push(`${result.differencesNoise} differences subtracted as this product's own wobble`);
898
966
  if (waived) arithmetic.push(`${waived} already recorded as intended and not shown again`);
899
967
  if (arithmetic.length) out.push(arithmetic.join(', ') + '.');
@@ -910,7 +978,12 @@ function renderCheck({ result, unaccounted, page, offset, limit, waived, expired
910
978
  'Compared against the STORED RECORD, not against the old build booted live. That is genuinely weaker: it lets back in every difference that comes from the machine and the day rather than from your change. Pass paired: true for the strong comparison.'
911
979
  );
912
980
  }
913
- if (result?.summary) out.push(result.summary);
981
+ // The engine's summary ends with the same "not everything was checked" sentence that is
982
+ // already printed under the headline, because both come from the one place that is
983
+ // allowed to write it. Said twice in one reply it reads as a stutter, and an agent
984
+ // paying by the token pays for it twice, so the second copy is taken out here rather
985
+ // than by weakening either of the two rules that put it there.
986
+ if (result?.summary) out.push(withoutRepeat(result.summary, notChecked));
914
987
 
915
988
  if (newlyUnstable.length) {
916
989
  out.push('');
@@ -1022,6 +1095,24 @@ function coverageSentence(engine, result) {
1022
1095
  return `NOT EVERYTHING WAS CHECKED: ${unopened} ways into this product have never been walked through, and ${gaps} other things were not looked at. A clean result only covers what was walked — staysfixed_coverage has the list.`;
1023
1096
  }
1024
1097
 
1098
+ /**
1099
+ * One sentence, said once.
1100
+ *
1101
+ * Returns `text` with `sentence` removed if it is in there, tidied so the seam does not
1102
+ * show. Both strings come from the engine, so this never edits meaning - it only stops the
1103
+ * same words arriving twice in one reply.
1104
+ *
1105
+ * @param {string} text
1106
+ * @param {string} sentence
1107
+ * @returns {string}
1108
+ */
1109
+ function withoutRepeat(text, sentence) {
1110
+ const trimmed = sentence.trim();
1111
+ if (trimmed === '' || !text.includes(trimmed)) return text;
1112
+ const left = text.replace(trimmed, '').replace(/[ \t]{2,}/g, ' ').trim();
1113
+ return left === '' ? text : left;
1114
+ }
1115
+
1025
1116
  /**
1026
1117
  * Did the run go where it was aimed?
1027
1118
  *
@@ -1118,8 +1209,11 @@ async function toolExplain(ctx, input) {
1118
1209
 
1119
1210
  /** @type {string[]} */
1120
1211
  const out = [];
1212
+ // `classify` returns a whole verdict, not a class name, and interpolating the object
1213
+ // printed "(SEALED: [object Object])" on the one reply an agent reads when it is trying
1214
+ // to understand a difference it is not allowed to waive.
1121
1215
  const sealed = classify(f);
1122
- out.push(f.title + (sealed ? ` (SEALED: ${sealed} - not yours to waive)` : ''));
1216
+ out.push(f.title + (sealed ? ` (SEALED: ${sealed.says} - not yours to waive)` : ''));
1123
1217
  if (typeof f.distance === 'number') {
1124
1218
  out.push(f.distance === 0 ? 'This sits inside the code you changed.' : `This sits ${f.distance} away from the code you changed, which is why it is ranked where it is. The further away, the more it looks like a side effect.`);
1125
1219
  }
@@ -1132,7 +1226,11 @@ async function toolExplain(ctx, input) {
1132
1226
  if (paths.length > 60) out.push(` and ${paths.length - 60} more.`);
1133
1227
  }
1134
1228
 
1135
- if (include.includes('values')) {
1229
+ // The engine's own deep answer lists every address with both values in full. When it is
1230
+ // there, printing a one-address sample above it is the same text twice in one reply -
1231
+ // and an agent pays for both copies.
1232
+ const engineShowsValues = typeof deep?.text === 'string' && f.sample != null && deep.text.includes(stringy(f.sample.candidate).trim());
1233
+ if (include.includes('values') && !engineShowsValues) {
1136
1234
  out.push('');
1137
1235
  if (f.sample) {
1138
1236
  out.push(`BEFORE - ${f.sample.path}`);
@@ -1360,7 +1458,13 @@ async function toolCoverage(ctx, input) {
1360
1458
  // machine could run. A Mac with Xcode on it can run an iPhone app; that says nothing
1361
1459
  // about whether there is an adapter here that knows how to open one.
1362
1460
  cannotBeDriven: (caps?.drivers ?? []).filter((/** @type {any} */ d) => !d.present).map((/** @type {any} */ d) => ({ surface: d.surface, why: d.why })),
1363
- unopened: (coverage?.gaps ?? []).filter((/** @type {{doors?: number}} */ g) => typeof g.doors !== 'number').map((/** @type {{what: string}} */ g) => g.what),
1461
+ // The whole gap, not just its headline. Several of the caveats the engine raises
1462
+ // share one headline — "This coverage count is less exact than it looks" — and
1463
+ // differ entirely in `why`, so a list of headlines is the same sentence three times
1464
+ // and none of the three reasons. The reason IS the content.
1465
+ unopened: (coverage?.gaps ?? [])
1466
+ .filter((/** @type {{doors?: number}} */ g) => typeof g.doors !== 'number')
1467
+ .map((/** @type {{what: string, why?: string, unlockedBy?: string}} */ g) => ({ what: g.what, why: g.why ?? null, unlockedBy: g.unlockedBy ?? null })),
1364
1468
  surfacesOutOfReach: unreachable.map((/** @type {any} */ s) => ({ name: s.name, why: s.summary, needs: s.needs })),
1365
1469
  surfacesPartial: partial.map((/** @type {any} */ s) => ({ name: s.name, why: s.summary })),
1366
1470
  neverVisible: caps?.limits ?? null,
@@ -1388,10 +1492,11 @@ async function toolCoverage(ctx, input) {
1388
1492
  // not look at it. Printing the sentence keeps the reason attached to the hole. The
1389
1493
  // doors gap is left out here because it is already counted, in its own words, just
1390
1494
  // above — and a number a reader can catch out twice is a number they stop believing.
1495
+ /** @type {{what: string, why: string, unlockedBy: string}[]} */
1391
1496
  const unopened = (coverage.gaps ?? [])
1392
1497
  .filter((/** @type {{doors?: number}} */ g) => typeof g.doors !== 'number')
1393
- .map((/** @type {{what: string}} */ g) => g.what);
1394
- out.push(`The last run walked ${coverage.journeys} ${coverage.journeys === 1 ? 'way in' : 'ways in'}.`);
1498
+ .map((/** @type {{what: string, why?: string, unlockedBy?: string}} */ g) => ({ what: String(g.what), why: String(g.why ?? ''), unlockedBy: String(g.unlockedBy ?? '') }));
1499
+ out.push(`The last run walked ${coverage.journeys} ${coverage.journeys === 1 ? 'journey' : 'journeys'}. A journey is one route through the product; a door is one way into it, and they are counted separately below.`);
1395
1500
  const doorsKnown = coverage.doorsKnown ?? 0;
1396
1501
  const never = Math.max(0, doorsKnown - (coverage.doorsWalked ?? 0));
1397
1502
  if (never > 0) {
@@ -1405,7 +1510,15 @@ async function toolCoverage(ctx, input) {
1405
1510
  out.push('It opened every way in that it knows about. That is not the same as every possible state - nothing can enumerate that - but there is no known door it has never been through.');
1406
1511
  } else if (unopened.length > 0) {
1407
1512
  out.push(`${unopened.length} other ${unopened.length === 1 ? 'thing was' : 'things were'} not looked at, so nothing in any check says anything about ${unopened.length === 1 ? 'it' : 'them'}:`);
1408
- for (const d of unopened.slice(0, 30)) out.push(`- ${trim(String(d), 160)}`);
1513
+ for (const gap of unopened.slice(0, 30)) {
1514
+ out.push(`- ${trim(gap.what, 160)}`);
1515
+ // The reason on its own line and never dropped. Three gaps here can carry the
1516
+ // same headline and three different reasons, and printing only the headline
1517
+ // turned that into the same sentence three times over - which reads as a bug in
1518
+ // the tool rather than as three separate holes in the coverage.
1519
+ if (gap.why) out.push(` ${trim(gap.why, 400)}`);
1520
+ if (gap.unlockedBy && !/^Read the caveat/i.test(gap.unlockedBy)) out.push(` What would close it: ${trim(gap.unlockedBy, 300)}`);
1521
+ }
1409
1522
  if (unopened.length > 30) out.push(`- and ${unopened.length - 30} more.`);
1410
1523
  }
1411
1524
  }
@@ -34,6 +34,13 @@ import { canonicalJson, matchPath } from './observation.js';
34
34
  /** Where in a value we are, when a rule needs to say. */
35
35
  const ROOT = '$';
36
36
 
37
+ /**
38
+ * How deep into a value the tidying goes. It matches the depth an observation is allowed to
39
+ * be in the first place (MAX_VALUE_DEPTH in observation.js), so on anything this tool made
40
+ * itself the limit is never reached; it only bites on a value read back off the disk.
41
+ */
42
+ const MAX_WALK_DEPTH = 64;
43
+
37
44
  /** Sentinel for a value a `drop` rule removed. Never appears in a result. */
38
45
  const DROPPED = Symbol('dropped');
39
46
 
@@ -632,7 +639,24 @@ export function normaliseCapture(capture, rules) {
632
639
  * @returns {ObservedValue|typeof DROPPED}
633
640
  */
634
641
  function walk(node, at, rules, record, depth) {
635
- if (depth > 64) return node;
642
+ if (depth > MAX_WALK_DEPTH) {
643
+ // Everything from here down goes into the comparison exactly as it arrived: no clock is
644
+ // rubbed out, no id is flattened. That is churn arriving as findings rather than a real
645
+ // finding disappearing, so it is the safe direction — but it is still a piece of the
646
+ // value nothing looked at, and a receipt is the only reason anybody would ever know.
647
+ if (record) {
648
+ record.push({
649
+ ruleId: 'depth.limit',
650
+ what: `Part of this value nests deeper than ${MAX_WALK_DEPTH} levels.`,
651
+ why: 'Tidying stops there, so nothing below it was rewritten before comparing.',
652
+ wouldHide: 'Nothing. It hides the opposite: timestamps and ids down there are compared as they are, so they will report as differences on every run.',
653
+ at,
654
+ before: '(not rewritten)',
655
+ after: '(not rewritten)',
656
+ });
657
+ }
658
+ return node;
659
+ }
636
660
 
637
661
  for (const rule of rules) {
638
662
  if (rule.kind === 'drop' && ruleAppliesAt(rule, at)) {
@@ -670,17 +694,40 @@ function walk(node, at, rules, record, depth) {
670
694
  return items;
671
695
  }
672
696
 
673
- /** @type {Record<string, ObservedValue>} */
674
- const out = {};
697
+ // A Map, not an object, and this is not a style choice. `newKey in out` asks the whole
698
+ // prototype chain, so on a plain `{}` the answer for `toString`, `constructor`, `valueOf`
699
+ // and `hasOwnProperty` is always yes — and every one of those entries was silently thrown
700
+ // away, on BOTH sides of the comparison, so a change inside one of them could never be
701
+ // seen. Worse, `out['__proto__'] = value` sets the prototype instead of storing anything,
702
+ // which loses the entry and quietly rewires the object. A Map has neither problem.
703
+ /** @type {Map<string, ObservedValue>} */
704
+ const kept = new Map();
675
705
  for (const [key, child] of Object.entries(node)) {
676
706
  const newKey = rewriteKey(key, at, rules, record);
677
707
  const value = walk(/** @type {ObservedValue} */ (child), `${at}.${key}`, rules, record, depth + 1);
678
708
  if (value === DROPPED) continue;
679
- // First key wins on a collision. Losing an entry silently is exactly why `keys` is off by
680
- // default; when a project switches it on anyway, the loss is at least deterministic.
681
- if (!(newKey in out)) out[newKey] = value;
709
+ if (!kept.has(newKey)) {
710
+ kept.set(newKey, value);
711
+ continue;
712
+ }
713
+ // Two keys tidied down to one name. The first wins, and the second is GONE — never
714
+ // compared with anything, at an address that no longer exists. This is the loss `keys`
715
+ // is off by default because of, and when a project switches it on anyway the loss is at
716
+ // least written down instead of happening in silence.
717
+ const first = kept.get(newKey);
718
+ if (record && canonicalJson(/** @type {ObservedValue} */ (first)) !== canonicalJson(value)) {
719
+ record.push({
720
+ ruleId: 'keys.collision',
721
+ what: `Two entries here ended up with the same name, "${newKey}".`,
722
+ why: 'A rule that rewrites keys turned two different names into one, and only the first entry survives.',
723
+ wouldHide: 'The second entry entirely. Nothing compares it against anything, so a change inside it cannot be seen. Sort the collection and observe its members by position instead.',
724
+ at: `${at}.${key} (key)`,
725
+ before: clip(canonicalJson(value)),
726
+ after: '<lost to a name it now shares>',
727
+ });
728
+ }
682
729
  }
683
- return out;
730
+ return Object.fromEntries(kept);
684
731
  }
685
732
 
686
733
  /**
@@ -329,30 +329,65 @@ function isPlainObject(v) {
329
329
  * @returns {string}
330
330
  */
331
331
  export function canonicalJson(value) {
332
- return JSON.stringify(canonicalise(value, 0)) ?? 'null';
332
+ return canonicalForm(value).json;
333
+ }
334
+
335
+ /**
336
+ * The canonical string, and whether anything had to be given up to produce it.
337
+ *
338
+ * The flag is the whole point. Below `MAX_VALUE_DEPTH` the canonical form stands in for the
339
+ * value exactly; past it, the deep part is replaced by a marker, and TWO DIFFERENT deep
340
+ * values collapse to the same string. Comparing those two strings and calling them equal is
341
+ * the tool saying "these are the same" about a pair it never finished reading, which is the
342
+ * one sentence it may never say. So the flag travels out and `sameValue` refuses.
343
+ *
344
+ * @param {ObservedValue} value
345
+ * @returns {{json: string, tooDeep: boolean}}
346
+ */
347
+ function canonicalForm(value) {
348
+ const how = { tooDeep: false };
349
+ return { json: JSON.stringify(canonicalise(value, 0, how)) ?? 'null', tooDeep: how.tooDeep };
333
350
  }
334
351
 
335
352
  /**
336
353
  * @param {ObservedValue} value
337
354
  * @param {number} depth
355
+ * @param {{tooDeep: boolean}} how
338
356
  * @returns {unknown}
339
357
  */
340
- function canonicalise(value, depth) {
341
- if (depth > MAX_VALUE_DEPTH) return '<too deep>';
358
+ function canonicalise(value, depth, how) {
359
+ if (depth > MAX_VALUE_DEPTH) {
360
+ how.tooDeep = true;
361
+ return '<too deep>';
362
+ }
342
363
  if (typeof value === 'number' && !Number.isFinite(value)) return `<number:${String(value)}>`;
343
- if (Array.isArray(value)) return value.map((v) => canonicalise(v, depth + 1));
364
+ if (Array.isArray(value)) return value.map((v) => canonicalise(v, depth + 1, how));
344
365
  if (isPlainObject(value)) {
345
- /** @type {Record<string, unknown>} */
346
- const out = {};
366
+ // Built through a Map and `Object.fromEntries`, because plain assignment cannot hold a
367
+ // key called `__proto__`: `out['__proto__'] = x` rewires the object instead of storing
368
+ // anything, so every object with that key canonicalised to `{}` and any two of them
369
+ // compared EQUAL however different their contents. A JSON body with a `__proto__` field
370
+ // in it is not exotic; it is what a product prints when it dumps data it was handed.
371
+ /** @type {Map<string, unknown>} */
372
+ const out = new Map();
347
373
  for (const key of Object.keys(value).sort()) {
348
- out[key] = canonicalise(/** @type {ObservedValue} */ (value[key]), depth + 1);
374
+ out.set(key, canonicalise(/** @type {ObservedValue} */ (value[key]), depth + 1, how));
349
375
  }
350
- return out;
376
+ return Object.fromEntries(out);
351
377
  }
352
378
  return value;
353
379
  }
354
380
 
355
381
  /**
382
+ * Are these two values the same value?
383
+ *
384
+ * "I could not finish reading either of them" is not the same answer as "yes", and until
385
+ * 2026-08-30 it came back as one: anything nesting deeper than the tool reads was flattened
386
+ * to the text `<too deep>`, so two completely different deep values compared EQUAL and every
387
+ * difference inside them vanished without a word. `makeObservation` refuses to make one that
388
+ * deep, so this only ever happens to a value read back off the disk — which is precisely the
389
+ * value nobody watched being made.
390
+ *
356
391
  * @param {ObservedValue|undefined} a
357
392
  * @param {ObservedValue|undefined} b
358
393
  * @returns {boolean}
@@ -360,7 +395,12 @@ function canonicalise(value, depth) {
360
395
  export function sameValue(a, b) {
361
396
  if (a === undefined || b === undefined) return a === b;
362
397
  if (a === b) return true;
363
- return canonicalJson(a) === canonicalJson(b);
398
+ const x = canonicalForm(a);
399
+ const y = canonicalForm(b);
400
+ // Different is the safe answer and the honest one: it makes the pair a finding somebody
401
+ // reads rather than a silence nobody can see.
402
+ if (x.tooDeep || y.tooDeep) return false;
403
+ return x.json === y.json;
364
404
  }
365
405
 
366
406
  /**
@@ -823,7 +863,13 @@ export function wobbleStorm(wobble) {
823
863
  const looked = unstable + wobble.steady;
824
864
  const vanished = wobble.entries.filter((e) => e.kind === 'vanished').length;
825
865
  const share = looked === 0 ? 0 : unstable / looked;
826
- if (!wobble.measured || looked < STORM_FLOOR || share <= STORM_SHARE) {
866
+ // Nothing held still at all. This is the one case the share and the floor between them let
867
+ // through: a journey with eight addresses where all eight wobble is 100% of the comparison
868
+ // thrown away, and the floor exists to stop three-out-of-four being called a storm, not to
869
+ // excuse a comparison that ended up empty. Zero steady addresses is not a tuned number; it
870
+ // is the arithmetic saying there was nothing left to compare.
871
+ const nothingHeldStill = wobble.measured && wobble.steady === 0 && unstable > 0;
872
+ if (!wobble.measured || (!nothingHeldStill && (looked < STORM_FLOOR || share <= STORM_SHARE))) {
827
873
  return { stormy: false, share, looked, vanished, why: '' };
828
874
  }
829
875
  const percent = Math.round(share * 100);