@codyswann/lisa 4.41.1 → 4.42.1

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 (78) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +3 -3
  2. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +327 -0
  3. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.sh +57 -0
  4. package/all/copy-overwrite/scripts/lisa-work-item.mjs +49 -3
  5. package/all/merge/.claude/settings.json +11 -0
  6. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.js +22 -0
  8. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  9. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  10. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  11. package/dist/core/upstream-evidence-manifest.js +21 -6
  12. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  13. package/package.json +4 -4
  14. package/plugins/lisa/.claude-plugin/plugin.json +18 -1
  15. package/plugins/lisa/.codex-plugin/hooks.json +17 -0
  16. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa/hooks/worktree-binding-guard.mjs +324 -0
  18. package/plugins/lisa/hooks/worktree-binding-guard.sh +54 -0
  19. package/plugins/lisa-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/.claude-plugin/plugin.json +18 -1
  26. package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +324 -0
  27. package/plugins/lisa-copilot/hooks/worktree-binding-guard.sh +54 -0
  28. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cursor/hooks/hooks.json +12 -0
  30. package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +324 -0
  31. package/plugins/lisa-cursor/hooks/worktree-binding-guard.sh +54 -0
  32. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-expo-agy/plugin.json +1 -1
  35. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  45. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  50. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  55. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails-agy/plugin.json +1 -1
  60. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  65. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  70. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/materialized-artifacts.json +2 -0
  73. package/plugins/src/base/.claude-plugin/plugin.json +17 -0
  74. package/plugins/src/base/hooks/worktree-binding-guard.mjs +324 -0
  75. package/plugins/src/base/hooks/worktree-binding-guard.sh +54 -0
  76. package/scripts/build-plugins.sh +2 -2
  77. package/scripts/lisa-enforcement-fallback.sh +3 -3
  78. package/scripts/two-channel-couplings.json +1 -1
@@ -8,8 +8,8 @@
8
8
  #
9
9
  # Every PreToolUse guard — block-no-verify, parity-safety-net,
10
10
  # block-shell-json-parsing, block-instruction-file-edits,
11
- # block-direct-issue-create, block-blind-automerge — is declared in the
12
- # Lisa plugin. A cloud session
11
+ # block-direct-issue-create, block-blind-automerge, worktree-binding-guard —
12
+ # is declared in the Lisa plugin. A cloud session
13
13
  # installs plugins at session start from the marketplace the repository
14
14
  # declares, and when that does not happen the container runs with
15
15
  # `installed_plugins.json` empty and no enforcement whatsoever.
@@ -316,7 +316,7 @@ plugin_tree_used=0
316
316
 
317
317
  for guard in block-no-verify parity-safety-net block-shell-json-parsing \
318
318
  block-instruction-file-edits block-direct-issue-create \
319
- block-managed-file-edits block-blind-automerge; do
319
+ block-managed-file-edits block-blind-automerge worktree-binding-guard; do
320
320
  if [ -f "$host_tree/$guard.sh" ]; then
321
321
  guard_names+=("$guard")
