@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.
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +26 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +19 -1
- package/plugins/lisa/.codex-plugin/hooks.json +18 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-queue-status/SKILL.md +24 -0
- package/plugins/lisa/hooks/enforcement-vintage.mjs +453 -0
- package/plugins/lisa/hooks/enforcement-vintage.sh +48 -0
- package/plugins/lisa/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa/skills/lisa-queue-status/SKILL.md +24 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa-agy/skills/lisa-queue-status/SKILL.md +24 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa-copilot/hooks/enforcement-vintage.mjs +453 -0
- package/plugins/lisa-copilot/hooks/enforcement-vintage.sh +48 -0
- package/plugins/lisa-copilot/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa-copilot/skills/lisa-queue-status/SKILL.md +24 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/enforcement-vintage.mjs +453 -0
- package/plugins/lisa-cursor/hooks/enforcement-vintage.sh +48 -0
- package/plugins/lisa-cursor/hooks/hooks.json +6 -0
- package/plugins/lisa-cursor/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa-cursor/skills/lisa-queue-status/SKILL.md +24 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/.claude-plugin/plugin.json +18 -0
- package/plugins/src/base/hooks/enforcement-vintage.mjs +453 -0
- package/plugins/src/base/hooks/enforcement-vintage.sh +48 -0
- package/plugins/src/base/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/src/base/skills/lisa-queue-status/SKILL.md +24 -0
- 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,
|