import type { PrecedentMatch } from "./client.js"; /** Convert an inline enumeration into a real markdown list so escalation reasons * render as scannable steps: "(1) … (2) … (3) …" digits become a numbered list, * "(a) … (b) … (c) …" letters become a bulleted list. Reasons that are already * structured markdown (they contain newlines — the common case now that reasons * lead with their own `###` sections) are returned untouched, as are reasons * with no clean enumeration. */ export declare function bulletizeReason(reason: string): string; /** Split `reason` on a parenthesised marker (digit or letter) and, only when the * markers form a genuine enumeration, render each item via `line`. Returns null * (so the caller falls through) otherwise. * * `String.split` with a capturing group yields the alternating shape * `[lead, marker1, text1, marker2, text2, …]`. The empty segments are KEPT (not * filtered), so adjacent markers — e.g. "(a)(b) x", which leaves an empty text * between the two markers — don't shift every marker onto the wrong text. * Safeguards keep stray parentheticals (phone numbers, error codes, a lone * "(x)", a single "(a) …" item) from being mangled: the split must yield the * full lead + ≥2 marker/text pairs (length ≥ 5), the sequence must open with * `firstMarker` ("1" / "a"), empty items are skipped, and ≥2 items must * survive. A non-marker prefix is preserved as lead text above the list. */ export declare function enumerateReason(reason: string, marker: RegExp, // matches "(n)"/"(x)" incl. surrounding whitespace, capturing the token firstMarker: string, // the token the sequence must open with ("1" / "a") line: (marker: string, text: string) => string): string | null; /** Why each escalation class is gated on a human. Rendered verbatim inside a * folded "Why a human must decide — " details block so the rationale * is standardized and never depends on how verbose a given reviewer subagent * felt (issue #58, motivated by rental-portal#121's bare "money-write risk — * see the issue body"). */ export declare const ESCALATION_CATEGORIES: Record; /** The once-key an `escalateOnce` escalation is filed under (issue #488): the PR * it was filed FOR, and the refusal reason that earned it. Both halves are * load-bearing — see `renderEscalateOnceMarker`. */ export interface EscalateOnceKey { /** The PR whose row owed the escalation (`--for-pr`). */ pr: number; /** The `ESCALATE_ONCE_REASONS` token on that row (`--once-reason`). */ reason: string; } export interface EscalationOptions { /** One of ESCALATION_CATEGORIES — appends the standardized "why a human" rationale. */ category?: string; /** GitHub login (with or without a leading @) accountable for acting on this. */ owner?: string; /** Stamp the permanent once-key marker INSIDE this banner (issue #488). */ once?: EscalateOnceKey; } /** Names the Action-needed section, tolerating LLM heading drift (emoji, case, * colons, extra decoration). Single source of truth for the fold (what stays * visible), the word-cap lint (which lines it applies to), and the * missing-section check — sharing it means a section can never render * unfolded while escaping the cap, or vice versa. */ export declare function isActionHeading(heading: string): boolean; /** Progressive disclosure (issue #166): the on-screen comment is the banner, the * owner, and the Action-needed steps — every other `###` section collapses into * a closed `
` block, one click away. Prompt-side brevity contracts * alone didn't hold (the reviewer still wrote walls of text), so the fold is * enforced here where it can't drift. Sections matching "action needed" stay * visible, as does any lead text before the first heading; if NO section * matches, the first section stays visible so a fold can never hide the whole * reason. Reasons without `###` headings pass through untouched. */ export declare function foldSecondarySections(reason: string): string; /** Canonical GitHub login: trimmed, leading @ stripped, empty collapsed to * undefined — so "@gemscng", " gemscng " and "gemscng" all render as one * consistent `@gemscng` (comment body, console line, and JSON alike). */ export declare function normalizeOwner(raw?: string | null): string | undefined; /** The visible part of the comment must read in one breath per line: any line * in the Action-needed section over this many words is dense prose, not a step * (the contract caps sentences at ~20 words; 30 leaves room for a code ref). * Single-sourced from the shared ShipFlow contract (readability.visibleLineWordCap); * the server's readability.VisibleLineWordCap reads the same value. */ export declare const ACTION_LINE_WORD_LIMIT: 30; /** Lines of the Action-needed section(s) that exceed ACTION_LINE_WORD_LIMIT * words. Only those sections are checked — they're the part that renders * unfolded, and the fix for an overlong step is precisely to move detail into * a folded section. Every section whose heading matches "action needed" is * checked, not just the first — heading drift can produce more than one. * * Graphical-first carve-outs (the contract prefers tables/mermaid over prose, * so the gate must not punish them): * - a markdown table row is measured PER CELL — a decision table packs several * one-breath cells onto one line by design; * - fenced code blocks (```mermaid diagrams, commands) are verbatim, not prose, * and are exempt — mirroring the server's readability.OverCapLines. */ export declare function overlongActionLines(reason: string): string[]; /** Deterministic quality gate on an escalation reason — pure string checks, no * AI. Returns one message per problem (empty array = clean). The rules encode * the loop contract: no open questions without a recommendation, no pointers * out of the comment, structured reasons lead with the action, and the visible * action steps stay short enough to read without effort (issue #166). */ export declare function lintEscalationReason(reason: string): string[]; export interface DecisionReply { /** The escalation decision number the human answered (the `N` in `N: answer`). */ n: number; /** The human's answer, trimmed. May itself contain colons and commas. */ answer: string; } /** Parse a human's structured per-decision reply on a `needs-human` issue into * the decisions they answered (issue #60). The loop maps each answer back to the * escalation's numbered decisions, so a reply like * * 1: frankfurter * 3: me * * resolves decisions 1 and 3 and leaves the rest escalated (partial answers stay * escalated with the remaining items). Accepted input, whitespace-tolerant throughout: * - one decision per line — `N: answer`; * - several on one line, comma-separated — `1: frankfurter, 3: me`; * - only the FIRST colon splits the number from the answer, so an answer may * itself contain colons (`1: use frankfurter: free tier` → n=1, * answer="use frankfurter: free tier"); * - a comma only starts a new decision when a `N:` marker follows it, so an * answer may contain commas (`1: A, B or C`) as long as no `N:` follows one. * A line that does not START with `N:` is chatter and ignored — a non-numeric * prefix ("option 1: x", "see step 2: …") is NOT a decision. No decisions → `[]`. * Duplicate N → **last wins**: a later answer for the same decision overrides the * earlier one, keeping the decision at its first-seen position. */ export declare function parseDecisionReplies(body: string): DecisionReply[]; /** Hidden marker the loop appends to every escalation banner carrying the raw * ask + category, so the server's webhook capture fingerprints the SAME text a * later precedents/match lookup will. Must be byte-identical to the Go * domain.EncodePrecedentContext (pinned by a fixed-vector parity test): the * open token from the shared contract, then `cat=` and `q=`. * Base64 (std) of the UTF-8 reason keeps the two languages in lockstep without * escaping ambiguity — a reason may itself contain `-->`. */ export declare function encodePrecedentContext(category: string, reason: string): string; /** * The permanent once-key an `escalateOnce` escalation is stamped with, rendered * inside the escalation banner itself so it costs ZERO extra comments: * `` (the `pr=`/`reason=` * attribute convention mirrors `precedentContext`'s `cat=`/`q=`). * * WHY A COMMENT AND NOT THE LABEL. The once-key used to be the parent issue's * live `needs-human` label. The server's `UnblockNeedsHuman` removes that label * on ANY non-bot, non-machinery comment — by design, that is how a human answer * un-parks the loop — so the only once-key was ERASED the moment a human * replied, and the row re-escalated on every following tick, forever. A comment * cannot be un-written by a reply. * * WHY THE KEY IS (PR, REASON) AND NOT THE PARENT. Every `ESCALATE_ONCE_REASONS` * token is terminal until a specific artifact changes (`rework_ceiling` = the * rework budget is spent; `correction_unreadable` = the PR has no legible loop * trail). NONE of them is resolved by a human replying on the PARENT ISSUE — the * real blocker is the PR's own `needs-reporter-review`, which only an exact * confirmation token on the PR thread clears — so at re-check the condition has * never stopped and a second escalation says nothing new. Keying on the parent * ALONE would be wrong in the other direction: it would swallow a legitimately * different refusal, on a different PR or for a later distinct reason. */ export declare function renderEscalateOnceMarker(pr: number, reason: string): string; /** * Validate `issue escalate --for-pr --once-reason ` into a key (or an * Error the caller prints before exiting non-zero — nothing is written). * * Both halves are required TOGETHER. A marker rendered with either half missing * or wrong matches nothing on the read side, so `inbox` would keep re-escalating * the PR every tick while the operator saw a clean escalation — a silent * reintroduction of the defect. The reason is checked against * `ESCALATE_ONCE_REASONS` for the same reason: a typo'd token is a key nobody * will ever look up. */ export declare function parseEscalateOnceKey(forPr?: string, onceReason?: string): EscalateOnceKey | undefined | Error; /** * Does this comment body OPEN with the escalation banner? * * THE THIRD HALF OF THE #411 RULE, for this marker (PR #489 round 4). Author + * anchor were not enough. `viewerDidAuthor` says "the CLI wrote it" and the * anchor says "a marker stands alone at column 0" — but the CLI writes many * comments that are not banners, and `issue wait --reason ""` posts * one on the very issue the key is read from, interpolating LLM-composed text * raw. An own-line marker in that reason satisfied BOTH halves and forged a * permanent key: `hasEscalateOnceMarker` returned true for a (PR, reason) that * was never escalated, silently spending the one escalation that pair ever earns. * Issue #488's exact harm, through the adjacent door. * * WHY SCOPE RATHER THAN NEUTRALIZE ANOTHER WRITER. Round 3 hardened the banner * writers; that closed the doors it enumerated and left every other CLI comment * open — `issue wait` today, whatever the loop writes next. Scoping the READ * inverts the burden: a key counts only where the ONE writer of a key * (`formatEscalationBody`) actually puts one, so a new CLI comment cannot become * a forgery surface by being added. Neutralizing writers stays as defence in * depth; it is no longer the thing standing between free text and a forged key. * * Single-sourced with `findLatestEscalationComment`, which has always applied * exactly this test on the write side — the two must not drift, or a banner the * `--update` path edits could be one the read path refuses to trust. */ export declare function isEscalationBanner(body: string): boolean; /** Does this comment body carry the once-key for exactly this (PR, reason)? * The pr number and the reason token must both match exactly — a marker for a * DIFFERENT reason on the same PR must not suppress the one escalation that * reason has earned — the marker must stand on its own line (see * `escalateOnceLineRe`: a quoted marker is a claim, not evidence), and the * comment must BE a banner (see `isEscalationBanner`: a key outside the one * place a key is written is a claim too). * * The scope lives HERE, not at the call site, deliberately. Every reader of this * marker goes through this predicate, so scoping it by construction is what * keeps the next reader from re-opening the hole the way `commands/inbox.ts` * did — the same reason `formatPrecedentDisclosure` lost its `once` parameter * rather than gaining a guard. */ export declare function hasEscalateOnceMarker(body: string, pr: number, reason: string): boolean; /** Every once-key marker line this body carries, in document order (deduped). * Same anchored, own-line, banner-scoped rule as `hasEscalateOnceMarker` — what * it returns is exactly what that predicate would later match. * * Banner-scoped for the same reason the predicate is, one hop earlier: this is * the harvester `preserveEscalateOnceMarkers` re-writes keys with, so an * unscoped harvest could LAUNDER a forged key out of a non-banner comment and * into a banner, where the read path would then honour it. Its only caller * already feeds it a `findLatestEscalationComment` result, so scoping costs no * reachable key — and a dropped key is the expensive direction here (it * re-opens the storm), which is why the check is the same single-sourced one * that selected the comment in the first place rather than a second rule. */ export declare function extractEscalateOnceMarkers(body: string): string[]; /** * The body an in-place `--update` may safely write over `existing`: `next`, plus * every once-key `existing` carried that `next` does not (issue #488). * * WHY (the reachable half of the defect). `issue escalate --update` replaces the * latest 🚧 banner wholesale via `findLatestEscalationComment` + * `ghUpdateIssueComment`, and that banner is very often the escalate-once one — * `findLatestEscalationComment` matches ANY CLI-authored comment opening with the * banner heading, which the escalate-once banner is. An ordinary re-escalation of * the same parent for an unrelated reason carries no key of its own, so the * replacement body carried no marker and the key filed for (PR, reason) was * ERASED — after which that pair re-escalated every tick, forever: the exact * storm this issue closes, reached through the adjacent door. And it is reachable * through MANDATED behaviour, not a corner case: the escalation contract tells * the loop to re-escalate with `--update` ("Shrink, don't stack — one live * escalation per issue"). * * WHY CARRY FORWARD rather than refuse the update. Refusing would trade a * silent data loss for a hard failure on the path the contract mandates, so a * legitimate re-escalation could not be written at all. Carrying the keys keeps * BOTH invariants: one live banner per issue, and keys that only ever accumulate. * The keys are hidden HTML comments, so a preserved key changes nothing a human * sees. Keeping the marker where it already lives also beats a separate durable * artifact, which would cost an extra comment per key and a second thing to keep * in sync. */ export declare function preserveEscalateOnceMarkers(next: string, existing: string): string; /** The folded "Precedent on file" section appended to an escalation when a * precedent was surfaced but not auto-applied (suggest / reconfirm / demoted * class, or auto-apply off). Visible summary + one reuse instruction; the * provenance stays inside the fold. Returns "" when there is no precedent. */ export declare function formatPrecedentSuggestion(m: PrecedentMatch): string; /** The auto-application disclosure comment (design §4), posted when a precedent * is auto-applied (server auto-apply flag on). Visible: the one-line reuse + * undo instruction; folded: the matched-precedent evidence. Carries the * `precedent-applied` marker with the precedent id + category so a one-word * `undo`/`no` reply reverses exactly this application. */ export declare function formatPrecedentDisclosure(m: PrecedentMatch): string; export interface EscalationCommentRef { id: string; body: string; viewerDidAuthor?: boolean; } /** The loop's most recent escalation comment on an issue (input oldest-first, * as `gh issue view --json comments` returns them), so `issue escalate * --update` can edit it in place instead of stacking a new banner. Only * comments that OPEN with the banner count — replies quoting it don't — and * only ones the CLI's own account authored. */ export declare function findLatestEscalationComment(comments: EscalationCommentRef[]): EscalationCommentRef | null; /** Renders the escalation comment the loop posts when it hands an issue to a * human. What a dev sees unfolded is deliberately minimal: banner, owner, the * Action-needed steps, footer. Everything explanatory — the reason's other * `###` sections and the `--category` rationale — collapses into `
` * blocks (foldSecondarySections), so readability no longer depends on how * verbose the reviewer subagent felt. A plain-paragraph reason just reads as * text; this adds no competing heading of its own. `owner` names the * accountable human right under the banner. */ export declare function formatEscalationBody(reason: string, opts?: EscalationOptions): string; //# sourceMappingURL=escalation-format.d.ts.map