322
322
  guard_scripts+=("$host_tree/$guard.sh")
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ // Do not edit directly — durable changes belong upstream in Lisa.
4
+
5
+ /**
6
+ * Reconcile the worktree a session was TOLD it is in against the one it is
7
+ * ACTUALLY operating in, and refuse to act while the two disagree.
8
+ *
9
+ * WHAT WENT WRONG. `EnterWorktree` reported "the session is now working in the
10
+ * worktree" for a tree that Bash never moved to and that Edit then refused by
11
+ * name (CodySwannGT/lisa#3864). Three subsystems, three answers, and the one
12
+ * that answered affirmatively was the wrong one. A refusal teaches you
13
+ * something; a success message that is not true is a confirmation — it removes
14
+ * the reason to check and sends the agent forward confidently in the wrong
15
+ * place. Acting on that confirmation, one session ran an install that landed in
16
+ * a different agent's active worktree. The next such command is a `git commit`
17
+ * or a file write, and that tree held eighteen modified tracked files.
18
+ *
19
+ * The displacement is also mutual: whoever calls `EnterWorktree` last wins for
20
+ * every session whose own binding never took, so each agent repairing itself
21
+ * displaces the others. That is why "just re-enter your worktree" is not a
22
+ * remedy — it is the mechanism.
23
+ *
24
+ * WHAT THIS FIXES AND WHAT IT CANNOT. Lisa does not own `EnterWorktree`, so it
25
+ * cannot make that call itself return failure. What it can do is make the
26
+ * false confirmation cost nothing: the FIRST action taken on the strength of it
27
+ * is refused, and the refusal names the worktree the session is really bound
28
+ * to. The gap is stated rather than hidden — the acceptance criterion asking
29
+ * `EnterWorktree` to fail is met one tool call later, by the guard, not by the
30
+ * tool.
31
+ *
32
+ * WHY BLOCKING AND NOT A WARNING. The failure mode is that nothing feels wrong.
33
+ * There is no symptom to notice, so an advisory line scrolls past exactly when
34
+ * it matters. A refusal is the only form of this check the agent cannot read
35
+ * past.
36
+ *
37
+ * WHY AN EXPLICIT ACKNOWLEDGEMENT AND NOT "BLOCK ONCE". A guard that blocks the
38
+ * first attempt and lets the retry through is defeated by a blind retry, which
39
+ * is the single most likely next action. So the block stands until the session
40
+ * states, in the acknowledgement, the absolute path it now intends to work in.
41
+ * A path that does not match what the session is actually in is refused too —
42
+ * that is the rejection control, and it is what stops a stale acknowledgement
43
+ * copied from another agent's transcript from silently rebinding this one.
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.
50
+ */
51
+
52
+ import { execFileSync } from "node:child_process";
53
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
54
+ import { homedir } from "node:os";
55
+ import { dirname, isAbsolute, join, resolve } from "node:path";
56
+
57
+ /** Tools whose effect on the wrong worktree is not undoable by re-reading. */
58
+ const GUARDED_TOOLS = new Set(["Bash", "Write", "Edit", "MultiEdit"]);
59
+
60
+ /**
61
+ * The literal an agent echoes to rebind this session deliberately.
62
+ *
63
+ * A shell line rather than a flag on this script, because the only surface the
64
+ * agent has for saying so is a Bash command, and it must be one this guard sees
65
+ * on its own PreToolUse pass — which is exactly where a command string arrives.
66
+ */
67
+ const ACCEPT_PREFIX = "lisa-worktree-binding: accept";
68
+
69
+ /**
70
+ * Resolve a path the way git reports one.
71
+ *
72
+ * `git rev-parse --show-toplevel` answers with symlinks resolved, and on macOS
73
+ * every temporary directory is a symlink — `/var/…` is `/private/var/…`. A
74
+ * comparison between git's answer and a path handed in by a tool would then
75
+ * differ by that prefix alone and read as a displacement, which is a false
76
+ * accusation in exactly the state the guard is supposed to be trusted in. A
77
+ * path that does not exist yet cannot be resolved and is returned as given.
78
+ * @param value - Absolute path to normalize
79
+ * @returns The path with symlinks resolved where possible
80
+ */
81
+ function realpath(value) {
82
+ try {
83
+ return realpathSync(value);
84
+ } catch {
85
+ return value;
86
+ }
87
+ }
88
+
89
+ function say(message) {
90
+ process.stderr.write(`worktree-binding-guard: ${message}\n`);
91
+ }
92
+
93
+ /** Run git, returning trimmed stdout, or null when it exits non-zero. */
94
+ function git(args, cwd) {
95
+ try {
96
+ return execFileSync("git", args, {
97
+ cwd,
98
+ encoding: "utf8",
99
+ stdio: ["ignore", "pipe", "ignore"],
100
+ }).trim();
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** The worktree root containing `cwd`, or null when `cwd` is not in a repo. */
107
+ function worktreeRoot(cwd) {
108
+ const top = git(["rev-parse", "--show-toplevel"], cwd);
109
+ return top ? resolve(top) : null;
110
+ }
111
+
112
+ /**
113
+ * The MAIN checkout's root, which is where `.claude/worktrees/` lives.
114
+ *
115
+ * `--git-common-dir` is shared by every linked worktree, so its parent is the
116
+ * main checkout no matter which worktree asks.
117
+ */
118
+ function mainCheckout(cwd) {
119
+ const common = git(
120
+ ["rev-parse", "--path-format=absolute", "--git-common-dir"],
121
+ cwd
122
+ );
123
+ return common ? dirname(resolve(common)) : null;
124
+ }
125
+
126
+ function stateFile(sessionId) {
127
+ const home = process.env.LISA_STATE_HOME || join(homedir(), ".lisa");
128
+ return join(home, "worktree-binding", `${sessionId}.json`);
129
+ }
130
+
131
+ function readState(sessionId) {
132
+ try {
133
+ return JSON.parse(readFileSync(stateFile(sessionId), "utf8"));
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ function writeState(sessionId, state) {
140
+ const file = stateFile(sessionId);
141
+ try {
142
+ mkdirSync(dirname(file), { recursive: true });
143
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
144
+ return true;
145
+ } catch (error) {
146
+ say(`could not record the binding (${error.message}); NOT enforcing`);
147
+ return false;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * The worktree `EnterWorktree` claims the session moved to.
153
+ *
154
+ * `path` is given verbatim; `name` resolves under the main checkout's
155
+ * `.claude/worktrees/`, which is where the tool creates one. A call carrying
156
+ * neither is a generated name this guard cannot predict, so it returns null and
157
+ * the claim is simply not recorded — an unrecorded claim degrades to the plain
158
+ * displacement check rather than to a false accusation.
159
+ */
160
+ function claimedRoot(toolInput, cwd) {
161
+ const path = toolInput?.path;
162
+ if (typeof path === "string" && path.trim()) {
163
+ return realpath(resolve(isAbsolute(path) ? path : join(cwd, path)));
164
+ }
165
+ const name = toolInput?.name;
166
+ if (typeof name === "string" && name.trim()) {
167
+ const main = mainCheckout(cwd);
168
+ return main
169
+ ? realpath(join(main, ".claude", "worktrees", name.trim()))
170
+ : null;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * Record what `EnterWorktree` just claimed, for the next tool call to check.
177
+ *
178
+ * Returns nothing on purpose. This runs on PostToolUse, where the tool has
179
+ * already happened and there is no call left to refuse, so every path through
180
+ * it is an allow — and a function whose only answer is "allow" should not be
181
+ * spelled as one that computes an exit code.
182
+ */
183
+ function recordClaim(payload, observed) {
184
+ const claimed = claimedRoot(payload.tool_input, payload.cwd);
185
+ if (!claimed || claimed === observed) return;
186
+ const previous = readState(payload.session_id);
187
+ writeState(payload.session_id, {
188
+ boundRoot: previous?.boundRoot ?? observed,
189
+ claimedRoot: claimed,
190
+ updatedAt: new Date().toISOString(),
191
+ });
192
+ }
193
+
194
+ function refuse(lines) {
195
+ process.stderr.write(`${lines.join("\n")}\n`);
196
+ return 2;
197
+ }
198
+
199
+ function acceptanceLine(observed) {
200
+ return ` echo '${ACCEPT_PREFIX} ${observed}'`;
201
+ }
202
+
203
+ /**
204
+ * Handle an acknowledgement line, or return null when the command is not one.
205
+ *
206
+ * The path is compared against what the session is measurably in, not against
207
+ * what it says it wants. An acknowledgement naming somewhere else is the case
208
+ * this is for: it means the operator is reasoning about a tree they are not in.
209
+ */
210
+ function handleAcceptance(payload, observed) {
211
+ const command = payload.tool_input?.command;
212
+ if (typeof command !== "string" || !command.includes(ACCEPT_PREFIX))
213
+ return null;
214
+ const stated = command.slice(
215
+ command.indexOf(ACCEPT_PREFIX) + ACCEPT_PREFIX.length
216
+ );
217
+ const match = /([^\s'"]+)/.exec(stated);
218
+ const path = match ? realpath(resolve(match[1])) : null;
219
+ if (path !== observed) {
220
+ return refuse([
221
+ `worktree-binding-guard: this acknowledgement names ${path ?? "no path"},`,
222
+ `but this session is operating in ${observed}. Not rebinding.`,
223
+ `If ${observed} is where you mean to work, acknowledge that path:`,
224
+ acceptanceLine(observed),
225
+ ]);
226
+ }
227
+ writeState(payload.session_id, {
228
+ boundRoot: observed,
229
+ claimedRoot: null,
230
+ updatedAt: new Date().toISOString(),
231
+ });
232
+ say(`bound to ${observed}`);
233
+ return 0;
234
+ }
235
+
236
+ /** Refusal text for a switch that reported success without taking effect. */
237
+ function unconfirmedSwitch(claimed, observed) {
238
+ return refuse([
239
+ `worktree-binding-guard: EnterWorktree reported success for`,
240
+ ` ${claimed}`,
241
+ `but this session is still operating in`,
242
+ ` ${observed}`,
243
+ "The switch did not take effect. Anything run now lands in the second",
244
+ "path, not the first — including installs, commits, and file writes, and",
245
+ "that tree may hold another agent's uncommitted work.",
246
+ "",
247
+ "Use absolute paths under the tree you actually want, or acknowledge that",
248
+ "you intend to keep working where you are:",
249
+ acceptanceLine(observed),
250
+ ]);
251
+ }
252
+
253
+ /** Refusal text for a binding that moved without this session asking. */
254
+ function displaced(bound, observed) {
255
+ return refuse([
256
+ "worktree-binding-guard: this session's worktree changed underneath it.",
257
+ ` bound to: ${bound}`,
258
+ ` now in: ${observed}`,
259
+ "Concurrent sessions share one worktree binding, so another agent's",
260
+ "EnterWorktree can move this one. Commands run now land in the second",
261
+ "path. If you moved deliberately, say so:",
262
+ acceptanceLine(observed),
263
+ ]);
264
+ }
265
+
266
+ function evaluate(payload) {
267
+ const sessionId = payload.session_id;
268
+ const cwd = payload.cwd;
269
+ if (typeof sessionId !== "string" || !sessionId || typeof cwd !== "string") {
270
+ say("payload carries no session id or cwd; binding is NOT enforced");
271
+ return 0;
272
+ }
273
+ const observed = worktreeRoot(cwd);
274
+ if (!observed) return 0;
275
+
276
+ if (payload.tool_name === "EnterWorktree") {
277
+ recordClaim(payload, observed);
278
+ return 0;
279
+ }
280
+ if (!GUARDED_TOOLS.has(payload.tool_name)) return 0;
281
+
282
+ const accepted = handleAcceptance(payload, observed);
283
+ if (accepted !== null) return accepted;
284
+
285
+ const state = readState(sessionId);
286
+ if (!state?.boundRoot) {
287
+ writeState(sessionId, {
288
+ boundRoot: observed,
289
+ claimedRoot: null,
290
+ updatedAt: new Date().toISOString(),
291
+ });
292
+ return 0;
293
+ }
294
+ if (state.claimedRoot && state.claimedRoot !== observed) {
295
+ return unconfirmedSwitch(state.claimedRoot, observed);
296
+ }
297
+ if (state.claimedRoot === observed) {
298
+ writeState(sessionId, {
299
+ boundRoot: observed,
300
+ claimedRoot: null,
301
+ updatedAt: new Date().toISOString(),
302
+ });
303
+ return 0;
304
+ }
305
+ if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
306
+ return 0;
307
+ }
308
+
309
+ function main() {
310
+ let raw = "";
311
+ try {
312
+ raw = readFileSync(0, "utf8");
313
+ } catch {
314
+ say("could not read the hook payload; binding is NOT enforced");
315
+ return 0;
316
+ }
317
+ let payload;
318
+ try {
319
+ payload = JSON.parse(raw);
320
+ } catch {
321
+ say("hook payload was not JSON; binding is NOT enforced");
322
+ return 0;
323
+ }
324
+ return evaluate(payload);
325
+ }
326
+
327
+ process.exit(main());
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ # Do not edit directly — durable changes belong upstream in Lisa.
4
+
5
+ # PreToolUse guard for Bash/Write/Edit/MultiEdit, and PostToolUse recorder for
6
+ # EnterWorktree: refuse to act while the worktree the session was told it is in
7
+ # disagrees with the one it is measurably in (CodySwannGT/lisa#3864).
8
+ #
9
+ # The reconciliation lives in worktree-binding-guard.mjs beside this file. It
10
+ # needs per-session state, path resolution and git plumbing, and a bash
11
+ # reimplementation of any of that would be a second copy of the thing the guard
12
+ # exists to keep honest.
13
+ #
14
+ # This wrapper does three things and nothing else: probe its interpreter, pass
15
+ # the hook envelope through untouched, and return the classifier's exit code so
16
+ # a refusal is a refusal.
17
+ #
18
+ # FAILING OPEN, LOUDLY
19
+ #
20
+ # Claude Code treats exit 2 as a refusal and every OTHER non-zero exit as a
21
+ # non-blocking hook error, so a missing interpreter under `set -e` would exit
22
+ # 127 and permit exactly what this exists to stop. Degrading to "allow" is
23
+ # correct — a hook that cannot read its input cannot tell a displacement from a
24
+ # directory listing — but it must SAY so, because a guard that is silently
25
+ # absent reads exactly like a guard that is passing. Same reasoning as
26
+ # block-host-name-leak.sh.
27
+ #
28
+ # AGENT PARITY, AND WHY IT STOPS WHERE IT DOES
29
+ #
30
+ # `EnterWorktree` is a Claude Code tool, and the process-global session binding
31
+ # it rewrites is a Claude Code structure. Codex, Antigravity and OpenCode manage
32
+ # their own worktrees and have neither, so there is nothing on those harnesses
33
+ # for the reconciler to reconcile — no `.agy.sh` adapter is shipped for this
34
+ # guard, and its absence is a statement rather than an omission. The Codex
35
+ # variant carries the PreToolUse half because the displacement check is
36
+ # harness-agnostic: it only asks whether the directory a session is measurably
37
+ # in has changed since it last acted.
38
+ set -uo pipefail
39
+
40
+ input="$(cat)"
41
+
42
+ if ! command -v node >/dev/null 2>&1; then
43
+ printf 'worktree-binding-guard: node not found; worktree-binding enforcement is NOT active\n' >&2
44
+ exit 0
45
+ fi
46
+
47
+ hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
48
+ classifier="$hook_dir/worktree-binding-guard.mjs"
49
+ if [ ! -r "$classifier" ]; then
50
+ printf 'worktree-binding-guard: classifier missing at %s; worktree-binding enforcement is NOT active\n' \
51
+ "$classifier" >&2
52
+ exit 0
53
+ fi
54
+
55
+ status=0
56
+ printf '%s' "$input" | node "$classifier" || status=$?
57
+ exit "$status"
@@ -2207,11 +2207,10 @@ function assertStateAmong(refs, contract) {
2207
2207
  * branch encodes none.
2208
2208
  */
2209
2209
  function branchWorkItem(contract) {
2210
- // A GitHub reference is `owner/repo#123`; no branch-naming convention
2211
- // encodes one, so there is nothing here to read.
2212
- if (contract.provider === "github") return undefined;
2213
2210
  const branch = activeBranch();
2214
2211
  if (!branch) return undefined;
2212
+ if (contract.provider === "github")
2213
+ return githubBranchIssue(branch, contract);
2215
2214
  const key =
2216
2215
  contract.provider === "jira" ? contract.project : contract.teamKey;
2217
2216
  if (!key) return undefined;
@@ -2225,6 +2224,53 @@ function branchWorkItem(contract) {
2225
2224
  return match ? `${key}-${match[1]}` : undefined;
2226
2225
  }
2227
2226
 
2227
+ /**
2228
+ * The GitHub issue a branch name encodes, canonicalized, or undefined.
2229
+ *
2230
+ * This used to be `branchWorkItem`'s first statement, returning `undefined`
2231
+ * unconditionally on the premise that "a GitHub reference is `owner/repo#123`;
2232
+ * no branch-naming convention encodes one". True of the canonical SPELLING and
2233
+ * false of the convention in use — `fix/3537-…`, `stack/3463`, `qd/3554-…`.
2234
+ * The number is right there; it simply is not written as a full reference. So
2235
+ * the fallback built to close the unbound-worktree gap never applied to the
2236
+ * provider this repository configures, and the trailer went on being compared
2237
+ * against nothing, while the hook printed `WORK_ITEM_TRACKING_OK` either way
2238
+ * (CodySwannGT/lisa#3861).
2239
+ *
2240
+ * WHY THE WHOLE FIRST SEGMENT AFTER THE FIRST SLASH, and nothing looser. A
2241
+ * bare number is ambiguous in a way a `KEY-123` shape is not, so position has
2242
+ * to carry the meaning the key would otherwise carry. Measured against the 246
2243
+ * branches this repository has: the rule reads 149 of them, and every one of
2244
+ * the 97 it declines genuinely encodes no issue number. A "first number
2245
+ * anywhere" rule would instead read `chore/upgrade-lisa-4.33.1` as issue 4,
2246
+ * `stack/queue-drain-20260903` as issue 20260903, and
2247
+ * `fix/se-7728-e2e-coverage-wildcard` as issue 7728 — a version, a date and
2248
+ * another tracker's key, each refusing a commit that is perfectly correct.
2249
+ *
2250
+ * `issue-<n>` shapes (`codex/issue-1264`) are knowingly NOT read. They are
2251
+ * unambiguous but rare here, and every additional pattern is another place to
2252
+ * be wrong; declining them fails open, which is the safe direction.
2253
+ *
2254
+ * EXPORTED SO IT CAN BE TESTED IN PROCESS, and that is not a formality. The
2255
+ * CLI-level cases around this reach it only by SPAWNING the script, and a
2256
+ * subprocess loads the file from disk rather than the instrumented module, so
2257
+ * the mutation gate cannot see through it: measured, 12 of 12 mutants in this
2258
+ * function survived every CLI case while an untouched range of the same file
2259
+ * scored 85.71% off the in-process importers. Taking `branch` as an argument
2260
+ * rather than calling `activeBranch()` is what makes that possible — the
2261
+ * function is pure, so a table of names can pin each boundary directly. Several
2262
+ * neighbours here are exported for the same reason.
2263
+ * @param {string} branch Active branch name.
2264
+ * @param {object} contract Resolved tracker contract.
2265
+ * @returns {string|undefined} Canonical `owner/repo#123`, or undefined.
2266
+ */
2267
+ export function githubBranchIssue(branch, contract) {
2268
+ // Bounded on both sides: the number must fill the segment, so `4.33.1` and
2269
+ // `se-7728` do not match, and `3463` is not read out of `34631`.
2270
+ const match = /^[^/]+\/([1-9]\d*)(?:-|$)/.exec(branch);
2271
+ return match ? `${contract.repository}#${match[1]}` : undefined;
2272
+ }
2273
+
2228
2274
  /**
2229
2275
  * Refuse a trailer that names a different work item than the branch is for.
2230
2276
  *
@@ -37,6 +37,17 @@
37
37
  },
38
38
  "plansDirectory": "./plans",
39
39
  "hooks": {
40
+ "PostToolUse": [
41
+ {
42
+ "matcher": "EnterWorktree",
43
+ "hooks": [
44
+ {
45
+ "type": "command",
46
+ "command": "bash \"$CLAUDE_PROJECT_DIR\"/scripts/lisa-hooks/worktree-binding-guard.sh"
47
+ }
48
+ ]
49
+ }
50
+ ],
40
51
  "PreToolUse": [
41
52
  {
42
53
  "matcher": "Bash",
@@ -1 +1 @@
1
- {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAghCjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAm7BjC,CAAC"}
1
+ {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA2hCjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA87BjC,CAAC"}
@@ -434,6 +434,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
434
434
  "b456ad79aae18ea3af4ae462327653a88ab865ec31c84aa233c3ded512acff32",
435
435
  "b4cc14d5dc02c790ea3e51b0134b9c3d5e4a0e8ef08a652817f7485a0badde17",
436
436
  "c64765cb502797daa163dbb6cad723b633a7c527dffeb6bd99cca4989a63fcf4",
437
+ "c9604929b1f5d3fee39ef7c3619f89002accf27712dbd38f25b6a7a0ceb0025a",
437
438
  "d7d88e44763694bed5eae998cd3663922c957e49f9f9621d9201f0417a4128ad",
438
439
  "d922508529c0ffd6b8411d7fc618b3d14ceb70901746adfbcf49e46eb685d6b9",
439
440
  "e0f4f2b11bea5497af12bae0d52902eb3d02b4ec3a7ae8e07236fb09b59cf560",
@@ -759,6 +760,13 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
759
760
  "e0b0073fe44f6e78b74f05cb232d30a3121acc3600bdbd5bc4299c1c710d56a2",
760
761
  "f21cdcb54b353714a92c2d1e25fd6b76318e8e8d7cb90cdb016f174923c532ef",
761
762
  ]),
763
+ "scripts/lisa-hooks/worktree-binding-guard.mjs": Object.freeze([
764
+ "822e4f9d2c3ad8fb184ff7fc880f6811004852fb731c7878b5a8a1a9532bceb8",
765
+ "bc1118b05eaea3fc7b3409c96979d64e3dee0907118f3e84d735163f473f632a",
766
+ ]),
767
+ "scripts/lisa-hooks/worktree-binding-guard.sh": Object.freeze([
768
+ "662652c09e5eb70f14db61136ea1206e9b337fce49dcabc774a45a07a0fd718f",
769
+ ]),
762
770
  "scripts/lisa-lint-staged-preflight.mjs": Object.freeze([
763
771
  "ce4bc224a102e3ac2bc29d8f2038eefd619129d4639c25954c93f05011f7f977",
764
772
  ]),
@@ -892,6 +900,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
892
900
  "04f78df45465d01601c8b5711b74749b2a2c2b70b9c916d6790bd69e1c11865d",
893
901
  "08ec9a37d66fb35443a5aac72e09d7ab5478cbd516f72e248d01b3befabddfe5",
894
902
  "0b6eff0a306ccd8396d6e3d71ac9c54d3a1c8c29449a9146e0c2e5726b74feb9",
903
+ "1365fd1272eac2d046bbc54d50b0ebdce5b1d9863c832d93c9d4671c3fa13e3c",
895
904
  "14bc7270452a95321e6bbe4a81afcd86fabc7b7cd595b2c59b1a467d0d58b262",
896
905
  "1d206070e5a0230aac5598ed320ab26ef9eb6db18afbd860a69bf483a303dd5f",
897
906
  "1fd9e91eeb37007e4ae273c4af2a34d7b4d9a143eb5edfb19f733bd6215b4a95",
@@ -900,6 +909,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
900
909
  "2b7e81f0e23628da9c3a124f3d5b0593e3a8c564cd595329b9f37510540a5581",
901
910
  "2c678c75d6de9b029f27fa6eec45b8421217a4fb5ef3412ad39883e10b213987",
902
911
  "2d9ca0841db9e75ddfd95e3a4d485fd01aa639a512ef648996f565b7991d8e2b",
912
+ "38489b50b0305256ef29037551bf12781b7caebf72c5609667c9c4e2d621f0ff",
903
913
  "3a9ec1ff863693a0fa0fef0ade6fa5534485d0249fb78302d37cdac3781348f1",
904
914
  "3f8c59e0f4ad43211f6964b4889f3fc673bee0496ec3613bdbdc4544424340f6",
905
915
  "41b967f736d7fcf9d8928ad7c8cc767c5beb9544e2fbfa3317c65734aa02fcfd",
@@ -912,6 +922,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
912
922
  "5bc1466a79c18e565e62305d4adeca0c7de94a1e5a22b2c2a75c84f634fcec86",
913
923
  "5e937e22bac62f66e91b288e9076e717147e4b382a431cdc8bbfc56681ebde8e",
914
924
  "5eb31c284e820a51312d4484f0669128007326af55ee515923fec33700d003a1",
925
+ "60bc3a715e02b5bf57f324f78e6da46dbb6dbb7769177c42139f7b7a2806eea6",
915
926
  "62f53156d425198609564e996f3844f52ab110e63f99f22e8338fcf7a83f316d",
916
927
  "637741a9e5d2904aed24d48efd6bec1326c5573f718a13152807aa0e4b42f0a1",
917
928
  "653252b0bbfd19bdffa046a2173c053e566ac6bf605c8d9060f9cf71ad7386de",
@@ -1467,6 +1478,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1467
1478
  "b456ad79aae18ea3af4ae462327653a88ab865ec31c84aa233c3ded512acff32",
1468
1479
  "b4cc14d5dc02c790ea3e51b0134b9c3d5e4a0e8ef08a652817f7485a0badde17",
1469
1480
  "c64765cb502797daa163dbb6cad723b633a7c527dffeb6bd99cca4989a63fcf4",
1481
+ "c9604929b1f5d3fee39ef7c3619f89002accf27712dbd38f25b6a7a0ceb0025a",
1470
1482
  "d7d88e44763694bed5eae998cd3663922c957e49f9f9621d9201f0417a4128ad",
1471
1483
  "d922508529c0ffd6b8411d7fc618b3d14ceb70901746adfbcf49e46eb685d6b9",
1472
1484
  "e0f4f2b11bea5497af12bae0d52902eb3d02b4ec3a7ae8e07236fb09b59cf560",
@@ -1746,6 +1758,13 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1746
1758
  "e0b0073fe44f6e78b74f05cb232d30a3121acc3600bdbd5bc4299c1c710d56a2",
1747
1759
  "f21cdcb54b353714a92c2d1e25fd6b76318e8e8d7cb90cdb016f174923c532ef",
1748
1760
  ]),
1761
+ "scripts/lisa-hooks/worktree-binding-guard.mjs": Object.freeze([
1762
+ "822e4f9d2c3ad8fb184ff7fc880f6811004852fb731c7878b5a8a1a9532bceb8",
1763
+ "bc1118b05eaea3fc7b3409c96979d64e3dee0907118f3e84d735163f473f632a",
1764
+ ]),
1765
+ "scripts/lisa-hooks/worktree-binding-guard.sh": Object.freeze([
1766
+ "662652c09e5eb70f14db61136ea1206e9b337fce49dcabc774a45a07a0fd718f",
1767
+ ]),
1749
1768
  "scripts/lisa-lint-staged-preflight.mjs": Object.freeze([
1750
1769
  "ce4bc224a102e3ac2bc29d8f2038eefd619129d4639c25954c93f05011f7f977",
1751
1770
  ]),
@@ -1858,6 +1877,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1858
1877
  "04401ae05bf9464e6929180ffdfff80afc528a1df20733b032a0342252ca1d4a",
1859
1878
  "08ec9a37d66fb35443a5aac72e09d7ab5478cbd516f72e248d01b3befabddfe5",
1860
1879
  "0b6eff0a306ccd8396d6e3d71ac9c54d3a1c8c29449a9146e0c2e5726b74feb9",
1880
+ "1365fd1272eac2d046bbc54d50b0ebdce5b1d9863c832d93c9d4671c3fa13e3c",
1861
1881
  "14bc7270452a95321e6bbe4a81afcd86fabc7b7cd595b2c59b1a467d0d58b262",
1862
1882
  "1d206070e5a0230aac5598ed320ab26ef9eb6db18afbd860a69bf483a303dd5f",
1863
1883
  "1fd9e91eeb37007e4ae273c4af2a34d7b4d9a143eb5edfb19f733bd6215b4a95",
@@ -1866,6 +1886,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1866
1886
  "2b7e81f0e23628da9c3a124f3d5b0593e3a8c564cd595329b9f37510540a5581",
1867
1887
  "2c678c75d6de9b029f27fa6eec45b8421217a4fb5ef3412ad39883e10b213987",
1868
1888
  "2d9ca0841db9e75ddfd95e3a4d485fd01aa639a512ef648996f565b7991d8e2b",
1889
+ "38489b50b0305256ef29037551bf12781b7caebf72c5609667c9c4e2d621f0ff",
1869
1890
  "3a9ec1ff863693a0fa0fef0ade6fa5534485d0249fb78302d37cdac3781348f1",
1870
1891
  "3f8c59e0f4ad43211f6964b4889f3fc673bee0496ec3613bdbdc4544424340f6",
1871
1892
  "41b967f736d7fcf9d8928ad7c8cc767c5beb9544e2fbfa3317c65734aa02fcfd",
@@ -1878,6 +1899,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1878
1899
  "5bc1466a79c18e565e62305d4adeca0c7de94a1e5a22b2c2a75c84f634fcec86",
1879
1900
  "5e937e22bac62f66e91b288e9076e717147e4b382a431cdc8bbfc56681ebde8e",
1880
1901
  "5eb31c284e820a51312d4484f0669128007326af55ee515923fec33700d003a1",
1902
+ "60bc3a715e02b5bf57f324f78e6da46dbb6dbb7769177c42139f7b7a2806eea6",
1881
1903
  "62f53156d425198609564e996f3844f52ab110e63f99f22e8338fcf7a83f316d",
1882
1904
  "653252b0bbfd19bdffa046a2173c053e566ac6bf605c8d9060f9cf71ad7386de",
1883
1905
  "6a8be8577242588f14ab52b25b2a22b6def53aefa9513b4d3d7c49f4d8027079",