@codyswann/lisa 4.45.5 → 4.45.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 (75) hide show
  1. package/all/copy-overwrite/scripts/lisa-cross-worktree-guard.mjs +6 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +74 -0
  3. package/all/copy-overwrite/scripts/lisa-worktree-guard.mjs +456 -0
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +24 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  8. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.js +15 -8
  10. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  11. package/dist/opencode/plugin-templates/parity-safety-net.sh +74 -0
  12. package/package.json +4 -4
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/hooks/cleanup-stale-worktrees.sh +42 -1
  16. package/plugins/lisa/hooks/parity-safety-net.sh +74 -0
  17. package/plugins/lisa-agy/hooks/parity-safety-net.sh +74 -0
  18. package/plugins/lisa-agy/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/hooks/cleanup-stale-worktrees.sh +42 -1
  26. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +74 -0
  27. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cursor/hooks/cleanup-stale-worktrees.sh +42 -1
  29. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +74 -0
  30. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo-agy/plugin.json +1 -1
  33. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  53. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails-agy/plugin.json +1 -1
  58. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  63. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  68. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/src/base/hooks/cleanup-stale-worktrees.sh +42 -1
  71. package/plugins/src/base/hooks/parity-safety-net.sh +74 -0
  72. package/scripts/cleanup-worktrees.sh +36 -3
  73. package/scripts/two-channel-couplings.json +1 -1
  74. package/typescript/copy-overwrite/.prettierignore +1 -0
  75. package/typescript/create-only/.github/workflows/review-evidence.yml +20 -1
@@ -28,6 +28,12 @@
28
28
  * proof that none is takeable *right now*. Deleting a worktree that holds
29
29
  * uncommitted work permanently forecloses the only window in which its own
30
30
  * case was detectable.
31
+ *
32
+ * That limit is now held open from the other end by
33
+ * `lisa-worktree-guard.mjs` (CodySwannGT/lisa#3863), which refuses to remove a
34
+ * worktree whose uncommitted bytes exist in no commit. The two are one control
35
+ * read in two directions: this file needs the original to still be there, and
36
+ * that one is what keeps it there.
31
37
  */
32
38
 
33
39
  import { execFileSync } from "node:child_process";
@@ -2033,6 +2033,77 @@ readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$
2033
2033
  # that could not run produced no lines, the loop body never executed, and the
2034
2034
  # guard was skipped with the command ALLOWED. A segmentation that cannot answer
2035
2035
  # must refuse, exactly like a scan that cannot answer.
2036
+ # 14b. Linked worktrees, which are the same principle one level out. `.git` is
2037
+ # protected because nothing in the working tree can rebuild it; a linked
2038
+ # worktree is protected because nothing OUTSIDE it can rebuild what it holds.
2039
+ # The asymmetry is exact and it is invisible from the prompt: `.git/refs` and
2040
+ # the object store are shared by every worktree of a repository, while
2041
+ # `.git/index` and the working files are per-worktree. So `git commit` moves
2042
+ # work somewhere a deletion cannot reach, and `git add` does not — a staged file
2043
+ # shows green in `git status`, which reads as saved, and dies with the
2044
+ # directory.
2045
+ #
2046
+ # Measured twice in one evening (issue #3863): one worktree lost a 284-line
2047
+ # staged test file, and a second lost nothing from the identical event purely
2048
+ # because a gate had forced its work into a commit first. Neither deletion
2049
+ # announced itself. That is the second reason this is a block rather than a
2050
+ # warning — a worktree deletion destroys the only evidence its own case could
2051
+ # leave, so there is no after-the-fact detection to fall back on.
2052
+ #
2053
+ # `git worktree remove` (guard 12) already refuses a dirty tree, and
2054
+ # `--force` is already blocked. This closes the spelling that walks around both:
2055
+ # `rm -rf` on the directory, which git never sees.
2056
+ #
2057
+ # Matching is by REGISTERED path — `git worktree list` names them — so an
2058
+ # ordinary directory that merely lives near one is untouched. A repository that
2059
+ # cannot be read answers "no": this guard adds protection to a shape that was
2060
+ # fully allowed a moment ago, and failing it closed would deny every recursive
2061
+ # delete on every path outside a repository.
2062
+ #
2063
+ # The comparison is on WHOLE TOKENS, which is the only spelling that gets both
2064
+ # error directions right. Substring containment would let a worktree named
2065
+ # `wt-1` claim `rm -rf wt-10`, an over-block on an unrelated directory; and
2066
+ # hand-written boundary classes leak the other way, because `rm -rf <wt>/`
2067
+ # with one trailing slash ends on a character every such class treats as
2068
+ # "inside a path". So every non-path character — quote, space, parenthesis,
2069
+ # redirection — is folded to a space first, which makes each argument a
2070
+ # space-delimited token regardless of how it was quoted, and the match is then
2071
+ # an equality test on that token. A SUBPATH (`rm -rf <wt>/node_modules`) is
2072
+ # deliberately not this guard's business: it is not a worktree deletion, and
2073
+ # blocking routine cleanup inside a worktree is how a guard gets switched off.
2074
+ names_linked_worktree() {
2075
+ local stmt="$1" probe root listed line primary wt rel
2076
+ root="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> /dev/null)" || return 1
2077
+ [ -n "$root" ] || return 1
2078
+ listed="$(git -C "$root" worktree list --porcelain 2> /dev/null)" || return 1
2079
+ probe=" $(printf '%s' "$stmt" | tr -c 'A-Za-z0-9_./-' ' ') "
2080
+ primary=""
2081
+ while IFS= read -r line; do
2082
+ case "$line" in
2083
+ worktree\ *) wt="${line#worktree }" ;;
2084
+ *) continue ;;
2085
+ esac
2086
+ # First entry is the primary checkout, which is not a linked worktree.
2087
+ if [ -z "$primary" ]; then
2088
+ primary="$wt"
2089
+ continue
2090
+ fi
2091
+ case "$probe" in
2092
+ *" $wt "* | *" $wt/ "*) return 0 ;;
2093
+ esac
2094
+ rel="${wt#"$primary"/}"
2095
+ [ "$rel" != "$wt" ] || continue
2096
+ # `./` is the same path spelled the way an agent in the project root types
2097
+ # it, and a token comparison is exact, so it has to be spelled here too.
2098
+ case "$probe" in
2099
+ *" $rel "* | *" $rel/ "* | *" ./$rel "* | *" ./$rel/ "*) return 0 ;;
2100
+ esac
2101
+ done <<EOF
2102
+ $listed
2103
+ EOF
2104
+ return 1
2105
+ }
2106
+
2036
2107
  git_segments_status=0
