@codyswann/lisa 4.73.1 → 4.73.2

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 (104) hide show
  1. package/all/copy-overwrite/scripts/lib/history-secret-git.mjs +158 -5
  2. package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +25 -1
  3. package/all/copy-overwrite/scripts/lisa-history-secrets.mjs +17 -3
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +86 -7
  5. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +7 -1
  6. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +62 -7
  7. package/all/copy-overwrite/scripts/lisa-rails-prepush.mjs +4 -1
  8. package/all/copy-overwrite/scripts/lisa-work-item.mjs +191 -5
  9. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +5 -5
  10. package/all/deletions.json +19 -2
  11. package/dist/codex/scripts/block-no-verify.sh +62 -7
  12. package/dist/core/config.d.ts +16 -0
  13. package/dist/core/config.d.ts.map +1 -1
  14. package/dist/core/config.js.map +1 -1
  15. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  16. package/dist/core/lisa-owned-hash-ledger.js +78 -0
  17. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  18. package/dist/core/lisa.d.ts +31 -0
  19. package/dist/core/lisa.d.ts.map +1 -1
  20. package/dist/core/lisa.js +69 -4
  21. package/dist/core/lisa.js.map +1 -1
  22. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  23. package/dist/core/retire-unmodified.d.ts +66 -0
  24. package/dist/core/retire-unmodified.d.ts.map +1 -0
  25. package/dist/core/retire-unmodified.js +244 -0
  26. package/dist/core/retire-unmodified.js.map +1 -0
  27. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  28. package/dist/core/upstream-evidence-manifest.js +22 -16
  29. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  30. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +7 -1
  31. package/dist/opencode/plugin-templates/block-no-verify.sh +62 -7
  32. package/package.json +4 -4
  33. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa/hooks/auto-update.mjs +69 -1
  36. package/plugins/lisa/hooks/block-blind-automerge.sh +86 -7
  37. package/plugins/lisa/hooks/block-managed-file-edits.sh +7 -1
  38. package/plugins/lisa/hooks/block-no-verify.agy.sh +62 -7
  39. package/plugins/lisa/hooks/block-no-verify.sh +62 -7
  40. package/plugins/lisa-agy/hooks/block-blind-automerge.sh +86 -7
  41. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +7 -1
  42. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +62 -7
  43. package/plugins/lisa-agy/plugin.json +1 -1
  44. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  47. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-copilot/hooks/auto-update.mjs +69 -1
  51. package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +86 -7
  52. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +7 -1
  53. package/plugins/lisa-copilot/hooks/block-no-verify.sh +62 -7
  54. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-cursor/hooks/auto-update.mjs +69 -1
  56. package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +86 -7
  57. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +7 -1
  58. package/plugins/lisa-cursor/hooks/block-no-verify.sh +62 -7
  59. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-expo-agy/plugin.json +1 -1
  62. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  67. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  72. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  77. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  82. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  86. package/plugins/lisa-rails-agy/plugin.json +1 -1
  87. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  91. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  92. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  96. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  97. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  99. package/plugins/src/base/hooks/auto-update.mjs +69 -1
  100. package/plugins/src/base/hooks/block-blind-automerge.sh +86 -7
  101. package/plugins/src/base/hooks/block-managed-file-edits.sh +7 -1
  102. package/plugins/src/base/hooks/block-no-verify.agy.sh +62 -7
  103. package/plugins/src/base/hooks/block-no-verify.sh +62 -7
  104. package/scripts/check-shipped-surface-removals.mjs +22 -2
@@ -271,7 +271,41 @@ def disables_verification(token):
271
271
  # tokens, so the invocation's boundary is a token the scan can stop at.
