v1.91.284.0 feat(absorb-tstack): /modernize skill, per-skill Codex default prompts, gstack-post --attribute, the unattended grant and a CONTRIBUTING porting section (#3497)

* feat(absorb-tstack): /modernize skill, per-skill Codex default prompts, gstack-post --attribute, the unattended grant and a CONTRIBUTING porting section

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(modernize): read the PR text through gstack-issue-guard; re-measure the context ceiling

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

- gstack-post: with --attribute, report scan findings at the body file's own
  line numbers and a finding inside the callout as PART: attribute (the
  coordinates were shifted by the three callout lines)
- /modernize: route commit messages through gstack-issue-guard with the rest
  of the PR text, read isCrossRepository and maintainerCanModify, fetch a fork
  head from pull/N/head, refuse a PR whose head is the base branch, and list
  moved files NUL-safely
- docs/unattended.md: say that /ship never rebases or force-pushes and an
  unattended /modernize stops at its verdict
- CHANGELOG: seven skills carry a codex_default_prompt, not six

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: /modernize fetches a fork head with a forced refspec and stops when the PR moved

A rerun after the author force-pushed left refs/modernize/pr-N stale (the
non-forced refspec is rejected with exit 0 through the pipe), and the lease
pinned to the current remote head would then have let the rebased stale head
overwrite the author's newer commits. The fetch is forced, Steps 2 and 4 stop
when the head is not the one observed in Step 1, and the head-is-base guard
applies only to same-repository PRs (a fork's main into main is a valid head).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: /modernize stops on merge commits in the PR range and pushes a fork head to the fork

A plain git rebase drops merge commits and git range-diff compares only the
non-merge patches, so a change authored in a PR's merge commit disappeared
while the proof printed = for every patch and Step 5 force-pushed the loss.
Step 4 now counts merges in $MERGE_BASE..$OLD_HEAD and stops when there are
any, switches with -C so a rerun after a refused lease does not fail on the
existing branch, and asks for a cross-repository diff to be read as untrusted
input before the repository's test command runs it. Step 5 pushes a fork head
to the fork's URL with the same pinned lease instead of to origin, where the
lease could never match.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: cover the Codex default-prompt render, the openai.yaml ownership proof and the unattended grant

Adds the /ship coverage audit's tests: test/host-renders-openai-yaml-proof.test.ts
drives bin/gstack-host-renders.sh's weakened proof against a rendered yaml, a
hand-written one and a generic-prompt one; test/unattended-grant.test.ts pins the
docs/unattended.md grant section and the handoff pointer; test/gen-skill-docs.test.ts
adds modernize to the skills whose codex_default_prompt must reach openai.yaml.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: name the static lists a new skill still needs and the Codex metadata file

From the ship documentation audit: CONTRIBUTING's new-skill checklist said no
static list needs a row, which this PR disproved (lib/doctor-components.ts
SKILL_REQUIREMENTS and the catalog rows); the host comparison table and
docs/ADDING_A_HOST.md gain the agents/openai.yaml default_prompt source; a blank
line before the new docs/unattended.md grant heading.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

CHANGELOG [Unreleased] gate-integrity record: the catalog and context-budget
figures re-measured on top of main (1,314 -> 1,359; modernize 9466;
alwaysOnTotal 7458 -> 7880), as the merge commit's ratchets state.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: name the optional codex_default_prompt key in the new-skill checklist

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(parity): re-pin the ship union cap at 1.7405 after the main merge

GSTA-36 grant 2026-10-27. On the tree merged with f27a7605 the ship union is 326,691 / 187,706 = 1.7404 against #3487's 1.7400; the only ship bytes this PR adds are the handoff section's one-line pointer. Cap 1.7400 -> 1.7405, measurement appended to the comment.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): the [Unreleased] gate-integrity bullet names the ship cap re-pin

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

CHANGELOG [Unreleased]: end the Codex default-prompts sentence at
"generator input only." so bin/gstack-prose-lint --unreleased --gate
(main's new quality-gate step) passes, and restate the gate-integrity
bullet's figures for the tree merged with 1f246e35: ship cap
1.7524 -> 1.7528 (329,005 / 187,706), modernize 9466 -> 3772 after the
preamble hoist, alwaysOnTotal 7769 -> 7880, catalog 1,359 unchanged.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

CHANGELOG [Unreleased] gate-integrity bullet names the current ship cap pin
(1.7525 -> 1.7529, measured 329,024 / 187,706 on the 392f50d4 merge) and keeps
the 1.7528 pin in the earlier-merges clause.

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:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-27 22:23:20 +00:00
1 parent 215c76e334
commit 10b77de4f2
46 files changed
+1217 -37

No files matched your search

+1
View File
@@ -185,6 +185,7 @@ export const SKILL_REQUIREMENTS: Readonly<Record<string, SkillRequirement>> = {
'ios-sync': CLAUDE_ONLY,
'landing-report': CLAUDE_ONLY,
learn: CLAUDE_ONLY,
modernize: CLAUDE_ONLY,
retro: CLAUDE_ONLY,
'setup-deploy': CLAUDE_ONLY,
'setup-gbrain': CLAUDE_ONLY,
+35 -7
View File
@@ -44,9 +44,9 @@ export const EXIT = { posted: 0, refused: 1, confirm: 2, cliFailed: 3, usage: 64
export const USAGE = `usage: gstack-post <op> [<target>] [options]
gstack-post pr-comment <pr> --body-file F
gstack-post issue-comment <issue> --body-file F
gstack-post reply <pr> --to <review-comment-id> --body-file F (GitHub only)
gstack-post pr-comment <pr> --body-file F [--attribute "<model> on behalf of <login>"]
gstack-post issue-comment <issue> --body-file F [--attribute ...]
gstack-post reply <pr> --to <review-comment-id> --body-file F [--attribute ...] (GitHub only)
gstack-post pr-title <pr> --title-file F
gstack-post pr-body <pr> --body-file F [--replace]
gstack-post pr-create --base <branch> [--head <branch>] [--draft] --title-file F --body-file F
@@ -59,6 +59,8 @@ export const USAGE = `usage: gstack-post <op> [<target>] [options]
--replace pr-body: overwrite the whole body instead of merging it by
## section into the current one (the default keeps every
section the file does not rewrite, the ship receipt included)
--attribute <text> comments only; opens the comment with a "> [!NOTE]"
callout naming who wrote it (one line, off by default)
Write the text files with your file-write tool; never put the text in a
shell command. The helper scans the exact bytes it sends.
@@ -80,11 +82,26 @@ interface Request {
host?: HostKind;
visibility?: RepoVisibility;
confirm?: string;
attribute?: string;
}
/** Operations that post a comment, the only ones an attribution callout applies to. */
const COMMENT_OPS: ReadonlySet<Op> = new Set<Op>(["pr-comment", "issue-comment", "reply"]);
/**
* The opt-in attribution callout (Garry, 2026-10-26: yes, opt-in, off by
* default): a GitHub alert block that opens an agent-posted comment with who
* wrote it, so a maintainer reading the thread sees the author before the text.
* GitLab renders it as a plain blockquote. Applied to comments and replies only,
* never to a PR body, a title or an issue body.
*/
export function attributionCallout(attribute: string): string {
return `> [!NOTE]\n> 🤖 **${attribute}**\n\n`;
}
class UsageError extends Error {}
const VALUE_FLAGS = new Set(["--to", "--base", "--head", "--title-file", "--body-file", "--host", "--repo-visibility", "--confirm"]);
const VALUE_FLAGS = new Set(["--to", "--base", "--head", "--title-file", "--body-file", "--host", "--repo-visibility", "--confirm", "--attribute"]);
function parseArgs(argv: string[]): Request {
const [op, ...rest] = argv;
@@ -118,7 +135,13 @@ function parseArgs(argv: string[]): Request {
target: positional[0], to: flags.get("--to"), base: flags.get("--base"), head: flags.get("--head"),
titleFile: flags.get("--title-file"), bodyFile: flags.get("--body-file"),
host: host as HostKind | undefined, visibility: vis as RepoVisibility | undefined, confirm: flags.get("--confirm"),
attribute: flags.get("--attribute")?.trim(),
};
if (req.attribute !== undefined) {
if (!COMMENT_OPS.has(req.op)) throw new UsageError("--attribute applies to pr-comment, issue-comment and reply only");
if (!req.attribute || /[\r\n\u0000-\u001f]/.test(req.attribute)) throw new UsageError("--attribute needs one line of text, like \"<model> on behalf of <login>\"");
if (req.attribute.length > 200) throw new UsageError("--attribute must be 200 characters or fewer");
}
const creates = req.op === "pr-create" || req.op === "issue-create";
if (positional.length > (creates ? 0 : 1)) throw new UsageError(`unexpected argument "${positional[creates ? 0 : 1]}"`);
if (!creates && !req.target) throw new UsageError(`${req.op} needs a target (a number or a URL)`);
@@ -455,7 +478,7 @@ function send(env: PostEnv, kind: HostKind, req: Request, n: string | undefined,
}
}
if (r.stdout.trim()) env.out(r.stdout.endsWith("\n") ? r.stdout : r.stdout + "\n");
env.out(`POSTED: ${req.op}${n ? ` ${n}` : ""}\n`);
env.out(`POSTED: ${req.op}${n ? ` ${n}` : ""}${req.attribute ? ` (attributed: ${req.attribute})` : ""}\n`);
return EXIT.posted;
}
@@ -482,6 +505,7 @@ export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config
const list = (names: string[]) => (names.length ? names.join(", ") : "none");
env.err(`gstack-post: pr-body merged by section: replaced ${list(m.replaced)}; kept ${list(m.kept)}; added ${list(m.added)}\n`);
}
if (body !== undefined && req.attribute) body = attributionCallout(req.attribute) + body;
const parts: Array<[Part, string]> = [];
if (title !== undefined) parts.push(["title", title]);
if (body !== undefined) parts.push(["body", body]);
@@ -491,7 +515,11 @@ export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config
const hasQuad = parts.some(([, text]) => /\b\d{1,3}(?:\.\d{1,3}){3}\b/.test(text));
const { found, oversize } = scanParts(parts, vis, email.status === 0 ? email.stdout.trim() || undefined : undefined, hasQuad ? repoReleaseVersions(env) : []);
env.out(`REPO_VISIBILITY: ${vis}\n`);
const line = (m: Located) => `RULE: ${m.finding.id} LINE: ${m.finding.line} PART: ${m.part}\n`;
const calloutLines = req.attribute ? attributionCallout(req.attribute).split("\n").length - 1 : 0;
const line = (m: Located) => {
if (m.part === "body" && m.finding.line <= calloutLines) return `RULE: ${m.finding.id} LINE: 1 PART: attribute\n`;
return `RULE: ${m.finding.id} LINE: ${m.part === "body" ? m.finding.line - calloutLines : m.finding.line} PART: ${m.part}\n`;
};
const high = found.filter(m => m.finding.severity === "HIGH");
if (oversize || high.length) {
for (const m of high) env.out(`HIGH ${line(m)}`);
@@ -504,7 +532,7 @@ export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config
const medium = found.filter(m => m.finding.severity === "MEDIUM");
if (medium.length) {
const token = confirmationToken(
{ op: req.op, host: remote.kind, repo: [remote.host, ...remote.segments].join("/"), target: n ?? null, to: req.to ?? null, base: req.base ?? null, head: req.head ?? null, draft: req.draft },
{ op: req.op, host: remote.kind, repo: [remote.host, ...remote.segments].join("/"), target: n ?? null, to: req.to ?? null, base: req.base ?? null, head: req.head ?? null, draft: req.draft, attribute: req.attribute ?? null },
parts, medium);
if (req.confirm !== token) {
for (const m of medium) env.out(line(m));