@codyswann/lisa 4.72.5 → 4.72.7

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 (65) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +176 -53
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +4 -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 +18 -4
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/nestjs/package-lisa/package.lisa.json +1 -1
  10. package/package.json +6 -6
  11. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa/hooks/worktree-binding-guard.mjs +176 -53
  14. package/plugins/lisa-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +176 -53
  22. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +176 -53
  24. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo-agy/plugin.json +1 -1
  27. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  37. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  42. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  47. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails-agy/plugin.json +1 -1
  52. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  57. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  62. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/src/base/hooks/worktree-binding-guard.mjs +176 -53
  65. package/typescript/package-lisa/package.lisa.json +2 -2
@@ -64,7 +64,7 @@
64
64
  "@aws-sdk/client-apigatewaymanagementapi": "^3.967.0",
65
65
  "@aws-sdk/client-cognito-identity-provider": "^3.969.0",
66
66
  "@aws-sdk/rds-signer": "^3.968.0",
67
- "@graphql-tools/utils": "^11.0.0",
67
+ "@graphql-tools/utils": "^12.0.1",
68
68
  "@nestjs/apollo": "^13.2.3",
69
69
  "@nestjs/common": "^11.1.11",
70
70
  "@nestjs/config": "^4.0.2",
package/package.json CHANGED
@@ -153,7 +153,7 @@
153
153
  "eslint-plugin-react-hooks": "^7.0.0",
154
154
  "flatted": "^3.4.2",
155
155
  "form-data": ">=4.0.6",
156
- "handlebars": ">=4.7.9",
156
+ "handlebars": ">=4.7.10",
157
157
  "lodash": ">=4.18.1",
158
158
  "multer": ">=2.3.0",
159
159
  "nanoid": "^3.3.18",
@@ -177,7 +177,7 @@
177
177
  "eslint-plugin-react-hooks": "^7.0.0",
178
178
  "flatted": "^3.4.2",
179
179
  "form-data": ">=4.0.6",
180
- "handlebars": ">=4.7.9",
180
+ "handlebars": ">=4.7.10",
181
181
  "lodash": ">=4.18.1",
182
182
  "multer": ">=2.3.0",
183
183
  "nanoid": "^3.3.18",
@@ -189,7 +189,7 @@
189
189
  "qs": "^6.16.0"
190
190
  },
191
191
  "name": "@codyswann/lisa",
192
- "version": "4.72.5",
192
+ "version": "4.72.7",
193
193
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
194
194
  "main": "dist/index.js",
195
195
  "exports": {
@@ -345,7 +345,7 @@
345
345
  "test": "tests"
346
346
  },
347
347
  "types": "./dist/index.d.ts",
348
- "lisaReleaseCommit": "8169d9f5db34981c25cb0d2b6813571e24088fd2",
349
- "gitHead": "8169d9f5db34981c25cb0d2b6813571e24088fd2",
350
- "lisaReleaseTag": "v4.72.5"
348
+ "lisaReleaseCommit": "0ff901294afde67c20b1db2ef1056add09209e1e",
349
+ "gitHead": "0ff901294afde67c20b1db2ef1056add09209e1e",
350
+ "lisaReleaseTag": "v4.72.7"
351
351
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.72.5",
3
+ "version": "4.72.7",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.72.5",
3
+ "version": "4.72.7",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -42,14 +42,16 @@
42
42
  * or accept a linked target this session was explicitly offered. A selected
43
43
  * target does not prove a move: the next guarded call must actually be there.
44
44
  *
45
- * FAILING OPEN, LOUDLY. Anything this cannot determine — no session id, no
46
- * cwd, not a git repository, an unreadable state file — exits 0 with a line on
47
- * stderr. A guard that cannot read its input cannot tell a displacement from a
48
- * directory listing, and one that wedges every tool call is switched off within
49
- * the hour. Silence is the only outcome that is never acceptable.
45
+ * FAILING OPEN, LOUDLY. Missing input or unavailable I/O — no session id, no
46
+ * cwd, not a git repository, an unreadable state file — retains the measured
47
+ * fail-open behavior. A readable state file with missing, mismatched or damaged
48
+ * ownership is a different fact: it cannot authorize a baseline or be silently
49
+ * overwritten. Legacy ambiguity requires explicit intent; a conflicting reserved
50
+ * slot requires a new session identity.
50
51
  */
51
52
 
52
53
  import { execFileSync } from "node:child_process";