272
272
  COMMAND_SEPARATORS = {
273
273
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
274
+ # `|&` pipes stdout AND stderr into the next command, so it ends a command
275
+ # exactly as `|` does. shlex emits it as one token, and before it was listed
276
+ # here `echo hi |& git commit --no-verify` read as a single `echo` command
277
+ # whose arguments happened to include git. The case terminators end a
278
+ # command the same way.
279
+ "|&", ";;", ";&", ";;&",
274
280
  }
281
+ # shlex also GLUES adjacent punctuation into one token across distinct
282
+ # operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
283
+ # test can recognise. An operator-only token is therefore re-split into the
284
+ # operators it is made of, longest first. Redirections are kept whole and are
285
+ # deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
286
+ # commit's argv scope.
287
+ OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
288
+ SHELL_OPERATOR = re.compile("|".join(
289
+ re.escape(operator) for operator in sorted(
290
+ COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
291
+ key=lambda operator: (-len(operator), operator),
292
+ )
293
+ ))
294
+
295
+
296
+ def split_operators(token):
297
+ """Split a glued operator-only token into the shell operators it holds.
298
+
299
+ Args:
300
+ token: One shlex token that was not read inside a quote or escape.
301
+
302
+ Returns:
303
+ The operators, longest first, or the token itself when it is not
304
+ made only of shell punctuation.
305
+ """
306
+ if OPERATOR_ONLY.match(token):
307
+ return SHELL_OPERATOR.findall(token)
308
+ return [token]
275
309
 
276
310
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
277
311
  MAX_SHELL_NESTING = 8
@@ -425,14 +459,28 @@ def shell_tokens(text):
425
459
  Returns:
426
460
  The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
427
461
  """
428
- lexer = shlex.shlex(
429
- line_boundaries_as_separators(text), posix=True, punctuation_chars=True
430
- )
462
+ stream = OperatorStream(line_boundaries_as_separators(text))
463
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
431
464
  lexer.whitespace_split = True
432
465
  # shlex treats `#` as a comment introducer by default and would silently
433
466
  # truncate the rest of the line; `shlex.split` disables it, and so must this.
434
467
  lexer.commenters = ""
435
- return list(lexer)
468
+ stream.lexer = lexer
469
+ tokens = []
470
+ while True:
471
+ stream.literal_operator = False
472
+ token = lexer.get_token()
473
+ if token is None:
474
+ return tokens
475
+ # Quoted punctuation is an argument, never a boundary: it stays whole,
476
+ # and a quoted separator is marked so no scan stops at it (a path or
477
+ # branch can be named `|&`). Only real operator runs are split.
478
+ if stream.literal_operator:
479
+ tokens.append(
480
+ chr(0) + token if token in COMMAND_SEPARATORS else token
481
+ )
482
+ else:
483
+ tokens.extend(split_operators(token))
436
484
 
437
485
 
438
486
  def cluster_skips_verification(cluster):
@@ -1048,6 +1096,10 @@ def resolve_script(token):
1048
1096
  """
1049
1097
  if COMPUTED_VALUE.search(token):
1050
1098
  return (None, "a computed path the guard cannot resolve before the shell does")
1099
+ # A quoted separator carries a NUL marker from shell_tokens; the path the
1100
+ # shell passes is the argument without it.
1101
+ if token.startswith(chr(0)):
1102
+ token = token[1:]
1051
1103
  text = os.path.expanduser(token.strip().strip("'\""))
1052
1104
  # `bash -` and a bare `-` read the script from stdin; there is no file.
1053
1105
  if not text or text == "-":
@@ -1998,9 +2050,12 @@ def flat_tokens(text):
1998
2050
  # shlex otherwise makes quoted ";" indistinguishable from a real
1999
2051
  # command boundary. Keep literal destinations as data, so a setter
2000
2052
  # cannot masquerade as a one-operand configuration read.