2037
2108
  git_segments="$(printf '%s' "$normalized_command_str" \
2038
2109
  | awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
@@ -2055,6 +2126,9 @@ while IFS= read -r git_stmt; do
2055
2126
  if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
2056
2127
  block "recursive forced delete of the git control plane (.git holds every commit, branch and stash not already pushed; nothing in the working tree can rebuild it). Delete a specific ignored artifact instead. Re-cloning is NOT a like-for-like replacement: every linked worktree stores a pointer into this .git, so discarding it discards each of them and any uncommitted work they hold — check \`git worktree list\` before treating the checkout as disposable."
2057
2128
  fi
2129
+ if names_linked_worktree "$git_stmt"; then
2130
+ block "recursive forced delete of a linked git worktree. A worktree's index and working files are private to it — only commits are shared — so this destroys any staged or unwritten-to-a-commit work another agent left there, and destroys the evidence that it did (CodySwannGT/lisa#3863). Use 'node scripts/lisa-worktree-guard.mjs remove <path>' instead: it deletes clean and already-committed trees without ceremony, names the files when they exist nowhere else, and records an explicit --force override."
2131
+ fi
2058
2132
  done <<<"$git_segments"
2059
2133
 
2060
2134
  # 15. Credential stores. Reading one into an agent transcript is disclosure even
