@codyswann/lisa 3.33.8 → 3.35.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 (119) hide show
  1. package/all/copy-overwrite/scripts/lisa-lint-staged-preflight.mjs +399 -0
  2. package/dist/cli/doctor-traceability-gate.d.ts +39 -0
  3. package/dist/cli/doctor-traceability-gate.d.ts.map +1 -0
  4. package/dist/cli/doctor-traceability-gate.js +164 -0
  5. package/dist/cli/doctor-traceability-gate.js.map +1 -0
  6. package/dist/cli/doctor.d.ts.map +1 -1
  7. package/dist/cli/doctor.js +7 -0
  8. package/dist/cli/doctor.js.map +1 -1
  9. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  10. package/dist/core/lisa-owned-hash-ledger.js +3 -0
  11. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  12. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  13. package/dist/core/upstream-evidence-manifest.js +32 -9
  14. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  15. package/package.json +1 -1
  16. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +53 -3
  19. package/plugins/lisa/.codex-plugin/skills/lisa-intake/SKILL.md +17 -1
  20. package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +53 -5
  21. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +48 -7
  22. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  23. package/plugins/lisa/rules/eager/automation-runbook-contract.md +2 -0
  24. package/plugins/lisa/rules/reference/automation-runbook-contract.md +19 -0
  25. package/plugins/lisa/scripts/automation-run-record.mjs +146 -33
  26. package/plugins/lisa/scripts/intake-blocker-reprobe.mjs +190 -0
  27. package/plugins/lisa/scripts/intake-prework-denominator.mjs +249 -0
  28. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +53 -3
  29. package/plugins/lisa/skills/lisa-intake/SKILL.md +17 -1
  30. package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +53 -5
  31. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +48 -7
  32. package/plugins/lisa/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  33. package/plugins/lisa-agy/plugin.json +1 -1
  34. package/plugins/lisa-agy/scripts/automation-run-record.mjs +146 -33
  35. package/plugins/lisa-agy/scripts/intake-blocker-reprobe.mjs +190 -0
  36. package/plugins/lisa-agy/scripts/intake-prework-denominator.mjs +249 -0
  37. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +53 -3
  38. package/plugins/lisa-agy/skills/lisa-intake/SKILL.md +17 -1
  39. package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +53 -5
  40. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +48 -7
  41. package/plugins/lisa-agy/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  42. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  45. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-copilot/rules/eager/automation-runbook-contract.md +2 -0
  49. package/plugins/lisa-copilot/rules/reference/automation-runbook-contract.md +19 -0
  50. package/plugins/lisa-copilot/scripts/automation-run-record.mjs +146 -33
  51. package/plugins/lisa-copilot/scripts/intake-blocker-reprobe.mjs +190 -0
  52. package/plugins/lisa-copilot/scripts/intake-prework-denominator.mjs +249 -0
  53. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +53 -3
  54. package/plugins/lisa-copilot/skills/lisa-intake/SKILL.md +17 -1
  55. package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +53 -5
  56. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +48 -7
  57. package/plugins/lisa-copilot/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  58. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-cursor/rules/automation-runbook-contract-reference.mdc +19 -0
  60. package/plugins/lisa-cursor/rules/automation-runbook-contract.mdc +2 -0
  61. package/plugins/lisa-cursor/scripts/automation-run-record.mjs +146 -33
  62. package/plugins/lisa-cursor/scripts/intake-blocker-reprobe.mjs +190 -0
  63. package/plugins/lisa-cursor/scripts/intake-prework-denominator.mjs +249 -0
  64. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +53 -3
  65. package/plugins/lisa-cursor/skills/lisa-intake/SKILL.md +17 -1
  66. package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +53 -5
  67. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +48 -7
  68. package/plugins/lisa-cursor/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  69. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-expo-agy/plugin.json +1 -1
  72. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  77. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  82. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  86. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  87. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  91. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  92. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  96. package/plugins/lisa-rails-agy/plugin.json +1 -1
  97. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  101. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  102. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  106. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  107. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  109. package/plugins/src/base/rules/eager/automation-runbook-contract.md +2 -0
  110. package/plugins/src/base/rules/reference/automation-runbook-contract.md +19 -0
  111. package/plugins/src/base/scripts/automation-run-record.mjs +146 -33
  112. package/plugins/src/base/scripts/intake-blocker-reprobe.mjs +190 -0
  113. package/plugins/src/base/scripts/intake-prework-denominator.mjs +249 -0
  114. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +53 -3
  115. package/plugins/src/base/skills/lisa-intake/SKILL.md +17 -1
  116. package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +53 -5
  117. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +48 -7
  118. package/plugins/src/base/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  119. package/typescript/copy-contents/.husky/pre-commit +64 -0
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Blocker re-probe gate for pre-work build-intake candidates.
4
+ *
5
+ * A blocker is a claim with a timestamp, not a fact. It is written once, and it
6
+ * decays the moment its condition goes true — a dependency lands on trunk, a
7
+ * CVE gets patched, a package publishes. Nothing in the old intake loop ever
8
+ * re-read one, so a discharged blocker held its item out of the queue forever.
9
+ *
10
+ * This module decides, per candidate, whether intake may select it. It never
11
+ * probes anything itself: the caller runs the probe and hands back the result,
12
+ * which keeps the decision auditable and testable apart from the network.
13
+ */
14
+
15
+ import { isPreWorkLaneType } from "./intake-prework-denominator.mjs";
16
+
17
+ /** Marker a writer stamps on an item a human deliberately parked. */
18
+ export const HUMAN_GATE_MARKER = "[lisa-human-gate]";
19
+
20
+ /** Default label naming a block only a human can clear. */
21
+ export const DEFAULT_HUMAN_NEEDED_LABEL = "human-needed";
22
+
23
+ /**
24
+ * @param {unknown} labels
25
+ * @returns {readonly string[]}
26
+ */
27
+ function normalizeLabels(labels) {
28
+ return (Array.isArray(labels) ? labels : [])
29
+ .map(label =>
30
+ typeof label === "string"
31
+ ? label
32
+ : typeof label?.name === "string"
33
+ ? label.name
34
+ : ""
35
+ )
36
+ .map(label => label.trim().toLowerCase())
37
+ .filter(label => label.length > 0);
38
+ }
39
+
40
+ /**
41
+ * @param {{ labels?: unknown, body?: unknown, humanNeededLabel?: unknown }} input
42
+ * @returns {boolean}
43
+ */
44
+ function isHumanGated(input) {
45
+ const configured = String(
46
+ input.humanNeededLabel ?? DEFAULT_HUMAN_NEEDED_LABEL
47
+ )
48
+ .trim()
49
+ .toLowerCase();
50
+ if (
51
+ configured.length > 0 &&
52
+ normalizeLabels(input.labels).includes(configured)
53
+ ) {
54
+ return true;
55
+ }
56
+
57
+ return String(input.body ?? "").includes(HUMAN_GATE_MARKER);
58
+ }
59
+
60
+ /**
61
+ * @param {unknown} value
62
+ * @returns {string}
63
+ */
64
+ function trimmedString(value) {
65
+ return typeof value === "string" ? value.trim() : "";
66
+ }
67
+
68
+ /**
69
+ * @param {{ discharged?: unknown, evidence?: unknown }} probe
70
+ * @returns {{ selectable: boolean, reason: string, evidence: string }}
71
+ */
72
+ function judgeProbe(probe) {
73
+ const evidence = trimmedString(probe.evidence);
74
+
75
+ if (probe.discharged !== true) {
76
+ return {
77
+ selectable: false,
78
+ reason: "blocker-holds",
79
+ evidence,
80
+ };
81
+ }
82
+ if (evidence.length === 0) {
83
+ return {
84
+ selectable: false,
85
+ reason: "blocker-discharge-unevidenced",
86
+ evidence,
87
+ };
88
+ }
89
+
90
+ return { selectable: true, reason: "blocker-discharged", evidence };
91
+ }
92
+
93
+ /**
94
+ * Decide whether a pre-work item may be selected as a build candidate.
95
+ *
96
+ * Order is load-bearing. The human gate is checked before anything else, so no
97
+ * probe result — however conclusive — can promote an item a human parked.
98
+ *
99
+ * @param {{
100
+ * laneType?: unknown
101
+ * labels?: unknown
102
+ * body?: unknown
103
+ * humanNeededLabel?: unknown
104
+ * statedBlocker?: unknown
105
+ * probe?: { discharged?: unknown, evidence?: unknown } | null
106
+ * }} input
107
+ * @returns {{ selectable: boolean, reason: string, humanGated: boolean, evidence: string }}
108
+ */
109
+ export function classifyPreWorkCandidate(input = {}) {
110
+ if (isHumanGated(input)) {
111
+ return {
112
+ selectable: false,
113
+ reason: "human-gate",
114
+ humanGated: true,
115
+ evidence: "",
116
+ };
117
+ }
118
+ if (!isPreWorkLaneType(input.laneType)) {
119
+ return {
120
+ selectable: false,
121
+ reason: "not-pre-work",
122
+ humanGated: false,
123
+ evidence: "",
124
+ };
125
+ }
126
+ if (trimmedString(input.statedBlocker).length === 0) {
127
+ return {
128
+ selectable: true,
129
+ reason: "no-blocker",
130
+ humanGated: false,
131
+ evidence: "",
132
+ };
133
+ }
134
+ if (!input.probe || typeof input.probe !== "object") {
135
+ return {
136
+ selectable: false,
137
+ reason: "blocker-unprobed",
138
+ humanGated: false,
139
+ evidence: "",
140
+ };
141
+ }
142
+
143
+ return { ...judgeProbe(input.probe), humanGated: false };
144
+ }
145
+
146
+ /** Plain-English wording for each verdict, for the note left on the item. */
147
+ const REASON_COPY = Object.freeze({
148
+ "human-gate": "A person parked this one on purpose, so intake left it alone.",
149
+ "not-pre-work": "This item is not sitting in a not-yet-started lane.",
150
+ "no-blocker": "No blocker was written on this item, so it is buildable.",
151
+ "blocker-unprobed":
152
+ "This item states a blocker that was not re-checked this cycle, so it stays put.",
153
+ "blocker-holds":
154
+ "The blocker was re-checked and still holds, so it stays put.",
155
+ "blocker-discharge-unevidenced":
156
+ "The blocker looked clear but no proof was recorded, so it stays put.",
157
+ "blocker-discharged":
158
+ "The blocker was re-checked and no longer applies, so this item is buildable again.",
159
+ });
160
+
161
+ /**
162
+ * Render the re-probe result to leave on the item, so the next cycle reads the
163
+ * answer instead of deriving it again.
164
+ *
165
+ * @param {{
166
+ * statedBlocker?: unknown
167
+ * checkedAt?: string | Date
168
+ * }} candidate
169
+ * @param {{ reason: string, evidence?: string }} verdict
170
+ * @returns {string}
171
+ */
172
+ export function formatReprobeNote(candidate = {}, verdict) {
173
+ const checkedAt =
174
+ candidate.checkedAt instanceof Date
175
+ ? candidate.checkedAt.toISOString()
176
+ : trimmedString(candidate.checkedAt) || new Date().toISOString();
177
+ const lines = [
178
+ "**Blocker re-check**",
179
+ "",
180
+ `- What has to be true: ${trimmedString(candidate.statedBlocker) || "(none written on this item)"}`,
181
+ `- What we found: ${REASON_COPY[verdict.reason] ?? verdict.reason}`,
182
+ ];
183
+ const evidence = trimmedString(verdict.evidence);
184
+ if (evidence.length > 0) {
185
+ lines.push(`- Proof: ${evidence}`);
186
+ }
187
+ lines.push(`- Checked at: ${checkedAt}`);
188
+
189
+ return lines.join("\n");
190
+ }
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pre-work denominator for build-queue intake scanners.
4
+ *
5
+ * A build-intake cycle that reports an empty lane is only as trustworthy as the
6
+ * set of lanes it swept. Sweeping by lane NAME (`Backlog`, `Todo`, `Ready`)
7
+ * omits every pre-work lane a team invented — most commonly a `Blocked` lane,
8
+ * which is not a distinct Linear state type at all: teams model it as
9
+ * `unstarted`, i.e. work that was never started. Items parked there carry a
10
+ * written blocker that nothing ever re-reads, so the lane reads as terminal
11
+ * while it is in fact a queue.
12
+ *
13
+ * This module fixes both halves: lanes are selected by TYPE, never by name, and
14
+ * the swept set is reported alongside the total so a wrong denominator is
15
+ * visible on inspection instead of silent forever.
16
+ */
17
+
18
+ /** Lane types that hold work which has not started. Both belong to intake. */
19
+ export const PRE_WORK_LANE_TYPES = Object.freeze(["backlog", "unstarted"]);
20
+
21
+ /** Loop ids whose `nothing-needed` runs must state their denominator. */
22
+ export const DENOMINATOR_REQUIRED_LOOP_IDS = Object.freeze(["intake-tickets"]);
23
+
24
+ /**
25
+ * Vendor lane-type vocabularies normalized onto Linear's five-value type set.
26
+ * JIRA exposes the same idea as `statusCategory` (key or display name); GitHub
27
+ * has no type field at all, so its callers pass a type explicitly.
28
+ */
29
+ const LANE_TYPE_ALIASES = Object.freeze({
30
+ backlog: "backlog",
31
+ unstarted: "unstarted",
32
+ started: "started",
33
+ completed: "completed",
34
+ canceled: "canceled",
35
+ cancelled: "canceled",
36
+ new: "unstarted",
37
+ indeterminate: "started",
38
+ done: "completed",
39
+ "to do": "unstarted",
40
+ "in progress": "started",
41
+ });
42
+
43
+ /**
44
+ * Map a vendor lane type onto the normalized vocabulary.
45
+ *
46
+ * @param {unknown} type
47
+ * @returns {string | null} normalized type, or null when unrecognized
48
+ */
49
+ export function normalizeLaneType(type) {
50
+ if (typeof type !== "string") {
51
+ return null;
52
+ }
53
+ return LANE_TYPE_ALIASES[type.trim().toLowerCase()] ?? null;
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} type
58
+ * @returns {boolean} whether this lane holds not-yet-started work
59
+ */
60
+ export function isPreWorkLaneType(type) {
61
+ const normalized = normalizeLaneType(type);
62
+ return normalized !== null && PRE_WORK_LANE_TYPES.includes(normalized);
63
+ }
64
+
65
+ /**
66
+ * @param {unknown} lane
67
+ * @returns {{ name: string, type: string | null, count: number, position: number } | null}
68
+ */
69
+ function normalizeLane(lane) {
70
+ if (!lane || typeof lane !== "object") {
71
+ return null;
72
+ }
73
+ const name = String(lane.name ?? "").trim();
74
+ if (name.length === 0) {
75
+ return null;
76
+ }
77
+ const rawCount = Number(lane.count ?? 0);
78
+ const rawPosition = Number(lane.position ?? Number.POSITIVE_INFINITY);
79
+
80
+ return {
81
+ name,
82
+ type: normalizeLaneType(lane.type ?? lane.statusCategory),
83
+ count: Number.isFinite(rawCount) && rawCount > 0 ? Math.trunc(rawCount) : 0,
84
+ position: Number.isFinite(rawPosition)
85
+ ? rawPosition
86
+ : Number.POSITIVE_INFINITY,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * @param {{ position: number, name: string }} left
92
+ * @param {{ position: number, name: string }} right
93
+ * @returns {number}
94
+ */
95
+ function compareLanes(left, right) {
96
+ if (left.position !== right.position) {
97
+ return left.position - right.position;
98
+ }
99
+ return left.name.localeCompare(right.name);
100
+ }
101
+
102
+ /**
103
+ * Select every lane holding not-yet-started work, by type.
104
+ *
105
+ * Deliberately name-blind: a team that adds `Blocked`, `Triaged`, or `Icebox`
106
+ * as an `unstarted` state gets it swept without a code change, and no hardcoded
107
+ * roster can fall behind the board.
108
+ *
109
+ * @param {readonly unknown[]} lanes
110
+ * @returns {readonly {name: string, type: string, count: number, position: number}[]}
111
+ */
112
+ export function selectPreWorkLanes(lanes) {
113
+ return (Array.isArray(lanes) ? lanes : [])
114
+ .map(normalizeLane)
115
+ .filter(lane => lane !== null && isPreWorkLaneType(lane.type))
116
+ .sort(compareLanes);
117
+ }
118
+
119
+ /**
120
+ * Build the denominator a scanner must report with its verdict.
121
+ *
122
+ * `totalOpen` is the count of open items on the queue, independent of lanes —
123
+ * supplying it is what makes an omitted lane arithmetically visible.
124
+ *
125
+ * @param {{ lanes?: readonly unknown[], totalOpen?: number }} input
126
+ * @returns {{
127
+ * swept: readonly {name: string, type: string, count: number}[]
128
+ * omitted: readonly {name: string, type: string | null, count: number}[]
129
+ * sweptCount: number
130
+ * totalOpen: number
131
+ * unsweptCount: number
132
+ * }}
133
+ */
134
+ export function buildIntakeDenominator(input = {}) {
135
+ const normalized = (Array.isArray(input.lanes) ? input.lanes : [])
136
+ .map(normalizeLane)
137
+ .filter(lane => lane !== null)
138
+ .sort(compareLanes);
139
+ const swept = normalized.filter(lane => isPreWorkLaneType(lane.type));
140
+ const omitted = normalized.filter(lane => !isPreWorkLaneType(lane.type));
141
+ const sweptCount = swept.reduce((total, lane) => total + lane.count, 0);
142
+ const laneTotal = normalized.reduce((total, lane) => total + lane.count, 0);
143
+ const rawTotalOpen = Number(input.totalOpen);
144
+ const totalOpen =
145
+ Number.isFinite(rawTotalOpen) && rawTotalOpen >= 0
146
+ ? Math.trunc(rawTotalOpen)
147
+ : laneTotal;
148
+
149
+ return {
150
+ swept: swept.map(({ name, type, count }) => ({ name, type, count })),
151
+ omitted: omitted.map(({ name, type, count }) => ({ name, type, count })),
152
+ sweptCount,
153
+ totalOpen,
154
+ unsweptCount: Math.max(totalOpen - sweptCount, 0),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Render the denominator for a non-technical operator.
160
+ *
161
+ * @param {ReturnType<typeof buildIntakeDenominator>} denominator
162
+ * @returns {string}
163
+ */
164
+ export function formatIntakeDenominator(denominator) {
165
+ const lanes = denominator?.swept ?? [];
166
+ const listed =
167
+ lanes.length > 0
168
+ ? lanes.map(lane => `${lane.name} (${lane.count})`).join(", ")
169
+ : "no pre-work lanes found";
170
+
171
+ return `Looked in every lane holding work that has not started — ${listed} — ${denominator.sweptCount} items checked out of ${denominator.totalOpen} still open.`;
172
+ }
173
+
174
+ /**
175
+ * Render the full operator-readable one-liner for a dry build lane.
176
+ *
177
+ * @param {ReturnType<typeof buildIntakeDenominator>} denominator
178
+ * @param {{ queue?: string }} [context]
179
+ * @returns {string}
180
+ */
181
+ export function summarizeDryLane(denominator, context = {}) {
182
+ const queue =
183
+ typeof context.queue === "string" && context.queue.trim().length > 0
184
+ ? ` on ${context.queue.trim()}`
185
+ : "";
186
+
187
+ return `Nothing ready to build${queue}. ${formatIntakeDenominator(denominator)}`;
188
+ }
189
+
190
+ /**
191
+ * @param {unknown} loopId
192
+ * @param {unknown} outcome
193
+ * @returns {boolean} whether this run must carry a denominator
194
+ */
195
+ export function requiresDenominator(loopId, outcome) {
196
+ return (
197
+ outcome === "nothing-needed" &&
198
+ DENOMINATOR_REQUIRED_LOOP_IDS.includes(String(loopId ?? "").trim())
199
+ );
200
+ }
201
+
202
+ /**
203
+ * @param {unknown} denominator
204
+ * @returns {string | null} the reason it is unusable, or null when valid
205
+ */
206
+ function findDenominatorDefect(denominator) {
207
+ if (!denominator || typeof denominator !== "object") {
208
+ return "no denominator was supplied";
209
+ }
210
+ if (!Array.isArray(denominator.swept) || denominator.swept.length === 0) {
211
+ return "it names no swept lanes";
212
+ }
213
+ if (!Number.isInteger(denominator.sweptCount)) {
214
+ return "its swept count is not a whole number";
215
+ }
216
+ if (!Number.isInteger(denominator.totalOpen)) {
217
+ return "its open-item total is not a whole number";
218
+ }
219
+ if (denominator.sweptCount > denominator.totalOpen) {
220
+ return "it claims to have swept more items than are open";
221
+ }
222
+ return null;
223
+ }
224
+
225
+ /**
226
+ * Refuse to record a dry build lane that does not say what it looked at.
227
+ *
228
+ * Thirty-one consecutive cycles reported an empty lane against a queue holding
229
+ * unswept pre-work rows. Every record was honest and every conclusion was
230
+ * false, because none of them stated a denominator. This turns that silent
231
+ * wrong answer into a loud one.
232
+ *
233
+ * @param {{ loopId?: unknown, outcome?: unknown, denominator?: unknown }} input
234
+ * @throws {Error} when a denominator is required and missing or malformed
235
+ */
236
+ export function assertDenominatorReported(input = {}) {
237
+ if (!requiresDenominator(input.loopId, input.outcome)) {
238
+ return;
239
+ }
240
+ const defect = findDenominatorDefect(input.denominator);
241
+ if (defect === null) {
242
+ return;
243
+ }
244
+
245
+ throw new Error(
246
+ `A "nothing-needed" run for loop "${String(input.loopId).trim()}" must state the lanes it swept, and ${defect}. ` +
247
+ `Pass a denominator built by buildIntakeDenominator() so an omitted lane is visible instead of silent.`
248
+ );
249
+ }
@@ -110,7 +110,7 @@ The only legitimate reasons to stop early:
110
110
 
111
111
  - Missing repo or required configuration. Surface the missing value and exit.
112
112
  - Label namespace not adopted (no issue carries any of `$READY` / `$CLAIMED` / `$DONE`). Surface a label-convention error and exit (this is setup, not a normal idle cycle — see "Adoption" at the bottom).
113
- - Empty ready set. Exit cleanly with `"No GitHub issues labeled $READY in <org>/<repo>. Nothing to do."`
113
+ - Empty pre-work set. Exit cleanly on the denominator-stated summary from `summarizeDryLane` which names every lane swept, its count, and the open total. A bare "nothing to do" is not an acceptable exit: it is indistinguishable from a wrong denominator (#2657).
114
114
 
115
115
  ## Lifecycle assumed
116
116
 
@@ -166,7 +166,57 @@ gh label list --repo <org>/<repo> --json name \
166
166
  '[.[] | .name | select(. == $r or . == $c or (. as $n | $d | index($n)))] | length'
167
167
  ```
168
168
 
169
- If none of the configured role labels exist on the repo → label convention not adopted, surface a setup error and exit. If the role labels exist but none are `$READY` on any open issue matching the resolved assignee filter (or any open issue when the filter is empty) → genuinely empty queue, exit cleanly with `"No GitHub issues labeled $READY. Nothing to do."`
169
+ If none of the configured role labels exist on the repo → label convention not adopted, surface a setup error and exit.
170
+
171
+ #### 2a. Sweep every pre-work lane, and state the denominator
172
+
173
+ GitHub Issues has no state-type field, so labels are the only lane available here — a constraint of
174
+ GitHub's data model, not a preference (see "Why labels" above). That makes the omission risk
175
+ *higher*, not lower: there is no `type` to fall back on, so the pre-work set must be derived from
176
+ **configured roles**, never from a hardcoded roster of label names. The pre-work lanes are:
177
+
178
+ - the configured `$READY` label — the human-flipped lane, worked first;
179
+ - the configured `$BLOCKED` label (`github.labels.build.blocked`) — items that were *never started*,
180
+ each carrying a written blocker that nothing re-read until Phase 2.5;
181
+ - open issues carrying **no** build role label — which this scanner must see anyway in order to
182
+ determine and stamp their repo.
183
+
184
+ Count each lane and the repo's **total open** count (`gh issue list --state open --limit 1000 --json
185
+ number | jq length`), then build the denominator with the shared helper — GitHub callers pass the
186
+ lane type explicitly, since there is none to read:
187
+
188
+ ```text
189
+ buildIntakeDenominator({ lanes: [{name: "$READY", type: "unstarted", count: <n>},
190
+ {name: "$BLOCKED", type: "unstarted", count: <n>},
191
+ {name: "(no role label)", type: "backlog", count: <n>},
192
+ {name: "$CLAIMED", type: "started", count: <n>}, …],
193
+ totalOpen: <open issue count> })
194
+ summarizeDryLane(denominator, { queue: "<org>/<repo>" })
195
+ ```
196
+
197
+ Every candidate outside `$READY` must clear Phase 2.5 before it is treated as a candidate at all. If
198
+ nothing survives, exit on the denominator-stated summary from `summarizeDryLane` — never a bare
199
+ "nothing to do". The run recorder rejects a dry build-intake run that does not name what it swept
200
+ (see `automation-runbook-contract`).
201
+
202
+ #### 2a.1 Re-probe the blockers instead of inheriting them
203
+
204
+ A blocker is a **claim with a timestamp, not a fact** — it goes stale the moment its condition comes
205
+ true, and nothing re-read one before this phase. For each `$BLOCKED` candidate:
206
+
207
+ 1. **Human gate first, and it is absolute.** An issue carrying the configured human-needed label
208
+ (`github.labels.build.human_needed`, default `human-needed`) or a `[lisa-human-gate]` marker in
209
+ its body is **never** auto-selected, whatever any probe says.
210
+ 2. **Extract the stated discharge condition**, then **probe it**. Machine-testable conditions — a
211
+ version on trunk, a published package, a CI run history, an advisory's patched status — rot
212
+ fastest and are cheapest to check. A human decision is not machine-testable; leave it.
213
+ 3. **Classify with `classifyPreWorkCandidate(...)`** from `scripts/intake-blocker-reprobe.mjs`. A
214
+ discharge with no recorded evidence is not a discharge, and neither is a candidate nothing
215
+ probed this cycle — the helper refuses both.
216
+ 4. **Record the result on the issue either way** via `formatReprobeNote(...)` as a comment, so the
217
+ next cycle reads the answer rather than re-deriving it. Keep it idempotent.
218
+ 5. **On `selectable: true`**, relabel `$BLOCKED → $READY` with the discharging evidence in the same
219
+ comment, and treat it as an ordinary candidate. On anything else, leave it where it is.
170
220
 
171
221
  #### 2b. Lifecycle-label trust resolution (bot-authored labels are not signals)
172
222
 
@@ -251,7 +301,7 @@ GitHub Issues live in one repo by definition, so the scanned repo's issues are u
251
301
  - **Container visibility is allowed.** A multi-repo Epic / Story / Spike may legitimately carry multiple `repo:<name>` labels for operator visibility. Do not split or claim it here; leave the repo markers intact and fall through to the leaf-only gate, which repairs the stale build-ready label instead of dispatching the container.
252
302
  - **Multi-repo leaf → split, never claim.** Run the `repo-scope-split` work-time procedure into single-repo siblings, each created **build-ready** (`build_ready: true`) and stamped with its own `repo:<name>`; the current repo's sibling becomes a normal candidate.
253
303
  - **Single-repo leaf for the current repo** → fall through to 3a (leaf-only gate) and 3b (claim).
254
- 4. Continue until a claimable current-repo leaf is found (claim it; one per cycle) or the ready set is exhausted — exit cleanly with `"No ready issues for repo <current>. Nothing to do."`.
304
+ 4. Continue until a claimable current-repo leaf is found (claim it; one per cycle) or the candidate set is exhausted — exit cleanly on the denominator-stated summary, naming the current repo alongside the swept lanes.
255
305
 
256
306
  #### 3a. Leaf-only claim gate (repair containers)
257
307
 
@@ -126,7 +126,7 @@ matches the mode this cycle ran in: **`intake-prd`** (PRD-side dispatch) or **`i
126
126
 
127
127
  | This cycle's exit path | Run outcome |
128
128
  |---|---|
129
- | Empty `Ready` set — the idle case (step 3), nothing to claim | `nothing-needed` |
129
+ | Empty pre-work set — the idle case (step 3), nothing to claim. **Must state its denominator** (see below) | `nothing-needed` |
130
130
  | A PRD routed to `Blocked` (clarifying questions) or `Ticketed`; a build ticket claimed and dispatched | `candidate-proposed` |
131
131
  | A build cycle that shipped and verified (merged PR + evidence), or a shipped PRD moved to `verified` | `change-proved` |
132
132
  | A protected deployment (or other autonomy boundary the lifecycle hits) waiting on a human approval | `approval-requested` |
@@ -151,6 +151,22 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/automation-run-record.mjs" \
151
151
  --runbook .lisa/automations/intake-tickets.runbook.md [--ref <item-url>]...
152
152
  ```
153
153
 
154
+ **A dry build lane must say what it looked in.** A `nothing-needed` run on `intake-tickets`
155
+ additionally passes `--denominator` — the JSON from `buildIntakeDenominator()` in
156
+ `scripts/intake-prework-denominator.mjs` — and the recorder **refuses the row without it**. This is
157
+ not decoration: 31 consecutive cycles reported a dry lane against a queue holding 61 unswept
158
+ pre-work rows, and every one of those records was honest. A missing row is noisy on inspection; a
159
+ wrong denominator is silent forever and looks exactly like a healthy dry queue. Stating the swept
160
+ set is what turns the silent wrong answer into an inspectable one (#2657).
161
+
162
+ ```bash
163
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/automation-run-record.mjs" \
164
+ --loop-id intake-tickets --outcome nothing-needed \
165
+ --summary "Nothing ready to build on team ENG. Looked in every lane holding work that has not started — Backlog (20), Todo (17), Ready (2), Blocked (61) — 100 items checked out of 343 still open." \
166
+ --denominator "$DENOMINATOR_JSON" \
167
+ --runbook .lisa/automations/intake-tickets.runbook.md
168
+ ```
169
+
154
170
  If `${CLAUDE_PLUGIN_ROOT}` is unset, resolve the plugin scripts directory directly — the built copy
155
171
  `plugins/lisa/scripts/automation-run-record.mjs` or the source
156
172
  `plugins/src/base/scripts/automation-run-record.mjs`. If recording still fails, **degrade, never
@@ -85,7 +85,7 @@ The only legitimate reasons to stop early:
85
85
 
86
86
  - Missing project key / JQL or required configuration. Surface the missing value and exit.
87
87
  - Workflow misconfigured (pre-flight check finds `$CLAIMED` or `$DONE` not reachable, or `$READY` status absent). Surface and exit.
88
- - Empty ready set. Exit cleanly with `"No tickets with Status=$READY. Nothing to do."`
88
+ - Empty pre-work set. Exit cleanly on the denominator-stated summary from `summarizeDryLane` which names every lane swept, its count, and the open total. A bare "nothing to do" is not an acceptable exit: it is indistinguishable from a wrong denominator (#2657).
89
89
 
90
90
  ## Lifecycle assumed
91
91
 
@@ -130,11 +130,59 @@ JQL="${BASE_JQL} ORDER BY priority DESC, created ASC"
130
130
 
131
131
  4. Confirm the configured Atlassian site by invoking `lisa-atlassian-access` `operation: list-sites` (it enforces connection match against `.lisa.config.json`).
132
132
 
133
- ### Phase 2 — Find ready tickets
133
+ ### Phase 2 — Sweep every pre-work status by CATEGORY
134
134
 
135
- Invoke `lisa-atlassian-access` `operation: search-issues jql: "<JQL>"`. Capture each ticket's: key, summary, issue type, priority, assignee, parent (epic), labels, components.
135
+ **Sweep by `statusCategory`, never by a roster of status names.** JIRA groups statuses into three
136
+ categories — `To Do` (`new`), `In Progress` (`indeterminate`), `Done` (`done`) — and a `Blocked`
137
+ status is not a fourth category: a project that wants one models it under **`To Do`**, i.e. work
138
+ that was never started. Keying the candidate set on status *names* therefore omits every pre-work
139
+ lane a project invented, and the scanner reports an empty queue over a full one. The same defect on
140
+ the Linear side produced 31 consecutive false "dry lane" cycles (#2657).
136
141
 
137
- If empty, report `"No tickets with Status=$READY. Nothing to do."` and exit. This is the common idle case.
142
+ 1. Run the ready query first: `lisa-atlassian-access` `operation: search-issues jql: "<JQL>"`. This
143
+ is the human-flipped lane and it is worked first.
144
+ 2. Then sweep the rest of pre-work with the same base JQL, swapping the status clause for
145
+ `statusCategory = "To Do"`, and read the **total open** count (`statusCategory != Done`). The
146
+ open total is what makes an omitted lane arithmetically visible.
147
+ 3. Page to exhaustion — a single unpaged `search-issues` call is not a count.
148
+
149
+ Capture each ticket's: key, summary, issue type, priority, assignee, parent (epic), status (with
150
+ its category), labels, components.
151
+
152
+ Build the denominator with the shared helper, which owns the pre-work vocabulary so no two
153
+ scanners can disagree about what counts:
154
+
155
+ ```text
156
+ buildIntakeDenominator({ lanes: [{name: "<status>", type: "<statusCategory>", count: <rows>}, …],
157
+ totalOpen: <statusCategory != Done count> })
158
+ summarizeDryLane(denominator, { queue: "<project key>" })
159
+ ```
160
+
161
+ The helper accepts JIRA's category vocabulary directly (`new` / `To Do` normalize to pre-work).
162
+
163
+ **Every candidate outside `$READY` must clear Phase 2.5 before it is treated as a candidate at
164
+ all.** If nothing survives, exit on the denominator-stated summary from `summarizeDryLane` — never a
165
+ bare "nothing to do". The run recorder rejects a dry build-intake run that does not name what it
166
+ swept (see `automation-runbook-contract`).
167
+
168
+ ### Phase 2.5 — Re-probe the blockers instead of inheriting them
169
+
170
+ A blocker is a **claim with a timestamp, not a fact** — it goes stale the moment its condition comes
171
+ true, and nothing re-read one before this phase. For each pre-work candidate outside `$READY`:
172
+
173
+ 1. **Human gate first, and it is absolute.** A ticket carrying the configured human-needed label
174
+ (`jira.labels.human_needed`, default `Human Needed`) or a `[lisa-human-gate]` marker in its
175
+ description is **never** auto-selected, whatever any probe says.
176
+ 2. **Extract the stated discharge condition**, then **probe it**. Machine-testable conditions — a
177
+ version on trunk, a published package, a CI run history, an advisory's patched status — rot
178
+ fastest and are cheapest to check. A human decision is not machine-testable; leave it.
179
+ 3. **Classify with `classifyPreWorkCandidate(...)`** from
180
+ `scripts/intake-blocker-reprobe.mjs`. A discharge with no recorded evidence is not a discharge,
181
+ and neither is a candidate nothing probed this cycle — the helper refuses both.
182
+ 4. **Record the result on the ticket either way** via `formatReprobeNote(...)` as a comment, so the
183
+ next cycle reads the answer rather than re-deriving it. Keep it idempotent.
184
+ 5. **On `selectable: true`**, transition the ticket to `$READY` with the discharging evidence in the
185
+ same comment, and treat it as an ordinary candidate. On anything else, leave it where it is.
138
186
 
139
187
  ### Phase 3 — Process the first eligible ready ticket
140
188
 
@@ -149,7 +197,7 @@ A JIRA project can oversee multiple repos (`frontend` / `backend` / `infrastruct
149
197
  - **Unlabeled** → determine the target repo(s) from the ticket (description, AC, technical approach) confirmed against the code surfaces, then **stamp** `repo:<name>` via `lisa-atlassian-access` `operation: write-ticket` (add the label / set the component) so later cycles filter cheaply; re-apply with the now-known repo.
150
198
  - **Multi-repo leaf → split, never claim.** Run the `repo-scope-split` work-time procedure to break it into single-repo siblings, each created **build-ready** (`build_ready: true`) and stamped with its own `repo:<name>`; the current repo's sibling becomes a normal candidate.
151
199
  - **Single-repo leaf for the current repo** → fall through to 3a (leaf-only gate) and 3b (claim).
152
- 4. Continue until a claimable current-repo leaf is found (claim it; one per cycle) or the ready set is exhausted — exit cleanly with `"No ready tickets for repo <current>. Nothing to do."`.
200
+ 4. Continue until a claimable current-repo leaf is found (claim it; one per cycle) or the candidate set is exhausted — exit cleanly on the denominator-stated summary, naming the current repo alongside the swept lanes.
153
201
 
154
202
  #### 3a. Leaf-only claim gate (skip / safe-block containers)
155
203