@codyswann/lisa 4.11.1 → 4.11.3
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/all/copy-overwrite/scripts/lisa-gates.mjs +13 -4
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +3 -2
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +19 -11
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +3 -2
- package/dist/sync/lifecycle-defaults.d.ts +1 -0
- package/dist/sync/lifecycle-defaults.d.ts.map +1 -1
- package/dist/sync/lifecycle-defaults.js +1 -0
- package/dist/sync/lifecycle-defaults.js.map +1 -1
- package/dist/sync/registry.d.ts.map +1 -1
- package/dist/sync/registry.js +5 -2
- package/dist/sync/registry.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-tracker-sync/SKILL.md +1 -1
- package/plugins/lisa/hooks/parity-safety-net.sh +3 -2
- package/plugins/lisa/rules/eager/leaf-only-lifecycle.md +11 -0
- package/plugins/lisa/rules/reference/config-resolution.md +7 -1
- package/plugins/lisa/rules/reference/leaf-only-lifecycle.md +21 -0
- package/plugins/lisa/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/lisa/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa/skills/lisa-tracker-sync/SKILL.md +1 -1
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +3 -2
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/lisa-agy/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa-agy/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa-agy/skills/lisa-tracker-sync/SKILL.md +1 -1
- 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 +1 -1
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +3 -2
- package/plugins/lisa-copilot/rules/eager/leaf-only-lifecycle.md +11 -0
- package/plugins/lisa-copilot/rules/reference/config-resolution.md +7 -1
- package/plugins/lisa-copilot/rules/reference/leaf-only-lifecycle.md +21 -0
- package/plugins/lisa-copilot/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/lisa-copilot/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa-copilot/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa-copilot/skills/lisa-tracker-sync/SKILL.md +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +3 -2
- package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +7 -1
- package/plugins/lisa-cursor/rules/leaf-only-lifecycle-reference.mdc +21 -0
- package/plugins/lisa-cursor/rules/leaf-only-lifecycle.mdc +11 -0
- package/plugins/lisa-cursor/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/lisa-cursor/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa-cursor/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa-cursor/skills/lisa-tracker-sync/SKILL.md +1 -1
- 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/hooks/parity-safety-net.sh +3 -2
- package/plugins/src/base/rules/eager/leaf-only-lifecycle.md +11 -0
- package/plugins/src/base/rules/reference/config-resolution.md +7 -1
- package/plugins/src/base/rules/reference/leaf-only-lifecycle.md +21 -0
- package/plugins/src/base/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/src/base/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/src/base/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/src/base/skills/lisa-tracker-sync/SKILL.md +1 -1
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Blocker classification for the parent status rollup.
|
|
4
|
+
*
|
|
5
|
+
* The `leaf-only-lifecycle` rollup propagates "blocked dominates" faithfully:
|
|
6
|
+
* one blocked leaf anywhere under a container puts the container in `blocked`.
|
|
7
|
+
* What it propagates is a single bit. It cannot say which leaf, and it cannot
|
|
8
|
+
* say what kind of hold it is — and those are two different asks:
|
|
9
|
+
*
|
|
10
|
+
* - a **hard blocker** waits on an external event and may clear with nobody
|
|
11
|
+
* touching it;
|
|
12
|
+
* - a **bad acceptance criterion** waits on a person rewriting it, and will
|
|
13
|
+
* never clear on its own.
|
|
14
|
+
*
|
|
15
|
+
* Rendered identically, the second class accumulates silently. Measured on one
|
|
16
|
+
* Epic: two occurrences, 32 identical hold comments, six weeks — while the
|
|
17
|
+
* initiative was two-thirds complete both times.
|
|
18
|
+
*
|
|
19
|
+
* This module classifies from **recorded signals only** — the markers and the
|
|
20
|
+
* `is blocked by` links a writer already put on the item. It never reads prose
|
|
21
|
+
* to guess, and it never decides that a criterion is bad: judging that is a
|
|
22
|
+
* human call, and `unknown` is the honest answer when nothing recorded says.
|
|
23
|
+
* Its job is to make the distinction recordable and visible, not to make it.
|
|
24
|
+
*
|
|
25
|
+
* It probes nothing itself. The caller reads the tracker and hands back the
|
|
26
|
+
* child graph, which keeps the decision auditable and testable apart from the
|
|
27
|
+
* network — same contract as `intake-blocker-reprobe`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
|
|
33
|
+
/** Classes a hold can carry, most specific first. */
|
|
34
|
+
export const BLOCKER_CLASSES = Object.freeze([
|
|
35
|
+
"spec-defect",
|
|
36
|
+
"human-input",
|
|
37
|
+
"hard-blocker",
|
|
38
|
+
"unknown",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Default marker naming a hold a person must rewrite the spec to clear. */
|
|
42
|
+
export const DEFAULT_SPEC_DEFECT_MARKER = "blocked:spec-defect";
|
|
43
|
+
|
|
44
|
+
/** Default marker naming a hold only a person can supply the input for. */
|
|
45
|
+
export const DEFAULT_HUMAN_NEEDED_MARKER = "human-needed";
|
|
46
|
+
|
|
47
|
+
/** Lifecycle values that mean "held", across the three trackers. */
|
|
48
|
+
const DEFAULT_BLOCKED_STATES = Object.freeze([
|
|
49
|
+
"blocked",
|
|
50
|
+
"status:blocked",
|
|
51
|
+
"Blocked",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Who must act, per class. One actor per class, never a shared line: the whole
|
|
56
|
+
* point is that "waits on an external event" and "waits on a person rewriting
|
|
57
|
+
* it" are different asks and must not be rendered the same way.
|
|
58
|
+
*/
|
|
59
|
+
export const BLOCKER_ACTOR = Object.freeze({
|
|
60
|
+
"spec-defect": "a person rewrites the acceptance criteria",
|
|
61
|
+
"human-input": "a person supplies the input",
|
|
62
|
+
"hard-blocker": "nobody — it waits on other work",
|
|
63
|
+
unknown: "a person says which kind of hold this is",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** Operator-readable heading and body, per class. Composed, never asserted. */
|
|
67
|
+
export const BLOCKER_COPY = Object.freeze({
|
|
68
|
+
"spec-defect": {
|
|
69
|
+
heading: "Waiting on a rewrite",
|
|
70
|
+
line: "A person must rewrite this item's acceptance criteria. Nothing external will ever clear it, so it will sit here until someone edits it.",
|
|
71
|
+
},
|
|
72
|
+
"human-input": {
|
|
73
|
+
heading: "Waiting on a person",
|
|
74
|
+
line: "A person must supply something no agent can invent — access, a credential, or a product decision. It will not clear on its own.",
|
|
75
|
+
},
|
|
76
|
+
"hard-blocker": {
|
|
77
|
+
heading: "Waiting on other work",
|
|
78
|
+
line: "Nobody has to act. This clears by itself when the work it names below closes.",
|
|
79
|
+
},
|
|
80
|
+
unknown: {
|
|
81
|
+
heading: "Nobody is assigned",
|
|
82
|
+
line: `Nothing recorded on this item says which kind of hold it is, so nobody is assigned to it and it will sit. A person must decide, then record it: add \`${DEFAULT_SPEC_DEFECT_MARKER}\` if the acceptance criteria are the problem, or an \`is blocked by\` link if it is waiting on other work.`,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** Why a classification run refuses to report. */
|
|
87
|
+
export const CLASSIFICATION_FAILURES = Object.freeze({
|
|
88
|
+
CONTAINER_UNREADABLE: "container-unreadable",
|
|
89
|
+
NO_CHILDREN: "rollup-has-no-children",
|
|
90
|
+
NOTHING_EXAMINED: "no-child-could-be-examined",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const FAILURE_COPY = Object.freeze({
|
|
94
|
+
"container-unreadable":
|
|
95
|
+
"The tracker could not be read, so nothing was examined. This is not an all-clear.",
|
|
96
|
+
"rollup-has-no-children":
|
|
97
|
+
"This container has no children, so there was nothing to roll up. A container with no children is a decomposition gap, not a clear rollup.",
|
|
98
|
+
"no-child-could-be-examined":
|
|
99
|
+
"Children were listed but none could be examined, so nothing was classified. This is not an all-clear.",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {unknown} value
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
function trimmedString(value) {
|
|
107
|
+
return typeof value === "string" ? value.trim() : "";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {unknown} labels
|
|
112
|
+
* @returns {readonly string[]}
|
|
113
|
+
*/
|
|
114
|
+
function normalizeMarkers(labels) {
|
|
115
|
+
return (Array.isArray(labels) ? labels : [])
|
|
116
|
+
.map(label =>
|
|
117
|
+
typeof label === "string" ? label : trimmedString(label?.name)
|
|
118
|
+
)
|
|
119
|
+
.map(label => trimmedString(label).toLowerCase())
|
|
120
|
+
.filter(label => label.length > 0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {unknown} states
|
|
125
|
+
* @returns {readonly string[]}
|
|
126
|
+
*/
|
|
127
|
+
function normalizeBlockedStates(states) {
|
|
128
|
+
const configured = (Array.isArray(states) ? states : DEFAULT_BLOCKED_STATES)
|
|
129
|
+
.map(state => trimmedString(state).toLowerCase())
|
|
130
|
+
.filter(state => state.length > 0);
|
|
131
|
+
|
|
132
|
+
return configured.length > 0
|
|
133
|
+
? configured
|
|
134
|
+
: DEFAULT_BLOCKED_STATES.map(state => state.toLowerCase());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A child's own lifecycle value, taken from whichever field the vendor reader
|
|
139
|
+
* filled: a JIRA/Linear `state`, or the single `status:*` GitHub label.
|
|
140
|
+
*
|
|
141
|
+
* @param {{ state?: unknown, status?: unknown, role?: unknown }} node
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
function nodeState(node) {
|
|
145
|
+
return (
|
|
146
|
+
trimmedString(node?.state) ||
|
|
147
|
+
trimmedString(node?.status) ||
|
|
148
|
+
trimmedString(node?.role)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Open `is blocked by` dependencies recorded on the item. A dependency the
|
|
154
|
+
* caller resolved as closed is dropped: it no longer holds anything.
|
|
155
|
+
*
|
|
156
|
+
* @param {unknown} blockedBy
|
|
157
|
+
* @returns {readonly string[]}
|
|
158
|
+
*/
|
|
159
|
+
function openDependencies(blockedBy) {
|
|
160
|
+
return (Array.isArray(blockedBy) ? blockedBy : [])
|
|
161
|
+
.map(entry =>
|
|
162
|
+
typeof entry === "string"
|
|
163
|
+
? { ref: entry, open: true }
|
|
164
|
+
: { ref: trimmedString(entry?.ref), open: entry?.open !== false }
|
|
165
|
+
)
|
|
166
|
+
.filter(entry => entry.open && entry.ref.length > 0)
|
|
167
|
+
.map(entry => entry.ref);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Classify one held item from its recorded signals.
|
|
172
|
+
*
|
|
173
|
+
* Order is load-bearing and runs most-deliberate first. A marker is something
|
|
174
|
+
* a person chose to write; a dependency link is something a flow wrote. Where
|
|
175
|
+
* both are present the human's record wins, because the marker is the only
|
|
176
|
+
* signal that carries a judgment nothing else can supply.
|
|
177
|
+
*
|
|
178
|
+
* @param {{
|
|
179
|
+
* ref?: unknown
|
|
180
|
+
* labels?: unknown
|
|
181
|
+
* markers?: unknown
|
|
182
|
+
* blockedBy?: unknown
|
|
183
|
+
* }} node
|
|
184
|
+
* @param {{ specDefect?: unknown, humanNeeded?: unknown }} [markerNames]
|
|
185
|
+
* @returns {{ class: string, actor: string, signals: readonly string[] }}
|
|
186
|
+
*/
|
|
187
|
+
export function classifyHeldItem(node = {}, markerNames = {}) {
|
|
188
|
+
const specDefect = (
|
|
189
|
+
trimmedString(markerNames.specDefect) || DEFAULT_SPEC_DEFECT_MARKER
|
|
190
|
+
).toLowerCase();
|
|
191
|
+
const humanNeeded = (
|
|
192
|
+
trimmedString(markerNames.humanNeeded) || DEFAULT_HUMAN_NEEDED_MARKER
|
|
193
|
+
).toLowerCase();
|
|
194
|
+
const markers = normalizeMarkers(node.markers ?? node.labels);
|
|
195
|
+
const dependencies = openDependencies(node.blockedBy);
|
|
196
|
+
|
|
197
|
+
if (markers.includes(specDefect)) {
|
|
198
|
+
return {
|
|
199
|
+
class: "spec-defect",
|
|
200
|
+
actor: BLOCKER_ACTOR["spec-defect"],
|
|
201
|
+
signals: [`marked \`${specDefect}\``],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (markers.includes(humanNeeded)) {
|
|
205
|
+
return {
|
|
206
|
+
class: "human-input",
|
|
207
|
+
actor: BLOCKER_ACTOR["human-input"],
|
|
208
|
+
signals: [`marked \`${humanNeeded}\``],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (dependencies.length > 0) {
|
|
212
|
+
return {
|
|
213
|
+
class: "hard-blocker",
|
|
214
|
+
actor: BLOCKER_ACTOR["hard-blocker"],
|
|
215
|
+
signals: [`blocked by ${dependencies.join(", ")}`],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { class: "unknown", actor: BLOCKER_ACTOR.unknown, signals: [] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {unknown} children
|
|
224
|
+
* @returns {readonly object[]}
|
|
225
|
+
*/
|
|
226
|
+
function childArray(children) {
|
|
227
|
+
return Array.isArray(children) ? children.filter(Boolean) : [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Walk the container's descendants, collecting every held item that is itself
|
|
232
|
+
* the bottom of a chain of holds.
|
|
233
|
+
*
|
|
234
|
+
* A held parent whose own children include a held one is *transparent*: it is
|
|
235
|
+
* blocked because of them, so reporting it would name the wrong item. Its path
|
|
236
|
+
* is kept instead, which is what turns a five-level descent into one read.
|
|
237
|
+
*
|
|
238
|
+
* @param {readonly object[]} children
|
|
239
|
+
* @param {{ blockedStates: readonly string[], markerNames: object }} context
|
|
240
|
+
* @param {readonly string[]} path
|
|
241
|
+
* @param {Set<string>} seen
|
|
242
|
+
* @param {{ examined: number, unexaminable: string[] }} tally
|
|
243
|
+
* @returns {object[]}
|
|
244
|
+
*/
|
|
245
|
+
function collectHolds(children, context, path, seen, tally) {
|
|
246
|
+
return childArray(children).flatMap(child => {
|
|
247
|
+
const ref = trimmedString(child.ref) || trimmedString(child.key);
|
|
248
|
+
const state = nodeState(child);
|
|
249
|
+
if (ref.length === 0 || state.length === 0) {
|
|
250
|
+
tally.unexaminable.push(ref || "(child with no reference)");
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
if (seen.has(ref)) {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
seen.add(ref);
|
|
257
|
+
tally.examined += 1;
|
|
258
|
+
|
|
259
|
+
const here = [...path, ref];
|
|
260
|
+
const descendants = collectHolds(
|
|
261
|
+
child.children,
|
|
262
|
+
context,
|
|
263
|
+
here,
|
|
264
|
+
seen,
|
|
265
|
+
tally
|
|
266
|
+
);
|
|
267
|
+
if (!context.blockedStates.includes(state.toLowerCase())) {
|
|
268
|
+
return descendants;
|
|
269
|
+
}
|
|
270
|
+
if (descendants.length > 0) {
|
|
271
|
+
return descendants;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return [
|
|
275
|
+
{
|
|
276
|
+
ref,
|
|
277
|
+
path: here,
|
|
278
|
+
...classifyHeldItem(child, context.markerNames),
|
|
279
|
+
},
|
|
280
|
+
];
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Classify every hold under a container and derive the rollup from it.
|
|
286
|
+
*
|
|
287
|
+
* Refuses rather than reports when it examined nothing: an unreadable tracker,
|
|
288
|
+
* a container with no children, and children none of which could be read all
|
|
289
|
+
* return `ok: false`. Reporting all-clear on any of them would be the same
|
|
290
|
+
* defect this module exists to fix, one level up.
|
|
291
|
+
*
|
|
292
|
+
* @param {{
|
|
293
|
+
* container?: { ref?: unknown, type?: unknown }
|
|
294
|
+
* readError?: unknown
|
|
295
|
+
* children?: unknown
|
|
296
|
+
* blockedStates?: unknown
|
|
297
|
+
* markerNames?: { specDefect?: unknown, humanNeeded?: unknown }
|
|
298
|
+
* }} input
|
|
299
|
+
* @returns {object}
|
|
300
|
+
*/
|
|
301
|
+
export function classifyRollupBlockers(input = {}) {
|
|
302
|
+
const container = {
|
|
303
|
+
ref: trimmedString(input.container?.ref) || "(unnamed container)",
|
|
304
|
+
type: trimmedString(input.container?.type) || "container",
|
|
305
|
+
};
|
|
306
|
+
const readError = trimmedString(input.readError);
|
|
307
|
+
if (readError.length > 0) {
|
|
308
|
+
return failure(container, CLASSIFICATION_FAILURES.CONTAINER_UNREADABLE, {
|
|
309
|
+
detail: readError,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const children = childArray(input.children);
|
|
314
|
+
if (children.length === 0) {
|
|
315
|
+
return failure(container, CLASSIFICATION_FAILURES.NO_CHILDREN);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const tally = { examined: 0, unexaminable: [] };
|
|
319
|
+
const blockers = collectHolds(
|
|
320
|
+
children,
|
|
321
|
+
{
|
|
322
|
+
blockedStates: normalizeBlockedStates(input.blockedStates),
|
|
323
|
+
markerNames: input.markerNames ?? {},
|
|
324
|
+
},
|
|
325
|
+
[container.ref],
|
|
326
|
+
new Set([container.ref]),
|
|
327
|
+
tally
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
if (tally.examined === 0) {
|
|
331
|
+
return failure(container, CLASSIFICATION_FAILURES.NOTHING_EXAMINED, {
|
|
332
|
+
detail: `${children.length} listed, none readable`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const byClass = Object.fromEntries(
|
|
337
|
+
BLOCKER_CLASSES.map(name => [
|
|
338
|
+
name,
|
|
339
|
+
blockers.filter(blocker => blocker.class === name),
|
|
340
|
+
]).filter(([, group]) => group.length > 0)
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
ok: true,
|
|
345
|
+
container,
|
|
346
|
+
verdict: blockers.length > 0 ? "blocked" : "not-blocked",
|
|
347
|
+
examined: tally.examined,
|
|
348
|
+
blockers,
|
|
349
|
+
byClass,
|
|
350
|
+
unexaminable: tally.unexaminable,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @param {{ ref: string, type: string }} container
|
|
356
|
+
* @param {string} reason
|
|
357
|
+
* @param {{ detail?: string }} [extra]
|
|
358
|
+
* @returns {object}
|
|
359
|
+
*/
|
|
360
|
+
function failure(container, reason, extra = {}) {
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
container,
|
|
364
|
+
verdict: "unclassified",
|
|
365
|
+
examined: 0,
|
|
366
|
+
reason,
|
|
367
|
+
message: FAILURE_COPY[reason] ?? reason,
|
|
368
|
+
detail: trimmedString(extra.detail),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {{ ref: string, path: readonly string[], signals: readonly string[] }} blocker
|
|
374
|
+
* @returns {string}
|
|
375
|
+
*/
|
|
376
|
+
function renderBlockerLine(blocker) {
|
|
377
|
+
const via =
|
|
378
|
+
blocker.path.length > 2 ? ` (via ${blocker.path.join(" -> ")})` : "";
|
|
379
|
+
const signals =
|
|
380
|
+
blocker.signals.length > 0 ? ` — ${blocker.signals.join("; ")}` : "";
|
|
381
|
+
|
|
382
|
+
return ` - ${blocker.ref}${via}${signals}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Render the rollup for an operator.
|
|
387
|
+
*
|
|
388
|
+
* Every sentence is composed from the per-class grouping rather than asserted
|
|
389
|
+
* over all blockers, so the summary and the per-item lines cannot disagree. A
|
|
390
|
+
* class with nothing in it produces no text at all.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} result - A {@link classifyRollupBlockers} result.
|
|
393
|
+
* @returns {string}
|
|
394
|
+
*/
|
|
395
|
+
export function renderRollupBlockerReport(result) {
|
|
396
|
+
if (!result?.ok) {
|
|
397
|
+
return [
|
|
398
|
+
`${result?.container?.ref ?? "(unnamed container)"} — NOT CLASSIFIED (${result?.reason ?? "unknown"}).`,
|
|
399
|
+
result?.message ?? "",
|
|
400
|
+
trimmedString(result?.detail).length > 0
|
|
401
|
+
? `Detail: ${result.detail}`
|
|
402
|
+
: "",
|
|
403
|
+
]
|
|
404
|
+
.filter(line => line.length > 0)
|
|
405
|
+
.join("\n");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (result.verdict === "not-blocked") {
|
|
409
|
+
return `${result.container.ref} — not blocked. ${result.examined} item(s) examined, none held.`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const held = result.blockers.length;
|
|
413
|
+
const sections = BLOCKER_CLASSES.filter(
|
|
414
|
+
name => (result.byClass[name] ?? []).length > 0
|
|
415
|
+
).map(name => {
|
|
416
|
+
const group = result.byClass[name];
|
|
417
|
+
const copy = BLOCKER_COPY[name];
|
|
418
|
+
|
|
419
|
+
return [
|
|
420
|
+
`${copy.heading} (${group.length}) — who must act: ${BLOCKER_ACTOR[name]}.`,
|
|
421
|
+
` ${copy.line}`,
|
|
422
|
+
...group.map(renderBlockerLine),
|
|
423
|
+
].join("\n");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return [
|
|
427
|
+
`${result.container.ref} — blocked. ${held} of ${result.examined} item(s) examined are held.`,
|
|
428
|
+
"",
|
|
429
|
+
...sections.flatMap(section => [section, ""]),
|
|
430
|
+
]
|
|
431
|
+
.join("\n")
|
|
432
|
+
.trimEnd();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A stable identity for a rollup verdict: the container, plus every held item
|
|
437
|
+
* and its class, sorted.
|
|
438
|
+
*
|
|
439
|
+
* This is what lets a repeat cycle stay quiet. 32 identical hold comments over
|
|
440
|
+
* six weeks was the same verdict restated on a schedule; comparing this string
|
|
441
|
+
* to the last one tells a caller whether it has anything new to say.
|
|
442
|
+
*
|
|
443
|
+
* @param {object} result - A {@link classifyRollupBlockers} result.
|
|
444
|
+
* @returns {string}
|
|
445
|
+
*/
|
|
446
|
+
export function rollupBlockerFingerprint(result) {
|
|
447
|
+
if (!result?.ok) {
|
|
448
|
+
return `container=${result?.container?.ref ?? "?"};unclassified=${result?.reason ?? "?"}`;
|
|
449
|
+
}
|
|
450
|
+
const pairs = [...result.blockers]
|
|
451
|
+
.map(blocker => `${blocker.ref}:${blocker.class}`)
|
|
452
|
+
.sort((left, right) => left.localeCompare(right));
|
|
453
|
+
|
|
454
|
+
return `container=${result.container.ref};verdict=${result.verdict};examined=${result.examined};holds=${pairs.join(",")}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Decide whether this cycle has anything new to say, and say what changed.
|
|
459
|
+
*
|
|
460
|
+
* @param {string | null | undefined} previousFingerprint
|
|
461
|
+
* @param {object} result - A {@link classifyRollupBlockers} result.
|
|
462
|
+
* @returns {{ changed: boolean, fingerprint: string, summary: string }}
|
|
463
|
+
*/
|
|
464
|
+
export function describeRollupBlockerChange(previousFingerprint, result) {
|
|
465
|
+
const fingerprint = rollupBlockerFingerprint(result);
|
|
466
|
+
const previous = trimmedString(previousFingerprint);
|
|
467
|
+
if (previous.length === 0) {
|
|
468
|
+
return {
|
|
469
|
+
changed: true,
|
|
470
|
+
fingerprint,
|
|
471
|
+
summary: "First classification of this container.",
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if (previous === fingerprint) {
|
|
475
|
+
return {
|
|
476
|
+
changed: false,
|
|
477
|
+
fingerprint,
|
|
478
|
+
summary:
|
|
479
|
+
"Unchanged since the last cycle — same items held, same classes. Nothing new to post.",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
changed: true,
|
|
485
|
+
fingerprint,
|
|
486
|
+
summary: `Changed since the last cycle: ${describeDelta(previous, fingerprint)}.`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* @param {string} previous
|
|
492
|
+
* @param {string} current
|
|
493
|
+
* @returns {string}
|
|
494
|
+
*/
|
|
495
|
+
function describeDelta(previous, current) {
|
|
496
|
+
const parse = fingerprint =>
|
|
497
|
+
new Map(
|
|
498
|
+
(fingerprint.split(";holds=")[1] ?? "")
|
|
499
|
+
.split(",")
|
|
500
|
+
.filter(pair => pair.length > 0)
|
|
501
|
+
.map(pair => {
|
|
502
|
+
const cut = pair.lastIndexOf(":");
|
|
503
|
+
return [pair.slice(0, cut), pair.slice(cut + 1)];
|
|
504
|
+
})
|
|
505
|
+
);
|
|
506
|
+
const before = parse(previous);
|
|
507
|
+
const after = parse(current);
|
|
508
|
+
const cleared = [...before.keys()].filter(ref => !after.has(ref));
|
|
509
|
+
const added = [...after.keys()].filter(ref => !before.has(ref));
|
|
510
|
+
const reclassified = [...after.entries()]
|
|
511
|
+
.filter(([ref, name]) => before.has(ref) && before.get(ref) !== name)
|
|
512
|
+
.map(([ref, name]) => `${ref} is now ${name} (was ${before.get(ref)})`);
|
|
513
|
+
const parts = [
|
|
514
|
+
cleared.length > 0 ? `${cleared.join(", ")} no longer held` : "",
|
|
515
|
+
added.length > 0 ? `${added.join(", ")} newly held` : "",
|
|
516
|
+
...reclassified,
|
|
517
|
+
].filter(part => part.length > 0);
|
|
518
|
+
|
|
519
|
+
return parts.length > 0 ? parts.join("; ") : "the rollup verdict itself";
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* @param {readonly string[]} argv
|
|
524
|
+
* @returns {number}
|
|
525
|
+
*/
|
|
526
|
+
export function runCli(argv) {
|
|
527
|
+
const args = new Map(
|
|
528
|
+
argv
|
|
529
|
+
.filter(arg => arg.startsWith("--"))
|
|
530
|
+
.map(arg => {
|
|
531
|
+
const eq = arg.indexOf("=");
|
|
532
|
+
return eq === -1
|
|
533
|
+
? [arg.slice(2), "true"]
|
|
534
|
+
: [arg.slice(2, eq), arg.slice(eq + 1)];
|
|
535
|
+
})
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const inputPath = args.get("input");
|
|
539
|
+
if (!inputPath) {
|
|
540
|
+
process.stderr.write(
|
|
541
|
+
"usage: rollup-blocker-classification.mjs --input=<file.json> [--since=<fingerprint>] [--json]\n"
|
|
542
|
+
);
|
|
543
|
+
return 2;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let payload;
|
|
547
|
+
try {
|
|
548
|
+
payload = JSON.parse(readFileSync(inputPath, "utf8"));
|
|
549
|
+
} catch (error) {
|
|
550
|
+
process.stderr.write(
|
|
551
|
+
`rollup-blocker-classification: NOT CLASSIFIED — could not read ${inputPath}: ${String(error?.message ?? error)}\n`
|
|
552
|
+
);
|
|
553
|
+
return 1;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const result = classifyRollupBlockers(payload);
|
|
557
|
+
const change = describeRollupBlockerChange(args.get("since"), result);
|
|
558
|
+
process.stdout.write(
|
|
559
|
+
args.get("json") === "true"
|
|
560
|
+
? `${JSON.stringify({ ...result, change }, null, 2)}\n`
|
|
561
|
+
: `${renderRollupBlockerReport(result)}\n\n${change.summary}\n`
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
return result.ok ? 0 : 1;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* True when `moduleUrl` names the module node was asked to run.
|
|
569
|
+
*
|
|
570
|
+
* Both sides are realpath'd. Reached through a symlinked checkout, a git
|
|
571
|
+
* worktree, or a `/tmp` path on macOS the naive comparisons disagree, the body
|
|
572
|
+
* never runs, and the process exits 0 having done nothing — and every
|
|
573
|
+
* Lisa-driven agent runs in a worktree, so that is the routine path.
|
|
574
|
+
*
|
|
575
|
+
* Written out rather than imported: this ships inside a plugin payload, which
|
|
576
|
+
* has no `./lib/` to resolve against.
|
|
577
|
+
* @param {string} moduleUrl - The caller's own `import.meta.url`.
|
|
578
|
+
* @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
|
|
579
|
+
* @returns {boolean} Whether the caller should run its CLI body.
|
|
580
|
+
*/
|
|
581
|
+
export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
582
|
+
if (!argv1) return false;
|
|
583
|
+
try {
|
|
584
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
585
|
+
} catch {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (invokedAsScript(import.meta.url)) {
|
|
591
|
+
process.exit(runCli(process.argv.slice(2)));
|
|
592
|
+
}
|
|
@@ -108,7 +108,13 @@ If the `subIssues` field is unavailable (older GHES), fall back to body parentag
|
|
|
108
108
|
| else any child has **started** (`status:in-progress`, or shipped to an env while a sibling has not) | `claimed` | `status:in-progress` |
|
|
109
109
|
| else (children exist, none started) | — | unchanged — parent keeps its non-ready container label |
|
|
110
110
|
|
|
111
|
-
- **Blocked dominates** — a single blocked child surfaces `status:blocked` on the parent even while siblings progress, so a human sees the parent needs attention.
|
|
111
|
+
- **Blocked dominates — and the rollup must say which child and which kind** — a single blocked child surfaces `status:blocked` on the parent even while siblings progress, so a human sees the parent needs attention. `status:blocked` alone is a single bit and cannot tell a child waiting on an external event from one whose acceptance criteria are unbuildable; the second never clears on its own. Run the shared classifier over the resolved child graph before writing the label or the comment:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/rollup-blocker-classification.mjs" --input=<graph.json>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Its report names, per class, the blocking leaf, the path to it (`#1495 -> #1515 -> #1547`) and **who must act** — that text goes in the rollup comment verbatim. It exits non-zero when it classified nothing (unreadable graph, no children, no readable child): treat that as a rollup that could not be derived and fall through to the **Safe default** below, never to "no blocked children". Never infer a class from prose and never apply the `spec_defect` marker from a flow — see `leaf-only-lifecycle` → **Classifying a hold**.
|
|
112
118
|
- **Least-advanced env wins** — the parent reaches an env only when every required child has reached at least that env; it never sits ahead of its laggard child. Native closure (`gh issue close --reason completed`) fires only when the resolved env is the production `status:done`, never at `status:on-dev`/`status:on-stg`.
|
|
113
119
|
- **"Required" children only** — a child labelled won't-do / optional does not hold the parent open; only leaves that must ship count toward the env-rollup check.
|
|
114
120
|
- **Recursive** — a parent reaches an env only when its children have all reached at least that env; an Epic reaches it only when its Stories have themselves rolled up to it. Evaluate bottom-up.
|
|
@@ -116,7 +122,7 @@ If the `subIssues` field is unavailable (older GHES), fall back to body parentag
|
|
|
116
122
|
|
|
117
123
|
**Single-environment collapse (this repo).** `.lisa.config.json` `deploy.branches` declares only `production: main`, so the env-keyed `done` resolves to the single label `status:done` — there is no `status:on-dev` / `status:on-stg` and **no dev → staging → prod promotion chain**. Resolve the env rungs via the env-keyed `done` logic in `config-resolution`, but in the single-environment case the only rung is production and it collapses to the one `status:done` value; the rollup never attempts to resolve a dev or staging `done`. Projects that DO have multiple environments keep the env-keyed map and roll the parent up to whichever `done` (including intermediate `status:on-dev`/`status:on-stg`) its leaves have collectively reached.
|
|
118
124
|
|
|
119
|
-
**Apply the derived label** (only when it differs from the parent's current `status:*`): remove the parent's existing `status:*` label and add the derived one, keeping exactly one `status:*` label so the build-queue invariant holds. Post an idempotent `[claude-sync] rollup` comment naming the derived state and the child tally (e.g. `3/4 leaves terminal, 1 blocked → status:blocked`);
|
|
125
|
+
**Apply the derived label** (only when it differs from the parent's current `status:*`): remove the parent's existing `status:*` label and add the derived one, keeping exactly one `status:*` label so the build-queue invariant holds. Post an idempotent `[claude-sync] rollup` comment naming the derived state and the child tally (e.g. `3/4 leaves terminal, 1 blocked → status:blocked`); when the derived state is `status:blocked` the comment also carries the classifier's per-class section, so the blocking child and its actor are one read rather than a descent. Skip the comment if an identical one is already the most recent rollup comment — the classifier's fingerprint is the dedupe key, and an unchanged verdict is not re-posted.
|
|
120
126
|
|
|
121
127
|
```bash
|
|
122
128
|
gh issue edit <parent-number> --repo <org>/<repo> \
|
|
@@ -92,7 +92,7 @@ When invoked with `--rollup`, this skill **derives a parent/container ticket's s
|
|
|
92
92
|
| else any child has **started** (`In Progress` / `Code Review`, or shipped to an env while a sibling has not) | `claimed` | `In Progress` |
|
|
93
93
|
| else (children exist, none started) | — | unchanged — parent keeps its non-ready container status |
|
|
94
94
|
|
|
95
|
-
- **Blocked dominates** — a single blocked child surfaces `Blocked` on the parent even while siblings progress.
|
|
95
|
+
- **Blocked dominates** — a single blocked child surfaces `Blocked` on the parent even while siblings progress. It never says *which* child or *which kind* of hold; run `scripts/rollup-blocker-classification.mjs` over the resolved child graph and carry its per-class report — blocking leaf, path, and who must act — into the rollup note. A non-zero exit means it classified nothing; that is a failure to report, never an all-clear. See `leaf-only-lifecycle` → **Classifying a hold**.
|
|
96
96
|
- **Least-advanced env wins** — the parent reaches an env only when every required child has reached at least that env; it never sits ahead of its laggard child. Apply native terminal resolution (the `leaf-only-lifecycle` Terminal native closure) only when the resolved env is the production `Done`, never at `On Dev`/`On Stg`.
|
|
97
97
|
- **"Required" children only** — won't-do / optional children do not hold the parent open.
|
|
98
98
|
- **Recursive** — an Epic reaches an env only when its Stories have themselves rolled up to at least that env; a Story reaches it only when its Sub-tasks have. Evaluate bottom-up.
|
|
@@ -125,7 +125,7 @@ When the caller passes `--rollup`, this skill **derives a parent/container's wor
|
|
|
125
125
|
| else any child has **started** (`In Progress` / `In Review`, or shipped to an env while a sibling has not) | `claimed` | `In Progress` |
|
|
126
126
|
| else (children exist, none started) | — | unchanged — parent keeps its non-ready container state |
|
|
127
127
|
|
|
128
|
-
- **Blocked dominates** — one blocked child surfaces `Blocked` on the parent even while siblings progress.
|
|
128
|
+
- **Blocked dominates** — one blocked child surfaces `Blocked` on the parent even while siblings progress. It never says *which* child or *which kind* of hold; run `scripts/rollup-blocker-classification.mjs` over the resolved child graph and carry its per-class report — blocking leaf, path, and who must act — into the rollup note. A non-zero exit means it classified nothing; that is a failure to report, never an all-clear. See `leaf-only-lifecycle` → **Classifying a hold**.
|
|
129
129
|
- **Least-advanced env wins** — the parent reaches an env only when every required child has reached at least that env; it never sits ahead of its laggard child. Native completion (moving the workflow `state` to Done) fires only when the resolved env is the production `Done`, never at `On Dev`/`On Stg`.
|
|
130
130
|
- **"Required" children only** — won't-do / optional (e.g. `Canceled`) children do not hold the parent open.
|
|
131
131
|
- **Recursive** — a Project reaches an env only when its Issues have themselves rolled up to at least that env. Evaluate bottom-up.
|
|
@@ -687,11 +687,41 @@ left in a status it should not carry, including a stale build-ready `ready`).
|
|
|
687
687
|
them (e.g. all `On Stg` → `On Stg`); else any child started → `claimed`; else unchanged.
|
|
688
688
|
Optional / won't-do / not-planned children are terminal-but-dropped and do not hold the parent
|
|
689
689
|
open.
|
|
690
|
+
2a. **When the derived state is `blocked`, classify the holds before writing anything.** A bare
|
|
691
|
+
`blocked` on a container is a single bit, and it cannot tell a hold that waits on an external
|
|
692
|
+
event from one that waits on a person rewriting an acceptance criterion — the second never
|
|
693
|
+
clears on its own, so rendering them alike lets it accumulate silently (`leaf-only-lifecycle` →
|
|
694
|
+
**Classifying a hold**; #3045). Feed the resolved child graph to the shared classifier:
|
|
695
|
+
|
|
696
|
+
```bash
|
|
697
|
+
ROLLUP_DIR="$(mktemp -d)"
|
|
698
|
+
# children[]: { ref, state, labels[], blockedBy[{ref, open}], children[] } — the graph
|
|
699
|
+
# step 1 already resolved, nested as deep as it was read. The script probes nothing.
|
|
700
|
+
jq -n --slurpfile g "$ROLLUP_DIR/graph.json" \
|
|
701
|
+
'{container: $g[0].container, children: $g[0].children, readError: $g[0].readError}' \
|
|
702
|
+
> "$ROLLUP_DIR/input.json"
|
|
703
|
+
|
|
704
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/rollup-blocker-classification.mjs" \
|
|
705
|
+
--input="$ROLLUP_DIR/input.json" --since="<last recorded fingerprint>"
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
It **exits non-zero and classifies nothing** when the tracker could not be read, the container
|
|
709
|
+
has no children, or no child was readable. That is a repair failure to report, never an
|
|
710
|
+
all-clear: do not fall through to "no blocked children" on a non-zero exit. On success its
|
|
711
|
+
report names, per class, the blocking leaf, the path to it, and **who must act** — those lines
|
|
712
|
+
are what goes in the rollup note, verbatim. Never set the `spec_defect` marker yourself and
|
|
713
|
+
never infer a class from prose; a hold with nothing recorded classifies `unknown`, and the
|
|
714
|
+
report already asks a person to decide.
|
|
715
|
+
|
|
690
716
|
3. **If the derived state differs from the parent's current state, apply it** via the vendor's
|
|
691
717
|
lifecycle write (JIRA transition, GitHub/Linear label swap keeping exactly one `status:*`),
|
|
692
718
|
removing any conflicting stale build lifecycle role — **including a stale `ready`** the parent
|
|
693
719
|
should never carry. Post an idempotent `[lisa-repair-intake]` rollup note naming the derived
|
|
694
|
-
state and the child tally (honor the backoff window + fingerprint).
|
|
720
|
+
state and the child tally (honor the backoff window + fingerprint). **When the derived state is
|
|
721
|
+
`blocked`, the note carries the classifier's per-class report** — the blocking leaf, its path,
|
|
722
|
+
and its actor — so an operator never descends the tree by hand to find out which item and which
|
|
723
|
+
kind. Use the classifier's `change.summary` as the dedupe test: an unchanged verdict has nothing
|
|
724
|
+
new to say and the note is **not** re-posted.
|
|
695
725
|
4. **Perform native closure only at the true terminal `done`.** When — and only when — the derived
|
|
696
726
|
env is the production/terminal value, finalize through the provider-native mechanism (GitHub
|
|
697
727
|
`gh issue close --reason completed`, Linear move to Done state, JIRA resolved/closed verified at
|