2001
- if token in COMMAND_SEPARATORS and stream.literal_operator:
2002
- token = "\x00" + token
2003
- tokens.append(token)
2053
+ if stream.literal_operator:
2054
+ if token in COMMAND_SEPARATORS:
2055
+ token = "\x00" + token
2056
+ tokens.append(token)
2057
+ else:
2058
+ tokens.extend(split_operators(token))
2004
2059
  return [
2005
2060
  token
2006
2061
  for index, token in enumerate(tokens)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -42,6 +42,7 @@ import { execFile } from "child_process";
42
42
  import {
43
43
  existsSync,
44
44
  lstatSync,
45
+ readlinkSync,
45
46
  mkdirSync,
46
47
  openSync,
47
48
  closeSync,
@@ -580,6 +581,70 @@ async function applyUpdate(ctx, update) {
580
581
  }
581
582
  }
582
583
 
584
+ /**
585
+ * What each pending file holds right now, so the later commit can prove it is
586
+ * still committing the update's bytes and not an edit made since
587
+ * (CodySwannGT/lisa#4393).
588
+ *
589
+ * A path maps to `"<git mode> <identity>"`, or to null when it is absent (the
590
+ * update deleted it). A regular file is `100644`/`100755` plus its
591
+ * `git hash-object` blob id, so an executable-bit change alone is caught. A
592
+ * symlink is `120000 link:<target>`: hash-object follows a link and hashes what
593
+ * it reaches, so a link retargeted at identical bytes would otherwise read as
594
+ * unchanged. Anything else (a directory, a submodule) is left OUT: it cannot be
595
+ * proved, and the reader treats a missing entry as "differs", so it never
596
+ * auto-commits on it. `lisa-work-item.mjs` recomputes these the same way.
597
+ * @param {(argv: string[], options: object) => Promise<string>} run Runner.
598
+ * @param {string} cwd Project directory.
599
+ * @param {readonly string[]} files Pending paths.
600
+ * @returns {Promise<Record<string, string | null> | null>} Digests, or null when they could not be read.
601
+ */
602
+ export async function workingTreeDigests(run, cwd, files) {
603
+ const digests = {};
604
+ const present = [];
605
+ for (const file of files) {
606
+ let stat;
607
+ try {
608
+ stat = lstatSync(path.join(cwd, file));
609
+ } catch {
610
+ digests[file] = null;
611
+ continue;
612
+ }
613
+ if (stat.isSymbolicLink()) {
614
+ // A link that vanishes before it can be read is left out, which the
615
+ // reader treats as "differs".
616
+ try {
617
+ digests[file] = `120000 link:${readlinkSync(path.join(cwd, file))}`;
618
+ } catch {
619
+ continue;
620
+ }
621
+ } else if (stat.isFile())
622
+ present.push([file, stat.mode & 0o111 ? "100755" : "100644"]);
623
+ }
624
+ if (present.length > 0) {
625
+ const ids = (
626
+ await run(
627
+ ["git", "hash-object", "--", ...present.map(([file]) => file)],
628
+ {
629
+ cwd,
630
+ }
631
+ )
632
+ )
633
+ .split("\n")
634
+ .map(line => line.trim())
635
+ .filter(Boolean);
636
+ if (
637
+ ids.length !== present.length ||
638
+ !ids.every(id => /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(id))
639
+ )
640
+ return null;
641
+ present.forEach(([file, mode], index) => {
642
+ digests[file] = `${mode} ${ids[index]}`;
643
+ });
644
+ }
645
+ return digests;
646
+ }
647
+
583
648
  /**
584
649
  * Commit the update now, or leave it pending for the first work-item binding.
585
650
  * @param {object} ctx Context.
@@ -635,10 +700,13 @@ async function settle(ctx, update) {
635
700
  projectDir,
636
701
  await gitPath("lisa/pending-update.json")
637
702
  );
703
+ const digests = await workingTreeDigests(run, projectDir, changed).catch(
704
+ () => null
705
+ );
638
706
  mkdirSync(path.dirname(marker), { recursive: true });
639
707
  writeFileSync(
640
708
  marker,
641
- `${JSON.stringify({ from: update.from, to: update.to, files: changed }, null, 2)}\n`
709
+ `${JSON.stringify({ from: update.from, to: update.to, files: changed, ...(digests ? { digests } : {}) }, null, 2)}\n`
642
710
  );
643
711
  return `It is NOT committed yet (${decision.reason}). The ${changed.length} changed file(s) are left in the working tree and recorded as a pending update; binding a work item on a feature branch (\`lisa-work-item.mjs link\` / \`attach-branch\`, which /lisa:track runs) commits them first, as their own commit. Do not fold these files into a feature commit.`;
644
712
  }
@@ -211,6 +211,7 @@ if [ -z "$command_str" ]; then
211
211
  fi
212
212
 
213
213
  if ! BLOCK_BLIND_AUTOMERGE_COMMAND="$command_str" python3 - <<'PY'
214
+ import io
214
215
  import json
215
216
  import os
216
217
  import re
@@ -244,7 +245,41 @@ MERGE_SEPARATE_VALUE = {
244
245
 
245
246
  COMMAND_SEPARATORS = {
246
247
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
248
+ # `|&` pipes stdout AND stderr into the next command, so it ends a command
249
+ # exactly as `|` does. shlex emits it as one token, and before it was listed
250
+ # here `echo hi |& git commit --no-verify` read as a single `echo` command
251
+ # whose arguments happened to include git. The case terminators end a
252
+ # command the same way.
253
+ "|&", ";;", ";&", ";;&",
247
254
  }
255
+ # shlex also GLUES adjacent punctuation into one token across distinct
256
+ # operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
257
+ # test can recognise. An operator-only token is therefore re-split into the
258
+ # operators it is made of, longest first. Redirections are kept whole and are
259
+ # deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
260
+ # commit's argv scope.
261
+ OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
262
+ SHELL_OPERATOR = re.compile("|".join(
263
+ re.escape(operator) for operator in sorted(
264
+ COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
265
+ key=lambda operator: (-len(operator), operator),
266
+ )
267
+ ))
268
+
269
+
270
+ def split_operators(token):
271
+ """Split a glued operator-only token into the shell operators it holds.
272
+
273
+ Args:
274
+ token: One shlex token that was not read inside a quote or escape.
275
+
276
+ Returns:
277
+ The operators, longest first, or the token itself when it is not
278
+ made only of shell punctuation.
279
+ """
280
+ if OPERATOR_ONLY.match(token):
281
+ return SHELL_OPERATOR.findall(token)
282
+ return [token]
248
283
 
249
284
  # Shell wrappers whose `-c` argument is a command in a string rather than argv.
250
285
  # The outer argv names bash, so the arming is invisible without recursing once.
@@ -422,12 +457,40 @@ def shell_tokens(text):
422
457
  Returns:
423
458
  The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
424
459
  """
425
- lexer = shlex.shlex(
426
- line_boundaries_as_separators(text), posix=True, punctuation_chars=True
427
- )
460
+ stream = OperatorStream(line_boundaries_as_separators(text))
461
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
428
462
  lexer.whitespace_split = True
429
463
  lexer.commenters = ""
430
- return list(lexer)
464
+ stream.lexer = lexer
465
+ tokens = []
466
+ while True:
467
+ stream.literal_operator = False
468
+ token = lexer.get_token()
469
+ if token is None:
470
+ return tokens
471
+ # Quoted punctuation is an argument, never a boundary: a branch can be
472
+ # named `|&`, and `gh pr merge '|&' --auto` must keep `--auto` in the
473
+ # merge's own argv. Only real operator runs are split.
474
+ if stream.literal_operator:
475
+ tokens.append(
476
+ chr(0) + token if token in COMMAND_SEPARATORS else token
477
+ )
478
+ else:
479
+ tokens.extend(split_operators(token))
480
+
481
+
482
+ class OperatorStream(io.StringIO):
483
+ """Remember punctuation read while shlex is inside a quote or escape."""
484
+
485
+ lexer = None
486
+ literal_operator = False
487
+
488
+ def read(self, size=-1):
489
+ value = super().read(size)
490
+ if (self.lexer is not None and self.lexer.state in {"'", '"', chr(92)}
491
+ and any(char in "();|&<>" for char in value)):
492
+ self.literal_operator = True
493
+ return value
431
494
 
432
495
 
433
496
  def is_gh(token):
@@ -493,6 +556,22 @@ def arms_auto_merge(argv):
493
556
  return False
494
557
 
495
558
 
559
+ def literal_value(token):
560
+ """The argument a token stands for, without the quoted-separator marker.
561
+
562
+ `shell_tokens` prefixes a QUOTED separator with NUL so no scan stops at it.
563
+ That marker is for boundary checks only: a value handed to a gh probe
564
+ (a selector, a base ref, a repo) must be the argument the shell passes.
565
+
566
+ Args:
567
+ token: One token from `shell_tokens`.
568
+
569
+ Returns:
570
+ The token as the shell would pass it.
571
+ """
572
+ return token[1:] if token.startswith(chr(0)) else token
573
+
574
+
496
575
  def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
497
576
  """The PR the command names, as gh itself would read it.
498
577
 
@@ -518,7 +597,7 @@ def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
518
597
  index += 1
519
598
  continue
520
599
  if not token.startswith("-"):
521
- return token
600
+ return literal_value(token)
522
601
  return None
523
602
 
524
603
 
@@ -538,7 +617,7 @@ def retarget_target(argv):
538
617
  if token in COMMAND_SEPARATORS:
539
618
  return None
540
619
  if token in BASE_FLAGS and index < len(argv):
541
- return argv[index]
620
+ return literal_value(argv[index])
542
621
  if token.startswith("--base="):
543
622
  return token.split("=", 1)[1]
544
623
  if token in EDIT_SEPARATE_VALUE:
@@ -562,7 +641,7 @@ def repo_flag(tokens, start):
562
641
  if token in COMMAND_SEPARATORS:
563
642
  break
564
643
  if token in {"-R", "--repo"} and index + 1 < len(tokens):
565
- return ["--repo", tokens[index + 1]]
644
+ return ["--repo", literal_value(tokens[index + 1])]
566
645
  if token.startswith("--repo="):
567
646
  return ["--repo", token.split("=", 1)[1]]
568
647
  index += 1
@@ -597,7 +597,13 @@ ENV_SPLIT_UNRESOLVED = 42
597
597
  BACKSLASH = chr(92)
598
598
  NEWLINE = chr(10)
599
599
 
600
- STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
600
+ # `|&` pipes stdout and stderr into the next command, and the case terminators
601
+ # end a statement as `;` does. Without them `printf x |& tee <managed>` stayed
602
+ # one statement whose command word was `printf`, so the `tee` write target was
603
+ # never examined.
604
+ STATEMENT_SEPARATORS = {
605
+ ";", "|", "||", "&", "&&", "(", ")", "&|", "|&", ";;", ";&", ";;&",
606
+ }
601
607
  # Ordinary output, noclobber override, and combined stdout/stderr output.
602
608
  # shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
603
609
  REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
@@ -271,7 +271,41 @@ def disables_verification(token):
271
271
  # tokens, so the invocation's boundary is a token the scan can stop at.
272
272
  COMMAND_SEPARATORS = {
273
273
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
274
+ # `|&` pipes stdout AND stderr into the next command, so it ends a command
275
+ # exactly as `|` does. shlex emits it as one token, and before it was listed
276
+ # here `echo hi |& git commit --no-verify` read as a single `echo` command
277
+ # whose arguments happened to include git. The case terminators end a
278
+ # command the same way.
279
+ "|&", ";;", ";&", ";;&",
274
280
  }
281
+ # shlex also GLUES adjacent punctuation into one token across distinct
282
+ # operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
283
+ # test can recognise. An operator-only token is therefore re-split into the
284
+ # operators it is made of, longest first. Redirections are kept whole and are
285
+ # deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
286
+ # commit's argv scope.
287
+ OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
288
+ SHELL_OPERATOR = re.compile("|".join(
289
+ re.escape(operator) for operator in sorted(
290
+ COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
291
+ key=lambda operator: (-len(operator), operator),
292
+ )
293
+ ))
294
+
295
+
296
+ def split_operators(token):
297
+ """Split a glued operator-only token into the shell operators it holds.
298
+
299
+ Args:
300
+ token: One shlex token that was not read inside a quote or escape.
301
+
302
+ Returns:
303
+ The operators, longest first, or the token itself when it is not
304
+ made only of shell punctuation.
305
+ """
306
+ if OPERATOR_ONLY.match(token):
307
+ return SHELL_OPERATOR.findall(token)
308
+ return [token]
275
309
 
276
310
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
277
311
  MAX_SHELL_NESTING = 8
@@ -425,14 +459,28 @@ def shell_tokens(text):
425
459
  Returns:
426
460
  The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
427
461
  """
428
- lexer = shlex.shlex(
429
- line_boundaries_as_separators(text), posix=True, punctuation_chars=True
430
- )
462
+ stream = OperatorStream(line_boundaries_as_separators(text))
463
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
431
464
  lexer.whitespace_split = True
432
465
  # shlex treats `#` as a comment introducer by default and would silently
433
466
  # truncate the rest of the line; `shlex.split` disables it, and so must this.
434
467
  lexer.commenters = ""
435
- return list(lexer)
468
+ stream.lexer = lexer
469
+ tokens = []
470
+ while True:
471
+ stream.literal_operator = False
472
+ token = lexer.get_token()
473
+ if token is None:
474
+ return tokens
475
+ # Quoted punctuation is an argument, never a boundary: it stays whole,
476
+ # and a quoted separator is marked so no scan stops at it (a path or
477
+ # branch can be named `|&`). Only real operator runs are split.
478
+ if stream.literal_operator:
479
+ tokens.append(
480
+ chr(0) + token if token in COMMAND_SEPARATORS else token
481
+ )
482
+ else:
483
+ tokens.extend(split_operators(token))
436
484
 
437
485
 
438
486
  def cluster_skips_verification(cluster):
@@ -1048,6 +1096,10 @@ def resolve_script(token):
1048
1096
  """
1049
1097
  if COMPUTED_VALUE.search(token):
1050
1098
  return (None, "a computed path the guard cannot resolve before the shell does")
1099
+ # A quoted separator carries a NUL marker from shell_tokens; the path the
1100
+ # shell passes is the argument without it.
1101
+ if token.startswith(chr(0)):
1102
+ token = token[1:]
1051
1103
  text = os.path.expanduser(token.strip().strip("'\""))
1052
1104
  # `bash -` and a bare `-` read the script from stdin; there is no file.
1053
1105
  if not text or text == "-":
@@ -1998,9 +2050,12 @@ def flat_tokens(text):
1998
2050
  # shlex otherwise makes quoted ";" indistinguishable from a real
1999
2051
  # command boundary. Keep literal destinations as data, so a setter
2000
2052
  # cannot masquerade as a one-operand configuration read.
2001
- if token in COMMAND_SEPARATORS and stream.literal_operator:
2002
- token = "\x00" + token
2003
- tokens.append(token)
2053
+ if stream.literal_operator:
2054
+ if token in COMMAND_SEPARATORS:
2055
+ token = "\x00" + token
2056
+ tokens.append(token)
2057
+ else:
2058
+ tokens.extend(split_operators(token))
2004
2059
  return [
2005
2060
  token
2006
2061
  for index, token in enumerate(tokens)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"