@codyswann/lisa 4.22.11 → 4.22.13

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 (181) hide show
  1. package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +4 -2
  2. package/all/copy-overwrite/scripts/lib/process-tree-runner.mjs +62 -11
  3. package/all/copy-overwrite/scripts/lisa-environment-prepare.mjs +10 -1
  4. package/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +1 -0
  5. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +43 -2
  6. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +19 -5
  7. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +13 -1
  8. package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +2 -2
  9. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  10. package/dist/core/lisa-owned-hash-ledger.js +24 -0
  11. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  12. package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts +5 -0
  13. package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts.map +1 -1
  14. package/dist/core/nightly-e2e-guard-behavior-certificate.js +8 -2
  15. package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
  16. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  17. package/dist/core/upstream-evidence-manifest.js +34 -32
  18. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  19. package/dist/opencode/plugin-templates/block-no-verify.sh +43 -2
  20. package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +4 -3
  21. package/dist/opencode/plugin-templates/lisa-block-no-verify.ts +11 -2
  22. package/dist/opencode/plugin-templates/parity-safety-net.sh +19 -5
  23. package/dist/strategies/copy-contents.d.ts +1 -1
  24. package/dist/strategies/copy-contents.d.ts.map +1 -1
  25. package/dist/strategies/copy-contents.js +6 -2
  26. package/dist/strategies/copy-contents.js.map +1 -1
  27. package/expo/create-only/.github/workflows/nightly-e2e-report.yml +1 -0
  28. package/package.json +3 -3
  29. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +7 -8
  32. package/plugins/lisa/.codex-plugin/skills/lisa-github-prd-intake/SKILL.md +10 -11
  33. package/plugins/lisa/.codex-plugin/skills/lisa-github-sync/SKILL.md +1 -1
  34. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-prd/SKILL.md +14 -12
  35. package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +7 -8
  36. package/plugins/lisa/.codex-plugin/skills/lisa-jira-journey/scripts/parse-plan.py +54 -12
  37. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +9 -10
  38. package/plugins/lisa/.codex-plugin/skills/lisa-linear-evidence/SKILL.md +1 -1
  39. package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +1 -1
  40. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +2 -1
  41. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +4 -3
  42. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-sync/SKILL.md +2 -2
  43. package/plugins/lisa/hooks/block-no-verify.sh +43 -2
  44. package/plugins/lisa/hooks/parity-safety-net.sh +19 -5
  45. package/plugins/lisa/hooks/threshold-ratchet-families.mjs +10 -1
  46. package/plugins/lisa/rules/reference/config-resolution.md +0 -4
  47. package/plugins/lisa/rules/reference/leaf-only-lifecycle.md +1 -1
  48. package/plugins/lisa/scripts/resolve-lifecycle-role.mjs +15 -3
  49. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +7 -8
  50. package/plugins/lisa/skills/lisa-github-prd-intake/SKILL.md +10 -11
  51. package/plugins/lisa/skills/lisa-github-sync/SKILL.md +1 -1
  52. package/plugins/lisa/skills/lisa-github-write-prd/SKILL.md +14 -12
  53. package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +7 -8
  54. package/plugins/lisa/skills/lisa-jira-journey/scripts/parse-plan.py +54 -12
  55. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +9 -10
  56. package/plugins/lisa/skills/lisa-linear-evidence/SKILL.md +1 -1
  57. package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +1 -1
  58. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +2 -1
  59. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +4 -3
  60. package/plugins/lisa/skills/lisa-tracker-sync/SKILL.md +2 -2
  61. package/plugins/lisa-agy/hooks/parity-safety-net.sh +19 -5
  62. package/plugins/lisa-agy/plugin.json +1 -1
  63. package/plugins/lisa-agy/scripts/resolve-lifecycle-role.mjs +15 -3
  64. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +7 -8
  65. package/plugins/lisa-agy/skills/lisa-github-prd-intake/SKILL.md +10 -11
  66. package/plugins/lisa-agy/skills/lisa-github-sync/SKILL.md +1 -1
  67. package/plugins/lisa-agy/skills/lisa-github-write-prd/SKILL.md +14 -12
  68. package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +7 -8
  69. package/plugins/lisa-agy/skills/lisa-jira-journey/scripts/parse-plan.py +54 -12
  70. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +9 -10
  71. package/plugins/lisa-agy/skills/lisa-linear-evidence/SKILL.md +1 -1
  72. package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +1 -1
  73. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +2 -1
  74. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +4 -3
  75. package/plugins/lisa-agy/skills/lisa-tracker-sync/SKILL.md +2 -2
  76. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  79. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-copilot/hooks/block-no-verify.sh +43 -2
  83. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +19 -5
  84. package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +10 -1
  85. package/plugins/lisa-copilot/rules/reference/config-resolution.md +0 -4
  86. package/plugins/lisa-copilot/rules/reference/leaf-only-lifecycle.md +1 -1
  87. package/plugins/lisa-copilot/scripts/resolve-lifecycle-role.mjs +15 -3
  88. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +7 -8
  89. package/plugins/lisa-copilot/skills/lisa-github-prd-intake/SKILL.md +10 -11
  90. package/plugins/lisa-copilot/skills/lisa-github-sync/SKILL.md +1 -1
  91. package/plugins/lisa-copilot/skills/lisa-github-write-prd/SKILL.md +14 -12
  92. package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +7 -8
  93. package/plugins/lisa-copilot/skills/lisa-jira-journey/scripts/parse-plan.py +54 -12
  94. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +9 -10
  95. package/plugins/lisa-copilot/skills/lisa-linear-evidence/SKILL.md +1 -1
  96. package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +1 -1
  97. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +2 -1
  98. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +4 -3
  99. package/plugins/lisa-copilot/skills/lisa-tracker-sync/SKILL.md +2 -2
  100. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-cursor/hooks/block-no-verify.sh +43 -2
  102. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +19 -5
  103. package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +10 -1
  104. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +0 -4
  105. package/plugins/lisa-cursor/rules/leaf-only-lifecycle-reference.mdc +1 -1
  106. package/plugins/lisa-cursor/scripts/resolve-lifecycle-role.mjs +15 -3
  107. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +7 -8
  108. package/plugins/lisa-cursor/skills/lisa-github-prd-intake/SKILL.md +10 -11
  109. package/plugins/lisa-cursor/skills/lisa-github-sync/SKILL.md +1 -1
  110. package/plugins/lisa-cursor/skills/lisa-github-write-prd/SKILL.md +14 -12
  111. package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +7 -8
  112. package/plugins/lisa-cursor/skills/lisa-jira-journey/scripts/parse-plan.py +54 -12
  113. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +9 -10
  114. package/plugins/lisa-cursor/skills/lisa-linear-evidence/SKILL.md +1 -1
  115. package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +1 -1
  116. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +2 -1
  117. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +4 -3
  118. package/plugins/lisa-cursor/skills/lisa-tracker-sync/SKILL.md +2 -2
  119. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  120. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  121. package/plugins/lisa-expo-agy/plugin.json +1 -1
  122. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  123. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  124. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  125. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  126. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  127. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  128. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  129. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  130. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  131. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  132. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  133. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  134. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  135. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  136. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  137. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  138. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  139. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  140. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  141. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  142. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  143. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  144. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  145. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  146. package/plugins/lisa-rails-agy/plugin.json +1 -1
  147. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  148. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  149. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  150. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  151. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  152. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  153. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  154. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  155. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  156. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  157. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  158. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  159. package/plugins/src/base/hooks/block-no-verify.sh +43 -2
  160. package/plugins/src/base/hooks/parity-safety-net.sh +19 -5
  161. package/plugins/src/base/hooks/threshold-ratchet-families.mjs +10 -1
  162. package/plugins/src/base/rules/reference/config-resolution.md +0 -4
  163. package/plugins/src/base/rules/reference/leaf-only-lifecycle.md +1 -1
  164. package/plugins/src/base/scripts/resolve-lifecycle-role.mjs +15 -3
  165. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +7 -8
  166. package/plugins/src/base/skills/lisa-github-prd-intake/SKILL.md +10 -11
  167. package/plugins/src/base/skills/lisa-github-sync/SKILL.md +1 -1
  168. package/plugins/src/base/skills/lisa-github-write-prd/SKILL.md +14 -12
  169. package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +7 -8
  170. package/plugins/src/base/skills/lisa-jira-journey/scripts/parse-plan.py +54 -12
  171. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +9 -10
  172. package/plugins/src/base/skills/lisa-linear-evidence/SKILL.md +1 -1
  173. package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +1 -1
  174. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +2 -1
  175. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +4 -3
  176. package/plugins/src/base/skills/lisa-tracker-sync/SKILL.md +2 -2
  177. package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +10 -1
  178. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +10 -13
  179. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +34 -3
  180. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +17 -1
  181. package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +10 -1
