diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index f5a0d9e40..7d822bc0d 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -263,7 +263,13 @@ jobs: report: runs-on: ubicloud-standard-8 needs: evals - if: always() && github.event_name == 'pull_request' + # Skip entirely for fork PRs: GITHUB_TOKEN is read-only on forks even with + # pull-requests/issues: write declared, so addComment always returns 401. + # Fork contributors still see all eval results in the job logs and artifacts. + if: > + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository timeout-minutes: 5 permissions: contents: read diff --git a/design/src/cli.ts b/design/src/cli.ts index a55d92555..5876b6e85 100644 --- a/design/src/cli.ts +++ b/design/src/cli.ts @@ -25,13 +25,27 @@ import { evolve } from "./evolve"; import { generateDesignToCodePrompt } from "./design-to-code"; import { serve } from "./serve"; import { gallery } from "./gallery"; +import { spawn as nodeSpawn } from "child_process"; import { - daemonStatus as daemonStatusClient, + daemonStatus, + shutdownDaemon, ensureDaemon, publishBoard, - shutdownDaemon, } from "./daemon-client"; -import { spawn as nodeSpawn } from "child_process"; + +/** + * Parse + validate `--api-timeout `. Throws on bad input so the caller can + * surface a clear error rather than passing NaN/0/negative to setTimeout (which + * would fire the AbortController immediately and instant-abort the request). + */ +export function parseApiTimeoutMs(raw: string | undefined): number | undefined { + if (!raw) return undefined; + const parsed = parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`--api-timeout must be a positive integer (ms), got: ${raw}`); + } + return parsed; +} function parseArgs(argv: string[]): { command: string; @@ -130,6 +144,17 @@ async function main(): Promise { process.exit(1); } + // Per-request timeout for OpenAI image-generation calls. Distinct from the + // existing `--timeout` flag, which controls the `compare --serve` / `serve` + // HTTP listener. See issue #1519. + let apiTimeoutMs: number | undefined; + try { + apiTimeoutMs = parseApiTimeoutMs(flags["api-timeout"] as string | undefined); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + switch (command) { case "generate": await generate({ @@ -140,6 +165,7 @@ async function main(): Promise { retry: flags.retry ? parseInt(flags.retry as string) : 0, size: flags.size as string, quality: flags.quality as string, + apiTimeoutMs, }); break; @@ -202,6 +228,7 @@ async function main(): Promise { size: flags.size as string, quality: flags.quality as string, viewports: flags.viewports as string, + apiTimeoutMs, }); break; @@ -210,6 +237,7 @@ async function main(): Promise { session: flags.session as string, feedback: flags.feedback as string, output: (flags.output as string) || "/tmp/gstack-iterate.png", + apiTimeoutMs, }); break; @@ -262,6 +290,7 @@ async function main(): Promise { screenshot: flags.screenshot as string, brief: flags.brief as string, output: (flags.output as string) || "/tmp/gstack-evolved.png", + apiTimeoutMs, }); break; @@ -290,7 +319,7 @@ async function main(): Promise { // Sub-commands: `$D daemon status` and `$D daemon stop [--force]`. const sub = positionals[0] || "status"; if (sub === "status") { - const s = await daemonStatusClient(); + const s = await daemonStatus(); if (!s.running) { console.log(JSON.stringify({ running: false }, null, 2)); process.exit(0); @@ -408,14 +437,16 @@ async function resolveImagePaths(input: string): Promise { // the production install to a single executable; daemon-client.ts spawns // ` --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev) // rather than relying on a separate daemon.ts file at a known path. -if (process.argv.includes("--daemon-mode")) { - const { start } = await import("./daemon"); - start(); - // start() binds Bun.serve and registers signal handlers; this branch - // never falls through to main(). Process stays alive on the bound port. -} else { - main().catch((err) => { - console.error(err.message || err); - process.exit(1); - }); +if (import.meta.main) { + if (process.argv.includes("--daemon-mode")) { + const { start } = await import("./daemon"); + start(); + // start() binds Bun.serve and registers signal handlers; this branch + // never falls through to main(). Process stays alive on the bound port. + } else { + main().catch((err) => { + console.error(err.message || err); + process.exit(1); + }); + } } diff --git a/design/src/commands.ts b/design/src/commands.ts index 17d5811a7..c6892a05e 100644 --- a/design/src/commands.ts +++ b/design/src/commands.ts @@ -17,17 +17,17 @@ export const COMMANDS = new Map`. + */ +export const DEFAULT_IMAGE_GEN_TIMEOUT_MS = 300_000; diff --git a/design/src/evolve.ts b/design/src/evolve.ts index 58e88ce16..8645ff8fe 100644 --- a/design/src/evolve.ts +++ b/design/src/evolve.ts @@ -8,11 +8,13 @@ import fs from "fs"; import path from "path"; import { requireApiKey } from "./auth"; +import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "./constants"; export interface EvolveOptions { screenshot: string; // Path to current site screenshot brief: string; // What to change ("make it calmer", "fix the hierarchy") output: string; // Output path for evolved mockup + apiTimeoutMs?: number; } /** @@ -52,7 +54,7 @@ export async function evolve(options: EvolveOptions): Promise { ].join("\n"); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 240_000); + const timeout = setTimeout(() => controller.abort(), options.apiTimeoutMs ?? DEFAULT_IMAGE_GEN_TIMEOUT_MS); try { const response = await fetch("https://api.openai.com/v1/responses", { diff --git a/design/src/generate.ts b/design/src/generate.ts index 3689aa710..2da4806b5 100644 --- a/design/src/generate.ts +++ b/design/src/generate.ts @@ -8,6 +8,7 @@ import { requireApiKey } from "./auth"; import { parseBrief } from "./brief"; import { createSession, sessionPath } from "./session"; import { checkMockup } from "./check"; +import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "./constants"; export interface GenerateOptions { brief?: string; @@ -17,6 +18,7 @@ export interface GenerateOptions { retry?: number; size?: string; quality?: string; + apiTimeoutMs?: number; } export interface GenerateResult { @@ -35,9 +37,10 @@ async function callImageGeneration( prompt: string, size: string, quality: string, + timeoutMs: number, ): Promise<{ responseId: string; imageData: string }> { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 240_000); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch("https://api.openai.com/v1/responses", { @@ -106,6 +109,7 @@ export async function generate(options: GenerateOptions): Promise { console.error(` Feedback: "${options.feedback}"`); const startTime = Date.now(); + const apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_IMAGE_GEN_TIMEOUT_MS; + // Single deadline shared across threading + fallback so cumulative wait is + // bounded by apiTimeoutMs rather than 2×apiTimeoutMs. + const deadline = startTime + apiTimeoutMs; // Try multi-turn with previous_response_id first let success = false; let responseId = ""; try { - const result = await callWithThreading(apiKey, session.lastResponseId, options.feedback); + const result = await callWithThreading(apiKey, session.lastResponseId, options.feedback, apiTimeoutMs); responseId = result.responseId; fs.mkdirSync(path.dirname(options.output), { recursive: true }); @@ -45,13 +51,19 @@ export async function iterate(options: IterateOptions): Promise { console.error(` Threading failed: ${err.message}`); console.error(" Falling back to re-generation with accumulated feedback..."); + const remaining = deadline - Date.now(); + if (remaining <= 0) { + const secs = (apiTimeoutMs / 1000).toFixed(apiTimeoutMs % 1000 === 0 ? 0 : 1); + throw new Error(`Timeout (${secs}s)`); + } + // Fallback: re-generate with original brief + all feedback const accumulatedPrompt = buildAccumulatedPrompt( session.originalBrief, [...session.feedbackHistory, options.feedback] ); - const result = await callFresh(apiKey, accumulatedPrompt); + const result = await callFresh(apiKey, accumulatedPrompt, remaining); responseId = result.responseId; fs.mkdirSync(path.dirname(options.output), { recursive: true }); @@ -80,9 +92,10 @@ async function callWithThreading( apiKey: string, previousResponseId: string, feedback: string, + timeoutMs: number, ): Promise<{ responseId: string; imageData: string }> { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 240_000); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch("https://api.openai.com/v1/responses", { @@ -128,9 +141,10 @@ async function callWithThreading( async function callFresh( apiKey: string, prompt: string, + timeoutMs: number, ): Promise<{ responseId: string; imageData: string }> { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 240_000); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch("https://api.openai.com/v1/responses", { diff --git a/design/src/variants.ts b/design/src/variants.ts index 257079dea..a8343c153 100644 --- a/design/src/variants.ts +++ b/design/src/variants.ts @@ -8,6 +8,7 @@ import fs from "fs"; import path from "path"; import { requireApiKey } from "./auth"; import { parseBrief } from "./brief"; +import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "./constants"; export interface VariantsOptions { brief?: string; @@ -17,6 +18,7 @@ export interface VariantsOptions { size?: string; quality?: string; viewports?: string; // "desktop,tablet,mobile" — generates at multiple sizes + apiTimeoutMs?: number; } const STYLE_VARIATIONS = [ @@ -42,6 +44,7 @@ export async function generateVariant( size: string, quality: string, fetchFn: typeof globalThis.fetch = globalThis.fetch, + timeoutMs: number = DEFAULT_IMAGE_GEN_TIMEOUT_MS, ): Promise<{ path: string; success: boolean; error?: string }> { const maxRetries = 3; const MAX_RETRY_AFTER_MS = 60_000; // cap honored Retry-After to bound stalls @@ -58,7 +61,7 @@ export async function generateVariant( skipLeadingDelay = false; const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 240_000); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchFn("https://api.openai.com/v1/responses", { @@ -125,7 +128,8 @@ export async function generateVariant( } catch (err: any) { clearTimeout(timeout); if (err.name === "AbortError") { - return { path: outputPath, success: false, error: "Timeout (120s)" }; + const secs = (timeoutMs / 1000).toFixed(timeoutMs % 1000 === 0 ? 0 : 1); + return { path: outputPath, success: false, error: `Timeout (${secs}s)` }; } lastError = err.message; } @@ -144,12 +148,13 @@ export async function variants(options: VariantsOptions): Promise { : parseBrief(options.brief!, false); const quality = options.quality || "high"; + const apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_IMAGE_GEN_TIMEOUT_MS; fs.mkdirSync(options.outputDir, { recursive: true }); // If viewports specified, generate responsive variants instead of style variants if (options.viewports) { - await generateResponsiveVariants(apiKey, baseBrief, options.outputDir, options.viewports, quality); + await generateResponsiveVariants(apiKey, baseBrief, options.outputDir, options.viewports, quality, apiTimeoutMs); return; } @@ -176,7 +181,7 @@ export async function variants(options: VariantsOptions): Promise { new Promise(resolve => setTimeout(resolve, delay)) .then(() => { console.error(` Starting variant ${String.fromCharCode(65 + i)}...`); - return generateVariant(apiKey, prompt, outputPath, size, quality); + return generateVariant(apiKey, prompt, outputPath, size, quality, undefined, apiTimeoutMs); }) ); } @@ -225,6 +230,7 @@ async function generateResponsiveVariants( outputDir: string, viewports: string, quality: string, + timeoutMs: number, ): Promise { const viewportList = viewports.split(",").map(v => v.trim().toLowerCase()); const configs = viewportList.map(v => VIEWPORT_CONFIGS[v]).filter(Boolean); @@ -250,7 +256,7 @@ async function generateResponsiveVariants( setTimeout(resolve, delay) ).then(() => { console.error(` Starting ${config.desc}...`); - return generateVariant(apiKey, prompt, outputPath, config.size, quality); + return generateVariant(apiKey, prompt, outputPath, config.size, quality, undefined, timeoutMs); }); }); diff --git a/design/test/api-timeout.test.ts b/design/test/api-timeout.test.ts new file mode 100644 index 000000000..c29cf44ca --- /dev/null +++ b/design/test/api-timeout.test.ts @@ -0,0 +1,133 @@ +/** + * Regression coverage for issue #1519 — image-generation calls were timing + * out at a hardcoded 120s with no CLI override. The fix raised the default + * and exposed a per-invocation override via `--api-timeout`. + * + * These tests pin the contract that the constant exports at the new value + * and that the `timeoutMs` param is honored by the AbortController path. + */ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { generateVariant } from "../src/variants"; +import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "../src/constants"; +import { parseApiTimeoutMs } from "../src/cli"; + +describe("DEFAULT_IMAGE_GEN_TIMEOUT_MS", () => { + test("default is 300_000ms (5min) — see issue #1519", () => { + expect(DEFAULT_IMAGE_GEN_TIMEOUT_MS).toBe(300_000); + }); +}); + +describe("parseApiTimeoutMs", () => { + test("undefined input returns undefined (use default)", () => { + expect(parseApiTimeoutMs(undefined)).toBeUndefined(); + }); + + test("valid positive integer returns the parsed number", () => { + expect(parseApiTimeoutMs("300000")).toBe(300000); + expect(parseApiTimeoutMs("1")).toBe(1); + }); + + test("non-numeric input throws (would otherwise be NaN -> instant abort)", () => { + expect(() => parseApiTimeoutMs("abc")).toThrow(/positive integer/); + }); + + test("zero throws (would otherwise fire setTimeout immediately)", () => { + expect(() => parseApiTimeoutMs("0")).toThrow(/positive integer/); + }); + + test("negative throws (would otherwise fire setTimeout immediately)", () => { + expect(() => parseApiTimeoutMs("-1")).toThrow(/positive integer/); + expect(() => parseApiTimeoutMs("-300000")).toThrow(/positive integer/); + }); + + test("trailing garbage is ignored (parseInt semantics; matches other flags)", () => { + expect(parseApiTimeoutMs("300000foo")).toBe(300000); + }); +}); + +describe("generateVariant timeoutMs override", () => { + let tmpDir: string; + let outputPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "api-timeout-")); + outputPath = path.join(tmpDir, "variant.png"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("aborts after explicit timeoutMs when fetch never resolves", async () => { + // Stub fetch that waits for the signal to abort, then throws AbortError. + const fetchFn = (async (_input: any, init?: any) => { + const signal = init?.signal as AbortSignal | undefined; + await new Promise((_resolve, reject) => { + if (!signal) return; + signal.addEventListener("abort", () => { + const err: any = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }); + return new Response("never reached"); + }) as typeof globalThis.fetch; + + const t0 = Date.now(); + const result = await generateVariant( + "fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn, 200, + ); + const elapsed = Date.now() - t0; + + expect(result.success).toBe(false); + expect(result.error).toBe("Timeout (0.2s)"); + // Was aborted by the 200ms timer, not by exponential-backoff retry chain + expect(elapsed).toBeLessThan(2_000); + }); + + test("formats whole-second timeouts without decimals", async () => { + const fetchFn = (async (_input: any, init?: any) => { + const signal = init?.signal as AbortSignal | undefined; + await new Promise((_resolve, reject) => { + if (!signal) return; + signal.addEventListener("abort", () => { + const err: any = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }); + return new Response("never reached"); + }) as typeof globalThis.fetch; + + const result = await generateVariant( + "fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn, 1000, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Timeout (1s)"); + }); + + test("default timeoutMs is the shared constant when omitted", async () => { + // 1x1 transparent PNG, base64 + const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII="; + const fetchFn = (async () => + new Response( + JSON.stringify({ + output: [{ type: "image_generation_call", result: TINY_PNG_BASE64 }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )) as typeof globalThis.fetch; + + // Should succeed using the default timeout — fetch resolves instantly here, + // so the timeoutMs value only matters for the AbortController setup not firing. + const result = await generateVariant( + "fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn, + ); + + expect(result.success).toBe(true); + }); +}); diff --git a/design/test/feedback-roundtrip.test.ts b/design/test/feedback-roundtrip.test.ts index e8d63db23..ef251b1c5 100644 --- a/design/test/feedback-roundtrip.test.ts +++ b/design/test/feedback-roundtrip.test.ts @@ -21,7 +21,10 @@ import { generateCompareHtml } from '../src/compare'; import * as fs from 'fs'; import * as path from 'path'; +import type { TabSession } from '../../browse/src/tab-session'; + let bm: BrowserManager; +let session: TabSession; let baseUrl: string; let server: ReturnType; let tmpDir: string; @@ -119,6 +122,7 @@ beforeAll(async () => { bm = new BrowserManager(); await bm.launch(); + session = bm.getActiveSession(); }); afterAll(() => { @@ -137,32 +141,32 @@ describe('Submit: browser click → feedback.json on disk', () => { serverState = 'serving'; // Navigate to the board (board JS uses relative URLs + location.protocol detect) - await handleWriteCommand('goto', [baseUrl], bm); + await handleWriteCommand('goto', [baseUrl], session, bm); // Verify the board detects HTTP mode (so postFeedback will actually fetch // instead of falling into the file:// DOM-only path) const httpDetected = await handleReadCommand('js', [ "location.protocol === 'http:' || location.protocol === 'https:'" - ], bm); + ], session, bm); expect(httpDetected).toBe('true'); // User picks variant A, rates it 5 stars await handleReadCommand('js', [ 'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()' - ], bm); + ], session, bm); await handleReadCommand('js', [ 'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[4].click()' - ], bm); + ], session, bm); // User adds overall feedback await handleReadCommand('js', [ 'document.getElementById("overall-feedback").value = "Ship variant A"' - ], bm); + ], session, bm); // User clicks Submit await handleReadCommand('js', [ 'document.getElementById("submit-btn").click()' - ], bm); + ], session, bm); // Wait a beat for the async POST to complete await new Promise(r => setTimeout(r, 300)); @@ -186,19 +190,19 @@ describe('Submit: browser click → feedback.json on disk', () => { // After submit, the page should be read-only const submitBtnExists = await handleReadCommand('js', [ 'document.getElementById("submit-btn").style.display' - ], bm); + ], session, bm); // submit button is hidden after post-submit lifecycle expect(submitBtnExists).toBe('none'); const successVisible = await handleReadCommand('js', [ 'document.getElementById("success-msg").style.display' - ], bm); + ], session, bm); expect(successVisible).toBe('block'); // Success message should mention /design-shotgun const successText = await handleReadCommand('js', [ 'document.getElementById("success-msg").textContent' - ], bm); + ], session, bm); expect(successText).toContain('design-shotgun'); }); }); @@ -211,17 +215,17 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => { serverState = 'serving'; // Fresh page - await handleWriteCommand('goto', [baseUrl], bm); + await handleWriteCommand('goto', [baseUrl], session, bm); // User clicks "Totally different" chiclet await handleReadCommand('js', [ 'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()' - ], bm); + ], session, bm); // User clicks Regenerate await handleReadCommand('js', [ 'document.getElementById("regen-btn").click()' - ], bm); + ], session, bm); // Wait for async POST await new Promise(r => setTimeout(r, 300)); @@ -244,12 +248,12 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => { if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath); serverState = 'serving'; - await handleWriteCommand('goto', [baseUrl], bm); + await handleWriteCommand('goto', [baseUrl], session, bm); // Click "More like this" on variant B (index 1) await handleReadCommand('js', [ 'document.querySelectorAll(".more-like-this")[1].click()' - ], bm); + ], session, bm); await new Promise(r => setTimeout(r, 300)); @@ -263,21 +267,21 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => { test('board shows spinner after regenerate (user stays on same tab)', async () => { serverState = 'serving'; - await handleWriteCommand('goto', [baseUrl], bm); + await handleWriteCommand('goto', [baseUrl], session, bm); await handleReadCommand('js', [ 'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()' - ], bm); + ], session, bm); await handleReadCommand('js', [ 'document.getElementById("regen-btn").click()' - ], bm); + ], session, bm); await new Promise(r => setTimeout(r, 300)); // Board should show "Generating new designs..." text const bodyText = await handleReadCommand('js', [ 'document.body.textContent' - ], bm); + ], session, bm); expect(bodyText).toContain('Generating new designs'); }); }); @@ -291,15 +295,15 @@ describe('Full regeneration round-trip: regen → reload → submit', () => { if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath); serverState = 'serving'; - await handleWriteCommand('goto', [baseUrl], bm); + await handleWriteCommand('goto', [baseUrl], session, bm); // Step 1: User clicks Regenerate await handleReadCommand('js', [ 'document.querySelector(".regen-chiclet[data-action=\\"match\\"]").click()' - ], bm); + ], session, bm); await handleReadCommand('js', [ 'document.getElementById("regen-btn").click()' - ], bm); + ], session, bm); await new Promise(r => setTimeout(r, 300)); @@ -329,21 +333,21 @@ describe('Full regeneration round-trip: regen → reload → submit', () => { expect(serverState).toBe('serving'); // Step 4: Board auto-refreshes (simulated by navigating again) - await handleWriteCommand('goto', [baseUrl], bm); + await handleWriteCommand('goto', [baseUrl], session, bm); // Verify the board is fresh (no prior picks) const status = await handleReadCommand('js', [ 'document.getElementById("status").textContent' - ], bm); + ], session, bm); expect(status).toBe(''); // Step 5: User picks variant C on round 2 and submits await handleReadCommand('js', [ 'document.querySelectorAll("input[name=\\"preferred\\"]")[2].click()' - ], bm); + ], session, bm); await handleReadCommand('js', [ 'document.getElementById("submit-btn").click()' - ], bm); + ], session, bm); await new Promise(r => setTimeout(r, 300)); diff --git a/document-release/SKILL.md b/document-release/SKILL.md index b95873625..e549961d9 100644 --- a/document-release/SKILL.md +++ b/document-release/SKILL.md @@ -941,21 +941,3 @@ When significant gaps are found, suggest running `/document-generate` to fill th > **STOP.** Before auditing each doc file and applying updates, polishing CHANGELOG voice, checking cross-doc consistency, cleaning up TODOS, the VERSION bump, and committing (Steps 2-9, after the coverage map in Step 1.5), Read `~/.claude/skills/gstack/document-release/sections/release-body.md` and execute it > in full. Do not work from memory — that section is the source of truth for this step. - ---- - -## Important Rules - -- **Read before editing.** Always read the full content of a file before modifying it. -- **Never clobber CHANGELOG.** Polish wording only. Never delete, replace, or regenerate entries. -- **Never bump VERSION silently.** Always ask. Even if already bumped, check whether it covers the full scope of changes. -- **Be explicit about what changed.** Every edit gets a one-line summary. -- **Generic heuristics, not project-specific.** The audit checks work on any repo. -- **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md. -- **Coverage map informs, never generates.** The Diataxis coverage map flags gaps for the PR body - and future work. It does NOT auto-generate missing documentation pages or sections. When gaps - are found, suggest `/document-generate` as the follow-up skill. -- **Diagram drift is advisory.** Flag stale architecture diagrams in the PR body but do not - auto-edit ASCII art or Mermaid blocks — they require human judgment to update correctly. -- **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person - who hasn't seen the code. diff --git a/document-release/SKILL.md.tmpl b/document-release/SKILL.md.tmpl index 7621cb312..6be52121f 100644 --- a/document-release/SKILL.md.tmpl +++ b/document-release/SKILL.md.tmpl @@ -140,21 +140,3 @@ When significant gaps are found, suggest running `/document-generate` to fill th --- {{SECTION:release-body}} - ---- - -## Important Rules - -- **Read before editing.** Always read the full content of a file before modifying it. -- **Never clobber CHANGELOG.** Polish wording only. Never delete, replace, or regenerate entries. -- **Never bump VERSION silently.** Always ask. Even if already bumped, check whether it covers the full scope of changes. -- **Be explicit about what changed.** Every edit gets a one-line summary. -- **Generic heuristics, not project-specific.** The audit checks work on any repo. -- **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md. -- **Coverage map informs, never generates.** The Diataxis coverage map flags gaps for the PR body - and future work. It does NOT auto-generate missing documentation pages or sections. When gaps - are found, suggest `/document-generate` as the follow-up skill. -- **Diagram drift is advisory.** Flag stale architecture diagrams in the PR body but do not - auto-edit ASCII art or Mermaid blocks — they require human judgment to update correctly. -- **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person - who hasn't seen the code. diff --git a/document-release/sections/release-body.md b/document-release/sections/release-body.md index 0f05f13b5..8b593e8c5 100644 --- a/document-release/sections/release-body.md +++ b/document-release/sections/release-body.md @@ -475,3 +475,21 @@ Substitute: STATUS = "clean" if no gaps, "issues_found" if gaps exist. SOURCE = **Cleanup:** Run `rm -f "$TMPERR_DOC"` after processing (if Codex was used). --- + +--- + +## Important Rules + +- **Read before editing.** Always read the full content of a file before modifying it. +- **Never clobber CHANGELOG.** Polish wording only. Never delete, replace, or regenerate entries. +- **Never bump VERSION silently.** Always ask. Even if already bumped, check whether it covers the full scope of changes. +- **Be explicit about what changed.** Every edit gets a one-line summary. +- **Generic heuristics, not project-specific.** The audit checks work on any repo. +- **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md. +- **Coverage map informs, never generates.** The Diataxis coverage map flags gaps for the PR body + and future work. It does NOT auto-generate missing documentation pages or sections. When gaps + are found, suggest `/document-generate` as the follow-up skill. +- **Diagram drift is advisory.** Flag stale architecture diagrams in the PR body but do not + auto-edit ASCII art or Mermaid blocks — they require human judgment to update correctly. +- **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person + who hasn't seen the code. diff --git a/document-release/sections/release-body.md.tmpl b/document-release/sections/release-body.md.tmpl index 475e8b258..2320cede3 100644 --- a/document-release/sections/release-body.md.tmpl +++ b/document-release/sections/release-body.md.tmpl @@ -360,3 +360,21 @@ If all coverage is complete and no diagrams drifted, output: "Coverage: all ship --- {{CODEX_DOC_REVIEW}} + +--- + +## Important Rules + +- **Read before editing.** Always read the full content of a file before modifying it. +- **Never clobber CHANGELOG.** Polish wording only. Never delete, replace, or regenerate entries. +- **Never bump VERSION silently.** Always ask. Even if already bumped, check whether it covers the full scope of changes. +- **Be explicit about what changed.** Every edit gets a one-line summary. +- **Generic heuristics, not project-specific.** The audit checks work on any repo. +- **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md. +- **Coverage map informs, never generates.** The Diataxis coverage map flags gaps for the PR body + and future work. It does NOT auto-generate missing documentation pages or sections. When gaps + are found, suggest `/document-generate` as the follow-up skill. +- **Diagram drift is advisory.** Flag stale architecture diagrams in the PR body but do not + auto-edit ASCII art or Mermaid blocks — they require human judgment to update correctly. +- **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person + who hasn't seen the code.