@codyswann/lisa 4.24.2 → 4.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +218 -82
  2. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +85 -12
  3. package/all/copy-overwrite/scripts/lisa-work-item.mjs +74 -15
  4. package/dist/cli/ui-config-write-persistence.d.ts +12 -2
  5. package/dist/cli/ui-config-write-persistence.d.ts.map +1 -1
  6. package/dist/cli/ui-config-write-persistence.js +7 -3
  7. package/dist/cli/ui-config-write-persistence.js.map +1 -1
  8. package/dist/cli/ui-config-write.d.ts +1 -1
  9. package/dist/cli/ui-config-write.d.ts.map +1 -1
  10. package/dist/cli/ui-config-write.js +62 -6
  11. package/dist/cli/ui-config-write.js.map +1 -1
  12. package/dist/codex/scripts/block-no-verify.sh +134 -37
  13. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  14. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  15. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  16. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  17. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  18. package/dist/core/upstream-evidence-manifest.js +7 -6
  19. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  20. package/dist/opencode/plugin-templates/block-no-verify.sh +218 -82
  21. package/package.json +3 -3
  22. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa/hooks/block-no-verify.agy.sh +134 -37
  25. package/plugins/lisa/hooks/block-no-verify.sh +218 -82
  26. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +134 -37
  27. package/plugins/lisa-agy/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  31. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-copilot/hooks/block-no-verify.sh +218 -82
  35. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cursor/hooks/block-no-verify.sh +218 -82
  37. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo-agy/plugin.json +1 -1
  40. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  60. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails-agy/plugin.json +1 -1
  65. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  70. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  75. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/src/base/hooks/block-no-verify.agy.sh +134 -37
  78. package/plugins/src/base/hooks/block-no-verify.sh +218 -82
  79. package/ui/README.md +30 -2
@@ -15,7 +15,13 @@
15
15
  # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config;
16
16
  # 6. GIT_CONFIG_PARAMETERS carrying core.hooksPath command-scope config;
17
17
  # 7. the short `-n`, as a real argv token of a `git commit` — bare, or bundled
18
- # into a short-option cluster such as `-nm "msg"`.
18
+ # into a short-option cluster such as `-nm "msg"`;
19
+ # 8. a nested shell carrying any of the above as its `-c` command string,
20
+ # including behind long shell options such as `bash --norc -c`;
21
+ # 9. `env --split-string` in EVERY abbreviation GNU accepts (`--s` upward,
22
+ # with or without an attached `=value`);
23
+ # 10. those forms behind a wrapper that does not change what runs —
24
+ # `command`, `exec`, `builtin`, `nohup`, `setsid`, and nested `env`.
19
25
  #
20
26
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
21
27
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
@@ -26,7 +32,7 @@
26
32
  # Add a name here in the same commit that closes a vector. A hardening that
27
33
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
28
34
  # review time instead of a named capability.
29
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify
35
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization
30
36
  #
31
37
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
32
38
  # commit-message prose while still catching quoted real argv values such as
