@codyswann/lisa 4.72.2 → 4.72.4

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 (63) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +225 -33
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +6 -2
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/package.json +4 -4
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa/hooks/worktree-binding-guard.mjs +225 -33
  13. package/plugins/lisa-agy/plugin.json +1 -1
  14. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  17. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +225 -33
  21. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +225 -33
  23. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-expo-agy/plugin.json +1 -1
  26. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  36. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  41. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  46. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-rails-agy/plugin.json +1 -1
  51. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  56. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  61. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/src/base/hooks/worktree-binding-guard.mjs +225 -33
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.72.2",
3
+ "version": "4.72.4",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -35,9 +35,9 @@
35
35
  * first attempt and lets the retry through is defeated by a blind retry, which
36
36
  * is the single most likely next action. So the block stands until the session
37
37
  * states, in the acknowledgement, the absolute path it now intends to work in.
38
- * A path that does not match what the session is actually in is refused too —
39
- * that is the rejection control, and it is what stops a stale acknowledgement
40
- * copied from another agent's transcript from silently rebinding this one.
38
+ * An acknowledgement can choose the observed root, return to the initial root,
39
+ * or accept a linked target this session was explicitly offered. A selected
40
+ * target does not prove a move: the next guarded call must actually be there.
41
41
  *
42
42
  * FAILING OPEN, LOUDLY. Anything this cannot determine — no session id, no
43
43
  * cwd, not a git repository, an unreadable state file — exits 0 with a line on
@@ -68,6 +68,7 @@ const GUARDED_TOOLS = new Set(["Bash", "Write", "Edit", "MultiEdit"]);
68
68
  * on its own PreToolUse pass — which is exactly where a command string arrives.
69
69
  */
70
70
  const ACCEPT_PREFIX = "lisa-worktree-binding: accept";
71
+ const ACK_AND_SEPARATOR = Symbol("acknowledgement AND separator");
71
72
 
72
73
  /**
73
74
  * Resolve a path the way git reports one.
@@ -154,6 +155,46 @@ function mainCheckout(cwd) {
154
155
  return null;
155
156
  }
156
157
 
158
+ /** Registered roots of the bound repository, with Git path quoting disabled. */
159
+ function linkedWorktreeRoots(bound) {
160
+ const listing = git(["worktree", "list", "--porcelain", "-z"], bound);
161
+ if (listing === null) {
162
+ say(
163
+ "git could not identify linked worktrees; repository identity is NOT enforced"
164
+ );
165
+ return null;
166
+ }
167
+ return new Set(
168
+ listing
169
+ .split("\0")
170
+ .filter(record => record.startsWith("worktree "))
171
+ .map(record => realpath(record.slice("worktree ".length)))
172
+ );
173
+ }
174
+
175
+ /** Canonical common Git directory: stable identity across linked checkouts. */
176
+ function repositoryIdentity(root) {
177
+ if (typeof root !== "string") return null;
178
+ const common = git(["rev-parse", "--git-common-dir"], root);
179
+ if (common === null) {
180
+ say("git could not compare repository identity; identity is NOT enforced");
181
+ return null;
182
+ }
183
+ return realpath(resolve(root, common));
184
+ }
185
+
186
+ /** A registration is only authoritative while its live repository agrees. */
187
+ function isLinkedWorktree(
188
+ bound,
189
+ candidate,
190
+ roots = linkedWorktreeRoots(bound)
191
+ ) {
192
+ if (!roots?.has(candidate) || worktreeRoot(candidate) !== candidate)
193
+ return false;
194
+ const common = repositoryIdentity(bound);
195
+ return common !== null && common === repositoryIdentity(candidate);
196
+ }
197
+
157
198
  /**
158
199
  * How much of an executed script this guard will read, in bytes.
159
200
  *
@@ -297,6 +338,7 @@ function executedPath(segment) {
297
338
  * fooled by exactly that and was caught only by a known-answer control.
298
339
  */
