diff --git a/claude-plugins/kampus-pipeline/README.md b/claude-plugins/kampus-pipeline/README.md index 2b9f1c03..a79c962e 100644 --- a/claude-plugins/kampus-pipeline/README.md +++ b/claude-plugins/kampus-pipeline/README.md @@ -98,7 +98,7 @@ handoff is a durable GitHub artifact, you can stop after any stage and resume la | `heal-ci` | Classify a red CI run into flake-vs-defect and emit one routed action — rerun a known transient once, or file a defect via `report`. | | `adr` | Record an architecture decision (Context / Decision / Consequences) into `.decisions/NNNN-slug.md` and the index, following supersede rules. | | `deslop-comments` | Ruthlessly cut comments that bury the code without earning their place — keeping load-bearing notes, collapsing duplicated "why" to ADR pointers. | -| `doctor` | Preflight a repo against the pipeline prerequisites (gh auth + scope, the 15 required labels, repo resolution, a CI signal, the two [repo-admin merge prerequisites](#repo-admin-prerequisites), npm deps) and print a tiered pass/fail checklist with the exact fix command for each gap. | +| `doctor` | Preflight a repo against the pipeline prerequisites (gh auth + scope, the required label vocabulary, repo resolution, a CI signal, the two [repo-admin merge prerequisites](#repo-admin-prerequisites), npm deps) and print a tiered pass/fail checklist with the exact fix command for each gap. | | `wayfinder` | The ideation-layer front door, upstream of the pipeline: chart a fuzzy destination into a living `wayfinder:map` issue, then work its open frontier of investigation/decision tickets — recording answers, graduating cleared fog, surfacing founder-decision-forks — until a concrete plan is ready for `triage` / `plan-epic` (epic #2421). | ## Install @@ -208,12 +208,14 @@ the superseded file's status edit), never a regenerated index. **Start with `doctor`.** Before the first run in a freshly-adopted repo, run the `doctor` skill (`claude-plugins/kampus-pipeline/skills/doctor/doctor.sh`) — it asserts the prerequisites in one pass (gh -auth + the `project` scope, the 15 required `status:*`/`type:*`/`p*` labels, repo +auth + the `project` scope, the required `status:*`/`type:*`/`p*` label vocabulary plus the +standing lanes and the platform discriminator `triage` homes into, repo resolution, a CI signal, the two [repo-admin merge prerequisites](#repo-admin-prerequisites), and the `@kampus/*` npm deps) and prints a tiered pass/fail checklist with the exact `gh label create …` / `gh auth refresh …` fix command for each -gap. It turns "did I wire this up right?" into one checkable command instead of a first run -that fails deep inside a `gh api` call. +gap. It turns "did I wire this up right?" into one checkable command instead of a pipeline +that runs green over a repo whose label scoping matches nothing (a missing label does not +error the first write — `POST …/labels` auto-creates it; the damage lands on the read side). To re-prove a skill's gate runs outside phoenix (the repeatable procedure behind #368 / #432), exercise it in a throwaway non-phoenix git repo — **not** phoenix diff --git a/claude-plugins/kampus-pipeline/skills/doctor/SKILL.md b/claude-plugins/kampus-pipeline/skills/doctor/SKILL.md index ca77f22c..003a8232 100644 --- a/claude-plugins/kampus-pipeline/skills/doctor/SKILL.md +++ b/claude-plugins/kampus-pipeline/skills/doctor/SKILL.md @@ -12,12 +12,15 @@ the repo — label creation, auth scopes — which is theirs to authorize). The pipeline is repo-agnostic (ADR [0062](https://github.com/kamp-us/phoenix/blob/main/.decisions/0062-repo-as-config-plugin.md)): an adopter installs the plugin and it operates on *their* issues. Several skills hard-depend on environment the host repo -must already provide — labels, `gh` auth, a CI signal — but nothing verifies that -up front, so a first run otherwise fails deep inside a `gh api` call (e.g. labeling -with a `status:needs-triage` that doesn't exist) instead of failing fast with a -clear "this repo isn't set up" message. Worst case it half-applies: an issue gets -created, then the label step errors, leaving an untracked issue outside the queue. -This skill closes that window. +must already provide — labels, `gh` auth, a CI signal, a home to file work into — +but nothing verifies that up front. This skill closes that window. + +A missing label does **not** announce itself: `POST /repos/{owner}/{repo}/issues/{n}/labels` +**auto-creates** the label it is handed (measured 2026-07-26 — HTTP 200, the label +materializes repo-wide at GitHub's default grey with no description). So `report` succeeds +and quietly mints an off-taxonomy label, and the failure surfaces later on the **read** +side, where `?labels=status:triaged` and every guard's label scoping match nothing and the +pipeline runs protecting nothing (#4300; the silent no-op ADR [0092](https://github.com/kamp-us/phoenix/blob/main/.decisions/0092-gates-fail-closed-on-zero-scope.md) exists to kill). ## Running it @@ -39,17 +42,19 @@ Do not run the fix commands yourself. |---|---|---| | **1 — load-bearing** | `gh` authenticated | every call is `gh api`; without auth nothing runs | | | `gh` token has the `project` scope | the org's Projects-classic integration requires it (the reason the suite is REST-only, never GraphQL) | -| | the 15 required labels exist (`status:*` spine, `type:*` class, `p*` priority) | the intake skills key on them — `report` applies `status:needs-triage`, `write-code` picks `status:triaged`, etc. A missing one fails a run mid-`gh api`. | +| | the required labels exist (`status:*` spine, `type:*` class, `p*` priority, the two standing lanes, the platform discriminator) | the intake skills key on them — `report` applies `status:needs-triage`, `write-code` picks `status:triaged`, `triage` exempts to `wayfinder:backlog` / `axis:pipeline-hardening`. A missing one makes every scan match nothing. | +| | that set still covers the shared vocabulary | the create-commands table carries colours the source can't; this asserts it hasn't fallen behind the source (below) | | **2 — gating** | target repo resolves | a skill can't target a repo it can't name | | | at least one CI workflow exists | `ship-it` Step 3 gates on checks-green; with zero checks that gate passes vacuously | | | repo-level `allow_auto_merge` is enabled | `ship-it`'s `gh pr merge --auto` cannot arm without it — the enqueue fails with `Auto merge is not allowed for this repository (enablePullRequestAutoMerge)` (ADR [0132](https://github.com/kamp-us/phoenix/blob/main/.decisions/0132-merge-queue-for-base-freshness.md) §Addendum §1) | | | a `merge_queue` rule applies to the **resolved** default branch | `ship-it` deliberately passes no merge-method flag because the queue owns it (ADR 0132 §Consequences); with no queue there is no method and the merge never lands | | **3 — optional** | `@kampus/pipeline-cli` resolves on npm | `adr` / `review-plan` reach for its `decisions-index` / `epic-ledger` tools via `pnpm dlx` as the published fallback (epic #994); absent → those stages degrade | | | a `run-evidence` producer is defined | `ship-it` guard 2 runs strict when present, and **degrades to checks-green when absent** (ADR [0086](https://github.com/kamp-us/phoenix/blob/main/.decisions/0086-ship-it-foreign-repo-degradation.md)) — so this is informational, not a failure | +| | the ideation-layer labels exist (`wayfinder:map`) | `wayfinder` keys its map issue on it; it is an ideation front door, not a first-run prerequisite | +| | at least one **open milestone** exists | `triage` Step 6 homes an issue into an arc/campaign milestone and **never creates one** (ADR [0072](https://github.com/kamp-us/phoenix/blob/main/.decisions/0072-milestones-encode-strategic-sequencing.md) §3). With none, triage can still exempt to a standing lane but cannot home into an arc — a WARN, never a fail, because a freshly-adopted repo legitimately has none yet | -The load-bearing pair is **auth + labels** (Tier 1): get those wrong and the very -first `report` → `triage` round trips into a raw GitHub API error. The Tier-2 checks -keep `ship-it` honest; Tier-3 only ever downgrades a single stage. +The load-bearing pair is **auth + labels** (Tier 1). The Tier-2 checks keep `ship-it` +honest; Tier-3 only ever downgrades a single stage. The last two Tier-2 checks are the **repo-admin governance pair**, and they are the one prerequisite class this checklist can only *report*: `allow_auto_merge` and the @@ -58,6 +63,12 @@ script can create** — not doctor, not any stand-up script. They also fail *las enqueue, after an adopter has already built, reviewed, approved, and armed a PR. Reading them up front is what turns that finish-line halt into a first-run checklist line. +Every check reports **three** states, never two: present, absent, and **UNDETERMINED** — +the read itself did not succeed. A failed label read is not "no labels", and no open +milestone found is not "the milestone read worked". Collapsing those is what lets a +checker pass while the thing it checks is absent, so doctor names the unknown as unknown +and still fails closed on it. + ## Conventions - **Read-only.** The helper never mutates the repo — it reads state and prints fix @@ -70,7 +81,10 @@ them up front is what turns that finish-line halt into a first-run checklist lin a different fact. `gh api` prints its error body to stdout **without** applying `--jq`, so every read keeps gh's exit status and admits only the literal values it expects (§ZS / ADR [0092](https://github.com/kamp-us/phoenix/blob/main/.decisions/0092-gates-fail-closed-on-zero-scope.md); the defect class of #4223). -- The required-label set in `doctor.sh` is the canonical one (name + color + - description). If the pipeline's label dimensions change (see - [`../gh-issue-intake-formats.md`](../gh-issue-intake-formats.md) §Pipeline labels), update the `REQUIRED_LABELS` - table there so a fresh adopter gets the current set. +- **`doctor.sh`'s `LABELS` table is a presentation mirror, not the source.** The required + set is single-sourced in `packages/pipeline-cli/src/tools/vocabulary-preflight/vocabulary.ts`, + which assembles it from the constants the guards themselves scope on; the table only adds the + colour + description a `gh label create` needs. Doctor reads the source through + `pipeline-cli vocabulary-preflight labels` and **reds if the table has fallen behind it**, so + the two cannot silently diverge — the drift that shipped as #4300. Change the vocabulary at + the source, then add the matching row here. diff --git a/claude-plugins/kampus-pipeline/skills/doctor/doctor.sh b/claude-plugins/kampus-pipeline/skills/doctor/doctor.sh index 9c156634..5e448b45 100755 --- a/claude-plugins/kampus-pipeline/skills/doctor/doctor.sh +++ b/claude-plugins/kampus-pipeline/skills/doctor/doctor.sh @@ -5,12 +5,24 @@ # `gh`/`npm` and writes nothing — every fix is a command it prints, never runs. # # Tiers (a foreign-repo adopter reads top-down): -# 1 load-bearing — gh auth + required labels. Without these the first -# report/triage run fails deep inside a `gh api` call. +# 1 load-bearing — gh auth + the required label vocabulary. # 2 gating — repo resolution, a CI signal ship-it can gate on, and the # merge-queue governance ship-it's enqueue silently depends on. -# 3 optional — `pnpm dlx` packages + the run-evidence producer; their -# absence degrades a stage, it does not break the pipeline. +# 3 optional — `pnpm dlx` packages, the run-evidence producer, the +# ideation-layer labels, and an open milestone; their absence +# degrades a stage, it does not break the pipeline. +# +# Why the labels are Tier 1 is NOT "the first write errors" — that was measured and is +# false. `POST /repos/{owner}/{repo}/issues/{n}/labels` AUTO-CREATES a missing label +# (observed 2026-07-26: HTTP 200, label materialized repo-wide at GitHub's default grey +# `ededed` with a null description). So `report` silently succeeds and mints an +# off-taxonomy label; the damage lands on the READ side, where `?labels=status:triaged` +# and every guard's label scoping then match nothing and the pipeline runs protecting +# nothing — the silent no-op ADR 0092 exists to kill (#4300). +# +# Every check below reports THREE states, never two: present, absent, and UNDETERMINED +# (the read itself did not succeed). A read that fails must never resolve to "fine" — +# that collapse is what let a green doctor sit on top of an unusable repo. # # Exit 0 only when every Tier-1 and Tier-2 check passes; Tier-3 gaps WARN, never fail. set -uo pipefail @@ -51,49 +63,134 @@ else fix "gh auth refresh -s project" fi -# 1c. required labels exist. The canonical set the intake skills key on (status:* -# spine, type:* class, p* priority), as NAME|HEX|DESCRIPTION rows fed to the -# loop from a heredoc (a `|`-delimited heredoc, not $(cat <<…) — the latter -# mis-parses under bash 3.2, the macOS default). +# 1c. the label vocabulary exists in the target repo, as NAME|TIER|HEX|DESCRIPTION rows fed +# to the loop from a heredoc (a `|`-delimited heredoc, not $(cat <<…) — the latter +# mis-parses under bash 3.2, the macOS default). TIER 1 rows are required and fail the +# run; TIER 3 rows are ideation-layer and only ever WARN, reported down in Tier 3. +# +# This table is a PRESENTATION mirror (it carries the colour + description the create +# commands need, which the source has no room for), NOT a second required-set: check 1d +# asserts it covers the shared vocabulary in `packages/pipeline-cli/src/tools/ +# vocabulary-preflight/vocabulary.ts`, which assembles the set from the constants the +# guards themselves scope on. Add a Tier-1 row here only alongside that source (#4272); +# the homing labels it must cover are ruled in ../triage/SKILL.md Step 6. +# +# Read the universe with the outcome SEPARATED from the content: a failed read leaves +# the same empty string a genuinely label-less repo does, and scoring absence off it +# would name every required label "missing" on what is really an auth or network fault. EXISTING="" +labels_read=no-repo if [ -n "${REPO:-}" ]; then - EXISTING=$(gh api "repos/$REPO/labels?per_page=100" --jq '.[].name' 2>/dev/null) + if EXISTING=$(gh api "repos/$REPO/labels?per_page=100" --jq '.[].name' 2>/dev/null); then + labels_read=ok + else + labels_read=failed + fi fi -missing=0 -while IFS='|' read -r name color desc; do +required_total=0 +missing_required=0 +required_fixes="" +REQUIRED_NAMES="" +optional_missing=0 +optional_fixes="" +while IFS='|' read -r name tier color desc; do [ -z "$name" ] && continue - if printf '%s\n' "$EXISTING" | grep -Fxq "$name"; then - continue + if [ "$tier" = "1" ]; then + required_total=$((required_total + 1)) + REQUIRED_NAMES="$REQUIRED_NAMES$name +" + fi + [ "$labels_read" = "ok" ] || continue # UNDETERMINED: never score absence off a read that failed + printf '%s\n' "$EXISTING" | grep -Fxq "$name" && continue + CREATE="gh label create \"$name\" --repo \"$REPO\" --color \"$color\" --description \"$desc\"" + if [ "$tier" = "1" ]; then + missing_required=$((missing_required + 1)) + required_fixes="$required_fixes$CREATE +" + else + optional_missing=$((optional_missing + 1)) + optional_fixes="$optional_fixes$CREATE +" fi - missing=$((missing + 1)) - fix "gh label create \"$name\" --repo \"$REPO\" --color \"$color\" --description \"$desc\"" done <<'LABELS' -status:needs-triage|fbca04|Filed, awaiting triage classification -status:needs-info|fbca04|Human-filed; awaiting answers before triage -status:planned|fbca04|plan-epic child: planned, not yet verified by review-plan, not pickable -status:triaged|fbca04|Triage signed off; ready for write-code to pick -status:planning|fbca04|Epic-lock held: a plan-epic/review-plan run is mutating this epic's children (ADR 0059) -status:awaiting-release|5319e7|Post-merge release-queue marker: deployed dark, awaiting a human flag flip (ADR 0083). -type:bug|1d76db|Behavior diverges from intent -type:chore|1d76db|No behavior change -type:decision|1d76db|One question; output is a recorded choice -type:epic|1d76db|Too big for one PR; spawns children -type:feature|1d76db|New capability, directly implementable -type:investigation|1d76db|Unknown; output is knowledge -p0|b60205|Highest priority -p1|d93f0b|Medium priority -p2|e99695|Lowest priority +status:needs-triage|1|fbca04|Filed, awaiting triage classification +status:needs-info|1|fbca04|Human-filed; awaiting answers before triage +status:planned|1|fbca04|plan-epic child: planned, not yet verified by review-plan, not pickable +status:triaged|1|fbca04|Triage signed off; ready for write-code to pick +status:planning|1|fbca04|Epic-lock held: a plan-epic/review-plan run is mutating this epic's children (ADR 0059) +status:awaiting-release|1|5319e7|Post-merge release-queue marker: deployed dark, awaiting a human flag flip (ADR 0083). +type:bug|1|1d76db|Behavior diverges from intent +type:chore|1|1d76db|No behavior change +type:decision|1|1d76db|One question; output is a recorded choice +type:epic|1|1d76db|Too big for one PR; spawns children +type:feature|1|1d76db|New capability, directly implementable +type:investigation|1|1d76db|Unknown; output is knowledge +p0|1|b60205|Highest priority +p1|1|d93f0b|Medium priority +p2|1|e99695|Lowest priority +wayfinder:backlog|1|8250df|Standing lane: a destination queued for a wayfinding chart (triage's standing-lane exemption) +axis:pipeline-hardening|1|5319e7|Standing lane: the cross-cutting pipeline-hardening axis (triage's standing-lane exemption) +area:infra|1|0e8a16|Platform/infra discriminator the lane tool scopes on +wayfinder:map|3|8250df|Ideation-layer map issue — the wayfinder chart front door (issue-shape marker, not a type) LABELS -if [ "$missing" -eq 0 ] && [ -n "$EXISTING" ]; then - say "$PASS" "all 15 required pipeline labels exist" -elif [ -z "$EXISTING" ]; then - say "$FAIL" "could not read repo labels (repo unresolved or gh unauthenticated) — run the fixes above first" +if [ "$labels_read" = "no-repo" ]; then + say "$FAIL" "required labels UNDETERMINED — the target repo never resolved, so the label universe was never read (unknown, NOT absent)" + fix "set CLAUDE_PIPELINE_REPO=owner/name, or run inside the target git repo, then re-run" + fails=$((fails + 1)) +elif [ "$labels_read" = "failed" ]; then + say "$FAIL" "required labels UNDETERMINED — the label read on $REPO FAILED (unknown, NOT absent; likely auth, permissions, or network)" + fix "gh auth status && gh api \"repos/$REPO/labels?per_page=100\" # make the read succeed, then re-run" fails=$((fails + 1)) +elif [ "$missing_required" -eq 0 ]; then + say "$PASS" "all $required_total required pipeline labels exist" else - say "$FAIL" "$missing required pipeline label(s) missing (create commands above)" + say "$FAIL" "$missing_required of $required_total required pipeline label(s) absent" + printf '%s' "$required_fixes" | while IFS= read -r line; do + [ -n "$line" ] && fix "$line" + done + fails=$((fails + 1)) +fi + +# 1d. doctor's Tier-1 rows still cover the shared vocabulary. The set the guards scope on +# lives in ONE place and is assembled from their own constants; this asserts the table +# above has not fallen behind it — the drift that let doctor green a repo where triage +# had no non-kill home (#4300). Reads it through the CLI shim, the only seam a bash +# preflight has onto a TS constant. +PCLI="$(cd "$(dirname "$0")/../../bin" 2>/dev/null && pwd)/pipeline-cli" +SHARED="" +shared_read=unresolved +if [ -x "$PCLI" ]; then + if SHARED=$("$PCLI" vocabulary-preflight labels 2>/dev/null) && [ -n "$SHARED" ]; then + shared_read=ok + fi +fi + +shared_total=0 +drifted="" +if [ "$shared_read" = "ok" ]; then + while IFS= read -r want; do + [ -z "$want" ] && continue + shared_total=$((shared_total + 1)) + printf '%s\n' "$REQUIRED_NAMES" | grep -Fxq "$want" && continue + drifted="$drifted $want" + done </dev/null); then + ms_read=ok + fi +fi + +if [ "$ms_read" != "ok" ]; then + say "$WARN" "open-milestone existence UNDETERMINED — the read did not succeed (unknown, NOT 'no milestone')" + fix "gh api \"repos/$REPO/milestones?state=open\" # make the read succeed, then re-run" +elif [ "${MILESTONES:-0}" -gt 0 ]; then + say "$PASS" "$MILESTONES open milestone(s) — triage can home an issue into an arc/campaign" +else + say "$WARN" "no open milestone — triage can exempt an issue to a standing lane, but cannot home one into an arc/campaign" + fix "a human creates the first arc/campaign milestone (triage never does): gh api -X POST \"repos/$REPO/milestones\" -f title=\"\"" +fi + hdr "Verdict" if [ "$fails" -eq 0 ]; then printf ' %s pipeline-ready: all Tier-1 and Tier-2 checks passed.\n' "$PASS" diff --git a/packages/pipeline-cli/src/tools/vocabulary-preflight/command.ts b/packages/pipeline-cli/src/tools/vocabulary-preflight/command.ts index 729fc2bc..cea43745 100644 --- a/packages/pipeline-cli/src/tools/vocabulary-preflight/command.ts +++ b/packages/pipeline-cli/src/tools/vocabulary-preflight/command.ts @@ -8,6 +8,7 @@ * * pipeline-cli vocabulary-preflight check # the resolved target repo (ADR 0062 §1) * pipeline-cli vocabulary-preflight check --root # point at a specific repo root + * pipeline-cli vocabulary-preflight labels # print the required set, one per line * * The pure decision lives in `vocabulary.ts` (unit-tested); the `gh api` label read and the * `ROADMAP.md` read in `github.ts`/`gate.ts`. `RepoLabelsLive` is baked in with @@ -16,11 +17,12 @@ * * Exit-code contract: 0 = every prerequisite met, any non-zero = failure. */ -import {Effect, FileSystem, Option, Path} from "effect"; +import {Console, Effect, FileSystem, Option, Path} from "effect"; import {Command, Flag} from "effect/unstable/cli"; import {onCheckFailed} from "../../gate-fail.ts"; import {checkVocabulary} from "./gate.ts"; import {RepoLabelsLive} from "./github.ts"; +import {REQUIRED_LABELS, renderLabelList} from "./vocabulary.ts"; // Repo-root markers, in priority order: a pnpm workspace, then a VCS dir. const ROOT_MARKERS = ["pnpm-workspace.yaml", ".git"] as const; @@ -68,8 +70,22 @@ const check = Command.make( ), ); +// Reads no repo state, so it is the one subcommand that answers in a repo the labels are missing +// from — which is the only situation its caller runs in. +const labels = Command.make( + "labels", + {}, + Effect.fn(function* () { + yield* Console.log(renderLabelList(REQUIRED_LABELS)); + }), +).pipe( + Command.withDescription( + "Print the required label vocabulary, one per line (the seam `doctor.sh` reads — #4300)", + ), +); + export const vocabularyPreflightCommand = Command.make("vocabulary-preflight").pipe( - Command.withSubcommands([check]), + Command.withSubcommands([check, labels]), Command.withDescription( "Fail-closed preflight: the required label vocabulary + ROADMAP.md exist in the target repo (#4272)", ), diff --git a/packages/pipeline-cli/src/tools/vocabulary-preflight/vocabulary.ts b/packages/pipeline-cli/src/tools/vocabulary-preflight/vocabulary.ts index 874b05dc..44e8de08 100644 --- a/packages/pipeline-cli/src/tools/vocabulary-preflight/vocabulary.ts +++ b/packages/pipeline-cli/src/tools/vocabulary-preflight/vocabulary.ts @@ -49,6 +49,14 @@ export const REQUIRED_LABELS: ReadonlyArray = [ ...new Set(LABEL_GROUPS.flatMap((group) => group.labels)), ]; +/** + * The required set as a newline-delimited list — the machine-readable seam a non-Node consumer + * reads this module through. `doctor.sh` is the caller (#4300): a bash preflight cannot import a + * TS constant, so without this it would have to retype the set, which is precisely the parallel + * hand-written list that drifted. + */ +export const renderLabelList = (labels: ReadonlyArray): string => labels.join("\n"); + /** `ROADMAP.md` as the preflight finds it: absent, or present with the rows it parsed. */ export type RoadmapSurface = | {readonly _tag: "absent"; readonly path: string} diff --git a/packages/pipeline-cli/src/tools/vocabulary-preflight/vocabulary.unit.test.ts b/packages/pipeline-cli/src/tools/vocabulary-preflight/vocabulary.unit.test.ts index 47a7bbd4..8ac93d77 100644 --- a/packages/pipeline-cli/src/tools/vocabulary-preflight/vocabulary.unit.test.ts +++ b/packages/pipeline-cli/src/tools/vocabulary-preflight/vocabulary.unit.test.ts @@ -14,6 +14,7 @@ import { judgeRoadmap, REQUIRED_LABELS, type RoadmapSurface, + renderLabelList, renderReport, } from "./vocabulary.ts"; @@ -134,3 +135,18 @@ describe("renderReport", () => { expect(report).toContain("not\nconfiguration"); }); }); + +describe("renderLabelList — the seam doctor.sh reads (#4300)", () => { + it("emits one label per line and nothing else, so a `while read` consumer needs no parser", () => { + const lines = renderLabelList(REQUIRED_LABELS).split("\n"); + expect(lines).toEqual([...REQUIRED_LABELS]); + }); + + // The list is doctor's Tier-1 floor, so an empty render would let doctor's drift check pass + // against nothing — the vacuous-scope shape of ADR 0092, one surface over. + it("is non-empty and carries the standing lanes doctor was missing", () => { + const lines = renderLabelList(REQUIRED_LABELS).split("\n"); + expect(lines.length).toBeGreaterThan(0); + for (const lane of EXEMPT_LABELS) expect(lines).toContain(lane); + }); +});