54
+ import { createHash } from "node:crypto";
53
55
  import {
54
56
  mkdirSync,
55
57
  readFileSync,
@@ -72,6 +74,10 @@ const GUARDED_TOOLS = new Set(["Bash", "Write", "Edit", "MultiEdit"]);
72
74
  */
73
75
  const ACCEPT_PREFIX = "lisa-worktree-binding: accept";
74
76
  const ACK_AND_SEPARATOR = Symbol("acknowledgement AND separator");
77
+ const BINDING_VERSION = 4;
78
+ const CURRENT_BINDING = Symbol("current binding state");
79
+ const UNAVAILABLE_BINDING = Symbol("unavailable binding read");
80
+ const BINDING_WRITE_CONFLICT = Symbol("binding write conflict");
75
81
 
76
82
  /**
77
83
  * Resolve a path the way git reports one.
@@ -586,62 +592,114 @@ function keyPart(value) {
586
592
  * its writes were refused, or an acknowledgement rebound the file out from
587
593
  * under the sibling (CodySwannGT/lisa#4277). The payload's `agent_id` is what
588
594
  * distinguishes a subagent from the main agent, so a caller carrying one gets
589
- * its own state file. The main agent carries no `agent_id` and keeps the
590
- * session-scoped key it always had, which is also the fallback for runtimes
591
- * that never send the field.
592
- *
593
- * Both shapes escape every component with {@link keyPart}, so the session key
594
- * `x` and the agent key `a--b` live in disjoint namespaces: an escaped
595
- * component never contains `-`, a main-session key is one component, an
596
- * agent key is two joined by the only `--` the string can hold. Session
597
- * "s%2Da--c" cannot collide with session "s-a" + agent "c", and neither can
598
- * collide across the two shapes (CodySwannGT/lisa#4294).
595
+ * its own state file. The main agent carries no `agent_id` and uses a null
596
+ * agent component, also the fallback for runtimes that never send the field.
597
+ *
598
+ * v4 hashes the JSON tuple into a lowercase, bounded filename in its own
599
+ * directory. The digest fixes filename size and casing; the persisted exact
600
+ * tuple remains the authority. A conflicting owner in a reserved slot is
601
+ * refused, never overwritten. Earlier flat filenames were ambiguous across
602
+ * formats (CodySwannGT/lisa#4311).
603
+ * @param {object} payload - Native hook identity
604
+ * @returns {[string, string|null]} Session and normalized optional agent
599
605
  */
600
- function bindingKey(payload) {
606
+ function bindingIdentity(payload) {
601
607
  const agent = payload?.agent_id;
602
- if (typeof agent !== "string" || !agent) return keyPart(payload.session_id);
603
- return `${keyPart(payload.session_id)}--${keyPart(agent)}`;
608
+ return [
609
+ payload.session_id,
610
+ typeof agent === "string" && agent ? agent : null,
611
+ ];
612
+ }
613
+
614
+ /**
615
+ * Resolve the reserved slot without exposing filename-unsafe identity bytes.
616
+ * @param {object} payload - Native hook identity
617
+ * @returns {string} Versioned, bounded relative state-file stem
618
+ */
619
+ function bindingKey(payload) {
620
+ const digest = createHash("sha256")
621
+ .update(JSON.stringify(bindingIdentity(payload)))
622
+ .digest("hex");
623
+ return join(`v${BINDING_VERSION}`, digest);
604
624
  }
605
625
 
606
626
  /**
607
627
  * The keys earlier versions wrote for this payload, newest first.
608
628
  *
609
629
  * The v1 key was the raw `session_id` (single shared file per session). v2
610
- * joined raw session and agent on `--`. Both are readable on upgrade so a
611
- * mid-session deploy does not silently re-baseline an established binding —
612
- * the ambiguity in a legacy `a--b` key is resolved toward the interpretation
613
- * the lookup is making, and the next write lands on the current key while the
614
- * legacy file is left to age out.
630
+ * joined sanitized session and agent on `--`; v3 escaped both components.
631
+ * All are inspected on upgrade, but only a matching v4 owner record grants
632
+ * authority. An unstamped file cannot distinguish its possible owners, so the
633
+ * guard requires explicit intent rather than guessing or silently re-baselining.
634
+ * Legacy files stay untouched and only safe filename stems are inspected.
615
635
  * @param {object} payload - The hook payload
616
636
  * @returns {string[]} Zero or more legacy keys this payload may have written
617
637
  */
618
638
  function legacyKeys(payload) {
619
639
  const agent = payload?.agent_id;
620
640
  const session = payload.session_id;
621
- if (typeof agent === "string" && agent) {
622
- // v2 wrote the sanitised composite. v1 never wrote a per-agent file — an
623
- // agent falling back to the shared session file is the #4277 bug again,
624
- // so the list stops there.
625
- const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
626
- return [`${san(session)}--${san(agent)}`];
641
+ const perAgent = typeof agent === "string" && agent;
642
+ const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
643
+ const older = perAgent ? `${san(session)}--${san(agent)}` : session;
644
+ let escaped = null;
645
+ try {
646
+ escaped = perAgent
647
+ ? `${keyPart(session)}--${keyPart(agent)}`
648
+ : keyPart(session);
649
+ } catch {
650
+ // A lone surrogate could not have produced an escaped v3 filename.
627
651
  }
628
- // v1 and v2 both keyed the main agent on the raw session id — but only when
629
- // that id contains no `--`. One that does spells exactly the composite
630
- // shape agent bindings now write, so reading it could hand this session a
631
- // stranger's binding. Losing the v1 binding for such an id re-baselines
632
- // once; the alternative is reading a file whose owner cannot be proven.
633
- return session.includes("--") ? [] : [session];
652
+ return [...new Set([escaped, older])].filter(
653
+ key => typeof key === "string" && key && !/[\\/]/u.test(key)
654
+ );
655
+ }
656
+
657
+ /**
658
+ * Require the producer's version, exact tuple and an absolute binding root.
659
+ * A matching identity with a damaged root cannot authorize re-baselining.
660
+ * @param {object|null} state - Parsed local state
661
+ * @param {object} payload - Caller claiming the slot
662
+ * @returns {boolean} Whether the record identifies this caller
663
+ */
664
+ function ownsState(state, payload) {
665
+ const expected = bindingIdentity(payload);
666
+ return (
667
+ typeof state?.boundRoot === "string" &&
668
+ isAbsolute(state.boundRoot) &&
669
+ !state.boundRoot.includes("\0") &&
670
+ state?.bindingVersion === BINDING_VERSION &&
671
+ Array.isArray(state.bindingIdentity) &&
672
+ state.bindingIdentity.length === expected.length &&
673
+ state.bindingIdentity.every((part, index) => part === expected[index])
674
+ );
634
675
  }
635
676
 
636
677
  function readState(bindingKeyValue, payload) {
637
- for (const key of [bindingKeyValue, ...legacyKeys(payload)]) {
678
+ try {
679
+ const state = JSON.parse(readFileSync(stateFile(bindingKeyValue), "utf8"));
680
+ return ownsState(state, payload)
681
+ ? { ...state, [CURRENT_BINDING]: true }
682
+ : { unverifiedOwner: true, canonicalConflict: true };
683
+ } catch (error) {
684
+ if (error instanceof SyntaxError)
685
+ return { unverifiedOwner: true, canonicalConflict: true };
686
+ // Unavailable I/O proves neither absence nor conflicting ownership. Keep
687
+ // it separate so an exclusive write cannot mistake EACCES for a race.
688
+ if (error.code !== "ENOENT")
689
+ return { [UNAVAILABLE_BINDING]: error.message };
690
+ }
691
+ let ambiguous = false;
692
+ for (const key of legacyKeys(payload)) {
638
693
  try {
639
- return JSON.parse(readFileSync(stateFile(key), "utf8"));
640
- } catch {
694
+ const state = JSON.parse(readFileSync(stateFile(key), "utf8"));
695
+ if (ownsState(state, payload)) return state;
696
+ ambiguous = true;
697
+ } catch (error) {
698
+ if (error instanceof SyntaxError) ambiguous = true;
641
699
  // Not written under this key — try the next older one.
642
700
  }
643
701
  }
644
- return null;
702
+ return ambiguous ? { unverifiedOwner: true } : null;
645
703
  }
646
704
 
647
705
  /**
@@ -657,23 +715,45 @@ function readState(bindingKeyValue, payload) {
657
715
  * @param state - Fields to write over the recorded state
658
716
  * @param payload - The hook payload, for legacy-key reads while a binding is
659
717
  * migrating to the current key format
660
- * @returns Whether the write succeeded
718
+ * @param options - Validated acknowledgement or create-only baseline intent
719
+ * @returns True on success, false for unavailable I/O, or a conflict sentinel
661
720
  */
662
- function writeState(bindingKeyValue, state, payload) {
721
+ function writeState(bindingKeyValue, state, payload, options = {}) {
663
722
  const file = stateFile(bindingKeyValue);
664
723
  const previous = readState(bindingKeyValue, payload);
724
+ if (previous?.[UNAVAILABLE_BINDING]) {
725
+ say(
726
+ `could not read the binding (${previous[UNAVAILABLE_BINDING]}); NOT enforcing`
727
+ );
728
+ return false;
729
+ }
730
+ if (
731
+ (options.createOnly && previous) ||
732
+ previous?.canonicalConflict ||
733
+ (previous?.unverifiedOwner && !options.acceptUnowned)
734
+ )
735
+ return BINDING_WRITE_CONFLICT;
736
+ const proven = previous?.unverifiedOwner ? null : previous;
737
+ // A runtime notice alone cannot establish a binding after a failed baseline.
738
+ // Every record we produce must satisfy the same authority check as a read.
739
+ const next = {
740
+ ...proven,
741
+ ...state,
742
+ bindingVersion: BINDING_VERSION,
743
+ bindingIdentity: bindingIdentity(payload),
744
+ };
745
+ if (!ownsState(next, payload)) return false;
665
746
  const originalRoot =
666
- previous?.originalRoot ?? previous?.boundRoot ?? state.boundRoot;
747
+ proven?.originalRoot ?? proven?.boundRoot ?? state.boundRoot;
667
748
  const originalCommonDir =
668
- previous?.originalCommonDir ?? repositoryIdentity(originalRoot);
749
+ proven?.originalCommonDir ?? repositoryIdentity(originalRoot);
669
750
  try {
670
751
  mkdirSync(dirname(file), { recursive: true });
671
752
  writeFileSync(
672
753
  file,
673
754
  `${JSON.stringify(
674
755
  {
675
- ...previous,
676
- ...state,
756
+ ...next,
677
757
  // Preserve the initial root after intentional rebinds. A legacy
678
758
  // state's established root is its only proven recovery anchor.
679
759
  originalRoot,
@@ -681,10 +761,12 @@ function writeState(bindingKeyValue, state, payload) {
681
761
  },
682
762
  null,
683
763
  2
684
- )}\n`
764
+ )}\n`,
765
+ { flag: previous?.[CURRENT_BINDING] ? "w" : "wx" }
685
766
  );
686
767
  return true;
687
768
  } catch (error) {
769
+ if (error.code === "EEXIST") return BINDING_WRITE_CONFLICT;
688
770
  say(`could not record the binding (${error.message}); NOT enforcing`);
689
771
  return false;
690
772
  }
@@ -764,7 +846,8 @@ function recordBaseline(bindingKeyValue, observed, payload) {
764
846
  // Never overwrite. A resumed session, a reconnect, or anything else that
765
847
  // fires the event twice must not be able to launder a displaced binding into
766
848
  // a fresh baseline — which is the whole failure this exists to close.
767
- if (readState(bindingKeyValue, payload)?.boundRoot) return;
849
+ const previous = readState(bindingKeyValue, payload);
850
+ if (previous?.boundRoot || previous?.unverifiedOwner) return;
768
851
  writeState(
769
852
  bindingKeyValue,
770
853
  {
@@ -772,7 +855,8 @@ function recordBaseline(bindingKeyValue, observed, payload) {
772
855
  claimedRoot: null,
773
856
  updatedAt: new Date().toISOString(),
774
857
  },
775
- payload
858
+ payload,
859
+ { createOnly: true }
776
860
  );
777
861
  }
778
862
 
@@ -788,6 +872,7 @@ function recordClaim(payload, observed) {
788
872
  const claimed = claimedRoot(payload.tool_input, payload.cwd);
789
873
  if (!claimed || claimed === observed) return;
790
874
  const previous = readState(bindingKey(payload), payload);
875
+ if (previous?.unverifiedOwner) return;
791
876
  writeState(
792
877
  bindingKey(payload),
793
878
  {
@@ -795,7 +880,8 @@ function recordClaim(payload, observed) {
795
880
  claimedRoot: claimed,
796
881
  updatedAt: new Date().toISOString(),
797
882
  },
798
- payload
883
+ payload,
884
+ { createOnly: !previous }
799
885
  );
800
886
  }
801
887
 
@@ -804,11 +890,41 @@ function refuse(lines) {
804
890
  return 2;
805
891
  }
806
892
 
893
+ /** Refuse an action whose binding changed while its creation was in flight. */
894
+ function bindingWriteConflict() {
895
+ return refuse([
896
+ "worktree-binding-guard: another hook recorded this session's binding during this call.",
897
+ "The existing file has been preserved. Retry the call to check its established binding.",
898
+ ]);
899
+ }
900
+
807
901
  function acceptanceLine(observed) {
808
902
  const literal = `${ACCEPT_PREFIX} ${observed}`.replaceAll("'", "'\\''");
809
903
  return ` echo '${literal}'`;
810
904
  }
811
905
 
906
+ /**
907
+ * Explain a real ownership ambiguity without treating a stranger's root as fact.
908
+ * @param {object} state - Unverified-state sentinel, containing no imported fields
909
+ * @param {string|null} observed - Measured checkout
910
+ * @returns {number} Blocking native-hook status
911
+ */
912
+ function unownedBinding(state, observed) {
913
+ return refuse([
914
+ "worktree-binding-guard: cannot prove this binding belongs to this session.",
915
+ "The existing file has no matching owner record. It has not been imported or changed.",
916
+ ...(state.canonicalConflict
917
+ ? [
918
+ "This session's reserved state file identifies another owner or an unknown format.",
919
+ "Start a new session with its own identity; acknowledging cannot overwrite that file.",
920
+ ]
921
+ : [
922
+ "Choose the worktree you intend to use by acknowledging its measured path:",
923
+ acceptanceLine(observed),
924
+ ]),
925
+ ]);
926
+ }
927
+
812
928
  /** Read literal shell words and AND separators without evaluating expansions. */
813
929
  function acknowledgementWords(text) {
814
930
  const words = [];
@@ -926,6 +1042,7 @@ function handleAcceptance(payload, observed) {
926
1042
  if (!request) return null;
927
1043
  const { path, validMove } = request;
928
1044
  const previous = readState(bindingKey(payload), payload);
1045
+ if (previous?.canonicalConflict) return unownedBinding(previous, observed);
929
1046
  const original = previous?.originalRoot ?? previous?.boundRoot;
930
1047
  const recovering = isOriginalRoot(previous, path);
931
1048
  const offered =
@@ -952,8 +1069,10 @@ function handleAcceptance(payload, observed) {
952
1069
  offeredRoot: null,
953
1070
  updatedAt: new Date().toISOString(),
954
1071
  },
955
- payload
1072
+ payload,
1073
+ { acceptUnowned: true }
956
1074
  );
1075
+ if (written === BINDING_WRITE_CONFLICT) return bindingWriteConflict();
957
1076
  if (written)
958
1077
  say(
959
1078
  path === observed
@@ -1188,6 +1307,7 @@ function evaluate(payload) {
1188
1307
  if (!GUARDED_TOOLS.has(payload.tool_name)) return 0;
1189
1308
 
1190
1309
  const state = readState(binding, payload);
1310
+ if (state?.unverifiedOwner) return unownedBinding(state, observed);
1191
1311
  if (!state?.boundRoot) {
1192
1312
  // No baseline means SessionStart did not run for this session — an older
1193
1313
  // install, a runtime that fires no such event, a harness that skips it.
@@ -1196,15 +1316,17 @@ function evaluate(payload) {
1196
1316
  // refusing. Treating it as a refusal would turn a floor into a wall on
1197
1317
  // every surface where the event does not fire, and a wall gets switched
1198
1318
  // off, which costs the whole guard.
1199
- writeState(
1319
+ const written = writeState(
1200
1320
  binding,
1201
1321
  {
1202
1322
  boundRoot: observed,
1203
1323
  claimedRoot: null,
1204
1324
  updatedAt: new Date().toISOString(),
1205
1325
  },
1206
- payload
1326
+ payload,
1327
+ { createOnly: true }
1207
1328
  );
1329
+ if (written === BINDING_WRITE_CONFLICT) return bindingWriteConflict();
1208
1330
  return 0;
1209
1331
  }
1210
1332
  if (
@@ -1216,7 +1338,7 @@ function evaluate(payload) {
1216
1338
  return unconfirmedSwitch(state.claimedRoot, observed);
1217
1339
  }
1218
1340
  if (state.claimedRoot === observed) {
1219
- writeState(
1341
+ const written = writeState(
1220
1342
  binding,
1221
1343
  {
1222
1344
  boundRoot: observed,
@@ -1226,6 +1348,7 @@ function evaluate(payload) {
1226
1348
  },
1227
1349
  payload
1228
1350
  );
1351
+ if (written === BINDING_WRITE_CONFLICT) return bindingWriteConflict();
1229
1352
  return 0;
1230
1353
  }
1231
1354
  if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.72.5",
3
+ "version": "4.72.7",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.72.5",
3
+ "version": "4.72.7",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.72.5",
3
+ "version": "4.72.7",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.72.5",
3
+ "version": "4.72.7",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.72.5",
3
+ "version": "4.72.7",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.72.5",
3
+ "version": "4.72.7",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.72.5",
3
+ "version": "4.72.7",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"