mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
* Add a check that keeps tracker codes and internal jargon out of PR titles and branch names gstack-plain-title checks the readable front of a title, branch name, PR opening or changelog entry against the repository's .gstack/plain-titles.json; one trailing issue code stays allowed. A Plain title workflow runs it on every new pull request, and /ship's push refuses a never-pushed branch whose name fails it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Move the PR body's machine-read lines into a closing Agent notes section The ship receipt, its runs block and the Plan line now live in one collapsed section at the end of the body, and every reader looks there first while still accepting the old receipt-at-top layout. gstack-post refuses to post a title the plain-title check refuses, and land uses the checked PR title as main's commit title. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Teach /ship to write plain titles, PR openings, commit messages and changelog entries The README now lists what gstack is for, so a PR opening can name the goal it serves. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep tests, size budgets and generated files in step with plain titles and Agent notes Copy lists that install result-code modules now include the plain-title codes, the ship goldens and size ledger are refreshed, the new workflow is classified, and the pinned prose tests read the new title and body layout. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the test-profile list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep text people add below the PR notes, and fix three smaller review findings The notes section now ends at its own closing tag, so a collapsible added after it survives the next post. The opening check splits the body the same way the notes reader does. Wave PR titles no longer name the area, which the plain-title check could refuse. The Capy line in the PR-body steps now puts Approvals in the notes. Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Mask credentials in refused-title lines and keep older PRs' plan lines readable A refused title's printed match is masked by the secret scanner first. The Plan line reader falls back to the whole body when the Agent notes section has none, so a body written before this change keeps its plan when its first receipt lands. The notes section also ends at its own closing tag when its opener is split or marked open, or when a collapsible inside it is left unclosed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Print a fixed placeholder when a credential in a refused title cannot be masked Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep a plain-title policy edit from scheduling every paid eval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the paid-eval list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Recheck the PR title on every edit so a base change cannot turn a refused title green Review-Finding: s11/targeted#1 reader=targeted Review-Finding: s11/adversarial-native#1 reader=adversarial-native 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>
109 lines
5.5 KiB
TypeScript
109 lines
5.5 KiB
TypeScript
/**
|
|
* agent-notes — the collapsed section at the end of a PR body that holds every line gstack's tools read
|
|
* (R76: the front of a body is for people, the end is for machines). The section is the marker line, then
|
|
* `<details><summary>Agent notes</summary>`, a blank line, the content, a blank line and `</details>`; there is one,
|
|
* at the very end of the body (a Capy badge a bot appends may follow it). It carries the ship receipt and its runs
|
|
* block, the Approvals table and Plan line, issue and Refs lines, ruled notes and the exception lines /ship writes, so
|
|
* the readable part above it opens with plain sentences.
|
|
*
|
|
* The marker counts only on a line of its own outside a code fence, so a body that quotes it in an example never
|
|
* opens a section; the last such line wins. `</details>` closes the section only on its own line outside a fence, so a
|
|
* receipt block inside the notes cannot end it early. A body with no marker has no section (`notes: null`): readers
|
|
* then read the whole body, which keeps PRs posted before this section existed working.
|
|
*/
|
|
export const AGENT_NOTES_MARKER = '<!-- gstack:agent-notes -->';
|
|
export const AGENT_NOTES_OPEN = '<details><summary>Agent notes</summary>';
|
|
const CLOSE = '</details>';
|
|
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
|
|
const fenceCloses = (fence: string, line: string) => new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`).test(line);
|
|
|
|
interface Parsed { readable: string; notes: string | null; after: string }
|
|
|
|
/** Line indexes of `wanted` lines outside code fences, from `from` on. */
|
|
function linesOutsideFences(lines: string[], from: number, wanted: string): number[] {
|
|
const hits: number[] = [];
|
|
let fence: string | null = null;
|
|
for (let i = from; i < lines.length; i++) {
|
|
const line = lines[i]!.replace(/\r?\n$/, '');
|
|
if (fence !== null) { if (fenceCloses(fence, line)) fence = null; continue; }
|
|
const open = FENCE_OPEN.exec(line);
|
|
if (open) { fence = open[1]!; continue; }
|
|
if (line.trim() === wanted) hits.push(i);
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
/**
|
|
* The section's lines in a body split after each newline: the marker line, the line after the wrapper's opener, and
|
|
* the closing `</details>` line (`lines.length` when it never closes); null when the body has no section.
|
|
*/
|
|
export function agentNotesLines(lines: string[]): { marker: number; start: number; close: number } | null {
|
|
const marker = linesOutsideFences(lines, 0, AGENT_NOTES_MARKER).at(-1);
|
|
if (marker === undefined) return null;
|
|
let start = marker + 1;
|
|
while (start < lines.length && lines[start]!.trim() === '') start++;
|
|
if (start < lines.length && /^<details\b[^>]*>\s*(?:<summary>[^<]*<\/summary>)?$/i.test(lines[start]!.trim())) {
|
|
start++;
|
|
if (start < lines.length && /^<summary>[^<]*<\/summary>$/i.test(lines[start]!.trim())) start++;
|
|
}
|
|
return { marker, start, close: wrapperClose(lines, start) };
|
|
}
|
|
|
|
/**
|
|
* The `</details>` line that closes the wrapper: the first one outside fences with every `<details>` opened inside the
|
|
* notes closed, or, when a `<details>` in the notes never closes, the first `</details>` line outside fences.
|
|
*/
|
|
function wrapperClose(lines: string[], from: number): number {
|
|
let depth = 0;
|
|
let first = -1;
|
|
let fence: string | null = null;
|
|
for (let i = from; i < lines.length; i++) {
|
|
const line = lines[i]!.replace(/\r?\n$/, '');
|
|
if (fence !== null) { if (fenceCloses(fence, line)) fence = null; continue; }
|
|
const open = FENCE_OPEN.exec(line);
|
|
if (open) { fence = open[1]!; continue; }
|
|
if (line.trim() === CLOSE && depth === 0) return i;
|
|
if (line.trim() === CLOSE && first < 0) first = i;
|
|
const prose = line.replace(/`[^`]*`/g, '');
|
|
depth = Math.max(0, depth + (prose.match(/<details\b/gi)?.length ?? 0) - (prose.match(/<\/details>/gi)?.length ?? 0));
|
|
}
|
|
return first >= 0 ? first : lines.length;
|
|
}
|
|
|
|
/** The body around its section: the text above the marker, the content inside the wrapper, and any text after `</details>`. */
|
|
function parse(body: string): Parsed {
|
|
const lines = body.split(/(?<=\n)/);
|
|
const at = agentNotesLines(lines);
|
|
if (!at) return { readable: body, notes: null, after: '' };
|
|
const { marker, start, close: end } = at;
|
|
return {
|
|
readable: lines.slice(0, marker).join(''),
|
|
notes: lines.slice(start, end).join('').replace(/^(?:[ \t]*\r?\n)+/, '').replace(/\s+$/, ''),
|
|
after: lines.slice(end + 1).join(''),
|
|
};
|
|
}
|
|
|
|
/** The text above the Agent notes marker, and the section's content (`null` when the body has no section). */
|
|
export function splitAgentNotes(body: string): { readable: string; notes: string | null } {
|
|
const { readable, notes } = parse(body);
|
|
return { readable, notes };
|
|
}
|
|
|
|
/** What machine readers parse: the section's content when the body has one, else the whole body (a legacy body). */
|
|
export function agentNotesOrBody(body: string): string {
|
|
return parse(body).notes ?? body;
|
|
}
|
|
|
|
/** The section for `content`: the marker, the collapsed wrapper, and the content between blank lines. */
|
|
export function renderAgentNotes(content: string): string {
|
|
return `${AGENT_NOTES_MARKER}\n${AGENT_NOTES_OPEN}\n\n${content.trim()}\n\n${CLOSE}\n`;
|
|
}
|
|
|
|
/** `body` with its section's content replaced by `content` (the section appended when the body has none); text after `</details>` stays after it. */
|
|
export function withAgentNotes(body: string, content: string): string {
|
|
const { readable, after } = parse(body);
|
|
const lead = readable.replace(/\s+$/, '');
|
|
const tail = after.replace(/^\s+/, '');
|
|
return (lead ? `${lead}\n\n` : '') + renderAgentNotes(content) + (tail ? `\n${tail}` : '');
|
|
}
|