@codyswann/lisa 4.72.5 → 4.72.6

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 +7 -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.72.5",
3
+ "version": "4.72.6",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.72.5",
3
+ "version": "4.72.6",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting 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.5",
3
+ "version": "4.72.6",
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.5",
3
+ "version": "4.72.6",
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.5",
3
+ "version": "4.72.6",
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.5",
3
+ "version": "4.72.6",
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.5",
3
+ "version": "4.72.6",
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.5",
3
+ "version": "4.72.6",
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.5",
3
+ "version": "4.72.6",
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.5",
3
+ "version": "4.72.6",
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.5",
3
+ "version": "4.72.6",
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.5",
3
+ "version": "4.72.6",
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"
@@ -39,14 +39,16 @@
39
39
  * or accept a linked target this session was explicitly offered. A selected
40
40
  * target does not prove a move: the next guarded call must actually be there.
41
41
  *
42
- * FAILING OPEN, LOUDLY. Anything this cannot determine — no session id, no
43
- * cwd, not a git repository, an unreadable state file — exits 0 with a line on
44
- * stderr. A guard that cannot read its input cannot tell a displacement from a
45
- * directory listing, and one that wedges every tool call is switched off within
46
- * the hour. Silence is the only outcome that is never acceptable.
42
+ * FAILING OPEN, LOUDLY. Missing input or unavailable I/O — no session id, no
43
+ * cwd, not a git repository, an unreadable state file — retains the measured
44
+ * fail-open behavior. A readable state file with missing, mismatched or damaged
45
+ * ownership is a different fact: it cannot authorize a baseline or be silently
46
+ * overwritten. Legacy ambiguity requires explicit intent; a conflicting reserved
47
+ * slot requires a new session identity.
47
48
  */
48
49
 
49
50
  import { execFileSync } from "node:child_process";
