@codyswann/lisa 4.11.2 → 4.12.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 (123) hide show
  1. package/all/copy-contents/.gitattributes +4 -0
  2. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  3. package/dist/core/learnings-merge-driver.js +15 -0
  4. package/dist/core/learnings-merge-driver.js.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +30 -9
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/dist/sync/lifecycle-defaults.d.ts +1 -0
  9. package/dist/sync/lifecycle-defaults.d.ts.map +1 -1
  10. package/dist/sync/lifecycle-defaults.js +1 -0
  11. package/dist/sync/lifecycle-defaults.js.map +1 -1
  12. package/dist/sync/registry.d.ts.map +1 -1
  13. package/dist/sync/registry.js +5 -2
  14. package/dist/sync/registry.js.map +1 -1
  15. package/package.json +2 -1
  16. package/plugins/lisa/.claude-plugin/plugin.json +10 -1
  17. package/plugins/lisa/.codex-plugin/hooks.json +9 -0
  18. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa/.codex-plugin/skills/lisa-github-sync/SKILL.md +8 -2
  20. package/plugins/lisa/.codex-plugin/skills/lisa-jira-sync/SKILL.md +1 -1
  21. package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +1 -1
  22. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +31 -1
  23. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-sync/SKILL.md +1 -1
  24. package/plugins/lisa/hooks/failure-signature-index.mjs +482 -0
  25. package/plugins/lisa/hooks/failure-signature-index.sh +26 -0
  26. package/plugins/lisa/rules/eager/leaf-only-lifecycle.md +11 -0
  27. package/plugins/lisa/rules/reference/config-resolution.md +7 -1
  28. package/plugins/lisa/rules/reference/leaf-only-lifecycle.md +21 -0
  29. package/plugins/lisa/scripts/rollup-blocker-classification.mjs +592 -0
  30. package/plugins/lisa/skills/lisa-github-sync/SKILL.md +8 -2
  31. package/plugins/lisa/skills/lisa-jira-sync/SKILL.md +1 -1
  32. package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +1 -1
  33. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +31 -1
  34. package/plugins/lisa/skills/lisa-tracker-sync/SKILL.md +1 -1
  35. package/plugins/lisa-agy/plugin.json +1 -1
  36. package/plugins/lisa-agy/scripts/rollup-blocker-classification.mjs +592 -0
  37. package/plugins/lisa-agy/skills/lisa-github-sync/SKILL.md +8 -2
  38. package/plugins/lisa-agy/skills/lisa-jira-sync/SKILL.md +1 -1
  39. package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +1 -1
  40. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +31 -1
  41. package/plugins/lisa-agy/skills/lisa-tracker-sync/SKILL.md +1 -1
  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 +10 -1
  48. package/plugins/lisa-copilot/hooks/failure-signature-index.mjs +482 -0
  49. package/plugins/lisa-copilot/hooks/failure-signature-index.sh +26 -0
  50. package/plugins/lisa-copilot/rules/eager/leaf-only-lifecycle.md +11 -0
  51. package/plugins/lisa-copilot/rules/reference/config-resolution.md +7 -1
  52. package/plugins/lisa-copilot/rules/reference/leaf-only-lifecycle.md +21 -0
  53. package/plugins/lisa-copilot/scripts/rollup-blocker-classification.mjs +592 -0
  54. package/plugins/lisa-copilot/skills/lisa-github-sync/SKILL.md +8 -2
  55. package/plugins/lisa-copilot/skills/lisa-jira-sync/SKILL.md +1 -1
  56. package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +1 -1
  57. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +31 -1
  58. package/plugins/lisa-copilot/skills/lisa-tracker-sync/SKILL.md +1 -1
  59. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-cursor/hooks/failure-signature-index.mjs +482 -0
  61. package/plugins/lisa-cursor/hooks/failure-signature-index.sh +26 -0
  62. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  63. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +7 -1
  64. package/plugins/lisa-cursor/rules/leaf-only-lifecycle-reference.mdc +21 -0
  65. package/plugins/lisa-cursor/rules/leaf-only-lifecycle.mdc +11 -0
  66. package/plugins/lisa-cursor/scripts/rollup-blocker-classification.mjs +592 -0
  67. package/plugins/lisa-cursor/skills/lisa-github-sync/SKILL.md +8 -2
  68. package/plugins/lisa-cursor/skills/lisa-jira-sync/SKILL.md +1 -1
  69. package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +1 -1
  70. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +31 -1
  71. package/plugins/lisa-cursor/skills/lisa-tracker-sync/SKILL.md +1 -1
  72. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-expo-agy/plugin.json +1 -1
  75. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  80. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  85. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  90. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  95. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-rails-agy/plugin.json +1 -1
  100. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  105. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  109. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  110. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  112. package/plugins/src/base/.claude-plugin/plugin.json +10 -1
  113. package/plugins/src/base/hooks/failure-signature-index.mjs +482 -0
  114. package/plugins/src/base/hooks/failure-signature-index.sh +26 -0
  115. package/plugins/src/base/rules/eager/leaf-only-lifecycle.md +11 -0
  116. package/plugins/src/base/rules/reference/config-resolution.md +7 -1
  117. package/plugins/src/base/rules/reference/leaf-only-lifecycle.md +21 -0
  118. package/plugins/src/base/scripts/rollup-blocker-classification.mjs +592 -0
  119. package/plugins/src/base/skills/lisa-github-sync/SKILL.md +8 -2
  120. package/plugins/src/base/skills/lisa-jira-sync/SKILL.md +1 -1
  121. package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +1 -1
  122. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +31 -1
  123. package/plugins/src/base/skills/lisa-tracker-sync/SKILL.md +1 -1
