@codyswann/lisa 4.45.16 → 4.46.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 (85) hide show
  1. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  2. package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
  3. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.js +26 -4
  5. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  6. package/package.json +4 -4
  7. package/plugins/lisa/.claude-plugin/plugin.json +19 -1
  8. package/plugins/lisa/.codex-plugin/hooks.json +18 -0
  9. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
  11. package/plugins/lisa/.codex-plugin/skills/lisa-queue-status/SKILL.md +24 -0
  12. package/plugins/lisa/hooks/enforcement-vintage.mjs +453 -0
  13. package/plugins/lisa/hooks/enforcement-vintage.sh +48 -0
  14. package/plugins/lisa/scripts/pr-arming-sweep.mjs +561 -0
  15. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
  16. package/plugins/lisa/skills/lisa-queue-status/SKILL.md +24 -0
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-agy/scripts/pr-arming-sweep.mjs +561 -0
  19. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
  20. package/plugins/lisa-agy/skills/lisa-queue-status/SKILL.md +24 -0
  21. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  24. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
  27. package/plugins/lisa-copilot/hooks/enforcement-vintage.mjs +453 -0
  28. package/plugins/lisa-copilot/hooks/enforcement-vintage.sh +48 -0
  29. package/plugins/lisa-copilot/scripts/pr-arming-sweep.mjs +561 -0
  30. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
  31. package/plugins/lisa-copilot/skills/lisa-queue-status/SKILL.md +24 -0
  32. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-cursor/hooks/enforcement-vintage.mjs +453 -0
  34. package/plugins/lisa-cursor/hooks/enforcement-vintage.sh +48 -0
  35. package/plugins/lisa-cursor/hooks/hooks.json +6 -0
  36. package/plugins/lisa-cursor/scripts/pr-arming-sweep.mjs +561 -0
  37. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
  38. package/plugins/lisa-cursor/skills/lisa-queue-status/SKILL.md +24 -0
  39. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo-agy/plugin.json +1 -1
  42. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  62. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails-agy/plugin.json +1 -1
  67. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  72. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  77. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/src/base/.claude-plugin/plugin.json +18 -0
  80. package/plugins/src/base/hooks/enforcement-vintage.mjs +453 -0
  81. package/plugins/src/base/hooks/enforcement-vintage.sh +48 -0
  82. package/plugins/src/base/scripts/pr-arming-sweep.mjs +561 -0
  83. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
  84. package/plugins/src/base/skills/lisa-queue-status/SKILL.md +24 -0
  85. package/scripts/lib/per-agent-hook-filter.mjs +19 -0
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unarmed open pull request detection for `/lisa:queue-status` (#3903).
4
+ *
5
+ * ## The gap this closes
6
+ *
7
+ * A pull request can be fully green and permanently unmergeable because
8
+ * `autoMergeRequest` is `null`, and nothing surfaces it. Green-and-unarmed and
9
+ * green-and-waiting read identically on every surface anyone checks: the checks
10
+ * list is green, `mergeStateStatus` is green, the PR page shows no complaint,
11
+ * and the PR simply sits. **There is no failing signal to notice**, which is
12
+ * why "waits forever" is accurate and why nobody catches it by watching for
13
+ * failures.
14
+ *
15
+ * Gates answer *"is this change acceptable?"*. None of them asks *"is anything
16
+ * going to act on the answer?"* — the observer that would notice sits outside
17
+ * the gate entirely, which is where this module sits.
18
+ *
19
+ * ## Report-only, and why
20
+ *
21
+ * **This is a report surface, not a gate.** It is registered in no `gates`
22
+ * block, runs on no commit/push/PR hook, and blocks nothing. Two reasons, both
23
+ * load-bearing:
24
+ *
25
+ * 1. Arming is a decision with its own arm-time control (`block-blind-automerge`,
26
+ * #3806). #3903 is about VISIBILITY of the unarmed state and explicitly puts
27
+ * automatic arming out of scope; a control that blocked on this would be
28
+ * making the arming decision by the back door.
29
+ * 2. Unarmed-ness is a property of the QUEUE, not of a change. Blocking a
30
+ * contributor's push because some other PR is unarmed punishes the wrong
31
+ * party. A governor about throughput fails open on its findings.
32
+ *
33
+ * ## But the MEASUREMENT fails closed
34
+ *
35
+ * The healthy state of this check is an EMPTY report — so a mistyped field
36
+ * path, a wrong JSON accessor, or a filter that excludes everything returns
37
+ * empty and looks exactly like a clean queue. That is the failure this module
38
+ * is most prone to, so the two are never allowed to share a verdict:
39
+ *
40
+ * - `UNARMED` means *examined, and `autoMergeRequest` was `null`*.
41
+ * - `NOT_EXAMINED` means *the arming state was never read* — the
42
+ * `autoMergeRequest` key is absent from the payload, which is what a caller
43
+ * that forgot the field in `gh pr list --json` produces. It is NOT `null`,
44
+ * and it must never be reported as armed or folded into a clean sweep.
45
+ *
46
+ * A sweep containing even one `NOT_EXAMINED` pull request returns
47
+ * `NOT_MEASURED`, never `MEASURED_CLEAN`. `MEASURED_CLEAN` is a POSITIVE
48
+ * ASSERTION — "I read the arming state of all N open pull requests and every
49
+ * one is armed or deliberately unarmed" — not an absence of complaints. The CLI
50
+ * exit codes carry the same split: 0 clean, 1 findings, **2 not measured**, so
51
+ * a scheduled sweep whose query broke fails loudly instead of reporting a
52
+ * healthy queue.
53
+ *
54
+ * ## Current state, never arming history
55
+ *
56
+ * Measured on this repository the night #3903 was built: a PR armed earlier in
57
+ * the same session, and verbally confirmed as armed, was later observed with
58
+ * `autoMergeRequest: null`; re-arming it succeeded immediately. **Arming can be
59
+ * silently dropped after the fact** — most plausibly when the branch is updated
60
+ * or force-updated underneath it. That defeats the obvious fix: arming at
61
+ * PR-creation time is not sufficient if the latch can come off afterwards. So
62
+ * this module classifies from CURRENT state only and has no notion of whether
63
+ * an arm ever happened.
64
+ *
65
+ * `autoMergeRequest` is one of the few PR fields that is trustworthy — a stored
66
+ * setting rather than a computed one, unlike `mergeStateStatus` (#3694). So
67
+ * detection is cheap; nothing was asking.
68
+ *
69
+ * ## Deliberately unarmed
70
+ *
71
+ * `lisa-drive-pr-to-merge`'s `auto_merge=false` mode deliberately leaves a PR
72
+ * open and unarmed for a human to merge. Reporting those would train operators
73
+ * to ignore the report, so a deliberate hold is declarable — by the
74
+ * `[lisa-auto-merge-off]` body marker (the same shape as Lisa's existing
75
+ * `[lisa-human-gate]` marker: a token with no other meaning, so its presence
76
+ * anywhere in the body IS the declaration) or by the `lisa:auto-merge-off`
77
+ * label, for operators who prefer a surface visible in the PR list. Drafts are
78
+ * excluded too: GitHub will not arm a draft, so an unarmed draft is the
79
+ * expected state rather than a finding.
80
+ *
81
+ * **A held pull request is suppressed from the findings, never from the
82
+ * report.** This is a suppression mechanism, and this repository has been bitten
83
+ * by the shape before — an allowlist added to harden a guard became its bypass.
84
+ * Anyone who wants a red sweep to go green now has a one-label remedy that will
85
+ * look like housekeeping, so every held PR is counted AND named, on the clean
86
+ * path as much as the findings path. "4 armed, 0 unarmed" and "4 armed, 0
87
+ * unarmed, 9 held" describe very different queues, and only the second lets a
88
+ * human notice the label spreading.
89
+ *
90
+ * The marker may carry `reason=<text>`, which the report prints. It is optional
91
+ * on purpose — a required field on a suppression gets satisfied with `reason=x`
92
+ * and buys nothing — but its absence is printed as "no reason declared", so an
93
+ * unexplained hold is visibly cheaper than an explained one.
94
+ */
95
+
96
+ import { realpathSync } from "node:fs";
97
+ import { fileURLToPath } from "node:url";
98
+
99
+ /** Per-pull-request arming verdicts. */
100
+ export const PR_ARMING_VERDICTS = Object.freeze([
101
+ "ARMED",
102
+ "UNARMED",
103
+ "DELIBERATELY_UNARMED",
104
+ "NOT_EXAMINED",
105
+ ]);
106
+
107
+ /** Whole-sweep verdicts. */
108
+ export const PR_ARMING_SWEEP_VERDICTS = Object.freeze([
109
+ "MEASURED_CLEAN",
110
+ "UNARMED_PRS_FOUND",
111
+ "NOT_MEASURED",
112
+ ]);
113
+
114
+ /** Body marker declaring a pull request deliberately left unarmed. */
115
+ export const AUTO_MERGE_OFF_MARKER = "[lisa-auto-merge-off]";
116
+
117
+ /** Label spelling of the same declaration, for PR-list visibility. */
118
+ export const AUTO_MERGE_OFF_LABEL = "lisa:auto-merge-off";
119
+
120
+ /**
121
+ * The `gh pr list --json` field selection this module needs.
122
+ *
123
+ * Exported so the skill that shells out and the tests that pin the contract
124
+ * read the same list — a caller that drops `autoMergeRequest` from it produces
125
+ * `NOT_EXAMINED`, which is the designed outcome rather than a silent pass.
126
+ */
127
+ export const REQUIRED_PR_FIELDS = Object.freeze([
128
+ "number",
129
+ "title",
130
+ "url",
131
+ "isDraft",
132
+ "labels",
133
+ "body",
134
+ "autoMergeRequest",
135
+ ]);
136
+
137
+ /**
138
+ * @typedef {"ARMED" | "UNARMED" | "DELIBERATELY_UNARMED" | "NOT_EXAMINED"} PullRequestArmingVerdict
139
+ *
140
+ * @typedef {{
141
+ * readonly number: number | null
142
+ * readonly title: string
143
+ * readonly url: string
144
+ * readonly verdict: PullRequestArmingVerdict
145
+ * readonly reason: string
146
+ * readonly declaredReason?: string | null
147
+ * readonly examined: boolean
148
+ * }} PullRequestArming
149
+ */
150
+
151
+ /**
152
+ * Classify one pull request's CURRENT auto-merge arming state.
153
+ *
154
+ * The single most important line in this function is the `hasOwnProperty`
155
+ * check: a payload with no `autoMergeRequest` key was never asked about arming,
156
+ * and answering "unarmed" or "armed" for it would be an invented measurement.
157
+ * `undefined` and `null` mean opposite things here and are never conflated.
158
+ *
159
+ * @param {Record<string, any>} pullRequest - One `gh pr view/list --json` object.
160
+ * @returns {PullRequestArming} The verdict, with the reason that produced it.
161
+ */
162
+ export function classifyPullRequestArming(pullRequest) {
163
+ const source =
164
+ pullRequest !== null && typeof pullRequest === "object" ? pullRequest : {};
165
+ const identity = {
166
+ number: Number.isFinite(Number(source.number))
167
+ ? Number(source.number)
168
+ : null,
169
+ title: typeof source.title === "string" ? source.title : "",
170
+ url: typeof source.url === "string" ? source.url : "",
171
+ };
172
+
173
+ if (
174
+ !Object.hasOwn(source, "autoMergeRequest") ||
175
+ source.autoMergeRequest === undefined
176
+ ) {
177
+ return {
178
+ ...identity,
179
+ verdict: "NOT_EXAMINED",
180
+ reason: "auto-merge-field-absent",
181
+ examined: false,
182
+ };
183
+ }
184
+
185
+ if (typeof source.autoMergeRequest === "object") {
186
+ if (source.autoMergeRequest !== null) {
187
+ return {
188
+ ...identity,
189
+ verdict: "ARMED",
190
+ reason: "auto-merge-request-present",
191
+ examined: true,
192
+ };
193
+ }
194
+ } else {
195
+ // Neither `null` nor an auto-merge object. JSON from `gh` never produces
196
+ // this, so it means the value came from somewhere other than the field
197
+ // being asked about — an unanswered question, not an answer.
198
+ return {
199
+ ...identity,
200
+ verdict: "NOT_EXAMINED",
201
+ reason: "auto-merge-field-unrecognized",
202
+ examined: false,
203
+ };
204
+ }
205
+
206
+ const deliberate = deliberateHoldReason(source);
207
+ if (deliberate) {
208
+ return {
209
+ ...identity,
210
+ verdict: "DELIBERATELY_UNARMED",
211
+ reason: deliberate.reason,
212
+ declaredReason: deliberate.declaredReason,
213
+ examined: true,
214
+ };
215
+ }
216
+
217
+ return {
218
+ ...identity,
219
+ verdict: "UNARMED",
220
+ reason: "auto-merge-request-null",
221
+ examined: true,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Why an unarmed pull request is a deliberate hold rather than a finding.
227
+ *
228
+ * @param {Record<string, any>} pullRequest
229
+ * @returns {string | null} A reason slug, or `null` when the hold is undeclared.
230
+ */
231
+ function deliberateHoldReason(pullRequest) {
232
+ if (pullRequest.isDraft === true) {
233
+ return { reason: "draft-pull-request", declaredReason: null };
234
+ }
235
+
236
+ const body = typeof pullRequest.body === "string" ? pullRequest.body : "";
237
+ const declaration = DECLARATION_LINE.exec(declarativeText(body));
238
+ if (declaration) {
239
+ return {
240
+ reason: "auto-merge-off-marker",
241
+ declaredReason: declaredReasonIn(declaration[1]),
242
+ };
243
+ }
244
+
245
+ const labels = Array.isArray(pullRequest.labels) ? pullRequest.labels : [];
246
+ const names = labels.map(label =>
247
+ typeof label === "string"
248
+ ? label
249
+ : typeof label?.name === "string"
250
+ ? label.name
251
+ : ""
252
+ );
253
+ if (names.some(name => name.trim().toLowerCase() === AUTO_MERGE_OFF_LABEL)) {
254
+ return { reason: "auto-merge-off-label", declaredReason: null };
255
+ }
256
+
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * The one shape that DECLARES a hold: the marker alone on a line, inside an
262
+ * HTML comment, optionally carrying `reason=<text>`.
263
+ *
264
+ * Deliberately NOT "the marker appearing anywhere". Lisa's `[lisa-human-gate]`
265
+ * marker is matched anywhere on the reasoning that it is a token with no other
266
+ * meaning, so its presence IS the declaration. **That reasoning does not
267
+ * transfer here, and this repository proved it the first time this control ran
268
+ * live**: the pull request that introduced the marker was suppressed by its own
269
+ * description, because the description explains what the marker is. A token
270
+ * that appears in prose about itself has a second meaning — being discussed —
271
+ * and matching it anywhere makes "write about the suppression" a way to be
272
+ * suppressed. Same rule as `ready-role-filing`: before matching a token, ask
273
+ * what it means in every position it can occupy.
274
+ */
275
+ const DECLARATION_LINE =
276
+ /^[ \t]*<!--[ \t]*\[lisa-auto-merge-off\][ \t]*(.*?)-->[ \t]*$/im;
277
+
278
+ /**
279
+ * The body with code spans and fenced blocks removed.
280
+ *
281
+ * Documentation of the declaration is not a declaration. Fenced examples and
282
+ * backticked mentions are how the marker is written ABOUT, so they are stripped
283
+ * before the declaration is looked for — the same move `block-direct-issue-create.sh`
284
+ * makes when it strips heredoc bodies before tokenising argv.
285
+ *
286
+ * @param {string} body - The raw pull request body.
287
+ * @returns {string} The body with quoted/code regions blanked out.
288
+ */
289
+ function declarativeText(body) {
290
+ return body.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
291
+ }
292
+
293
+ /**
294
+ * Read the `reason=<text>` a declaration carries, if it carries one.
295
+ *
296
+ * A bare marker is a claim; a reason costs the writer something to write, which
297
+ * is what makes a declaration in this repository worth trusting. The reason is
298
+ * OPTIONAL rather than required — a required field on a suppression mechanism
299
+ * gets satisfied with `reason=x` and buys nothing — but its absence is carried
300
+ * through to the report and shown as "no reason declared", so a queue full of
301
+ * unexplained holds is visible as one.
302
+ *
303
+ * @param {string} tail - The declaration's text after the marker.
304
+ * @returns {string | null} The declared reason, or `null` when none was given.
305
+ */
306
+ function declaredReasonIn(tail) {
307
+ const match = /^\s*reason[=:]\s*(.+)$/i.exec(String(tail ?? "").trim());
308
+ if (!match) return null;
309
+ const reason = match[1].replace(/--+>?\s*$/, "").trim();
310
+ return reason.length > 0 ? reason : null;
311
+ }
312
+
313
+ /**
314
+ * Sweep the open pull requests and report the unarmed ones.
315
+ *
316
+ * Read-only and total: it mutates nothing, reaches nothing over the network,
317
+ * and returns every finding rather than the first.
318
+ *
319
+ * @param {readonly Record<string, any>[] | {
320
+ * readonly pullRequests?: readonly Record<string, any>[]
321
+ * readonly fetchError?: string | null
322
+ * }} input - A bare `gh pr list --json` array, or the same wrapped with an
323
+ * optional `fetchError` describing a query that failed.
324
+ * @returns {{
325
+ * readonly verdict: "MEASURED_CLEAN" | "UNARMED_PRS_FOUND" | "NOT_MEASURED"
326
+ * readonly reasons: readonly string[]
327
+ * readonly examinedCount: number
328
+ * readonly unarmed: readonly PullRequestArming[]
329
+ * readonly deliberatelyUnarmed: readonly PullRequestArming[]
330
+ * readonly armed: readonly PullRequestArming[]
331
+ * readonly notExamined: readonly PullRequestArming[]
332
+ * readonly reportOnly: true
333
+ * }}
334
+ */
335
+ export function sweepPullRequestArming(input = []) {
336
+ const wrapped = Array.isArray(input)
337
+ ? { pullRequests: input }
338
+ : (input ?? {});
339
+ const fetchError =
340
+ typeof wrapped.fetchError === "string" &&
341
+ wrapped.fetchError.trim().length > 0
342
+ ? wrapped.fetchError.trim()
343
+ : null;
344
+ const list = Array.isArray(wrapped.pullRequests)
345
+ ? wrapped.pullRequests
346
+ : null;
347
+
348
+ if (fetchError !== null || list === null) {
349
+ return {
350
+ verdict: "NOT_MEASURED",
351
+ reasons: [
352
+ fetchError !== null
353
+ ? "pull-request-query-failed"
354
+ : "pull-request-list-unavailable",
355
+ ],
356
+ examinedCount: 0,
357
+ unarmed: [],
358
+ deliberatelyUnarmed: [],
359
+ armed: [],
360
+ notExamined: [],
361
+ reportOnly: true,
362
+ };
363
+ }
364
+
365
+ const classified = list.map(classifyPullRequestArming);
366
+ const bucket = verdict =>
367
+ classified.filter(entry => entry.verdict === verdict);
368
+ const buckets = {
369
+ unarmed: bucket("UNARMED"),
370
+ deliberatelyUnarmed: bucket("DELIBERATELY_UNARMED"),
371
+ armed: bucket("ARMED"),
372
+ notExamined: bucket("NOT_EXAMINED"),
373
+ };
374
+ const examinedCount = classified.filter(entry => entry.examined).length;
375
+
376
+ // Blindness outranks findings. A sweep that could not read part of the queue
377
+ // has no standing to call the rest of it clean, and must not print the
378
+ // reassuring answer for pull requests it never looked at.
379
+ if (buckets.notExamined.length > 0) {
380
+ return {
381
+ verdict: "NOT_MEASURED",
382
+ reasons: [...new Set(buckets.notExamined.map(entry => entry.reason))],
383
+ examinedCount,
384
+ ...buckets,
385
+ reportOnly: true,
386
+ };
387
+ }
388
+
389
+ if (buckets.unarmed.length > 0) {
390
+ return {
391
+ verdict: "UNARMED_PRS_FOUND",
392
+ reasons: ["unarmed-open-pull-requests"],
393
+ examinedCount,
394
+ ...buckets,
395
+ reportOnly: true,
396
+ };
397
+ }
398
+
399
+ return {
400
+ verdict: "MEASURED_CLEAN",
401
+ reasons: ["all-open-pull-requests-armed-or-deliberately-unarmed"],
402
+ examinedCount,
403
+ ...buckets,
404
+ reportOnly: true,
405
+ };
406
+ }
407
+
408
+ /**
409
+ * The CLI exit status for a sweep.
410
+ *
411
+ * `2` is the load-bearing one: an unmeasurable sweep must not exit `0` beside
412
+ * an empty finding list, because that is indistinguishable from a healthy
413
+ * queue — the exact confusion #3903 is about.
414
+ *
415
+ * @param {{ readonly verdict?: string }} sweep
416
+ * @returns {0 | 1 | 2}
417
+ */
418
+ export function pullRequestArmingExitCode(sweep) {
419
+ if (sweep?.verdict === "MEASURED_CLEAN") return 0;
420
+ if (sweep?.verdict === "UNARMED_PRS_FOUND") return 1;
421
+ return 2;
422
+ }
423
+
424
+ /**
425
+ * Render the sweep as an operator-readable report.
426
+ *
427
+ * The clean line states what was examined rather than saying nothing, so an
428
+ * operator can tell a measured clean queue from a query that returned nothing.
429
+ *
430
+ * @param {ReturnType<typeof sweepPullRequestArming>} sweep
431
+ * @returns {string} A terminal-first report, newline-terminated.
432
+ */
433
+ export function formatPullRequestArmingReport(sweep) {
434
+ const lines = [`Auto-merge arming: ${sweep.verdict}`];
435
+
436
+ if (sweep.verdict === "NOT_MEASURED") {
437
+ lines.push(
438
+ sweep.notExamined.length > 0
439
+ ? `Arming state was NOT read for ${sweep.notExamined.length} open pull request(s) (${sweep.reasons.join(", ")}).`
440
+ : `The open pull request list could not be read at all (${sweep.reasons.join(", ")}).`,
441
+ `This is not a clean queue — it is an unanswered question. Re-run the query with --json ${REQUIRED_PR_FIELDS.join(",")}.`
442
+ );
443
+ for (const entry of sweep.notExamined) {
444
+ lines.push(` ? #${entry.number ?? "?"} ${entry.title}`.trimEnd());
445
+ }
446
+ return `${lines.join("\n")}\n`;
447
+ }
448
+
449
+ lines.push(
450
+ `Examined ${sweep.examinedCount} open pull request(s): ${sweep.armed.length} armed, ${sweep.unarmed.length} unarmed, ${sweep.deliberatelyUnarmed.length} deliberately unarmed.`
451
+ );
452
+
453
+ if (sweep.verdict === "MEASURED_CLEAN") {
454
+ lines.push(
455
+ "Every open pull request is armed or deliberately unarmed. Nothing is waiting on an absent latch."
456
+ );
457
+ lines.push(...heldLines(sweep));
458
+ return `${lines.join("\n")}\n`;
459
+ }
460
+
461
+ lines.push(
462
+ "These pull requests will never merge on their own. Arm each, or declare the hold:"
463
+ );
464
+ for (const entry of sweep.unarmed) {
465
+ lines.push(` ! #${entry.number ?? "?"} ${entry.title}`.trimEnd());
466
+ if (entry.url) lines.push(` ${entry.url}`);
467
+ }
468
+ lines.push(
469
+ `Arm: gh pr merge <n> --auto --merge, then read it back with gh pr view <n> --json autoMergeRequest.`,
470
+ `Declare a deliberate hold: add the ${AUTO_MERGE_OFF_LABEL} label, or the ${AUTO_MERGE_OFF_MARKER} marker to the body.`
471
+ );
472
+ lines.push(...heldLines(sweep));
473
+ return `${lines.join("\n")}\n`;
474
+ }
475
+
476
+ /**
477
+ * The held-pull-request block, which is never omitted when the bucket is
478
+ * non-empty.
479
+ *
480
+ * A suppression mechanism that hides what it suppresses is the next defect,
481
+ * not the fix for this one. This repository has already been bitten by the
482
+ * shape: an allowlist added to harden a guard BECAME its bypass. `lisa:auto-merge-off`
483
+ * is a one-label remedy for a red sweep and will look like housekeeping to
484
+ * whoever applies it, so every held pull request is COUNTED AND NAMED. "4 armed,
485
+ * 0 unarmed" and "4 armed, 0 unarmed, 9 held" describe very different queues,
486
+ * and only the second lets a human notice the label spreading.
487
+ *
488
+ * @param {ReturnType<typeof sweepPullRequestArming>} sweep
489
+ * @returns {string[]} Report lines, empty when nothing is held.
490
+ */
491
+ function heldLines(sweep) {
492
+ if (sweep.deliberatelyUnarmed.length === 0) return [];
493
+
494
+ return [
495
+ `Held (declared, not merging): ${sweep.deliberatelyUnarmed.length}. Each is suppressed from the finding list above:`,
496
+ ...sweep.deliberatelyUnarmed.map(entry => {
497
+ const why = entry.declaredReason
498
+ ? `reason: ${entry.declaredReason}`
499
+ : entry.reason === "draft-pull-request"
500
+ ? "draft"
501
+ : "no reason declared";
502
+ const label = `#${entry.number ?? "?"} ${entry.title}`.trim();
503
+ return ` - ${label} (${why})`;
504
+ }),
505
+ ];
506
+ }
507
+
508
+ /**
509
+ * CLI: read `gh pr list --json <fields>` output on stdin, print the report.
510
+ *
511
+ * @returns {Promise<void>}
512
+ */
513
+ async function main() {
514
+ const chunks = [];
515
+ for await (const chunk of process.stdin) {
516
+ chunks.push(chunk);
517
+ }
518
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
519
+
520
+ let sweep;
521
+ try {
522
+ sweep = sweepPullRequestArming(raw.length === 0 ? {} : JSON.parse(raw));
523
+ } catch (error) {
524
+ sweep = sweepPullRequestArming({ fetchError: error.message });
525
+ }
526
+
527
+ const asJson = process.argv.includes("--json");
528
+ process.stdout.write(
529
+ asJson
530
+ ? `${JSON.stringify(sweep, null, 2)}\n`
531
+ : formatPullRequestArmingReport(sweep)
532
+ );
533
+ process.exitCode = pullRequestArmingExitCode(sweep);
534
+ }
535
+
536
+ /**
537
+ * True when `moduleUrl` names the module node was asked to run.
538
+ *
539
+ * Written out rather than imported: this is a plugin payload, which has no
540
+ * `./lib/` to resolve against. Same rule and reasoning as
541
+ * `scripts/lib/invoked-as-script.mjs` and `scoping-label-audit.mjs`.
542
+ *
543
+ * @param {string} moduleUrl - The caller's own `import.meta.url`.
544
+ * @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
545
+ * @returns {boolean} Whether the caller should run its CLI body.
546
+ */
547
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
548
+ if (!argv1) return false;
549
+ try {
550
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
551
+ } catch {
552
+ return false;
553
+ }
554
+ }
555
+
556
+ if (invokedAsScript(import.meta.url)) {
557
+ main().catch(error => {
558
+ process.stderr.write(`${error.message}\n`);
559
+ process.exitCode = 2;
560
+ });
561
+ }
@@ -229,6 +229,30 @@ fi
229
229
  gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest # must print null
230
230
  ```
231
231
 
232
+ **Then declare the hold**, so the deliberate case stays distinguishable from the
233
+ defect. `/lisa:queue-status`'s arming sweep (#3903) reports every open PR whose
234
+ `autoMergeRequest` is `null`, because a green unarmed PR waits forever and no
235
+ other surface says so. An undeclared deliberate hold appears there as a finding
236
+ on every run, and a report that is wrong every run is one operators learn to
237
+ ignore:
238
+
239
+ ```bash
240
+ gh pr edit <pr> --add-label "lisa:auto-merge-off"
241
+ ```
242
+
243
+ Where that label does not exist in the repo, put the marker in the PR body
244
+ instead — the sweep reads either spelling — and **give it a reason**, because
245
+ the sweep prints one and prints `no reason declared` when it cannot:
246
+
247
+ ```text
248
+ <!-- [lisa-auto-merge-off] reason=<why a human owns this merge> -->
249
+ ```
250
+
251
+ Declaring the hold **suppresses it from the findings, not from the report**: the
252
+ sweep still counts and names every held PR. That is deliberate — a label that
253
+ turned a red sweep green and left no trace would be a bypass wearing the costume
254
+ of a fix.
255
+
232
256
  If the disarm fails or the re-read still shows an armed `autoMergeRequest`,
233
257
  **fail closed**: treat the PR as a hard block (section 4) and report that the
234
258
  `awaiting-human` state was NOT reached — never proceed to a state in which the
@@ -69,6 +69,30 @@ For each inspected queue, report:
69
69
 
70
70
  The report should stay terminal-first and immediately actionable: observable queue facts first, then the smallest useful next step.
71
71
 
72
+ ## Pull request arming (#3903)
73
+
74
+ Alongside the two work queues, report the **arming state of the repo's open pull requests**. A PR whose `autoMergeRequest` is `null` is fully green and permanently unmergeable: every check passes, `mergeStateStatus` is green, nothing complains, and it waits forever. Green-and-unarmed and green-and-waiting read identically on every other surface, so this is the one place that asks the question.
75
+
76
+ Run the sweep rather than eyeballing the list — an LLM reading PR pages one at a time is exactly the shell-loop-by-prose failure #3512 records:
77
+
78
+ ```bash
79
+ gh pr list --state open --limit 200 \
80
+ --json number,title,url,isDraft,labels,body,autoMergeRequest \
81
+ | node "${CLAUDE_PLUGIN_ROOT}/scripts/pr-arming-sweep.mjs"
82
+ ```
83
+
84
+ Report the verdict verbatim, and never translate it into an absence:
85
+
86
+ - `MEASURED_CLEAN` — a **positive assertion**: N open PRs were read and every one is armed or deliberately unarmed. Say the count. "Nothing to report" is not the same sentence and must not be substituted for it.
87
+ - `UNARMED_PRS_FOUND` — list each PR. The next step is `gh pr merge <n> --auto --merge` **followed by reading the state back** (`gh pr view <n> --json autoMergeRequest`); arming can be silently dropped after the fact, so an arm that was not read back is not a fact.
88
+ - `NOT_MEASURED` — the arming state was **not read** for at least one PR (usually a `--json` selection missing `autoMergeRequest`). This is an unanswered question, not a clean queue. Fix the query and re-run; do **not** report the queue as healthy.
89
+
90
+ Arming state feeds no queue verdict and gates nothing — this stays a report, per #3903's Out of Scope. Never arm a PR from this skill; `/lisa:queue-status` is read-only.
91
+
92
+ A PR deliberately left for a human (`lisa-drive-pr-to-merge`'s `auto_merge=false` mode) declares itself with the `lisa:auto-merge-off` label or a `[lisa-auto-merge-off] reason=<text>` body marker. Drafts are excluded — GitHub will not arm a draft.
93
+
94
+ **Held PRs are suppressed from the findings, never from the report.** Always show the `Held (declared, not merging): N` block and the numbers under it, including when the verdict is `MEASURED_CLEAN`. This is a one-label remedy for a red sweep and it will look like housekeeping to whoever applies it — "4 armed, 0 unarmed" and "4 armed, 0 unarmed, 9 held" describe very different queues, and only the second lets an operator notice the label spreading. Flag it when holds outnumber armed PRs, or when several read `no reason declared`.
95
+
72
96
  ## Highlight semantics
73
97
 
74
98
  Each queue section may include one or more highlighted items. A highlight is not a raw dump of every issue in that role; it is the single oldest or otherwise most actionable item Lisa can justify surfacing without mutating work.
@@ -151,6 +151,25 @@ const SCRIPT_RULES = {
151
151
  agy: false,
152
152
  copilot: true,
153
153
  },
154
+ // Ships wherever the agent has a session-start event, on the same reasoning
155
+ // as `inject-resolved-config.sh`. The question it answers — WHICH COPY of
156
+ // Lisa is executing in this session — is not vendor-specific: every harness
157
+ // resolves a plugin copy once at session start and then runs it unchanged,
158
+ // so every harness can hand an agent a stale contract while the repository
159
+ // is green.
160
+ //
161
+ // Documented gap: agy has no SessionStart event at all, so agy sessions get
162
+ // no vintage block. The compensating rung is the shared one AGENTS.md
163
+ // requires when a harness cannot represent a control — the Bash enforcement
164
+ // dispatcher still names the producing copy and its vintage in every refusal
165
+ // it prints, and `lisa doctor` reports the same resolution on every agent.
166
+ "enforcement-vintage.sh": {
167
+ claude: true,
168
+ codex: true,
169
+ cursor: true,
170
+ agy: false,
171
+ copilot: true,
172
+ },
154
173
  "inject-flow-context.sh": {
155
174
  claude: true,
156
175
  codex: true,