@codyswann/lisa 4.54.13 → 4.55.0

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 (578) hide show
  1. package/all/copy-contents/.gitattributes +8 -0
  2. package/all/copy-overwrite/scripts/check-orphaned-branches.mjs +78 -9
  3. package/all/copy-overwrite/scripts/check-third-party-action-pins.mjs +17 -1
  4. package/all/copy-overwrite/scripts/lib/process-tree-runner.mjs +194 -7
  5. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +31 -0
  6. package/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +81 -2
  7. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +293 -8
  8. package/all/copy-overwrite/scripts/lisa-hooks/block-instruction-file-edits.sh +21 -0
  9. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +78 -15
  10. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +21 -0
  11. package/all/copy-overwrite/scripts/lisa-hooks/block-shell-json-parsing.sh +21 -0
  12. package/all/copy-overwrite/scripts/lisa-hooks/guard-dedupe.bash +340 -0
  13. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +29 -1
  14. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +183 -1
  15. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.sh +21 -0
  16. package/all/copy-overwrite/scripts/lisa-postinstall.mjs +21 -1
  17. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +100 -5
  18. package/all/copy-overwrite/scripts/lisa-work-item.mjs +1085 -80
  19. package/all/create-only/.github/workflows/continuous-gates.yml +7 -0
  20. package/cdk/create-only/.github/workflows/ci.yml +7 -0
  21. package/cdk/create-only/.github/workflows/deploy.yml +21 -0
  22. package/dist/cli/apply.d.ts.map +1 -1
  23. package/dist/cli/apply.js +9 -1
  24. package/dist/cli/apply.js.map +1 -1
  25. package/dist/cli/doctor-apply-deletions.d.ts +61 -0
  26. package/dist/cli/doctor-apply-deletions.d.ts.map +1 -0
  27. package/dist/cli/doctor-apply-deletions.js +137 -0
  28. package/dist/cli/doctor-apply-deletions.js.map +1 -0
  29. package/dist/cli/doctor-cdk-preset-adoption.d.ts +15 -0
  30. package/dist/cli/doctor-cdk-preset-adoption.d.ts.map +1 -0
  31. package/dist/cli/doctor-cdk-preset-adoption.js +316 -0
  32. package/dist/cli/doctor-cdk-preset-adoption.js.map +1 -0
  33. package/dist/cli/doctor-config-shadowing.d.ts +22 -0
  34. package/dist/cli/doctor-config-shadowing.d.ts.map +1 -0
  35. package/dist/cli/doctor-config-shadowing.js +123 -0
  36. package/dist/cli/doctor-config-shadowing.js.map +1 -0
  37. package/dist/cli/doctor-rails-deploy-intent.d.ts +15 -0
  38. package/dist/cli/doctor-rails-deploy-intent.d.ts.map +1 -0
  39. package/dist/cli/doctor-rails-deploy-intent.js +197 -0
  40. package/dist/cli/doctor-rails-deploy-intent.js.map +1 -0
  41. package/dist/cli/doctor-seeded-artifacts.d.ts +12 -0
  42. package/dist/cli/doctor-seeded-artifacts.d.ts.map +1 -0
  43. package/dist/cli/doctor-seeded-artifacts.js +54 -0
  44. package/dist/cli/doctor-seeded-artifacts.js.map +1 -0
  45. package/dist/cli/doctor-stale-banner-scan.d.ts +44 -0
  46. package/dist/cli/doctor-stale-banner-scan.d.ts.map +1 -0
  47. package/dist/cli/doctor-stale-banner-scan.js +152 -0
  48. package/dist/cli/doctor-stale-banner-scan.js.map +1 -0
  49. package/dist/cli/doctor-stale-managed-banner.d.ts +19 -0
  50. package/dist/cli/doctor-stale-managed-banner.d.ts.map +1 -0
  51. package/dist/cli/doctor-stale-managed-banner.js +198 -0
  52. package/dist/cli/doctor-stale-managed-banner.js.map +1 -0
  53. package/dist/cli/doctor.d.ts.map +1 -1
  54. package/dist/cli/doctor.js +16 -0
  55. package/dist/cli/doctor.js.map +1 -1
  56. package/dist/cli/ui-detected-stacks.d.ts.map +1 -1
  57. package/dist/cli/ui-detected-stacks.js +3 -2
  58. package/dist/cli/ui-detected-stacks.js.map +1 -1
  59. package/dist/cli/update-check.d.ts.map +1 -1
  60. package/dist/cli/update-check.js +14 -0
  61. package/dist/cli/update-check.js.map +1 -1
  62. package/dist/cli/worktree-liveness.d.ts +5 -0
  63. package/dist/cli/worktree-liveness.d.ts.map +1 -1
  64. package/dist/cli/worktree-liveness.js +7 -6
  65. package/dist/cli/worktree-liveness.js.map +1 -1
  66. package/dist/cli/worktree-ownership.d.ts +13 -0
  67. package/dist/cli/worktree-ownership.d.ts.map +1 -1
  68. package/dist/cli/worktree-ownership.js +14 -0
  69. package/dist/cli/worktree-ownership.js.map +1 -1
  70. package/dist/configs/eslint/base.d.ts +34 -0
  71. package/dist/configs/eslint/base.d.ts.map +1 -1
  72. package/dist/configs/eslint/base.js +34 -0
  73. package/dist/configs/eslint/base.js.map +1 -1
  74. package/dist/core/apply-receipt.d.ts +65 -0
  75. package/dist/core/apply-receipt.d.ts.map +1 -1
  76. package/dist/core/apply-receipt.js +62 -2
  77. package/dist/core/apply-receipt.js.map +1 -1
  78. package/dist/core/cdk-preset-adoption.d.ts +136 -0
  79. package/dist/core/cdk-preset-adoption.d.ts.map +1 -0
  80. package/dist/core/cdk-preset-adoption.js +135 -0
  81. package/dist/core/cdk-preset-adoption.js.map +1 -0
  82. package/dist/core/config-shadowing.d.ts +36 -0
  83. package/dist/core/config-shadowing.d.ts.map +1 -1
  84. package/dist/core/config-shadowing.js +38 -0
  85. package/dist/core/config-shadowing.js.map +1 -1
  86. package/dist/core/config.d.ts +11 -0
  87. package/dist/core/config.d.ts.map +1 -1
  88. package/dist/core/config.js.map +1 -1
  89. package/dist/core/learnings-merge-driver.d.ts +10 -0
  90. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  91. package/dist/core/learnings-merge-driver.js +18 -0
  92. package/dist/core/learnings-merge-driver.js.map +1 -1
  93. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  94. package/dist/core/lisa-owned-hash-ledger.js +136 -0
  95. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  96. package/dist/core/lisa.d.ts.map +1 -1
  97. package/dist/core/lisa.js +9 -1
  98. package/dist/core/lisa.js.map +1 -1
  99. package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts +5 -5
  100. package/dist/core/nightly-e2e-guard-behavior-certificate.js +7 -7
  101. package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
  102. package/dist/core/ownership-header.d.ts +74 -0
  103. package/dist/core/ownership-header.d.ts.map +1 -0
  104. package/dist/core/ownership-header.js +76 -0
  105. package/dist/core/ownership-header.js.map +1 -0
  106. package/dist/core/rails-deploy-production-intent.d.ts +36 -0
  107. package/dist/core/rails-deploy-production-intent.d.ts.map +1 -0
  108. package/dist/core/rails-deploy-production-intent.js +79 -0
  109. package/dist/core/rails-deploy-production-intent.js.map +1 -0
  110. package/dist/core/stale-managed-banner.d.ts +78 -0
  111. package/dist/core/stale-managed-banner.d.ts.map +1 -0
  112. package/dist/core/stale-managed-banner.js +118 -0
  113. package/dist/core/stale-managed-banner.js.map +1 -0
  114. package/dist/core/two-channel-delivery-scan.d.ts +33 -1
  115. package/dist/core/two-channel-delivery-scan.d.ts.map +1 -1
  116. package/dist/core/two-channel-delivery-scan.js +133 -6
  117. package/dist/core/two-channel-delivery-scan.js.map +1 -1
  118. package/dist/core/two-channel-delivery.d.ts +101 -4
  119. package/dist/core/two-channel-delivery.d.ts.map +1 -1
  120. package/dist/core/two-channel-delivery.js +121 -13
  121. package/dist/core/two-channel-delivery.js.map +1 -1
  122. package/dist/core/two-channel-staleness.d.ts +137 -0
  123. package/dist/core/two-channel-staleness.d.ts.map +1 -0
  124. package/dist/core/two-channel-staleness.js +268 -0
  125. package/dist/core/two-channel-staleness.js.map +1 -0
  126. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  127. package/dist/core/upstream-evidence-manifest.js +265 -121
  128. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  129. package/dist/core/workflow-deletion-ownership.d.ts +6 -24
  130. package/dist/core/workflow-deletion-ownership.d.ts.map +1 -1
  131. package/dist/core/workflow-deletion-ownership.js +22 -42
  132. package/dist/core/workflow-deletion-ownership.js.map +1 -1
  133. package/dist/detection/detectors/cdk.d.ts.map +1 -1
  134. package/dist/detection/detectors/cdk.js +2 -1
  135. package/dist/detection/detectors/cdk.js.map +1 -1
  136. package/dist/health/template-inspection.d.ts.map +1 -1
  137. package/dist/health/template-inspection.js +2 -1
  138. package/dist/health/template-inspection.js.map +1 -1
  139. package/dist/migrations/ensure-lisa-postinstall.d.ts +16 -0
  140. package/dist/migrations/ensure-lisa-postinstall.d.ts.map +1 -1
  141. package/dist/migrations/ensure-lisa-postinstall.js +16 -0
  142. package/dist/migrations/ensure-lisa-postinstall.js.map +1 -1
  143. package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts +2 -2
  144. package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts.map +1 -1
  145. package/dist/migrations/ensure-pinned-reusable-workflow-refs.js +98 -34
  146. package/dist/migrations/ensure-pinned-reusable-workflow-refs.js.map +1 -1
  147. package/dist/migrations/ensure-playwright-dedicated-caller.d.ts.map +1 -1
  148. package/dist/migrations/ensure-playwright-dedicated-caller.js +1 -0
  149. package/dist/migrations/ensure-playwright-dedicated-caller.js.map +1 -1
  150. package/dist/opencode/hooks-installer.d.ts.map +1 -1
  151. package/dist/opencode/hooks-installer.js +1 -0
  152. package/dist/opencode/hooks-installer.js.map +1 -1
  153. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +78 -15
  154. package/dist/opencode/plugin-templates/block-no-verify.sh +21 -0
  155. package/dist/opencode/plugin-templates/guard-dedupe.bash +337 -0
  156. package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +242 -55
  157. package/dist/opencode/plugin-templates/parity-safety-net.sh +29 -1
  158. package/dist/strategies/package-lisa.d.ts.map +1 -1
  159. package/dist/strategies/package-lisa.js +2 -1
  160. package/dist/strategies/package-lisa.js.map +1 -1
  161. package/dist/utils/path-utils.d.ts +29 -0
  162. package/dist/utils/path-utils.d.ts.map +1 -1
  163. package/dist/utils/path-utils.js +41 -0
  164. package/dist/utils/path-utils.js.map +1 -1
  165. package/expo/create-only/.github/workflows/ci.yml +21 -0
  166. package/expo/create-only/.github/workflows/deploy.yml +28 -0
  167. package/expo/create-only/.github/workflows/maestro-e2e.yml +7 -0
  168. package/expo/create-only/.github/workflows/nightly-e2e-bypass-reaper.yml +124 -13
  169. package/expo/create-only/.github/workflows/nightly-e2e-health.yml +7 -0
  170. package/expo/create-only/.github/workflows/nightly-e2e-report.yml +7 -0
  171. package/expo/create-only/.github/workflows/nightly-e2e-tracking.yml +8 -0
  172. package/expo/create-only/.github/workflows/playwright-e2e.yml +7 -0
  173. package/harper-fabric/copy-overwrite/.github/workflows/ci.yml +7 -0
  174. package/harper-fabric/create-only/.github/workflows/deploy.yml +14 -0
  175. package/nestjs/create-only/.github/workflows/ci.yml +14 -0
  176. package/nestjs/create-only/.github/workflows/deploy.yml +28 -0
  177. package/package.json +10 -5
  178. package/phaser/copy-overwrite/.github/workflows/ci.yml +7 -0
  179. package/plugins/lisa/.claude-plugin/plugin.json +28 -1
  180. package/plugins/lisa/.codex-plugin/hooks.json +27 -0
  181. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  182. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +130 -19
  183. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +27 -4
  184. package/plugins/lisa/.codex-plugin/skills/lisa-github-prd-intake/SKILL.md +1 -1
  185. package/plugins/lisa/.codex-plugin/skills/lisa-github-validate-issue/SKILL.md +22 -0
  186. package/plugins/lisa/.codex-plugin/skills/lisa-github-verify/SKILL.md +14 -0
  187. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +51 -0
  188. package/plugins/lisa/.codex-plugin/skills/lisa-intake/SKILL.md +1 -1
  189. package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +27 -4
  190. package/plugins/lisa/.codex-plugin/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  191. package/plugins/lisa/.codex-plugin/skills/lisa-jira-verify/SKILL.md +14 -0
  192. package/plugins/lisa/.codex-plugin/skills/lisa-jira-write-ticket/SKILL.md +51 -0
  193. package/plugins/lisa/.codex-plugin/skills/lisa-linear-access/SKILL.md +55 -2
  194. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +27 -4
  195. package/plugins/lisa/.codex-plugin/skills/lisa-linear-prd-intake/SKILL.md +47 -30
  196. package/plugins/lisa/.codex-plugin/skills/lisa-linear-to-tracker/SKILL.md +3 -3
  197. package/plugins/lisa/.codex-plugin/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  198. package/plugins/lisa/.codex-plugin/skills/lisa-linear-verify/SKILL.md +14 -0
  199. package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +54 -3
  200. package/plugins/lisa/.codex-plugin/skills/lisa-prd-ticket-coverage/SKILL.md +2 -2
  201. package/plugins/lisa/.codex-plugin/skills/lisa-qa-clear/SKILL.md +19 -7
  202. package/plugins/lisa/.codex-plugin/skills/lisa-qa-fail/SKILL.md +28 -3
  203. package/plugins/lisa/.codex-plugin/skills/lisa-qa-queue/SKILL.md +28 -3
  204. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +94 -24
  205. package/plugins/lisa/.codex-plugin/skills/lisa-rework-triage/SKILL.md +32 -2
  206. package/plugins/lisa/.codex-plugin/skills/lisa-setup-linear/SKILL.md +4 -4
  207. package/plugins/lisa/.codex-plugin/skills/lisa-track/SKILL.md +1 -1
  208. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-build-intake/SKILL.md +9 -0
  209. package/plugins/lisa/agents/linear-prd-intake.md +8 -8
  210. package/plugins/lisa/hooks/block-blind-automerge.sh +81 -2
  211. package/plugins/lisa/hooks/block-direct-issue-create.agy.sh +64 -16
  212. package/plugins/lisa/hooks/block-direct-issue-create.sh +293 -8
  213. package/plugins/lisa/hooks/block-instruction-file-edits.sh +21 -0
  214. package/plugins/lisa/hooks/block-managed-file-edits.sh +78 -15
  215. package/plugins/lisa/hooks/block-no-verify.sh +21 -0
  216. package/plugins/lisa/hooks/block-shell-json-parsing.sh +21 -0
  217. package/plugins/lisa/hooks/failure-signature-index.mjs +240 -5
  218. package/plugins/lisa/hooks/guard-dedupe.bash +337 -0
  219. package/plugins/lisa/hooks/inject-rules.sh +13 -5
  220. package/plugins/lisa/hooks/operational-hazards.mjs +736 -0
  221. package/plugins/lisa/hooks/operational-hazards.sh +28 -0
  222. package/plugins/lisa/hooks/parity-safety-net.sh +29 -1
  223. package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +102 -21
  224. package/plugins/lisa/hooks/threshold-ratchet-families.mjs +130 -2
  225. package/plugins/lisa/hooks/threshold-ratchet.mjs +63 -13
  226. package/plugins/lisa/hooks/worktree-binding-guard.mjs +183 -1
  227. package/plugins/lisa/hooks/worktree-binding-guard.sh +21 -0
  228. package/plugins/lisa/rules/eager/00-rule-index.md +1 -0
  229. package/plugins/lisa/rules/eager/operational-hazards.md +50 -0
  230. package/plugins/lisa/rules/eager/tracked-work.md +1 -1
  231. package/plugins/lisa/rules/reference/config-resolution.md +1 -1
  232. package/plugins/lisa/rules/reference/leaf-only-lifecycle.md +1 -1
  233. package/plugins/lisa/rules/reference/operational-hazards.md +115 -0
  234. package/plugins/lisa/rules/reference/work-item-trailer-definition.md +129 -0
  235. package/plugins/lisa/scripts/intake-blocker-reprobe.mjs +407 -26
  236. package/plugins/lisa/scripts/qa-signal-lifecycle.mjs +507 -0
  237. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +130 -19
  238. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +27 -4
  239. package/plugins/lisa/skills/lisa-github-prd-intake/SKILL.md +1 -1
  240. package/plugins/lisa/skills/lisa-github-validate-issue/SKILL.md +22 -0
  241. package/plugins/lisa/skills/lisa-github-verify/SKILL.md +14 -0
  242. package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +51 -0
  243. package/plugins/lisa/skills/lisa-intake/SKILL.md +1 -1
  244. package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +27 -4
  245. package/plugins/lisa/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  246. package/plugins/lisa/skills/lisa-jira-verify/SKILL.md +14 -0
  247. package/plugins/lisa/skills/lisa-jira-write-ticket/SKILL.md +51 -0
  248. package/plugins/lisa/skills/lisa-linear-access/SKILL.md +55 -2
  249. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +27 -4
  250. package/plugins/lisa/skills/lisa-linear-prd-intake/SKILL.md +48 -31
  251. package/plugins/lisa/skills/lisa-linear-to-tracker/SKILL.md +3 -3
  252. package/plugins/lisa/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  253. package/plugins/lisa/skills/lisa-linear-verify/SKILL.md +14 -0
  254. package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +54 -3
  255. package/plugins/lisa/skills/lisa-prd-ticket-coverage/SKILL.md +2 -2
  256. package/plugins/lisa/skills/lisa-qa-clear/SKILL.md +19 -7
  257. package/plugins/lisa/skills/lisa-qa-fail/SKILL.md +28 -3
  258. package/plugins/lisa/skills/lisa-qa-queue/SKILL.md +28 -3
  259. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +94 -24
  260. package/plugins/lisa/skills/lisa-rework-triage/SKILL.md +32 -2
  261. package/plugins/lisa/skills/lisa-setup-linear/SKILL.md +5 -5
  262. package/plugins/lisa/skills/lisa-track/SKILL.md +1 -1
  263. package/plugins/lisa/skills/lisa-tracker-build-intake/SKILL.md +9 -0
  264. package/plugins/lisa-agy/agents/linear-prd-intake.md +8 -8
  265. package/plugins/lisa-agy/hooks/block-blind-automerge.sh +81 -2
  266. package/plugins/lisa-agy/hooks/block-direct-issue-create.agy.sh +64 -16
  267. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +293 -8
  268. package/plugins/lisa-agy/hooks/block-instruction-file-edits.sh +21 -0
  269. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +78 -15
  270. package/plugins/lisa-agy/hooks/block-shell-json-parsing.sh +21 -0
  271. package/plugins/lisa-agy/hooks/guard-dedupe.bash +337 -0
  272. package/plugins/lisa-agy/hooks/parity-safety-net.sh +29 -1
  273. package/plugins/lisa-agy/hooks.json +1 -1
  274. package/plugins/lisa-agy/plugin.json +1 -1
  275. package/plugins/lisa-agy/scripts/intake-blocker-reprobe.mjs +407 -26
  276. package/plugins/lisa-agy/scripts/qa-signal-lifecycle.mjs +507 -0
  277. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +130 -19
  278. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +27 -4
  279. package/plugins/lisa-agy/skills/lisa-github-prd-intake/SKILL.md +1 -1
  280. package/plugins/lisa-agy/skills/lisa-github-validate-issue/SKILL.md +22 -0
  281. package/plugins/lisa-agy/skills/lisa-github-verify/SKILL.md +14 -0
  282. package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +51 -0
  283. package/plugins/lisa-agy/skills/lisa-intake/SKILL.md +1 -1
  284. package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +27 -4
  285. package/plugins/lisa-agy/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  286. package/plugins/lisa-agy/skills/lisa-jira-verify/SKILL.md +14 -0
  287. package/plugins/lisa-agy/skills/lisa-jira-write-ticket/SKILL.md +51 -0
  288. package/plugins/lisa-agy/skills/lisa-linear-access/SKILL.md +55 -2
  289. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +27 -4
  290. package/plugins/lisa-agy/skills/lisa-linear-prd-intake/SKILL.md +48 -31
  291. package/plugins/lisa-agy/skills/lisa-linear-to-tracker/SKILL.md +3 -3
  292. package/plugins/lisa-agy/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  293. package/plugins/lisa-agy/skills/lisa-linear-verify/SKILL.md +14 -0
  294. package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +54 -3
  295. package/plugins/lisa-agy/skills/lisa-prd-ticket-coverage/SKILL.md +2 -2
  296. package/plugins/lisa-agy/skills/lisa-qa-clear/SKILL.md +19 -7
  297. package/plugins/lisa-agy/skills/lisa-qa-fail/SKILL.md +28 -3
  298. package/plugins/lisa-agy/skills/lisa-qa-queue/SKILL.md +28 -3
  299. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +94 -24
  300. package/plugins/lisa-agy/skills/lisa-rework-triage/SKILL.md +32 -2
  301. package/plugins/lisa-agy/skills/lisa-setup-linear/SKILL.md +5 -5
  302. package/plugins/lisa-agy/skills/lisa-track/SKILL.md +1 -1
  303. package/plugins/lisa-agy/skills/lisa-tracker-build-intake/SKILL.md +9 -0
  304. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  305. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  306. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  307. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  308. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  309. package/plugins/lisa-copilot/.claude-plugin/plugin.json +19 -1
  310. package/plugins/lisa-copilot/agents/linear-prd-intake.agent.md +8 -8
  311. package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +81 -2
  312. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +293 -8
  313. package/plugins/lisa-copilot/hooks/block-instruction-file-edits.sh +21 -0
  314. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +78 -15
  315. package/plugins/lisa-copilot/hooks/block-no-verify.sh +21 -0
  316. package/plugins/lisa-copilot/hooks/block-shell-json-parsing.sh +21 -0
  317. package/plugins/lisa-copilot/hooks/failure-signature-index.mjs +240 -5
  318. package/plugins/lisa-copilot/hooks/guard-dedupe.bash +337 -0
  319. package/plugins/lisa-copilot/hooks/inject-rules.sh +13 -5
  320. package/plugins/lisa-copilot/hooks/operational-hazards.mjs +736 -0
  321. package/plugins/lisa-copilot/hooks/operational-hazards.sh +28 -0
  322. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +29 -1
  323. package/plugins/lisa-copilot/hooks/threshold-ratchet-compare.mjs +102 -21
  324. package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +130 -2
  325. package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +63 -13
  326. package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +183 -1
  327. package/plugins/lisa-copilot/hooks/worktree-binding-guard.sh +21 -0
  328. package/plugins/lisa-copilot/rules/eager/00-rule-index.md +1 -0
  329. package/plugins/lisa-copilot/rules/eager/operational-hazards.md +50 -0
  330. package/plugins/lisa-copilot/rules/eager/tracked-work.md +1 -1
  331. package/plugins/lisa-copilot/rules/reference/config-resolution.md +1 -1
  332. package/plugins/lisa-copilot/rules/reference/leaf-only-lifecycle.md +1 -1
  333. package/plugins/lisa-copilot/rules/reference/operational-hazards.md +115 -0
  334. package/plugins/lisa-copilot/rules/reference/work-item-trailer-definition.md +129 -0
  335. package/plugins/lisa-copilot/scripts/intake-blocker-reprobe.mjs +407 -26
  336. package/plugins/lisa-copilot/scripts/qa-signal-lifecycle.mjs +507 -0
  337. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +130 -19
  338. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +27 -4
  339. package/plugins/lisa-copilot/skills/lisa-github-prd-intake/SKILL.md +1 -1
  340. package/plugins/lisa-copilot/skills/lisa-github-validate-issue/SKILL.md +22 -0
  341. package/plugins/lisa-copilot/skills/lisa-github-verify/SKILL.md +14 -0
  342. package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +51 -0
  343. package/plugins/lisa-copilot/skills/lisa-intake/SKILL.md +1 -1
  344. package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +27 -4
  345. package/plugins/lisa-copilot/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  346. package/plugins/lisa-copilot/skills/lisa-jira-verify/SKILL.md +14 -0
  347. package/plugins/lisa-copilot/skills/lisa-jira-write-ticket/SKILL.md +51 -0
  348. package/plugins/lisa-copilot/skills/lisa-linear-access/SKILL.md +55 -2
  349. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +27 -4
  350. package/plugins/lisa-copilot/skills/lisa-linear-prd-intake/SKILL.md +48 -31
  351. package/plugins/lisa-copilot/skills/lisa-linear-to-tracker/SKILL.md +3 -3
  352. package/plugins/lisa-copilot/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  353. package/plugins/lisa-copilot/skills/lisa-linear-verify/SKILL.md +14 -0
  354. package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +54 -3
  355. package/plugins/lisa-copilot/skills/lisa-prd-ticket-coverage/SKILL.md +2 -2
  356. package/plugins/lisa-copilot/skills/lisa-qa-clear/SKILL.md +19 -7
  357. package/plugins/lisa-copilot/skills/lisa-qa-fail/SKILL.md +28 -3
  358. package/plugins/lisa-copilot/skills/lisa-qa-queue/SKILL.md +28 -3
  359. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +94 -24
  360. package/plugins/lisa-copilot/skills/lisa-rework-triage/SKILL.md +32 -2
  361. package/plugins/lisa-copilot/skills/lisa-setup-linear/SKILL.md +5 -5
  362. package/plugins/lisa-copilot/skills/lisa-track/SKILL.md +1 -1
  363. package/plugins/lisa-copilot/skills/lisa-tracker-build-intake/SKILL.md +9 -0
  364. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  365. package/plugins/lisa-cursor/agents/linear-prd-intake.md +8 -8
  366. package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +81 -2
  367. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +293 -8
  368. package/plugins/lisa-cursor/hooks/block-instruction-file-edits.sh +21 -0
  369. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +78 -15
  370. package/plugins/lisa-cursor/hooks/block-no-verify.sh +21 -0
  371. package/plugins/lisa-cursor/hooks/block-shell-json-parsing.sh +21 -0
  372. package/plugins/lisa-cursor/hooks/failure-signature-index.mjs +240 -5
  373. package/plugins/lisa-cursor/hooks/guard-dedupe.bash +337 -0
  374. package/plugins/lisa-cursor/hooks/hooks.json +10 -0
  375. package/plugins/lisa-cursor/hooks/operational-hazards.mjs +736 -0
  376. package/plugins/lisa-cursor/hooks/operational-hazards.sh +28 -0
  377. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +29 -1
  378. package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +102 -21
  379. package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +130 -2
  380. package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +63 -13
  381. package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +183 -1
  382. package/plugins/lisa-cursor/hooks/worktree-binding-guard.sh +21 -0
  383. package/plugins/lisa-cursor/rules/00-rule-index.mdc +1 -0
  384. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +1 -1
  385. package/plugins/lisa-cursor/rules/leaf-only-lifecycle-reference.mdc +1 -1
  386. package/plugins/lisa-cursor/rules/operational-hazards-reference.mdc +120 -0
  387. package/plugins/lisa-cursor/rules/operational-hazards.mdc +55 -0
  388. package/plugins/lisa-cursor/rules/tracked-work.mdc +1 -1
  389. package/plugins/lisa-cursor/rules/work-item-trailer-definition-reference.mdc +134 -0
  390. package/plugins/lisa-cursor/scripts/intake-blocker-reprobe.mjs +407 -26
  391. package/plugins/lisa-cursor/scripts/qa-signal-lifecycle.mjs +507 -0
  392. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +130 -19
  393. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +27 -4
  394. package/plugins/lisa-cursor/skills/lisa-github-prd-intake/SKILL.md +1 -1
  395. package/plugins/lisa-cursor/skills/lisa-github-validate-issue/SKILL.md +22 -0
  396. package/plugins/lisa-cursor/skills/lisa-github-verify/SKILL.md +14 -0
  397. package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +51 -0
  398. package/plugins/lisa-cursor/skills/lisa-intake/SKILL.md +1 -1
  399. package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +27 -4
  400. package/plugins/lisa-cursor/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  401. package/plugins/lisa-cursor/skills/lisa-jira-verify/SKILL.md +14 -0
  402. package/plugins/lisa-cursor/skills/lisa-jira-write-ticket/SKILL.md +51 -0
  403. package/plugins/lisa-cursor/skills/lisa-linear-access/SKILL.md +55 -2
  404. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +27 -4
  405. package/plugins/lisa-cursor/skills/lisa-linear-prd-intake/SKILL.md +48 -31
  406. package/plugins/lisa-cursor/skills/lisa-linear-to-tracker/SKILL.md +3 -3
  407. package/plugins/lisa-cursor/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  408. package/plugins/lisa-cursor/skills/lisa-linear-verify/SKILL.md +14 -0
  409. package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +54 -3
  410. package/plugins/lisa-cursor/skills/lisa-prd-ticket-coverage/SKILL.md +2 -2
  411. package/plugins/lisa-cursor/skills/lisa-qa-clear/SKILL.md +19 -7
  412. package/plugins/lisa-cursor/skills/lisa-qa-fail/SKILL.md +28 -3
  413. package/plugins/lisa-cursor/skills/lisa-qa-queue/SKILL.md +28 -3
  414. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +94 -24
  415. package/plugins/lisa-cursor/skills/lisa-rework-triage/SKILL.md +32 -2
  416. package/plugins/lisa-cursor/skills/lisa-setup-linear/SKILL.md +5 -5
  417. package/plugins/lisa-cursor/skills/lisa-track/SKILL.md +1 -1
  418. package/plugins/lisa-cursor/skills/lisa-tracker-build-intake/SKILL.md +9 -0
  419. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  420. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  421. package/plugins/lisa-expo-agy/plugin.json +1 -1
  422. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  423. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  424. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  425. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  426. package/plugins/lisa-harper-fabric/hooks/inject-rules.sh +14 -3
  427. package/plugins/lisa-harper-fabric/rules/eager/harper-fabric.md +31 -0
  428. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  429. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  430. package/plugins/lisa-harper-fabric-copilot/hooks/inject-rules.sh +14 -3
  431. package/plugins/lisa-harper-fabric-copilot/rules/eager/harper-fabric.md +31 -0
  432. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  433. package/plugins/lisa-harper-fabric-cursor/rules/harper-fabric-reference.mdc +57 -0
  434. package/plugins/lisa-harper-fabric-cursor/rules/harper-fabric.mdc +32 -53
  435. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  436. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  437. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  438. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  439. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  440. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  441. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  442. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  443. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  444. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  445. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  446. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  447. package/plugins/lisa-phaser/hooks/inject-rules.sh +14 -3
  448. package/plugins/lisa-phaser/rules/eager/phaser.md +44 -0
  449. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  450. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  451. package/plugins/lisa-phaser-copilot/hooks/inject-rules.sh +14 -3
  452. package/plugins/lisa-phaser-copilot/rules/eager/phaser.md +44 -0
  453. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  454. package/plugins/lisa-phaser-cursor/rules/phaser-reference.mdc +189 -0
  455. package/plugins/lisa-phaser-cursor/rules/phaser.mdc +45 -185
  456. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  457. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  458. package/plugins/lisa-rails/hooks/inject-rules.sh +14 -4
  459. package/plugins/lisa-rails/rules/eager/rails-conventions.md +23 -0
  460. package/plugins/lisa-rails-agy/plugin.json +1 -1
  461. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  462. package/plugins/lisa-rails-copilot/hooks/inject-rules.sh +14 -4
  463. package/plugins/lisa-rails-copilot/rules/eager/rails-conventions.md +23 -0
  464. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  465. package/plugins/lisa-rails-cursor/rules/rails-conventions-reference.mdc +181 -0
  466. package/plugins/lisa-rails-cursor/rules/rails-conventions.mdc +24 -177
  467. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  468. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  469. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  470. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  471. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  472. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  473. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  474. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  475. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  476. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  477. package/plugins/materialized-artifacts.json +1 -0
  478. package/plugins/src/base/.claude-plugin/plugin.json +27 -0
  479. package/plugins/src/base/agents/linear-prd-intake.md +8 -8
  480. package/plugins/src/base/hooks/block-blind-automerge.sh +81 -2
  481. package/plugins/src/base/hooks/block-direct-issue-create.agy.sh +64 -16
  482. package/plugins/src/base/hooks/block-direct-issue-create.sh +293 -8
  483. package/plugins/src/base/hooks/block-instruction-file-edits.sh +21 -0
  484. package/plugins/src/base/hooks/block-managed-file-edits.sh +78 -15
  485. package/plugins/src/base/hooks/block-no-verify.sh +21 -0
  486. package/plugins/src/base/hooks/block-shell-json-parsing.sh +21 -0
  487. package/plugins/src/base/hooks/failure-signature-index.mjs +240 -5
  488. package/plugins/src/base/hooks/guard-dedupe.bash +337 -0
  489. package/plugins/src/base/hooks/inject-rules.sh +13 -5
  490. package/plugins/src/base/hooks/operational-hazards.mjs +736 -0
  491. package/plugins/src/base/hooks/operational-hazards.sh +28 -0
  492. package/plugins/src/base/hooks/parity-safety-net.sh +29 -1
  493. package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +102 -21
  494. package/plugins/src/base/hooks/threshold-ratchet-families.mjs +130 -2
  495. package/plugins/src/base/hooks/threshold-ratchet.mjs +63 -13
  496. package/plugins/src/base/hooks/worktree-binding-guard.mjs +183 -1
  497. package/plugins/src/base/hooks/worktree-binding-guard.sh +21 -0
  498. package/plugins/src/base/rules/eager/00-rule-index.md +1 -0
  499. package/plugins/src/base/rules/eager/operational-hazards.md +50 -0
  500. package/plugins/src/base/rules/eager/tracked-work.md +1 -1
  501. package/plugins/src/base/rules/reference/config-resolution.md +1 -1
  502. package/plugins/src/base/rules/reference/leaf-only-lifecycle.md +1 -1
  503. package/plugins/src/base/rules/reference/operational-hazards.md +115 -0
  504. package/plugins/src/base/rules/reference/work-item-trailer-definition.md +129 -0
  505. package/plugins/src/base/scripts/intake-blocker-reprobe.mjs +407 -26
  506. package/plugins/src/base/scripts/qa-signal-lifecycle.mjs +507 -0
  507. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +130 -19
  508. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +27 -4
  509. package/plugins/src/base/skills/lisa-github-prd-intake/SKILL.md +1 -1
  510. package/plugins/src/base/skills/lisa-github-validate-issue/SKILL.md +22 -0
  511. package/plugins/src/base/skills/lisa-github-verify/SKILL.md +14 -0
  512. package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +51 -0
  513. package/plugins/src/base/skills/lisa-intake/SKILL.md +1 -1
  514. package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +27 -4
  515. package/plugins/src/base/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  516. package/plugins/src/base/skills/lisa-jira-verify/SKILL.md +14 -0
  517. package/plugins/src/base/skills/lisa-jira-write-ticket/SKILL.md +51 -0
  518. package/plugins/src/base/skills/lisa-linear-access/SKILL.md +55 -2
  519. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +27 -4
  520. package/plugins/src/base/skills/lisa-linear-prd-intake/SKILL.md +48 -31
  521. package/plugins/src/base/skills/lisa-linear-to-tracker/SKILL.md +3 -3
  522. package/plugins/src/base/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  523. package/plugins/src/base/skills/lisa-linear-verify/SKILL.md +14 -0
  524. package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +54 -3
  525. package/plugins/src/base/skills/lisa-prd-ticket-coverage/SKILL.md +2 -2
  526. package/plugins/src/base/skills/lisa-qa-clear/SKILL.md +19 -7
  527. package/plugins/src/base/skills/lisa-qa-fail/SKILL.md +28 -3
  528. package/plugins/src/base/skills/lisa-qa-queue/SKILL.md +28 -3
  529. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +94 -24
  530. package/plugins/src/base/skills/lisa-rework-triage/SKILL.md +32 -2
  531. package/plugins/src/base/skills/lisa-setup-linear/SKILL.md +5 -5
  532. package/plugins/src/base/skills/lisa-track/SKILL.md +1 -1
  533. package/plugins/src/base/skills/lisa-tracker-build-intake/SKILL.md +9 -0
  534. package/plugins/src/harper-fabric/hooks/inject-rules.sh +14 -3
  535. package/plugins/src/harper-fabric/rules/eager/harper-fabric.md +31 -0
  536. package/plugins/src/phaser/hooks/inject-rules.sh +14 -3
  537. package/plugins/src/phaser/rules/eager/phaser.md +44 -0
  538. package/plugins/src/rails/hooks/inject-rules.sh +14 -4
  539. package/plugins/src/rails/rules/eager/rails-conventions.md +23 -0
  540. package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +63 -13
  541. package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +102 -21
  542. package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +130 -2
  543. package/rails/create-only/.github/workflows/ci.yml +8 -0
  544. package/rails/create-only/.github/workflows/deploy.yml +22 -0
  545. package/scripts/build-plugins.sh +2 -1
  546. package/scripts/check-engine-floor.mjs +361 -0
  547. package/scripts/check-guard-parity-notes.mjs +709 -0
  548. package/scripts/check-template-workflow-refs.mjs +9 -0
  549. package/scripts/check-third-party-action-pins.mjs +17 -1
  550. package/scripts/check-workflow-contract-assertions.mjs +480 -0
  551. package/scripts/copy-opencode-plugin-templates.mjs +1 -0
  552. package/scripts/deployed-guard-advice.mjs +636 -0
  553. package/scripts/generate-agy-plugin-artifacts.mjs +35 -10
  554. package/scripts/generate-export-surface.mjs +105 -19
  555. package/scripts/generate-two-channel-couplings.ts +143 -212
  556. package/scripts/lisa-enforcement-fallback.sh +31 -0
  557. package/scripts/measure-scripts-profile-gap.mts +361 -0
  558. package/scripts/mutation-performance-measure.mjs +21 -5
  559. package/scripts/reconcile-release-tags.mjs +65 -84
  560. package/scripts/two-channel-couplings.json +302 -70
  561. package/scripts/workflow-contract-assertion.sh +47 -0
  562. package/typescript/copy-overwrite/.lintstagedrc.json +1 -0
  563. package/typescript/copy-overwrite/eslint.ignore.config.json +1 -0
  564. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +527 -53
  565. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +301 -13
  566. package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +63 -13
  567. package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +102 -21
  568. package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +130 -2
  569. package/typescript/create-only/.github/workflows/ci.yml +7 -0
  570. /package/plugins/lisa-harper-fabric/rules/{harper-fabric.md → reference/harper-fabric.md} +0 -0
  571. /package/plugins/lisa-harper-fabric-copilot/rules/{harper-fabric.md → reference/harper-fabric.md} +0 -0
  572. /package/plugins/lisa-phaser/rules/{phaser.md → reference/phaser.md} +0 -0
  573. /package/plugins/lisa-phaser-copilot/rules/{phaser.md → reference/phaser.md} +0 -0
  574. /package/plugins/lisa-rails/rules/{rails-conventions.md → reference/rails-conventions.md} +0 -0
  575. /package/plugins/lisa-rails-copilot/rules/{rails-conventions.md → reference/rails-conventions.md} +0 -0
  576. /package/plugins/src/harper-fabric/rules/{harper-fabric.md → reference/harper-fabric.md} +0 -0
  577. /package/plugins/src/phaser/rules/{phaser.md → reference/phaser.md} +0 -0
  578. /package/plugins/src/rails/rules/{rails-conventions.md → reference/rails-conventions.md} +0 -0