@@ -0,0 +1,456 @@
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
+ * lisa-worktree-guard — refuse to delete a linked worktree whose uncommitted
7
+ * work exists in no commit (CodySwannGT/lisa#3863).
8
+ *
9
+ * @remarks
10
+ * ## The asymmetry that makes this necessary
11
+ *
12
+ * `.git/refs` and the object store are **shared** across every linked worktree
13
+ * of a repository; `.git/index` and the working files are **per-worktree**. So
14
+ * deleting a worktree spares everything that was committed and destroys
15
+ * everything that was only staged or only written. Two agents hit the same
16
+ * deletion event on the same evening: the one whose work was in a commit lost
17
+ * nothing, the one whose work was staged lost the whole deliverable. Nothing
18
+ * about the two situations looked different from the outside — `git status`
19
+ * reports staged work in green, which reads as "saved".
20
+ *
21
+ * `lisa-cross-worktree-guard.mjs` is the same control read from the other end:
22
+ * it detects one agent committing another agent's uncommitted file, and it
23
+ * needs BOTH halves — the commit and the surviving original — to see anything
24
+ * at all. This guard is what keeps the second half in existence.
25
+ *
26
+ * ## Why the deletion cannot be allowed to report success quietly
27
+ *
28
+ * A worktree deletion **destroys the only evidence its own case could leave**.
29
+ * Once the directory is gone the staged blob is unreferenced and the incident
30
+ * is indistinguishable from ordinary cleanup: no per-agent identifier appears
31
+ * in any git object, the branch survives, and the aggregate worktree count is
32
+ * flat whenever deletions are matched by creations. Any detection has to happen
33
+ * *before* the removal, which is why this is a pre-flight refusal rather than a
34
+ * monitor.
35
+ *
36
+ * ## What is refused, and what is not
37
+ *
38
+ * The question asked of every uncommitted file is narrow and answerable:
39
+ * **does a commit anywhere in this repository already contain these exact
40
+ * bytes?** If yes, deleting the worktree costs nothing recoverable and the
41
+ * removal proceeds without ceremony — a worktree full of untracked build
42
+ * output, or holding a file byte-identical to one already pushed, is not work
43
+ * at risk. If no, the removal is refused and the at-risk files are named.
44
+ *
45
+ * Reachability is answered from `git rev-list --objects --all`, which walks
46
+ * every ref rather than only the current branch, so work committed on a
47
+ * different branch (or already pushed and merged) reads as safe. A blob that
48
+ * exists only in the object database — which is exactly what `git add` writes —
49
+ * is NOT reachable and is exactly the case this guard exists to catch.
50
+ *
51
+ * ## The override is real, and it is recorded
52
+ *
53
+ * An operator who genuinely wants the directory gone must not be stranded, so
54
+ * `--force` (or `LISA_WORKTREE_REMOVE_OVERRIDE=1`) proceeds. Both the refusal
55
+ * and the override append a line to a JSONL ledger in the **common** git
56
+ * directory — never inside the worktree, which is about to stop existing. The
57
+ * ledger is the only artifact that survives to say a detectable case was
58
+ * knowingly made undetectable.
59
+ *
60
+ * Failing closed is deliberate: an unreadable repository, a `rev-list` that
61
+ * cannot be buffered, or a worktree git does not know about all resolve to
62
+ * refusal. "I could not tell" must never be cheaper than "there is work here".
63
+ * @module scripts/lisa-worktree-guard
64
+ */
65
+ import { execFileSync } from "node:child_process";
66
+ import { appendFileSync, existsSync } from "node:fs";
67
+ import path from "node:path";
68
+
69
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
70
+
71
+ /** Exit code meaning "safe, or removed". */
72
+ const OK = 0;
73
+ /** Exit code meaning "usage or environment error". */
74
+ const ERROR = 1;
75
+ /** Exit code meaning "refused: uncommitted work would be destroyed". */
76
+ const REFUSED = 2;
77
+
78
+ /** Ledger filename, written beside the shared object store. */
79
+ const LEDGER = "lisa-worktree-removals.jsonl";
80
+
81
+ /** Environment variable spelling of `--force`. */
82
+ const OVERRIDE_ENV = "LISA_WORKTREE_REMOVE_OVERRIDE";
83
+
84
+ /** Verdict reason: the path is not a git worktree at all. */
85
+ const NOT_A_WORKTREE = "not-a-worktree";
86
+
87
+ /** Verdict reason: the path is the primary checkout, which is never removable. */
88
+ const PRIMARY_CHECKOUT = "primary-checkout";
89
+
90
+ /** Verdict reason: the repository's objects could not be enumerated. */
91
+ const UNREADABLE = "unreadable";
92
+
93
+ /** Largest `git rev-list` payload buffered before the guard fails closed. */
94
+ const MAX_BUFFER = 1 << 28;
95
+
96
+ /**
97
+ * Run git, returning stdout.
98
+ * @param {string[]} args - Arguments after `git`.
99
+ * @param {string} cwd - Directory to run in.
100
+ * @returns {string} Captured stdout.
101
+ */
102
+ function git(args, cwd) {
103
+ return execFileSync("git", args, {
104
+ cwd,
105
+ encoding: "utf8",
106
+ maxBuffer: MAX_BUFFER,
107
+ stdio: ["ignore", "pipe", "pipe"],
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Run git, returning `null` instead of throwing.
113
+ * @param {string[]} args - Arguments after `git`.
114
+ * @param {string} cwd - Directory to run in.
115
+ * @returns {string | null} Captured stdout, or `null` when git failed.
116
+ */
117
+ function gitOrNull(args, cwd) {
118
+ try {
119
+ return git(args, cwd);
120
+ } catch {
121
+ // probe-direction: fail-closed — no caller spends this null as an answer.
122
+ // `candidatePaths`, `indexBlobs` and `candidateBlobs` each propagate it,
123
+ // and `classifyWorktree` turns it into UNREADABLE, which REFUSES the
124
+ // removal. A git that cannot answer costs a false refusal, never a
125
+ // silent deletion.
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Split NUL-delimited git output into entries.
132
+ * @param {string | null} out - Raw git output.
133
+ * @returns {string[]} Non-empty entries.
134
+ */
135
+ function splitZ(out) {
136
+ return (out ?? "").split("\0").filter(Boolean);
137
+ }
138
+
139
+ /**
140
+ * Repo-relative paths whose current bytes may differ from every commit.
141
+ *
142
+ * Staged additions, unstaged modifications and untracked files are all at risk;
143
+ * ignored files are not (that is what makes build output cheap to delete).
144
+ *
145
+ * Returns `null` when any of the three probes failed. An empty list means
146
+ * "git looked and found nothing"; `null` means "git could not look", and the
147
+ * two must never collapse into each other — collapsing them is what lets an
148
+ * unreadable worktree be deleted as though it were empty.
149
+ * @param {string} wt - Worktree path.
150
+ * @returns {string[] | null} Candidate repo-relative paths, or `null`.
151
+ */
152
+ function candidatePaths(wt) {
153
+ const staged = gitOrNull(
154
+ ["diff", "--cached", "--name-only", "--no-renames", "-z"],
155
+ wt
156
+ );
157
+ const unstaged = gitOrNull(["diff", "--name-only", "--no-renames", "-z"], wt);
158
+ const untracked = gitOrNull(
159
+ ["ls-files", "--others", "--exclude-standard", "-z"],
160
+ wt
161
+ );
162
+ if (staged === null || unstaged === null || untracked === null) return null;
163
+ return [
164
+ ...new Set([...splitZ(staged), ...splitZ(unstaged), ...splitZ(untracked)]),
165
+ ];
166
+ }
167
+
168
+ /**
169
+ * Index blob ids for the given paths, keyed by path.
170
+ * @param {string} wt - Worktree path.
171
+ * @param {string[]} paths - Candidate repo-relative paths.
172
+ * @returns {Map<string, string> | null} Path to staged blob id, or `null`.
173
+ */
174
+ function indexBlobs(wt, paths) {
175
+ const entries = new Map();
176
+ if (paths.length === 0) return entries;
177
+ const raw = gitOrNull(["ls-files", "-s", "-z", "--", ...paths], wt);
178
+ if (raw === null) return null;
179
+ for (const line of splitZ(raw)) {
180
+ const tab = line.indexOf("\t");
181
+ if (tab === -1) continue;
182
+ const fields = line.slice(0, tab).split(" ");
183
+ if (fields.length >= 2) entries.set(line.slice(tab + 1), fields[1]);
184
+ }
185
+ return entries;
186
+ }
187
+
188
+ /**
189
+ * Every blob a worktree holds that no commit may contain, as path/blob pairs.
190
+ *
191
+ * Both sides of a path are collected: the INDEX blob (what `git add` wrote) and
192
+ * the WORKING blob (what is on disk). They differ for a file staged and then
193
+ * edited again, and losing either is losing work.
194
+ * @param {string} wt - Worktree path.
195
+ * @returns {{path: string, blob: string, source: string}[] | null} Candidate
196
+ * blobs, or `null` when the worktree could not be read.
197
+ */
198
+ function candidateBlobs(wt) {
199
+ const paths = candidatePaths(wt);
200
+ if (paths === null) return null;
201
+ const staged = indexBlobs(wt, paths);
202
+ if (staged === null) return null;
203
+ const found = [];
204
+ for (const rel of paths) {
205
+ const stagedBlob = staged.get(rel);
206
+ if (stagedBlob)
207
+ found.push({ path: rel, blob: stagedBlob, source: "index" });
208
+ if (!existsSync(path.join(wt, rel))) continue;
209
+ const hashed = gitOrNull(["hash-object", "--", rel], wt);
210
+ // A file that is on disk but cannot be hashed is unread, not unchanged.
211
+ if (hashed === null) return null;
212
+ const id = hashed.trim();
213
+ if (id && id !== stagedBlob) {
214
+ found.push({ path: rel, blob: id, source: "worktree" });
215
+ }
216
+ }
217
+ return found;
218
+ }
219
+
220
+ /**
221
+ * The subset of `wanted` blob ids that some commit already contains.
222
+ *
223
+ * Returns `null` when the answer could not be computed, which callers must
224
+ * treat as "nothing is reachable" rather than as "everything is".
225
+ * @param {string} wt - Worktree path.
226
+ * @param {Set<string>} wanted - Blob ids to look for.
227
+ * @returns {Set<string> | null} Reachable ids, or `null` on failure.
228
+ */
229
+ function reachableBlobs(wt, wanted) {
230
+ if (wanted.size === 0) return new Set();
231
+ const raw = gitOrNull(["rev-list", "--objects", "--all"], wt);
232
+ if (raw === null) return null;
233
+ const reachable = new Set();
234
+ for (const line of raw.split("\n")) {
235
+ const space = line.indexOf(" ");
236
+ const id = space === -1 ? line : line.slice(0, space);
237
+ if (wanted.has(id)) reachable.add(id);
238
+ }
239
+ return reachable;
240
+ }
241
+
242
+ /**
243
+ * Resolve the worktree's identity: its branch, the primary checkout, and
244
+ * whether the path is a linked worktree at all.
245
+ * @param {string} target - Path to inspect.
246
+ * @returns {{worktree: string, primary: string, common: string, branch: string, linked: boolean} | null} Identity, or `null` when not a repository.
247
+ */
248
+ function identify(target) {
249
+ const top = gitOrNull(["rev-parse", "--show-toplevel"], target)?.trim();
250
+ if (!top) return null;
251
+ const gitDir = gitOrNull(["rev-parse", "--absolute-git-dir"], target)?.trim();
252
+ const commonRaw = gitOrNull(
253
+ ["rev-parse", "--git-common-dir"],
254
+ target
255
+ )?.trim();
256
+ if (!gitDir || !commonRaw) return null;
257
+ const common = path.resolve(top, commonRaw);
258
+ const listed = gitOrNull(["worktree", "list", "--porcelain"], target) ?? "";
259
+ const first = listed.split("\n")[0] ?? "";
260
+ return {
261
+ worktree: top,
262
+ primary: first.startsWith("worktree ")
263
+ ? first.slice(9)
264
+ : path.dirname(common),
265
+ common,
266
+ branch:
267
+ gitOrNull(["rev-parse", "--abbrev-ref", "HEAD"], target)?.trim() ?? "",
268
+ linked: path.resolve(gitDir) !== common,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Classify a worktree for removal safety.
274
+ * @param {string} target - Path to inspect.
275
+ * @returns {{ok: boolean, reason: string, identity: object | null, atRisk: object[]}} Verdict.
276
+ */
277
+ export function classifyWorktree(target) {
278
+ const identity = identify(target);
279
+ if (!identity) {
280
+ return { ok: false, reason: NOT_A_WORKTREE, identity: null, atRisk: [] };
281
+ }
282
+ if (!identity.linked) {
283
+ return { ok: false, reason: PRIMARY_CHECKOUT, identity, atRisk: [] };
284
+ }
285
+ const blobs = candidateBlobs(identity.worktree);
286
+ if (blobs === null) {
287
+ return { ok: false, reason: UNREADABLE, identity, atRisk: [] };
288
+ }
289
+ const reachable = reachableBlobs(
290
+ identity.worktree,
291
+ new Set(blobs.map(entry => entry.blob))
292
+ );
293
+ if (reachable === null) {
294
+ return { ok: false, reason: UNREADABLE, identity, atRisk: blobs };
295
+ }
296
+ const atRisk = blobs.filter(entry => !reachable.has(entry.blob));
297
+ return {
298
+ ok: atRisk.length === 0,
299
+ reason: atRisk.length === 0 ? "safe" : "work-at-risk",
300
+ identity,
301
+ atRisk,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Append one line to the removal ledger in the shared git directory.
307
+ *
308
+ * Best-effort by construction: a ledger that cannot be written must not turn a
309
+ * safe removal into a failure, and must not turn a refusal into a pass.
310
+ * @param {string} common - Absolute path to the common git directory.
311
+ * @param {object} row - The record to append.
312
+ * @returns {string | null} Ledger path when written, else `null`.
313
+ */
314
+ export function recordRemoval(common, row) {
315
+ const file = path.join(common, LEDGER);
316
+ try {
317
+ appendFileSync(file, `${JSON.stringify(row)}\n`, "utf8");
318
+ return file;
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Human-readable refusal, naming every file that exists nowhere else.
326
+ * @param {object} verdict - Result of `classifyWorktree`.
327
+ * @returns {string} Message for stderr.
328
+ */
329
+ function refusalMessage(verdict) {
330
+ const lines = verdict.atRisk.map(
331
+ entry => ` ${entry.source.padEnd(8)} ${entry.path} (blob ${entry.blob})`
332
+ );
333
+ return [
334
+ `REFUSED: ${verdict.identity.worktree}`,
335
+ `${verdict.atRisk.length} file(s) here exist in no commit, in this repository or any other:`,
336
+ ...lines,
337
+ "",
338
+ "A worktree's index and working files are per-worktree; only commits are shared.",
339
+ "Deleting this directory destroys the bytes above and leaves no trace that it did.",
340
+ "Commit them (they need no push to survive), or re-run with --force to delete them",
341
+ `anyway — an override is recorded in ${LEDGER}.`,
342
+ ].join("\n");
343
+ }
344
+
345
+ /**
346
+ * Reasons that describe a broken request rather than work at risk.
347
+ * @param {string} reason - Verdict reason.
348
+ * @returns {string} Operator-readable explanation.
349
+ */
350
+ function hardFailure(reason) {
351
+ if (reason === NOT_A_WORKTREE) return "not a git worktree";
352
+ if (reason === PRIMARY_CHECKOUT) {
353
+ return "refusing to touch the primary checkout (no override exists)";
354
+ }
355
+ return "could not read the repository's objects; refusing fail-closed";
356
+ }
357
+
358
+ /**
359
+ * Parse argv into a verb, a target and flags.
360
+ * @param {string[]} argv - Arguments after the script name.
361
+ * @returns {{verb: string, target: string, force: boolean, json: boolean}} Options.
362
+ */
363
+ function parseArgs(argv) {
364
+ const rest = argv.filter(arg => !arg.startsWith("--"));
365
+ const verb = rest[0] === "remove" || rest[0] === "check" ? rest[0] : "check";
366
+ const positional = rest[0] === verb && rest.length > 1 ? rest[1] : rest[0];
367
+ return {
368
+ verb,
369
+ target: positional ?? "",
370
+ force: argv.includes("--force") || process.env[OVERRIDE_ENV] === "1",
371
+ json: argv.includes("--json"),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Entry point.
377
+ * @param {string[]} argv - Arguments after the script name.
378
+ * @param {{out?: NodeJS.WritableStream, err?: NodeJS.WritableStream}} [io] - Streams.
379
+ * @returns {number} Process exit code.
380
+ */
381
+ export function main(argv, io = {}) {
382
+ const out = io.out ?? process.stdout;
383
+ const err = io.err ?? process.stderr;
384
+ const opts = parseArgs(argv);
385
+ if (!opts.target) {
386
+ err.write(
387
+ "usage: lisa-worktree-guard [check|remove] <worktree> [--force]\n"
388
+ );
389
+ return ERROR;
390
+ }
391
+ const verdict = classifyWorktree(opts.target);
392
+ if (opts.json) out.write(`${JSON.stringify(verdict)}\n`);
393
+ if (!verdict.identity || verdict.reason === PRIMARY_CHECKOUT) {
394
+ err.write(`${hardFailure(verdict.reason)}\n`);
395
+ return ERROR;
396
+ }
397
+ return finish(verdict, opts, out, err);
398
+ }
399
+
400
+ /**
401
+ * Apply the verdict: refuse, record an override, or remove.
402
+ * @param {object} verdict - Result of `classifyWorktree`.
403
+ * @param {object} opts - Parsed options.
404
+ * @param {NodeJS.WritableStream} out - Stdout.
405
+ * @param {NodeJS.WritableStream} err - Stderr.
406
+ * @returns {number} Process exit code.
407
+ */
408
+ function finish(verdict, opts, out, err) {
409
+ const { identity } = verdict;
410
+ const base = {
411
+ at: new Date().toISOString(),
412
+ worktree: identity.worktree,
413
+ branch: identity.branch,
414
+ verb: opts.verb,
415
+ atRisk: verdict.atRisk,
416
+ };
417
+ if (!verdict.ok && !opts.force) {
418
+ recordRemoval(identity.common, { ...base, action: "refused" });
419
+ if (verdict.reason === UNREADABLE) {
420
+ err.write(`${hardFailure(verdict.reason)}\n`);
421
+ return REFUSED;
422
+ }
423
+ err.write(`${refusalMessage(verdict)}\n`);
424
+ return REFUSED;
425
+ }
426
+ const action = verdict.ok ? "removed" : "override";
427
+ if (opts.verb === "check") {
428
+ if (!verdict.ok) recordRemoval(identity.common, { ...base, action });
429
+ return OK;
430
+ }
431
+ return remove(identity, { ...base, action }, out, err);
432
+ }
433
+
434
+ /**
435
+ * Remove the worktree and record it.
436
+ * @param {object} identity - Worktree identity.
437
+ * @param {object} row - Ledger row.
438
+ * @param {NodeJS.WritableStream} out - Stdout.
439
+ * @param {NodeJS.WritableStream} err - Stderr.
440
+ * @returns {number} Process exit code.
441
+ */
442
+ function remove(identity, row, out, err) {
443
+ recordRemoval(identity.common, row);
444
+ try {
445
+ git(["worktree", "remove", "--force", identity.worktree], identity.primary);
446
+ } catch (error) {
447
+ err.write(`error: git worktree remove failed: ${error.message}\n`);
448
+ return ERROR;
449
+ }
450
+ out.write(`removed ${identity.worktree}\n`);
451
+ return OK;
452
+ }
453
+
454
+ if (invokedAsScript(import.meta.url)) {
455
+ process.exitCode = main(process.argv.slice(2));
456
+ }
@@ -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,CAykCjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA4+BjC,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,CAqlCjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAw/BjC,CAAC"}
@@ -426,11 +426,14 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
426
426
  "99422b4912cd660ddeb5ec5ab9fc333e57fa5763e711117a00b06dd2eda0bbb7",
427
427
  ]),
428
428
  "scripts/lisa-cross-worktree-guard.mjs": Object.freeze([
429
+ "2ca521021c535be700caaaf4755a54363f685a9a85e435624833cb838a072c5d",
429
430
  "3967e4eb189c043e8702ca5cff40c4612ed77027a053e23635eeb367a436cb9d",
430
431
  "41b92e1e94a8ee9ad0235359dbd83566a49b17db60da933835057dc77008ab87",
431
432
  "592aed14ae21fea08be73497f5cb5d8c94a0a1915792632265fe2717a8817176",
432
433
  "d8625e2d339ac512350203e29e70f7b20546bf5248cf952aafd997389feb230f",
434
+ "da6ec451ca6ea7d890874f8f54bc56f41e541a1190bb170a5167ea104637aba6",
433
435
  "e0ef99ef1df32f76601a2106a2b68b3c31a126e0b48e8b8ceaba1a7476c32900",
436
+ "e3422f6177b6d6eb5509f17673ff8e10e7ed91bf59dd1630e9c5f77d2b56f327",
434
437
  ]),
435
438
  "scripts/lisa-destructive-guard.mjs": Object.freeze([
436
439
  "4fb431a3c58f443e303d613934d023d9e95ddae5c42710bb7009f7c080c82a64",
@@ -740,9 +743,11 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
740
743
  "1d4fbe135f1bb4e861d286a6512ed201d4522250dfe3f9eba6584bc5ff166a95",
741
744
  "2b1ca7d317dea3af106df1a74b1718e3df9c18e76e9d9d769aa3271b7abf8a3f",
742
745
  "2bdba97c9a1ccc0e70247ee02b5eff02c042aba83b8e7fed3dfbf29a8a83b394",
746
+ "2da3ad6d7680b61e20b48022e57b8960e063266cb26c197c2c8daa89adf4b648",
743
747
  "388cf8847581d96a1ff8ea98290a33a5f96d67cdd502ea807a0b8d88efe90e19",
744
748
  "4242b39169bb05aaac4113569125a2c3a351c5dde2d12fd7dacc303dd9a2b5a4",
745
749
  "450338257697a75a8fce9f06f3fc00bf3417947e50d601794da41f9c520a7cc3",
750
+ "57051a1913665b34acc342002d799c703258650a19c9b9894e1911ddf3bd519f",
746
751
  "5ef7c10bee0110c4af1e429fac01332433102c495ec467966ddd7b1494dc899d",
747
752
  "64546813f168fb446f39e34bc674fd3af85b15bd5e7f4f6bf4503463e701a7b9",
748
753
  "74b5b90fcf15dbeb14a69677cf74e339154421cd056fdaf603d1167b34a74105",
@@ -758,6 +763,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
758
763
  "a211f1a06aecddb841851463da302f694ab0eafff6bab75ee944b0a58aac431d",
759
764
  "a22fd1b7c483f1a538202353ee85c82be17a8910e03c0a930635ce8cb3595c64",
760
765
  "a28779ba2e6e4e999014d50734ea0b7e36af886bdb08d144bd4274acd642c902",
766
+ "afe82473fe2a08af298997cbce62417fdaefa37f4a963f720ed38b992a65b8d3",
761
767
  "b5505f55a5092f1d451ae5a01ada85a031e5b063d08fc0ce5527d94b67191a60",
762
768
  "b6fbbda29d897a7c39cf0524c91d5c66a47054df32e036f15cdabefdc34ac72a",
763
769
  "b83c5043a0f29b2d8851258acfe6906aab7922cd80f486e25220e55f26a8c0a5",
@@ -1019,6 +1025,12 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
1019
1025
  "fc0d92739e620d8fd70f7aa4a12d3e0b3a8f790d47c4a8322040f32c30b9445a",
1020
1026
  "fe064a2bd11200ed256fb36e203978f858fccbcaf40708496c066383345900ef",
1021
1027
  ]),
1028
+ "scripts/lisa-worktree-guard.mjs": Object.freeze([
1029
+ "2535ef2a60b3413dacb8491c008ab28f0a07929196277d1d1fed54a04740c025",
1030
+ "71b67f940338e66864eec970b6881aba639176c9d262ab0ecf7203006d9f7cbf",
1031
+ "80ff38d47bdf125526bdbf4f41c1df6154baff2834dad4ec5a11a3dd3df5747e",
1032
+ "ebcea40e1484501f257128c9c92b8a3e2c4cefaeae397b96d267ef878d962da4",
1033
+ ]),
1022
1034
  "scripts/nightly-e2e-github-tracking.mjs": Object.freeze([
1023
1035
  "2606caa9d31b2488aa7e468e584c9f2afda8581fa1941f4da9ec8b3cab09c1de",
1024
1036
  "6fc4edbb8707a90ee878567d88f05dd2d7c2df062f81983c5419c8d4036b203f",
@@ -1516,11 +1528,14 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1516
1528
  "5d587fd849cef02e9706d084ac2b508f3885da0ba59b76311e3b666f4fbff01e",
1517
1529
  ]),
1518
1530
  "scripts/lisa-cross-worktree-guard.mjs": Object.freeze([
1531
+ "2ca521021c535be700caaaf4755a54363f685a9a85e435624833cb838a072c5d",
1519
1532
  "3967e4eb189c043e8702ca5cff40c4612ed77027a053e23635eeb367a436cb9d",
1520
1533
  "41b92e1e94a8ee9ad0235359dbd83566a49b17db60da933835057dc77008ab87",
1521
1534
  "592aed14ae21fea08be73497f5cb5d8c94a0a1915792632265fe2717a8817176",
1522
1535
  "d8625e2d339ac512350203e29e70f7b20546bf5248cf952aafd997389feb230f",
1536
+ "da6ec451ca6ea7d890874f8f54bc56f41e541a1190bb170a5167ea104637aba6",
1523
1537
  "e0ef99ef1df32f76601a2106a2b68b3c31a126e0b48e8b8ceaba1a7476c32900",
1538
+ "e3422f6177b6d6eb5509f17673ff8e10e7ed91bf59dd1630e9c5f77d2b56f327",
1524
1539
  ]),
1525
1540
  "scripts/lisa-destructive-guard.mjs": Object.freeze([
1526
1541
  "4fb431a3c58f443e303d613934d023d9e95ddae5c42710bb7009f7c080c82a64",
@@ -1785,9 +1800,11 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1785
1800
  "1bf3512bb94923a6cb25b1e7a619a3fc8e1336bffa16f499b459aaee3eaed66d",
1786
1801
  "1d4fbe135f1bb4e861d286a6512ed201d4522250dfe3f9eba6584bc5ff166a95",
1787
1802
  "2bdba97c9a1ccc0e70247ee02b5eff02c042aba83b8e7fed3dfbf29a8a83b394",
1803
+ "2da3ad6d7680b61e20b48022e57b8960e063266cb26c197c2c8daa89adf4b648",
1788
1804
  "388cf8847581d96a1ff8ea98290a33a5f96d67cdd502ea807a0b8d88efe90e19",
1789
1805
  "4242b39169bb05aaac4113569125a2c3a351c5dde2d12fd7dacc303dd9a2b5a4",
1790
1806
  "450338257697a75a8fce9f06f3fc00bf3417947e50d601794da41f9c520a7cc3",
1807
+ "57051a1913665b34acc342002d799c703258650a19c9b9894e1911ddf3bd519f",
1791
1808
  "5ef7c10bee0110c4af1e429fac01332433102c495ec467966ddd7b1494dc899d",
1792
1809
  "64546813f168fb446f39e34bc674fd3af85b15bd5e7f4f6bf4503463e701a7b9",
1793
1810
  "74b5b90fcf15dbeb14a69677cf74e339154421cd056fdaf603d1167b34a74105",
@@ -1803,6 +1820,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1803
1820
  "a211f1a06aecddb841851463da302f694ab0eafff6bab75ee944b0a58aac431d",
1804
1821
  "a22fd1b7c483f1a538202353ee85c82be17a8910e03c0a930635ce8cb3595c64",
1805
1822
  "a28779ba2e6e4e999014d50734ea0b7e36af886bdb08d144bd4274acd642c902",
1823
+ "afe82473fe2a08af298997cbce62417fdaefa37f4a963f720ed38b992a65b8d3",
1806
1824
  "b5505f55a5092f1d451ae5a01ada85a031e5b063d08fc0ce5527d94b67191a60",
1807
1825
  "b6fbbda29d897a7c39cf0524c91d5c66a47054df32e036f15cdabefdc34ac72a",
1808
1826
  "b83c5043a0f29b2d8851258acfe6906aab7922cd80f486e25220e55f26a8c0a5",
@@ -2040,6 +2058,12 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
2040
2058
  "fc0d92739e620d8fd70f7aa4a12d3e0b3a8f790d47c4a8322040f32c30b9445a",
2041
2059
  "fe064a2bd11200ed256fb36e203978f858fccbcaf40708496c066383345900ef",
2042
2060
  ]),
2061
+ "scripts/lisa-worktree-guard.mjs": Object.freeze([
2062
+ "2535ef2a60b3413dacb8491c008ab28f0a07929196277d1d1fed54a04740c025",
2063
+ "71b67f940338e66864eec970b6881aba639176c9d262ab0ecf7203006d9f7cbf",
2064
+ "80ff38d47bdf125526bdbf4f41c1df6154baff2834dad4ec5a11a3dd3df5747e",
2065
+ "ebcea40e1484501f257128c9c92b8a3e2c4cefaeae397b96d267ef878d962da4",
2066
+ ]),
2043
2067
  "scripts/nightly-e2e-github-tracking.mjs": Object.freeze([
2044
2068
  "2606caa9d31b2488aa7e468e584c9f2afda8581fa1941f4da9ec8b3cab09c1de",
2045
2069
  "6fc4edbb8707a90ee878567d88f05dd2d7c2df062f81983c5419c8d4036b203f",