@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.
- package/all/copy-contents/.gitattributes +4 -0
- package/dist/core/learnings-merge-driver.d.ts.map +1 -1
- package/dist/core/learnings-merge-driver.js +15 -0
- package/dist/core/learnings-merge-driver.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +30 -9
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- 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 +2 -1
- package/plugins/lisa/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa/.codex-plugin/hooks.json +9 -0
- 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/failure-signature-index.mjs +482 -0
- package/plugins/lisa/hooks/failure-signature-index.sh +26 -0
- 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/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 +10 -1
- package/plugins/lisa-copilot/hooks/failure-signature-index.mjs +482 -0
- package/plugins/lisa-copilot/hooks/failure-signature-index.sh +26 -0
- 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/failure-signature-index.mjs +482 -0
- package/plugins/lisa-cursor/hooks/failure-signature-index.sh +26 -0
- package/plugins/lisa-cursor/hooks/hooks.json +4 -0
- 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/.claude-plugin/plugin.json +10 -1
- package/plugins/src/base/hooks/failure-signature-index.mjs +482 -0
- package/plugins/src/base/hooks/failure-signature-index.sh +26 -0
- 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
|
@@ -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
|
|
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
|
+
}
|