diff --git a/CHANGELOG.md b/CHANGELOG.md index cce391a..3335ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - `content icon check` and `content icon normalize` take `--min-contrast` (and the family key `icon_min_contrast`, range 1 to 21) to set the contrast floor of the icon ink on the card. Precedence is the flag, then the family key, then the default 3.0, so existing results do not change. 4.5:1 is recommended for the card plate but is not the default. - `content icon check` and `content icon normalize` take `--strict` (and the family key `icon_strict_family_ink: true`, off by default) to fail instead of warn when the dominant icon ink is not a family ink. Without a family palette the family-ink check is still not emitted. - `org labels ORG --repos-from-gh` plans labels across accessible repositories without the private estate registry, with paginated read-only GitHub capture, optional verified local checkouts, unchanged reference guards, and a documented org-default-forms-first workflow. Registry-backed plans still require the org registry row, and checkout repository names match GitHub case-insensitively, including resolved pending renames. Registry-free runs also isolate lazy brand lookups from estate decision files, and held reference rewrites retain their file/line guidance with an incomplete result. Workflow/labeler/release/Renovate references remain final skips rather than unresolved input requests. Missing Windows device-named checkouts fall back to captured metadata instead of aborting the org plan. Pending transfers check the repository's current owner's default issue forms and hold renames when that evidence is unavailable. Sparse or partially missing checkouts retain reference holds based on captured paths and the Git index, while unrelated omitted files remain allowed. +- `references/art-direction.md` documents the Codex image-generation recipe for the icon pipeline (the `codex exec` command, reading the image path from the run output, and the retry and attempt limits). Image-folder and sandbox notes are labelled as observed runtime behaviour, not OpenAI-documented, and the exact returned path is the rule. Each distinct returned path is one candidate, and the attempt cap is per candidate slot (four slots, 3 empty attempts each); unreadable-only output counts as an empty attempt and ends in `art-fail` after 3. Documentation and static wording tests only; no behaviour change. ### Fixed diff --git a/references/art-direction.md b/references/art-direction.md index c284285..380837b 100644 --- a/references/art-direction.md +++ b/references/art-direction.md @@ -74,6 +74,60 @@ Starred values are approximate until the estate planner re-measures them and pub | Screenshot (B, E) | `assets/screenshot.webp` | 1600×1000 viewport | WebP q80 ≤ 300 KB; captured from the live URL in a browser, never drawn by an image model | | Provenance | `assets/PROMPTS.md`, `.github/profile/assets/PROMPTS.md` | — | Markdown, one block per committed generated asset | +## Codex image-generation recipe + +### Command + +The prompt comes from `content icon prompt` (written with `--out`, for example `art/prompts/ORG/REPO/icon.md`); give it to Codex non-interactively: + +```bash +codex exec --skip-git-repo-check --json "$(cat art/prompts/ORG/REPO/icon.md)" > run.jsonl +``` + +- `exec` runs one non-interactive turn. +- `--skip-git-repo-check` lets it run from a scratch directory. +- `--json` prints one JSON event per line, so the run output can be parsed (the image path is read from it, see below). +- `--sandbox MODE` selects the sandbox policy for model-generated shell commands. +- `--output-last-message FILE` also writes the final message to FILE. +- A prompt of `-` is read from standard input. + +Use the same model and version for a whole family run (pipeline rule 4). Record the tool, model id and version in `PROMPTS.md` exactly as the run reports them; if the run does not expose a model id, write `not exposed` in the `TOOL / MODEL ID AND VERSION` field. Never invent a model id. Flag names follow codex-cli 0.166.0 at the time of writing; if one is rejected, run `codex exec --help` and change nothing else in the recipe. + +This recipe does not rely on a documented image path: OpenAI documentation covers non-interactive `codex exec` and its JSON events, not where generated images are stored, so always use the exact path returned by the run output. + +### Read the image path from the run output + +1. After the run, search the `run.jsonl` events (or the final message) for every image file path Codex reports for the generated images, and use exactly those paths (see item 5). +2. Never pick the newest file in the generated-images folder. Parallel sessions write into the same place, so the newest file can belong to another run and the wrong image gets normalized. +3. If the run output names no readable image path, treat the attempt as an empty run (see Retry and attempt limits). Do not fall back to the newest file or to a directory listing. +4. Copy the chosen image to scratch (see Sandbox (d)) and pass that copy to content icon normalize --in PATH, not the generated_images path. In the `PROMPTS.md` provenance block, record the Codex session id and only the image file name, or the path written relative to `$CODEX_HOME` (for example `$CODEX_HOME/generated_images/`), in the prose beside the record. Never record the absolute path: it contains the local user name. The Provenance record fenced template itself does not change. +5. One invocation may report between zero and four image paths. Every DISTINCT path the run output reports is one candidate; use exactly the reported paths, never a directory listing or the newest file. A run that reports no readable path is an empty run: either it reports no path, or every path it reports is unreadable (unreadable-only output). An unreadable path fills no slot, and readable paths in the same run still fill their slots. Parallel sessions write into the same place, so do not run candidates for several repositories in the same working directory at once unless you can tell their outputs apart. + +### Retry and attempt limits + +- The limit applies per candidate, not per candidate set. Pipeline rule 7 asks for four candidates (the option A template also says so), so there are four candidate slots, and the cap applies to each slot. A run that returns k distinct readable paths fills k slots (up to the unfilled ones); make further runs, with the same unchanged prompt, only for unfilled slots. +- An empty run (no path, or unreadable-only output) is retried as a NEW run with the same unchanged prompt, at most 2 retries (3 attempts total) for each slot that is still unfilled. Each empty run counts as one attempt against every slot that is still unfilled. Parallel runs can finish without a path, which is why a retry is allowed. Retries never alter the prompt. +- Successful candidates never count toward the cap, so the four-candidate round of pipeline rule 7 (then 4 more once, then log `art-fail`) is still reachable. Only empty runs count toward the 3 attempts, and they are not candidates. An unreadable path is not a successful candidate, so unreadable-only output is counted as an empty run and cannot loop forever. +- The second round of rule 7 ("4 more once") opens four new slots with the same per-slot cap of 3 attempts (2 retries each); slots from the first round do not carry over their attempt counts. +- After 3 empty attempts for any one slot, including attempts that returned only unreadable paths, stop and log `art-fail` with a one-line reason. Do not loop. + +### Privacy: only public facts go into prompts + +- (a) Prompts may contain only public facts: the public repository name, its public description and topics, the family palette and style lock, and the glyph fields supplied by the writer. +- (b) Never paste private-repository content into a prompt: no private repo names, README or source text, issue text, customer or personal data, emails, tokens, or estate-registry rows. +- (c) `content icon prompt` does not establish whether a fact is public. It writes repository metadata (name, description, topics) and fields derived from the local README, such as its heading, into the Markdown file that Codex receives, with no privacy filtering. Before running Codex, read the complete generated Markdown, including every README-derived field, and remove or replace anything that is not public. Use public-only inputs, or hold the task. The prompt leaves the machine. +- (d) For a private repository, do not generate the icon with Codex from its contents. Use only the family glyph fields and a neutral name, or hold the task. +- (e) Keep the prompt text in the provenance record verbatim, which makes this rule auditable. + +### Sandbox: read from the generated-images folder + +These are OBSERVED runtime notes from issue #64 and the Pick Your AI evidence, not behaviour documented by OpenAI. Do not cite OpenAI documentation for them; the rule that holds regardless is: use the exact path returned by the run output. + +- (a) Observed: Codex stored generated images in its `generated_images` folder inside `$CODEX_HOME` (default: the `.codex` folder in the user's home, `~`). Read the image from the path named in the run output, which points there. +- (b) Observed: files that Codex writes into its own sandbox, for example a copy it saves into the working directory it was given, can be locked against the user account (access denied). Do not rely on them, and do not try to chmod or take ownership of them. +- (c) If a path is unreadable, copy nothing from the sandbox and do not retry the same path. Treat that path as returning nothing: if the same run reports another readable path in the generated-images folder, use that; otherwise the run is an empty run and counts as one attempt under the retry limit above, ending in `art-fail` after 3 empty attempts for the slot. +- (d) Copy the chosen image to a scratch location outside the repository before `content icon normalize --in`, and commit only the normalized `assets/icon.png`. Never commit the raw candidate. Do not write any real absolute local path or user name; use `$CODEX_HOME` and `~` only. + ## Repository glyph prompt template `content icon prompt` uses this self-contained option A template by default. Its diff --git a/references/content.md b/references/content.md index 95ab88b..ee00840 100644 --- a/references/content.md +++ b/references/content.md @@ -66,4 +66,4 @@ both tokens and exits 4 with their names; add them to the custom config or pass tokens for every family. Option B uses the illustration palette and does not require these glyph tokens, whether art direction is selected implicitly or explicitly. -All actions run on a checkout and a metadata file alone. The registry and approvals refine the result when present (a renamed org's packages and links resolve to its current login; a registry `language:` override reaches the chip); without them the resolver uses `references/brand.yaml` and treats nothing as approved. `icon prompt`, `icon normalize` and `icon check` default to the skill's bundled [art-direction.md](art-direction.md), regardless of the working directory. Option A uses its repository glyph template and the family's `brand.yaml` tokens; option B uses its illustration template. Pass `--art-direction FILE` to select an explicit art direction file (relative paths resolve from the current directory), including an estate-format file with `(b) Family palette` and `(d) Shared prompt template` sections. A missing selected file reports `needs-input:art-direction` and names that path; `prompt` exits 4, while normalization and QA log the missing palette and continue their other checks. +All actions run on a checkout and a metadata file alone. The registry and approvals refine the result when present (a renamed org's packages and links resolve to its current login; a registry `language:` override reaches the chip); without them the resolver uses `references/brand.yaml` and treats nothing as approved. `icon prompt`, `icon normalize` and `icon check` default to the skill's bundled [art-direction.md](art-direction.md), regardless of the working directory. Option A uses its repository glyph template and the family's `brand.yaml` tokens; option B uses its illustration template. Pass `--art-direction FILE` to select an explicit art direction file (relative paths resolve from the current directory), including an estate-format file with `(b) Family palette` and `(d) Shared prompt template` sections. A missing selected file reports `needs-input:art-direction` and names that path; `prompt` exits 4, while normalization and QA log the missing palette and continue their other checks. The Codex recipe for producing the candidate image (command, how to read its path, retry limit) is in [art-direction.md](art-direction.md#codex-image-generation-recipe). diff --git a/tests/test_art_direction_recipe.py b/tests/test_art_direction_recipe.py new file mode 100644 index 0000000..e7cfbe2 --- /dev/null +++ b/tests/test_art_direction_recipe.py @@ -0,0 +1,364 @@ +"""E3.4.1: the Codex image-generation recipe in references/art-direction.md (doc contract, no behaviour).""" +import re + +import pytest + +from conftest import ROOT + +ART = ROOT / "references" / "art-direction.md" +HEAD = "## Codex image-generation recipe" +GLYPH = "## Repository glyph prompt template" +PROMPT_TOKEN = "art/prompts/ORG/REPO/icon.md" + +# Baseline: references/art-direction.md at commit e54952a, before the recipe was added. +# Everything outside the recipe section must still match these values. Change them only +# together with a deliberate change to the settled art-direction rules (see AGENTS.md). +BASE_HEADINGS = """# Art direction +## Pipeline rules (MUST) +## Family palettes and motifs +## CompleteTech org motifs (banner and shared card illustration) +## Asset specification +## Repository glyph prompt template +## Prompt template for illustrations +## Provenance record (`PROMPTS.md`, one block per committed asset) +## assets/icon.png +## Art QA checklist (all MUST pass before commit; also the candidate rejection rules)""".splitlines() + +BASE_TABLE_ROWS = """| Family | Canvas | Primary | Accent | Text | Motif | Fonts | +|---|---|---|---|---|---|---| +| CompleteTech | ink `#0F172A` → navy `#001F3F`, 32 px `#1E3A8A` grid at 25% | sky `#00A7FD` → azure `#0081ED` → royal `#005ADD` | gold `#FFC300` (small details only); rose `#E11D48` only on the single finding object in Security art | `#F1F5F9`, `#CBD5E1`; muted `#94A3B8` | Isometric platform with satin-plastic objects linked by gold circuit traces | Poppins, JetBrains Mono | +| AI Ascension | ink `#15120E` / `#2A241C` | amber `#F2B75C` / `#E39B3A` | paper `#EDE4D3` | `#EDE4D3` | Existing system; banners kept | Fraunces, Bricolage Grotesque, JetBrains Mono | +| Jev Engineering | near-black `#010104` | cyan `#22D3EE`* | violet `#8B5CF6`* | `#F1F5F9` | A glowing central node fanning into lanes ending in a circle, square, triangle and diamond | Poppins, JetBrains Mono | +| jevplay.games | pink `#F386A1` with a halftone layer (12 px pitch, `#E551BA` at 20%) | magenta `#E551BA` | ink `#1E1E1E`, white | `#1E1E1E` | Retro pixel desktop windows and Jev, the boxy pixel robot | Existing wordmark; Press Start 2P (36 px or larger) | +| LibreEvolve | dark navy `#021527` | emerald `#10B981`* | faded emerald `#065F46`* | `#F1F5F9` | A lineage tree: most branches fade, one bright path continues | Poppins | +| Touched By An Agent | maroon `#1D1116` | amber `#F59E0B`* | magenta `#E11D74`* | `#FDF2F8` | One stylized robotic hand, signal ripples, a smart ring | Poppins | +| romgenie | dark teal `#002C31` | brass `#B8860B`* | lamp glow `#F4C430`* | `#F8FAFC` | Glossy 3D vault objects: chests, tapes, reels; a brass telescope and binoculars for the watching org | Poppins | +| Org | Motif objects | +|---|---| +| CompleteTech-LLC | cloud puff, blank document sheet, gear, chat bubble | +| CompleteTech-Skills | envelope with open flap, document sheet with blank ruled lines, coin stack, scroll with gold wax seal, certificate with gold ribbon rosette | +| CompleteTech-LLC-AI-Research | telescope aimed at a cluster of glowing node spheres | +| `CompleteTech-Security` | shield over rack server units, blank tag cards; one tag card in `#E11D48` as the finding | +| CompleteTech-Infrastructure | rack server unit, plug-in cube, cloud puff, circuit board | +| CompleteTech-Commerce | bar-chart panel, coin stack, briefcase, magnifier | +| CompleteTech-Creative | film reel, microphone, open book, chat bubble | +| CompleteTech-Pokemon | card stack, game controller, coin stack | +| CompleteTech-Data | database cylinder stack, map pin, bar-chart panel, folder | +| CompleteTech-Games | puzzle piece, game controller, card stack | +| CompleteTech-Directories | folder, map pin, magnifier, certificate with gold ribbon rosette | +| CompleteTech-Hardware | circuit board, gear, plug-in cube | +| CompleteTechDiscord-Games | chat bubble, game controller, puzzle piece | +| CompleteTech-DiscordApps | chat bubble, plug-in cube, bar-chart panel | +| CompleteTech-Patent-IP | blank document sheet, gold wax seal, magnifier | +| CompleteTech-Career-Toolkit | blank document sheet, briefcase, certificate with gold ribbon rosette | +| CompleteTech-ManageEngine | chat bubble, folder, cloud puff, gear | +| Asset | Path | Pixels | Format and limit | +|---|---|---|---| +| README hero banner | `docs/images/banner.png` | 1280×360 (or 2560×720) | PNG ≤ 300 KB, opaque; generated by `banner` | +| Repo card | `docs/images/card.svg` | 400×160 | SVG ≤ 40 KB; generated by `card` | +| Repository icon | `assets/icon.png` | 1024×1024 | PNG RGBA, transparent (drawn to read on the family card colour, `#F8FAFC` for CompleteTech) or with one opaque square ground (it then fills the card, full-bleed), ≤ 300 KB, no text | +| Brand files | `assets/brand/` in this repository (the published masters the generators and the footer use): `logo.png`, `logo-dark.png` (the exemplar pair), `logo-dark-full.png` (the candidate dark master, decision 19) and `mark-light.png`. `mark-dark.png` and `bulb-dark.png` are not shipped yet (see `assets/brand/README.md`). The former `CompleteTech-LLC/.github/brand/` location is historical only: those URLs returned 404, and the footer serves the files from `assets/brand/` (same sha256). | as committed | PNG; see `assets/brand/README.md` for modes | +| Logo pair in Skills repositories | `assets/logo.png`, `assets/logo-dark.png` | byte-identical to the `assets/brand/` masters (same sha256) | sha256 `a5c3c4ef55336ed4b883d3bc3bb6e1e942b836469bd2b2933bc8cc230b321621` (logo.png, 243,500 B) and `0dc00c0e5159518ff1d552b7052293b4e545ddd35be489e6d114e3ea64db27ec` (logo-dark.png, 210,785 B) | +| Social preview | `assets/social-preview.png` | 1280×640 | PNG ≤ 900 KB (heavier: JPG q85); hard limit under 1 MB; uploaded in the browser | +| Org avatar | `.github/profile/assets/avatar-500.png` (+ `avatar-1024.png` master) | 500×500 | Opaque PNG ≤ 300 KB; uploaded in the browser | +| Org banner | `.github/profile/assets/banner.jpg` | 1600×400 | JPG q85 ≤ 400 KB | +| Diagram source | `assets/diagrams/workflow.mmd` | — | Mermaid | +| Example preview | `assets/examples/example.png` | ≤ 2600 px wide | ≤ 600 KB | +| Screenshot (B, E) | `assets/screenshot.webp` | 1600×1000 viewport | WebP q80 ≤ 300 KB; captured from the live URL in a browser, never drawn by an image model | +| Provenance | `assets/PROMPTS.md`, `.github/profile/assets/PROMPTS.md` | — | Markdown, one block per committed generated asset | +| Family | Style lock (verbatim) | +|---|---| +| CompleteTech | Clean glossy isometric 3D product illustration, orthographic isometric camera from the front-left. Every scene sits on one rounded-rectangle platform: deep navy (#001F3F) top, ink (#0F172A) sides, a thin glowing sky-blue (#00A7FD) edge line, floating above an empty background with a soft contact shadow. Objects are smooth satin plastic in sky blue (#00A7FD), azure (#0081ED), royal blue (#005ADD) and off-white (#F1F5F9), with small warm-gold (#FFC300) details. Glass parts are frosted translucent blue. Thin gold circuit traces ending in round solder pads link the objects across the platform top. Soft rounded geometry, subtle bevels, bloom on gold details only, key light from the upper left, calm and uncluttered. | +| jevplay-games | Bold retro pixel-art illustration with chunky one-pixel dark outlines at a clearly visible pixel scale, flat colors on a plain flat pink background, and a playful early-desktop-computer aesthetic with simple window frames. | +| Jev-Engineering | Minimal glowing vector-style 3D render on a near-black background: smooth neon lines and nodes, soft cyan and violet glow, clean geometric primitives, calm technical mood, no clutter. | +| LibreEvolve | Minimal glowing illustration on dark navy: a luminous emerald branching structure, soft depth of field, most branches dim and fading, one path bright, calm scientific mood. | +| TouchedByAnAgent | Glossy 3D product illustration on deep maroon: smooth pink-peach robotics, warm amber glow, soft magenta signal ripples, tasteful and non-suggestive, studio lighting. | +| romgenie | Glossy illustrated 3D still life in a warm vault: brass-bound objects, soft lamp light from above, dark teal ambient background, storybook-premium finish. |""".splitlines() + +BASE_PROVENANCE = """## assets/icon.png +- Date: YYYY-MM-DD +- Tool and model: TOOL / MODEL ID AND VERSION +- Seed: SEED or "not supported" +- Reference images: golden reference path, jev-reference.png, or "none" +- Prompt: (verbatim, fenced) +- Candidates: 4 or 8; selected: N (reason: one line) +- Post-processing: showcase.py VERSION; output sha256 HASH; size KB +""" + +# A local path an operator could paste into the recipe: POSIX absolute or home-relative, +# Windows drive-rooted (backslash or slash) or UNC. Each must start a token (after +# whitespace, a backtick, a quote, a parenthesis or "="), so URLs and mid-word slashes +# such as art/prompts/ORG/REPO do not match. +LOCAL_PATH = re.compile( + r"(?:^|[\s`'\"(=])[A-Za-z]:[\\/]" # C:\x or C:/x + r"|(?:^|[\s`'\"(=])\\\\[A-Za-z0-9_.-]" # \\server\share + r"|(?:^|[\s`'\"(=])~?/[A-Za-z0-9_.-]", # /tmp/x, /home/x or ~/x + re.M, +) + + +def _text(): + return ART.read_text(encoding="utf-8") + + +def _recipe(text): + start = text.find(HEAD) + if start < 0: + return "" + body = text[start + len(HEAD):] + m = re.search(r"^## ", body, re.M) + return HEAD + body[: m.start()] if m else HEAD + body + + +def _outside_recipe(text): + recipe = _recipe(text) + assert recipe, "recipe section not found" + return text.replace(recipe, "", 1) + + +def test_recipe_section_exists_before_glyph_template(): + text = _text() + assert text.count(HEAD) == 1, "recipe heading must appear exactly once" + assert text.find(HEAD) < text.find(GLYPH) + assert _recipe(text), "recipe section not found" + + +def test_recipe_has_codex_exec_command(): + section = _recipe(_text()) + assert "codex exec" in section + assert "--json" in section + assert "--skip-git-repo-check" in section + fence = re.search(r"```bash\n(.*?)```", section, re.S) + assert fence, "command must sit in a ```bash fence" + assert "codex exec" in fence.group(1) + assert "--json" in fence.group(1) + assert "--skip-git-repo-check" in fence.group(1) + + +def test_recipe_reads_path_from_run_output_not_newest(): + lowered = _recipe(_text()).lower() + assert "never pick the newest" in lowered + assert "run output" in lowered + + +def test_recipe_retry_limit(): + section = _recipe(_text()) + assert "2 retries" in section + assert "3 attempts" in section + + +def test_recipe_makes_no_verification_claims(): + # The recipe is documentation only: it must not report observed outcomes or + # verification (for example "a retry has succeeded") that nothing in the repo proves. + lowered = _recipe(_text()).lower() + for phrase in ("verified", "has succeeded", "tested on", "was tested", "confirmed on"): + assert phrase not in lowered, phrase + + +def test_recipe_has_no_local_paths(): + section = _recipe(_text()) + assert not re.search(r"[A-Za-z]:\\Users|/home/|/Users/", section) + assert LOCAL_PATH.search(section) is None + + +def test_recipe_prompt_path_is_a_placeholder_token(): + # The guard below swaps this token for absolute paths; it must be present in the real section. + assert PROMPT_TOKEN in _recipe(_text()) + + +@pytest.mark.parametrize("bad", [ + "/tmp/private/icon.md", + "/var/folders/x/icon.md", + "/home/user/icon.md", + "/Users/user/icon.md", + "~/art/icon.md", + "C:\\private\\icon.md", + "C:/private/icon.md", + "C:\\Users\\user\\icon.md", + "\\\\server\\share\\icon.md", +]) +def test_local_path_guard_rejects_absolute_paths(bad): + section = _recipe(_text()).replace(PROMPT_TOKEN, bad) + assert PROMPT_TOKEN not in section + assert LOCAL_PATH.search(section), bad + + +def test_local_path_guard_ignores_urls_and_relative_paths(): + sample = "see https://www.complete.tech/a and art/prompts/ORG/REPO/icon.md and run.jsonl" + assert LOCAL_PATH.search(sample) is None + + +def test_existing_sections_intact(): + text = _text() + for heading in ("## Prompt template for illustrations", GLYPH, "## Pipeline rules (MUST)"): + assert text.count(heading) == 1, heading + + +def test_existing_heading_sequence_unchanged(): + headings = [line for line in _outside_recipe(_text()).splitlines() if re.match(r"#{1,6} ", line)] + assert headings == BASE_HEADINGS + + +def test_existing_tables_unchanged(): + rows = [line for line in _outside_recipe(_text()).splitlines() if line.startswith("|")] + assert rows == BASE_TABLE_ROWS + + +def test_existing_provenance_template_unchanged(): + text = _outside_recipe(_text()) + start = text.index("## Provenance record") + block = re.search(r"```markdown\n(.*?)```", text[start:], re.S) + assert block is not None, "provenance record fence missing" + assert block.group(1) == BASE_PROVENANCE + + +def test_family_additions_line_present_once(): + lines = [line for line in _text().splitlines() if line.startswith("- Family additions:")] + assert len(lines) == 1 + + +def test_recipe_says_only_public_facts_in_prompts(): + lowered = _recipe(_text()).lower() + assert "only public facts" in lowered + + +def test_recipe_does_not_claim_generator_filters_privacy(): + # Reproduced on the base: `content icon prompt` copies repo metadata and the + # local README heading into the prompt with no public/private check. + lowered = _recipe(_text()).lower() + assert "already limited" not in lowered + assert "does not establish whether a fact is public" in lowered + assert "complete generated markdown" in lowered + assert "readme-derived" in lowered + + +def test_recipe_forbids_private_repository_content(): + lowered = _recipe(_text()).lower() + assert "private-repository content" in lowered + assert "registry" in lowered + + +def test_recipe_sandbox_note(): + lowered = _recipe(_text()).lower() + assert "generated_images" in lowered + assert "access denied" in lowered + assert "never commit the raw candidate" in lowered + + +def test_recipe_privacy_and_sandbox_headings(): + section = _recipe(_text()) + headings = [line for line in section.splitlines() if line.startswith("### ")] + assert any(h.startswith("### Privacy") for h in headings) + assert any(h.startswith("### Sandbox") for h in headings) + + +def test_recipe_provenance_never_records_absolute_path(): + # The generated-images folder is inside $CODEX_HOME (the user's home), so the + # absolute path holds the local user name. Provenance records the file name or a + # $CODEX_HOME-relative path only. + section = _recipe(_text()) + step = next(ln for ln in section.splitlines() if ln.startswith("4. ")) + assert "$CODEX_HOME" in step + assert "file name" in step + assert "never record the absolute path" in step.lower() + assert "record the codex session id and the path" not in step.lower() + + +def test_recipe_attempt_cap_is_per_candidate(): + # Successful runs are candidates and must not exhaust the cap, otherwise the + # four-candidate round (pipeline rule 7) could never complete. + section = _recipe(_text()) + lowered = section.lower() + assert "per candidate, not per candidate set" in lowered + assert "per candidate set" not in lowered.replace("per candidate, not per candidate set", "") + assert "successful candidates never count toward the cap" in lowered + assert "only empty runs count toward the 3 attempts" in lowered + + +def test_recipe_labels_sandbox_facts_as_observed_and_uses_exact_returned_path(): + # Static wording test (mock evidence, not an image-generation run). + lowered = _recipe(_text()).lower() + assert "observed runtime notes" in lowered + assert "issue #64" in lowered + assert "not behaviour documented by openai" in lowered + assert "exact path returned by the run output" in lowered + assert "does not rely on a documented image path" in lowered + assert "codex-cli 0.166.0" in lowered + assert "at the time of writing" in lowered + for flag in ("--skip-git-repo-check", "--json", "--sandbox", "--output-last-message"): + assert flag in lowered, flag + assert "never invent a model id" in lowered + assert "not exposed" in lowered + + +def test_recipe_does_not_cite_unverified_url_for_image_path(): + # Static wording test (mock evidence, not an image-generation run). + assert "http" not in _recipe(_text()).lower() + + +def test_recipe_candidate_contract_is_consistent_with_four_candidate_template(): + # Static wording test: the fixed generator template line stays byte-identical and unique, + # and the recipe handles every returned path instead of one run per candidate. + text = _text() + assert text.count("Produce four candidates. {SEED_INSTRUCTION}") == 1 + assert text.count("Produce four candidates.") == 1 + section = _recipe(text) + lowered = section.lower() + assert "one run per candidate" not in lowered + assert "every distinct path the run output reports is one candidate" in lowered + assert "between zero and four image paths" in lowered + assert "empty run" in lowered + assert "four candidate slots" in lowered + assert "the cap applies to each slot" in lowered + assert "same unchanged prompt" in lowered + assert "new run" in lowered + assert "2 retries" in lowered and "3 attempts" in lowered + assert "after 3 empty attempts for any one slot" in lowered + assert "art-fail" in lowered + + +def test_recipe_unreadable_only_output_counts_against_slot_budget(): + # Static wording test (mock evidence, not an image-generation run): a run whose only + # reported paths are unreadable must fill no slot and count as an empty run, so the + # per-slot budget of 3 attempts still terminates with art-fail. + section = _recipe(_text()) + lowered = section.lower() + assert "a run that reports no readable path is an empty run" in lowered + assert "unreadable-only" in lowered + assert "an unreadable path fills no slot" in lowered + assert "counts as one attempt against every slot that is still unfilled" in lowered + assert "unreadable-only output is counted as an empty run" in lowered + assert "after 3 empty attempts for any one slot, including attempts that returned only unreadable paths" in lowered + assert "art-fail" in lowered + sandbox_c = [ + line.strip().lower() + for line in section.splitlines() + if line.strip().lower().startswith("- (c) if a path is unreadable") + ] + assert len(sandbox_c) == 1, "sandbox item (c) must appear exactly once" + assert "empty run" in sandbox_c[0] + assert "one attempt" in sandbox_c[0] + assert "art-fail" in sandbox_c[0] + assert "retry the run under the retry limit" not in sandbox_c[0] + + +def test_recipe_opus_p3_fixes_plural_paths_rule7_slots_and_scratch_copy(): + # Static wording test (mock evidence, not an image-generation run): the four accepted P3 findings. + text = _text() + assert text.count("Produce four candidates.") == 1 + lowered = _recipe(text).lower() + # (1) item 1 reads every image path the run reports, not a single path + assert "every image file path codex reports" in lowered + assert "use exactly those paths (see item 5)" in lowered + assert "use exactly that path" not in lowered + # (2) the first retry bullet distinguishes pipeline rule 7 and the option A template + assert "pipeline rule 7 asks for four candidates" in lowered + assert "the option a template also says so" in lowered + assert "the fixed prompt asks for four candidates" not in lowered + assert "four candidate slots" in lowered + # (3) the second round of rule 7 opens four new slots with the same per-slot cap + assert "the second round of rule 7" in lowered + assert "four new slots with the same per-slot cap of 3 attempts" in lowered + # (4) item 4 normalizes a scratch copy of the chosen image, not the generated path + assert "copy the chosen image to scratch (see sandbox (d)) and pass that copy to content icon normalize --in path" in lowered + assert "pass the chosen path to `content icon normalize" not in lowered