@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
@@ -462,11 +462,12 @@ Every lifecycle skill operates on a fixed set of **roles** (`ready`, `claimed`,
462
462
 
463
463
  #### Build markers (additive labels, not lifecycle roles)
464
464
 
465
- A **marker** is an additive label applied *alongside* a lifecycle role, not a state the item transitions *to*. Markers carry no rollup or transition semantics — they annotate an item that is already in some role. The build lifecycle defines one marker:
465
+ A **marker** is an additive label applied *alongside* a lifecycle role, not a state the item transitions *to*. Markers carry no rollup or transition semantics — they annotate an item that is already in some role, and the rollup reads them to say *which kind* of hold a `blocked` item is (`rollup-blocker-classification`). The build lifecycle defines two markers:
466
466
 
467
467
  | Marker | What it means | JIRA default | GitHub/Linear default |
468
468
  |---|---|---|---|
469
469
  | `human_needed` | Applied with `blocked` when — **after the agent has drafted every authorable missing section via the `pre-flight-autofill` procedure** — the block still requires a human to confirm the drafted assumptions or supply something no agent can invent: real missing credentials, access/permissions, or an irreducible product/scoping decision. | `Human Needed` (label) | `human-needed` (label) |
470
+ | `spec_defect` | Applied with `blocked` when the thing holding the item is the item's **own specification** — an acceptance criterion that cannot be satisfied as written, a field naming an environment or surface the project does not have, a validation journey demanding something the project cannot produce. **Human-applied only.** No agent sets it, because judging a criterion unbuildable is a product call, and the agent that wrote a bad criterion is the least likely to know it did. | `Spec Defect` (label) | `blocked:spec-defect` (label) |
470
471
 
471
472
  Markers are labels on **every** vendor, Linear included — that is the one place the Linear build lane still touches `linear.labels`.
472
473
 
@@ -475,9 +476,14 @@ Resolution keys:
475
476
  - JIRA: `jira.labels.human_needed` (default `Human Needed`). Applied as a JIRA **label** — not a workflow status — because an item holds exactly one status but any number of labels. The `blocked` status still drives the lifecycle; `human_needed` is the additive marker on top of it.
476
477
  - GitHub: `github.labels.build.human_needed` (default `human-needed`). Added next to the `blocked` label.
477
478
  - Linear: `linear.labels.build.human_needed` (default `human-needed`). Applied as a Linear **label**, for the same reason as JIRA — an Issue holds exactly one workflow state but any number of labels, so an additive marker cannot be a state. The `blocked` **state** still drives the lifecycle; `human_needed` is the marker on top of it. This is the only build-lane key left in `linear.labels`.
479
+ - `spec_defect` resolves the same way on each vendor — `jira.labels.spec_defect` (default `Spec Defect`), `github.labels.build.spec_defect` and `linear.labels.build.spec_defect` (default `blocked:spec-defect`).
478
480
 
479
481
  **When to apply it.** Apply `human_needed` only when a human must act before the item can move — the pre-flight gate failures that bounce a ticket back to its reporter are exactly this case, but **only after** the agent has run the `pre-flight-autofill` draft-then-block procedure (drafting the authorable gaps — acceptance criteria, validation journey, repository, relationship search, etc. — into the ticket as labeled assumptions). What then remains for the human is to **confirm those assumptions** or supply a genuinely human-only input (real missing credentials, an irreducible product/scoping decision). The marker means "a human must confirm or decide," not "a human must author from scratch."
480
482
 
483
+ **Why `spec_defect` is separate from `human_needed`.** Both name a hold a person must clear, but they are different asks and the rollup must not merge them. `human_needed` waits on an input from *outside* the item — a credential, an access grant, a product decision — and the item's own text is fine. `spec_defect` waits on the item's text being *rewritten*, and no external event will ever supply that. An item carrying `spec_defect` will sit forever until somebody edits it; that is exactly the class that accumulated silently when the rollup rendered both as plain `blocked` (issue #3045: 32 identical hold comments over six weeks on one Epic). Where both markers are present, `spec_defect` wins — it is the more specific record, and the only one carrying a judgment nothing else can supply.
484
+
485
+ **Nothing infers `spec_defect`.** It is never derived from prose, a title, or a failed gate. A `blocked` item carrying neither marker and no open `is blocked by` link classifies as **`unknown`**, and the rollup says so and asks a person to decide — an honest `unknown` beats a confident wrong answer, and auto-reclassifying would turn a human product call into a guess.
486
+
481
487
  **When NOT to apply it.** Do **not** apply `human_needed` to a block that an automated cycle can clear on its own: a block whose `is blocked by` dependency is another tracked ticket that will build and close (for example a `repair-intake`-filed build-ready fix ticket for an unmergeable PR or a failed deploy), or any block waiting only on a retry. Those self-heal; flagging them for a human is noise. If such an item already carries a stale `human_needed` marker, clear it when the block becomes auto-recoverable.
482
488
 
483
489
  The marker is **best-effort and additive**: it never replaces the `blocked` role and never gates a transition. A project that does not define the label name inherits the defaults above; a project that does not want the marker at all can leave the label absent in its tracker, in which case the add is a no-op the agent records and moves on.
@@ -74,12 +74,33 @@ The middle two rungs are the same idea seen at two resolutions: a parent reaches
74
74
  Notes:
75
75
 
76
76
  - **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.
77
+ - **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.
77
78
  - **"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.
78
79
  - **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`).
79
80
  - **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.
80
81
  - **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).
81
82
  - **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`).
82
83
 
84
+ ### Classifying a hold
85
+
86
+ `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:
87
+
88
+ | Class | Recorded signal on the leaf | Who must act | Clears on its own? |
89
+ |---|---|---|---|
90
+ | `spec-defect` | the `spec_defect` marker (`config-resolution` → Build markers) | a person rewrites the acceptance criteria | never |
91
+ | `human-input` | the `human_needed` marker | a person supplies the input — access, a credential, a product decision | no |
92
+ | `hard-blocker` | at least one **open** `is blocked by` link | nobody | yes, when that work closes |
93
+ | `unknown` | none of the above | a person says which kind it is, then records it | no |
94
+
95
+ Four properties are load-bearing:
96
+
97
+ 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.
98
+ 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.
99
+ 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.
100
+ 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.
101
+
102
+ 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.
103
+
83
104
  ### The rollup env states are the configured "done" map — multi-env capable
84
105
 
85
106
  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.
@@ -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
+ }