51
+ import { createHash } from "node:crypto";
50
52
  import {
51
53
  mkdirSync,
52
54
  readFileSync,
@@ -69,6 +71,10 @@ const GUARDED_TOOLS = new Set(["Bash", "Write", "Edit", "MultiEdit"]);
69
71
  */
70
72
  const ACCEPT_PREFIX = "lisa-worktree-binding: accept";
71
73
  const ACK_AND_SEPARATOR = Symbol("acknowledgement AND separator");
74
+ const BINDING_VERSION = 4;
75
+ const CURRENT_BINDING = Symbol("current binding state");
76
+ const UNAVAILABLE_BINDING = Symbol("unavailable binding read");
77
+ const BINDING_WRITE_CONFLICT = Symbol("binding write conflict");
72
78
 
73
79
  /**
74
80
  * Resolve a path the way git reports one.
@@ -583,62 +589,114 @@ function keyPart(value) {
583
589
  * its writes were refused, or an acknowledgement rebound the file out from
584
590
  * under the sibling (CodySwannGT/lisa#4277). The payload's `agent_id` is what
585
591
  * distinguishes a subagent from the main agent, so a caller carrying one gets
586
- * its own state file. The main agent carries no `agent_id` and keeps the
587
- * session-scoped key it always had, which is also the fallback for runtimes
588
- * that never send the field.
589
- *
590
- * Both shapes escape every component with {@link keyPart}, so the session key
591
- * `x` and the agent key `a--b` live in disjoint namespaces: an escaped
592
- * component never contains `-`, a main-session key is one component, an
593
- * agent key is two joined by the only `--` the string can hold. Session
594
- * "s%2Da--c" cannot collide with session "s-a" + agent "c", and neither can
595
- * collide across the two shapes (CodySwannGT/lisa#4294).
592
+ * its own state file. The main agent carries no `agent_id` and uses a null
593
+ * agent component, also the fallback for runtimes that never send the field.
594
+ *
595
+ * v4 hashes the JSON tuple into a lowercase, bounded filename in its own
596
+ * directory. The digest fixes filename size and casing; the persisted exact
597
+ * tuple remains the authority. A conflicting owner in a reserved slot is
598
+ * refused, never overwritten. Earlier flat filenames were ambiguous across
599
+ * formats (CodySwannGT/lisa#4311).
600
+ * @param {object} payload - Native hook identity
601
+ * @returns {[string, string|null]} Session and normalized optional agent
596
602
  */
597
- function bindingKey(payload) {
603
+ function bindingIdentity(payload) {
598
604
  const agent = payload?.agent_id;
599
- if (typeof agent !== "string" || !agent) return keyPart(payload.session_id);
600
- return `${keyPart(payload.session_id)}--${keyPart(agent)}`;
605
+ return [
606
+ payload.session_id,
607
+ typeof agent === "string" && agent ? agent : null,
608
+ ];
609
+ }
610
+
611
+ /**
612
+ * Resolve the reserved slot without exposing filename-unsafe identity bytes.
613
+ * @param {object} payload - Native hook identity
614
+ * @returns {string} Versioned, bounded relative state-file stem
615
+ */
616
+ function bindingKey(payload) {
617
+ const digest = createHash("sha256")
618
+ .update(JSON.stringify(bindingIdentity(payload)))
619
+ .digest("hex");
620
+ return join(`v${BINDING_VERSION}`, digest);
601
621
  }
602
622
 
603
623
  /**
604
624
  * The keys earlier versions wrote for this payload, newest first.
605
625
  *
606
626
  * The v1 key was the raw `session_id` (single shared file per session). v2
607
- * joined raw session and agent on `--`. Both are readable on upgrade so a
608
- * mid-session deploy does not silently re-baseline an established binding —
609
- * the ambiguity in a legacy `a--b` key is resolved toward the interpretation
610
- * the lookup is making, and the next write lands on the current key while the
611
- * legacy file is left to age out.
627
+ * joined sanitized session and agent on `--`; v3 escaped both components.
628
+ * All are inspected on upgrade, but only a matching v4 owner record grants
629
+ * authority. An unstamped file cannot distinguish its possible owners, so the
630
+ * guard requires explicit intent rather than guessing or silently re-baselining.
631
+ * Legacy files stay untouched and only safe filename stems are inspected.
612
632
  * @param {object} payload - The hook payload
613
633
  * @returns {string[]} Zero or more legacy keys this payload may have written
614
634
  */
615
635
  function legacyKeys(payload) {
616
636
  const agent = payload?.agent_id;
617
637
  const session = payload.session_id;
618
- if (typeof agent === "string" && agent) {
619
- // v2 wrote the sanitised composite. v1 never wrote a per-agent file — an
620
- // agent falling back to the shared session file is the #4277 bug again,
621
- // so the list stops there.
622
- const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
623
- return [`${san(session)}--${san(agent)}`];
638
+ const perAgent = typeof agent === "string" && agent;
639
+ const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
640
+ const older = perAgent ? `${san(session)}--${san(agent)}` : session;
641
+ let escaped = null;
642
+ try {
643
+ escaped = perAgent
644
+ ? `${keyPart(session)}--${keyPart(agent)}`
645
+ : keyPart(session);
646
+ } catch {
647
+ // A lone surrogate could not have produced an escaped v3 filename.
624
648
  }
625
- // v1 and v2 both keyed the main agent on the raw session id — but only when
626
- // that id contains no `--`. One that does spells exactly the composite
627
- // shape agent bindings now write, so reading it could hand this session a
628
- // stranger's binding. Losing the v1 binding for such an id re-baselines
629
- // once; the alternative is reading a file whose owner cannot be proven.
630
- return session.includes("--") ? [] : [session];
649
+ return [...new Set([escaped, older])].filter(
650
+ key => typeof key === "string" && key && !/[\\/]/u.test(key)
651
+ );
652
+ }
653
+
654
+ /**
655
+ * Require the producer's version, exact tuple and an absolute binding root.
656
+ * A matching identity with a damaged root cannot authorize re-baselining.
657
+ * @param {object|null} state - Parsed local state
658
+ * @param {object} payload - Caller claiming the slot
659
+ * @returns {boolean} Whether the record identifies this caller
660
+ */
661
+ function ownsState(state, payload) {
662
+ const expected = bindingIdentity(payload);
663
+ return (
664
+ typeof state?.boundRoot === "string" &&
665
+ isAbsolute(state.boundRoot) &&
666
+ !state.boundRoot.includes("\0") &&
667
+ state?.bindingVersion === BINDING_VERSION &&
668
+ Array.isArray(state.bindingIdentity) &&
669
+ state.bindingIdentity.length === expected.length &&
670
+ state.bindingIdentity.every((part, index) => part === expected[index])
671
+ );
631
672
  }
632
673
 
633
674
  function readState(bindingKeyValue, payload) {
634
- for (const key of [bindingKeyValue, ...legacyKeys(payload)]) {
675
+ try {
676
+ const state = JSON.parse(readFileSync(stateFile(bindingKeyValue), "utf8"));
677
+ return ownsState(state, payload)
678
+ ? { ...state, [CURRENT_BINDING]: true }
679
+ : { unverifiedOwner: true, canonicalConflict: true };
680
+ } catch (error) {
681
+ if (error instanceof SyntaxError)
682
+ return { unverifiedOwner: true, canonicalConflict: true };
683
+ // Unavailable I/O proves neither absence nor conflicting ownership. Keep
684
+ // it separate so an exclusive write cannot mistake EACCES for a race.
685
+ if (error.code !== "ENOENT")
686
+ return { [UNAVAILABLE_BINDING]: error.message };
687
+ }
688
+ let ambiguous = false;
689
+ for (const key of legacyKeys(payload)) {
635
690
  try {
636
- return JSON.parse(readFileSync(stateFile(key), "utf8"));
637
- } catch {
691
+ const state = JSON.parse(readFileSync(stateFile(key), "utf8"));
692
+ if (ownsState(state, payload)) return state;
693
+ ambiguous = true;
694
+ } catch (error) {
695
+ if (error instanceof SyntaxError) ambiguous = true;
638
696
  // Not written under this key — try the next older one.
639
697
  }
640
698
  }
641
- return null;
699
+ return ambiguous ? { unverifiedOwner: true } : null;
642
700
  }
643
701
 
644
702
  /**
@@ -654,23 +712,45 @@ function readState(bindingKeyValue, payload) {
654
712
  * @param state - Fields to write over the recorded state
655
713
  * @param payload - The hook payload, for legacy-key reads while a binding is
656
714
  * migrating to the current key format
657
- * @returns Whether the write succeeded
715
+ * @param options - Validated acknowledgement or create-only baseline intent
716
+ * @returns True on success, false for unavailable I/O, or a conflict sentinel
658
717
  */
659
- function writeState(bindingKeyValue, state, payload) {
718
+ function writeState(bindingKeyValue, state, payload, options = {}) {
660
719
  const file = stateFile(bindingKeyValue);
661
720
  const previous = readState(bindingKeyValue, payload);
721
+ if (previous?.[UNAVAILABLE_BINDING]) {
722
+ say(
723
+ `could not read the binding (${previous[UNAVAILABLE_BINDING]}); NOT enforcing`
724
+ );
725
+ return false;
726
+ }
727
+ if (
728
+ (options.createOnly && previous) ||
729
+ previous?.canonicalConflict ||
730
+ (previous?.unverifiedOwner && !options.acceptUnowned)
731
+ )
732
+ return BINDING_WRITE_CONFLICT;
733
+ const proven = previous?.unverifiedOwner ? null : previous;
734
+ // A runtime notice alone cannot establish a binding after a failed baseline.
735
+ // Every record we produce must satisfy the same authority check as a read.
736
+ const next = {
737
+ ...proven,
738
+ ...state,
739
+ bindingVersion: BINDING_VERSION,
740
+ bindingIdentity: bindingIdentity(payload),
741
+ };
742
+ if (!ownsState(next, payload)) return false;
662
743
  const originalRoot =
663
- previous?.originalRoot ?? previous?.boundRoot ?? state.boundRoot;
744
+ proven?.originalRoot ?? proven?.boundRoot ?? state.boundRoot;
664
745
  const originalCommonDir =
665
- previous?.originalCommonDir ?? repositoryIdentity(originalRoot);
746
+ proven?.originalCommonDir ?? repositoryIdentity(originalRoot);
666
747
  try {
667
748
  mkdirSync(dirname(file), { recursive: true });
668
749
  writeFileSync(
669
750
  file,
670
751
  `${JSON.stringify(
671
752
  {
672
- ...previous,
673
- ...state,
753
+ ...next,
674
754
  // Preserve the initial root after intentional rebinds. A legacy
675
755
  // state's established root is its only proven recovery anchor.
676
756
  originalRoot,
@@ -678,10 +758,12 @@ function writeState(bindingKeyValue, state, payload) {
678
758
  },
679
759
  null,
680
760
  2
681
- )}\n`
761
+ )}\n`,
762
+ { flag: previous?.[CURRENT_BINDING] ? "w" : "wx" }
682
763
  );
683
764
  return true;
684
765
  } catch (error) {
766
+ if (error.code === "EEXIST") return BINDING_WRITE_CONFLICT;
685
767
  say(`could not record the binding (${error.message}); NOT enforcing`);
686
768
  return false;
687
769
  }
@@ -761,7 +843,8 @@ function recordBaseline(bindingKeyValue, observed, payload) {
761
843
  // Never overwrite. A resumed session, a reconnect, or anything else that
762
844
  // fires the event twice must not be able to launder a displaced binding into
763
845
  // a fresh baseline — which is the whole failure this exists to close.
764
- if (readState(bindingKeyValue, payload)?.boundRoot) return;
846
+ const previous = readState(bindingKeyValue, payload);
847
+ if (previous?.boundRoot || previous?.unverifiedOwner) return;
765
848
  writeState(
766
849
  bindingKeyValue,
767
850
  {
@@ -769,7 +852,8 @@ function recordBaseline(bindingKeyValue, observed, payload) {
769
852
  claimedRoot: null,
770
853
  updatedAt: new Date().toISOString(),
771
854
  },
772
- payload
855
+ payload,
856
+ { createOnly: true }
773
857
  );
774
858
  }
775
859
 
@@ -785,6 +869,7 @@ function recordClaim(payload, observed) {
785
869
  const claimed = claimedRoot(payload.tool_input, payload.cwd);
786
870
  if (!claimed || claimed === observed) return;
787
871
  const previous = readState(bindingKey(payload), payload);
872
+ if (previous?.unverifiedOwner) return;
788
873
  writeState(
789
874
  bindingKey(payload),
790
875
  {
@@ -792,7 +877,8 @@ function recordClaim(payload, observed) {
792
877
  claimedRoot: claimed,
793
878
  updatedAt: new Date().toISOString(),
794
879
  },
795
- payload
880
+ payload,
881
+ { createOnly: !previous }
796
882
  );
797
883
  }
798
884
 
@@ -801,11 +887,41 @@ function refuse(lines) {
801
887
  return 2;
802
888
  }
803
889
 
890
+ /** Refuse an action whose binding changed while its creation was in flight. */
891
+ function bindingWriteConflict() {
892
+ return refuse([
893
+ "worktree-binding-guard: another hook recorded this session's binding during this call.",
894
+ "The existing file has been preserved. Retry the call to check its established binding.",
895
+ ]);
896
+ }
897
+
804
898
  function acceptanceLine(observed) {
805
899
  const literal = `${ACCEPT_PREFIX} ${observed}`.replaceAll("'", "'\\''");
806
900
  return ` echo '${literal}'`;
807
901
  }
808
902
 
903
+ /**
904
+ * Explain a real ownership ambiguity without treating a stranger's root as fact.
905
+ * @param {object} state - Unverified-state sentinel, containing no imported fields
906
+ * @param {string|null} observed - Measured checkout
907
+ * @returns {number} Blocking native-hook status
908
+ */
909
+ function unownedBinding(state, observed) {
910
+ return refuse([
911
+ "worktree-binding-guard: cannot prove this binding belongs to this session.",
912
+ "The existing file has no matching owner record. It has not been imported or changed.",
913
+ ...(state.canonicalConflict
914
+ ? [
915
+ "This session's reserved state file identifies another owner or an unknown format.",
916
+ "Start a new session with its own identity; acknowledging cannot overwrite that file.",
917
+ ]
918
+ : [
919
+ "Choose the worktree you intend to use by acknowledging its measured path:",
920
+ acceptanceLine(observed),
921
+ ]),
922
+ ]);
923
+ }
924
+
809
925
  /** Read literal shell words and AND separators without evaluating expansions. */
810
926
  function acknowledgementWords(text) {
811
927
  const words = [];
@@ -923,6 +1039,7 @@ function handleAcceptance(payload, observed) {
923
1039
  if (!request) return null;
924
1040
  const { path, validMove } = request;
925
1041
  const previous = readState(bindingKey(payload), payload);
1042
+ if (previous?.canonicalConflict) return unownedBinding(previous, observed);
926
1043
  const original = previous?.originalRoot ?? previous?.boundRoot;
927
1044
  const recovering = isOriginalRoot(previous, path);
928
1045
  const offered =
@@ -949,8 +1066,10 @@ function handleAcceptance(payload, observed) {
949
1066
  offeredRoot: null,
950
1067
  updatedAt: new Date().toISOString(),
951
1068
  },
952
- payload
1069
+ payload,
1070
+ { acceptUnowned: true }
953
1071
  );
1072
+ if (written === BINDING_WRITE_CONFLICT) return bindingWriteConflict();
954
1073
  if (written)
955
1074
  say(
956
1075
  path === observed
@@ -1185,6 +1304,7 @@ function evaluate(payload) {
1185
1304
  if (!GUARDED_TOOLS.has(payload.tool_name)) return 0;
1186
1305
 
1187
1306
  const state = readState(binding, payload);
1307
+ if (state?.unverifiedOwner) return unownedBinding(state, observed);
1188
1308
  if (!state?.boundRoot) {
1189
1309
  // No baseline means SessionStart did not run for this session — an older
1190
1310
  // install, a runtime that fires no such event, a harness that skips it.
@@ -1193,15 +1313,17 @@ function evaluate(payload) {
1193
1313
  // refusing. Treating it as a refusal would turn a floor into a wall on
1194
1314
  // every surface where the event does not fire, and a wall gets switched
1195
1315
  // off, which costs the whole guard.
1196
- writeState(
1316
+ const written = writeState(
1197
1317
  binding,
1198
1318
  {
1199
1319
  boundRoot: observed,
1200
1320
  claimedRoot: null,
1201
1321
  updatedAt: new Date().toISOString(),
1202
1322
  },
1203
- payload
1323
+ payload,
1324
+ { createOnly: true }
1204
1325
  );
1326
+ if (written === BINDING_WRITE_CONFLICT) return bindingWriteConflict();
1205
1327
  return 0;
1206
1328
  }
1207
1329
  if (
@@ -1213,7 +1335,7 @@ function evaluate(payload) {
1213
1335
  return unconfirmedSwitch(state.claimedRoot, observed);
1214
1336
  }
1215
1337
  if (state.claimedRoot === observed) {
1216
- writeState(
1338
+ const written = writeState(
1217
1339
  binding,
1218
1340
  {
1219
1341
  boundRoot: observed,
@@ -1223,6 +1345,7 @@ function evaluate(payload) {
1223
1345
  },
1224
1346
  payload
1225
1347
  );
1348
+ if (written === BINDING_WRITE_CONFLICT) return bindingWriteConflict();
1226
1349
  return 0;
1227
1350
  }
1228
1351
  if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
@@ -42,7 +42,7 @@
42
42
  "@isaacs/brace-expansion": "^5.0.1",
43
43
  "axios": ">=1.20.0",
44
44
  "esbuild": ">=0.28.1",
45
- "handlebars": ">=4.7.9",
45
+ "handlebars": ">=4.7.10",
46
46
  "lodash": ">=4.18.1",
47
47
  "multer": ">=2.3.0",
48
48
  "undici": "^6.28.1",
@@ -57,7 +57,7 @@
57
57
  "@isaacs/brace-expansion": "^5.0.1",
58
58
  "axios": ">=1.20.0",
59
59
  "esbuild": ">=0.28.1",
60
- "handlebars": ">=4.7.9",
60
+ "handlebars": ">=4.7.10",
61
61
  "lodash": ">=4.18.1",
62
62
  "multer": ">=2.3.0",
63
63
  "undici": "^6.28.1",