@codyswann/lisa 4.45.4 → 4.45.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 (81) 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-hooks/worktree-binding-guard.mjs +115 -23
  4. package/all/copy-overwrite/scripts/lisa-worktree-guard.mjs +456 -0
  5. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  6. package/dist/core/lisa-owned-hash-ledger.js +28 -0
  7. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  8. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  9. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.js +21 -10
  11. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  12. package/dist/opencode/plugin-templates/parity-safety-net.sh +74 -0
  13. package/package.json +5 -4
  14. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/hooks/cleanup-stale-worktrees.sh +42 -1
  17. package/plugins/lisa/hooks/parity-safety-net.sh +74 -0
  18. package/plugins/lisa/hooks/worktree-binding-guard.mjs +115 -23
  19. package/plugins/lisa-agy/hooks/parity-safety-net.sh +74 -0
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  24. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/hooks/cleanup-stale-worktrees.sh +42 -1
  28. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +74 -0
  29. package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +115 -23
  30. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cursor/hooks/cleanup-stale-worktrees.sh +42 -1
  32. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +74 -0
  33. package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +115 -23
  34. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo-agy/plugin.json +1 -1
  37. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  57. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails-agy/plugin.json +1 -1
  62. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  67. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  72. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/src/base/hooks/cleanup-stale-worktrees.sh +42 -1
  75. package/plugins/src/base/hooks/parity-safety-net.sh +74 -0
  76. package/plugins/src/base/hooks/worktree-binding-guard.mjs +115 -23
  77. package/scripts/check-cwd-resolution-corpus.mjs +163 -0
  78. package/scripts/cleanup-worktrees.sh +36 -3
  79. package/scripts/two-channel-couplings.json +1 -1
  80. package/typescript/copy-overwrite/.prettierignore +1 -0
  81. package/typescript/create-only/.github/workflows/review-evidence.yml +20 -1
@@ -2030,6 +2030,77 @@ readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$
2030
2030
  # that could not run produced no lines, the loop body never executed, and the
2031
2031
  # guard was skipped with the command ALLOWED. A segmentation that cannot answer
2032
2032
  # must refuse, exactly like a scan that cannot answer.
2033
+ # 14b. Linked worktrees, which are the same principle one level out. `.git` is
2034
+ # protected because nothing in the working tree can rebuild it; a linked
2035
+ # worktree is protected because nothing OUTSIDE it can rebuild what it holds.
2036
+ # The asymmetry is exact and it is invisible from the prompt: `.git/refs` and
2037
+ # the object store are shared by every worktree of a repository, while
2038
+ # `.git/index` and the working files are per-worktree. So `git commit` moves
2039
+ # work somewhere a deletion cannot reach, and `git add` does not — a staged file
2040
+ # shows green in `git status`, which reads as saved, and dies with the
2041
+ # directory.
2042
+ #
2043
+ # Measured twice in one evening (issue #3863): one worktree lost a 284-line
2044
+ # staged test file, and a second lost nothing from the identical event purely
2045
+ # because a gate had forced its work into a commit first. Neither deletion
2046
+ # announced itself. That is the second reason this is a block rather than a
2047
+ # warning — a worktree deletion destroys the only evidence its own case could
2048
+ # leave, so there is no after-the-fact detection to fall back on.
2049
+ #
2050
+ # `git worktree remove` (guard 12) already refuses a dirty tree, and
2051
+ # `--force` is already blocked. This closes the spelling that walks around both:
2052
+ # `rm -rf` on the directory, which git never sees.
2053
+ #
2054
+ # Matching is by REGISTERED path — `git worktree list` names them — so an
2055
+ # ordinary directory that merely lives near one is untouched. A repository that
2056
+ # cannot be read answers "no": this guard adds protection to a shape that was
2057
+ # fully allowed a moment ago, and failing it closed would deny every recursive
2058
+ # delete on every path outside a repository.
2059
+ #
2060
+ # The comparison is on WHOLE TOKENS, which is the only spelling that gets both
2061
+ # error directions right. Substring containment would let a worktree named
2062
+ # `wt-1` claim `rm -rf wt-10`, an over-block on an unrelated directory; and
2063
+ # hand-written boundary classes leak the other way, because `rm -rf <wt>/`
2064
+ # with one trailing slash ends on a character every such class treats as
2065
+ # "inside a path". So every non-path character — quote, space, parenthesis,
2066
+ # redirection — is folded to a space first, which makes each argument a
2067
+ # space-delimited token regardless of how it was quoted, and the match is then
2068
+ # an equality test on that token. A SUBPATH (`rm -rf <wt>/node_modules`) is
2069
+ # deliberately not this guard's business: it is not a worktree deletion, and
2070
+ # blocking routine cleanup inside a worktree is how a guard gets switched off.
2071
+ names_linked_worktree() {
2072
+ local stmt="$1" probe root listed line primary wt rel
2073
+ root="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> /dev/null)" || return 1
2074
+ [ -n "$root" ] || return 1
2075
+ listed="$(git -C "$root" worktree list --porcelain 2> /dev/null)" || return 1
2076
+ probe=" $(printf '%s' "$stmt" | tr -c 'A-Za-z0-9_./-' ' ') "
2077
+ primary=""
2078
+ while IFS= read -r line; do
2079
+ case "$line" in
2080
+ worktree\ *) wt="${line#worktree }" ;;
2081
+ *) continue ;;
2082
+ esac
2083
+ # First entry is the primary checkout, which is not a linked worktree.
2084
+ if [ -z "$primary" ]; then
2085
+ primary="$wt"
2086
+ continue
2087
+ fi
2088
+ case "$probe" in
2089
+ *" $wt "* | *" $wt/ "*) return 0 ;;
2090
+ esac
2091
+ rel="${wt#"$primary"/}"
2092
+ [ "$rel" != "$wt" ] || continue
2093
+ # `./` is the same path spelled the way an agent in the project root types
2094
+ # it, and a token comparison is exact, so it has to be spelled here too.
2095
+ case "$probe" in
2096
+ *" $rel "* | *" $rel/ "* | *" ./$rel "* | *" ./$rel/ "*) return 0 ;;
2097
+ esac
2098
+ done <<EOF
2099
+ $listed
2100
+ EOF
2101
+ return 1
2102
+ }
2103
+
2033
2104
  git_segments_status=0