@@ -222,26 +228,61 @@ COMMAND_SEPARATORS = {
222
228
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
223
229
  MAX_SHELL_NESTING = 8
224
230
 
225
- # Prefix options accepted by `env` before the command name. Value-taking
226
- # options are separate because their operand must never be mistaken for the
227
- # command (`env -u bash -c ...` consumes `bash` as the variable name).
228
- ENV_OPTIONS_NO_VALUE = {
229
- "-i", "--ignore-environment", "-0", "--null", "-v", "--debug",
231
+ # GNU env's long options, listed in FULL so an abbreviation can be resolved
232
+ # against them. getopt_long accepts any prefix that names exactly one option,
233
+ # and no other env option begins with `s` — so `env --s`, `env --sp`, and
234
+ # `env --split` are `env --split-string` just as completely as the full
235
+ # spelling is. Matching only the full spelling is bypassed by deleting
236
+ # characters, which is why the set below exists instead of a literal list of
237
+ # the two spellings the guard used to know.
238
+ ENV_LONG_OPTIONS = {
239
+ "--argv0", "--block-signals", "--chdir", "--debug", "--default-signal",
240
+ "--help", "--ignore-environment", "--ignore-signal",
241
+ "--list-signal-handling", "--null", "--split-string", "--unset",
242
+ "--version",
230
243
  }
231
- ENV_OPTIONS_SEPARATE_VALUE = {
232
- "-u", "--unset", "-C", "--chdir", "-S", "--split-string",
233
- "-a", "--argv0",
234
- }
235
- ENV_OPTIONS_INLINE_VALUE = (
236
- "--unset=", "--chdir=", "--split-string=", "--argv0=",
237
- )
244
+ # Of those, the ones whose value is a SEPARATE token, so the operand is never
245
+ # mistaken for the command (`env -u bash -c ...` consumes `bash` as the
246
+ # variable name). The signal options take an OPTIONAL value, which GNU reads
247
+ # only when it is attached with `=`, so they never consume a following token.
248
+ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
249
+
250
+ # env's single-letter options, by whether the rest of a cluster is more
251
+ # options or the option's value.
252
+ ENV_SHORT_NO_VALUE = frozenset("i0v")
253
+ ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
254
+
255
+ # Builtins and wrappers that run whatever FOLLOWS them without changing what
256
+ # it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
257
+ # and `env -- env -S ...` runs exactly the inner `env`. Reading the wrapper as
258
+ # the command is how a payload hides from a guard that inspects only the first
259
+ # word of an invocation.
260
+ COMMAND_PREFIX_WRAPPERS = {"command", "exec", "builtin", "nohup", "setsid"}
261
+ # The only option of those that consumes a separate following token
262
+ # (`exec -a NAME env -S ...`).
263
+ COMMAND_PREFIX_SEPARATE_VALUE = {"-a"}
238
264
 
239
265
  # Shell options whose following token is an operand, not another option. The
240
266
  # generic recursive scanner must step over these before looking for `-c`.
241
267
  SHELL_OPTIONS_SEPARATE_VALUE = {
242
- "-o", "+o", "-O", "+O", "--rcfile", "--init-file",
268
+ "-o", "+o", "-O", "+O", "--rcfile", "--init-file", "--emulate",
269
+ }
270
+ SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=", "--emulate=")
271
+
272
+ # Boolean long options of the recognized shells. Listed only for PRECISION:
273
+ # a long option is never the command-string flag either way, but naming the
274
+ # ordinary ones keeps `bash --norc script.sh` from being treated as a possible
275
+ # value-taking option whose operand must be scanned past. zsh and ksh accept a
276
+ # long option per shell setting, so this set is deliberately not exhaustive —
277
+ # the scanner stays correct on the ones it does not name.
278
+ SHELL_LONG_OPTIONS_NO_VALUE = {
279
+ "--debugger", "--dump-po-strings", "--dump-strings", "--emacs",
280
+ "--globalrcs", "--help", "--interactive", "--login", "--monitor",
281
+ "--no-globalrcs", "--no-rcs", "--noediting", "--noprofile", "--norc",
282
+ "--norcs", "--posix", "--pretty-print", "--privileged", "--protected",
283
+ "--rcs", "--restricted", "--verbose", "--version", "--vi", "--wordexp",
284
+ "--xtrace",
243
285
  }
244
- SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=")
245
286
 
246
287
  # git's own options that take a SEPARATE value token, which therefore must be
247
288
  # skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
@@ -395,56 +436,143 @@ def subcommand_after_git(tokens, start):
395
436
  return None
396
437
 
397
438
 
398
- def shell_starts_command(tokens, index):
399
- """Whether a recognized shell token occupies command position."""
400
- start = index - 1
401
- while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
402
- start -= 1
403
- prefix = tokens[start + 1:index]
404
- if not prefix:
405
- return True
406
- if prefix[0].rsplit("/", 1)[-1] != "env":
407
- return all("=" in token and not token.startswith("=") for token in prefix)
439
+ def resolve_env_long_option(name):
440
+ """Resolve one GNU env long option, abbreviations included.
408
441
 
409
- cursor = 1
442
+ Args:
443
+ name: A token beginning with `--`, with any `=value` already removed.
444
+
445
+ Returns:
446
+ The full option spelling; "" when the abbreviation names more than one
447
+ option, which real env refuses; None when nothing matches at all.
448
+ """
449
+ if not name.startswith("--") or name == "--":
450
+ return None
451
+ if name in ENV_LONG_OPTIONS:
452
+ return name
453
+ matches = [option for option in ENV_LONG_OPTIONS if option.startswith(name)]
454
+ if len(matches) == 1:
455
+ return matches[0]
456
+ return "" if matches else None
457
+
458
+
459
+ def env_option_kind(token):
460
+ """Classify one token sitting in an `env` option position.
461
+
462
+ Returns:
463
+ "split-string" when env will reparse an argv value as shell words,
464
+ "separate-value" when the NEXT token is this option's operand,
465
+ "no-value" when the token stands alone, "ambiguous" when env accepts
466
+ the token but this parser cannot say which option it is, or None when
467
+ the token is not an option.
468
+ """
469
+ if not token.startswith("-") or token in {"-", "--"}:
470
+ return None
471
+ if token.startswith("--"):
472
+ name = token.split("=", 1)[0]
473
+ resolved = resolve_env_long_option(name)
474
+ if not resolved:
475
+ # Either unknown to this parser or an abbreviation naming several
476
+ # options. Neither can be reasoned about, so say so and let the
477
+ # caller fail closed rather than read past it.
478
+ return "ambiguous"
479
+ if resolved == "--split-string":
480
+ return "split-string"
481
+ if "=" in token:
482
+ return "no-value"
483
+ return (
484
+ "separate-value" if resolved in ENV_LONG_SEPARATE_VALUE else "no-value"
485
+ )
486
+ for position, letter in enumerate(token[1:], start=2):
487
+ if letter == "S":
488
+ return "split-string"
489
+ if letter in ENV_SHORT_TAKES_VALUE:
490
+ # The rest of this cluster is the option's value when there is
491
+ # one; otherwise the value is the following token.
492
+ return "no-value" if position <= len(token) - 1 else "separate-value"
493
+ if letter not in ENV_SHORT_NO_VALUE:
494
+ return "ambiguous"
495
+ return "no-value"
496
+
497
+
498
+ def strip_command_prefix(prefix):
499
+ """Drop the tokens that precede a command without changing what it is.
500
+
501
+ Variable assignments, the `command`/`exec`/`builtin` builtins, and nested
502
+ `env` wrappers all leave the following word in command position. Reading
503
+ any of them as the command itself is what let `command env -S ...` and
504
+ `env -- env -S ...` slip past the split-string check: the prefix failed
505
+ the "assignments only" test, so the wrapped `env` was never classified.
506
+
507
+ Args:
508
+ prefix: The tokens between the last command separator and the
509
+ candidate program.
510
+
511
+ Returns:
512
+ A pair (status, remainder). "ok" carries the tokens still standing
513
+ between the separator and the candidate — empty means the candidate is
514
+ in command position. "operand" means the candidate is a wrapper
515
+ option's value rather than a command. "ambiguous" means a wrapper
516
+ option could not be classified and the caller must fail closed.
517
+ """
518
+ cursor = 0
410
519
  while cursor < len(prefix):
411
520
  token = prefix[cursor]
412
- if token == "--":
413
- return cursor == len(prefix) - 1
414
- if "=" in token and not token.startswith("=") and not token.startswith("-"):
521
+ if "=" in token and not token.startswith(("=", "-")):
415
522
  cursor += 1
416
523
  continue
417
- if token in ENV_OPTIONS_NO_VALUE or token.startswith(ENV_OPTIONS_INLINE_VALUE):
524
+ program = token.rsplit("/", 1)[-1]
525
+ if program in COMMAND_PREFIX_WRAPPERS:
418
526
  cursor += 1
527
+ while cursor < len(prefix) and prefix[cursor].startswith("-"):
528
+ if prefix[cursor] == "--":
529
+ cursor += 1
530
+ break
531
+ if prefix[cursor] in COMMAND_PREFIX_SEPARATE_VALUE:
532
+ if cursor + 1 >= len(prefix):
533
+ return ("operand", [])
534
+ cursor += 2
535
+ continue
536
+ cursor += 1
419
537
  continue
420
- if token in ENV_OPTIONS_SEPARATE_VALUE:
421
- # If the option has no operand inside the prefix, the shell token
422
- # itself is that operand and therefore is not command position.
423
- if cursor + 1 >= len(prefix):
424
- return False
425
- cursor += 2
538
+ if program == "env":
539
+ cursor += 1
540
+ while cursor < len(prefix):
541
+ option = prefix[cursor]
542
+ if option == "--":
543
+ cursor += 1
544
+ break
545
+ kind = env_option_kind(option)
546
+ if kind is None:
547
+ break
548
+ if kind == "ambiguous":
549
+ return ("ambiguous", [])
550
+ if kind == "split-string":
551
+ # Everything after `-S` is env's own opaque payload, so the
552
+ # candidate is not a command at all.
553
+ return ("operand", [])
554
+ if kind == "separate-value":
555
+ if cursor + 1 >= len(prefix):
556
+ return ("operand", [])
557
+ cursor += 2
558
+ continue
559
+ cursor += 1
426
560
  continue
427
- if token.startswith("-"):
428
- # An unknown env option makes command-position parsing ambiguous.
429
- # Fail closed for a recognized shell rather than trusting it.
430
- return True
431
- return False
432
- return True
561
+ break
562
+ return ("ok", prefix[cursor:])
433
563
 
434
564
 
435
- def env_short_option_uses_split_string(token):
436
- """Whether one GNU env short-option cluster enables split-string."""
437
- if not token.startswith("-") or token.startswith("--") or token == "-":
438
- return False
439
- for letter in token[1:]:
440
- if letter == "S":
441
- return True
442
- if letter in {"u", "C", "a"}:
443
- # The rest of this token is the option's value, not more options.
444
- return False
445
- if letter not in {"i", "0", "v"}:
446
- return False
447
- return False
565
+ def shell_starts_command(tokens, index):
566
+ """Whether a recognized shell token occupies command position."""
567
+ start = index - 1
568
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
569
+ start -= 1
570
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
571
+ if status == "ambiguous":
572
+ # A prefix option this parser cannot classify may still be launching
573
+ # the shell. Fail closed rather than trusting the wrapper.
574
+ return True
575
+ return status == "ok" and not remainder
448
576
 
449
577
 
450
578
  def env_uses_split_string(tokens, index):
@@ -464,8 +592,8 @@ def env_uses_split_string(tokens, index):
464
592
  start = index - 1
465
593
  while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
466
594
  start -= 1
467
- prefix = tokens[start + 1:index]
468
- if not all("=" in token and not token.startswith("=") for token in prefix):
595
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
596
+ if status != "ok" or remainder:
469
597
  return False
470
598
 
471
599
  cursor = index + 1
@@ -473,40 +601,39 @@ def env_uses_split_string(tokens, index):
473
601
  token = tokens[cursor]
474
602
  if token in COMMAND_SEPARATORS or token == "--":
475
603
  return False
476
- if (
477
- token in {"-S", "--split-string"}
478
- or token.startswith("--split-string=")
479
- or env_short_option_uses_split_string(token)
480
- ):
604
+ kind = env_option_kind(token)
605
+ if kind == "split-string":
481
606
  return True
482
- if "=" in token and not token.startswith(("=", "-")):
483
- cursor += 1
484
- continue
485
- if token in ENV_OPTIONS_NO_VALUE or token.startswith(
486
- tuple(
487
- option
488
- for option in ENV_OPTIONS_INLINE_VALUE
489
- if option != "--split-string="
490
- )
491
- ):
492
- cursor += 1
493
- continue
494
- if token in ENV_OPTIONS_SEPARATE_VALUE:
495
- cursor += 2
496
- continue
497
- return False
607
+ if kind == "ambiguous":
608
+ # An option env accepts but this parser cannot name makes the rest
609
+ # of the invocation unreadable; refuse instead of reading past it.
610
+ return True
611
+ if kind is None:
612
+ if "=" in token and not token.startswith(("=", "-")):
613
+ cursor += 1
614
+ continue
615
+ return False
616
+ cursor += 2 if kind == "separate-value" else 1
498
617
  return False
499
618
 
500
619
 
501
620
  def nested_shell_payload(tokens, index):
502
621
  """Return a shell `-c` payload, or True when option parsing is ambiguous."""
503
622
  cursor = index + 1
623
+ opaque_option_seen = False
504
624
  while cursor < len(tokens):
505
625
  option = tokens[cursor]
506
626
  if option in COMMAND_SEPARATORS or option == "--":
507
627
  return None
508
628
  if not option.startswith(("-", "+")):
509
- return None
629
+ # Ordinarily the script operand, which ends option parsing. After
630
+ # a long option this parser cannot name it may instead be that
631
+ # option's value (`zsh --emulate ksh -c ...`), so keep scanning
632
+ # rather than concluding there is no command string.
633
+ if not opaque_option_seen:
634
+ return None
635
+ cursor += 1
636
+ continue
510
637
  if option in SHELL_OPTIONS_SEPARATE_VALUE:
511
638
  if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
512
639
  return True
@@ -515,8 +642,17 @@ def nested_shell_payload(tokens, index):
515
642
  if option.startswith(SHELL_OPTIONS_INLINE_VALUE):
516
643
  cursor += 1
517
644
  continue
518
- body = option[1:]
519
- if "c" in body:
645
+ # A LONG option is never the command-string flag, and testing it for a
646
+ # bare `c` was the bug: `--norc` and `--restricted` merely CONTAIN one,
647
+ # so the real `-c` that followed was consumed as their payload and the
648
+ # nested `git commit --no-verify` was never classified at all. The
649
+ # command string lives only in a single-dash cluster (`-c`, `-lc`).
650
+ if option.startswith("--"):
651
+ if option not in SHELL_LONG_OPTIONS_NO_VALUE:
652
+ opaque_option_seen = True
653
+ cursor += 1
654
+ continue
655
+ if "c" in option[1:]:
520
656
  if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
521
657
  return True
522
658
  return tokens[cursor + 1]
@@ -673,8 +673,10 @@ export function readLivePolicy({ repo, gh }) {
673
673
  * One record per declared (ruleset, context) pair, from `declaredChecks`.
674
674
  * Supplied, it is what the comparison is made of; absent, the name-keyed
675
675
  * `homes`/`pins` projection is reconstituted into equivalent records.
676
- * @param {Array<{context:string, ruleset?:string}>} [options.awaited]
677
- * Awaited declarations, preserving their ruleset identity.
676
+ * @param {Array<{context:string, ruleset?:string, integration_id?:number}>} [options.awaited]
677
+ * Awaited declarations, preserving their ruleset identity and declared pin.
678
+ * A declaration carrying a ruleset becomes a comparison record of its own,
679
+ * unless `records` already declares that same (ruleset, context) pair.
678
680
  * @returns {ContextDrift} Three sets, plus the unsatisfied records.
679
681
  */
680
682
  export function reconcileContexts({
@@ -711,6 +713,37 @@ export function reconcileContexts({
711
713
  if (!byContext.has(record.context)) byContext.set(record.context, []);
712
714
  byContext.get(record.context).push(record);
713
715
  }
716
+ // An awaited declaration names a ruleset too — the home `awaitedHome`
717
+ // resolved, which is where its repair will be written. It has to reach the
718
+ // records the comparison is made of for exactly the reason a configured
719
+ // record does: without it, an awaited context required in `release` answered
720
+ // for the requirement `base` was missing, the pair read as matched, and the
721
+ // repair that would have added it was never planned.
722
+ for (const record of awaitedDeclarations) {
723
+ if (!declaredNames.has(record?.context)) continue;
724
+ // No home could be resolved — an ambiguous repository, or an explicit
725
+ // `--ruleset` naming one that is not here. Name-only matching is retained
726
+ // there on purpose: tightening would report drift against a ruleset nobody
727
+ // chose, and the repair has no place to write it either.
728
+ if (record.ruleset === undefined || record.ruleset === null) continue;
729
+ if (!byContext.has(record.context)) byContext.set(record.context, []);
730
+ const declarationsFor = byContext.get(record.context);
731
+ // An explicit `requiredChecks` declaration for the same pair is the one the
732
+ // project wrote down, and it stays in charge — including of its pin. Adding
733
+ // the awaited record beside it would state two expectations for one
734
+ // (ruleset, context) pair, and a pinned declaration and an unpinned one can
735
+ // never both be satisfied: the pair would be permanently missing.
736
+ if (declarationsFor.some(entry => entry.ruleset === record.ruleset)) {
737
+ continue;
738
+ }
739
+ declarationsFor.push({
740
+ context: record.context,
741
+ ruleset: record.ruleset,
742
+ ...(Number.isInteger(record.integration_id)
743
+ ? { integration_id: record.integration_id }
744
+ : {}),
745
+ });
746
+ }
714
747
  // A gate-derived context names no ruleset and no app, so it keeps matching on
715
748
  // name against any active ruleset; nothing is tightened that was never stated.
716
749
  const declarations = [...declaredNames]
@@ -1243,7 +1276,7 @@ function planContextRepairs({
1243
1276
  pins,
1244
1277
  homes,
1245
1278
  }) {
1246
- /** @type {Map<string, {add: string[], remove: string[], pins: Record<string, number>, unpinned: Set<string>}>} */
1279
+ /** @type {Map<string, {add: string[], remove: string[], pins: Record<string, number>, unpinned: Set<string>, awaited: Set<string>}>} */
1247
1280
  const groups = new Map();
1248
1281
  const group = name => {
1249
1282
  if (!groups.has(name)) {
@@ -1252,11 +1285,26 @@ function planContextRepairs({
1252
1285
  remove: [],
1253
1286
  pins: {},
1254
1287
  unpinned: new Set(),
1288
+ // Which of THIS ruleset's additions are awaited. A declaration that
1289
+ // resolved a home of its own is matched by name below; this set is for
1290
+ // the ones that did not, whose home only becomes knowable here.
1291
+ awaited: new Set(),
1255
1292
  });
1256
1293
  }
1257
1294
  return groups.get(name);
1258
1295
  };
1259
1296
  const problems = [];
1297
+ // An awaited declaration with no ruleset resolved no home of its own, yet it
1298
+ // is still routed somewhere — through `homes`, or through the fallback. The
1299
+ // payload matched awaited records against the target by their OWN ruleset,
1300
+ // so these were dropped from the awaited list and written with the Actions
1301
+ // pin: the one writer that can never post an external check, which leaves the
1302
+ // repaired rule permanently unsatisfiable. Record where each one lands.
1303
+ const unscopedAwaited = new Set(
1304
+ awaited
1305
+ .filter(record => record.ruleset === undefined || record.ruleset === null)
1306
+ .map(record => record.context)
1307
+ );
1260
1308
 
1261
1309
  // A removal goes to the ruleset that actually requires it. That ruleset is
1262
1310
  // reported alongside every EXTRA context precisely so this is knowable.
@@ -1281,6 +1329,7 @@ function planContextRepairs({
1281
1329
  const target = home ? group(home) : null;
1282
1330
  if (target) {
1283
1331
  target.add.push(context);
1332
+ if (unscopedAwaited.has(context)) target.awaited.add(context);
1284
1333
  // A declared record is authoritative about its own pin — INCLUDING the
1285
1334
  // absence of one. `pins` is name-keyed and last-write-wins, so if `base`
1286
1335
  // declares a context unpinned and `release` pins it to app 99, the
@@ -1303,11 +1352,16 @@ function planContextRepairs({
1303
1352
  problems.push(fallback.problem);
1304
1353
  continue;
1305
1354
  }
1306
- group(fallback.ruleset.name).add.push(context);
1355
+ const fallbackGroup = group(fallback.ruleset.name);
1356
+ fallbackGroup.add.push(context);
1357
+ if (unscopedAwaited.has(context)) fallbackGroup.awaited.add(context);
1307
1358
  }
1308
1359
 
1309
1360
  const actions = [];
1310
- for (const [name, { add, remove, pins: groupPins, unpinned }] of groups) {
1361
+ for (const [
1362
+ name,
1363
+ { add, remove, pins: groupPins, unpinned, awaited: routedAwaited },
1364
+ ] of groups) {
1311
1365
  const ruleset = (live.rulesets ?? []).find(entry => entry?.name === name);
1312
1366
  if (!ruleset) {
1313
1367
  actions.push({
@@ -1329,9 +1383,17 @@ function planContextRepairs({
1329
1383
  payload: rulesetPayload(ruleset, {
1330
1384
  add,
1331
1385
  remove,
1332
- awaited: awaited
1333
- .filter(record => record.ruleset === name)
1334
- .map(record => record.context),
1386
+ // Both halves of the same question — which of this ruleset's additions
1387
+ // an external app posts. A declaration that named this ruleset answers
1388
+ // it directly; one that named none was answered by the routing above.
1389
+ awaited: [
1390
+ ...new Set([
1391
+ ...awaited
1392
+ .filter(record => record.ruleset === name)
1393
+ .map(record => record.context),
1394
+ ...routedAwaited,
1395
+ ]),
1396
+ ],
1335
1397
  // The name-keyed map still carries AWAITED pins, which are declared per
1336
1398
  // context and have no ruleset of their own, so it stays the base. A
1337
1399
  // declared record then overrides it — with its pin, or by deleting the
@@ -1478,13 +1540,24 @@ export function reconcile({
1478
1540
 
1479
1541
  // The structured declaration travels through the comparison, so a pinned
1480
1542
  // context is not reported as satisfied by an unpinned one somewhere else.
1481
- // Only `configured` is passed: it is what the project actually declared. The
1482
- // awaited-context homes below are a routing hint for repairs, not a stated
1483
- // expectation, and treating them as one would report drift nobody declared.
1543
+ // The awaited-context home is part of that structure, not a repair-only
1544
+ // routing hint: it is the ruleset this run WILL write the context to, so a
1545
+ // comparison that ignored it reported the requirement satisfied by whichever
1546
+ // other ruleset happened to carry the name — and then planned no repair for
1547
+ // the one that lacked it. Where no home resolves, nothing is asserted and the
1548
+ // context keeps matching on name alone, so no drift is invented either.
1484
1549
  const resolvedAwaitedHome = awaitedHome(live, rulesetName);
1550
+ const declaredAwaitedPins = awaitedPins(gates, moment);
1551
+ // The declared pin travels with the declaration. Once the awaited home is a
1552
+ // comparison record, that record is what the repair reads its pin off — so
1553
+ // omitting it here would have the repair write the context unpinned and
1554
+ // silently discard the one app the project said may post it.
1485
1555
  const awaitedDeclarations = awaited.map(context => ({
1486
1556
  context,
1487
1557
  ...(resolvedAwaitedHome === null ? {} : { ruleset: resolvedAwaitedHome }),
1558
+ ...(Object.hasOwn(declaredAwaitedPins, context)
1559
+ ? { integration_id: declaredAwaitedPins[context] }
1560
+ : {}),
1488
1561
  }));
1489
1562
  const contexts = reconcileContexts({
1490
1563
  declared,
@@ -1514,7 +1587,7 @@ export function reconcile({
1514
1587
  prune,
1515
1588
  rulesetName,
1516
1589
  awaited: awaitedDeclarations,
1517
- pins: { ...configured.pins, ...awaitedPins(gates, moment) },
1590
+ pins: { ...configured.pins, ...declaredAwaitedPins },
1518
1591
  // An awaited context has no ruleset in its declaration — it is declared
1519
1592
  // on a GATE. Its home defaults to the ruleset Lisa generates from
1520
1593
  // config, which is where the applier writes it, so the two writers
@@ -2508,30 +2508,89 @@ function option(args, name, envName) {
2508
2508
  }
2509
2509
 
2510
2510
  /**
2511
- * Resolve the canonical pull-request option without letting an environment
2512
- * fallback outrank either explicit alias.
2511
+ * Read one explicit pull-request alias, refusing a present-but-empty flag.
2512
+ *
2513
+ * Written as "absent, or a usable value" — never "absent, or absent-looking".
2514
+ * The shared `option` helper treats a valueless flag as absent so a good value
2515
+ * in the environment still wins, which is right for `--ref` and wrong here: an
2516
+ * operator who typed `--pr-url` has NAMED the evidence, and quietly answering
2517
+ * with `LISA_PR_URL` writes a backlink to whatever pull request the surrounding
2518
+ * automation happened to export. Measured before this refusal existed:
2519
+ * `backlink --ref <item> --pr-url --json` with `LISA_PR_URL` set to a different
2520
+ * pull request wrote the managed comment pointing at the environment's pull
2521
+ * request and exited 0, so the traceability gate went green having proved the
2522
+ * work item was linked to the wrong change.
2523
+ *
2524
+ * A present flag whose value is empty, whitespace, or another option is the
2525
+ * same typo with three spellings, and all three are refused rather than
2526
+ * resolved — a refusal costs one re-run, and the alternative is evidence
2527
+ * nobody chose.
2528
+ * @param {string[]} args Command arguments.
2529
+ * @param {string} name Alias to read, including leading dashes.
2530
+ * @returns {string | undefined} The supplied value, or undefined when absent.
2531
+ */
2532
+ function explicitPullRequestUrl(args, name) {
2533
+ const index = args.indexOf(name);
2534
+ if (index < 0) return undefined;
2535
+ const value = args[index + 1];
2536
+ if (value === undefined || value.startsWith("-") || value.trim() === "") {
2537
+ throw new TrackingError(
2538
+ `${name} was supplied without a pull-request URL. Pass ` +
2539
+ `${name} https://github.com/owner/repo/pull/123, or omit the flag ` +
2540
+ `entirely to fall back to LISA_PR_URL`
2541
+ );
2542
+ }
2543
+ return value;
2544
+ }
2545
+
2546
+ /**
2547
+ * Resolve the canonical pull-request URL for backlink, validation and
2548
+ * completion, without letting an environment fallback outrank either alias.
2549
+ *
2550
+ * Two rules, and both exist because the backlink is how completion is PROVEN.
2513
2551
  *
2514
- * Resolving `--pr-url` with an environment fallback before inspecting `--url`
2515
- * lets `LISA_PR_URL` hide an explicit alias. Conflicting explicit aliases are
2516
- * refused instead of choosing completion evidence by argument order.
2552
+ * Precedence: `LISA_PR_URL` is read only when neither explicit alias is
2553
+ * present. Resolving `--pr-url` with an environment fallback before inspecting
2554
+ * `--url` let the environment hide an explicit alias.
2555
+ *
2556
+ * Canonicalisation: the value returned is the parsed canonical URL, not the
2557
+ * caller's string. `assertBacklink` compares URLs by equality — token equality
2558
+ * in `textContainsBacklink`, `===` on both native link fields — and the
2559
+ * completion path already canonicalises through `mergedPullRequestEvidence`.
2560
+ * A backlink WRITTEN from the raw string therefore did not match the same pull
2561
+ * request READ back a moment later if the two spellings differed by a trailing
2562
+ * slash, and the failure surfaces as "no verified backlink" on work that is
2563
+ * genuinely linked. Canonicalising here puts one spelling on both sides. It
2564
+ * also means a value that is not a GitHub pull-request URL at all is refused
2565
+ * where it is supplied, rather than written into the managed comment and
2566
+ * discovered later by the check it was supposed to satisfy.
2567
+ *
2568
+ * Conflicting explicit aliases are refused on the RAW strings, so two
2569
+ * spellings of one pull request conflict just as two different pull requests
2570
+ * do. The looser rule would have to canonicalise first and then accept, and
2571
+ * accepting evidence the caller spelled two ways is the direction that reports
2572
+ * unproven work as proven.
2573
+ *
2574
+ * An empty `LISA_PR_URL` is absent, unlike an empty `--pr-url`: exporting an
2575
+ * empty variable is how a shell says "unset" and no operator typed it, so the
2576
+ * caller still gets the command's own "requires --pr-url <url>" refusal
2577
+ * instead of a parse error about a string nobody wrote.
2517
2578
  * @param {string[]} args Command arguments.
2518
- * @returns {string | undefined} The explicit or environment URL.
2579
+ * @returns {string | undefined} The canonical URL, or undefined when none.
2519
2580
  */
2520
2581
  function pullRequestUrlOption(args) {
2521
- const explicit = name => {
2522
- const index = args.indexOf(name);
2523
- if (index < 0) return undefined;
2524
- const value = args[index + 1];
2525
- return value === undefined || value.startsWith("-") ? undefined : value;
2526
- };
2527
- const canonical = explicit("--pr-url");
2528
- const alias = explicit("--url");
2582
+ const canonical = explicitPullRequestUrl(args, "--pr-url");
2583
+ const alias = explicitPullRequestUrl(args, "--url");
2529
2584
  if (canonical && alias && canonical !== alias) {
2530
2585
  throw new TrackingError(
2531
2586
  "Conflicting pull-request evidence: --pr-url and --url name different values"
2532
2587
  );
2533
2588
  }
2534
- return canonical ?? alias ?? process.env.LISA_PR_URL;
2589
+ const supplied = canonical ?? alias;
2590
+ if (supplied !== undefined) return githubPullRequestUrl(supplied).url;
2591
+ const fromEnvironment = String(process.env.LISA_PR_URL ?? "").trim();
2592
+ if (fromEnvironment === "") return undefined;
2593
+ return githubPullRequestUrl(fromEnvironment).url;
2535
2594
  }
2536
2595
 
2537
2596
  function bind(args) {