From ec1a045ed2ad16f96a8f25b2519bd6c3365e7d8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:45:54 +0000 Subject: [PATCH] Version Packages --- .../a-ladder-with-more-than-one-rung.md | 15 ----- .../a-window-sized-by-what-a-pr-carries.md | 12 ---- .changeset/one-claimant-for-three-keys.md | 17 ----- .changeset/the-arrow-is-the-signal.md | 18 ----- .changeset/the-drills-hand-back-the-keys.md | 16 ----- .../threads-read-from-the-newest-end.md | 14 ---- .../three-neutrals-spent-on-something.md | 17 ----- .changeset/two-marks-pointing-the-same-way.md | 17 ----- packages/gh-cockpit/CHANGELOG.md | 55 ++++++++++++++++ packages/gh-cockpit/package.json | 8 +-- packages/gh-ink/CHANGELOG.md | 66 +++++++++++++++++++ packages/gh-ink/package.json | 6 +- packages/gh-pr-comments/CHANGELOG.md | 14 ++++ packages/gh-pr-comments/package.json | 6 +- packages/gh-pr-health/CHANGELOG.md | 14 ++++ packages/gh-pr-health/package.json | 6 +- packages/gh-webhook-replay/CHANGELOG.md | 9 +++ packages/gh-webhook-replay/package.json | 4 +- packages/gh-workflow/CHANGELOG.md | 27 ++++++++ packages/gh-workflow/package.json | 4 +- packages/gh/CHANGELOG.md | 36 ++++++++++ packages/gh/package.json | 2 +- 22 files changed, 239 insertions(+), 144 deletions(-) delete mode 100644 .changeset/a-ladder-with-more-than-one-rung.md delete mode 100644 .changeset/a-window-sized-by-what-a-pr-carries.md delete mode 100644 .changeset/one-claimant-for-three-keys.md delete mode 100644 .changeset/the-arrow-is-the-signal.md delete mode 100644 .changeset/the-drills-hand-back-the-keys.md delete mode 100644 .changeset/threads-read-from-the-newest-end.md delete mode 100644 .changeset/three-neutrals-spent-on-something.md delete mode 100644 .changeset/two-marks-pointing-the-same-way.md diff --git a/.changeset/a-ladder-with-more-than-one-rung.md b/.changeset/a-ladder-with-more-than-one-rung.md deleted file mode 100644 index 5cf9cca..0000000 --- a/.changeset/a-ladder-with-more-than-one-rung.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@kud/gh-cockpit": minor ---- - -The PR summary line degrades by dropping the least valuable cell rather than the only elastic one, so a narrow terminal no longer wraps a line the module says never wraps. - -The header has always claimed "the numbers are never truncated and the line never wraps". The first half held; the second did not. The ladder was **one rung** — the head branch went, and everything else stayed — so there was no step at all between fitting and wrapping: once `kud · opened 3 months ago` exceeded the width on its own, nothing could give and the line wrapped. - -The ranking, most expendable first, which is now the order cells are dropped: the **author**, because it is `kud` on very nearly every row of a solo cockpit; **`opened Nd ago`**, because staleness is the inbox's question and by the time you have drilled in you have already decided to look; then the **head branch**; and `→ base` is kept longest, because since the previous release it is drawn only when it is _not_ the repo default — so its presence already means it is the notable fact on the line. - -That inverts what the old single rung sacrificed. It gave up the branch name and kept both provenance cells, which is backwards: it protected the two facts you can most afford to lose. - -The size, file count and draft marker are never dropped. The numbers are never truncated, and a draft changes the meaning of the whole panel below the line. - -`trail` on the returned `Summary` splits into `author` and `opened`, since dropping them independently is the whole point. diff --git a/.changeset/a-window-sized-by-what-a-pr-carries.md b/.changeset/a-window-sized-by-what-a-pr-carries.md deleted file mode 100644 index ad08edf..0000000 --- a/.changeset/a-window-sized-by-what-a-pr-carries.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@kud/gh": minor -"@kud/gh-workflow": patch ---- - -The inbox windows `reviewThreads` at `last: 20` and selects `totalCount` beside it, taking the whole query from 114 points to 54. - -The window was `first: 50` because fifty is a round number. It is also the dominant cost on every PR-bearing source, because it multiplies beneath five searches at once — the same multiplication the `myPRs` cap and the `minimal` shape were both introduced to fight, left untouched in the one selection that spends the most. Measured 2026-09-09 on a live account, the whole document, same selections that ship: a window of 50 cost 114 points at 27,230 nodes, 20 cost 54, and 10 cost 34. The anchor is free — `first: 20` and `last: 20` both measure 8 points on `myPRs`, so taking the window from the other end costs nothing. Against 5,000 points an hour that is 92 loads where there were 44, on a query a naive poll has previously killed the board by refreshing. - -The data never justified fifty. Across 13 pull-request rows on that account the deepest carried **two** review threads and the median carried none — nothing above ten on either PR source. Twenty keeps ten times it — the step down to ten buys three more points and spends the only headroom there is against the next pull request that gets busy, and `kud/ambre#69` carried 67 threads while it was open. - -`totalCount` is what makes the narrower window safe rather than merely cheap, and it is the half that has to ship with it. A window smaller than the world is the trap `sourceCoverage` already exists for one level up: count what came back, call it the total, and a truncated row reads as complete. `@kud/gh-workflow`'s `threadsTotal` now reads the scalar, falling back to `nodes.length` only for a caller whose own query omits it — so a PR carrying twenty-four threads reports twenty-four rather than twenty. The coverage this buys is on the COUNT alone: `isResolved` beyond the window is still unseen, so a PR past twenty threads can under-report unresolved ones to `computeHealth`. That is the direction to fail in, and it is why the window keeps room for a busy PR rather than for a quiet week. diff --git a/.changeset/one-claimant-for-three-keys.md b/.changeset/one-claimant-for-three-keys.md deleted file mode 100644 index bf0ae51..0000000 --- a/.changeset/one-claimant-for-three-keys.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@kud/gh-ink": minor ---- - -`q`, `esc` and `backspace` belong to the app now, bound once at the root through `@kud/ink-ui`'s `useAppKeys`. The browse screen publishes a _peel_ instead of claiming the keys itself. - -Ink runs every active `useInput` on every key, with no order and no propagation — so "who gets `esc`" cannot be settled by layering handlers. It is settled by there being one claimant, and there were five for `q` and eight for `esc`. - -**A hole this closes for free:** during the cold-fetch loading phase **nothing bound `q` at all**. `App` returns above the browse screen and the empty/failed screen only mounts once a fetch has resolved, so a slow first load could be left only with ctrl+c. The hook sits above every phase, so it now answers there too. - -**Two behaviour changes you will notice.** `q` in the repo picker quits, where it previously did nothing at all — the picker's branch returned unconditionally and swallowed it. And `esc` with the repo picker open over a search now closes the picker; before, the arms were written in source order rather than priority order, so an inner layer plus a filter meant one press cleared the filter and left the inner layer standing. - -The peel is a plain function per screen — a priority order over that screen's own booleans, published through a ref — and deliberately not a back stack, which `@kud/ink-ui`'s own manual is explicit about. The search field has no arm in it: the root hook is inactive while a text field has focus, so `esc` there never reaches the peel and the field's own handler stays live. That inactivity is also what keeps `q` a letter while you are typing. - -`hidden` keeps both of its jobs — don't render, and don't act on keys. It looks like one boolean doing two things, but the listen half is not simply `isActive`: four lines above that guard re-arm the idle pulse deliberately, because a key pressed at a hidden tab is still evidence of a person at the keyboard. Gating the handler itself would have stopped an idle return from re-arming it. - -The drill views are untouched in this release and still own their own `esc`; the root defers to them and behaviour inside a drill is unchanged. diff --git a/.changeset/the-arrow-is-the-signal.md b/.changeset/the-arrow-is-the-signal.md deleted file mode 100644 index ef86089..0000000 --- a/.changeset/the-arrow-is-the-signal.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@kud/gh": minor -"@kud/gh-cockpit": minor ---- - -The PR summary line draws `→ base` only when the base is not the repo's default branch, and draws it a tier brighter than the provenance around it. The arrow's presence is now the signal. - -A pull request onto `develop`, or onto a stacked base, read identically to an ordinary one in every other cell on that line — which made it exactly the fact you can be wrong about and never notice. The line exists to answer "what IS this pull request" before you start reading CI results, and it was silent on the one thing that changes the answer. - -Suppression alone would not have done it. Presence cannot fire from inside the dim tier: `→ develop` wedged between two branch-shaped tokens, in a run already littered with `·`, at identical luminance, gives the eye no reason to stop — and this module's own header defines dim as "provenance you look at deliberately or not at all". So `base` comes back as its own cell and is drawn at the plain tier while the head branch stays dim. Two channels, presence and luminance, both already in this screen's vocabulary, and no hue spent: there is no "notable" colour token, a PR onto `develop` on a repo with a develop flow is entirely correct rather than wrong, and colour is the one channel a colourblind reader cannot use alone. - -Why suppress rather than always draw it brighter: the draft marker's own rationale says a cell that is usually empty teaches you to skip past it. A cell that is usually **identical** teaches the same skip, faster. `→ main` on every pull request is the most efficient way there is to train a reader out of looking at that cell, so by the time it says `develop` they stopped weeks ago. Always-shown-and-dim was not the neutral option — it manufactured the blindness. - -`@kud/gh` gains `fetchDefaultBranch`, a `gh repo view --json defaultBranchRef` call in its own module. It is a second call rather than a field on the health projection because `gh pr view --json` has no default-branch field at all — checked against the live field list on gh 2.100.0 — and because it is a per-repo fact that caches on a different key from anything per-PR. Cockpit keys it by repo through the drill cache, so the answer paints from disk immediately, revalidates behind it, and a repo that renames its default heals itself on the next drill-in. The call is mounted beside the health fetch and adds no wall clock. - -It **throws** on a failed lookup rather than resolving `undefined`, and the distinction is load-bearing. `undefined` already means _draw the base_, so against a caller that revalidates on every mount a swallowed failure would redraw a cell that had been correctly suppressed — one network blip flickering `→ main` back onto a pull request it had been absent from. Answered-with-no-default and did-not-answer are now different outcomes. - -The line was deliberately **not** given a new overflow ladder. It still has the one rung it has always had — the head branch goes, everything else stays — which remains right now that `→ base` appears only when it is notable. That the ladder is one step at all, so a narrow enough terminal wraps a line the module claims never wraps, is true today with no suppression anywhere near it, and is tracked separately. diff --git a/.changeset/the-drills-hand-back-the-keys.md b/.changeset/the-drills-hand-back-the-keys.md deleted file mode 100644 index be6a33e..0000000 --- a/.changeset/the-drills-hand-back-the-keys.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@kud/gh-ink": minor -"@kud/gh-cockpit": minor ---- - -The drill views hand `q` and `esc` back to the app. `esc` goes back one level, `q` quits from any depth, and the footers say so. - -This is the second half of the navigation contract. `@kud/ink-ui`'s manual puts it plainly: _a view exported from a `*-ink` package takes `onBack` and never binds `esc` or `q` itself_ — the host's peel routes to it. Five views were binding both, which is why `q` inside a drill went _back_ rather than quitting, and why leaving the app from three levels down meant three presses of a key labelled "quit". - -Each drill now publishes a **peel** through `DetailContext.registerPeel` — its own layers, innermost first, reporting whether there was one to close. When a drill says no, closing the drill is the root's next layer out. `FilePicker` and `CheckLogView` publish nothing at all, because they are leaves: they push no layers, so the drill above closes them. - -`AiLauncher` is the exception that proves the shape. It is not a leaf — agent → placement is two screens — so it publishes just the step it can pop itself, and backing out of the placement returns to the agent list rather than throwing the launcher away. Its `step` stays inside it, where it belongs; lifting it into both callers would have put the launcher's internal state in two places that do not own it. - -**What you will notice:** `q` in a drill now quits instead of going back, and every drill footer reads `esc back` rather than `q/esc back`. A reply box is unaffected — the root stands its keys down while a text field has focus, so `q` types a `q` and backspace deletes. - -The `process.exit(0)` after `runHere()` in the AI panel is deliberately untouched. It is not a quit binding: a shell command is taking the terminal over, and Ink's async unmount would race the handover. diff --git a/.changeset/threads-read-from-the-newest-end.md b/.changeset/threads-read-from-the-newest-end.md deleted file mode 100644 index f0b3fb3..0000000 --- a/.changeset/threads-read-from-the-newest-end.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@kud/gh": minor -"@kud/gh-workflow": patch ---- - -The review-thread window is anchored at the **newest** threads. It was taking the oldest, which made a busy pull request read as clear while a reviewer was waiting. - -`first: N` on a Relay connection returns the OLDEST N, and the inbox had been asking that way since the window was introduced. Verified on `kud/ambre#69`, which carried 67 review threads and was open for eleven days: `reviewThreads(first: 3)` returns threads first commented on at 2025-08-12, while `last: 3` returns 2025-08-20 and 2025-08-23. For the whole time that pull request sat in the inbox, the mapper was handed the oldest 50 and never saw the newest 17 — and its newest ten alone carry eight unresolved threads. - -Both consumers in `@kud/gh-workflow`'s `map.ts` were wrong, in different directions and both in the unsafe one. `conversationOf` builds `lastEventAt` as a max over thread comment times, so the whose-move clock read stale by days on exactly the pull requests with live discussion. Worse, `computeHealth` tests `unresolvedThreads > 0` — and threads get RESOLVED over time, so sampling the oldest end systematically sampled the threads most likely to be resolved already. The token fell through to `waiting` or `approved` and the row read clear. A false `threads` costs a glance; a false clear costs a missed review. - -`last` biases both the other way, and costs exactly what `first` cost at the same window size. The query-side pin lives in `@kud/gh`'s `inbox.test.ts` and the consumer-side pin in `@kud/gh-workflow`'s `map.test.ts`, so they fail together if the anchor is ever swapped back for symmetry with the other windows in that file — a change that would compile cleanly and silently reintroduce the false clear. - -`GHDetail` also gains `threadsSampled`, how many threads the fetch actually returned, beside the `threadsTotal` that now reads the connection's own count. Two numbers rather than a truncation flag, so a consumer can say how many it is not showing rather than only that something was cut. One honest limit remains: `totalCount` counts resolved and unresolved alike and GitHub offers no `isResolved` argument, so `unresolvedThreads` past the window is permanently a sample. diff --git a/.changeset/three-neutrals-spent-on-something.md b/.changeset/three-neutrals-spent-on-something.md deleted file mode 100644 index 8881af1..0000000 --- a/.changeset/three-neutrals-spent-on-something.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@kud/gh-ink": minor ---- - -Three cells on the inbox row stop spending the middle tier on nothing: a label every row in the section carries is suppressed, the age pair reads `6d (1w)` rather than `6d · 1w`, and the repo name in a section rule steps out of the furniture tier. - -The row has exactly three neutrals — default for the answer, `secondary` for context, `dimColor` for furniture — and the trailing band had stopped reading as three of anything. Each of these is the same fault: a tier spent on something that says nothing, or withheld from something that does. - -**The label cell.** `impliedLabels` already suppresses a label a repo's convention puts on every issue, because a label on every row is the group header repeated. It is keyed on REPO, which is right for a repo convention and blind to uniformity that comes from the QUERY — a `label:plan` view spanning five repos draws `plan` on all sixty rows while only the one repo in the config is exempt. The section axis is now measured too, over `section.items` rather than the visible window (a cell that appears as you scroll is worse than one always there) and only where the section holds more than one row (one row makes every label trivially uniform, and suppressing there would hide the only classification on screen). What this costs when it is wrong is not a wasted cell but a wasted tier: a tone the eye meets on every single row is calibrated to and filed as background, so a uniform label teaches the reader that `secondary` means nothing — and every varied label further down the list inherits that. - -**The age pair.** It was `6d · 1w`, on the argument that the left value is by construction the smaller of the two and that the invariant teaches the order without a legend, a colour or a second glyph column. That fails twice. Knowing which value is smaller is not knowing which value is _which_ — monotonicity establishes that an ordering exists and says nothing about what the two quantities are. And it only reads as ordered inside one unit: `0m · 1d` is obviously ordered, while `6d · 1w` needs weeks converted to days before the ordering is even visible, and cross-unit pairs are the common case rather than the edge, because GitHub ages cross units within a fortnight. So the one worked example that would teach the pattern is the one almost never on screen. A parenthetical is read as subordinate to the number beside it by everyone, which kills the "two peers separated by a dot" reading: the bare value is the age, the parenthetical the lifetime. It costs nothing — both forms are seven columns — and it frees the `·` to mean one thing everywhere else on the row. - -The pair is also tiered now. Both halves were `dimColor`, which said "you may skip this" about the half you are actually scanning for. Last-activity takes `secondary`, the parenthetical stays furniture. The parentheses carry the meaning alone for a reader who sees no colour; the tier only reinforces them. - -**The section rule.** `── kud/gh ───` drew the repo name and the dashes at the same tier, so the one word on that line that answers "what am I looking at" was painted as skippable. The name takes `secondary`; the rules stay furniture. An active header keeps its own colour and bold. - -No new token and no new hue anywhere in this — every change moves a cell between the three neutrals the row already has. diff --git a/.changeset/two-marks-pointing-the-same-way.md b/.changeset/two-marks-pointing-the-same-way.md deleted file mode 100644 index 1f2ce5a..0000000 --- a/.changeset/two-marks-pointing-the-same-way.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@kud/gh-ink": patch ---- - -The turn column draws no rightward arrow. `→` is a blank now, `←` is kept, and the row's colour literals go through the design system's tokens. - -The cursor sits at column 0 and the turn cell at column 4, and both were small rightward points. The collision is not that two marks are close together — it is that they **pointed the same way while only one of them is on every row**. At scan speed the eye is asking "which row am I on", and a rightward mark four columns in, present on some rows and not others, was a second candidate answer to that question. - -Substituting a different rightward glyph would patch the symptom and land on a different neighbour — `▸` beside `◆` is two filled blobs in adjacent cells. Blanking separates the pair by **direction**, which is a shape channel and therefore survives the colourblind invariant `health-display.ts` exists to enforce; a hue change would not. Nothing else on the row is a horizontal arrow — not the health map, the transit frames, the merge sparkle, the thread glyph or the tag — and the tree run `└─` is furniture two tiers down and present on every nested row, which is what makes it scenery rather than a competitor. - -Nothing the cell was carrying is lost. `→` said "you spoke last, nothing is being asked of you", which is the _absence_ of a claim — and absence already draws as a blank here, exactly as `none` health does. The band header says it in words, the unresolved-thread cell is already quiet in that state, and the explain action has room for a sentence. What it buys is a sparse column whose only ink is `←`, the one state that is a claim on you. - -The accepted cost, stated rather than discovered: "you spoke last" and "we never learned who spoke" now draw alike. The second is a fetch fact rather than a domain one, and neither is actionable, so it is not worth a column in the aligned zone. - -Three text sites moved with it, or the interface would go on teaching a glyph it no longer draws: the two explain lines lose their `(→)`, and the `?` legend drops that row. The pin gains a legend row for the first time — it has always sat in this column while the modal documented ten health states, two arrows, and nothing about the `+` beside them. A stale reference in the pin's own explain line, still naming the `!` it was moved off when it collided with `conflict`, is corrected to `+`. - -Separately, fifteen hardcoded colour literals in the row — `"cyan"`, `"red"`, `"green"` — now go through `colors.info`, `colors.error` and `colors.success`. They render identically today; the point is that a literal stops tracking the token the moment the token moves. diff --git a/packages/gh-cockpit/CHANGELOG.md b/packages/gh-cockpit/CHANGELOG.md index 4f62b53..b9f62b8 100644 --- a/packages/gh-cockpit/CHANGELOG.md +++ b/packages/gh-cockpit/CHANGELOG.md @@ -1,5 +1,60 @@ # @kud/gh-cockpit +## 0.6.0 + +### Minor Changes + +- 2367989: The PR summary line degrades by dropping the least valuable cell rather than the only elastic one, so a narrow terminal no longer wraps a line the module says never wraps. + + The header has always claimed "the numbers are never truncated and the line never wraps". The first half held; the second did not. The ladder was **one rung** — the head branch went, and everything else stayed — so there was no step at all between fitting and wrapping: once `kud · opened 3 months ago` exceeded the width on its own, nothing could give and the line wrapped. + + The ranking, most expendable first, which is now the order cells are dropped: the **author**, because it is `kud` on very nearly every row of a solo cockpit; **`opened Nd ago`**, because staleness is the inbox's question and by the time you have drilled in you have already decided to look; then the **head branch**; and `→ base` is kept longest, because since the previous release it is drawn only when it is _not_ the repo default — so its presence already means it is the notable fact on the line. + + That inverts what the old single rung sacrificed. It gave up the branch name and kept both provenance cells, which is backwards: it protected the two facts you can most afford to lose. + + The size, file count and draft marker are never dropped. The numbers are never truncated, and a draft changes the meaning of the whole panel below the line. + + `trail` on the returned `Summary` splits into `author` and `opened`, since dropping them independently is the whole point. + +- 4c8bc86: The PR summary line draws `→ base` only when the base is not the repo's default branch, and draws it a tier brighter than the provenance around it. The arrow's presence is now the signal. + + A pull request onto `develop`, or onto a stacked base, read identically to an ordinary one in every other cell on that line — which made it exactly the fact you can be wrong about and never notice. The line exists to answer "what IS this pull request" before you start reading CI results, and it was silent on the one thing that changes the answer. + + Suppression alone would not have done it. Presence cannot fire from inside the dim tier: `→ develop` wedged between two branch-shaped tokens, in a run already littered with `·`, at identical luminance, gives the eye no reason to stop — and this module's own header defines dim as "provenance you look at deliberately or not at all". So `base` comes back as its own cell and is drawn at the plain tier while the head branch stays dim. Two channels, presence and luminance, both already in this screen's vocabulary, and no hue spent: there is no "notable" colour token, a PR onto `develop` on a repo with a develop flow is entirely correct rather than wrong, and colour is the one channel a colourblind reader cannot use alone. + + Why suppress rather than always draw it brighter: the draft marker's own rationale says a cell that is usually empty teaches you to skip past it. A cell that is usually **identical** teaches the same skip, faster. `→ main` on every pull request is the most efficient way there is to train a reader out of looking at that cell, so by the time it says `develop` they stopped weeks ago. Always-shown-and-dim was not the neutral option — it manufactured the blindness. + + `@kud/gh` gains `fetchDefaultBranch`, a `gh repo view --json defaultBranchRef` call in its own module. It is a second call rather than a field on the health projection because `gh pr view --json` has no default-branch field at all — checked against the live field list on gh 2.100.0 — and because it is a per-repo fact that caches on a different key from anything per-PR. Cockpit keys it by repo through the drill cache, so the answer paints from disk immediately, revalidates behind it, and a repo that renames its default heals itself on the next drill-in. The call is mounted beside the health fetch and adds no wall clock. + + It **throws** on a failed lookup rather than resolving `undefined`, and the distinction is load-bearing. `undefined` already means _draw the base_, so against a caller that revalidates on every mount a swallowed failure would redraw a cell that had been correctly suppressed — one network blip flickering `→ main` back onto a pull request it had been absent from. Answered-with-no-default and did-not-answer are now different outcomes. + + The line was deliberately **not** given a new overflow ladder. It still has the one rung it has always had — the head branch goes, everything else stays — which remains right now that `→ base` appears only when it is notable. That the ladder is one step at all, so a narrow enough terminal wraps a line the module claims never wraps, is true today with no suppression anywhere near it, and is tracked separately. + +- 2e307ca: The drill views hand `q` and `esc` back to the app. `esc` goes back one level, `q` quits from any depth, and the footers say so. + + This is the second half of the navigation contract. `@kud/ink-ui`'s manual puts it plainly: _a view exported from a `*-ink` package takes `onBack` and never binds `esc` or `q` itself_ — the host's peel routes to it. Five views were binding both, which is why `q` inside a drill went _back_ rather than quitting, and why leaving the app from three levels down meant three presses of a key labelled "quit". + + Each drill now publishes a **peel** through `DetailContext.registerPeel` — its own layers, innermost first, reporting whether there was one to close. When a drill says no, closing the drill is the root's next layer out. `FilePicker` and `CheckLogView` publish nothing at all, because they are leaves: they push no layers, so the drill above closes them. + + `AiLauncher` is the exception that proves the shape. It is not a leaf — agent → placement is two screens — so it publishes just the step it can pop itself, and backing out of the placement returns to the agent list rather than throwing the launcher away. Its `step` stays inside it, where it belongs; lifting it into both callers would have put the launcher's internal state in two places that do not own it. + + **What you will notice:** `q` in a drill now quits instead of going back, and every drill footer reads `esc back` rather than `q/esc back`. A reply box is unaffected — the root stands its keys down while a text field has focus, so `q` types a `q` and backspace deletes. + + The `process.exit(0)` after `runHere()` in the AI panel is deliberately untouched. It is not a quit binding: a shell command is taking the terminal over, and Ink's async unmount would race the handover. + +### Patch Changes + +- Updated dependencies [c1115f3] +- Updated dependencies [4d5f45b] +- Updated dependencies [4c8bc86] +- Updated dependencies [2e307ca] +- Updated dependencies [cac544b] +- Updated dependencies [7f5a616] +- Updated dependencies [a59a289] + - @kud/gh@0.17.0 + - @kud/gh-workflow@0.12.1 + - @kud/gh-ink@0.57.0 + ## 0.5.6 ### Patch Changes diff --git a/packages/gh-cockpit/package.json b/packages/gh-cockpit/package.json index 502fb8e..eb2087a 100644 --- a/packages/gh-cockpit/package.json +++ b/packages/gh-cockpit/package.json @@ -1,6 +1,6 @@ { "name": "@kud/gh-cockpit", - "version": "0.5.6", + "version": "0.6.0", "description": "A configurable GitHub cockpit for the terminal — your PRs, reviews and issues in one Ink TUI, grouped by whose move it is.", "type": "module", "main": "dist/index.js", @@ -53,11 +53,11 @@ "react": ">=19" }, "dependencies": { - "@kud/gh": "0.16.0", - "@kud/gh-ink": "0.56.0", + "@kud/gh": "0.17.0", + "@kud/gh-ink": "0.57.0", "@kud/ink-ui": "0.29.0", "zx": "8.8.5", - "@kud/gh-workflow": "0.12.0" + "@kud/gh-workflow": "0.12.1" }, "devDependencies": { "@types/node": "22.20.1", diff --git a/packages/gh-ink/CHANGELOG.md b/packages/gh-ink/CHANGELOG.md index 7080f39..1224a4f 100644 --- a/packages/gh-ink/CHANGELOG.md +++ b/packages/gh-ink/CHANGELOG.md @@ -1,5 +1,71 @@ # @kud/gh-ink +## 0.57.0 + +### Minor Changes + +- 4d5f45b: `q`, `esc` and `backspace` belong to the app now, bound once at the root through `@kud/ink-ui`'s `useAppKeys`. The browse screen publishes a _peel_ instead of claiming the keys itself. + + Ink runs every active `useInput` on every key, with no order and no propagation — so "who gets `esc`" cannot be settled by layering handlers. It is settled by there being one claimant, and there were five for `q` and eight for `esc`. + + **A hole this closes for free:** during the cold-fetch loading phase **nothing bound `q` at all**. `App` returns above the browse screen and the empty/failed screen only mounts once a fetch has resolved, so a slow first load could be left only with ctrl+c. The hook sits above every phase, so it now answers there too. + + **Two behaviour changes you will notice.** `q` in the repo picker quits, where it previously did nothing at all — the picker's branch returned unconditionally and swallowed it. And `esc` with the repo picker open over a search now closes the picker; before, the arms were written in source order rather than priority order, so an inner layer plus a filter meant one press cleared the filter and left the inner layer standing. + + The peel is a plain function per screen — a priority order over that screen's own booleans, published through a ref — and deliberately not a back stack, which `@kud/ink-ui`'s own manual is explicit about. The search field has no arm in it: the root hook is inactive while a text field has focus, so `esc` there never reaches the peel and the field's own handler stays live. That inactivity is also what keeps `q` a letter while you are typing. + + `hidden` keeps both of its jobs — don't render, and don't act on keys. It looks like one boolean doing two things, but the listen half is not simply `isActive`: four lines above that guard re-arm the idle pulse deliberately, because a key pressed at a hidden tab is still evidence of a person at the keyboard. Gating the handler itself would have stopped an idle return from re-arming it. + + The drill views are untouched in this release and still own their own `esc`; the root defers to them and behaviour inside a drill is unchanged. + +- 2e307ca: The drill views hand `q` and `esc` back to the app. `esc` goes back one level, `q` quits from any depth, and the footers say so. + + This is the second half of the navigation contract. `@kud/ink-ui`'s manual puts it plainly: _a view exported from a `*-ink` package takes `onBack` and never binds `esc` or `q` itself_ — the host's peel routes to it. Five views were binding both, which is why `q` inside a drill went _back_ rather than quitting, and why leaving the app from three levels down meant three presses of a key labelled "quit". + + Each drill now publishes a **peel** through `DetailContext.registerPeel` — its own layers, innermost first, reporting whether there was one to close. When a drill says no, closing the drill is the root's next layer out. `FilePicker` and `CheckLogView` publish nothing at all, because they are leaves: they push no layers, so the drill above closes them. + + `AiLauncher` is the exception that proves the shape. It is not a leaf — agent → placement is two screens — so it publishes just the step it can pop itself, and backing out of the placement returns to the agent list rather than throwing the launcher away. Its `step` stays inside it, where it belongs; lifting it into both callers would have put the launcher's internal state in two places that do not own it. + + **What you will notice:** `q` in a drill now quits instead of going back, and every drill footer reads `esc back` rather than `q/esc back`. A reply box is unaffected — the root stands its keys down while a text field has focus, so `q` types a `q` and backspace deletes. + + The `process.exit(0)` after `runHere()` in the AI panel is deliberately untouched. It is not a quit binding: a shell command is taking the terminal over, and Ink's async unmount would race the handover. + +- 7f5a616: Three cells on the inbox row stop spending the middle tier on nothing: a label every row in the section carries is suppressed, the age pair reads `6d (1w)` rather than `6d · 1w`, and the repo name in a section rule steps out of the furniture tier. + + The row has exactly three neutrals — default for the answer, `secondary` for context, `dimColor` for furniture — and the trailing band had stopped reading as three of anything. Each of these is the same fault: a tier spent on something that says nothing, or withheld from something that does. + + **The label cell.** `impliedLabels` already suppresses a label a repo's convention puts on every issue, because a label on every row is the group header repeated. It is keyed on REPO, which is right for a repo convention and blind to uniformity that comes from the QUERY — a `label:plan` view spanning five repos draws `plan` on all sixty rows while only the one repo in the config is exempt. The section axis is now measured too, over `section.items` rather than the visible window (a cell that appears as you scroll is worse than one always there) and only where the section holds more than one row (one row makes every label trivially uniform, and suppressing there would hide the only classification on screen). What this costs when it is wrong is not a wasted cell but a wasted tier: a tone the eye meets on every single row is calibrated to and filed as background, so a uniform label teaches the reader that `secondary` means nothing — and every varied label further down the list inherits that. + + **The age pair.** It was `6d · 1w`, on the argument that the left value is by construction the smaller of the two and that the invariant teaches the order without a legend, a colour or a second glyph column. That fails twice. Knowing which value is smaller is not knowing which value is _which_ — monotonicity establishes that an ordering exists and says nothing about what the two quantities are. And it only reads as ordered inside one unit: `0m · 1d` is obviously ordered, while `6d · 1w` needs weeks converted to days before the ordering is even visible, and cross-unit pairs are the common case rather than the edge, because GitHub ages cross units within a fortnight. So the one worked example that would teach the pattern is the one almost never on screen. A parenthetical is read as subordinate to the number beside it by everyone, which kills the "two peers separated by a dot" reading: the bare value is the age, the parenthetical the lifetime. It costs nothing — both forms are seven columns — and it frees the `·` to mean one thing everywhere else on the row. + + The pair is also tiered now. Both halves were `dimColor`, which said "you may skip this" about the half you are actually scanning for. Last-activity takes `secondary`, the parenthetical stays furniture. The parentheses carry the meaning alone for a reader who sees no colour; the tier only reinforces them. + + **The section rule.** `── kud/gh ───` drew the repo name and the dashes at the same tier, so the one word on that line that answers "what am I looking at" was painted as skippable. The name takes `secondary`; the rules stay furniture. An active header keeps its own colour and bold. + + No new token and no new hue anywhere in this — every change moves a cell between the three neutrals the row already has. + +### Patch Changes + +- a59a289: The turn column draws no rightward arrow. `→` is a blank now, `←` is kept, and the row's colour literals go through the design system's tokens. + + The cursor sits at column 0 and the turn cell at column 4, and both were small rightward points. The collision is not that two marks are close together — it is that they **pointed the same way while only one of them is on every row**. At scan speed the eye is asking "which row am I on", and a rightward mark four columns in, present on some rows and not others, was a second candidate answer to that question. + + Substituting a different rightward glyph would patch the symptom and land on a different neighbour — `▸` beside `◆` is two filled blobs in adjacent cells. Blanking separates the pair by **direction**, which is a shape channel and therefore survives the colourblind invariant `health-display.ts` exists to enforce; a hue change would not. Nothing else on the row is a horizontal arrow — not the health map, the transit frames, the merge sparkle, the thread glyph or the tag — and the tree run `└─` is furniture two tiers down and present on every nested row, which is what makes it scenery rather than a competitor. + + Nothing the cell was carrying is lost. `→` said "you spoke last, nothing is being asked of you", which is the _absence_ of a claim — and absence already draws as a blank here, exactly as `none` health does. The band header says it in words, the unresolved-thread cell is already quiet in that state, and the explain action has room for a sentence. What it buys is a sparse column whose only ink is `←`, the one state that is a claim on you. + + The accepted cost, stated rather than discovered: "you spoke last" and "we never learned who spoke" now draw alike. The second is a fetch fact rather than a domain one, and neither is actionable, so it is not worth a column in the aligned zone. + + Three text sites moved with it, or the interface would go on teaching a glyph it no longer draws: the two explain lines lose their `(→)`, and the `?` legend drops that row. The pin gains a legend row for the first time — it has always sat in this column while the modal documented ten health states, two arrows, and nothing about the `+` beside them. A stale reference in the pin's own explain line, still naming the `!` it was moved off when it collided with `conflict`, is corrected to `+`. + + Separately, fifteen hardcoded colour literals in the row — `"cyan"`, `"red"`, `"green"` — now go through `colors.info`, `colors.error` and `colors.success`. They render identically today; the point is that a literal stops tracking the token the moment the token moves. + +- Updated dependencies [c1115f3] +- Updated dependencies [4c8bc86] +- Updated dependencies [cac544b] + - @kud/gh@0.17.0 + - @kud/gh-workflow@0.12.1 + ## 0.56.0 ### Minor Changes diff --git a/packages/gh-ink/package.json b/packages/gh-ink/package.json index c99cc51..25cf19b 100644 --- a/packages/gh-ink/package.json +++ b/packages/gh-ink/package.json @@ -1,6 +1,6 @@ { "name": "@kud/gh-ink", - "version": "0.56.0", + "version": "0.57.0", "description": "Ink components for rendering GitHub PR review comments and health — controlled, presentation-only, built on @kud/ink-ui and fed by @kud/gh.", "type": "module", "main": "dist/index.js", @@ -48,8 +48,8 @@ "react": ">=19" }, "dependencies": { - "@kud/gh": "0.16.0", - "@kud/gh-workflow": "0.12.0", + "@kud/gh": "0.17.0", + "@kud/gh-workflow": "0.12.1", "@kud/ink-ui": "0.29.0", "zx": "8.8.5" }, diff --git a/packages/gh-pr-comments/CHANGELOG.md b/packages/gh-pr-comments/CHANGELOG.md index 27ce3ea..ffd9e3a 100644 --- a/packages/gh-pr-comments/CHANGELOG.md +++ b/packages/gh-pr-comments/CHANGELOG.md @@ -1,5 +1,19 @@ # @kud/gh-pr-comments +## 0.1.109 + +### Patch Changes + +- Updated dependencies [c1115f3] +- Updated dependencies [4d5f45b] +- Updated dependencies [4c8bc86] +- Updated dependencies [2e307ca] +- Updated dependencies [cac544b] +- Updated dependencies [7f5a616] +- Updated dependencies [a59a289] + - @kud/gh@0.17.0 + - @kud/gh-ink@0.57.0 + ## 0.1.108 ### Patch Changes diff --git a/packages/gh-pr-comments/package.json b/packages/gh-pr-comments/package.json index 4aba7d9..46fa247 100644 --- a/packages/gh-pr-comments/package.json +++ b/packages/gh-pr-comments/package.json @@ -1,6 +1,6 @@ { "name": "@kud/gh-pr-comments", - "version": "0.1.108", + "version": "0.1.109", "description": "Browse, reply to, and resolve GitHub PR review comments in the terminal.", "type": "module", "bin": { @@ -36,8 +36,8 @@ "access": "public" }, "dependencies": { - "@kud/gh": "0.16.0", - "@kud/gh-ink": "0.56.0", + "@kud/gh": "0.17.0", + "@kud/gh-ink": "0.57.0", "@kud/ink-ui": "0.29.0", "ink": "7.1.0", "react": "19.2.7" diff --git a/packages/gh-pr-health/CHANGELOG.md b/packages/gh-pr-health/CHANGELOG.md index e2494ee..326bcb8 100644 --- a/packages/gh-pr-health/CHANGELOG.md +++ b/packages/gh-pr-health/CHANGELOG.md @@ -1,5 +1,19 @@ # @kud/gh-pr-health +## 0.1.109 + +### Patch Changes + +- Updated dependencies [c1115f3] +- Updated dependencies [4d5f45b] +- Updated dependencies [4c8bc86] +- Updated dependencies [2e307ca] +- Updated dependencies [cac544b] +- Updated dependencies [7f5a616] +- Updated dependencies [a59a289] + - @kud/gh@0.17.0 + - @kud/gh-ink@0.57.0 + ## 0.1.108 ### Patch Changes diff --git a/packages/gh-pr-health/package.json b/packages/gh-pr-health/package.json index 4b61550..0ab7b88 100644 --- a/packages/gh-pr-health/package.json +++ b/packages/gh-pr-health/package.json @@ -1,6 +1,6 @@ { "name": "@kud/gh-pr-health", - "version": "0.1.108", + "version": "0.1.109", "description": "Checks, reviews and merge state for a GitHub PR in the terminal.", "type": "module", "bin": { @@ -36,8 +36,8 @@ "access": "public" }, "dependencies": { - "@kud/gh": "0.16.0", - "@kud/gh-ink": "0.56.0", + "@kud/gh": "0.17.0", + "@kud/gh-ink": "0.57.0", "@kud/ink-ui": "0.29.0", "execa": "9.6.1", "ink": "7.1.0", diff --git a/packages/gh-webhook-replay/CHANGELOG.md b/packages/gh-webhook-replay/CHANGELOG.md index 5499f90..0ee82c3 100644 --- a/packages/gh-webhook-replay/CHANGELOG.md +++ b/packages/gh-webhook-replay/CHANGELOG.md @@ -1,5 +1,14 @@ # @kud/gh-webhook-replay +## 0.1.27 + +### Patch Changes + +- Updated dependencies [c1115f3] +- Updated dependencies [4c8bc86] +- Updated dependencies [cac544b] + - @kud/gh@0.17.0 + ## 0.1.26 ### Patch Changes diff --git a/packages/gh-webhook-replay/package.json b/packages/gh-webhook-replay/package.json index 479b257..e8c92e6 100644 --- a/packages/gh-webhook-replay/package.json +++ b/packages/gh-webhook-replay/package.json @@ -1,6 +1,6 @@ { "name": "@kud/gh-webhook-replay", - "version": "0.1.26", + "version": "0.1.27", "description": "Replay the latest pull_request webhook delivery on a GitHub repo hook.", "type": "module", "bin": { @@ -36,7 +36,7 @@ "access": "public" }, "dependencies": { - "@kud/gh": "0.16.0", + "@kud/gh": "0.17.0", "@kud/ink-ui": "0.29.0", "ink": "7.1.0", "react": "19.2.7" diff --git a/packages/gh-workflow/CHANGELOG.md b/packages/gh-workflow/CHANGELOG.md index efa88ac..7978012 100644 --- a/packages/gh-workflow/CHANGELOG.md +++ b/packages/gh-workflow/CHANGELOG.md @@ -1,5 +1,32 @@ # @kud/gh-workflow +## 0.12.1 + +### Patch Changes + +- c1115f3: The inbox windows `reviewThreads` at `last: 20` and selects `totalCount` beside it, taking the whole query from 114 points to 54. + + The window was `first: 50` because fifty is a round number. It is also the dominant cost on every PR-bearing source, because it multiplies beneath five searches at once — the same multiplication the `myPRs` cap and the `minimal` shape were both introduced to fight, left untouched in the one selection that spends the most. Measured 2026-09-09 on a live account, the whole document, same selections that ship: a window of 50 cost 114 points at 27,230 nodes, 20 cost 54, and 10 cost 34. The anchor is free — `first: 20` and `last: 20` both measure 8 points on `myPRs`, so taking the window from the other end costs nothing. Against 5,000 points an hour that is 92 loads where there were 44, on a query a naive poll has previously killed the board by refreshing. + + The data never justified fifty. Across 13 pull-request rows on that account the deepest carried **two** review threads and the median carried none — nothing above ten on either PR source. Twenty keeps ten times it — the step down to ten buys three more points and spends the only headroom there is against the next pull request that gets busy, and `kud/ambre#69` carried 67 threads while it was open. + + `totalCount` is what makes the narrower window safe rather than merely cheap, and it is the half that has to ship with it. A window smaller than the world is the trap `sourceCoverage` already exists for one level up: count what came back, call it the total, and a truncated row reads as complete. `@kud/gh-workflow`'s `threadsTotal` now reads the scalar, falling back to `nodes.length` only for a caller whose own query omits it — so a PR carrying twenty-four threads reports twenty-four rather than twenty. The coverage this buys is on the COUNT alone: `isResolved` beyond the window is still unseen, so a PR past twenty threads can under-report unresolved ones to `computeHealth`. That is the direction to fail in, and it is why the window keeps room for a busy PR rather than for a quiet week. + +- cac544b: The review-thread window is anchored at the **newest** threads. It was taking the oldest, which made a busy pull request read as clear while a reviewer was waiting. + + `first: N` on a Relay connection returns the OLDEST N, and the inbox had been asking that way since the window was introduced. Verified on `kud/ambre#69`, which carried 67 review threads and was open for eleven days: `reviewThreads(first: 3)` returns threads first commented on at 2025-08-12, while `last: 3` returns 2025-08-20 and 2025-08-23. For the whole time that pull request sat in the inbox, the mapper was handed the oldest 50 and never saw the newest 17 — and its newest ten alone carry eight unresolved threads. + + Both consumers in `@kud/gh-workflow`'s `map.ts` were wrong, in different directions and both in the unsafe one. `conversationOf` builds `lastEventAt` as a max over thread comment times, so the whose-move clock read stale by days on exactly the pull requests with live discussion. Worse, `computeHealth` tests `unresolvedThreads > 0` — and threads get RESOLVED over time, so sampling the oldest end systematically sampled the threads most likely to be resolved already. The token fell through to `waiting` or `approved` and the row read clear. A false `threads` costs a glance; a false clear costs a missed review. + + `last` biases both the other way, and costs exactly what `first` cost at the same window size. The query-side pin lives in `@kud/gh`'s `inbox.test.ts` and the consumer-side pin in `@kud/gh-workflow`'s `map.test.ts`, so they fail together if the anchor is ever swapped back for symmetry with the other windows in that file — a change that would compile cleanly and silently reintroduce the false clear. + + `GHDetail` also gains `threadsSampled`, how many threads the fetch actually returned, beside the `threadsTotal` that now reads the connection's own count. Two numbers rather than a truncation flag, so a consumer can say how many it is not showing rather than only that something was cut. One honest limit remains: `totalCount` counts resolved and unresolved alike and GitHub offers no `isResolved` argument, so `unresolvedThreads` past the window is permanently a sample. + +- Updated dependencies [c1115f3] +- Updated dependencies [4c8bc86] +- Updated dependencies [cac544b] + - @kud/gh@0.17.0 + ## 0.12.0 ### Minor Changes diff --git a/packages/gh-workflow/package.json b/packages/gh-workflow/package.json index 45b9642..a751e83 100644 --- a/packages/gh-workflow/package.json +++ b/packages/gh-workflow/package.json @@ -1,6 +1,6 @@ { "name": "@kud/gh-workflow", - "version": "0.12.0", + "version": "0.12.1", "description": "Pure workflow semantics for a GitHub inbox — whose move it is, how rows sort, group and filter. No renderer, no transport, no filesystem.", "type": "module", "main": "dist/index.js", @@ -43,7 +43,7 @@ "access": "public" }, "dependencies": { - "@kud/gh": "0.16.0" + "@kud/gh": "0.17.0" }, "devDependencies": { "typescript": "5.9.3", diff --git a/packages/gh/CHANGELOG.md b/packages/gh/CHANGELOG.md index 8f54bac..abeb02b 100644 --- a/packages/gh/CHANGELOG.md +++ b/packages/gh/CHANGELOG.md @@ -1,5 +1,41 @@ # @kud/gh +## 0.17.0 + +### Minor Changes + +- c1115f3: The inbox windows `reviewThreads` at `last: 20` and selects `totalCount` beside it, taking the whole query from 114 points to 54. + + The window was `first: 50` because fifty is a round number. It is also the dominant cost on every PR-bearing source, because it multiplies beneath five searches at once — the same multiplication the `myPRs` cap and the `minimal` shape were both introduced to fight, left untouched in the one selection that spends the most. Measured 2026-09-09 on a live account, the whole document, same selections that ship: a window of 50 cost 114 points at 27,230 nodes, 20 cost 54, and 10 cost 34. The anchor is free — `first: 20` and `last: 20` both measure 8 points on `myPRs`, so taking the window from the other end costs nothing. Against 5,000 points an hour that is 92 loads where there were 44, on a query a naive poll has previously killed the board by refreshing. + + The data never justified fifty. Across 13 pull-request rows on that account the deepest carried **two** review threads and the median carried none — nothing above ten on either PR source. Twenty keeps ten times it — the step down to ten buys three more points and spends the only headroom there is against the next pull request that gets busy, and `kud/ambre#69` carried 67 threads while it was open. + + `totalCount` is what makes the narrower window safe rather than merely cheap, and it is the half that has to ship with it. A window smaller than the world is the trap `sourceCoverage` already exists for one level up: count what came back, call it the total, and a truncated row reads as complete. `@kud/gh-workflow`'s `threadsTotal` now reads the scalar, falling back to `nodes.length` only for a caller whose own query omits it — so a PR carrying twenty-four threads reports twenty-four rather than twenty. The coverage this buys is on the COUNT alone: `isResolved` beyond the window is still unseen, so a PR past twenty threads can under-report unresolved ones to `computeHealth`. That is the direction to fail in, and it is why the window keeps room for a busy PR rather than for a quiet week. + +- 4c8bc86: The PR summary line draws `→ base` only when the base is not the repo's default branch, and draws it a tier brighter than the provenance around it. The arrow's presence is now the signal. + + A pull request onto `develop`, or onto a stacked base, read identically to an ordinary one in every other cell on that line — which made it exactly the fact you can be wrong about and never notice. The line exists to answer "what IS this pull request" before you start reading CI results, and it was silent on the one thing that changes the answer. + + Suppression alone would not have done it. Presence cannot fire from inside the dim tier: `→ develop` wedged between two branch-shaped tokens, in a run already littered with `·`, at identical luminance, gives the eye no reason to stop — and this module's own header defines dim as "provenance you look at deliberately or not at all". So `base` comes back as its own cell and is drawn at the plain tier while the head branch stays dim. Two channels, presence and luminance, both already in this screen's vocabulary, and no hue spent: there is no "notable" colour token, a PR onto `develop` on a repo with a develop flow is entirely correct rather than wrong, and colour is the one channel a colourblind reader cannot use alone. + + Why suppress rather than always draw it brighter: the draft marker's own rationale says a cell that is usually empty teaches you to skip past it. A cell that is usually **identical** teaches the same skip, faster. `→ main` on every pull request is the most efficient way there is to train a reader out of looking at that cell, so by the time it says `develop` they stopped weeks ago. Always-shown-and-dim was not the neutral option — it manufactured the blindness. + + `@kud/gh` gains `fetchDefaultBranch`, a `gh repo view --json defaultBranchRef` call in its own module. It is a second call rather than a field on the health projection because `gh pr view --json` has no default-branch field at all — checked against the live field list on gh 2.100.0 — and because it is a per-repo fact that caches on a different key from anything per-PR. Cockpit keys it by repo through the drill cache, so the answer paints from disk immediately, revalidates behind it, and a repo that renames its default heals itself on the next drill-in. The call is mounted beside the health fetch and adds no wall clock. + + It **throws** on a failed lookup rather than resolving `undefined`, and the distinction is load-bearing. `undefined` already means _draw the base_, so against a caller that revalidates on every mount a swallowed failure would redraw a cell that had been correctly suppressed — one network blip flickering `→ main` back onto a pull request it had been absent from. Answered-with-no-default and did-not-answer are now different outcomes. + + The line was deliberately **not** given a new overflow ladder. It still has the one rung it has always had — the head branch goes, everything else stays — which remains right now that `→ base` appears only when it is notable. That the ladder is one step at all, so a narrow enough terminal wraps a line the module claims never wraps, is true today with no suppression anywhere near it, and is tracked separately. + +- cac544b: The review-thread window is anchored at the **newest** threads. It was taking the oldest, which made a busy pull request read as clear while a reviewer was waiting. + + `first: N` on a Relay connection returns the OLDEST N, and the inbox had been asking that way since the window was introduced. Verified on `kud/ambre#69`, which carried 67 review threads and was open for eleven days: `reviewThreads(first: 3)` returns threads first commented on at 2025-08-12, while `last: 3` returns 2025-08-20 and 2025-08-23. For the whole time that pull request sat in the inbox, the mapper was handed the oldest 50 and never saw the newest 17 — and its newest ten alone carry eight unresolved threads. + + Both consumers in `@kud/gh-workflow`'s `map.ts` were wrong, in different directions and both in the unsafe one. `conversationOf` builds `lastEventAt` as a max over thread comment times, so the whose-move clock read stale by days on exactly the pull requests with live discussion. Worse, `computeHealth` tests `unresolvedThreads > 0` — and threads get RESOLVED over time, so sampling the oldest end systematically sampled the threads most likely to be resolved already. The token fell through to `waiting` or `approved` and the row read clear. A false `threads` costs a glance; a false clear costs a missed review. + + `last` biases both the other way, and costs exactly what `first` cost at the same window size. The query-side pin lives in `@kud/gh`'s `inbox.test.ts` and the consumer-side pin in `@kud/gh-workflow`'s `map.test.ts`, so they fail together if the anchor is ever swapped back for symmetry with the other windows in that file — a change that would compile cleanly and silently reintroduce the false clear. + + `GHDetail` also gains `threadsSampled`, how many threads the fetch actually returned, beside the `threadsTotal` that now reads the connection's own count. Two numbers rather than a truncation flag, so a consumer can say how many it is not showing rather than only that something was cut. One honest limit remains: `totalCount` counts resolved and unresolved alike and GitHub offers no `isResolved` argument, so `unresolvedThreads` past the window is permanently a sample. + ## 0.16.0 ### Minor Changes diff --git a/packages/gh/package.json b/packages/gh/package.json index 18a6291..37c2fa4 100644 --- a/packages/gh/package.json +++ b/packages/gh/package.json @@ -1,6 +1,6 @@ { "name": "@kud/gh", - "version": "0.16.0", + "version": "0.17.0", "description": "Surface-agnostic GitHub core — gh CLI primitives, PR review comments, health, and webhook logic.", "type": "module", "main": "dist/index.js",