2034
2105
  git_segments="$(printf '%s' "$normalized_command_str" \
2035
2106
  | awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
@@ -2052,6 +2123,9 @@ while IFS= read -r git_stmt; do
2052
2123
  if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
2053
2124
  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."
2054
2125
  fi
2126
+ if names_linked_worktree "$git_stmt"; then
2127
+ 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."
2128
+ fi
2055
2129
  done <<<"$git_segments"
2056
2130
 
2057
2131
  # 15. Credential stores. Reading one into an agent transcript is disclosure even
@@ -304,6 +304,11 @@ function foreignTreeIn(text, bound, cwd) {
304
304
  for (const raw of code.match(/[^\s'";|&()<>]+/gu) ?? []) {
305
305
  const token = raw.replace(/^["']|["']$/gu, "");
306
306
  if (!token.includes("/")) continue;
307
+ // A null base means an unliteral `cd` left the directory this script runs
308
+ // from unknown. An ABSOLUTE token is unambiguous either way; a relative one
309
+ // is exactly the thing that cannot be placed, so it is skipped rather than
310
+ // resolved against a directory picked for having been handy.
311
+ if (!isAbsolute(token) && cwd === null) continue;
307
312
  const candidate = isAbsolute(token) ? token : join(cwd, token);
308
313
  let root;
309
314
  try {
@@ -317,6 +322,63 @@ function foreignTreeIn(text, bound, cwd) {
317
322
  return null;
318
323
  }
319
324
 
325
+ /**
326
+ * A `cd` target this guard cannot turn into a literal directory.
327
+ *
328
+ * Mirrors `parity-safety-net.sh`'s `follow_cd` test exactly, and the mirror is
329
+ * the point: two spellings of one rule are how the guards drift.
330
+ */
331
+ const UNLITERAL_CD_TARGET = /[$`*?[{]/u;
332
+
333
+ /**
334
+ * The literal target of a leading `cd`, `""` for a bare `cd`, or null when the
335
+ * segment does not change directory.
336
+ *
337
+ * A bare `cd` is `cd "$HOME"`. Leaving the previous directory in place for it
338
+ * would resolve later tokens from a directory the shell has already left.
339
+ * @param segment - One statement from the command
340
+ * @returns The target text, or null when this segment is not a `cd`
341
+ */
342
+ function cdTarget(segment) {
343
+ const tokens = tokenise(segment);
344
+ if (tokens.length === 0 || tokens[0] !== "cd") return null;
345
+ return tokens[1] ?? "";
346
+ }
347
+
348
+ /**
349
+ * Apply one `cd` to the directory currently in force.
350
+ *
351
+ * Contract points 1-4 of `cwd-resolution-corpus.json`: a literal target
352
+ * REPLACES the directory rather than joining a list of candidates — the
353
+ * ordering was #3933's entire defect; a relative target composes against the
354
+ * directory in force; a target that is not literal text yields unknown; and
355
+ * unknown is sticky only until the next resolvable `cd`, because this returns
356
+ * a fresh answer for each one.
357
+ * @param current - Directory in force, or null when it is already unknown
358
+ * @param target - Literal `cd` target, `""` for a bare `cd`
359
+ * @returns The new directory, or null when it cannot be known
360
+ */
361
+ function applyCd(current, target) {
362
+ const home = homedir();
363
+ let value = target === "" ? home : target;
364
+ if (value === "~") value = home;
365
+ else if (value.startsWith("~/")) value = join(home, value.slice(2));
366
+ if (
367
+ value === "" ||
368
+ value.startsWith("-") ||
369
+ UNLITERAL_CD_TARGET.test(value)
370
+ ) {
371
+ return null;
372
+ }
373
+ if (!isAbsolute(value) && current === null) return null;
374
+ const next = isAbsolute(value) ? resolve(value) : resolve(current, value);
375
+ try {
376
+ return statSync(next).isDirectory() ? next : null;
377
+ } catch {
378
+ return null;
379
+ }
380
+ }
381
+
320
382
  /**
321
383
  * The foreign worktree an executed script reaches, or null.
322
384
  *
@@ -340,41 +402,71 @@ function foreignTreeIn(text, bound, cwd) {
340
402
  * disagreeing. The scripted form is the one neither covers, and it is the only
341
403
  * cell this arm fills.
342
404
  *
343
- * ## A relative script path is resolved against `payload.cwd`, and that is a
344
- * KNOWN limitation shared with `parity-safety-net.sh`
405
+ * ## A relative script path is resolved against the cwd the command will have
406
+ *
407
+ * A leading `cd` establishes the directory this guard resolves relative tokens
408
+ * from, because that is the directory the shell will be in once the command
409
+ * runs. Resolving against `payload.cwd` instead was CodySwannGT/lisa#3933's
410
+ * defect, inherited here knowingly and fixed in CodySwannGT/lisa#3952: on a
411
+ * machine running many worktrees of one repository, every tree holds its own
412
+ * copy of every script at the same relative path, and they differ by whatever
413
+ * each lane is doing.
345
414
  *
346
- * CodySwannGT/lisa#3933, filed against that guard, is the same defect: a
347
- * relative token is resolved against the hook process's working directory, not
348
- * against the directory the command will have once it runs its own leading
349
- * `cd`. So `cd <other tree> && bash scripts/foo.sh` reads THIS tree's copy of
350
- * `scripts/foo.sh` and reports on it with full confidence.
415
+ * ## One contract, two implementations, and why that is the honest answer
351
416
  *
352
- * It matters here for the same reason it matters there: on a machine running
353
- * many worktrees of one repository, every tree holds its own copy of every
354
- * script at the same relative path, and they differ by whatever each lane is
355
- * doing. A confident scan of the wrong copy is wrong in both directions, and
356
- * the silent one — the copy that will run reaches out, the copy that was read
357
- * does not — is the direction this arm exists to prevent.
417
+ * `parity-safety-net.sh` answers this same question — "which file will this
418
+ * command actually execute?" — in POSIX shell, where `follow_cd` mutates
419
+ * file-scope state that `follow_locate` reads. This guard is Node. **There is
420
+ * no call that reaches from here into a shell function's file-scope state**, so
421
+ * the duplication is not a factoring somebody skipped; it is one idea that
422
+ * cannot cross a language boundary as a function call.
358
423
  *
359
- * NOT fixed here on purpose. #3933 is assigned and its options (honour a
360
- * leading `cd`, or fail closed when the effective cwd is uncertain) belong in
361
- * one place rather than two: a second implementation of the same resolution is
362
- * how two guards drift into disagreeing about which file a command runs.
363
- * When that lands, this call site should adopt whatever it produces.
424
+ * What binds the two is a written contract and a corpus of rows that BOTH
425
+ * guards are driven over in CI, so a divergence fails a test on the commit that
426
+ * causes it rather than surfacing later as a wrong verdict. Contract and corpus
427
+ * live in `tests/unit/hooks/support/cwd-resolution-corpus.json`.
364
428
  *
365
- * The exposure here is narrower than there. This arm is reached only after the
366
- * session has been confirmed to be in its bound worktree, so the common case
367
- * is a relative path resolving inside the tree the session is actually in.
429
+ * This is explicitly NOT unification, and CodySwannGT/lisa#3952 records it as
430
+ * such: two implementations remain. **A behaviour with no row in the corpus is
431
+ * free to diverge — add a row when you add a branch.**
432
+ *
433
+ * ## The two guards agree on the RESOLUTION, not on the verdict
434
+ *
435
+ * When the effective directory cannot be known, both guards decline to resolve
436
+ * the token. What they then do differs, and that difference is deliberate:
437
+ * `parity-safety-net.sh` refuses, because a destructive command it cannot see
438
+ * is the thing it exists to stop. This guard fails open, which is the doctrine
439
+ * every other undecidable case in this file follows. The corpus asserts the
440
+ * resolved path, not the exit code, precisely so that one contract can bind two
441
+ * guards whose fail directions are correctly different.
368
442
  *
369
443
  * Fails OPEN and says so, like every other undecidable case in this file.
370
444
  */
371
445
  function scriptReachingForeignTree(payload, bound) {
372
446
  const command = payload.tool_input?.command;
373
447
  if (typeof command !== "string" || !command) return null;
448
+ let cwd = payload.cwd;
374
449
  for (const segment of command.split(COMMAND_SEPARATORS)) {
450
+ const target = cdTarget(segment);
451
+ if (target !== null) {
452
+ cwd = applyCd(cwd, target);
453
+ continue;
454
+ }
375
455
  const named = executedPath(segment);
376
456
  if (!named) continue;
377
- const path = isAbsolute(named) ? named : join(payload.cwd, named);
457
+ if (!isAbsolute(named) && cwd === null) {
458
+ // Contract point 3: the effective directory is unknown, so WHICH copy of
459
+ // a same-named script would run is unknown too. Declining to resolve is
460
+ // the half shared with parity-safety-net.sh; failing open rather than
461
+ // refusing is this guard's own doctrine, and the corpus binds the two
462
+ // guards on the resolution precisely so it does not have to bind them
463
+ // on a verdict one of them would be wrong to reach.
464
+ say(
465
+ `cannot tell which directory ${named} would run from; NOT enforcing on it`
466
+ );
467
+ continue;
468
+ }
469
+ const path = isAbsolute(named) ? resolve(named) : resolve(cwd, named);
378
470
  let text;
379
471
  try {
380
472
  if (statSync(path).size > SCRIPT_READ_LIMIT) {
@@ -386,7 +478,7 @@ function scriptReachingForeignTree(payload, bound) {
386
478
  // Not a readable file: nothing was executed that this can read.
387
479
  continue;
388
480
  }
389
- const foreign = foreignTreeIn(text, bound, payload.cwd);
481
+ const foreign = foreignTreeIn(text, bound, cwd);
390
482
  if (foreign) return { script: path, foreign };
391
483
  }
392
484
  return null;
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Refuse a cwd-resolution corpus that cannot bite.
4
+ *
5
+ * Two guards answer "which file will this command actually execute?" in two
6
+ * languages — POSIX shell in `parity-safety-net.sh`, Node in
7
+ * `worktree-binding-guard.mjs` — and one corpus of rows is what keeps their
8
+ * answers the same (CodySwannGT/lisa#3952). That arrangement is only worth
9
+ * anything while the rows are adversarial, and rows decay in one direction: a
10
+ * `holds` row is easy to write and always passes, so a corpus left alone drifts
11
+ * towards proving the resolver RAN rather than that it BITES.
12
+ *
13
+ * So the shape is enforced rather than requested:
14
+ *
15
+ * - both adversarial classes present, because a resolver is wrong in two
16
+ * directions and one class of row only ever catches one of them
17
+ * - every contract point carried by at least one adversarial row, because a
18
+ * point pinned only by rows both wrong implementations already pass is
19
+ * documented rather than pinned
20
+ * - at least one `unknown` expectation, because the fail-closed floor is the
21
+ * half nobody writes voluntarily
22
+ * - a row that skips a guard must say why, in the row, rather than by omission
23
+ *
24
+ * Exits 1 with the reason on any violation. Report-only otherwise.
25
+ * @module scripts/check-cwd-resolution-corpus
26
+ */
27
+ import { readFileSync } from "node:fs";
28
+ import path from "node:path";
29
+
30
+ const CORPUS = path.resolve(
31
+ "tests/unit/hooks/support/cwd-resolution-corpus.json"
32
+ );
33
+ const GUARDS = ["shell", "node"];
34
+ const CONTROLS = ["bites", "wall", "holds"];
35
+ /** The classes that fail under a wrong implementation. `holds` does not. */
36
+ const ADVERSARIAL = ["bites", "wall"];
37
+
38
+ /**
39
+ * Read and parse the corpus.
40
+ * @returns The parsed corpus document
41
+ */
42
+ function load() {
43
+ return JSON.parse(readFileSync(CORPUS, "utf8"));
44
+ }
45
+
46
+ /**
47
+ * Every id appears once, so a row cannot be silently replaced by a copy.
48
+ * @param rows - Corpus rows
49
+ * @returns Violation messages
50
+ */
51
+ function checkIds(rows) {
52
+ const seen = new Set();
53
+ const bad = [];
54
+ for (const row of rows) {
55
+ if (seen.has(row.id)) bad.push(`duplicate row id: ${row.id}`);
56
+ seen.add(row.id);
57
+ }
58
+ return bad;
59
+ }
60
+
61
+ /**
62
+ * Each row declares a known control class and at least one guard it applies to.
63
+ * @param rows - Corpus rows
64
+ * @returns Violation messages
65
+ */
66
+ function checkShape(rows) {
67
+ const bad = [];
68
+ for (const row of rows) {
69
+ if (!CONTROLS.includes(row.control)) {
70
+ bad.push(`${row.id}: control must be one of ${CONTROLS.join(", ")}`);
71
+ }
72
+ const applies = row.applies ?? [];
73
+ if (applies.length === 0) bad.push(`${row.id}: applies to no guard`);
74
+ for (const guard of applies) {
75
+ if (!GUARDS.includes(guard))
76
+ bad.push(`${row.id}: unknown guard ${guard}`);
77
+ }
78
+ if (!applies.includes("node") && !row.why_not_node) {
79
+ bad.push(
80
+ `${row.id}: skips the Node guard without saying why. A row that skips a guard by omission is how a gap becomes invisible — add why_not_node.`
81
+ );
82
+ }
83
+ }
84
+ return bad;
85
+ }
86
+
87
+ /**
88
+ * Both control classes are present, and the fail-closed floor is represented.
89
+ * @param rows - Corpus rows
90
+ * @returns Violation messages
91
+ */
92
+ function checkControls(rows) {
93
+ const bad = [];
94
+ for (const control of ADVERSARIAL) {
95
+ if (rows.some(row => row.control === control)) continue;
96
+ bad.push(
97
+ control === "bites"
98
+ ? 'no row has control "bites". Nothing here fails under a pre-fix implementation, so the silent direction — a confident verdict about a file nobody was about to run — is unguarded.'
99
+ : 'no row has control "wall". Nothing here fails under an over-eager implementation, so nothing stops the fail-closed floor becoming a wall, and a wall gets switched off.'
100
+ );
101
+ }
102
+ if (!rows.some(row => row.expect === "unknown")) {
103
+ bad.push(
104
+ 'no row expects "unknown". The fail-closed floor is the half nobody writes voluntarily, so it is required.'
105
+ );
106
+ }
107
+ return bad;
108
+ }
109
+
110
+ /**
111
+ * Every contract point is carried by at least one row that a pre-fix
112
+ * implementation would fail.
113
+ * @param contract - Contract points, keyed by number
114
+ * @param rows - Corpus rows
115
+ * @returns Violation messages
116
+ */
117
+ function checkCoverage(contract, rows) {
118
+ const bad = [];
119
+ for (const point of Object.keys(contract)) {
120
+ const covering = rows.filter(row =>
121
+ (row.contract ?? []).map(String).includes(point)
122
+ );
123
+ if (covering.length === 0) {
124
+ bad.push(
125
+ `contract point ${point} has no row. Add a row when you add a branch.`
126
+ );
127
+ continue;
128
+ }
129
+ if (!covering.some(row => ADVERSARIAL.includes(row.control))) {
130
+ bad.push(
131
+ `contract point ${point} is carried only by "holds" rows, which both wrong implementations already pass. It is documented, not pinned.`
132
+ );
133
+ }
134
+ }
135
+ return bad;
136
+ }
137
+
138
+ /**
139
+ * Report every violation at once, then exit.
140
+ * @returns Process exit code
141
+ */
142
+ function main() {
143
+ const corpus = load();
144
+ const rows = corpus.rows ?? [];
145
+ const bad = [
146
+ ...checkIds(rows),
147
+ ...checkShape(rows),
148
+ ...checkControls(rows),
149
+ ...checkCoverage(corpus.contract ?? {}, rows),
150
+ ];
151
+ if (bad.length > 0) {
152
+ process.stderr.write("cwd-resolution corpus is not adversarial enough:\n");
153
+ for (const line of bad) process.stderr.write(` - ${line}\n`);
154
+ return 1;
155
+ }
156
+ const biting = rows.filter(row => ADVERSARIAL.includes(row.control)).length;
157
+ process.stdout.write(
158
+ `cwd-resolution corpus: ${rows.length} row(s), ${biting} of which a wrong implementation fails; ${Object.keys(corpus.contract).length} contract point(s) each pinned by at least one.\n`
159
+ );
160
+ return 0;
161
+ }
162
+
163
+ process.exit(main());
@@ -13,8 +13,13 @@
13
13
  # 3. No modified/staged TRACKED files ("real work" is never deleted).
14
14
  # 4. Its HEAD commit is reachable from some remote ref (nothing unpushed).
15
15
  # 5. It is older than --min-age-days (default 7) by directory mtime.
16
+ # 6. Its uncommitted content is reachable from some commit — asked of the
17
+ # bytes, not of the tracking state, by lisa-worktree-guard.
16
18
  # Untracked-only dirt (node_modules, .env.local, build output) blocks
17
- # removal by default; pass --force-untracked to treat it as junk.
19
+ # removal by default; pass --force-untracked to treat it as junk. Even then
20
+ # the content gate stands: --force-untracked means "untracked files are not
21
+ # by themselves work", not "delete bytes that exist in no commit"
22
+ # (CodySwannGT/lisa#3863).
18
23
  #
19
24
  # DRY-RUN BY DEFAULT. Nothing is deleted until you pass --apply.
20
25
  #
@@ -46,7 +51,12 @@ while [ $# -gt 0 ]; do
46
51
  fi
47
52
  MIN_AGE_DAYS="$1" ;;
48
53
  --delete-branches) DELETE_BRANCHES=1 ;;
49
- -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
54
+ # Print the whole leading comment block, not a hardcoded line count. `2,30p`
55
+ # silently stopped covering the last option the moment the header grew by
56
+ # six lines, and nothing reported it — the help text is not asserted
57
+ # anywhere, so the only symptom is an operator who never learns an option
58
+ # exists. Stop at the first non-comment line instead, which cannot drift.
59
+ -h|--help) sed -n '2,${/^[^#]/q;p;}' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
50
60
  *) REPO="$1" ;;
51
61
  esac
52
62
  shift
@@ -61,6 +71,21 @@ fi
61
71
  NOW=$(date +%s)
62
72
  MIN_AGE_SECS=$((MIN_AGE_DAYS * 86400))
63
73
  REMOVED=0 KEPT_DIRTY=0 KEPT_UNPUSHED=0 KEPT_YOUNG=0 KEPT_UNTRACKED=0 ORPHANS=0 ERRORS=0
74
+ KEPT_UNREACHABLE=0
75
+
76
+ # Content-reachability guard. Absent node or an unresolvable script leaves the
77
+ # older gates in charge rather than silently widening what this script deletes.
78
+ GUARD=""
79
+ for candidate in \
80
+ "$REPO/scripts/lisa-worktree-guard.mjs" \
81
+ "$REPO/node_modules/@codyswann/lisa/all/copy-overwrite/scripts/lisa-worktree-guard.mjs" \
82
+ "$REPO/all/copy-overwrite/scripts/lisa-worktree-guard.mjs"; do
83
+ if [ -f "$candidate" ]; then
84
+ GUARD="$candidate"
85
+ break
86
+ fi
87
+ done
88
+ command -v node > /dev/null 2>&1 || GUARD=""
64
89
 
65
90
  log() { printf '%s\n' "$*"; }
66
91
  act() { if [ "$APPLY" = 1 ]; then log "REMOVE $*"; else log "WOULD-REMOVE $*"; fi; }
@@ -149,6 +174,14 @@ process_worktree() {
149
174
  return
150
175
  fi
151
176
 
177
+ # Content gate: bytes that exist in no commit are never junk, whatever
178
+ # --force-untracked says about tracking state.
179
+ if [ -n "$GUARD" ] && ! node "$GUARD" check "$wt" > /dev/null 2>&1; then
180
+ KEPT_UNREACHABLE=$((KEPT_UNREACHABLE + 1))
181
+ log "KEEP (holds content that exists in no commit) $wt"
182
+ return
183
+ fi
184
+
152
185
  local force_flag=()
153
186
  if [ -n "$(git -C "$wt" status --porcelain 2>/dev/null)" ]; then
154
187
  if [ "$FORCE_UNTRACKED" = 1 ]; then
@@ -217,6 +250,6 @@ fi
217
250
  log ""
218
251
  log "=== summary ==="
219
252
  log "removed (or would remove): $REMOVED orphan dirs: $ORPHANS"
220
- log "kept: $KEPT_DIRTY modified-tracked, $KEPT_UNPUSHED unpushed, $KEPT_UNTRACKED untracked-only, $KEPT_YOUNG too-young errors: $ERRORS"
253
+ log "kept: $KEPT_DIRTY modified-tracked, $KEPT_UNPUSHED unpushed, $KEPT_UNTRACKED untracked-only, $KEPT_UNREACHABLE uncommitted-content, $KEPT_YOUNG too-young errors: $ERRORS"
221
254
  [ "$APPLY" = 0 ] && log "(dry run — rerun with --apply to delete)"
222
255
  exit 0
@@ -7,7 +7,7 @@
7
7
  "workflows": 22,
8
8
  "steps": 716,
9
9
  "couplings": 34,
10
- "inventory": 76
10
+ "inventory": 77
11
11
  },
12
12
  "counts": {
13
13
  "package-backed": 10,
@@ -93,6 +93,7 @@ scripts/lisa-run-gates.mjs
93
93
  scripts/lisa-schema-validate.mjs
94
94
  scripts/lisa-test-node.mjs
95
95
  scripts/lisa-work-item.mjs
96
+ scripts/lisa-worktree-guard.mjs
96
97
  scripts/nightly-e2e-provider-action.mjs
97
98
  scripts/nightly-e2e-provider-github-jira.mjs
98
99
  scripts/nightly-e2e-provider-linear-sentry.mjs
@@ -123,7 +123,26 @@ concurrency:
123
123
 
124
124
  jobs:
125
125
  vacuity:
126
- name: 🕵️ Did the required review checks do any work?
126
+ # DECLARATIVE, NOT A QUESTION (#3917). This was `🕵️ Did the required review
127
+ # checks do any work?`, and an unsatisfied verdict exits non-zero, so the
128
+ # row rendered `Did the required review checks do any work? fail`. A yes/no
129
+ # question answered `fail` composes to "NO" — the row asserted that the
130
+ # review checks did no work at exactly the moment the verdict said
131
+ # `UNDETERMINED — ... is NOT reporting that nobody reviewed`. A
132
+ # question-shaped name cannot safely carry more than two outcomes, and the
133
+ # settle-timeout gives this gate a third.
134
+ #
135
+ # AND THE OBVIOUS REPLACEMENT WOULD BE A NEW FALSE CLAIM. `Required review
136
+ # checks did work` reads true on a failure but FALSE on a pass: this job
137
+ # exits 0 for a SATISFIED gate and for a WAIVED one alike, so the pass path
138
+ # would assert work on the very pull requests nothing reviewed. The name
139
+ # therefore describes the GATE and asserts nothing about the review; the
140
+ # three-valued answer is published next door as `🕵️ Review evidence
141
+ # verdict`, which is where it belongs.
142
+ #
143
+ # Only the name changed. Severity, exit codes, the wait, and the verdict
144
+ # vocabulary are untouched.
145
+ name: 🕵️ Review evidence gate
127
146
  runs-on: ubuntu-latest
128
147
  # The guard waits for the declared checks to settle before judging them: a
129
148
  # review bot posts `pending — "Review queued"` and `pending — "Review in