@@ -37,11 +37,11 @@ Two consequences the vendor arms must not restate differently:
37
37
  Resolve roles through the shared resolver rather than an inlined helper:
38
38
 
39
39
  ```bash
40
- node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
40
+ node "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-plugins/lisa}}/scripts/resolve-lifecycle-role.mjs" \
41
41
  --role <role> --vendor <jira|linear|github> --intent <read|write> [--env <env>]
42
42
  ```
43
43
 
44
- Exit `0` with a value means configured; exit `0` with **empty** output means an optional role is unset — skip the transition; exit `2` means a required role is unset or a write was refused a fallback value.
44
+ Exit `0` with a value means configured; exit `0` with **empty** output means an optional role is unset — skip the transition; exit `2` means a required role is unset or a write was refused a fallback value. Any other exit is a resolver failure, never an unset role.
45
45
 
46
46
  If `$ARGUMENTS` is empty, all vendor skills auto-detect a ticket reference from the active plan file (most recently modified `.md` in `plans/`).
47
47
 
@@ -23,7 +23,7 @@
23
23
  # Add a name here in the same commit that closes a vector. A hardening that
24
24
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
25
25
  # review time instead of a named capability.
26
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short
26
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, 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
27
27
  #
28
28
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
29
29
  # commit-message prose while still catching quoted real argv values such as
@@ -216,6 +216,9 @@ COMMAND_SEPARATORS = {
216
216
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
217
217
  }
218
218
 
219
+ SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
220
+ MAX_SHELL_NESTING = 8
221
+
219
222
  # git's own options that take a SEPARATE value token, which therefore must be
220
223
  # skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
221
224
  # `git -C /repo commit` both reach `commit`.