@@ -8,6 +8,7 @@
8
8
  * commit trailers, pull-request bodies, and the configured tracker.
9
9
  */
10
10
  import { spawnSync } from "node:child_process";
11
+ import { createHash } from "node:crypto";
11
12
  import {
12
13
  chmodSync,
13
14
  existsSync,
@@ -65,6 +66,8 @@ export const WORK_ITEM_CONTRACT_VERSION = "1.0.0";
65
66
  const RELEASE_SUBJECT =
66
67
  /^chore\(release\): \d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)? \[skip ci\](?: \[skip-cd\])?$/;
67
68
  const ZERO_OID = /^0+$/;
69
+ /** `git` pretty-format that prints a commit message body verbatim. */
70
+ const RAW_MESSAGE_FORMAT = "--format=%B";
68
71
  const MARKER = "[lisa-pr-link]";
69
72
  /**
70
73
  * The deploy environment whose done role is terminal — the only one that
@@ -120,6 +123,218 @@ function workItemLineValue(line) {
120
123
  const value = line.slice(match[0].length).trim();
121
124
  return value === "" ? null : value;
122
125
  }
126
+
127
+ /**
128
+ * Is this a bare positive issue number — no sign, no padding, no suffix?
129
+ *
130
+ * A digit walk rather than a pattern. The shipped tree's lint refuses
131
+ * backtracking-prone quantifiers, and scanning characters cannot backtrack at
132
+ * all; the previous reader spelled this as part of a regex assembled from an
133
+ * escaped repository name, which is the construction that drifted.
134
+ * @param {string} text Candidate digits.
135
+ * @returns {boolean} True when `text` is `[1-9]` followed by digits only.
136
+ */
137
+ function isIssueNumber(text) {
138
+ if (text.length === 0 || text[0] < "1" || text[0] > "9") return false;
139
+ for (let index = 1; index < text.length; index += 1) {
140
+ if (text[index] < "0" || text[index] > "9") return false;
141
+ }
142
+ return true;
143
+ }
144
+ /**
145
+ * ## Lane attribution: which agent lane produced this, without saying who
146
+ *
147
+ * Every session in this fleet — and the human operator — pushes under ONE git
148
+ * identity, so `author.login` is a constant across every commit, branch and
149
+ * pull request. That is correct for accountability (the human IS the author of
150
+ * all of it) and useless for ROUTING: a green pull request nobody armed, a
151
+ * branch carrying finished work, an unpushed body found in a worktree sweep —
152
+ * none of them can be handed back to whoever should finish them, because the
153
+ * record does not say which lane made them. A survey has to guess from branch
154
+ * names and file mtimes, and guesses have been wrong in both directions.
155
+ *
156
+ * The obvious remedy — record the session URL — is itself a defect: this is a
157
+ * public repository and a published session identifier is an
158
+ * identifier-hygiene problem, filed separately. So the requirement is narrower
159
+ * than "add provenance":
160
+ *
161
+ * - it must distinguish WHICH lane to route to;
162
+ * - it must publish nothing that is itself an identifier;
163
+ * - it must survive the branch being deleted, which is when routing matters
164
+ * most, because pull requests auto-delete their branch on merge;
165
+ * - and it must not imply the human is not the accountable author.
166
+ *
167
+ * A lane id satisfies all four. It is `lane-` followed by twelve hex
168
+ * characters of a SHA-256 over an owner id the AGENT RUNTIME supplied — never
169
+ * the id itself. The digest is one-way over a high-entropy input, so it names
170
+ * no session, no URL, no person and no project; it is a stable handle that
171
+ * only a live lane can claim, by deriving its own and finding them equal.
172
+ *
173
+ * ### Written once, at the source
174
+ *
175
+ * The stamp goes on at `prepare-commit-msg`, in the process that is making the
176
+ * commit, from that process's own environment. An attribution reconstructed
177
+ * later — from a branch name, a timestamp, a report of a report — loses
178
+ * fidelity at every hop, and the sweep that motivated this is exactly that
179
+ * failure. `--if-exists=doNothing` means a rebase, an amend or a cherry-pick
180
+ * by a DIFFERENT lane preserves the original stamp rather than stealing the
181
+ * attribution.
182
+ *
183
+ * ### One primitive, three surfaces
184
+ *
185
+ * `renderLaneTrailer` / `parseLaneId` are deliberately text-in, text-out and
186
+ * carry no assumption that the text is a commit message. The same pair serves
187
+ * a commit trailer, a pull-request body marker and a tracker comment footer —
188
+ * the three places sibling work needs lane attribution — so the fleet has one
189
+ * token vocabulary rather than three bespoke ones.
190
+ *
191
+ * @see CodySwannGT/lisa#3771
192
+ */
193
+
194
+ /** Trailer key that carries the lane id in a commit message or PR body. */
195
+ export const LANE_TRAILER = "Lane-Id";
196
+
197
+ /**
198
+ * Environment variables consulted, in order, for the caller's owner id.
199
+ *
200
+ * Every one is supplied by the surrounding agent runtime. Lisa invents none of
201
+ * them: an id minted per invocation would differ from the one stamped on the
202
+ * commit and no lane could ever recognise its own work.
203
+ *
204
+ * `CLAUDE_CODE_SESSION_ID` is the variable Claude Code actually exports.
205
+ * `CLAUDE_SESSION_ID` is kept after it because the sibling list in
206
+ * `src/cli/worktree-ownership.ts` shipped with only that spelling, and a
207
+ * receipt written under it must keep resolving.
208
+ *
209
+ * A unit test asserts this list equals that one. Two readers of one fact drift
210
+ * silently otherwise, and the drift is invisible: both halves keep working,
211
+ * they just stop agreeing about who "mine" is.
212
+ */
213
+ export const LANE_ID_VARIABLES = Object.freeze([
214
+ "LISA_OWNER_ID",
215
+ "CLAUDE_CODE_SESSION_ID",
216
+ "CLAUDE_SESSION_ID",
217
+ "CODEX_SESSION_ID",
218
+ ]);
219
+
220
+ /** Fixed, human-readable prefix that makes a lane id greppable. */
221
+ const LANE_PREFIX = "lane-";
222
+ /**
223
+ * Hex characters of the digest kept.
224
+ *
225
+ * Twelve is 48 bits — collision-free at fleet scale by a wide margin, and
226
+ * short enough to read in a report line without wrapping.
227
+ */
228
+ const LANE_DIGEST_LENGTH = 12;
229
+ /**
230
+ * The prefix only: an anchored, fixed-length literal with no quantifier, so it
231
+ * cannot backtrack over a commit message or pull-request body this script does
232
+ * not own. Same reasoning as `WORK_ITEM_PREFIX` above; the value is parsed by
233
+ * character scanning rather than by a second pattern.
234
+ */
235
+ const LANE_PREFIX_MATCH = /^lane-id:/i;
236
+
237
+ /**
238
+ * Derive a lane id from an owner id supplied by the agent runtime.
239
+ *
240
+ * One-way by construction. The returned token cannot be turned back into the
241
+ * owner id, contains no character the owner id contributed, and is therefore
242
+ * safe to publish on a public repository — which is the whole reason it is a
243
+ * digest rather than the id.
244
+ * @param {unknown} ownerId Owner id from the agent runtime.
245
+ * @returns {string | undefined} The lane id, or undefined when there is no id.
246
+ */
247
+ export function deriveLaneId(ownerId) {
248
+ const trimmed = typeof ownerId === "string" ? ownerId.trim() : "";
249
+ if (trimmed === "") return undefined;
250
+ const digest = createHash("sha256").update(trimmed, "utf8").digest("hex");
251
+ return LANE_PREFIX + digest.slice(0, LANE_DIGEST_LENGTH);
252
+ }
253
+
254
+ /**
255
+ * Resolve the lane id of the process running this command.
256
+ *
257
+ * Undefined is a real answer and a restrictive one: a caller with no id can
258
+ * never match a stamp, so every attributed commit is somebody else's as far as
259
+ * it is concerned. "I could not tell" must never resolve in favour of taking
260
+ * over another lane's work.
261
+ * @param {NodeJS.ProcessEnv} [env] Environment to read.
262
+ * @returns {string | undefined} The caller's lane id, or undefined.
263
+ */
264
+ export function resolveCallerLaneId(env = process.env) {
265
+ for (const variable of LANE_ID_VARIABLES) {
266
+ const lane = deriveLaneId(env[variable]);
267
+ if (lane !== undefined) return lane;
268
+ }
269
+ return undefined;
270
+ }
271
+
272
+ /**
273
+ * Whether a value has the exact shape this module mints.
274
+ *
275
+ * Scanned character by character rather than matched with a quantified
276
+ * pattern, so the check is linear over untrusted text.
277
+ * @param {string} value Candidate token.
278
+ * @returns {boolean} True when the value is a well-formed lane id.
279
+ */
280
+ function isLaneId(value) {
281
+ if (!value.startsWith(LANE_PREFIX)) return false;
282
+ const digest = value.slice(LANE_PREFIX.length);
283
+ if (digest.length !== LANE_DIGEST_LENGTH) return false;
284
+ for (const character of digest) {
285
+ const digit = character >= "0" && character <= "9";
286
+ const hex = character >= "a" && character <= "f";
287
+ if (!digit && !hex) return false;
288
+ }
289
+ return true;
290
+ }
291
+
292
+ /**
293
+ * Render the one marker line every surface uses.
294
+ *
295
+ * A commit trailer, a pull-request body line and a tracker comment footer are
296
+ * the same string here on purpose — see the module note above.
297
+ * @param {string} laneId Lane id to render.
298
+ * @returns {string} The marker line, without a trailing newline.
299
+ */
300
+ export function renderLaneTrailer(laneId) {
301
+ return `${LANE_TRAILER}: ${laneId}`;
302
+ }
303
+
304
+ /**
305
+ * Read a lane id out of arbitrary text.
306
+ *
307
+ * Text-in, text-out: a commit message, a pull-request body and a tracker
308
+ * comment are all just text, and this makes no assumption about which it was
309
+ * handed.
310
+ * @param {string} text Commit message, pull-request body or comment body.
311
+ * @returns {string | undefined} The stamped lane id, or undefined.
312
+ */
313
+ export function parseLaneId(text) {
314
+ for (const line of text.split("\n")) {
315
+ const match = LANE_PREFIX_MATCH.exec(line);
316
+ if (!match) continue;
317
+ const value = line.slice(match[0].length).trim();
318
+ if (isLaneId(value)) return value;
319
+ }
320
+ return undefined;
321
+ }
322
+
323
+ /**
324
+ * Judge a stamp against the caller.
325
+ *
326
+ * A stamp naming somebody else is a hard "theirs" even when the caller has no
327
+ * lane of its own, for the same reason an unidentified cleaner may not delete
328
+ * a claimed worktree: an unidentified caller proves nothing.
329
+ * @param {string | undefined} markerLane Lane id found on the work.
330
+ * @param {string | undefined} callerLane Lane id of the running process.
331
+ * @returns {"mine" | "theirs" | "unattributed"} The routing verdict.
332
+ */
333
+ export function judgeLane(markerLane, callerLane) {
334
+ if (markerLane === undefined) return "unattributed";
335
+ return markerLane === callerLane ? "mine" : "theirs";
336
+ }
337
+
123
338
  const GUIDANCE = [
124
339
  "Mention the ticket this work relates to, or ask Lisa to create one:",
125
340
  " Work-Item: <configured-project-ticket>",
@@ -1008,16 +1223,206 @@ function writeState(ref, provider = trackerContract().provider, options = {}) {
1008
1223
  * Comment lines and a verbose commit's diff are not a hazard: the prefix is
1009
1224
  * anchored at column zero, so `# Work-Item: …` never matches, and every
1010
1225
  * unified-diff line carries a space, `+` or `-` in that column.
1226
+ *
1227
+ * EXPORTED because the definition being private is what made it rediscoverable
1228
+ * as a bug. Anything in this fleet that reads a `Work-Item:` trailer imports
1229
+ * this; the alternative each new tool reached for — `%(trailers)` or an ad-hoc
1230
+ * regex — is Definition A, and Definition A misses 92% of this repository's
1231
+ * trailers. See the `work-item-trailer-definition` rule (#3747).
1232
+ *
1233
+ * It deliberately does NOT validate the shape of a value. It answers "what does
1234
+ * this text say?"; `canonicalizeRef` above it decides what is acceptable. A
1235
+ * reader that filtered malformed values out during the scan would make a bad
1236
+ * value invisible rather than refused — the #2672 failure re-created one layer
1237
+ * down. A filter a caller applies to this output is the CALLER's filter.
1011
1238
  * @param {string} message Commit message or pull-request body.
1012
1239
  * @returns {string[]} Every Work-Item value found, in order of appearance.
1013
1240
  */
1014
- function workItemLines(message) {
1015
- return String(message ?? "")
1016
- .split(/\r?\n/)
1017
- .flatMap(line => {
1018
- const value = workItemLineValue(line);
1019
- return value === null ? [] : [value];
1020
- });
1241
+ export function workItemLines(message) {
1242
+ return messageLines(message).flatMap(line => {
1243
+ const value = workItemLineValue(line);
1244
+ return value === null ? [] : [value];
1245
+ });
1246
+ }
1247
+
1248
+ /**
1249
+ * One commit message or pull-request body, as lines.
1250
+ *
1251
+ * THE shared split, so every reader here means the same thing by "a line".
1252
+ * Definition B — the whole message, not git's final paragraph — is the whole
1253
+ * point of the `work-item-trailer-definition` rule (#3747), and it survives
1254
+ * only if the next reader that needs lines reuses this instead of writing a
1255
+ * third scanner with its own idea of where a message ends.
1256
+ * @param {string} message Commit message or pull-request body.
1257
+ * @returns {string[]} Every line, in order, without their terminators.
1258
+ */
1259
+ function messageLines(message) {
1260
+ return String(message ?? "").split(/\r?\n/);
1261
+ }
1262
+
1263
+ /**
1264
+ * ## The coding-session URL a commit message must not publish
1265
+ *
1266
+ * Measured on this fleet's own public history: 65 commits on the default
1267
+ * branch, and 54 more on one unmerged branch, carry a coding-session URL in
1268
+ * their message across nine session identifiers spanning a month
1269
+ * (CodySwannGT/lisa#3731). They are not credentials and nobody can use them.
1270
+ * They are internal identifiers, published in perpetuity, that partition a
1271
+ * public history by working session — metadata nobody decided to publish.
1272
+ *
1273
+ * **Nothing typed them.** The harness appended the trailer itself, below
1274
+ * whatever the agent wrote, after the agent had finished writing. That is why
1275
+ * the check lives at `commit-msg` and reads the file git is about to commit:
1276
+ * every instruction, rule and convention aimed at the agent is upstream of the
1277
+ * moment the text actually appears, so a guard placed there would be inert
1278
+ * against the one producer that has emitted every single instance. The
1279
+ * convention has since changed and no longer emits it — which is exactly the
1280
+ * state that existed before, and exactly the state that failed.
1281
+ *
1282
+ * Deliberately NOT wired into `validateMessage`: that reader also serves the
1283
+ * push and pull-request gates, which walk history. Refusing there would refuse
1284
+ * the 119 commits that already carry one — a published-history rewrite, which
1285
+ * is a human decision and explicitly out of scope. This ticket is about not
1286
+ * adding more.
1287
+ */
1288
+ const SESSION_URL_MARK = "claude.ai/code/session_";
1289
+
1290
+ /**
1291
+ * The shortest run of identifier characters this treats as a real session id.
1292
+ *
1293
+ * **This is the line between a leak and a sentence about leaks.** Real ids are
1294
+ * 24 characters of base62; prose that has to name the form writes
1295
+ * `claude.ai/code/session_<id>` or trails it off. A guard whose pattern matched
1296
+ * prose would refuse the commit that adds the guard, its tests and its
1297
+ * documentation — the failure this repository has walked into more than once —
1298
+ * so the discriminator is not "does the text mention it" but "does the text
1299
+ * carry an actual published identifier".
1300
+ */
1301
+ const SESSION_ID_FLOOR = 16;
1302
+
1303
+ /**
1304
+ * Does `line` carry `needle` at `index`, comparing ASCII case-insensitively?
1305
+ *
1306
+ * A character walk rather than `line.toLowerCase().includes(needle)`. Case
1307
+ * folding is not length-preserving in Unicode — U+0130 expands to two code
1308
+ * units — so an index taken in a folded copy does not address the original,
1309
+ * and the id that follows the mark would be measured from the wrong place.
1310
+ * @param {string} line One line of a commit message.
1311
+ * @param {number} index Where in `line` to compare.
1312
+ * @param {string} needle Lowercase text to look for.
1313
+ * @returns {boolean} True when `line` carries `needle` there.
1314
+ */
1315
+ function matchesFolded(line, index, needle) {
1316
+ if (index + needle.length > line.length) return false;
1317
+ for (let offset = 0; offset < needle.length; offset += 1) {
1318
+ const code = line.charCodeAt(index + offset);
1319
+ const folded = code >= 65 && code <= 90 ? code + 32 : code;
1320
+ if (folded !== needle.charCodeAt(offset)) return false;
1321
+ }
1322
+ return true;
1323
+ }
1324
+
1325
+ /**
1326
+ * Is this one of the characters a session identifier is made of?
1327
+ * @param {string} character A single character.
1328
+ * @returns {boolean} True for `[0-9A-Za-z_-]`.
1329
+ */
1330
+ function isSessionIdCharacter(character) {
1331
+ return (
1332
+ (character >= "0" && character <= "9") ||
1333
+ (character >= "a" && character <= "z") ||
1334
+ (character >= "A" && character <= "Z") ||
1335
+ character === "-" ||
1336
+ character === "_"
1337
+ );
1338
+ }
1339
+
1340
+ /**
1341
+ * Does this line carry a session URL with a real identifier on it?
1342
+ *
1343
+ * Scanned character by character, never matched with a pattern. The shipped
1344
+ * tree's lint refuses backtracking-prone quantifiers, and the input here is a
1345
+ * commit message — text this script does not get to trust the shape of.
1346
+ * @param {string} line One line of a commit message.
1347
+ * @returns {boolean} True when the line publishes a session identifier.
1348
+ */
1349
+ function carriesSessionUrl(line) {
1350
+ for (let index = 0; index < line.length; index += 1) {
1351
+ if (!matchesFolded(line, index, SESSION_URL_MARK)) continue;
1352
+ let end = index + SESSION_URL_MARK.length;
1353
+ while (end < line.length && isSessionIdCharacter(line[end])) end += 1;
1354
+ if (end - index - SESSION_URL_MARK.length >= SESSION_ID_FLOOR) return true;
1355
+ }
1356
+ return false;
1357
+ }
1358
+
1359
+ /**
1360
+ * Git's `--verbose` scissors, below which nothing reaches the commit.
1361
+ *
1362
+ * `git commit -v` appends the staged diff under this line and strips both
1363
+ * before recording the message. A guard that read past it would refuse a commit
1364
+ * for text that was never going to be published — including, precisely, the
1365
+ * commit that stages this guard's own test fixtures.
1366
+ */
1367
+ const SCISSORS_MARK = ">8";
1368
+
1369
+ /**
1370
+ * The part of a commit message that will actually be recorded.
1371
+ * @param {string} message The commit message file's contents.
1372
+ * @returns {string[]} Lines above the scissors, or every line when there is none.
1373
+ */
1374
+ function publishedLines(message) {
1375
+ const lines = messageLines(message);
1376
+ const cut = lines.findIndex(
1377
+ line => line.trimStart().startsWith("#") && line.includes(SCISSORS_MARK)
1378
+ );
1379
+ return cut === -1 ? lines : lines.slice(0, cut);
1380
+ }
1381
+
1382
+ /**
1383
+ * Which lines of this message publish a coding-session URL.
1384
+ * @param {string} message The commit message file's contents.
1385
+ * @returns {number[]} One-based line numbers, in order.
1386
+ */
1387
+ function sessionUrlLines(message) {
1388
+ return publishedLines(message).flatMap((line, index) =>
1389
+ carriesSessionUrl(line) ? [index + 1] : []
1390
+ );
1391
+ }
1392
+
1393
+ /**
1394
+ * Refuse a commit message that publishes a coding-session URL.
1395
+ *
1396
+ * The refusal names line numbers and the URL's PREFIX only. Echoing the
1397
+ * identifier back would copy it into CI logs and terminal scrollback, which is
1398
+ * the same act at a different address.
1399
+ * @param {string} message The commit message file's contents.
1400
+ * @throws {TrackingError} When the message carries a session identifier.
1401
+ */
1402
+ function assertNoSessionUrl(message) {
1403
+ const lines = sessionUrlLines(message);
1404
+ if (lines.length === 0) return;
1405
+ const plural = lines.length === 1 ? "" : "s";
1406
+ const error = new TrackingError(
1407
+ [
1408
+ `This commit message publishes a coding-session URL on line${plural} ${lines.join(", ")}.`,
1409
+ "",
1410
+ `Remove every ${SESSION_URL_MARK}… occurrence from the message.`,
1411
+ "",
1412
+ "It is not a credential, and this refusal is hygiene rather than a",
1413
+ "breach: the identifier has no reader outside the session that made it.",
1414
+ "But this history is public and permanent, and a session id partitions",
1415
+ "it by working session — metadata nobody chose to publish. If something",
1416
+ "other than you is appending it, that is the thing to turn off; an",
1417
+ "instruction to stop cannot reach the code that writes it.",
1418
+ "",
1419
+ "To route a commit back to the run that produced it, use the",
1420
+ "non-identifying `Lane-Id:` trailer Lisa already stamps at",
1421
+ "prepare-commit-msg.",
1422
+ ].join("\n")
1423
+ );
1424
+ error.selfExplanatory = true;
1425
+ throw error;
1021
1426
  }
1022
1427
 
1023
1428
  /**
@@ -1042,12 +1447,17 @@ function workItemLines(message) {
1042
1447
  *
1043
1448
  * Two DIFFERENT references is the real ambiguity — which one is this change
1044
1449
  * about? — and it still fails, naming both.
1450
+ *
1451
+ * EXPORTED alongside `workItemLines` for the same reason: a tool that needs THE
1452
+ * work item rather than every line should get the canonicalizing, ambiguity-
1453
+ * refusing reader instead of writing its own. See the
1454
+ * `work-item-trailer-definition` rule (#3747).
1045
1455
  * @param {string} text Commit message or pull-request body.
1046
1456
  * @param {object} contract Resolved tracker contract.
1047
1457
  * @param {string} subject What is being read, for the message.
1048
1458
  * @returns {string} The canonical work-item reference.
1049
1459
  */
1050
- function soleWorkItem(text, contract, subject) {
1460
+ export function soleWorkItem(text, contract, subject) {
1051
1461
  const values = workItemLines(text);
1052
1462
  if (values.length === 0) {
1053
1463
  throw new TrackingError(`No Work-Item trailer anywhere in the ${subject}`);
@@ -1094,6 +1504,85 @@ function isMergeInProgress() {
1094
1504
  );
1095
1505
  }
1096
1506
 
1507
+ /**
1508
+ * Does this commit have more than one parent?
1509
+ *
1510
+ * The STRUCTURAL merge question, and the only one both gate paths ask. It is a
1511
+ * property of the commit rather than of the repository, so unlike
1512
+ * `isMergeInProgress` it survives the merge completing.
1513
+ * @param {string} sha Commit to inspect.
1514
+ * @param {object} [options] Passed to `run`; `allowFailure` makes an
1515
+ * unresolvable commit-ish answer "not a merge" instead of throwing.
1516
+ * @returns {boolean} Whether the commit is a merge.
1517
+ */
1518
+ function isMergeCommit(sha, options = {}) {
1519
+ const result = run("git", ["rev-list", "--parents", "-n", "1", sha], options);
1520
+ return result.stdout.trim().split(/\s+/).length > 2;
1521
+ }
1522
+
1523
+ /**
1524
+ * A commit message with git's own cleanup applied.
1525
+ *
1526
+ * `git stripspace` rather than a regular expression because the comment marker
1527
+ * is configurable — `core.commentChar`, and a whole `core.commentString` since
1528
+ * git 2.45 — so a hard-coded `#` reads a repository that chose `;` as carrying
1529
+ * comment lines that are really message text. This is the same pass git runs
1530
+ * on the file after the hook returns, so comparing two messages through it
1531
+ * compares what git will actually store.
1532
+ * @param {string} message Raw message text.
1533
+ * @returns {string} The cleaned message.
1534
+ */
1535
+ function cleanedMessage(message) {
1536
+ const result = run("git", ["stripspace", "--strip-comments"], {
1537
+ allowFailure: true,
1538
+ input: message,
1539
+ });
1540
+ return (result.status === 0 ? result.stdout : message).trim();
1541
+ }
1542
+
1543
+ /**
1544
+ * Is this `commit-msg` run an amend of a merge that has already landed?
1545
+ *
1546
+ * The commit path used to ask only whether a merge was IN PROGRESS. That state
1547
+ * is transient: `git merge` removes `MERGE_HEAD` the moment the merge commits,
1548
+ * so `git commit --amend` on that same merge — the tidy way to fold in
1549
+ * generated artifacts the merge staled — reads as an ordinary commit and is
1550
+ * refused for lacking a trailer. The push path never had the defect, because it
1551
+ * counts parents. Two detectors, two different questions, and only one of them
1552
+ * survived the merge completing (#3875).
1553
+ *
1554
+ * At `commit-msg` time the commit does not exist yet, so ITS parents cannot be
1555
+ * counted — which is why the transient check was there. What can be read is
1556
+ * HEAD, and on an amend HEAD is the very commit being rewritten. So the
1557
+ * exemption needs both halves: HEAD is structurally a merge, AND the proposed
1558
+ * message is the message HEAD already carries.
1559
+ *
1560
+ * The second half is what keeps a NEW commit authored on top of a merge
1561
+ * checked. Treating every `commit-msg` invocation as an amend would exempt it,
1562
+ * and that is the strictly worse failure — an unlinked commit let through
1563
+ * rather than a linked one blocked. A new commit carries a new message.
1564
+ *
1565
+ * What remains is a new commit that reuses HEAD's merge message verbatim
1566
+ * (`git commit -C HEAD`). It is exempt here and NOT exempt at push time, where
1567
+ * `commitExemption` counts its single parent and demands the trailer. So the
1568
+ * looser commit-time answer is bounded by the structural gate downstream rather
1569
+ * than being the last word.
1570
+ * @param {string} message The proposed commit message.
1571
+ * @returns {boolean} Whether to treat this as an amend of a merge commit.
1572
+ */
1573
+ function amendsMergeAtHead(message) {
1574
+ if (!isMergeCommit("HEAD", { allowFailure: true })) return false;
1575
+ const current = run("git", ["show", "-s", RAW_MESSAGE_FORMAT, "HEAD"], {
1576
+ allowFailure: true,
1577
+ });
1578
+ // probe-direction: fail-closed — a HEAD whose message cannot be read grants
1579
+ // no exemption, so an unreadable answer makes the gate stricter and the
1580
+ // author writes an ordinary trailered commit. The opposite default would
1581
+ // exempt on a failed probe, which is the shape this whole ticket is about.
1582
+ if (current.status !== 0) return false;
1583
+ return cleanedMessage(message) === cleanedMessage(current.stdout);
1584
+ }
1585
+
1097
1586
  /**
1098
1587
  * `.lisa.config.json` as it stands at one commit, or `{}` when it cannot be
1099
1588
  * read there.
@@ -1202,8 +1691,7 @@ function protectedCommits(commits, configRef, remote) {
1202
1691
  }
1203
1692
 
1204
1693
  function commitExemption(sha, onProtectedBranch = new Set()) {
1205
- const parents = git(["rev-list", "--parents", "-n", "1", sha]).split(/\s+/);
1206
- if (parents.length > 2) return "merge";
1694
+ if (isMergeCommit(sha)) return "merge";
1207
1695
  if (RELEASE_SUBJECT.test(git(["show", "-s", "--format=%s", sha])))
1208
1696
  return "release";
1209
1697
  return onProtectedBranch.has(sha) ? "protected" : undefined;
@@ -1799,6 +2287,10 @@ function backlinkTokens(text) {
1799
2287
  * field. Exported so the comparison can be asserted directly — a permissive
1800
2288
  * comparison returns `true` rather than throwing, so nothing observable changes
1801
2289
  * without an assertion on the returned boolean.
2290
+ *
2291
+ * READ-ONLY. Its permissiveness is correct here and only here: gate 5 should
2292
+ * pass when a human links the pull request in their own words. It must never
2293
+ * authorise a write — see `managedBacklinkTarget` for why that split exists.
1802
2294
  * @param {unknown} value Comment body, or a structure containing one.
1803
2295
  * @param {string} prUrl The pull request URL that must be linked.
1804
2296
  * @returns {boolean} True when this exact pull request is linked.
@@ -1829,18 +2321,55 @@ function backlinkBody(prUrl) {
1829
2321
  }
1830
2322
 
1831
2323
  /**
1832
- * Whether a comment payload is Lisa's managed backlink comment.
2324
+ * A comment body's visible text, whatever shape the provider returned.
2325
+ *
2326
+ * GitHub and Linear hand back a plain string; Jira hands back an Atlassian
2327
+ * Document Format tree whose prose lives in the `text` field of its leaf nodes.
2328
+ * Collecting those in document order reconstructs what a reader sees, which is
2329
+ * the only thing an identity test can honestly compare. Deliberately NOT
2330
+ * `JSON.stringify`: that would let structure — a link's `href`, a node type —
2331
+ * count as text the author wrote.
2332
+ * @param {unknown} body Comment body in whatever shape the provider returned.
2333
+ * @returns {string} The visible text, or the empty string when there is none.
2334
+ */
2335
+ function bodyText(body) {
2336
+ if (typeof body === "string") return body;
2337
+ if (Array.isArray(body)) return body.map(bodyText).join(" ");
2338
+ if (body && typeof body === "object")
2339
+ return Object.entries(body)
2340
+ .map(([key, value]) =>
2341
+ key === "text" && typeof value === "string" ? value : bodyText(value)
2342
+ )
2343
+ .join(" ");
2344
+ return "";
2345
+ }
2346
+
2347
+ /**
2348
+ * The pull request a body is Lisa's managed backlink FOR, or null.
2349
+ *
2350
+ * This is an IDENTITY test, and the split from `textContainsBacklink` is the
2351
+ * whole point. That predicate answers "is this pull request linked from this
2352
+ * item?" — the right question for a reader, which should accept a human's link
2353
+ * however they wrote it. Reusing it to authorise a write turned a claim about
2354
+ * the ITEM into a claim about the COMMENT: any prose carrying the marker and
2355
+ * the URL as a bare token was treated as Lisa's to replace wholesale, and the
2356
+ * gate's own printed remedy contains both, so pasting a gate failure into a
2357
+ * comment was enough to have that comment flattened to one line.
1833
2358
  *
1834
- * Shape-agnostic deliberately: GitHub and Linear return a plain string body,
1835
- * Jira returns an Atlassian Document Format tree. Serialising covers all three
1836
- * without a per-provider walker, and the marker is distinctive enough that a
1837
- * false positive would have to be a comment quoting it verbatim which is
1838
- * still Lisa's comment to reuse rather than a second one to add.
2359
+ * So a body must BE `backlinkBody(...)`, not merely contain its parts.
2360
+ * Whitespace is normalised because a provider may hand back the body it stored
2361
+ * with a trailing newline, and refusing to recognise Lisa's own comment would
2362
+ * make every rerun post a duplicate the opposite failure, equally wrong.
1839
2363
  * @param {unknown} body Comment body in whatever shape the provider returned.
1840
- * @returns {boolean} True when this is the managed comment.
2364
+ * @returns {string | null} The pull request URL it links, or null if the body
2365
+ * is somebody's writing rather than the managed comment.
1841
2366
  */
1842
- function carriesMarker(body) {
1843
- return JSON.stringify(body ?? "").includes(MARKER);
2367
+ function managedBacklinkTarget(body) {
2368
+ const text = bodyText(body).replace(/\s+/g, " ").trim();
2369
+ const prefix = `${MARKER} `;
2370
+ if (!text.startsWith(prefix)) return null;
2371
+ const target = text.slice(prefix.length);
2372
+ return target && !target.includes(" ") ? target : null;
1844
2373
  }
1845
2374
 
1846
2375
  /**
@@ -1863,6 +2392,12 @@ function carriesMarker(body) {
1863
2392
  * and its replacement, a pull request reopened or recreated against a different
1864
2393
  * base, and work split across repositories all put two pull requests on one
1865
2394
  * item, and none of them involve a stack.
2395
+ *
2396
+ * Both arms run off `managedBacklinkTarget`, so a comment nobody managed is
2397
+ * neither overwritten nor counted. The count is the operator-visible half of
2398
+ * the same predicate: "1 other pull request already linked" said of a prose
2399
+ * comment is a false statement about the item, and it is the line that
2400
+ * surfaced this defect.
1866
2401
  * @param {readonly unknown[]} comments Comments as the provider returned them.
1867
2402
  * @param {string} prUrl The pull request being discharged.
1868
2403
  * @param {(comment: unknown) => unknown} bodyOf Reads a comment's body.
@@ -1873,9 +2408,9 @@ function partitionBacklinks(comments, prUrl, bodyOf) {
1873
2408
  const mine = [];
1874
2409
  let others = 0;
1875
2410
  for (const comment of comments) {
1876
- const body = bodyOf(comment);
1877
- if (!carriesMarker(body)) continue;
1878
- if (textContainsBacklink(body, prUrl)) mine.push(comment);
2411
+ const target = managedBacklinkTarget(bodyOf(comment));
2412
+ if (target === null) continue;
2413
+ if (target === prUrl) mine.push(comment);
1879
2414
  else others += 1;
1880
2415
  }
1881
2416
  return { mine: mine[0], others };
@@ -2037,9 +2572,10 @@ function linearBacklink(ref, prUrl, contract) {
2037
2572
  * The managed comment as an Atlassian Document Format tree.
2038
2573
  *
2039
2574
  * A single text node, so the marker and the URL land in one string — which is
2040
- * what `textContainsBacklink` walks the tree looking for. Splitting them across
2041
- * nodes would write a comment the reader accepts visually and the check
2042
- * rejects.
2575
+ * what `textContainsBacklink` walks the tree looking for, and what
2576
+ * `managedBacklinkTarget` reconstructs when deciding the comment is Lisa's to
2577
+ * update. Splitting them across nodes would write a comment the reader accepts
2578
+ * visually and the check rejects.
2043
2579
  * @param {string} prUrl Pull request URL.
2044
2580
  * @returns {object} The ADF document.
2045
2581
  */
@@ -2291,12 +2827,31 @@ function assertStateAmong(refs, contract) {
2291
2827
  * take the fail-open path on every commit, and print the same success line: a
2292
2828
  * second fail-open wearing the first fix's clothes, and strictly worse than
2293
2829
  * the gap, because the gap would now be believed closed.
2830
+ * EXPORTED, AND TAKING THE BRANCH AS AN ARGUMENT, so the provider dispatch is
2831
+ * reachable in process. `githubBranchIssue` below was split out for that
2832
+ * reason and is measurable; the routing that decides whether it is called at
2833
+ * all was not. The CLI cases exercise these lines only by SPAWNING the script,
2834
+ * and a spawned child loads the file from disk rather than the instrumented
2835
+ * module, so a mutant here is activated in a process the assertions never
2836
+ * observe. It survives, and the gate reports a score rather than reporting
2837
+ * that it measured nothing — `if (false)` on the provider arm routes every
2838
+ * GitHub branch away from the extractor and restores the defect
2839
+ * CodySwannGT/lisa#3861 closed, while every end-to-end case still passes.
2840
+ * Measured after the split, over these lines only: 21 mutants, 19 killed,
2841
+ * 90.48%, where before the split every mutant here was unkillable. The one
2842
+ * survivor left inside this function is `if (!branch)` mutated to `if (false)`,
2843
+ * and it is EQUIVALENT rather than unproven: both arms already decline an
2844
+ * absent name on their own — the GitHub reading needs a slash-segment starting
2845
+ * with a digit, the key-based reading needs the configured key — so no input
2846
+ * distinguishes the two programs. The guard stays because it is the documented
2847
+ * fail-open and because the next arm added here may not be so forgiving; the
2848
+ * equivalence is recorded so a later reader does not mistake it for a gap.
2849
+ * @param {string|undefined} branch Active branch name, if any.
2294
2850
  * @param {object} contract Resolved tracker contract.
2295
2851
  * @returns {string|undefined} Canonical reference, or undefined when the
2296
2852
  * branch encodes none.
2297
2853
  */
2298
- function branchWorkItem(contract) {
2299
- const branch = activeBranch();
2854
+ export function branchWorkItemFrom(branch, contract) {
2300
2855
  if (!branch) return undefined;
2301
2856
  if (contract.provider === "github")
2302
2857
  return githubBranchIssue(branch, contract);
@@ -2313,6 +2868,28 @@ function branchWorkItem(contract) {
2313
2868
  return match ? `${key}-${match[1]}` : undefined;
2314
2869
  }
2315
2870
 
2871
+ /**
2872
+ * The work item the ACTIVE branch encodes, or undefined when it encodes none.
2873
+ *
2874
+ * The impure half of the split, and deliberately nothing beyond the
2875
+ * `activeBranch()` call — every decision, including the detached-HEAD
2876
+ * fail-open, lives in `branchWorkItemFrom`, which is pure and exported so the
2877
+ * mutation gate can see it.
2878
+ *
2879
+ * This one line is therefore the whole residue of the subprocess boundary: its
2880
+ * body-removal mutant survives, because the only cases that reach it spawn the
2881
+ * script. Those cases DO catch it — with this returning undefined the trailer
2882
+ * comparison silently fails open again and the mismatch cases stop refusing —
2883
+ * but the gate cannot observe a child process. Shrinking that blind spot from
2884
+ * the whole dispatch to one delegating call is the point of the split.
2885
+ * @param {object} contract Resolved tracker contract.
2886
+ * @returns {string|undefined} Canonical reference, or undefined when the
2887
+ * branch encodes none.
2888
+ */
2889
+ function branchWorkItem(contract) {
2890
+ return branchWorkItemFrom(activeBranch(), contract);
2891
+ }
2892
+
2316
2893
  /**
2317
2894
  * The GitHub issue a branch name encodes, canonicalized, or undefined.
2318
2895
  *
@@ -2357,7 +2934,26 @@ export function githubBranchIssue(branch, contract) {
2357
2934
  // Bounded on both sides: the number must fill the segment, so `4.33.1` and
2358
2935
  // `se-7728` do not match, and `3463` is not read out of `34631`.
2359
2936
  const match = /^[^/]+\/([1-9]\d*)(?:-|$)/.exec(branch);
2360
- return match ? `${contract.repository}#${match[1]}` : undefined;
2937
+ if (!match) return undefined;
2938
+ // A LEADING date stamp escapes every other bound this rule has. The comment
2939
+ // above already declines `stack/queue-drain-20260903`, but only because the
2940
+ // digits trail there; `release/20260903-cutover` puts the same stamp at the
2941
+ // front of the segment, where it fills it exactly and reads as issue
2942
+ // 20260903. Nothing else here can tell the two apart, because by shape they
2943
+ // are the same token in the same position.
2944
+ //
2945
+ // Matched as a DATE rather than as "too many digits": a length bound would
2946
+ // be a guess about how many issues this fleet will ever file, and would
2947
+ // start refusing real numbers on the day it is wrong. `YYYYMMDD` with a
2948
+ // real month and a real day is narrow enough that the only issue number it
2949
+ // can cost is one no repository will reach.
2950
+ //
2951
+ // Declining fails OPEN — the trailer is then compared against nothing, which
2952
+ // is the state this whole fallback was built to improve on but is still
2953
+ // strictly safer than refusing a commit that is perfectly correct.
2954
+ if (/^(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])$/.test(match[1]))
2955
+ return undefined;
2956
+ return `${contract.repository}#${match[1]}`;
2361
2957
  }
2362
2958
 
2363
2959
  /**
@@ -2407,7 +3003,10 @@ function assertIdentityMatches(ref, contract) {
2407
3003
  }
2408
3004
 
2409
3005
  function validateMessage(message, options = {}) {
2410
- if (options.allowInProgressMerge && isMergeInProgress())
3006
+ if (
3007
+ options.allowMergeExemption &&
3008
+ (isMergeInProgress() || amendsMergeAtHead(message))
3009
+ )
2411
3010
  return { exempt: "merge" };
2412
3011
  if (RELEASE_SUBJECT.test(messageSubject(message)))
2413
3012
  return { exempt: "release" };
@@ -2419,7 +3018,7 @@ function validateMessage(message, options = {}) {
2419
3018
  }
2420
3019
 
2421
3020
  function commitMessage(sha) {
2422
- return git(["show", "-s", "--format=%B", sha]);
3021
+ return git(["show", "-s", RAW_MESSAGE_FORMAT, sha]);
2423
3022
  }
2424
3023
 
2425
3024
  /**
@@ -2493,6 +3092,13 @@ function validateCommits(commits, configRef, remote) {
2493
3092
  refs: list,
2494
3093
  issue: ref ? issues.get(ref) : undefined,
2495
3094
  issues,
3095
+ // De-duplicated on purpose, and it is the same `unique` every counter below
3096
+ // was computed from. A caller that compared a counter against the RAW
3097
+ // `commits.length` would read "some commits were exempt" out of a range in
3098
+ // which every one of them was, whenever the same sha appeared twice — which
3099
+ // a push range spanning several refs does routinely. The whole point of
3100
+ // this field is to be the denominator those counters actually add up to.
3101
+ examined: unique.length,
2496
3102
  mergeExempt,
2497
3103
  protectedExempt,
2498
3104
  releaseExempt,
@@ -3160,6 +3766,61 @@ function reportMapping(findings, commitRefs, bodyRefs) {
3160
3766
  });
3161
3767
  }
3162
3768
 
3769
+ /**
3770
+ * True when this range introduced no authored work of its own, and at least one
3771
+ * of the things it did introduce was a merge.
3772
+ *
3773
+ * ## Why the condition is stated against `examined`
3774
+ *
3775
+ * The deferral used to read `relevant === 0 && mergeExempt > 0`. Both halves
3776
+ * are true of every range that should defer, but only one of them SAYS so.
3777
+ * `mergeExempt > 0` is satisfied by a range in which SOME commits are merges —
3778
+ * strictly weaker than the precondition the deferral means to assert, which is
3779
+ * that not one commit in the range authored anything. It coincided with the
3780
+ * right answer because `relevant === 0` was carrying that claim, so the two
3781
+ * agreed by accident rather than by construction, and a refactor that loosened
3782
+ * `relevant` would have taken the deferral with it silently
3783
+ * (CodySwannGT/lisa#3921).
3784
+ *
3785
+ * Stating it as "every commit examined was exempt" makes the predicate
3786
+ * self-sufficient: it reads the denominator directly instead of trusting a
3787
+ * counter that is incremented before the trailer is parsed.
3788
+ *
3789
+ * ## Why it is not `mergeExempt === examined`
3790
+ *
3791
+ * Because that is a DIFFERENT claim — "the range is nothing but merges" — and
3792
+ * it is strictly stronger than the one the deferral needs. A back-merge of a
3793
+ * non-default deploy branch legitimately drags release commits along with the
3794
+ * merge; those are exempt for their own reason, and a range of one merge plus
3795
+ * one `chore(release):` commit still introduced no authored work. Requiring
3796
+ * merges-only would refuse it with "no non-merge commit linked to a work item",
3797
+ * which is the #3851 deadlock returning through the door this closed.
3798
+ *
3799
+ * `mergeExempt > 0` stays as the second conjunct, and it is not the weak test
3800
+ * this replaced: it is what separates a deferral ("the subject is one level up,
3801
+ * at the merge's own pull request") from the release-only and deploy-chain
3802
+ * exemptions, which have their own branches and their own wording above.
3803
+ *
3804
+ * Exported so the predicate can be asserted directly. The push path reaches it
3805
+ * only through a spawned child, where the interesting case — a range whose
3806
+ * exemptions do not add up — cannot be built out of real commits at all.
3807
+ * @param {object} [result] Commit-side result from `validateCommits`.
3808
+ * @returns {boolean} True when the commit-side question has no subject here.
3809
+ */
3810
+ export function mergeOnlyRange(result) {
3811
+ if (!result) return false;
3812
+ const examinedCount = result.examined ?? 0;
3813
+ const exempt =
3814
+ (result.mergeExempt ?? 0) +
3815
+ (result.releaseExempt ?? 0) +
3816
+ (result.protectedExempt ?? 0);
3817
+ return (
3818
+ examinedCount > 0 &&
3819
+ (result.mergeExempt ?? 0) > 0 &&
3820
+ exempt === examinedCount
3821
+ );
3822
+ }
3823
+
3163
3824
  /**
3164
3825
  * Check every pull-request requirement and report all of the unmet ones.
3165
3826
  *
@@ -3228,12 +3889,13 @@ function validatePrData(outcome, prUrl, prBody, rangeIsPartial = false) {
3228
3889
  // Nothing to check HERE, and the subject is one level up. Scoped to a merge
3229
3890
  // because that is the only way a partial range empties out while the pull
3230
3891
  // request is attributed: the trailered commit is excluded for having been
3231
- // validated on an earlier push. `relevant === 0` cannot mask an untrailered
3232
- // commit — `validateCommits` increments the counter BEFORE reading the
3233
- // trailer, so a missing one raises through `outcome.error` on a different
3234
- // branch entirely, and the control below still refuses it.
3892
+ // validated on an earlier push. `mergeOnlyRange` cannot mask an untrailered
3893
+ // commit — `validateCommits` classifies one as neither merge, release nor
3894
+ // protected, so the exemptions no longer add up to the range and the
3895
+ // predicate is false; the missing trailer also raises through `outcome.error`
3896
+ // on a different branch entirely, and the control below still refuses it.
3235
3897
  const deferredToPullRequest =
3236
- rangeIsPartial === true && result?.relevant === 0 && result.mergeExempt > 0;
3898
+ rangeIsPartial === true && mergeOnlyRange(result);
3237
3899
  // All three are COMMIT-side. They carry `IN_THIS_PR` because a rewrite plus a
3238
3900
  // force-push does clear them without recreating the pull request — but the
3239
3901
  // tag's wording invites a body edit, which cannot touch a commit message. The
@@ -3332,15 +3994,27 @@ function alreadyTraced(result) {
3332
3994
  * `alreadyTraced` renders nothing here: it fires only for `protectedExempt`,
3333
3995
  * and a back-merge onto a feature branch has none. So the zero went unexplained.
3334
3996
  *
3335
- * ## Why `relevant === 0 && mergeExempt > 0` is the whole condition
3997
+ * ## The condition is the deferral's own, not a second opinion
3998
+ *
3999
+ * Both read {@link mergeOnlyRange}, deliberately: a message rendered on a
4000
+ * condition that merely AGREES with the deferral is a second implementation of
4001
+ * the same rule, free to drift from it, and a clause claiming a deferral that
4002
+ * did not happen is worse than no clause. This renders only on the push path,
4003
+ * where the range is ALWAYS a subset of the pull request's — `reportPushGroup`
4004
+ * passes `rangeIsPartial: true` unconditionally, and says why. `validate-pr`
4005
+ * reads the full `base..head` range and does not defer, which is why its
4006
+ * success line does not carry this.
4007
+ *
4008
+ * ## What it must say, and why the count alone did not
3336
4009
  *
3337
- * The deferral itself is `rangeIsPartial && relevant === 0 && mergeExempt > 0`.
3338
- * This renders only on the push path, where the range is ALWAYS a subset of the
3339
- * pull request's`reportPushGroup` passes `rangeIsPartial: true`
3340
- * unconditionally, and says why. `validate-pr` reads the full `base..head`
3341
- * range and does not defer, which is why its success line does not carry this.
4010
+ * A gate that stands down has to name what it stood down FROM and what still
4011
+ * ran, or the next reader cannot separate "deferred, correctly, with gates 4
4012
+ * and 5 still enforced" from "skipped" and that distinction is the entire
4013
+ * subject of the surrounding work (CodySwannGT/lisa#3921). So the clause names
4014
+ * the count, names gate 3 as the one with no subject here, names where it is
4015
+ * asked instead, and names the gates that were enforced anyway.
3342
4016
  *
3343
- * ## This adds a sentence and relaxes nothing
4017
+ * ## This adds sentences and relaxes nothing
3344
4018
  *
3345
4019
  * The verdict, the exit status and every gate are untouched: a clause is
3346
4020
  * appended to a line that already said OK. A diagnostic fix that also softened
@@ -3349,9 +4023,25 @@ function alreadyTraced(result) {
3349
4023
  * @returns {string} A clause to append, or the empty string.
3350
4024
  */
3351
4025
  function carriedByPullRequest(result) {
3352
- return result.relevant === 0 && result.mergeExempt > 0
3353
- ? ` (${result.mergeExempt} merge commit(s); this push introduces no authored work, so the pull request's own range carries the requirement)`
3354
- : "";
4026
+ if (!mergeOnlyRange(result)) return "";
4027
+ // Stated separately from the deferral itself, and this is the half #3921
4028
+ // asked for. "N merge commit(s)" alone leaves a reader unable to tell a
4029
+ // correct deferral from a skip: the two differ entirely in what ELSE ran, and
4030
+ // what else ran was not in the output. Gate 5 is named as inapplicable rather
4031
+ // than enforced under `trailer`, because a run that contacted no tracker has
4032
+ // not enforced a backlink — claiming it here would be the vacuous success
4033
+ // this line exists to prevent, one clause along.
4034
+ const stillEnforced =
4035
+ result.contract?.verify === "full"
4036
+ ? "gates 4 and 5 were still enforced here, on this push, exactly as on any other"
4037
+ : 'gate 4 was still enforced here, on this push, exactly as on any other; gate 5 does not apply (workItem.verify is "trailer")';
4038
+ return (
4039
+ ` (${result.mergeExempt} merge commit(s), and nothing else in this range; ` +
4040
+ `this push introduces no authored work, so gate 3 had no subject here and ` +
4041
+ `the pull request's own range carries the requirement — \`validate-pr\` ` +
4042
+ `asks it there from base..head, as a required check. Nothing else stood ` +
4043
+ `down: ${stillEnforced})`
4044
+ );
3355
4045
  }
3356
4046
 
3357
4047
  /**
@@ -3626,33 +4316,50 @@ function backlink(args) {
3626
4316
  * somebody's pull request NAMED the item somewhere, which is a different and
3627
4317
  * much weaker claim — see `sweepDeclarations` for what that difference cost.
3628
4318
  *
3629
- * Scanned with the `m` flag over the WHOLE body rather than read through git's
3630
- * `%(trailers:key=Work-Item)`, and that is the load-bearing part. Git's trailer
3631
- * parser only considers the LAST paragraph of a message, and this project's
3632
- * commits put `Work-Item:` above the trailing co-author block, so the parser
3633
- * does not see it as a trailer at all. Measured on `origin/main`: the parser
3634
- * finds 602 distinct issues where a full-body scan finds 835 — it misses 233,
3635
- * and the undercount is clean, plausible and well-formed, which is why it
3636
- * survived. Anything in this fleet that counts or verifies trailers has to
3637
- * scan the body (CodySwannGT/lisa#3859).
4319
+ * Read through `workItemLines` the whole body, prefix anchored at column zero
4320
+ * — rather than git's `%(trailers:key=Work-Item)`, and that is the load-bearing
4321
+ * part. Git's trailer parser only considers the LAST paragraph of a message,
4322
+ * and this project's commits put `Work-Item:` above the trailing co-author
4323
+ * block, so the parser does not see it as a trailer at all. Measured on
4324
+ * `origin/main`: the parser finds 602 distinct issues where a full-body scan
4325
+ * finds 835 — it misses 233, and the undercount is clean, plausible and
4326
+ * well-formed, which is why it survived. Anything in this fleet that counts or
4327
+ * verifies trailers has to scan the body (CodySwannGT/lisa#3859).
4328
+ *
4329
+ * It USED to carry its own regex, which is how the two readers drifted: that
4330
+ * one allowed leading whitespace where `workItemLines` anchors at column zero,
4331
+ * so it would have matched the context line of a verbose commit's own diff.
4332
+ * Measured over full `origin/main` history: 2,059 `Work-Item:` lines at column
4333
+ * zero and ZERO indented ones, so the looser form served no real input and
4334
+ * admitted one class of false positive. One definition now, not two (#3747).
4335
+ *
4336
+ * The `owner/name#number` filter stays HERE rather than moving down into the
4337
+ * reader. This function answers a narrower question — which issues in THIS
4338
+ * repository does the commit declare? — and a value the filter drops is one the
4339
+ * reader still saw. That ordering is what keeps a malformed trailer refusable
4340
+ * by the gate instead of invisible to it.
4341
+ *
4342
+ * CRLF needs nothing here, and a relayed review finding that said otherwise
4343
+ * was refuted rather than acted on. `workItemLines` splits on `/\r?\n/`, so a
4344
+ * CRLF line arrives without its carriage return; the value is trimmed besides.
4345
+ * The case is pinned in `work-item-cli-writes.test.ts` so the refutation is a
4346
+ * control rather than a paragraph.
3638
4347
  * @param {string} body A commit message body.
3639
4348
  * @param {string} repository `owner/name` the item must belong to.
3640
4349
  * @returns {number[]} Declared issue numbers, de-duplicated, in first-seen order.
3641
4350
  */
3642
4351
  export function declaredWorkItemNumbers(body, repository) {
3643
- const escaped = String(repository ?? "").replace(
3644
- /[.*+?^${}()|[\]\\]/g,
3645
- "\\$&"
3646
- );
3647
- if (!escaped) return [];
3648
- const pattern = new RegExp(
3649
- `^[ \\t]*Work-Item:[ \\t]*${escaped}#([1-9]\\d*)[ \\t]*$`,
3650
- "gim"
3651
- );
4352
+ const owner = String(repository ?? "").toLowerCase();
4353
+ if (owner === "") return [];
3652
4354
  const numbers = [];
3653
- let match;
3654
- while ((match = pattern.exec(String(body ?? ""))) !== null)
3655
- numbers.push(Number(match[1]));
4355
+ for (const value of workItemLines(body)) {
4356
+ const hash = value.indexOf("#");
4357
+ if (hash === -1) continue;
4358
+ if (value.slice(0, hash).toLowerCase() !== owner) continue;
4359
+ const digits = value.slice(hash + 1);
4360
+ if (!isIssueNumber(digits)) continue;
4361
+ numbers.push(Number(digits));
4362
+ }
3656
4363
  return [...new Set(numbers)];
3657
4364
  }
3658
4365
 
@@ -4441,6 +5148,12 @@ function complete(args) {
4441
5148
  *
4442
5149
  * Reports by default and only acts under `--apply`, because a sweep that closes
4443
5150
  * things as a side effect of being run is not something anyone will run twice.
5151
+ *
5152
+ * `--since <rev>` bounds the evidence scan to what a deploy branch gained after
5153
+ * `<rev>`. That is what makes an APPLYING run safe to trigger from a merge: the
5154
+ * unbounded question is "what has ever shipped and is still open", which is a
5155
+ * backlog nobody should complete unattended, while the bounded one is "what did
5156
+ * this push ship", which is exactly the item the merge earned. See `sweepBound`.
4444
5157
  */
4445
5158
  /**
4446
5159
  * Buffer bound for a deploy branch's commit log.
@@ -4518,6 +5231,92 @@ function resolvedBranchRev(branch) {
4518
5231
  return undefined;
4519
5232
  }
4520
5233
 
5234
+ /** Bounds a sweep to the commits a single push added to a deploy branch. */
5235
+ const SINCE_FLAG = "--since";
5236
+
5237
+ /**
5238
+ * The revision a bounded sweep starts AFTER, or undefined for the whole history.
5239
+ *
5240
+ * This is what gives directly-driven work a terminal path. The unbounded sweep
5241
+ * answers "what has ever shipped and is still open", which is a backlog
5242
+ * question: pointing `--apply` at it completes every item the history declares,
5243
+ * so it is only ever safe to run deliberately, by a person who has read the
5244
+ * report. A merge cannot run that. `--since <rev>` narrows the same
5245
+ * evidence to what THIS push added, which is a question a push-triggered run
5246
+ * can answer and act on — it completes the items its own merge shipped and
5247
+ * structurally cannot reach any other (CodySwannGT/lisa#3704).
5248
+ *
5249
+ * A valueless flag is REFUSED rather than read as absent. The shared `option`
5250
+ * helper falls back when a flag carries no value, which is right where the
5251
+ * fallback is another spelling of the same answer and catastrophic here: the
5252
+ * fallback is "no bound at all", so a typo would silently widen an applying run
5253
+ * from one merge to the entire backlog. That is the one direction this must
5254
+ * never fail in.
5255
+ * @param {string[]} args Command arguments.
5256
+ * @returns {string | undefined} The bound, or undefined when unbounded.
5257
+ */
5258
+ function sweepBound(args) {
5259
+ const index = args.indexOf(SINCE_FLAG);
5260
+ if (index < 0) return undefined;
5261
+ const value = args[index + 1];
5262
+ if (value === undefined || value.startsWith("-") || value.trim() === "") {
5263
+ throw new TrackingError(
5264
+ `${SINCE_FLAG} was supplied without a revision, and it is not read as absent.\n` +
5265
+ `Pass ${SINCE_FLAG} <rev> — the commit the deploy branch was at before the push — ` +
5266
+ `or omit the flag entirely to sweep the whole history.\n` +
5267
+ `Treating it as absent would widen an --apply run from the one merge that triggered it ` +
5268
+ `to every item the history declares.`
5269
+ );
5270
+ }
5271
+ return value.trim();
5272
+ }
5273
+
5274
+ /**
5275
+ * Resolve a sweep bound to a commit, refusing when it names nothing here.
5276
+ *
5277
+ * An unresolvable bound is NOT DETERMINED, never an empty range. `git log
5278
+ * <branch> --not <unknown>` does not fail quietly — it fails — but a bound
5279
+ * that resolves in the caller's repository and not in this checkout (a shallow
5280
+ * clone, an unfetched ref, the all-zero SHA a branch-creation push carries) is
5281
+ * the case where a run could otherwise report a narrower answer than the one
5282
+ * its operator asked for. Refusing says which of the two happened.
5283
+ * @param {string | undefined} since The bound as supplied.
5284
+ * @returns {string | undefined} The resolved object ID, or undefined.
5285
+ */
5286
+ function resolvedSinceRev(since) {
5287
+ if (since === undefined) return undefined;
5288
+ const result = run(
5289
+ "git",
5290
+ ["rev-parse", "--verify", "--quiet", `${since}^{commit}`],
5291
+ { allowFailure: true }
5292
+ );
5293
+ const sha = result.stdout.trim();
5294
+ if (result.status !== 0 || sha === "") {
5295
+ throw new TrackingError(
5296
+ `NOT DETERMINED: ${SINCE_FLAG} ${since} resolves to no commit in this checkout, ` +
5297
+ `so the range a bounded sweep would examine is unknown.\n` +
5298
+ `An unresolvable bound is not an empty one — reporting "no drift" from it would be ` +
5299
+ `an absence claim over evidence never read.\n` +
5300
+ `Fetch the history (\`git fetch --unshallow origin\`) and re-run, or drop ${SINCE_FLAG} ` +
5301
+ `to sweep the whole deploy history.`
5302
+ );
5303
+ }
5304
+ return sha;
5305
+ }
5306
+
5307
+ /**
5308
+ * The sentence a report owes its reader when the scan was bounded.
5309
+ * @param {string | undefined} since The bound as supplied.
5310
+ * @returns {string} A qualifying line, or the empty string when unbounded.
5311
+ */
5312
+ function describeSinceBound(since) {
5313
+ if (since === undefined) return "";
5314
+ return (
5315
+ `\nBounded: only commits a deploy branch gained since ${since} were read, so anything ` +
5316
+ `declared before that is outside this result.`
5317
+ );
5318
+ }
5319
+
4521
5320
  /**
4522
5321
  * Work items DECLARED by commits reachable from a deploy branch.
4523
5322
  *
@@ -4534,16 +5333,35 @@ function resolvedBranchRev(branch) {
4534
5333
  *
4535
5334
  * Reachability is also what confirms the work actually landed, rather than
4536
5335
  * trusting a pull request's `baseRefName` to say where it went.
5336
+ *
5337
+ * UNRESOLVABLE branches are reported, never silently dropped. A configured
5338
+ * deploy branch that resolves to no commit is a branch the sweep did not look
5339
+ * at, and a `continue` past it turns part of an absence claim into evidence
5340
+ * never read — the same shape as the truncated log below, which this function
5341
+ * already refuses over. It is not fatal on its own, because a project may
5342
+ * configure `dev` or `staging` in a clone that has only ever fetched `main`
5343
+ * and the sweep is still useful there; what it may not do is let the omission
5344
+ * go unsaid. When NOTHING resolved there is no evidence at all, and the run
5345
+ * refuses rather than reporting a clean queue.
4537
5346
  * @param {string} repository `owner/name` the items belong to.
4538
5347
  * @param {object} contract Resolved tracker contract.
4539
- * @returns {Map<number, string[]>} Issue number to declaring commits.
5348
+ * @param {string} [since] Revision to bound the scan after; see `sweepBound`.
5349
+ * @returns {{declarations: Map<number, string[]>, unresolved: string[]}} Issue
5350
+ * number to declaring commits, and the deploy branches that resolved to
5351
+ * nothing.
4540
5352
  */
4541
- function deployedDeclarations(repository, contract) {
5353
+ function deployedDeclarations(repository, contract, since) {
5354
+ const sinceRev = resolvedSinceRev(since);
4542
5355
  const declarations = new Map();
5356
+ const unresolved = [];
4543
5357
  for (const branch of contract.deployBranches.keys()) {
4544
5358
  const rev = resolvedBranchRev(branch);
4545
- if (!rev) continue;
4546
- const result = run("git", ["log", rev, "-z", "--format=%H%n%B"], {
5359
+ if (!rev) {
5360
+ unresolved.push(branch);
5361
+ continue;
5362
+ }
5363
+ const bound = sinceRev ? ["--not", sinceRev] : [];
5364
+ const result = run("git", ["log", rev, ...bound, "-z", "--format=%H%n%B"], {
4547
5365
  allowFailure: true,
4548
5366
  maxBuffer: DECLARATION_LOG_MAX_BYTES,
4549
5367
  });
@@ -4570,7 +5388,31 @@ function deployedDeclarations(repository, contract) {
4570
5388
  ]);
4571
5389
  }
4572
5390
  }
4573
- return declarations;
5391
+ if (
5392
+ unresolved.length > 0 &&
5393
+ unresolved.length === contract.deployBranches.size
5394
+ ) {
5395
+ throw new TrackingError(
5396
+ `no configured deploy branch resolves to a commit (${unresolved.join(", ")}), so no absence of drift can be reported.\n` +
5397
+ `Fetch them (\`git fetch origin\`) or correct \`deploy.branches\` in .lisa.config.json; ` +
5398
+ `a deploy branch the sweep could not read is not an empty one.`
5399
+ );
5400
+ }
5401
+ return { declarations, unresolved };
5402
+ }
5403
+
5404
+ /**
5405
+ * The sentence a report owes its reader when part of the evidence was missing.
5406
+ * @param {string[]} unresolved Deploy branches that resolved to no commit.
5407
+ * @returns {string} A qualifying line, or the empty string when nothing was.
5408
+ */
5409
+ function describeUnresolvedBranches(unresolved) {
5410
+ if (unresolved.length === 0) return "";
5411
+ return (
5412
+ `\nNOT examined: ${unresolved.join(", ")} — configured as a deploy branch ` +
5413
+ `but resolving to no commit here, so anything shipped only there is ` +
5414
+ `outside this result.`
5415
+ );
4574
5416
  }
4575
5417
 
4576
5418
  /**
@@ -4603,7 +5445,12 @@ function sweep(args) {
4603
5445
  else subjects.set(issue.number, { ...issue, roles: [role] });
4604
5446
  }
4605
5447
  }
4606
- const declarations = deployedDeclarations(repository, contract);
5448
+ const since = sweepBound(args);
5449
+ const { declarations, unresolved } = deployedDeclarations(
5450
+ repository,
5451
+ contract,
5452
+ since
5453
+ );
4607
5454
  const apply = args.includes("--apply");
4608
5455
  // Named in every report, clean or not. The old clean-result sentence spoke
4609
5456
  // only of the claimed role while the subject list excluded the ready lane
@@ -4638,21 +5485,166 @@ function sweep(args) {
4638
5485
  }
4639
5486
  }
4640
5487
  if (drifted === 0) {
5488
+ const examinedSummary = `Examined ${subjects.size} item(s) across ${roles.length} lifecycle role(s); no role outside ${examined} was queried.`;
4641
5489
  console.log(
4642
- `No drift: every open item carrying ${examined} is genuinely in flight.\n` +
4643
- `Examined ${subjects.size} item(s) across ${roles.length} lifecycle role(s); ` +
4644
- `no role outside ${examined} was queried.`
5490
+ `No drift: every open item carrying ${examined} is genuinely in flight.\n${examinedSummary}${describeSinceBound(since)}${describeUnresolvedBranches(unresolved)}`
4645
5491
  );
4646
5492
  return;
4647
5493
  }
4648
5494
  if (!apply) {
5495
+ const driftHeadline = `${drifted} open item(s) carrying ${examined} are declared by a commit on a deploy branch.`;
4649
5496
  console.log(
4650
- `\n${drifted} open item(s) carrying ${examined} are declared by a commit on a deploy branch. ` +
4651
- `Re-run with --apply to complete them.`
5497
+ `\n${driftHeadline} Re-run with --apply to complete them.${describeSinceBound(since)}${describeUnresolvedBranches(unresolved)}`
4652
5498
  );
4653
5499
  }
4654
5500
  }
4655
5501
 
5502
+ /**
5503
+ * Whether a line is `Key: value` — the shape git recognises as a trailer.
5504
+ *
5505
+ * Character-scanned rather than matched, so it is linear over a commit message
5506
+ * this script does not own.
5507
+ * @param {string} line One line of a commit message.
5508
+ * @returns {boolean} Whether git would read this line as a trailer.
5509
+ */
5510
+ function isTrailerLine(line) {
5511
+ const colon = line.indexOf(": ");
5512
+ if (colon <= 0) return false;
5513
+ if (line.slice(colon + 2).trim() === "") return false;
5514
+ for (const character of line.slice(0, colon)) {
5515
+ const letter =
5516
+ (character >= "a" && character <= "z") ||
5517
+ (character >= "A" && character <= "Z");
5518
+ const digit = character >= "0" && character <= "9";
5519
+ if (!letter && !digit && character !== "-") return false;
5520
+ }
5521
+ return true;
5522
+ }
5523
+
5524
+ /**
5525
+ * Where the lane line goes, so the final trailer paragraph is never split.
5526
+ *
5527
+ * `git interpret-trailers` starts a NEW paragraph whenever the last one holds
5528
+ * any line it does not recognise as a trailer — and the final block of an
5529
+ * agent-authored message routinely ends with a non-trailer signature line. The
5530
+ * split is silent and it costs something real: `Co-Authored-By` stops being in
5531
+ * the last paragraph, which is the only place GitHub reads co-authors from, so
5532
+ * a routing aid would quietly break attribution to fix attribution.
5533
+ *
5534
+ * Scanning back from the end and stopping at the first blank line keeps the
5535
+ * search inside the final paragraph. A message whose tail is prose has no
5536
+ * trailer to sit beside and is left to git, which correctly opens a paragraph
5537
+ * for it.
5538
+ * @param {string[]} lines Message lines, trailing blanks already removed.
5539
+ * @returns {number} Index to insert after, or -1 for "let git decide".
5540
+ */
5541
+ function trailerInsertionPoint(lines) {
5542
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
5543
+ if (lines[index].trim() === "") return -1;
5544
+ if (isTrailerLine(lines[index])) return index;
5545
+ }
5546
+ return -1;
5547
+ }
5548
+
5549
+ /**
5550
+ * Stamp the caller's lane onto a commit message being prepared.
5551
+ *
5552
+ * Never the reason a commit fails. Attribution is a routing aid; a git that
5553
+ * declines to rewrite trailers is a worse commit message, not a violation, and
5554
+ * refusing the commit over it would make every session's first encounter with
5555
+ * this feature an outage.
5556
+ *
5557
+ * An existing stamp is left alone. On a rebase, an amend or a cherry-pick the
5558
+ * message already carries the ORIGINATING lane's stamp, and the lane doing the
5559
+ * rewrite must not take credit for work it did not start — which is precisely
5560
+ * the guess a survey has to make when nothing is recorded.
5561
+ * @param {string} file Commit message file.
5562
+ * @returns {void}
5563
+ */
5564
+ function stampLane(file) {
5565
+ const lane = resolveCallerLaneId();
5566
+ if (lane === undefined) return;
5567
+ const message = readFileSync(file, "utf8");
5568
+ if (message.split("\n").some(line => LANE_PREFIX_MATCH.test(line))) return;
5569
+ const lines = message.split("\n");
5570
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
5571
+ const at = trailerInsertionPoint(lines);
5572
+ if (at === -1) {
5573
+ run(
5574
+ "git",
5575
+ [
5576
+ "interpret-trailers",
5577
+ "--in-place",
5578
+ "--if-missing=add",
5579
+ "--trailer",
5580
+ renderLaneTrailer(lane),
5581
+ file,
5582
+ ],
5583
+ { allowFailure: true }
5584
+ );
5585
+ return;
5586
+ }
5587
+ lines.splice(at + 1, 0, renderLaneTrailer(lane));
5588
+ writeFileSync(file, `${lines.join("\n")}\n`);
5589
+ }
5590
+
5591
+ /**
5592
+ * Answer "which lane does this belong to, and is it mine?".
5593
+ *
5594
+ * With no argument it reports the caller's own lane, which is what a session
5595
+ * needs to recognise its own work later. With `--commit` it reads the message
5596
+ * of a commit — the form that survives branch deletion, because the commit
5597
+ * lives on in the base branch once the pull request has landed. With
5598
+ * `--text-file` it reads any text at all, which is how a pull-request body or
5599
+ * a tracker comment is classified with the same vocabulary.
5600
+ * @param {string[]} args Command arguments.
5601
+ * @returns {void}
5602
+ */
5603
+ function laneCommand(args) {
5604
+ const caller = resolveCallerLaneId();
5605
+ const commit = flagValue(args, "--commit");
5606
+ const textFile = flagValue(args, "--text-file");
5607
+ if (commit === undefined && textFile === undefined) {
5608
+ console.log(caller ?? "unattributed");
5609
+ return;
5610
+ }
5611
+ const text =
5612
+ textFile === undefined
5613
+ ? commitMessageOf(commit)
5614
+ : readFileSync(textFile, "utf8");
5615
+ const marker = parseLaneId(text);
5616
+ console.log(`LANE ${judgeLane(marker, caller)} ${marker ?? "none"}`);
5617
+ }
5618
+
5619
+ /**
5620
+ * The value that follows a flag, refusing a flag left without one.
5621
+ * @param {string[]} args Command arguments.
5622
+ * @param {string} flag Flag to look for.
5623
+ * @returns {string | undefined} The value, or undefined when the flag is absent.
5624
+ */
5625
+ function flagValue(args, flag) {
5626
+ const index = args.indexOf(flag);
5627
+ if (index === -1) return undefined;
5628
+ const value = args[index + 1];
5629
+ if (value === undefined || value.startsWith("--"))
5630
+ throw new TrackingError(`${flag} requires a value`);
5631
+ return value;
5632
+ }
5633
+
5634
+ /**
5635
+ * The full message of one commit.
5636
+ * @param {string} ref Commit-ish to read.
5637
+ * @returns {string} The commit message body.
5638
+ */
5639
+ function commitMessageOf(ref) {
5640
+ const result = run("git", ["log", "-1", RAW_MESSAGE_FORMAT, ref], {
5641
+ allowFailure: true,
5642
+ });
5643
+ if (result.status !== 0)
5644
+ throw new TrackingError(`could not read the commit message of '${ref}'`);
5645
+ return result.stdout;
5646
+ }
5647
+
4656
5648
  function prepareCommitMessage(args) {
4657
5649
  const [file, source = ""] = args;
4658
5650
  if (!file)
@@ -4664,6 +5656,11 @@ function prepareCommitMessage(args) {
4664
5656
  RELEASE_SUBJECT.test(messageSubject(readFileSync(file, "utf8")))
4665
5657
  )
4666
5658
  return;
5659
+ // Before the binding check, deliberately. The lane stamp is what makes an
5660
+ // ORPHAN routable, and an orphan is disproportionately likely to be exactly
5661
+ // the commit that never got a work item bound — so gating attribution on the
5662
+ // binding would withhold it in the one case it exists for.
5663
+ stampLane(file);
4667
5664
  const state = readState(true);
4668
5665
  if (!state) return;
4669
5666
  assertStateBranch(state);
@@ -4684,10 +5681,17 @@ function validateCommit(args) {
4684
5681
  const file = args[0];
4685
5682
  if (!file)
4686
5683
  throw new TrackingError("validate-commit requires the commit message file");
5684
+ const message = readFileSync(file, "utf8");
5685
+ // Before the traceability gates and outside their exemptions, deliberately.
5686
+ // A merge or release message is exempt from naming a work item because
5687
+ // nothing bound one; neither fact says anything about whether the harness
5688
+ // appended an identifier to it, and those are exactly the messages no agent
5689
+ // proof-reads.
5690
+ assertNoSessionUrl(message);
4687
5691
  let result;
4688
5692
  try {
4689
- result = validateMessage(readFileSync(file, "utf8"), {
4690
- allowInProgressMerge: true,
5693
+ result = validateMessage(message, {
5694
+ allowMergeExemption: true,
4691
5695
  });
4692
5696
  } catch (error) {
4693
5697
  // The commit-msg hook is the EARLIEST moment an operator meets any of this,
@@ -5232,6 +6236,7 @@ function main() {
5232
6236
  // broken, which is when the answer matters most.
5233
6237
  if (command === "contract-version")
5234
6238
  return console.log(WORK_ITEM_CONTRACT_VERSION);
6239
+ if (command === "lane") return laneCommand(args);
5235
6240
  if (command === "prepare-commit-msg") return prepareCommitMessage(args);
5236
6241
  if (command === "validate-commit") return validateCommit(args);
5237
6242
  if (command === "validate-push") return validatePush(args);
@@ -5240,7 +6245,7 @@ function main() {
5240
6245
  if (command === "validate-pr") return validatePr(args);
5241
6246
  if (command === DISCHARGE_COMMAND) return dischargePrGates(args);
5242
6247
  throw new TrackingError(
5243
- "Usage: lisa-work-item.mjs link|current|attach-branch|clear|verify-level|contract-version|backlink|complete|sweep|prepare-commit-msg|validate-commit|validate-push|validate-push-destination|validate-pr|discharge-pr-gates" +
6248
+ "Usage: lisa-work-item.mjs link|current|attach-branch|clear|verify-level|contract-version|backlink|complete|sweep|lane|prepare-commit-msg|validate-commit|validate-push|validate-push-destination|validate-pr|discharge-pr-gates" +
5244
6249
  "\n(`bind` is accepted as an alias for `link`, but some agent harnesses refuse the token `bind` in a command line.)"
5245
6250
  );
5246
6251
  }