PR description as data
Everything a reader needs about a change — the PR title, a fixed-size map for the reader (the TL;DR, the why, at most seven linked pointers, the feedback wanted, the risk, one line of verification) and, collapsed under it, the decisions, the validation criteria with their proofs and notes for agents — is one typed object, PrDescription, and every surface that shows it (the GitHub PR body today; the run page's review panel; whatever replaces GitHub after that) is a renderer over that object. There is never a second copy of the content to keep in sync, and because anchors are stored as (path, from, to) and rendered against the PR head at render time, regenerating a body after a repush is a re-render, not a rewrite. Every field is capped in the schema, so the map is the same size for a 3-file and an 80-file PR; the shape and its numbers are argued in docs/decisions/0050.
This is the data half of the coding agent's PR description (agent-coding.md item 3 describes the submission contract as the prompt states it); item 5 below is the pipeline that turns the submitted object into the PR. It was motivated twice by a PR body nobody could consume: first the prose Changes list gave way to a hunk-anchored Tour with embedded code, then the Tour, grown to twenty embedded blocks on a large PR, gave way to the capped map.
- Code:
src/core/prDescription.ts(PR_DESCRIPTION_CAPS,visibleLength,fitToCap,describeDescriptionIssues,recordedJson,PrDescriptionSchema/parsePrDescription,renderPrDescriptionMarkdown,anchorUrl,MAP_LABELS,FOLD_SUMMARIES,parsePrDescriptionMarkdown,redactPrDescription) with the zod-free shapes insrc/core/prDescriptionTypes.ts(Pointer,RenderedPointer), the title gate's predicatesrc/core/prTitle.mjs(checkPrTitle,TITLE_MAX_LENGTH), the generatedsrc/core/prTitleVocabulary.jsonit judges against and the repository scoping (TITLE_GATE_REPOSITORY,titleGateApplies,prDescriptionSchemaFor;ToolContext.repoinsrc/tools/runnableTool.ts, bound in the run loop);src/tools/submit.ts(submit_pr_description);src/execution/githubPulls.ts(findOpenPrByHead/openPullRequest/updatePullRequest); the coding PR post-step insrc/core/codingPrPostStep.ts(trackPushedBranch/pushedBranchOf,observeCodingWorkspace,runCodingPrPostStep), wired fromsrc/core/dispatch/runLoop.tsandsrc/core/shipPipeline.ts; the description turn insrc/core/descriptionTurn.ts(item 5);scripts/render-pr-description.ts; fixture + goldensrc/core/testing/goldenMap.description.json/goldenMap.body.md. - Tests:
src/core/prDescription.test.ts;src/tools/submit.test.ts(submit_pr_description tool);src/execution/githubPulls.test.ts;src/core/dispatcher.test.ts(coding PR post-step);src/core/descriptionTurn.test.tsandsrc/core/dispatch/runLoop.test.ts(the description turn on the run's pi session, and thefinishplan's).
Language and reader context
The pr-description skill requires ASD-STE100 Simplified Technical English, with short, direct sentences, active voice and concrete terms. The map assumes no product or system knowledge. It introduces the target product and the affected user task, explains the current problem and changed result, and connects internal components to that task. Links support the explanation; they do not replace it. The existing field caps still apply. The author checks that a new reader can understand the product, task, current result and changed result from the map alone.
Behavior
The object.
PrDescription = { title, tldr, why, pointers: Pointer[], feedbackWanted, risk, verified, decisions: {title, rationale}[], validation: { criteria: {criterion, proof}[] }, agentNotes? }.titleis the PR title's single source — metadata for the PR's own title field, never rendered into the body. APointeris{ label, text, risk?, anchor: { path, from, to } }— the linked label naming the thing, one sentence, an optional risk (rendered as ⚠), and the lines the label links to. Every field is capped (PR_DESCRIPTION_CAPS, the one table the schema, the prompt and the tool descriptions read):title72 on every repository (the whole line counted raw, a link's target included — GitHub renders no markdown in a title — exactly as the title gate counts it;PR_DESCRIPTION_CAPS.titlereads the gate's ownTITLE_MAX_LENGTH), and on the repository that carries the title gate — this project's own,TITLE_GATE_REPOSITORY(SWITCHBOARD_REPO), matched case-insensitively against theowner/namethe dispatcher resolved (ToolContext.repo, offRepoContext.repo) — judged as the CI title gate judges it:prDescriptionSchemaFor(repo)swaps incheckPrTitlefromsrc/core/prTitle.mjs, the one predicatescripts/check-pr-title.mjsruns, against the generatedsrc/core/prTitleVocabulary.json(release-and-deploy.md item 22) — thetype(scope)!: descriptiongrammar, a type from the release config, a scope from the code map's Areas or none, no trailing period, the cap with the bots' scopes and arevert:exempt — every problem one issue attitlein the gate's own sentence (the cap's too, so one wording per repository), so a run cuts and resubmits inside its turn and CI'stitlecheck never sees a title the tool accepted — the last tool call of a run whose loop was cut has no shell left to ask the gate itself. The vocabulary is one repository's house convention and the refusal names that repository's code map, so any other repository, or a run with no repository, keeps the universal schema and the cap alone (fix(api): …on another repository is never told to use this repository's Areas); the migration note behind!needs files only the gate can read, so the gate alone judges it,tldr300,why400,pointers1–7 withlabel60 /text160 /risk100,feedbackWanted200,risk300,verified200; collapsed,decisions0–10 withrationale400,criteria1–30 withcriterion200 /proof300,agentNotes2000. A cap counts visible characters (visibleLength: a markdown link[label](target)counts its label only, so a why with three issue links is not punished for their URLs); a violation names the cap and the count (at most 300 visible characters (got 305),at most 7 pointers (got 12)), never zod's generic element count. Every text field is non-empty after trimming;title, a pointer'slabel,textandriskare single-line; an anchor'spathis repo-relative (no leading/, no..segment), lines are positive withto ≥ from.parsePrDescription(unknown)is the one validating entry point (zod; a violation throws naming the path) — thesubmit_pr_descriptiontool and any JSON file go through it. The map's worst case is under 3,800 visible characters and the whole body under 26,000 (both pinned by a test), against an unbounded contract before.The sha is a render-time input.
renderPrDescriptionMarkdown(desc, { repo, headSha })refuses anything butowner/nameand a full 40-char lowercase sha: a pointer's link lands on the exact lines whatever the branch does next only for a commit ref. Because the object holds no sha, re-rendering at a new head is the whole repush story.The GitHub rendering. No headings. The map first: the
tldras the opening paragraph;**Why:** <why>;**Where to look**then one numbered row per pointer,N. [label](https://github.com/<owner>/<repo>/blob/<sha>/<path>#L<from>-L<to>) <text>with⚠ <risk>appended only when a risk is set (a]in a label is escaped);**Feedback wanted:** …;**Risk:** …;**Verified:** …. Every permalink sits inside link syntax, never bare on its own line — a bare permalink is what GitHub embeds as a code block, and the map is links, not code. Then the folds, each a<details>with its<summary>, a blank line (GitHub renders markdown inside only after one), the body and</details>, present only when there is content:Decisions (N)as- **Title.** rationale(exactly one trailing period on the title),Validation (N criteria)(1 criterion) as a| Criterion | Proof |table with|escaped and newlines flattened inside cells,For agentsas the notes. The body ends with the generated-with footer.The golden is a real PR.
src/core/testing/goldenMap.description.jsonis a real pull request's description as data (the one that introduced the map, rewritten onto the fixture repoacme/api);goldenMap.body.mdis its rendering at the fixture head; the suite asserts they are byte-identical, and the PR's live body is the same object rendered at its real head and repo — the PR body, the golden and the test are one artifact by construction.scripts/render-pr-description.ts <json> --repo <owner/name> --head <sha>prints the rendering for the operator path.The coding agent submits the object; the system renders it and ships the PR.
submit_pr_description(fulltoolset only — readonly/web/none never carry it) validates withparsePrDescription; a violation comes back as a string error naming EVERY failing zod path at once (never a throw) — on this project's own repository a title the gate would refuse among them,title: <the gate's sentence>, the tool readingToolContext.repoto know — and the refusal carries the cut: each cap issue says how many characters to remove (visible ones; raw for the title, whose cut is offered in the gate's own sentence on the gated repository too) and quotes the longest prefix of the field that fits (describeDescriptionIssuesover the cap params the schema puts on the issue;fitToCap(s, max)besidevisibleLength: cut at a word boundary under the same count, never inside a[label](target)link — a link that does not fit is dropped whole — with the orphaned separator trimmed; already-fitting text unchanged; the empty string, and no quote, when not even the first word fits), so the model takes the prefixes verbatim and the second submit lands — an overshoot is one to five characters and a trim that shortens a URL removes nothing the counter sees, which is why the count alone cost a turn per field. The refused object is on the record: before it answers, the tool publishes adescription_refusedrun note whose summary counts the fields over their cap and names them (or the issues, when none is a cap), carrying the object as submitted — redacted as a JSON value throughmapStringLeaves, since the input need not be a validPrDescription, then clipped byrecordedJsonto the four-levelRecordedJsonthe run store's RPC typing accepts (a description is three deep; a deeper object is stored as its JSON text) — and the issues with their cuts, so the ledger says what the model changed between one submit and the next. So the model fixes the object and retries within its own budget, and the last valid call wins — a resubmit after a fix-up push supersedes the earlier one. The PR's head branch is the branch the run's owngit pushnamed, read off the run's bash calls and results as they stream by (trackPushedBranchover the run events: only the result of a bash call whose command invokedgit push, paired bycallId, is read — a transcript printed by another command is not a push; the command is judged on the event's FULLcommandfield, which the runner puts on every bashtool_call(redacted,COMMAND_CAP4000 chars), because the 200-charsummaryroutinely loses a chainedcheckout && add && commit && push'spushpast the cap — a single chained command does exactly that, and judged on the summary the post-step would fall back to the checkout; an older record withoutcommandis judged on the summary, where that shape is a known false negative;pushedBranchOfparses that result: git'sTo <url>header followed by per-ref status lines —* [new branch] feat/x -> feat/x,abc..def feat/x -> feat/x, a forced+update, an= [up to date]answer; the block ends at the first line that is not a status line; rejections, deletions and tags name nothing;git fetch's look-alike block sits underFromand never counts; the last push wins) — the checked-out branch stands in only when no push was observed. The checkout is not the record of what was pushed: anything that moves HEAD between the push and the post — a second run'sgit checkout -bin a shared sandbox, the agent itself checking out another branch after pushing — would otherwise make the post ask the remote for a branch nobody pushed and orphan the pushed work. After the run — before the workspace is released (a resident re-attach would show the ref's tip, not what the run pushed) — the dispatcher observes the workspace: HEAD (git rev-parse HEAD), the checked-out branch (--abbrev-ref HEAD), the head branch's upstream (@{u}, or<branch>@{u}when a push named the branch) and, when a push named the branch, that branch's local tip (git rev-parse refs/heads/<branch>) concurrently, then the remote's own head for the head branch (git ls-remote --exit-code origin refs/heads/<branch>, through the same executor — and so the same credential — the agent's push used); on the cold path a root probe that fails discovers the single cloned subdirectory and re-probes there, and when the dispatch resolved no repo slug theowner/namecomes from the workspace's own origin remote (GitHub URL forms only). The observed head is the head branch's tip — HEAD when it is checked out, the localrefs/heads/<branch>when HEAD moved on (never HEAD then: that is another branch's commit); a pushed branch whose local ref is gone too has no observable head. A branch counts as pushed by this run only when its producer-owned accepted Git write (ordinary branch recorder, bound existing-PR receipt or successful salvage/identity rewrite) names that ref and head, and the post-step observes the remote at that head. The post-step never creates a receipt-lesspushed_headfrom local/remote equality: equality is workspace observation, not a mutation, and PR lookup is not an edit. An existing PR's metadata may still be edited without a code push;pr_opened(created:false)then follows only the successful API edit, not a lookup. Remote observation is never inferred from names or from the clone's tracking state: a--depth/--single-branchclone (the cold sandbox's usualgh repo clone … -- --depth N) never creates the remote-tracking ref for a pushed branch, so@{u}fails after a successfulgit push -u; a remote that answers "no such branch" (exit 2) is final, and@{u}— the local record of the last push — stands in only when the remote probe itself failed (network, auth, an origin the thread user cannot read). The body renders at the observed 40-char head and the PR opens in the bot process over GitHub REST (src/execution/githubPulls.ts, App installation token, never aghshell-out) — open-or-edit: the open PR for the head branch is always looked up first and edited when it exists, so a repush re-renders and edits, never duplicates. Inputs are typed end to end:titlefrom the validated object, head from the observed head branch, base in this order — the PR's true base ref when the thread's context came from a PR (a fix round repushes the PR's own head branch, whose binding ref is not the merge base), else the base a coordinator's spawn put on its child's tag (CoordinatorTag.base, http-ingress.md item 9: a plan unit's child is dispatched AT its unit branch so the resident attaches there, which makes the binding ref the branch itself — the plan's base is the only signal that names the target, and only the tag carries it), else the thread's resident binding ref, else the dispatch's resolved ref — prose can alter none of them. The render + open happen before the run's stream finishes: the accepted object is published as a typedpr_descriptionevent (every string leaf redacted) and the outcome as a typedpr_openedevent (url, number, created), so the PR facts live in the run record, not only in the reply. The reply carries the PR URL (opened vs. updated wording). A repush re-renders the title too — the object'stitleis the single source, so a hand-edited PR title is overwritten by the next submit. Failure honesty: a proven-pushed branch with no valid description first asks GitHub whether that branch already heads an open PR (the samestate=open+head=owner:branchlookup open-or-edit starts with) — a follow-up on an existing PR repushes the PR's own head branch, and the accepted push changed the PR's code — and when it does, the note names that PR as updated by the push and warns that its description was not resubmitted (title and body untouched, since there is no description to render — and agent-coding.md item 3 requires a resubmit after every push to an existing PR). The record keeps the producer's accepted head, notpr_opened: lookup alone is not an API edit; only a branch with no open PR (or a lookup that failed — logged, never thrown) gets the branch compare URL and the plain "no PR was opened" statement. The description turn (src/core/descriptionTurn.ts) enforces the rule before the note is ever needed: when a coding run's model loop ends with no submitted description, a producer-confirmed push whose head the remote still holds on a branch that is not the base, and an open PR heading that branch (the same lookup; a failed lookup means no turn), the dispatcher — and a ship coding round for the pipeline's own PR (agent-ship.md item 7), which runs the identical turn on a copy of the round's messages — after the model is done, before the answer lands or the workspace is released, never after a hard stop, publishes adescription_turnrun note and prompts the run's own pi session ONE more time (a follow-up naming the PR, the pushed head,github_pull_getas the way to read the current title and body, the three steps, and the two prohibitions: no further push, no PR opened) under a clip below the coding def's budgets (at most 8 turns / 5 minutes, never the shared AgentDef), with the same tool context — so the description arrives through the samesubmit_pr_descriptionhook, relayed, and the ledger row sees it — under arun.description_turnspan, through the seam the run stage hands over (followUp; harness-pi.md item 14); a run resumed as afinishplan (run-history.md item 37) has no session to prompt, so its note says so and nothing is submitted — the warning below then stands; the workspace is observed again afterwards, and the post-step then opens-or-edits as for any submitted description ("PR updated:<url>" — the head it was re-rendered at follows at verbose, routing-and-config item 28). A turn that still submits nothing leaves the warning standing, worded to say the turn was given ("even in the dedicated description turn this run was given"); a turn that fails is logged and reported the same way. The turn's tool events join the run record like any other; its messages and compactions are ledgered on both harnesses. Each ancillary step preserves the selected substantive report, so recovery retains the description exchange without publishing its acknowledgement as the work report; a failed render/open is reported with the branch compare URL and the plain reason; an unproven push (the branch not on the remote, unpushed commits, unobservable head) gets an honest note and NO compare URL — a URL implying a remote branch that may not exist is a fabrication — but never a false "no PR was opened" over a pull request that exists: when the remote holds the branch at an older commit, or the pushed head is unobservable while the branch is known — the budget wind-down ends runs exactly there, a rebase in flight or a cut probe leaving the push unprovable minutes after it landed — the post-step first asks whether an open PR heads the branch (the same lookup) and, found, renders the description at THAT pull request's head as GitHub reports it — never the workspace's unproven tip — and edits it by number: the reply "PR updated … body re-rendered at its head<sha>" with the workspace's state kept beside it, the recordpr_openedcreated: falsewith nohead(the run's own push is unproven, so the release has no branch to remember) plus thesubmittedartifact; a lookup answer with no head commit, or a failed edit, names the PR and says the description was not re-rendered; a branch the remote said is absent is never asked (no same-repo open PR can head it), and null or a failed lookup leaves the honest note standing — "no PR was opened" is reserved for a branch no open PR heads; when the pushed branch and the checkout disagree, the note names both (the branch the push named, and the branch the workspace was checked out on) so the reader knows which branch was asked about; a branch that is its own base says so — a description submitted while the observed branch IS the resolved base (a workspace that never left the default branch; a run dispatched at a branch the thread's binding names as its base too) makes no PR call and offers no compare URL, and is never silent: the post-step publishes arun_noteof kindpr_not_openednaming the branch and returns the reply note ("the branch<b>is the base branch the pull request would target, so no PR was opened"), so the card and the record — and a plan runner'spr-checkreading them — show why no pull request followed; unless the thread's own pull request is known (CodingPrTarget.ownPr: the pull request the thread's own run opened, inherited off the run record with the head commit the resolver fetched —recordPrOfinrepoContext.ts,prFromRecord; resident-repos.md item 29 — never a PR a person named): a workspace on the base is a run that pushed nothing (a push would have named its branch, and the base is not one the gate lets it push), so the description is for that pull request — edited by number (updatePullRequest, the same REST PATCH open-or-edit uses), the body rendered at ITS head and never at the base's tip the workspace shows, the reply the "PR updated" wording, the recordpr_openedwithcreated: falseand nohead(nothing was pushed, so the release has no branch to remember) plus thesubmittedartifact; a failed edit is said with the pull request's URL and the reason, never a fabricated success. The refusal stands for a run with no known pull request, and a run that pushed a branch keeps open-or-edit from that branch whatever the thread's PR. The thread's own pull request stays the target once it is merged or closed (recordPrOfstatemerged/closed, off the resolver'sclosedRecordPr— the fail-closed rule binds no ref and posts no review to it, but its body is still the record of the change): a description resubmitted with nothing pushed past that pull request's head edits its body by number and the reply says the pull request is merged (or closed) and its description edited in place — a thread rebound onto the pull request's own head branch (resident-repos.md item 16) reaches the same edit, since a base resolved off the binding is then the branch itself, never a refusal calling that head "the base"; a push of the pull request's own head branch past that head is new work, which a closed pull request cannot take — no edit, no open call, apr_not_openednote and the compare URL. Only that branch can be pushed past it (recordPrOf'sheadBranch: the head GitHub names for the pull request — the ref bound from an open one,closedRecordPr.headReffor a closed one whose head lives on this repository; none for a fork's): the observation cannot tell a run's push from a checkout at the remote's tip, and a tree on the base at origin's tip — the thread returned to the default once the branch was gone (resident-repos.md item 16's second movement) — is exactly that, so the base is never reported as pushed and a description from it edits the closed pull request like any run that pushed nothing, the reply saying so with no word of a push and no compare URL; a tree on some other branch describes that branch, not the closed body, and takes the ordinary path — opened or edited from the branch against the base when the remote holds it, the unpushed note when it does not. With no description submitted the same workspace has nothing to report, and the agent's own report stands.The identities on the pull request (record 0062): the identity rewrite runs before the open or edit of the pushed branch (agent-coding.md item 2 — on
unreadablenothing opens, onrewrittenthe pull request pins the rebuilt tip and the note andpr_openedcarry the count; a run whose repo only the workspace's origin named recorded no start state — the dispatch fired no read at attach — so its rewrite fails closed with a reason saying no read was attempted, and that path opens no pull request). A verified bound login controls assignment: the pre-check must answer 204; a 404 is skipped with one log line. Requester display identity is independent of that binding. Every system-created body starts withRequested by **<display name>** · [Thread](<thread page>), before the map in item 3, including coordinator recovery with or without a submitted description. The requester is the admitted message's person, or the matching durable pipeline requester's person and originating thread, never a steer's author. A missing name falls back to a verified login or the recorded user id; recovery falls back to the instance's user id. Display text is escaped as text. The link is the tokenless Switchboard thread page using the full namespaced thread key (not a viewer-relative web conversation id), with no run link. A missing or invalid public base omits the link, not the name. The renderer'sRenderContext.requestedByis bot-owned provenance outside the model's description object; open, re-render and recovery preserve it. The parser excludes this leading provenance line from the TL;DR so a round trip never duplicates it. Thread-page access and its source-channel link follow web-chat.md item 11.The inverse: a rendered body back into the object.
parsePrDescriptionMarkdown(body)→{ description: Partial<PrDescription> & { pointers: RenderedPointer[] }, complete, problems }reads a body by the contract item 3 writes: thetldris every line before the first bold label (headings dropped); each of**Why:**,**Feedback wanted:**,**Risk:**,**Verified:**owns the lines from its label to the next label, the**Where to look**list or the first<details>(case-insensitive; a label inside a fence is code; a duplicate label keeps the first, drops the second's lines and is a problem; a bold phrase that is not a label stays in the field it sits in); pointers by their numbered rowsN. [label](permalink) text ⚠ risk— the path percent-decoded, the anchor validated byPrAnchorSchema, the permalink's sha riding on the anchor asRenderedPointer.anchor.shaso a reader can tell whether it is at the head being looked at, the text and the risk split on⚠; the folds by their<summary>text (Decisions,Validation,For agents; an unknown fold is skipped), decisions by- **Title.** rationale, validation by the| Criterion | Proof |table with\|/\\unescaped. A body under the previous contract (a## Tourheading outside a fence) is read by that grammar:## TL;DR(else the leading paragraph) → tldr,## What & why→ why, each### N.step's bare permalink line → a pointer with the step's title as label and its description (a**Look for:**appended) as text,## Risks & implications→ risk,## Decisionsand the## Validationtable as today;Remaining changesand the validation summary have no home and are dropped;legacy Tour shapeis always a problem, socompleteis false. Strict where the renderer is strict, lenient where a person would be, never a throw: a row without a github permalink, with a schema-invalid anchor (absolute or traversing path,to < from) or a malformed percent escape is a problem and is dropped; a 7–40-hex sha and a single-line#L<a>are accepted; a body without either shape takes its first text astldr; every missing label or fold is a problem;complete⇔problemsis empty. Round trip on the golden:tldr,why,pointers(plus each anchor's sha = the render head),feedbackWanted,risk,verified,decisions,validationandagentNotescome back equal. Lossy by construction: the PR title is not in the body; a decision title that ended in a period loses it (the renderer's normalization); a newline inside a validation cell was flattened to a space when rendered. This is what a review run publishes for a PR no coding run described (reading-diff.md item 7).
Roadmap (gaps)
None open. The panel renderer is reading-diff.md item 12: the run page renders the same object beside the reading diff from the review_artifact pr_description every PR review carries (item 7) — never a second authoring path.
Validation criteria
Complete literal rendered bodies prove title exclusion and inline permalink placement; subset-only renderer checks use those same independent oracles. Negative description-turn fixtures carry accepted-push evidence so each later refusal condition is exercised.
| Criterion | Proof |
|---|---|
| The description uses Simplified Technical English and explains the target product, user task, current problem and changed result for a reader with no system context | [agent] Follow skills/pr-description/SKILL.md to write a description. Read only the rendered map, without the diff or linked issues. Identify what the product lets a person do, who uses the affected part, what happens today and what changes. Check for short, direct sentences, active voice and concrete terms; define unfamiliar terms at first use. |
| Schema: an omitted, empty or whitespace-only optional pointer risk is absent through submission and rendering; nonstrings, nonblank over-cap or multiline risks still fail, and top-level risk remains required | [unit] src/core/prDescription.test.ts::parsePrDescription (the schema)::treats blank optional pointer risks as absent through render and parse, but keeps risk validation, src/tools/submit.test.ts::submit_pr_description tool::accepts schema-valid blank optional pointer risks and records them as absent |
| Schema: complete object accepted, lines trimmed; no pointers, missing fields and blank required strings rejected naming the field; decisions may be empty, criteria may not | [unit] src/core/prDescription.test.ts::parsePrDescription (the schema) (7) |
Anchors: absolute or traversing path, non-positive line, to < from rejected | [unit] ::rejects a bad anchor… |
| The map renders in contract order — tldr first with no heading, the bold labels, the numbered pointers — then the folds, then the footer; no heading anywhere | [unit] ::renders the map in the contract order… |
A pointer is N. [label](permalink) text ⚠ risk, numbered from 1, ⚠ only with a risk, a ] in a label escaped; no bare permalink line anywhere in a rendered body | [unit] ::a pointer is …, src/core/prDescription.test.ts::golden: a full PR description rendered through the pipeline::fixture → markdown equals the checked-in body byte for byte |
Sha is a render input: same object, different head → different anchors; anchorUrl shape | [unit] ::anchors take the sha from the render context… |
Short sha / branch ref / non owner/name refused | [unit] ::refuses a short sha or a non owner\/name repo |
| Every field capped in visible characters with the cap and the count named, a link's target not counted; pointers at most seven; the collapsed half capped too | [unit] ::caps every map field…, ::caps the pointers at seven… |
On the repository that carries the title gate the title is judged as the gate judges it, with the gate's own sentence: an unknown scope names the scope, the whole vocabulary and the code map; an unknown type, a grammar miss and a trailing period are refused the same way; every problem is one issue at title in the gate's order; a code-map scope, no scope, the bots' scopes over the cap and a revert pass; the golden description's title passes the gate; the cap is the gate's 72 raw characters, a link's target counted, refused with the gate's count sentence; another repository, or none, keeps the cap alone in the universal wording and accepts a title the gate would refuse; the repository is matched case-insensitively; the tool answers error: invalid PR description — title: <the gate's sentence> with the sink untouched on that repository and records the same title anywhere else; through the run loop, a coding run on that repository sees the refusal in its tool result and its corrected resubmit opens the PR, while the same title on another repository opens as before | [unit] src/core/prDescription.test.ts::the title is judged as the CI title gate judges it (release-and-deploy item 22)::*, src/tools/submit.test.ts::submit_pr_description tool::on the repository that carries the title gate, a title the gate would refuse…, src/core/dispatcher.test.ts::on the repository that carries the title gate, the tool refuses a title CI's… |
| Requester attribution leads the body independently of a GitHub binding, is escaped as text and stays outside the parsed TL;DR and agent notes; assignment still requires a verified login and a 204 pre-check | [unit] src/core/prDescription.test.ts::renderPrDescriptionMarkdown::the requested-by line leads the body without a GitHub binding and stays outside the parsed description, src/core/prDescription.test.ts::renderPrDescriptionMarkdown::requester display text cannot inject Markdown, mentions, HTML or a second header line, src/core/codingPrPostStep.test.ts::runCodingPrPostStep — the identity rewrite before the open (record 0062)::the body leads with requester attribution with or without a binding, while only a binding allows assignment, src/core/codingPrPostStep.test.ts::runCodingPrPostStep — the identity rewrite before the open (record 0062)::the assignee is added after a 204 and skipped after a 404; a clean rewrite opens with no count on the note or the event |
| Request provenance survives binding failures, resumed publication, edits and coordinator recovery; missing child/name uses the durable requester's facts; the first line links the originating thread, not a run | [unit] src/core/dispatch/runLoop.test.ts::a resume with the answer in hand (the \finish` plan)::PR attribution uses the initiating requester and thread even without a binding or after a binding lookup failure, src/core/codingPrPostStep.test.ts::runCodingPrPostStep — the identity rewrite before the open (record 0062)::the requested-by line survives a re-render…, src/channels/adminCoordinator.test.ts::POST /admin/coordinator/pr-check — the open pull request heading the instance's branch (item 9)::recover after a dead coding child…, src/channels/adminCoordinator.test.ts::POST /admin/coordinator/pr-check — the open pull request heading the instance's branch (item 9)::recovery attributes the durable requester even when the child record or display name is missing` |
| Thread URLs use full Slack/web keys, omit capabilities and omit missing/invalid destinations; the thread page retains its source-channel link | [unit] src/core/dispatch/reply.test.ts::threadPageLink — tokenless request provenance::*, src/channels/web.test.ts::GET /threads and /threads/<id> — the seed from the runs service (items 2, 7, 8, 11)::a thread from another channel the viewer may read opens read-only… |
| The refusal carries the cut (item 5): every failing field is named in one refusal; each cap issue says how many characters to remove (visible for prose, raw for the title — on the gated repository inside the gate's own sentence) and quotes the longest prefix that fits, cut at a word boundary, never inside a link, a link that does not fit dropped whole, the orphaned separator trimmed, already-fitting text unchanged, the empty string and no quote when no word fits; an object over two caps is refused once with both prefixes and a resubmit of the quoted prefixes is accepted; a non-cap issue keeps its wording and carries no cut | [unit] src/core/prDescription.test.ts::fitToCap — the longest prefix that fits a cap, cut at a word boundary::*, src/core/prDescription.test.ts::describeDescriptionIssues — every issue named, every cap issue with its cut::*, src/tools/submit.test.ts::submit_pr_description tool::an object over two caps is refused once naming BOTH fields…, src/core/dispatcher.test.ts::coding PR post-step…::a description over two caps is refused ONCE with both fields… |
A refused submit is on the record (item 5): the tool publishes a description_refused run note before it answers — the summary counting the fields over their cap and naming them, or the issues when none is a cap; the object as submitted with every string leaf redacted, then clipped to what the record can carry (recordedJson: JSON four objects deep, a whole description kept as it is, a deeper object stored as its JSON text); the issues with their cuts, redacted too — and the friction reader accepts the kind | [unit] src/tools/submit.test.ts::submit_pr_description tool::a refused submit is on the record…, src/core/prDescription.test.ts::recordedJson — the refused object as the record can carry it::*, src/tools/submit.test.ts::submit_pr_description tool::a refusal for reasons other than a cap names the issues on the note too…, src/core/runEventLines.test.ts::parseRunEventLines::accepts every declared run_note kind… |
The folds: no decisions → no Decisions block, agentNotes → For agents, the validation fold names its count, each <details> opens with its summary and a blank line; decisions punctuation; cell escaping | [unit] ::the folds…, ::decisions read … |
| A maximal object renders a map under 3,800 visible characters and a body under 26,000 | [unit] ::a maximal object renders a map under 3,800 visible characters… |
| The golden description renders byte-for-byte to the checked-in body, and the PR's live body is the same object at its real head | [unit] ::golden: a full PR description rendered through the pipeline. [agent] gh pr view <n> --json body --jq .body equals scripts/render-pr-description.ts src/core/testing/goldenMap.description.json --repo <owner/repo> --head <headRefOid> (modulo GitHub's trailing-newline normalization) |
Tool: valid object reaches the hook parsed and trimmed; last valid call wins and an invalid call leaves the valid one standing; schema violations are string errors naming the path (missing field, blank title, bad anchor); every field of the map and the folds declared, agentNotes the one optional; full toolset only | [unit] src/tools/submit.test.ts::submit_pr_description tool (11) |
Open-or-edit idempotency: lookup state=open + head=owner:branch first, create only when absent; typed title/head/base — prose in the body cannot alter them; missing credential or non-2xx → throw with detail; oversized body clipped visibly | [unit] src/execution/githubPulls.test.ts::githubPulls (9) |
Dispatcher renders at the observed head and opens from typed values: the observed head branch as head, base from binding ref (else resolved ref), title from the object; reply carries the returned URL | [unit] src/core/dispatcher.test.ts::description submitted + head observed → the PR opens from typed values…, ::no resident binding → base falls back to the dispatch's resolved ref |
| The head branch is the coordinator-owned unit ref for a child and the branch the run's push named for a standalone coding run: HEAD moved to another branch after the push → the PR still opens from the PUSHED branch, the body rendered at that branch's local tip (never the checkout's commit); no accepted push → the checkout is only the observed branch, not proof of publication or authority to open a PR; the pushed branch gone from the remote while the checkout moved on → the honest note names BOTH branches, no PR call, no compare URL; the pushed branch's local ref gone too → "could not be observed" naming both | [unit] src/core/dispatcher.test.ts::HEAD moved to another branch after the push …, ::the pushed branch is gone from the remote while the checkout moved on…, ::no push observed in the run → the checkout is observation, not a run-owned publication; src/core/codingPrPostStep.test.ts::HEAD moved to another branch after the push …, ::the pushed branch is not on the remote while the workspace sits on another…, ::the pushed branch has no local tip to observe…; ::observeCodingWorkspace::a push named another branch than the checkout …, ::no push observed → the checkout IS the head branch…, ::the pushed branch is the checkout too…, ::the pushed branch has no local ref any more…, ::a pushed branch name with shell metacharacters reaches every probe quoted |
| A coordinator child still opens the unit PR when the last push and dirty checkout are an auxiliary branch, provided the owned unit branch is proven at the remote and has a description; a skipped auxiliary checkout checkpoint does not suppress publication | [unit] src/core/dispatch/runLoop.test.ts::a resume with the answer in hand (the \finish` plan)::opens the unit PR after an auxiliary branch push and a dirty auxiliary checkout` |
pushedBranchOf reads only git's push status block, and trackPushedBranch reads it only from the result of a bash call whose command invoked git push (paired by callId — a cat/echo of a transcript, or a result with no paired call, names nothing; the latest push wins and a later non-push result never erases it): new-branch, fast-forward, forced and up-to-date lines name the remote branch (HEAD:feat/x names the remote side; a redacted credential in the To URL still counts); rejections, deletions and tags name nothing (a deletion line stays inside the block, so a mixed push still names its updated branch); a git fetch block (From) and a status-shaped line with no To header never count; the block ends at the first non-status line; the last push wins; only bash results with output are read; the push command is recognized on the event's full command (a chained command whose push sits past the 200-char summary cap still counts), and on the capped summary alone for an event without command (where it is missed); startPoint() reads where the run's FIRST push found the branch — [new branch] created, old..new the pre-push head, an up-to-date first push and a restored branch unknown, a later push never overwriting the first — the start point the identity rewrite subtracts for a branch the dispatch did not know at attach (agent-coding.md item 2) | [unit] src/core/codingPrPostStep.test.ts::pushedBranchOf (the branch a run's own git push named) (12), ::trackPushedBranch (paired with the command that produced the output) (6, incl. ::a chained command whose \git push` sits past the 200-char summary cap…, ::startPoint(): the FIRST push's status says where the branch started…); src/core/dispatcher.test.ts::a push block printed by …` |
| A verified repush on the thread's PR branch uses the PR's TRUE base ref rather than that head branch; a checkout merely matching the PR head without an accepted write cannot edit it. | [unit] src/core/codingPrPostStep.test.ts::runCodingPrPostStep (callable with explicit inputs)::the base falls to the PR's true base ref first (a fix round repushes the PR's own head branch), src/core/dispatcher.test.ts::coding PR post-step (docs/reference/specs/pr-description.md)::a thread bound to an existing PR's head branch cannot edit it without a trusted accepted write |
| Repush → the existing open PR is edited, never duplicated; the reply says updated, not opened | [unit] src/core/dispatcher.test.ts::an existing open PR is edited (open-or-edit): created:false → the reply says updated, not opened |
A coordinator's child dispatched at its unit branch (the resident binding ref IS the branch) opens its PR against the plan's base the tag carries, pr_opened in the record; the same child without a tag reads the branch as the base — no PR call, a pr_not_opened note in the record and a reply saying the branch is the base, never silence | [unit] src/core/dispatcher.test.ts::a coordinator's child dispatched at the unit branch (the resident binding ref IS the branch) with a tag carrying the plan's base…, ::the same child without a coordinator tag: the binding ref is the branch, so the branch is the base… |
The branch is its own base with a description submitted → no PR call, no compare URL, a pr_not_opened note published naming the branch, the reply note says the branch is the base (the GitHub default-branch fetch resolving to the branch included) — for a run with no known pull request; with no description, nothing to report and no event | [unit] src/core/codingPrPostStep.test.ts::the branch IS the resolved base (a resident binding at the branch) with a description submitted…, ::the branch is the base and no pull request is known → the refusal stands…, ::GitHub's fetched default branch equals the observed branch → no PR call…, ::the branch is the base but no description was submitted → nothing to report… |
The branch is the base but the thread's own pull request is known (ownPr, nothing pushed) → that pull request is edited by number with the body rendered at its head (never the workspace's tip), pr_opened created:false with no head and the submitted artifact in the record, the "PR updated" reply, no open call and no refusal; a failed edit names the PR and the reason with no event; a run that pushed a branch keeps open-or-edit from that branch; a thread bound to its own pull request's head branch (a base resolved off the binding is then that branch) gets the same edit, never the base-branch refusal; recordPrOf answers the record's PR with its head and its state whatever ref the message phrased, and none for a person's PR, no PR, or a record PR without a head; the run loop hands the record's PR to the post-step and the reply says the PR was updated | [unit] src/core/codingPrPostStep.test.ts::the branch is the base but the thread's own pull request is known (nothing pushed)…, ::the thread is bound to its own pull request's head branch, no base known and nothing pushed…, ::the edit of the thread's own pull request fails…, ::the thread's own pull request is known but the run pushed a branch…, src/core/repoContext.test.ts::resolveRepoContext: the pull request the thread's own run opened binds the follow-up (resident-repos item 29)::recordPrOf: the record's PR with its head commit…, src/core/dispatcher.test.ts::coding PR post-step (docs/reference/specs/pr-description.md)::a description resubmitted from the base branch in a thread whose own run opened the PR… |
The thread's own pull request is merged or closed since (recordPrOf state merged / closed, off the resolver's closedRecordPr): a description resubmitted with nothing pushed past that pull request's head edits its body by number — pr_opened created:false, the submitted artifact, the "PR updated" reply saying the pull request is merged (or closed) and its description edited in place, never the base-branch refusal; a push of the pull request's own head branch past its head is new work — no edit, no open call, a pr_not_opened note naming the pull request and the reply with the compare URL, never the old body re-rendered at the old head — and only that branch (ownPr.headBranch) is ever "pushed past" it: a workspace on the base at the remote's tip (the thread returned to the default, nothing pushed) edits the closed body with the closed wording, no word of a push and no compare URL; a third branch the remote holds takes the ordinary open-or-edit from that branch against the base, one the remote lacks the unpushed note — neither an edit of the closed body; the resolver hands the record's closed PR over beside prUnpostable with the pull request's own merge-time head — never the branch's current tip, which a push after the merge may have moved: no ref-tip fetch is made for a closed pull request — and its head branch as headRef when the head lives on this repository (none for a fork's head, none for a headless one) and never a PR a person named; end to end, a thread bound to its merged pull request's head branch gets the edit and the merged wording, and one returned to the default after its pull request closed gets the edit and the closed wording with no word of a push | [unit] src/core/codingPrPostStep.test.ts::the thread's own pull request is merged and nothing was pushed past its head…, ::the thread's own pull request is merged but the branch was pushed past its head…, ::the thread's own pull request is closed and the workspace sits on the base at the remote's tip…, ::the thread's own pull request is closed and the run pushed a third branch…, ::the thread's own pull request is closed and the workspace sits on a third branch the remote does not hold…, src/core/repoContext.test.ts::resolveRepoContext: the pull request the thread's own run opened binds the follow-up (resident-repos item 29)::fails closed like every inherited PR…, ::a merged record PR's head is the pull request's own merge-time head…, ::a closed PR a person named in a turn is unpostable and nothing more…, src/core/dispatcher.test.ts::coding PR post-step (docs/reference/specs/pr-description.md)::a description resubmitted in a thread whose own pull request is merged, the thread bound to its head branch…, ::a description resubmitted after the thread returned to the default, its own pull request closed and nothing pushed… |
Pushed is proven by the remote's own refs/heads/<branch>: a single-branch clone whose @{u} fails after a real push still opens its PR; a push without -u too; the remote holding an older commit → unpushed commits; exit 2 ("no such branch") is final even over a stale @{u}; a failed remote probe falls back to @{u}; only the exact ref counts — an exit-0 answer of look-alike refs is "absent" (final), an exit-0 answer with no ref line is a failed probe; a detached checkout asks the remote nothing; the branch ref reaches the shell quoted | [unit] src/core/codingPrPostStep.test.ts::observeCodingWorkspace::single-branch clone …, ::no upstream configured at all…, ::the remote holds an older commit…, ::the remote answers 'no such branch' (exit 2)…, ::the remote probe itself fails…, ::only the exact ref counts…, ::an exit-0 answer with no ref line…, ::a detached checkout…, ::a branch name with shell metacharacters… |
| Not pushed and no open PR heads the branch → no PR call, honest note without a compare URL: the branch not on the remote ("was not found on the remote"), the remote branch behind HEAD ("unpushed commits", both commits named), or an unobservable head | [unit] src/core/codingPrPostStep.test.ts::the branch is not on the remote at all…, ::an unproven push (the remote branch behind the workspace)…; src/core/dispatcher.test.ts::the remote has no such branch → the branch does not count as pushed…, ::the remote branch behind the workspace HEAD → not pushed…, ::description submitted but the pushed head is unobservable… |
A "no PR was opened" ending never hides a pull request that exists (the budget wind-down's endings): a description with unpushed commits over a remote branch an open PR heads → that PR edited, the body rendered at ITS head, pr_opened created:false with no head, the submitted artifact, the "PR updated … at its head" reply naming the unpushed work; the same for an unobservable pushed head on a known branch; a lookup answer without a head commit, or a failed edit → the note names the PR and says the description was not re-rendered; a fresh branch (no open PR, or the lookup throws) keeps the honest "no PR was opened" note; a branch the remote said is absent is never asked | [unit] src/core/codingPrPostStep.test.ts::description + unproven push (unpushed commits) + the branch heads an open PR…, ::description + unobservable pushed head + the branch heads an open PR…, ::description + unproven push + the lookup answers with no head commit…, ::description + unproven push + the edit fails…, ::description + unproven push onto a fresh branch (no open PR heads it, or the lookup throws)…, ::the open-PR lookup is never asked when open-or-edit will do its own… |
| Cold path: a clone in a subdirectory is discovered and probed; no git anywhere → honest note; an agent-discovered repo's slug comes from the origin remote, an unparseable remote refuses honestly | [unit] src/core/dispatcher.test.ts::cold path: the clone lives in a subdirectory…, ::no git repository anywhere in the workspace…, ::dispatch resolved no repo slug (agent-discovered repo)…, ::no repo anywhere — dispatch resolved none and the origin remote is unparseable… |
Failure honesty: proven-pushed branch + no description → the open-PR lookup first: an open PR heading the branch → the reply names it as updated by the push and warns that the description was not resubmitted (no compare URL, no PR call or pr_opened from lookup; the accepted branch head is already recorded); none, or a failed lookup → compare URL + plain statement, no PR call; open throws → error + compare URL, run still completes; nothing pushed → no call and no note | [unit] src/core/codingPrPostStep.test.ts::no description + accepted push + an existing PR lookup → note names changed code without claiming a metadata edit, ::no description + proven push + no open PR heads the branch…, ::no description + proven push, the open-PR lookup throws…, ::the open-PR lookup is never asked when open-or-edit will do its own…; src/core/dispatcher.test.ts::no description submitted but a pushed branch is observable…, ::no description submitted, an accepted push on an existing PR updates its code; lookup does not emit a metadata edit, ::no description submitted and the open-PR lookup fails…, ::openPullRequest throws…, ::the workspace sat on the base branch…, ::description submitted but no branch is observable… |
The description turn: due only for no description + a producer-confirmed push whose head the remote still holds + a branch that is not the base + an open PR heading it (a failed lookup → no turn); the follow-up names the PR, the pushed head, github_pull_get and its arguments, submit_pr_description, and forbids a further push or opening a PR; the turn publishes the description_turn note and prompts the run's session once under the clip (8 turns / 5 min, never the shared def) with the turn's tool context, and reports the description the hook received (none on a bare answer or a refused prompt, never a throw); without a session (a finish plan) it asks nothing and says so; end to end, the turn's description edits the PR with the updated wording and pr_description + pr_opened + the note land in the record; a turn that submits nothing leaves the ⚠️ note naming the turn; no turn when a description was submitted, the push is unproven, or no open PR heads the branch | [unit] src/core/descriptionTurn.test.ts::descriptionTurnTarget — when a description turn is due (6), ::descriptionFollowUp — the user turn the model is given, ::runDescriptionTurn — one clipped prompt on the run's own pi session (harness-pi item 14) (3); src/core/dispatcher.test.ts::a description-less push onto a branch that heads an open PR → ONE description turn…, ::the description turn still submits nothing → the ⚠️ note says the turn was given…, ::no description turn when a description was submitted, when the push is unproven, or when no open PR heads the branch; src/core/dispatch/runLoop.test.ts::the pi harness — every preset's runs, in the run's container::a coding run on pi whose push landed on an open PR without a description… |
A remote tip matching the checkout without a producer-owned accepted write starts no description turn and publishes no pushed_head or claimed update. An adopted existing-PR writer cannot edit its bound PR without acceptance; ordinary metadata-only edits record pr_opened(created:false) only after a successful API call. A lookup alone records no edit, even after a genuine push with no description. | [unit] src/core/descriptionTurn.test.ts::descriptionTurnTarget — when a description turn is due::an existing-PR checkout at the remote tip without an accepted push receipt gets no description turn, src/core/codingPrPostStep.test.ts::runCodingPrPostStep (callable with explicit inputs)::an existing-PR run without an accepted push cannot edit its PR even after submitting a description, src/core/codingPrPostStep.test.ts::runCodingPrPostStep (callable with explicit inputs)::an accepted write is already recorded before the post-step; remote equality alone never emits another pushed head, src/core/codingPrPostStep.test.ts::runCodingPrPostStep (callable with explicit inputs)::a description alone edits existing PR metadata only when the API succeeds, without inventing a push, src/core/codingPrPostStep.test.ts::runCodingPrPostStep (callable with explicit inputs)::an observed remote tip without a run-owned push never emits a pushed head or calls the PR writer, src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::gives an existing-PR writer the verified adoption receipt and does not invent a push from its clean checkout, src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::keeps an existing PR unchanged when its writer submits a description without pushing |
Readonly runs never trigger the post-step; observation happens before workspace release; the accepted object rides a pr_description event with EVERY string leaf redacted; the outcome rides a pr_opened event published before the stream finishes | [unit] src/core/dispatcher.test.ts::a readonly review run with a verdict never triggers the PR post-step, ::the head and branch are observed BEFORE the workspace is released, ::the accepted description is published on the run stream as a typed pr_description event, redacted, ::redaction walks every string leaf…, ::the PR outcome is a fact of the run: a typed pr_opened event… |
| 6: the golden renders and parses back equal — tldr, why, pointers with each anchor's render sha, feedbackWanted, risk, verified, decisions, validation, agentNotes; the title is not in the body; a small description round-trips (no decisions, a pointer risk, an escaped label, single-line anchor, cell unescaping) with the decision's trailing period as the lossy part | [unit] src/core/prDescription.test.ts::parsePrDescriptionMarkdown — the inverse of the renderer::golden round trip…, ::a small description round-trips too… |
6: a body without either shape never throws — first text as tldr, no pointers, complete: false, problems naming the missing labels; a hand-written map body (CRLF, a bold note inside the tldr, no folds, a short sha) parses and names exactly the missing fold | [unit] ::a body without either shape never throws…, ::a hand-written body in the map shape… |
6: pointer strictness — no github permalink, a non-github link, a traversing path or to < from is a problem and dropped; a ## or a label inside a fence is not structure; a duplicate label keeps the first; a percent-encoded path is decoded, a malformed escape is a problem; an unknown fold is skipped; an unrecognized decision line is a problem. A legacy Tour body parses by the old grammar into pointers with legacy Tour shape as a problem | [unit] ::pointer strictness…, ::a percent-encoded path is decoded…, ::a legacy Tour body parses by the old grammar… |
Panel renderer: the run page's review panel renders the object's title and prose — the TL;DR and the Why on its Description tab — from the review's pr_description artifact; the pointers are the PR body's rendering alone | [unit] web/src/modules/pr-review/prReview.test.ts::PrReviewPanel::the Description tab…, web/src/pages/runPage.test.ts::PR-review panel wiring::a seeded pr_description renders in the panel… |
| Durable recovery creates a pull request with one native POST and credits only a positive receipt matching its frozen full head, repository, refs and exact title/body; edited sweep descriptions likewise require exact native receipt. Transport/unreadable or mismatched replies remain uncertain and cannot authorize replay. | [unit] src/execution/githubPulls.test.ts::githubPulls::durable pull mutation receipts::* |