@@ -79,12 +79,33 @@ The middle two rungs are the same idea seen at two resolutions: a parent reaches
79
79
  Notes:
80
80
 
81
81
  - **Blocked dominates.** A single blocked leaf surfaces blocked/attention on the parent even if other leaves are progressing, so a human sees the parent needs attention.
82
+ - **Blocked must name its leaf and its class.** "Blocked" alone is a single bit, and it is lossy in the one dimension that decides what the operator does next. Two holds that render identically are not the same problem: one waits on an external event and may clear with nobody touching it; the other waits on a person rewriting an acceptance criterion and will never clear on its own. Rendered the same, the second class accumulates silently — measured on one Epic at two occurrences, 32 identical hold comments and six weeks, while the initiative was two-thirds complete both times (#3045). See **Classifying a hold** below.
82
83
  - **"Required" leaves.** Optional or won't-do children do not hold a parent open; only the leaves that must ship for the parent to be complete are counted toward the env-rollup check.
83
84
  - **Least-advanced env wins.** The parent reflects the env the whole required set has collectively reached — never an env ahead of its laggard leaf. Native closure (below) fires only when the resolved env is the production/terminal value, never at an intermediate env (`On Dev`/`On Stg`).
84
85
  - **Rollup is recursive.** An Epic rolls up from its Stories, each of which rolls up from its own leaves. Evaluate bottom-up: a Story reaches an env only when its leaves have all reached at least that env; an Epic reaches it only when its Stories have.
85
86
  - **Vendor support varies.** Apply the rollup state the vendor can express. Where a vendor has no native intermediate state, use the nearest configured role or a metadata/comment signal rather than forcing a non-existent status (PRD #522 non-goal: vendors need not expose identical states).
86
87
  - **The parent never carries `ready`.** `ready` is a *human* "this is buildable, claim it" signal and only ever lives on leaves. Rollup moves a parent between non-ready container states (in-progress / per-env / blocked / terminal); it never sets the parent to `ready`. A container found carrying `ready` is a leaf-only-invariant violation — recompute its rolled state from its children and apply that (see `repair-intake`).
87
88
 
89
+ ### Classifying a hold
90
+
91
+ `rollup-blocker-classification` (`plugins/src/base/scripts/rollup-blocker-classification.mjs`) is the single implementation. It takes the child graph the vendor reader already produced and returns, per held leaf, a class and the actor who must clear it:
92
+
93
+ | Class | Recorded signal on the leaf | Who must act | Clears on its own? |
94
+ |---|---|---|---|
95
+ | `spec-defect` | the `spec_defect` marker (`config-resolution` → Build markers) | a person rewrites the acceptance criteria | never |
96
+ | `human-input` | the `human_needed` marker | a person supplies the input — access, a credential, a product decision | no |
97
+ | `hard-blocker` | at least one **open** `is blocked by` link | nobody | yes, when that work closes |
98
+ | `unknown` | none of the above | a person says which kind it is, then records it | no |
99
+
100
+ Four properties are load-bearing:
101
+
102
+ 1. **Recorded signals only.** A class comes from a marker or a link somebody wrote, never from reading the item's prose. There is no heuristic to drift.
103
+ 2. **It never reclassifies.** Deciding an acceptance criterion is unbuildable is a human product call. The tool makes the distinction *recordable and visible*; it does not make it. `unknown` is the honest answer, and it names the person who must resolve it rather than guessing.
104
+ 3. **A transparent parent is not the blocker.** A held item whose own children include a held one is reported *through*: the rollup names the leaf at the bottom of the chain and the path to it (`#1495 -> #1515 -> #1547`), which is what turns a five-level descent into one read.
105
+ 4. **It fails rather than reporting all-clear.** An unreadable tracker, a container with no children, and children none of which could be read each return a refusal, not `not-blocked`. A rollup that examined nothing and reported clean would be the same defect one level up.
106
+
107
+ The report is **composed per class**: a class with nothing in it produces no text, so the summary and the per-item lines cannot disagree (the `#3101` shape). It also carries a fingerprint of `container + held refs + classes`, so a cycle whose verdict is unchanged has nothing new to post and stays quiet — the 32 identical hold comments were one verdict restated on a schedule.
108
+
88
109
  ### The rollup env states are the configured "done" map — multi-env capable
89
110
 
90
111
  The env rungs are whatever the project configures for `done` — which is **env-keyed** (`config-resolution` "Env-keyed `done`"): a `done` map keyed by environment (`dev`, `staging`, `production`), each leaf's env resolved from its merged PR's base branch. This rule does **not** hardcode a `dev → staging → prod` promotion chain as required — that is a project-specific deploy topology; the ladder is simply the ordered keys of the project's `done` map. A downstream project with dev/staging/prod environments rolls a parent up to the least-advanced env value its required leaves have collectively reached (an intermediate-env parent state, e.g. `On Stg`), and only to the production `done` value once every required leaf is at production. The rule stays generic and multi-env capable.
@@ -31,6 +31,17 @@ Evaluate over the env ladder `in-progress < dev < staging < production` (the ord
31
31
  3. Else any leaf has **started** (claimed/in review, or shipped while a sibling has not) → parent is **in-progress** (`claimed`).
32
32
  4. Else (leaves exist but none started) → parent unchanged.
33
33
 
34
+ **Blocked dominates — but it must say which leaf and which kind.** A parent that rolls up to `blocked` names the blocking leaf (with the path to it) and the **class** of the hold, derived by `rollup-blocker-classification` from recorded signals only:
35
+
36
+ | Class | Recorded signal on the leaf | Who must act |
37
+ |---|---|---|
38
+ | `spec-defect` | the `spec_defect` marker (human-applied only) | a person rewrites the acceptance criteria — nothing external will ever clear it |
39
+ | `human-input` | the `human_needed` marker | a person supplies the input (access, credential, product decision) |
40
+ | `hard-blocker` | an open `is blocked by` link | nobody — it clears when that work closes |
41
+ | `unknown` | none of the above | a person says which kind it is, and records it |
42
+
43
+ Never infer a class from prose, and never auto-apply `spec_defect`: judging a criterion unbuildable is a human call, and `unknown` is the honest answer. A rollup that examined nothing — unreadable tracker, no children, no readable child — **fails**; it never reports not-blocked.
44
+
34
45
  **Blocked dominates.** A parent reaches an env only once all required leaves have reached at least that env. Intermediate-env rollup (`On Dev`/`On Stg`) happens, but native closure fires only at production `done`. Optional/won't-do children do not hold a parent open. Rollup is recursive — bottom-up. The parent never carries `ready`; a container found in `ready` is reconciled by rolling it up from its children.
35
46
 
36
47
  ## Terminal native closure
@@ -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`); skip the comment if an identical one is already the most recent rollup comment.
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.