@codyswann/lisa 4.66.2 → 4.66.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 (68) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +135 -50
  2. package/cdk/create-only/.github/workflows/deploy.yml +10 -0
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +7 -6
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +19 -4
  11. package/expo/create-only/.github/workflows/deploy.yml +10 -0
  12. package/nestjs/create-only/.github/workflows/deploy.yml +10 -0
  13. package/package.json +4 -4
  14. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/hooks/worktree-binding-guard.mjs +135 -50
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  21. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +135 -50
  25. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +135 -50
  27. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo-agy/plugin.json +1 -1
  30. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  50. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails-agy/plugin.json +1 -1
  55. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  60. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  65. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/src/base/hooks/worktree-binding-guard.mjs +135 -50
  68. package/typescript/copy-contents/.husky/commit-msg +48 -11
@@ -511,6 +511,27 @@ function stateFile(bindingKey) {
511
511
  return join(home, "worktree-binding", `${bindingKey}.json`);
512
512
  }
513
513
 
514
+ /**
515
+ * Percent-escape every byte that is not filename-safe, so the output cannot
516
+ * contain `-` at all and two escaped parts can be joined on `--` without the
517
+ * joiner ever being smuggled in from either side.
518
+ *
519
+ * `:` and friends are folded too — the key lands in a filename and `:` is
520
+ * illegal on Windows/NTFS (CodySwannGT/lisa#4277). `encodeURIComponent`
521
+ * leaves `! ' ( ) *` alone, and `*` is equally illegal on Windows, so they
522
+ * are escaped by hand.
523
+ * @param {string} value - One component of the binding key
524
+ * @returns {string} A component containing only `[A-Za-z0-9._~]` and `%XX`
525
+ */
526
+ function keyPart(value) {
527
+ return encodeURIComponent(value)
528
+ .replaceAll(
529
+ /[!'()*]/g,
530
+ c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
531
+ )
532
+ .replaceAll("-", "%2D");
533
+ }
534
+
514
535
  /**
515
536
  * The identity a binding belongs to.
516
537
  *
@@ -524,22 +545,58 @@ function stateFile(bindingKey) {
524
545
  * session-scoped key it always had, which is also the fallback for runtimes
525
546
  * that never send the field.
526
547
  *
527
- * The key lands in a filename, so the composite is folded into a set that is
528
- * legal on every filesystem — `:` is not, which rules it out as a separator
529
- * and as tolerated session_id content on Windows hosts.
548
+ * Both shapes escape every component with {@link keyPart}, so the session key
549
+ * `x` and the agent key `a--b` live in disjoint namespaces: an escaped
550
+ * component never contains `-`, a main-session key is one component, an
551
+ * agent key is two joined by the only `--` the string can hold. Session
552
+ * "s%2Da--c" cannot collide with session "s-a" + agent "c", and neither can
553
+ * collide across the two shapes (CodySwannGT/lisa#4294).
530
554
  */
531
555
  function bindingKey(payload) {
532
556
  const agent = payload?.agent_id;
533
- if (typeof agent !== "string" || !agent) return payload.session_id;
534
- return `${payload.session_id}--${agent}`.replaceAll(/[^A-Za-z0-9._-]/g, "_");
557
+ if (typeof agent !== "string" || !agent) return keyPart(payload.session_id);
558
+ return `${keyPart(payload.session_id)}--${keyPart(agent)}`;
535
559
  }
536
560
 
537
- function readState(sessionId) {
538
- try {
539
- return JSON.parse(readFileSync(stateFile(sessionId), "utf8"));
540
- } catch {
541
- return null;
561
+ /**
562
+ * The keys earlier versions wrote for this payload, newest first.
563
+ *
564
+ * The v1 key was the raw `session_id` (single shared file per session). v2
565
+ * joined raw session and agent on `--`. Both are readable on upgrade so a
566
+ * mid-session deploy does not silently re-baseline an established binding —
567
+ * the ambiguity in a legacy `a--b` key is resolved toward the interpretation
568
+ * the lookup is making, and the next write lands on the current key while the
569
+ * legacy file is left to age out.
570
+ * @param {object} payload - The hook payload
571
+ * @returns {string[]} Zero or more legacy keys this payload may have written
572
+ */
573
+ function legacyKeys(payload) {
574
+ const agent = payload?.agent_id;
575
+ const session = payload.session_id;
576
+ if (typeof agent === "string" && agent) {
577
+ // v2 wrote the sanitised composite. v1 never wrote a per-agent file — an
578
+ // agent falling back to the shared session file is the #4277 bug again,
579
+ // so the list stops there.
580
+ const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
581
+ return [`${san(session)}--${san(agent)}`];
542
582
  }
583
+ // v1 and v2 both keyed the main agent on the raw session id — but only when
584
+ // that id contains no `--`. One that does spells exactly the composite
585
+ // shape agent bindings now write, so reading it could hand this session a
586
+ // stranger's binding. Losing the v1 binding for such an id re-baselines
587
+ // once; the alternative is reading a file whose owner cannot be proven.
588
+ return session.includes("--") ? [] : [session];
589
+ }
590
+
591
+ function readState(bindingKeyValue, payload) {
592
+ for (const key of [bindingKeyValue, ...legacyKeys(payload)]) {
593
+ try {
594
+ return JSON.parse(readFileSync(stateFile(key), "utf8"));
595
+ } catch {
596
+ // Not written under this key — try the next older one.
597
+ }
598
+ }
599
+ return null;
543
600
  }
544
601
 
545
602
  /**
@@ -552,17 +609,23 @@ function readState(sessionId) {
552
609
  * {@link noticeRuntimeDrift} and read by nobody else, and a clobbering write
553
610
  * would turn its "reported once" into "reported again after the next
554
611
  * acknowledgement", which is the noise the notice is designed not to be.
555
- * @param sessionId - The session whose state file this is
612
+ * @param bindingKeyValue - The key whose state file this is
556
613
  * @param state - Fields to write over the recorded state
614
+ * @param payload - The hook payload, for legacy-key reads while a binding is
615
+ * migrating to the current key format
557
616
  * @returns Whether the write succeeded
558
617
  */
559
- function writeState(sessionId, state) {
560
- const file = stateFile(sessionId);
618
+ function writeState(bindingKeyValue, state, payload) {
619
+ const file = stateFile(bindingKeyValue);
561
620
  try {
562
621
  mkdirSync(dirname(file), { recursive: true });
563
622
  writeFileSync(
564
623
  file,
565
- `${JSON.stringify({ ...readState(sessionId), ...state }, null, 2)}\n`
624
+ `${JSON.stringify(
625
+ { ...readState(bindingKeyValue, payload), ...state },
626
+ null,
627
+ 2
628
+ )}\n`
566
629
  );
567
630
  return true;
568
631
  } catch (error) {
@@ -637,19 +700,24 @@ function claimedRoot(toolInput, cwd) {
637
700
  *
638
701
  * Returns nothing on purpose, like its sibling below: SessionStart has no call
639
702
  * to refuse, so every path through it allows.
640
- * @param sessionId - The session this baseline belongs to
703
+ * @param bindingKeyValue - The key this baseline belongs to
641
704
  * @param observed - The worktree root the session started in
705
+ * @param payload - The hook payload, for legacy-key reads
642
706
  */
643
- function recordBaseline(sessionId, observed) {
707
+ function recordBaseline(bindingKeyValue, observed, payload) {
644
708
  // Never overwrite. A resumed session, a reconnect, or anything else that
645
709
  // fires the event twice must not be able to launder a displaced binding into
646
710
  // a fresh baseline — which is the whole failure this exists to close.
647
- if (readState(sessionId)?.boundRoot) return;
648
- writeState(sessionId, {
649
- boundRoot: observed,
650
- claimedRoot: null,
651
- updatedAt: new Date().toISOString(),
652
- });
711
+ if (readState(bindingKeyValue, payload)?.boundRoot) return;
712
+ writeState(
713
+ bindingKeyValue,
714
+ {
715
+ boundRoot: observed,
716
+ claimedRoot: null,
717
+ updatedAt: new Date().toISOString(),
718
+ },
719
+ payload
720
+ );
653
721
  }
654
722
 
655
723
  /**
@@ -663,12 +731,16 @@ function recordBaseline(sessionId, observed) {
663
731
  function recordClaim(payload, observed) {
664
732
  const claimed = claimedRoot(payload.tool_input, payload.cwd);
665
733
  if (!claimed || claimed === observed) return;
666
- const previous = readState(bindingKey(payload));
667
- writeState(bindingKey(payload), {
668
- boundRoot: previous?.boundRoot ?? observed,
669
- claimedRoot: claimed,
670
- updatedAt: new Date().toISOString(),
671
- });
734
+ const previous = readState(bindingKey(payload), payload);
735
+ writeState(
736
+ bindingKey(payload),
737
+ {
738
+ boundRoot: previous?.boundRoot ?? observed,
739
+ claimedRoot: claimed,
740
+ updatedAt: new Date().toISOString(),
741
+ },
742
+ payload
743
+ );
672
744
  }
673
745
 
674
746
  function refuse(lines) {
@@ -704,11 +776,15 @@ function handleAcceptance(payload, observed) {
704
776
  acceptanceLine(observed),
705
777
  ]);
706
778
  }
707
- writeState(bindingKey(payload), {
708
- boundRoot: observed,
709
- claimedRoot: null,
710
- updatedAt: new Date().toISOString(),
711
- });
779
+ writeState(
780
+ bindingKey(payload),
781
+ {
782
+ boundRoot: observed,
783
+ claimedRoot: null,
784
+ updatedAt: new Date().toISOString(),
785
+ },
786
+ payload
787
+ );
712
788
  say(`bound to ${observed}`);
713
789
  return 0;
714
790
  }
@@ -880,15 +956,16 @@ function driftNotice(version) {
880
956
  * Never refuses. The dependency is a reporting gap, not a live displacement,
881
957
  * and blocking a session over a version number would be a wall where an
882
958
  * observation belongs.
883
- * @param sessionId - The session being started
959
+ * @param bindingKeyValue - The key being started
884
960
  * @param env - Environment to read the runtime version from
961
+ * @param payload - The hook payload, for legacy-key reads
885
962
  * @returns Nothing; every path through this allows
886
963
  */
887
- function noticeRuntimeDrift(sessionId, env) {
964
+ function noticeRuntimeDrift(bindingKeyValue, env, payload) {
888
965
  const version = runtimeVersion(env);
889
966
  if (version === null || version === VERIFIED_RUNTIME_VERSION) return;
890
- if (readState(sessionId)?.runtimeNoticed === version) return;
891
- writeState(sessionId, { runtimeNoticed: version });
967
+ if (readState(bindingKeyValue, payload)?.runtimeNoticed === version) return;
968
+ writeState(bindingKeyValue, { runtimeNoticed: version }, payload);
892
969
  process.stdout.write(
893
970
  `${JSON.stringify({
894
971
  hookSpecificOutput: {
@@ -911,11 +988,11 @@ function evaluate(payload) {
911
988
  if (!observed) return 0;
912
989
 
913
990
  if (payload.hook_event_name === "SessionStart") {
914
- recordBaseline(binding, observed);
991
+ recordBaseline(binding, observed, payload);
915
992
  // After the baseline, never before: `recordBaseline` writes a fresh state
916
993
  // for a session it has not seen, and the notice's record has to go on top
917
994
  // of that write rather than under it.
918
- noticeRuntimeDrift(binding, process.env);
995
+ noticeRuntimeDrift(binding, process.env, payload);
919
996
  return 0;
920
997
  }
921
998
  if (payload.tool_name === "EnterWorktree") {
@@ -927,7 +1004,7 @@ function evaluate(payload) {
927
1004
  const accepted = handleAcceptance(payload, observed);
928
1005
  if (accepted !== null) return accepted;
929
1006
 
930
- const state = readState(binding);
1007
+ const state = readState(binding, payload);
931
1008
  if (!state?.boundRoot) {
932
1009
  // No baseline means SessionStart did not run for this session — an older
933
1010
  // install, a runtime that fires no such event, a harness that skips it.
@@ -936,22 +1013,30 @@ function evaluate(payload) {
936
1013
  // refusing. Treating it as a refusal would turn a floor into a wall on
937
1014
  // every surface where the event does not fire, and a wall gets switched
938
1015
  // off, which costs the whole guard.
939
- writeState(binding, {
940
- boundRoot: observed,
941
- claimedRoot: null,
942
- updatedAt: new Date().toISOString(),
943
- });
1016
+ writeState(
1017
+ binding,
1018
+ {
1019
+ boundRoot: observed,
1020
+ claimedRoot: null,
1021
+ updatedAt: new Date().toISOString(),
1022
+ },
1023
+ payload
1024
+ );
944
1025
  return 0;
945
1026
  }
946
1027
  if (state.claimedRoot && state.claimedRoot !== observed) {
947
1028
  return unconfirmedSwitch(state.claimedRoot, observed);
948
1029
  }
949
1030
  if (state.claimedRoot === observed) {
950
- writeState(binding, {
951
- boundRoot: observed,
952
- claimedRoot: null,
953
- updatedAt: new Date().toISOString(),
954
- });
1031
+ writeState(
1032
+ binding,
1033
+ {
1034
+ boundRoot: observed,
1035
+ claimedRoot: null,
1036
+ updatedAt: new Date().toISOString(),
1037
+ },
1038
+ payload
1039
+ );
955
1040
  return 0;
956
1041
  }
957
1042
  if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -511,6 +511,27 @@ function stateFile(bindingKey) {
511
511
  return join(home, "worktree-binding", `${bindingKey}.json`);
512
512
  }
513
513
 
514
+ /**
515
+ * Percent-escape every byte that is not filename-safe, so the output cannot
516
+ * contain `-` at all and two escaped parts can be joined on `--` without the
517
+ * joiner ever being smuggled in from either side.
518
+ *
519
+ * `:` and friends are folded too — the key lands in a filename and `:` is
520
+ * illegal on Windows/NTFS (CodySwannGT/lisa#4277). `encodeURIComponent`
521
+ * leaves `! ' ( ) *` alone, and `*` is equally illegal on Windows, so they
522
+ * are escaped by hand.
523
+ * @param {string} value - One component of the binding key
524
+ * @returns {string} A component containing only `[A-Za-z0-9._~]` and `%XX`
525
+ */
526
+ function keyPart(value) {
527
+ return encodeURIComponent(value)
528
+ .replaceAll(
529
+ /[!'()*]/g,
530
+ c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
531
+ )
532
+ .replaceAll("-", "%2D");
533
+ }
534
+
514
535
  /**
515
536
  * The identity a binding belongs to.
516
537
  *
@@ -524,22 +545,58 @@ function stateFile(bindingKey) {
524
545
  * session-scoped key it always had, which is also the fallback for runtimes
525
546
  * that never send the field.
526
547
  *
527
- * The key lands in a filename, so the composite is folded into a set that is
528
- * legal on every filesystem — `:` is not, which rules it out as a separator
529
- * and as tolerated session_id content on Windows hosts.
548
+ * Both shapes escape every component with {@link keyPart}, so the session key
549
+ * `x` and the agent key `a--b` live in disjoint namespaces: an escaped
550
+ * component never contains `-`, a main-session key is one component, an
551
+ * agent key is two joined by the only `--` the string can hold. Session
552
+ * "s%2Da--c" cannot collide with session "s-a" + agent "c", and neither can
553
+ * collide across the two shapes (CodySwannGT/lisa#4294).
530
554
  */
531
555
  function bindingKey(payload) {
532
556
  const agent = payload?.agent_id;
533
- if (typeof agent !== "string" || !agent) return payload.session_id;
534
- return `${payload.session_id}--${agent}`.replaceAll(/[^A-Za-z0-9._-]/g, "_");
557
+ if (typeof agent !== "string" || !agent) return keyPart(payload.session_id);
558
+ return `${keyPart(payload.session_id)}--${keyPart(agent)}`;
535
559
  }
536
560
 
537
- function readState(sessionId) {
538
- try {
539
- return JSON.parse(readFileSync(stateFile(sessionId), "utf8"));
540
- } catch {
541
- return null;
561
+ /**
562
+ * The keys earlier versions wrote for this payload, newest first.
563
+ *
564
+ * The v1 key was the raw `session_id` (single shared file per session). v2
565
+ * joined raw session and agent on `--`. Both are readable on upgrade so a
566
+ * mid-session deploy does not silently re-baseline an established binding —
567
+ * the ambiguity in a legacy `a--b` key is resolved toward the interpretation
568
+ * the lookup is making, and the next write lands on the current key while the
569
+ * legacy file is left to age out.
570
+ * @param {object} payload - The hook payload
571
+ * @returns {string[]} Zero or more legacy keys this payload may have written
572
+ */
573
+ function legacyKeys(payload) {
574
+ const agent = payload?.agent_id;
575
+ const session = payload.session_id;
576
+ if (typeof agent === "string" && agent) {
577
+ // v2 wrote the sanitised composite. v1 never wrote a per-agent file — an
578
+ // agent falling back to the shared session file is the #4277 bug again,
579
+ // so the list stops there.
580
+ const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
581
+ return [`${san(session)}--${san(agent)}`];
542
582
  }
583
+ // v1 and v2 both keyed the main agent on the raw session id — but only when
584
+ // that id contains no `--`. One that does spells exactly the composite
585
+ // shape agent bindings now write, so reading it could hand this session a
586
+ // stranger's binding. Losing the v1 binding for such an id re-baselines
587
+ // once; the alternative is reading a file whose owner cannot be proven.
588
+ return session.includes("--") ? [] : [session];
589
+ }
590
+
591
+ function readState(bindingKeyValue, payload) {
592
+ for (const key of [bindingKeyValue, ...legacyKeys(payload)]) {
593
+ try {
594
+ return JSON.parse(readFileSync(stateFile(key), "utf8"));
595
+ } catch {
596
+ // Not written under this key — try the next older one.
597
+ }
598
+ }
599
+ return null;
543
600
  }
544
601
 
545
602
  /**
@@ -552,17 +609,23 @@ function readState(sessionId) {
552
609
  * {@link noticeRuntimeDrift} and read by nobody else, and a clobbering write
553
610
  * would turn its "reported once" into "reported again after the next
554
611
  * acknowledgement", which is the noise the notice is designed not to be.
555
- * @param sessionId - The session whose state file this is
612
+ * @param bindingKeyValue - The key whose state file this is
556
613
  * @param state - Fields to write over the recorded state
614
+ * @param payload - The hook payload, for legacy-key reads while a binding is
615
+ * migrating to the current key format
557
616
  * @returns Whether the write succeeded
558
617
  */
559
- function writeState(sessionId, state) {
560
- const file = stateFile(sessionId);
618
+ function writeState(bindingKeyValue, state, payload) {
619
+ const file = stateFile(bindingKeyValue);
561
620
  try {
562
621
  mkdirSync(dirname(file), { recursive: true });
563
622
  writeFileSync(
564
623
  file,
565
- `${JSON.stringify({ ...readState(sessionId), ...state }, null, 2)}\n`
624
+ `${JSON.stringify(
625
+ { ...readState(bindingKeyValue, payload), ...state },
626
+ null,
627
+ 2
628
+ )}\n`
566
629
  );
567
630
  return true;
568
631
  } catch (error) {
@@ -637,19 +700,24 @@ function claimedRoot(toolInput, cwd) {
637
700
  *
638
701
  * Returns nothing on purpose, like its sibling below: SessionStart has no call
639
702
  * to refuse, so every path through it allows.
640
- * @param sessionId - The session this baseline belongs to
703
+ * @param bindingKeyValue - The key this baseline belongs to
641
704
  * @param observed - The worktree root the session started in
705
+ * @param payload - The hook payload, for legacy-key reads
642
706
  */
643
- function recordBaseline(sessionId, observed) {
707
+ function recordBaseline(bindingKeyValue, observed, payload) {
644
708
  // Never overwrite. A resumed session, a reconnect, or anything else that
645
709
  // fires the event twice must not be able to launder a displaced binding into
646
710
  // a fresh baseline — which is the whole failure this exists to close.
647
- if (readState(sessionId)?.boundRoot) return;
648
- writeState(sessionId, {
649
- boundRoot: observed,
650
- claimedRoot: null,
651
- updatedAt: new Date().toISOString(),
652
- });
711
+ if (readState(bindingKeyValue, payload)?.boundRoot) return;
712
+ writeState(
713
+ bindingKeyValue,
714
+ {
715
+ boundRoot: observed,
716
+ claimedRoot: null,
717
+ updatedAt: new Date().toISOString(),
718
+ },
719
+ payload
720
+ );
653
721
  }
654
722
 
655
723
  /**
@@ -663,12 +731,16 @@ function recordBaseline(sessionId, observed) {
663
731
  function recordClaim(payload, observed) {
664
732
  const claimed = claimedRoot(payload.tool_input, payload.cwd);
665
733
  if (!claimed || claimed === observed) return;
666
- const previous = readState(bindingKey(payload));
667
- writeState(bindingKey(payload), {
668
- boundRoot: previous?.boundRoot ?? observed,
669
- claimedRoot: claimed,
670
- updatedAt: new Date().toISOString(),
671
- });
734
+ const previous = readState(bindingKey(payload), payload);
735
+ writeState(
736
+ bindingKey(payload),
737
+ {
738
+ boundRoot: previous?.boundRoot ?? observed,
739
+ claimedRoot: claimed,
740
+ updatedAt: new Date().toISOString(),
741
+ },
742
+ payload
743
+ );
672
744
  }
673
745
 
674
746
  function refuse(lines) {
@@ -704,11 +776,15 @@ function handleAcceptance(payload, observed) {
704
776
  acceptanceLine(observed),
705
777
  ]);
706
778
  }
707
- writeState(bindingKey(payload), {
708
- boundRoot: observed,
709
- claimedRoot: null,
710
- updatedAt: new Date().toISOString(),
711
- });
779
+ writeState(
780
+ bindingKey(payload),
781
+ {
782
+ boundRoot: observed,
783
+ claimedRoot: null,
784
+ updatedAt: new Date().toISOString(),
785
+ },
786
+ payload
787
+ );
712
788
  say(`bound to ${observed}`);
713
789
  return 0;
714
790
  }
@@ -880,15 +956,16 @@ function driftNotice(version) {
880
956
  * Never refuses. The dependency is a reporting gap, not a live displacement,
881
957
  * and blocking a session over a version number would be a wall where an
882
958
  * observation belongs.
883
- * @param sessionId - The session being started
959
+ * @param bindingKeyValue - The key being started
884
960
  * @param env - Environment to read the runtime version from
961
+ * @param payload - The hook payload, for legacy-key reads
885
962
  * @returns Nothing; every path through this allows
886
963
  */
887
- function noticeRuntimeDrift(sessionId, env) {
964
+ function noticeRuntimeDrift(bindingKeyValue, env, payload) {
888
965
  const version = runtimeVersion(env);
889
966
  if (version === null || version === VERIFIED_RUNTIME_VERSION) return;
890
- if (readState(sessionId)?.runtimeNoticed === version) return;
891
- writeState(sessionId, { runtimeNoticed: version });
967
+ if (readState(bindingKeyValue, payload)?.runtimeNoticed === version) return;
968
+ writeState(bindingKeyValue, { runtimeNoticed: version }, payload);
892
969
  process.stdout.write(
893
970
  `${JSON.stringify({
894
971
  hookSpecificOutput: {
@@ -911,11 +988,11 @@ function evaluate(payload) {
911
988
  if (!observed) return 0;
912
989
 
913
990
  if (payload.hook_event_name === "SessionStart") {
914
- recordBaseline(binding, observed);
991
+ recordBaseline(binding, observed, payload);
915
992
  // After the baseline, never before: `recordBaseline` writes a fresh state
916
993
  // for a session it has not seen, and the notice's record has to go on top
917
994
  // of that write rather than under it.
918
- noticeRuntimeDrift(binding, process.env);
995
+ noticeRuntimeDrift(binding, process.env, payload);
919
996
  return 0;
920
997
  }
921
998
  if (payload.tool_name === "EnterWorktree") {
@@ -927,7 +1004,7 @@ function evaluate(payload) {
927
1004
  const accepted = handleAcceptance(payload, observed);
928
1005
  if (accepted !== null) return accepted;
929
1006
 
930
- const state = readState(binding);
1007
+ const state = readState(binding, payload);
931
1008
  if (!state?.boundRoot) {
932
1009
  // No baseline means SessionStart did not run for this session — an older
933
1010
  // install, a runtime that fires no such event, a harness that skips it.
@@ -936,22 +1013,30 @@ function evaluate(payload) {
936
1013
  // refusing. Treating it as a refusal would turn a floor into a wall on
937
1014
  // every surface where the event does not fire, and a wall gets switched
938
1015
  // off, which costs the whole guard.
939
- writeState(binding, {
940
- boundRoot: observed,
941
- claimedRoot: null,
942
- updatedAt: new Date().toISOString(),
943
- });
1016
+ writeState(
1017
+ binding,
1018
+ {
1019
+ boundRoot: observed,
1020
+ claimedRoot: null,
1021
+ updatedAt: new Date().toISOString(),
1022
+ },
1023
+ payload
1024
+ );
944
1025
  return 0;
945
1026
  }
946
1027
  if (state.claimedRoot && state.claimedRoot !== observed) {
947
1028
  return unconfirmedSwitch(state.claimedRoot, observed);
948
1029
  }
949
1030
  if (state.claimedRoot === observed) {
950
- writeState(binding, {
951
- boundRoot: observed,
952
- claimedRoot: null,
953
- updatedAt: new Date().toISOString(),
954
- });
1031
+ writeState(
1032
+ binding,
1033
+ {
1034
+ boundRoot: observed,
1035
+ claimedRoot: null,
1036
+ updatedAt: new Date().toISOString(),
1037
+ },
1038
+ payload
1039
+ );
955
1040
  return 0;
956
1041
  }
957
1042
  if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.66.2",
3
+ "version": "4.66.4",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"