299
340
  function foreignTreeIn(text, bound, cwd) {
341
+ let linkedRoots;
300
342
  const code = text
301
343
  .split("\n")
302
344
  .map(line => line.replace(/^\s*#.*$/u, ""))
@@ -317,7 +359,10 @@ function foreignTreeIn(text, bound, cwd) {
317
359
  } catch {
318
360
  continue;
319
361
  }
320
- if (root && root !== bound) return root;
362
+ if (root && root !== bound) {
363
+ if (linkedRoots === undefined) linkedRoots = linkedWorktreeRoots(bound);
364
+ if (isLinkedWorktree(bound, root, linkedRoots)) return root;
365
+ }
321
366
  }
322
367
  return null;
323
368
  }
@@ -496,10 +541,10 @@ function reachesOut(script, foreign, bound) {
496
541
  ` script: ${script}`,
497
542
  ` reaches: ${foreign}`,
498
543
  ` bound to: ${bound}`,
499
- "The directory change lives inside the file, so it happens after this",
500
- "guard has measured where the session is. Run the work from the tree it",
501
- "belongs to, or acknowledge the move:",
544
+ "The access lives inside the file, after this guard measures the session.",
545
+ "Run the work from the tree it belongs to, or acknowledge that target:",
502
546
  acceptanceLine(foreign),
547
+ "Then move to the acknowledged tree before further guarded calls.",
503
548
  ]);
504
549
  }
505
550
 
