mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
v1.91.366.0 fix: a partial gstack-post pr-body post keeps the PR's intro (#3683)
* gstack-post: a partial pr-body post keeps the PR's intro; only a full post owns the preamble Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * gstack-post: only a receipt above the first heading makes a full post (review fixes) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: a partial pr-body post's own lead text still replaces the PR's (docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
a305302963
commit
12dc6782c2
8 files changed
+58
-25
No files matched your search
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.366.0] - 2026-10-31
|
||||
|
||||
**A partial `gstack-post pr-body` post keeps the PR's intro.** Since GST-412, any body file with a `## ` section replaced the text above the PR's first heading, so a lane that posted only `## Screenshots` deleted a human intro or a `Review-loop exception:` line under the receipt (GStack Minder: "a partial pr-body post must keep the human intro").
|
||||
|
||||
### Fixed
|
||||
- **Only a full post owns the text above the first heading.** A file with a receipt block above its first `## ` section, as /ship composes it, still replaces stale lead text and stale notes under the receipt. Any other file keeps them; its own lead text still replaces the PR's lead text.
|
||||
|
||||
### Tests
|
||||
- `test/gstack-post.test.ts`: a partial post keeps the intro and the note under the receipt, even with a caption of its own or a receipt further down; the full-post cases now carry a receipt, as /ship's body does.
|
||||
|
||||
## [1.91.365.0] - 2026-10-31
|
||||
|
||||
**An unclosed code fence in an outside review no longer hides a later `[P1]` or not-ready verdict (GST-436).** Before, an outside-voice reply that said no issues were found, opened a code fence it never closed, and then listed a `[P1]` finding or ended with `Verdict: not ready` was classified clean, because everything after the fence was treated as quoted code. Now a P0 or P1 severity or a last `Verdict: not ready` after an unclosed fence still blocks. Text inside that fence still can't make a reply clean, so a quoted `Verdict: ready`, `[P3]` or `No issues found.` there is ignored, as before.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
### P3: GST-421 and GST-412 follow-ups (filed 2026-10-31, from the PR B /ship review)
|
||||
|
||||
- **Partial `gstack-post pr-body` posts drop the intro.** Since GST-412, any body file with a `## ` section replaces the text above the current body's first heading, so a lane that posts only `## Screenshots` deletes a human intro or a `Review-loop exception:` line written there (stderr names it; GitHub's edit history keeps it). Decide whether the preamble should be managed only when the file brings its own lead text or a receipt, or have /ship put the exception line under a named section. **Priority:** P3.
|
||||
- **Check the Approvals table's own cells.** `--check-prose` skips the whole rendered Approvals block, including the Asked, Covers and Still pending cells the lane types. Narrow the skip to the Exact words column. **Priority:** P3.
|
||||
- **The root `.bun/` blind spot.** `gstack-wtree` leaves every untracked file under a root `.bun/` out of the fingerprint, so an edit there never makes evidence STALE. Narrow the exclusion to Bun's cache subfolder if anything starts loading code from it. **Priority:** P3.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.365.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.366.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -2522,12 +2522,15 @@ same-titled section (title compared case-insensitively), the
|
||||
````gstack-ship-receipt```` block is a section of its own so a receipt
|
||||
rewrite replaces only the receipt, sections the file does not rewrite stay in
|
||||
place, and new sections are appended after them. The text above the first
|
||||
`## ` heading belongs to the file whenever the file has a `## ` section: the
|
||||
current body's text there (the opening lines, and any lines between the receipt
|
||||
and the first heading, or the whole body when it has no `## ` heading) is
|
||||
replaced by the file's own text above its first heading, or dropped when the
|
||||
file has none. A file with no `## ` section (a receipt-only or text-only post)
|
||||
leaves that text alone. One stderr line names what was replaced, kept, added
|
||||
`## ` heading belongs to the file when the file is a full post: a
|
||||
receipt block above its first `## ` section, as /ship composes it. Then the current body's text there (the opening lines, and
|
||||
any lines between the receipt and the first heading, or the whole body when it
|
||||
has no `## ` heading) is replaced by the file's own text above its first
|
||||
heading, or dropped when the file has none. A partial post (`## ` sections
|
||||
with no receipt above them, such as a lane posting `## Screenshots`) and a
|
||||
file with no `## ` section (a receipt-only or text-only post) leave that text alone, so a human
|
||||
intro or a `Review-loop exception:` line under the receipt survives; only
|
||||
opening lines the file brings of its own replace the PR's opening lines. One stderr line names what was replaced, kept, added
|
||||
and dropped, a dropped piece with its first line, or with that line withheld
|
||||
when the redaction scan flags it (`gstack-post: pr-body merged
|
||||
by section: replaced What changed; kept ship receipt, Why, Approvals; added
|
||||
|
||||
+9
-5
@@ -9,8 +9,9 @@
|
||||
* `pr-body` merges by `## ` section into the PR's current body (read through
|
||||
* the same runner): a section in the file replaces the same-titled one, the
|
||||
* ship-receipt block is a section of its own, sections the file does not
|
||||
* rewrite are kept, new ones are appended, and a file with a `## ` section
|
||||
* replaces the text above the first heading; `--replace` overwrites instead.
|
||||
* rewrite are kept, new ones are appended, and a file with a receipt above
|
||||
* its first `## ` section replaces the text above the first heading;
|
||||
* `--replace` overwrites instead.
|
||||
* The scan runs on the merged bytes, which are the bytes sent.
|
||||
*
|
||||
* Exit codes: 0 posted; 1 HIGH finding (or oversize input) refused;
|
||||
@@ -212,25 +213,28 @@ function splitSections(body: string): Section[] {
|
||||
* A file section whose title the current body holds more than once (an old
|
||||
* description pasted under the new one) matches no single section, so the
|
||||
* merge refuses instead of replacing one copy and keeping the stale other.
|
||||
* A file with at least one `## ` section manages the preamble (GST-412): every
|
||||
* A full post (a file with a receipt block above its first `## ` section, as
|
||||
* /ship composes it) manages the preamble (GST-412): every
|
||||
* non-blank text section before the current body's first heading (the leading
|
||||
* text, text after a receipt block) is dropped, the file's own text before its
|
||||
* first heading takes its place (leading text first, text after the file's
|
||||
* receipt right after the receipt), a dropped piece the file brings no text for
|
||||
* is listed in `dropped` with its first line (withheld when the redaction scan
|
||||
* flags it, since the merged bytes no longer hold it), and the receipt keeps the rules
|
||||
* above. A file without a `## ` section merges as described above.
|
||||
* above. Any other file (`## ` sections with no receipt above them, or no
|
||||
* `## ` section) merges as described above, so the current intro is kept.
|
||||
*/
|
||||
export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[]; dropped: string[] } {
|
||||
const now = splitSections(current);
|
||||
const next = splitSections(incoming).filter(n => !(n.key.startsWith("text:") && n.text.trim() === ""));
|
||||
const ambiguous = next.filter(n => now.filter(s => s.key === n.key).length > 1).map(n => `"${n.label}"`);
|
||||
if (ambiguous.length) throw new UsageError(`the PR's current body holds ${[...new Set(ambiguous)].join(", ")} more than once, so the file's copy matches no single section; nothing was posted. Rerun with --replace and the whole body`);
|
||||
const managed = next.some(n => n.key.startsWith("h2:"));
|
||||
const preamble = (list: Section[]) => {
|
||||
const end = list.findIndex(s => s.key.startsWith("h2:"));
|
||||
return list.slice(0, end < 0 ? list.length : end).filter(s => s.key.startsWith("text:") && s.text.trim() !== "");
|
||||
};
|
||||
const firstHeading = next.findIndex(n => n.key.startsWith("h2:"));
|
||||
const managed = firstHeading > 0 && next.slice(0, firstHeading).some(n => n.key === "receipt");
|
||||
const stale = managed ? preamble(now) : [];
|
||||
const ours = managed ? preamble(next) : [];
|
||||
const role = (s: Section) => (s.key === "text:lead" ? "lead" : "trailing");
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.365",
|
||||
"version": "1.91.366",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+27
-10
@@ -446,21 +446,21 @@ describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
expect(after.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, Why; kept unclosed receipt fence; added none\n");
|
||||
});
|
||||
|
||||
// Value: protects=a file with sections owns the text above the first heading (GST-412): stale lead text and stale text between the receipt and the
|
||||
// Value: protects=a full post (a receipt above its first heading, as /ship composes it) owns the text above the first heading (GST-412): stale lead text and stale text between the receipt and the
|
||||
// first heading are dropped and named on stderr with their first line, the file's own preamble takes their place, a receipt-only or text-only file
|
||||
// keeps the older merge, a repost is byte-identical and an attribution callout is never doubled; fails_when=pr-body keeps a stale intro above the
|
||||
// receipt or a stale note under it after /ship reposts the sections; why_new=lead text was kept unless the file brought its own; seam=fake run;
|
||||
// no_claim=real gh
|
||||
test("a file with sections replaces every text section above the current first heading and names each dropped piece", () => {
|
||||
test("a full post replaces every text section above the current first heading and names each dropped piece", () => {
|
||||
const stale = withBody("Draft intro from gh pr create, long enough to need truncation in the summary line\nsecond line\n\n" + CURRENT, { "/b": RECEIPT("bbbb") + "\n## What changed\n\n- new\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], stale)).toBe(EXIT.posted);
|
||||
expect(sent(stale).input).toBe(RECEIPT("bbbb") + "\n## What changed\n\n- new\n\n## Why\n\nBecause.\n\n## Approvals\n\n- owner: do it\n");
|
||||
expect(stale.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, What changed; kept Why, Approvals; added none; dropped leading text (\"Draft intro from gh pr create, long enough to need truncati…\")\n");
|
||||
|
||||
const between = withBody(RECEIPT("aaaa") + "Old note under the receipt\n\n## What changed\n\n- old\n", { "/b": "## What changed\n\n- new\n" });
|
||||
const between = withBody(RECEIPT("aaaa") + "Old note under the receipt\n\n## What changed\n\n- old\n", { "/b": RECEIPT("bbbb") + "## What changed\n\n- new\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], between)).toBe(EXIT.posted);
|
||||
expect(sent(between).input).toBe(RECEIPT("aaaa") + "\n## What changed\n\n- new\n");
|
||||
expect(between.stderr()).toBe("gstack-post: pr-body merged by section: replaced What changed; kept ship receipt; added none; dropped trailing text (\"Old note under the receipt\")\n");
|
||||
expect(sent(between).input).toBe(RECEIPT("bbbb") + "\n## What changed\n\n- new\n");
|
||||
expect(between.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, What changed; kept none; added none; dropped trailing text (\"Old note under the receipt\")\n");
|
||||
|
||||
const own = withBody("Stale intro\n\n" + RECEIPT("aaaa") + "Stale note\n\n## Why\n\nold\n", { "/b": "Fresh intro\n\n" + RECEIPT("bbbb") + "Fresh note\n\n## Why\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], own)).toBe(EXIT.posted);
|
||||
@@ -469,6 +469,23 @@ describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
expect(own.stderr()).toBe("gstack-post: pr-body merged by section: replaced leading text, ship receipt, trailing text, Why; kept none; added none\n");
|
||||
});
|
||||
|
||||
// Value: protects=a partial post (no receipt above its first `## ` heading, even with a caption of its own or a receipt further down) keeps
|
||||
// the human intro and the note under the receipt; fails_when=a lane posting only `## Screenshots`
|
||||
// deletes the intro or a Review-loop exception line; why_new=GST-412 made any file with a section own the preamble (GstackMinder,
|
||||
// 2026-10-31: "a partial pr-body post must keep the human intro"); seam=fake run; no_claim=real gh
|
||||
test("a partial post keeps the current intro and the note under the receipt", () => {
|
||||
const partial = withBody("Human intro\n\n" + RECEIPT("aaaa") + "Review-loop exception: accepted by owner\n\n## What changed\n\n- old\n", { "/b": "## Screenshots\n\n\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], partial)).toBe(EXIT.posted);
|
||||
expect(sent(partial).input).toBe("Human intro\n\n" + RECEIPT("aaaa") + "Review-loop exception: accepted by owner\n\n## What changed\n\n- old\n\n## Screenshots\n\n\n");
|
||||
expect(partial.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept leading text, ship receipt, trailing text, What changed; added Screenshots\n");
|
||||
|
||||
for (const file of ["Screenshots below.\n\n## Screenshots\n\n\n", "## Screenshots\n\n\n\n" + RECEIPT("bbbb")]) {
|
||||
const merged = mergeBodySections("Human intro\n\n" + RECEIPT("aaaa") + "Review-loop exception: accepted by owner\n\n## What changed\n\n- old\n", file);
|
||||
expect(merged.body).toContain("Review-loop exception: accepted by owner");
|
||||
expect(merged.dropped).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=the dropped-piece excerpt on stderr never repeats a secret the merge removed from the body (the scan only reads the merged bytes);
|
||||
// fails_when=a stale intro holding a credential is echoed to stderr while it leaves the PR; why_new=the excerpt is new in GST-412; seam=fake run; no_claim=real gh
|
||||
test("a dropped piece whose first line the redaction scan flags is named without its text", () => {
|
||||
@@ -504,21 +521,21 @@ describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
});
|
||||
|
||||
// Value: protects=--replace keeps the plain overwrite for a caller that composed the whole body, a current body with no H2 headings is kept as the leading
|
||||
// section by a file without sections and dropped (and named) by a file with sections (GST-412), an empty current body posts the new body byte for byte,
|
||||
// section by a file without sections and dropped (and named) by a full post (GST-412), an empty current body posts the new body byte for byte,
|
||||
// the scan runs on the merged bytes, and an unreadable current body is exit 3 with the --replace hint rather than an overwrite; fails_when=--replace
|
||||
// merges, headingless text is dropped by a receipt-only post or kept under new sections, the merge changes a lone new body, a secret in a kept section
|
||||
// passes the scan, or a failed read overwrites; why_new=first pr-body merge tests; seam=fake run; no_claim=real gh
|
||||
test("--replace overwrites; a body without H2 headings is kept by a file without sections and dropped by one with sections; the merged bytes are what is scanned; a failed read refuses", () => {
|
||||
test("--replace overwrites; a body without H2 headings is kept by a file without a receipt and dropped by a full post; the merged bytes are what is scanned; a failed read refuses", () => {
|
||||
const replaced = withBody(CURRENT, { "/b": "## What changed\n\nonly this\n" });
|
||||
expect(run(["pr-body", "5", "--replace", "--body-file", "/b"], replaced)).toBe(EXIT.posted);
|
||||
expect(sent(replaced).input).toBe("## What changed\n\nonly this\n");
|
||||
expect(replaced.stderr()).toBe("");
|
||||
expect(replaced.calls.some(c => c.cmd === "gh" && c.args[1] === "view")).toBe(false);
|
||||
|
||||
const plain = withBody("Just a paragraph.\nAnother line.", { "/b": "## Tests\n\n- one\n" });
|
||||
const plain = withBody("Just a paragraph.\nAnother line.", { "/b": RECEIPT("bbbb") + "\n## Tests\n\n- one\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], plain)).toBe(EXIT.posted);
|
||||
expect(sent(plain).input).toBe("## Tests\n\n- one\n");
|
||||
expect(plain.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept none; added Tests; dropped leading text (\"Just a paragraph.\")\n");
|
||||
expect(sent(plain).input).toBe(RECEIPT("bbbb") + "\n## Tests\n\n- one\n");
|
||||
expect(plain.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept none; added ship receipt, Tests; dropped leading text (\"Just a paragraph.\")\n");
|
||||
const plainText = withBody("Just a paragraph.\nAnother line.", { "/b": RECEIPT("bbbb") });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], plainText)).toBe(EXIT.posted);
|
||||
expect(sent(plainText).input).toBe(RECEIPT("bbbb") + "\nJust a paragraph.\nAnother line.");
|
||||
|
||||
Reference in new issue
Block a user