@@ -333,6 +336,8 @@ def commit_bypasses_verification(argv):
333
336
  if token.startswith("--"):
334
337
  if token in COMMIT_LONG_SEPARATE_VALUE:
335
338
  index += 1
339
+ elif disables_verification(token):
340
+ return True
336
341
  continue
337
342
  # A bare `-` is git's stdin placeholder, not an option cluster.
338
343
  if not token.startswith("-") or token == "-":
@@ -366,11 +371,25 @@ def subcommand_after_git(tokens, start):
366
371
  return None
367
372
 
368
373
 
369
- def git_commit_skips_verification(text):
374
+ def shell_starts_command(tokens, index):
375
+ """Whether a recognized shell token occupies command position."""
376
+ start = index - 1
377
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
378
+ start -= 1
379
+ prefix = tokens[start + 1:index]
380
+ if not prefix:
381
+ return True
382
+ if prefix[0] == "env":
383
+ prefix = prefix[1:]
384
+ return all("=" in token and not token.startswith("=") for token in prefix)
385
+
386
+
387
+ def git_commit_skips_verification(text, depth=0):
370
388
  """Whether the command runs `git commit` with the short `-n` bypass.
371
389
 
372
390
  Args:
373
391
  text: The command line, heredoc payloads already stripped.
392
+ depth: Number of recognized shell payloads already traversed.
374
393
 
375
394
  Returns:
376
395
  True if any `git commit` invocation on the line skips verification.
@@ -387,6 +406,28 @@ def git_commit_skips_verification(text):
387
406
  found = subcommand_after_git(scoped_tokens, index + 1)
388
407
  if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
389
408
  return True
409
+ if depth >= MAX_SHELL_NESTING:
410
+ return False
411
+ for index, token in enumerate(scoped_tokens):
412
+ program = token.rsplit("/", 1)[-1]
413
+ if program not in SHELL_PROGRAMS or not shell_starts_command(
414
+ scoped_tokens, index
415
+ ):
416
+ continue
417
+ cursor = index + 1
418
+ while cursor < len(scoped_tokens):
419
+ option = scoped_tokens[cursor]
420
+ if option in COMMAND_SEPARATORS or not option.startswith("-"):
421
+ break
422
+ cursor += 1
423
+ if "c" not in option[1:]:
424
+ continue
425
+ if cursor >= len(scoped_tokens):
426
+ break
427
+ payload = scoped_tokens[cursor]
428
+ if git_commit_skips_verification(payload, depth + 1):
429
+ return True
430
+ break
390
431
  return False
391
432
 
392
433
 
@@ -724,6 +724,10 @@ classify_rm_target() {
724
724
  set +f
725
725
  block "recursive forced delete of the current directory (rm -rf .)"
726
726
  ;;
727
+ .git | .git/* | */.git | */.git/*)
728
+ set +f
729
+ 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)"
730
+ ;;
727
731
  .. | ../* | */.. | */../*)
728
732
  set +f
729
733
  block "recursive forced delete of a path outside the project (.. traversal)"
@@ -1031,11 +1035,16 @@ fi
1031
1035
  # purpose, and blocking those would be the collision that gets a guard switched
1032
1036
  # off. The trailing `([/[:space:]'"$qc"']|$)` is what draws that line.
1033
1037
  readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$)'
1034
- if matches_cs "$RM_RF_CLUSTER" || matches_cs "$RM_RF_SPLIT"; then
1035
- if matches_cs "$GIT_CONTROL_PLANE"; then
1038
+ while IFS= read -r git_stmt; do
1039
+ [ -n "$git_stmt" ] || continue
1040
+ if ! scan -E "$git_stmt" "$RM_RF_CLUSTER" \
1041
+ && ! scan -E "$git_stmt" "$RM_RF_SPLIT"; then
1042
+ continue
1043
+ fi
1044
+ if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
1036
1045
  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, or re-clone if the checkout is genuinely to be discarded."
1037
1046
  fi
1038
- fi
1047
+ done <<< "$(printf '%s' "$normalized_command_str" | tr '&|;' '\n')"
1039
1048
 
1040
1049
  # 15. Credential stores. Reading one into an agent transcript is disclosure even
1041
1050
  # when nothing is copied anywhere: the value lands in a log, a context window,
@@ -1077,14 +1086,19 @@ has_private_dotenv_path() {
1077
1086
  | grep -Eio -- '(^|[[:space:]='"$qc"'./])\.env(\.[[:alnum:]_-]+)?')" \
1078
1087
  || dotenv_scan_status=$?
1079
1088
  if [ "$dotenv_scan_status" -gt 1 ]; then
1080
- block "dotenv path classification failed; denying the credential-store read fail-closed."
1089
+ scan_failed "$dotenv_scan_status"
1081
1090
  fi
1082
1091
 
1083
1092
  while IFS= read -r candidate; do
1084
1093
  [ -n "$candidate" ] || continue
1094
+ dotenv_normalize_status=0
1085
1095
  candidate="$(printf '%s' "$candidate" \
1086
1096
  | tr '[:upper:]' '[:lower:]' \
1087
- | grep -Eo -- '\.env(\.[[:alnum:]_-]+)?')"
1097
+ | grep -Eo -- '\.env(\.[[:alnum:]_-]+)?')" \
1098
+ || dotenv_normalize_status=$?
1099
+ if [ "$dotenv_normalize_status" -gt 1 ] || [ -z "$candidate" ]; then
1100
+ scan_failed "$dotenv_normalize_status"
1101
+ fi
1088
1102
  case "$candidate" in
1089
1103
  .env.example | .env.sample | .env.template | .env.dist | .env.schema | .env.defaults) ;;
1090
1104
  *) return 0 ;;
@@ -79,7 +79,7 @@ export const FAMILIES = [
79
79
  },
80
80
  {
81
81
  id: "lighthouse",
82
- match: /(^|\/)lighthouserc-config\.json$/,
82
+ match: /(^|\/)(?:lighthouserc-config|lighthouserc|\.lighthouserc)\.json$/,
83
83
  kind: "lighthouse",
84
84
  },
85
85
  ];
@@ -98,6 +98,8 @@ export const LIGHTHOUSE_ASSERTION_DIRECTIONS = Object.freeze({
98
98
  maxLength: "max",
99
99
  });
100
100
 
101
+ const LIGHTHOUSE_LEVELS = Object.freeze({ off: 0, warn: 1, error: 2 });
102
+
101
103
  /**
102
104
  * Extract numeric Lighthouse assertions with a direction chosen per key.
103
105
  *
@@ -133,6 +135,13 @@ export function extractLighthouseAssertions(conf) {
133
135
  return out;
134
136
  }
135
137
  for (const [audit, spec] of Object.entries(assertions)) {
138
+ const level = Array.isArray(spec) ? spec[0] : spec;
139
+ if (typeof level === "string" && Object.hasOwn(LIGHTHOUSE_LEVELS, level)) {
140
+ out.set(`${audit}.$level`, {
141
+ value: LIGHTHOUSE_LEVELS[level],
142
+ direction: "min",
143
+ });
144
+ }
136
145
  const options =
137
146
  Array.isArray(spec) &&
138
147
  spec.length >= 2 &&
@@ -909,10 +909,6 @@ read_notion_token() {
909
909
  if [ -n "${PLUGIN_ROOT:-}" ]; then
910
910
  candidates+=("$PLUGIN_ROOT/skills/lisa-secrets-access/scripts/resolve-secret.mjs")
911
911
  fi
912
- # Last rung deliberately needs no environment variable: an agent that was
913
- # never handed a plugin root still has the installed package to fall back on.
914
- candidates+=(node_modules/@codyswann/lisa/plugins/lisa/skills/lisa-secrets-access/scripts/resolve-secret.mjs)
915
-
916
912
  local resolver
917
913
  local tried=()
918
914
  for resolver in "${candidates[@]}"; do
@@ -83,7 +83,7 @@ Notes:
83
83
 
84
84
  ### Classifying a hold
85
85
 
86
- `rollup-blocker-classification` (`plugins/src/base/scripts/rollup-blocker-classification.mjs`) is the single implementation. It takes the child graph the vendor reader already produced and returns, per held leaf, a class and the actor who must clear it:
86
+ `rollup-blocker-classification` (`scripts/rollup-blocker-classification.mjs` under the active Lisa plugin root) is the single installed implementation. It takes the child graph the vendor reader already produced and returns, per held leaf, a class and the actor who must clear it:
87
87
 
88
88
  | Class | Recorded signal on the leaf | Who must act | Clears on its own? |
89
89
  |---|---|---|---|
@@ -94,6 +94,9 @@ export const VENDOR_ROOTS = Object.freeze({
94
94
  github: "github.labels.build",
95
95
  });
96
96
 
97
+ /** GitHub's PRD lifecycle is a separate required role map from build work. */
98
+ const GITHUB_PRD_PREFIX = "prd.";
99
+
97
100
  /** Resolution outcomes, reported so a caller never has to guess what happened. */
98
101
  export const OUTCOMES = Object.freeze({
99
102
  CONFIGURED: "configured",
@@ -170,7 +173,12 @@ export const resolveRole = ({
170
173
  global: globalConfig,
171
174
  fallback,
172
175
  }) => {
173
- const root = VENDOR_ROOTS[vendor];
176
+ let root = VENDOR_ROOTS[vendor];
177
+ let configuredRole = role;
178
+ if (vendor === "github" && role.startsWith(GITHUB_PRD_PREFIX)) {
179
+ root = "github.labels.prd";
180
+ configuredRole = role.slice(GITHUB_PRD_PREFIX.length);
181
+ }
174
182
  if (!root) {
175
183
  return {
176
184
  value: "",
@@ -182,7 +190,9 @@ export const resolveRole = ({
182
190
 
183
191
  // `done` is env-keyed on every vendor; an env selects one rung of the map.
184
192
  const path =
185
- env && role === "done" ? `${root}.done.${env}` : `${root}.${role}`;
193
+ env && configuredRole === "done"
194
+ ? `${root}.done.${env}`
195
+ : `${root}.${configuredRole}`;
186
196
 
187
197
  for (const [label, source] of [
188
198
  ["local", local],
@@ -199,7 +209,9 @@ export const resolveRole = ({
199
209
  }
200
210
  }
201
211
 
202
- const optional = OPTIONAL_ROLES.includes(role);
212
+ const optional =
213
+ !role.startsWith(GITHUB_PRD_PREFIX) &&
214
+ OPTIONAL_ROLES.includes(configuredRole);
203
215
 
204
216
  // R2 — a fallback may inform a read, never supply a write target.
205
217
  if (fallback && intent === "write") {
@@ -36,18 +36,17 @@ selection-only: never assign or reassign issues as part of build intake.
36
36
  Build-queue label names are read from `.lisa.config.json` `github.labels.build.*`, falling back to defaults documented in the `config-resolution` rule. Bash pattern:
37
37
 
38
38
  ```bash
39
- # Read role with default fallback. Local overrides global per-key.
40
39
  read_role() {
41
40
  # Single resolver — see config-resolution "The single resolver".
42
- # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
43
- # never substitute the second argument. A default on an optional role is what
44
- # made "unset" indistinguishable from "not customized".
45
- node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
46
- --role "$1" --vendor github --intent "${3:-read}" 2>/dev/null
41
+ local value
42
+ value=$(node "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-plugins/lisa}}/scripts/resolve-lifecycle-role.mjs" \
43
+ --role "$1" --vendor github --intent "${2:-read}") || return $?
44
+ [ -n "$value" ] || { echo "Error: required GitHub role '$1' resolved empty." >&2; return 2; }
45
+ printf '%s\n' "$value"
47
46
  }
48
47
 
49
- READY=$(read_role ready "status:ready")
50
- CLAIMED=$(read_role claimed "status:in-progress")
48
+ READY=$(read_role ready read) || exit $?
49
+ CLAIMED=$(read_role claimed write) || exit $?
51
50
 
52
51
  read_intake_assignee() {
53
52
  local cli_value local_v
@@ -19,21 +19,20 @@ Run one intake cycle against that repo. The first eligible issue with the `ready
19
19
  PRD label names are read from `.lisa.config.json` `github.labels.prd.*`, falling back to defaults documented in the `config-resolution` rule. Bash pattern:
20
20
 
21
21
  ```bash
22
- # Read role with default fallback. Local overrides global per-key.
23
22
  read_role() {
24
23
  # Single resolver — see config-resolution "The single resolver".
25
- # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
26
- # never substitute the second argument. A default on an optional role is what
27
- # made "unset" indistinguishable from "not customized".
28
- node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
29
- --role "$1" --vendor github --intent "${3:-read}" 2>/dev/null
24
+ local value
25
+ value=$(node "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-plugins/lisa}}/scripts/resolve-lifecycle-role.mjs" \
26
+ --role "$1" --vendor github --intent "${2:-write}") || return $?
27
+ [ -n "$value" ] || { echo "Error: required GitHub PRD role '$1' resolved empty." >&2; return 2; }
28
+ printf '%s\n' "$value"
30
29
  }
31
30
 
32
- READY=$(read_role ready "prd-ready")
33
- IN_REVIEW=$(read_role in_review "prd-in-review")
34
- BLOCKED=$(read_role blocked "prd-blocked")
35
- TICKETED=$(read_role ticketed "prd-ticketed")
36
- SHIPPED=$(read_role shipped "prd-shipped")
31
+ READY=$(read_role prd.ready write) || exit $?
32
+ IN_REVIEW=$(read_role prd.in_review write) || exit $?
33
+ BLOCKED=$(read_role prd.blocked write) || exit $?
34
+ TICKETED=$(read_role prd.ticketed write) || exit $?
35
+ SHIPPED=$(read_role prd.shipped write) || exit $?
37
36
  ```
38
37
 
39
38
  In prose below, the role names refer to the resolved labels: e.g. "the `ready` label" means whatever `github.labels.prd.ready` resolves to (default: `prd-ready`).
@@ -129,7 +129,7 @@ classifier absent.
129
129
 
130
130
  **Single-environment collapse (this repo).** `.lisa.config.json` `deploy.branches` declares only `production: main`, so the env-keyed `done` resolves to the single label `status:done` — there is no `status:on-dev` / `status:on-stg` and **no dev → staging → prod promotion chain**. Resolve the env rungs via the env-keyed `done` logic in `config-resolution`, but in the single-environment case the only rung is production and it collapses to the one `status:done` value; the rollup never attempts to resolve a dev or staging `done`. Projects that DO have multiple environments keep the env-keyed map and roll the parent up to whichever `done` (including intermediate `status:on-dev`/`status:on-stg`) its leaves have collectively reached.
131
131
 
132
- **Apply the derived label** (only when it differs from the parent's current `status:*`): remove the parent's existing `status:*` label and add the derived one, keeping exactly one `status:*` label so the build-queue invariant holds. Post an idempotent `[claude-sync] rollup` comment naming the derived state and the child tally (e.g. `3/4 leaves terminal, 1 blocked → status:blocked`); when the derived state is `status:blocked` the comment also carries the classifier's per-class section, so the blocking child and its actor are one read rather than a descent. Skip the comment if an identical one is already the most recent rollup comment — the classifier's fingerprint is the dedupe key, and an unchanged verdict is not re-posted.
132
+ **Apply the derived label** (only when it differs from the parent's current `status:*`): remove the parent's existing `status:*` label and add the derived one, keeping exactly one `status:*` label so the build-queue invariant holds. Re-read the labels immediately after the edit and require exactly one `status:*`; if the count differs, surface an error and stop without trying to normalize the ambiguous set automatically. Post an idempotent `[claude-sync] rollup` comment naming the derived state and the child tally (e.g. `3/4 leaves terminal, 1 blocked → status:blocked`); when the derived state is `status:blocked` the comment also carries the classifier's per-class section, so the blocking child and its actor are one read rather than a descent. Use the classifier's `change.summary` as the dedupe key and skip the comment when that summary is unchanged.
133
133
 
134
134
  ```bash
135
135
  gh issue edit <parent-number> --repo <org>/<repo> \
@@ -22,21 +22,23 @@ REPO=$(jq -r '.github.repo // empty' .lisa.config.local.json 2>/dev/null); REPO=
22
22
 
23
23
  read_role() {
24
24
  # Single resolver — see config-resolution "The single resolver".
25
- # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
26
- # never substitute the second argument. A default on an optional role is what
27
- # made "unset" indistinguishable from "not customized".
28
- node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
29
- --role "$1" --vendor github --intent "${3:-read}" 2>/dev/null
25
+ # Every PRD role used here is required. Resolver errors remain visible, and
26
+ # an empty result is refused rather than passed to `gh --label`.
27
+ local value
28
+ value=$(node "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-plugins/lisa}}/scripts/resolve-lifecycle-role.mjs" \
29
+ --role "$1" --vendor github --intent "${2:-write}") || return $?
30
+ [ -n "$value" ] || { echo "Error: required GitHub role '$1' resolved empty." >&2; return 2; }
31
+ printf '%s\n' "$value"
30
32
  }
31
33
  # Resolve the FULL PRD lifecycle vocabulary from config (never hard-code names) — needed so the
32
34
  # "exactly one role" reconcile and the past-ready check work for projects that renamed any label.
33
- PRD_DRAFT=$(read_role '.github.labels.prd.draft' 'prd-draft')
34
- PRD_READY=$(read_role '.github.labels.prd.ready' 'prd-ready')
35
- PRD_IN_REVIEW=$(read_role '.github.labels.prd.in_review' 'prd-in-review')
36
- PRD_BLOCKED=$(read_role '.github.labels.prd.blocked' 'prd-blocked')
37
- PRD_TICKETED=$(read_role '.github.labels.prd.ticketed' 'prd-ticketed')
38
- PRD_SHIPPED=$(read_role '.github.labels.prd.shipped' 'prd-shipped')
39
- PRD_VERIFIED=$(read_role '.github.labels.prd.verified' 'prd-verified')
35
+ PRD_DRAFT=$(read_role prd.draft write) || exit $?
36
+ PRD_READY=$(read_role prd.ready write) || exit $?
37
+ PRD_IN_REVIEW=$(read_role prd.in_review write) || exit $?
38
+ PRD_BLOCKED=$(read_role prd.blocked write) || exit $?
39
+ PRD_TICKETED=$(read_role prd.ticketed write) || exit $?
40
+ PRD_SHIPPED=$(read_role prd.shipped write) || exit $?
41
+ PRD_VERIFIED=$(read_role prd.verified write) || exit $?
40
42
  # All lifecycle labels (for one-of reconcile) and the "progressed past ready" set (never down-rank):
41
43
  ALL_PRD_LABELS=("$PRD_DRAFT" "$PRD_READY" "$PRD_IN_REVIEW" "$PRD_BLOCKED" "$PRD_TICKETED" "$PRD_SHIPPED" "$PRD_VERIFIED")
42
44
  PROGRESSED=("$PRD_IN_REVIEW" "$PRD_BLOCKED" "$PRD_TICKETED" "$PRD_SHIPPED" "$PRD_VERIFIED")
@@ -20,18 +20,17 @@ Run one build-intake cycle. The first eligible ready ticket is claimed, built vi
20
20
  Status names are read from `.lisa.config.json` `jira.workflow.*`, falling back to defaults documented in the `config-resolution` rule. Bash pattern:
21
21
 
22
22
  ```bash
23
- # Read role with default fallback. Local overrides global per-key.
24
23
  read_role() {
25
24
  # Single resolver — see config-resolution "The single resolver".
26
- # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
27
- # never substitute the second argument. A default on an optional role is what
28
- # made "unset" indistinguishable from "not customized".
29
- node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
30
- --role "$1" --vendor jira --intent "${3:-read}" 2>/dev/null
25
+ local value
26
+ value=$(node "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-plugins/lisa}}/scripts/resolve-lifecycle-role.mjs" \
27
+ --role "$1" --vendor jira --intent "${2:-read}") || return $?
28
+ [ -n "$value" ] || { echo "Error: required JIRA role '$1' resolved empty." >&2; return 2; }
29
+ printf '%s\n' "$value"
31
30
  }
32
31
 
33
- READY=$(read_role ready "Ready")
34
- CLAIMED=$(read_role claimed "In Progress")
32
+ READY=$(read_role ready read) || exit $?
33
+ CLAIMED=$(read_role claimed write) || exit $?
35
34
  ```
36
35
 
37
36
  For env-keyed `done`, resolve the env first, then look up `done[<env>]`:
@@ -33,6 +33,7 @@ import sys
33
33
  import urllib.request
34
34
  from base64 import b64encode
35
35
  from pathlib import Path
36
+ from urllib.parse import urlsplit
36
37
 
37
38
 
38
39
  # Intentionally matches the exact local-claim prefixes only. EVIDENCE-REF is a
@@ -66,13 +67,50 @@ def jira_config_candidates():
66
67
  return candidates
67
68
 
68
69
 
70
+ def read_jira_config(config_path):
71
+ """Read the server and login fields from one jira-cli config."""
72
+ server = ""
73
+ login = ""
74
+ with open(config_path) as config:
75
+ for line in config:
76
+ if line.startswith("server:"):
77
+ server = line.split(":", 1)[1].strip()
78
+ elif line.startswith("login:"):
79
+ login = line.split(":", 1)[1].strip()
80
+ return server, login
81
+
82
+
83
+ def server_origin(server):
84
+ """Return the normalized HTTPS origin used as the credential trust key."""
85
+ try:
86
+ parsed = urlsplit(server)
87
+ port = parsed.port
88
+ except ValueError:
89
+ return ""
90
+ if parsed.scheme.lower() != "https" or not parsed.hostname:
91
+ return ""
92
+ authority = parsed.hostname.lower()
93
+ if port and port != 443:
94
+ authority = f"{authority}:{port}"
95
+ return f"https://{authority}"
96
+
97
+
69
98
  def resolve_jira_config_path():
70
- """Resolve the first existing project config, then the home fallback."""
99
+ """Resolve a project config only when its host matches a user trust root."""
71
100
  candidates = jira_config_candidates()
72
- for candidate in candidates:
73
- if candidate.exists():
101
+ home_config = candidates[-1]
102
+ trusted_server = os.environ.get("JIRA_SERVER", "").strip()
103
+ if not trusted_server and home_config.exists():
104
+ trusted_server, _ = read_jira_config(home_config)
105
+ trusted_origin = server_origin(trusted_server)
106
+
107
+ for candidate in candidates[:-1]:
108
+ if not candidate.exists():
109
+ continue
110
+ candidate_server, _ = read_jira_config(candidate)
111
+ if trusted_origin and server_origin(candidate_server) == trusted_origin:
74
112
  return candidate
75
- return candidates[-1]
113
+ return home_config
76
114
 
77
115
 
78
116
  def get_jira_config():
@@ -82,14 +120,18 @@ def get_jira_config():
82
120
  print(f"ERROR: jira-cli config not found at {config_path}", file=sys.stderr)
83
121
  sys.exit(1)
84
122
 
85
- server = ""
86
- login = ""
87
- with open(config_path) as f:
88
- for line in f:
89
- if line.startswith("server:"):
90
- server = line.split(":", 1)[1].strip()
91
- elif line.startswith("login:"):
92
- login = line.split(":", 1)[1].strip()
123
+ server, login = read_jira_config(config_path)
124
+
125
+ trusted_server = os.environ.get("JIRA_SERVER", "").strip()
126
+ trusted_origin = server_origin(trusted_server)
127
+ if trusted_origin:
128
+ if server_origin(server) != trusted_origin:
129
+ print("ERROR: jira-cli config server does not match trusted JIRA_SERVER", file=sys.stderr)
130
+ sys.exit(1)
131
+ server = trusted_server.rstrip("/")
132
+ if not server_origin(server):
133
+ print("ERROR: JIRA server must be an HTTPS URL from a trusted config", file=sys.stderr)
134
+ sys.exit(1)
93
135
 
94
136
  token = os.environ.get("JIRA_API_TOKEN", "")
95
137
  if not token:
@@ -21,20 +21,19 @@ This skill is the destination of the `lisa-tracker-build-intake` shim when `trac
21
21
  Build-queue **workflow state** names are read from `.lisa.config.json` `linear.workflow.*`, falling back to defaults documented in the `config-resolution` rule. Bash pattern:
22
22
 
23
23
  ```bash
24
- # Read role with default fallback. Local overrides global per-key.
25
24
  read_role() {
26
25
  # Single resolver — see config-resolution "The single resolver".
27
26
  # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
28
- # never substitute the second argument. A default on an optional role is what
29
- # made "unset" indistinguishable from "not customized".
30
- node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
31
- --role "$1" --vendor linear --intent "${3:-read}" 2>/dev/null
27
+ # never substitute a default. Any non-zero status is a resolver failure and
28
+ # remains visible to the caller.
29
+ node "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-plugins/lisa}}/scripts/resolve-lifecycle-role.mjs" \
30
+ --role "$1" --vendor linear --intent "${2:-read}"
32
31
  }
33
32
 
34
- READY=$(read_role ready "Ready")
35
- CLAIMED=$(read_role claimed "In Progress")
36
- REVIEW=$(read_role review "In Review")
37
- BLOCKED=$(read_role blocked "Blocked")
33
+ READY=$(read_role ready read) || exit $?
34
+ CLAIMED=$(read_role claimed write) || exit $?
35
+ REVIEW=$(read_role review write) || exit $?
36
+ BLOCKED=$(read_role blocked read) || exit $?
38
37
  ```
39
38
 
40
39
  For env-keyed `done`, resolve the env first, then look up `done[<env>]`:
@@ -69,7 +68,7 @@ else
69
68
  fi
70
69
  ```
71
70
 
72
- In prose below, the role names refer to the resolved **states**: e.g. "the `ready` state" means whatever `linear.workflow.ready` resolves to (default: `Ready`).
71
+ In prose below, the role names refer to the configured **states**: e.g. "the `ready` state" means whatever `linear.workflow.ready` resolves to. Required roles must be configured; optional roles carry no default.
73
72
 
74
73
  ## Why native states, not labels
75
74
 
@@ -109,6 +109,6 @@ Return:
109
109
  ## Rules
110
110
 
111
111
  - Never modify the Issue description as part of evidence posting — comments only. Description edits go through `lisa-linear-write-issue`.
112
- - Never skip the label transition. The build queue is keyed off the configured `linear.workflow.*` labels; an item that ships without transitioning is invisible to monitoring.
112
+ - Never skip the transition when `review` is configured. An empty `$REVIEW` is the one sanctioned skip (Phase 5): post the evidence comment, leave the Issue in `$CLAIMED`, and report the skip. Whenever `$REVIEW` is non-empty, the configured native state transition is mandatory.
113
113
  - If `lisa-linear-access operation: save-comment` fails, retry once. If it fails again, surface the error — don't pretend the comment was posted.
114
114
  - Do not delete prior comments. The history is the audit trail.
@@ -152,7 +152,7 @@ When the caller passes `--rollup`, this skill **derives a parent/container's wor
152
152
 
153
153
  ## Rules
154
154
 
155
- - Never transition a leaf Issue's workflow `state` from this skill at all. `--rollup` (parent derivation, per the `leaf-only-lifecycle` rule) is the only write path that remains; every other milestone SUGGESTS a transition in its comment and nothing more. The state is the lifecycle lane, so a write here would move the item in the build queue.
155
+ - Never transition a leaf Issue's workflow `state` from this skill except for the narrowly defined Phase 4b backstop that reopens a leaf auto-closed at an intermediate environment. `--rollup` remains the only other write path (parent derivation, per the `leaf-only-lifecycle` rule); every normal milestone SUGGESTS a transition in its comment and nothing more. The state is the lifecycle lane, so an unscoped write here would move the item in the build queue.
156
156
  - Never resolve a role by searching the team's states. Only a name the project configured may be written; a `type`- or position-derived match may inform a read and must never supply a write target (`config-resolution`, R2).
157
157
  - Rollup derives a *parent's* workflow state from its children and never rolls a parent into the human-owned ready lane (never `$READY`). It cites the `leaf-only-lifecycle` rule by slug rather than restating the state machine.
158
158
  - Never post empty or minimal comments — if a milestone has no meaningful content, skip the post.
@@ -728,7 +728,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/rollup-blocker-classification.mjs" \
728
728
  report already asks a person to decide.
729
729
 
730
730
  3. **If the derived state differs from the parent's current state, apply it** via the vendor's
731
- lifecycle write (JIRA transition, GitHub/Linear label swap keeping exactly one `status:*`),
731
+ lifecycle write: a JIRA transition; a GitHub label swap keeping exactly one `status:*`; or a
732
+ Linear `save-issue` update that writes the resolved workflow `stateId`, never a lifecycle label.
732
733
  removing any conflicting stale build lifecycle role — **including a stale `ready`** the parent
733
734
  should never carry. Post an idempotent `[lisa-repair-intake]` rollup note naming the derived
734
735
  state and the child tally (honor the backoff window + fingerprint). Include that exact rendered
@@ -131,6 +131,7 @@ export function probe(name, exec = boundedChildOutput) {
131
131
  return { present: true, version: extractVersion(out) };
132
132
  } catch (err) {
133
133
  if (err.code === "ENOENT") return { present: false, version: null };
134
+ if (err.code === "ETIMEDOUT") throw err;
134
135
  const output = `${err.stdout ?? ""}${err.stderr ?? ""}`;
135
136
  return { present: true, version: extractVersion(output) };
136
137
  }
@@ -808,9 +809,9 @@ export function installUserSessionHook(repoRoot, options = {}) {
808
809
  const dir = join(home, ".claude");
809
810
  const settingsPath = join(dir, "settings.json");
810
811
  // Settings stores shell source, so the absolute path still needs quoting.
811
- // JSON string syntax is accepted by the shell for ordinary path text and
812
- // safely escapes spaces, quotes, dollars and backticks.
813
- const command = `bash ${JSON.stringify(script)}`;
812
+ // Single quotes suppress parameter expansion and command substitution. An
813
+ // embedded quote closes, escapes, and reopens the quoted shell word.
814
+ const command = `bash '${script.replaceAll("'", `'\\''`)}'`;
814
815
 
815
816
  let settings = {};
816
817
  if (exists(settingsPath)) {
@@ -37,11 +37,11 @@ Two consequences the vendor arms must not restate differently:
37
37
  Resolve roles through the shared resolver rather than an inlined helper:
38
38
 
39
39
  ```bash
40
- node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
40
+ node "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-plugins/lisa}}/scripts/resolve-lifecycle-role.mjs" \
41
41
  --role <role> --vendor <jira|linear|github> --intent <read|write> [--env <env>]
42
42
  ```
43
43
 
44
- Exit `0` with a value means configured; exit `0` with **empty** output means an optional role is unset — skip the transition; exit `2` means a required role is unset or a write was refused a fallback value.
44
+ Exit `0` with a value means configured; exit `0` with **empty** output means an optional role is unset — skip the transition; exit `2` means a required role is unset or a write was refused a fallback value. Any other exit is a resolver failure, never an unset role.
45
45
 
46
46
  If `$ARGUMENTS` is empty, all vendor skills auto-detect a ticket reference from the active plan file (most recently modified `.md` in `plans/`).
47
47