@@ -599,9 +644,8 @@ function readState(bindingKeyValue, payload) {
599
644
  /**
600
645
  * Write the session's binding state, MERGING over whatever is already there.
601
646
  *
602
- * Every caller below hands in all three lifecycle fields explicitly, so the
603
- * merge changes nothing for them — `claimedRoot: null` still clears a claim.
604
- * What it buys is that a field one writer owns is not silently dropped by
647
+ * Explicit `claimedRoot: null` still clears a claim. Merging means that a
648
+ * field one writer owns is not silently dropped by
605
649
  * another writer that had no opinion about it: `runtimeNoticed` is written by
606
650
  * {@link noticeRuntimeDrift} and read by nobody else, and a clobbering write
607
651
  * would turn its "reported once" into "reported again after the next
@@ -614,12 +658,24 @@ function readState(bindingKeyValue, payload) {
614
658
  */
615
659
  function writeState(bindingKeyValue, state, payload) {
616
660
  const file = stateFile(bindingKeyValue);
661
+ const previous = readState(bindingKeyValue, payload);
662
+ const originalRoot =
663
+ previous?.originalRoot ?? previous?.boundRoot ?? state.boundRoot;
664
+ const originalCommonDir =
665
+ previous?.originalCommonDir ?? repositoryIdentity(originalRoot);
617
666
  try {
618
667
  mkdirSync(dirname(file), { recursive: true });
619
668
  writeFileSync(
620
669
  file,
621
670
  `${JSON.stringify(
622
- { ...readState(bindingKeyValue, payload), ...state },
671
+ {
672
+ ...previous,
673
+ ...state,
674
+ // Preserve the initial root after intentional rebinds. A legacy
675
+ // state's established root is its only proven recovery anchor.
676
+ originalRoot,
677
+ originalCommonDir,
678
+ },
623
679
  null,
624
680
  2
625
681
  )}\n`
@@ -746,43 +802,161 @@ function refuse(lines) {
746
802
  }
747
803
 
748
804
  function acceptanceLine(observed) {
749
- return ` echo '${ACCEPT_PREFIX} ${observed}'`;
805
+ const literal = `${ACCEPT_PREFIX} ${observed}`.replaceAll("'", "'\\''");
806
+ return ` echo '${literal}'`;
807
+ }
808
+
809
+ /** Read literal shell words and AND separators without evaluating expansions. */
810
+ function acknowledgementWords(text) {
811
+ const words = [];
812
+ let word = "",
813
+ active = false,
814
+ quote = null;
815
+ const emit = () => {
816
+ if (active) words.push(word);
817
+ word = "";
818
+ active = false;
819
+ };
820
+ for (let index = 0; index < text.length; index += 1) {
821
+ const char = text[index];
822
+ if (quote === "'") {
823
+ if (char === "'") quote = null;
824
+ else word += char;
825
+ continue;
826
+ }
827
+ if (char === "\\") {
828
+ const next = text[index + 1];
829
+ if (next === undefined) return null;
830
+ if (quote === '"' && !['"', "\\", "$", "`", "\n"].includes(next)) {
831
+ word += char;
832
+ continue;
833
+ }
834
+ index += 1;
835
+ if (next !== "\n") {
836
+ word += next;
837
+ active = true;
838
+ }
839
+ continue;
840
+ }
841
+ if (quote === '"') {
842
+ if (char === '"') quote = null;
843
+ else if (char === "$" || char === "`") return null;
844
+ else word += char;
845
+ continue;
846
+ }
847
+ if (char === "'" || char === '"') {
848
+ quote = char;
849
+ active = true;
850
+ } else if (char === " " || char === "\t") emit();
851
+ else if (char === "\n") return null;
852
+ else if (char === "&" && text[index + 1] === "&") {
853
+ emit();
854
+ words.push(ACK_AND_SEPARATOR);
855
+ index += 1;
856
+ } else if (/[&;|<>$`*?[\]{}()]/u.test(char)) return null;
857
+ else {
858
+ word += char;
859
+ active = true;
860
+ }
861
+ }
862
+ if (quote !== null) return null;
863
+ emit();
864
+ return words;
865
+ }
866
+
867
+ /** Strip only actual shell edge whitespace in linear time. */
868
+ function acknowledgementText(command) {
869
+ let start = 0,
870
+ end = command.length;
871
+ while (start < end && " \t\n".includes(command[start])) start += 1;
872
+ while (end > start && " \t\n".includes(command[end - 1])) end -= 1;
873
+ return command.slice(start, end);
874
+ }
875
+
876
+ /** A literal echo, optionally preceded by a literal cd to the stated root. */
877
+ function acceptanceRequest(command) {
878
+ const text = acknowledgementText(command);
879
+ const words = acknowledgementWords(text.replace(/;$/u, ""));
880
+ if (!words) return null;
881
+ const direct = words.length === 2 && words[0] === "echo";
882
+ const moving =
883
+ words.length === 5 &&
884
+ words[0] === "cd" &&
885
+ typeof words[1] === "string" &&
886
+ words[2] === ACK_AND_SEPARATOR &&
887
+ words[3] === "echo";
888
+ if (!direct && !moving) return null;
889
+ const message = words.at(-1);
890
+ if (typeof message !== "string" || !message.startsWith(`${ACCEPT_PREFIX} `))
891
+ return null;
892
+ const stated = message.slice(ACCEPT_PREFIX.length + 1);
893
+ // The printed protocol names absolute roots. Empty/relative operands and
894
+ // cd's special ~, -, or -- forms cannot authorize a move.
895
+ if (!isAbsolute(stated)) return null;
896
+ const path = realpath(stated);
897
+ const destination =
898
+ moving && isAbsolute(words[1]) ? realpath(words[1]) : null;
899
+ return {
900
+ path,
901
+ validMove: !moving || (destination !== null && destination === path),
902
+ };
750
903
  }
751
904
 
752
905
  /**
753
- * Handle an acknowledgement line, or return null when the command is not one.
754
- *
755
- * The path is compared against what the session is measurably in, not against
756
- * what it says it wants. An acknowledgement naming somewhere else is the case
757
- * this is for: it means the operator is reasoning about a tree they are not in.
906
+ * Validate the initial recovery root against the identity captured at baseline.
907
+ * The current checkout may have been removed or deliberately rebound elsewhere.
758
908
  */
909
+ function isOriginalRoot(state, path) {
910
+ const original = state?.originalRoot ?? state?.boundRoot;
911
+ if (path !== original || worktreeRoot(path) !== path) return false;
912
+ // Older state can only prove the repository of its established binding.
913
+ const common = state.originalCommonDir ?? repositoryIdentity(state.boundRoot);
914
+ return common !== null && common === repositoryIdentity(path);
915
+ }
916
+
917
+ /** Acknowledgements select intent; the next call must prove actual movement. */
759
918
  function handleAcceptance(payload, observed) {
760
919
  const command = payload.tool_input?.command;
761
920
  if (typeof command !== "string" || !command.includes(ACCEPT_PREFIX))
762
921
  return null;
763
- const stated = command.slice(
764
- command.indexOf(ACCEPT_PREFIX) + ACCEPT_PREFIX.length
765
- );
766
- const match = /([^\s'"]+)/.exec(stated);
767
- const path = match ? realpath(resolve(match[1])) : null;
768
- if (path !== observed) {
922
+ const request = acceptanceRequest(command);
923
+ if (!request) return null;
924
+ const { path, validMove } = request;
925
+ const previous = readState(bindingKey(payload), payload);
926
+ const original = previous?.originalRoot ?? previous?.boundRoot;
927
+ const recovering = isOriginalRoot(previous, path);
928
+ const offered =
929
+ path === previous?.offeredRoot &&
930
+ isLinkedWorktree(previous.boundRoot, path);
931
+ if (!validMove || (path !== observed && !recovering && !offered)) {
932
+ const suggestion = observed ?? original;
769
933
  return refuse([
770
934
  `worktree-binding-guard: this acknowledgement names ${path ?? "no path"},`,
771
- `but this session is operating in ${observed}. Not rebinding.`,
772
- `If ${observed} is where you mean to work, acknowledge that path:`,
773
- acceptanceLine(observed),
935
+ "but it is not a valid move to the observed, original, or offered worktree. Not rebinding.",
936
+ ...(suggestion
937
+ ? [
938
+ "Acknowledge the measured or original worktree:",
939
+ acceptanceLine(suggestion),
940
+ ]
941
+ : []),
774
942
  ]);
775
943
  }
776
- writeState(
944
+ const written = writeState(
777
945
  bindingKey(payload),
778
946
  {
779
- boundRoot: observed,
947
+ boundRoot: path,
780
948
  claimedRoot: null,
949
+ offeredRoot: null,
781
950
  updatedAt: new Date().toISOString(),
782
951
  },
783
952
  payload
784
953
  );
785
- say(`bound to ${observed}`);
954
+ if (written)
955
+ say(
956
+ path === observed
957
+ ? `bound to ${path}`
958
+ : `binding set to ${path}; move there before further guarded calls`
959
+ );
786
960
  return 0;
787
961
  }
788
962
 
@@ -982,6 +1156,11 @@ function evaluate(payload) {
982
1156
  }
983
1157
  const binding = bindingKey(payload);
984
1158
  const observed = worktreeRoot(cwd);
1159
+ // The initial-root recovery acknowledgement also works outside any repo.
1160
+ if (GUARDED_TOOLS.has(payload.tool_name)) {
1161
+ const accepted = handleAcceptance(payload, observed);
1162
+ if (accepted !== null) return accepted;
1163
+ }
985
1164
  if (!observed) return 0;
986
1165
 
987
1166
  if (payload.hook_event_name === "SessionStart") {
@@ -993,14 +1172,18 @@ function evaluate(payload) {
993
1172
  return 0;
994
1173
  }
995
1174
  if (payload.tool_name === "EnterWorktree") {
1175
+ const previous = readState(binding, payload);
1176
+ if (
1177
+ previous?.boundRoot &&
1178
+ previous.boundRoot !== observed &&
1179
+ !isLinkedWorktree(previous.boundRoot, observed)
1180
+ )
1181
+ return 0;
996
1182
  recordClaim(payload, observed);
997
1183
  return 0;
998
1184
  }
999
1185
  if (!GUARDED_TOOLS.has(payload.tool_name)) return 0;
1000
1186
 
1001
- const accepted = handleAcceptance(payload, observed);
1002
- if (accepted !== null) return accepted;
1003
-
1004
1187
  const state = readState(binding, payload);
1005
1188
  if (!state?.boundRoot) {
1006
1189
  // No baseline means SessionStart did not run for this session — an older
@@ -1021,6 +1204,11 @@ function evaluate(payload) {
1021
1204
  );
1022
1205
  return 0;
1023
1206
  }
1207
+ if (
1208
+ state.boundRoot !== observed &&
1209
+ !isLinkedWorktree(state.boundRoot, observed)
1210
+ )
1211
+ return 0;
1024
1212
  if (state.claimedRoot && state.claimedRoot !== observed) {
1025
1213
  return unconfirmedSwitch(state.claimedRoot, observed);
1026
1214
  }
@@ -1030,6 +1218,7 @@ function evaluate(payload) {
1030
1218
  {
1031
1219
  boundRoot: observed,
1032
1220
  claimedRoot: null,
1221
+ offeredRoot: null,
1033
1222
  updatedAt: new Date().toISOString(),
1034
1223
  },
1035
1224
  payload
@@ -1040,7 +1229,10 @@ function evaluate(payload) {
1040
1229
  // Last, and only once the session is provably where it should be: a script
1041
1230
  // that moves somewhere else after this check has already run.
1042
1231
  const reach = scriptReachingForeignTree(payload, observed);
1043
- if (reach) return reachesOut(reach.script, reach.foreign, observed);
1232
+ if (reach) {
1233
+ writeState(binding, { offeredRoot: reach.foreign }, payload);
1234
+ return reachesOut(reach.script, reach.foreign, observed);
1235
+ }
1044
1236
  return 0;
1045
1237
  }
1046
1238