Sync third-party marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-06-15 00:01:16 +08:00
parent 3f3384b292
commit 708d620e7d
20 changed files with 622 additions and 68 deletions
+12 -12
View File
@@ -184,18 +184,6 @@
}, },
"category": "文档处理" "category": "文档处理"
}, },
{
"name": "oh-my-codex",
"source": {
"source": "local",
"path": "./plugins/codex/plugins/oh-my-codex"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{ {
"name": "ppt-translator", "name": "ppt-translator",
"source": { "source": {
@@ -256,6 +244,18 @@
}, },
"category": "文档处理" "category": "文档处理"
}, },
{
"name": "oh-my-codex",
"source": {
"source": "local",
"path": "./plugins/codex/plugins/oh-my-codex"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{ {
"name": "ppt-master", "name": "ppt-master",
"source": { "source": {
+4 -4
View File
@@ -15,8 +15,8 @@
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main", "ref": "main",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"commit": "0332e47d2a961f738cf4c52e86cd517eaae4197b", "commit": "29d03f493095899104f2c47ece39b166c5b14568",
"syncedAt": "2026-06-11T08:39:42Z" "syncedAt": "2026-06-14T16:00:01Z"
}, },
{ {
"id": "ui-ux-pro-max", "id": "ui-ux-pro-max",
@@ -69,8 +69,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "a0d62437f12d4f913a45b3acaadd61bba914f52e", "commit": "34d1f0057d51ff2cc15bcbbec071ef35f6fc1ae1",
"syncedAt": "2026-06-13T16:00:00Z" "syncedAt": "2026-06-14T16:00:01Z"
} }
] ]
} }
+12 -12
View File
@@ -184,18 +184,6 @@
}, },
"category": "文档处理" "category": "文档处理"
}, },
{
"name": "oh-my-codex",
"source": {
"source": "local",
"path": "./plugins/oh-my-codex"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{ {
"name": "ppt-translator", "name": "ppt-translator",
"source": { "source": {
@@ -256,6 +244,18 @@
}, },
"category": "文档处理" "category": "文档处理"
}, },
{
"name": "oh-my-codex",
"source": {
"source": "local",
"path": "./plugins/oh-my-codex"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{ {
"name": "ppt-master", "name": "ppt-master",
"source": { "source": {
@@ -1,6 +1,6 @@
{ {
"name": "oh-my-codex", "name": "oh-my-codex",
"version": "0.18.11", "version": "0.18.12",
"description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。", "description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。",
"author": { "author": {
"name": "Yeachan Heo", "name": "Yeachan Heo",
@@ -2,8 +2,8 @@
"sourceId": "oh-my-codex", "sourceId": "oh-my-codex",
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main", "ref": "main",
"commit": "0332e47d2a961f738cf4c52e86cd517eaae4197b", "commit": "29d03f493095899104f2c47ece39b166c5b14568",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"sourcePath": "plugins/oh-my-codex", "sourcePath": "plugins/oh-my-codex",
"syncedAt": "2026-06-11T08:39:42Z" "syncedAt": "2026-06-14T16:00:01Z"
} }
@@ -9,6 +9,7 @@ const hookDir = dirname(fileURLToPath(import.meta.url));
const OMX_PLUGIN_HOOK_LAUNCHER_CONTRACT_MARKER = 'omx-plugin-hook-launcher:v1'; const OMX_PLUGIN_HOOK_LAUNCHER_CONTRACT_MARKER = 'omx-plugin-hook-launcher:v1';
const MAX_WRAPPER_STDIN_BYTES = 1024 * 1024; const MAX_WRAPPER_STDIN_BYTES = 1024 * 1024;
const RAW_EVENT_SCAN_BYTES = 64 * 1024; const RAW_EVENT_SCAN_BYTES = 64 * 1024;
const MAX_STOP_STDOUT_BYTES = 1024 * 1024;
const CODEX_HOOK_EVENT_NAMES = new Set([ const CODEX_HOOK_EVENT_NAMES = new Set([
'SessionStart', 'SessionStart',
'PreToolUse', 'PreToolUse',
@@ -270,6 +271,19 @@ function hasActiveAutopilotStateForOversizedStop(input) {
return shouldContinueAutopilotState(sessionState); return shouldContinueAutopilotState(sessionState);
} }
function parseSingleJsonObjectOutput(raw) {
const text = String(raw ?? '').trim();
if (!text) return null;
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return parsed;
} catch {
return null;
}
}
function writeJsonNoop() { function writeJsonNoop() {
process.stdout.write(`${JSON.stringify({})}\n`); process.stdout.write(`${JSON.stringify({})}\n`);
process.exitCode = 0; process.exitCode = 0;
@@ -310,13 +324,33 @@ async function main() {
shell: process.platform === 'win32', shell: process.platform === 'win32',
}); });
const stdoutChunks = [];
let stdoutBytes = 0; let stdoutBytes = 0;
let bufferedStopStdoutBytes = 0;
let stopStdoutOversized = false;
let childSpawnError = null; let childSpawnError = null;
let childStdinError = null; let childStdinError = null;
child.stdout.on('data', (chunk) => { child.stdout.on('data', (chunk) => {
stdoutBytes += Buffer.byteLength(chunk); const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
process.stdout.write(chunk); stdoutBytes += buffer.byteLength;
if (isStop) {
if (!stopStdoutOversized) {
const remaining = MAX_STOP_STDOUT_BYTES - bufferedStopStdoutBytes;
if (remaining > 0) {
const slice = buffer.subarray(0, remaining);
stdoutChunks.push(slice);
bufferedStopStdoutBytes += slice.byteLength;
}
if (buffer.byteLength > remaining) {
stopStdoutOversized = true;
child.stdout.destroy();
child.kill();
}
}
} else {
process.stdout.write(chunk);
}
}); });
child.stderr.pipe(process.stderr); child.stderr.pipe(process.stderr);
child.stdin.on('error', (error) => { child.stdin.on('error', (error) => {
@@ -326,6 +360,23 @@ async function main() {
childSpawnError = error; childSpawnError = error;
}); });
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
if (isStop && stopStdoutOversized) {
writeStopFallback('plugin_stop_hook_launcher_stdout_oversized', `codex-native-hook produced more than ${MAX_STOP_STDOUT_BYTES} bytes of Stop hook stdout`);
return;
}
if (isStop && stdoutBytes > 0) {
const stdoutText = Buffer.concat(stdoutChunks).toString('utf8');
const parsed = parseSingleJsonObjectOutput(stdoutText);
if (parsed) {
process.stdout.write(`${JSON.stringify(parsed)}\n`);
process.exitCode = 0;
return;
}
writeStopFallback('plugin_stop_hook_launcher_invalid_stdout', `codex-native-hook produced invalid Stop hook JSON stdout (${stdoutBytes} bytes)`);
return;
}
if (isStop && stdoutBytes === 0) { if (isStop && stdoutBytes === 0) {
if (childSpawnError) { if (childSpawnError) {
writeStopFallback('plugin_stop_hook_launcher_spawn_error', `failed to launch ${command} codex-native-hook: ${childSpawnError.message}`); writeStopFallback('plugin_stop_hook_launcher_spawn_error', `failed to launch ${command} codex-native-hook: ${childSpawnError.message}`);
@@ -12,6 +12,10 @@ Use this skill when a task depends on current external best practices, version-a
Produce a cited, reusable best-practice answer or handoff that separates current external evidence from repo-local facts and dependency-selection decisions. For pre-planning investigation, this is the ordinary first research wrapper: gather official/upstream evidence, then hand it to `$ralplan` or the caller as planning input. Do not present `$best-practice-research` as a final architecture component or as a validator-gated research loop. Produce a cited, reusable best-practice answer or handoff that separates current external evidence from repo-local facts and dependency-selection decisions. For pre-planning investigation, this is the ordinary first research wrapper: gather official/upstream evidence, then hand it to `$ralplan` or the caller as planning input. Do not present `$best-practice-research` as a final architecture component or as a validator-gated research loop.
## Terminal By Default
This skill is terminal and read-only by default. It gathers evidence and produces a cited recommendation with a handoff, then stops. Do not write or edit files, create or amend commits, run mutating commands, or otherwise modify repository state under this skill — even when the question has clear implementation implications. When implementation is warranted, stop and hand off rather than continuing: name `$ralplan` for planning and `$ultragoal`, `$team`, or `executor` for execution, and resume only after the user explicitly switches to that workflow.
## Activate When ## Activate When
- The user asks for best practices, recommended approach, current guidance, official recommendations, standards, or version-aware external behavior. - The user asks for best practices, recommended approach, current guidance, official recommendations, standards, or version-aware external behavior.
@@ -71,7 +75,7 @@ Produce a cited, reusable best-practice answer or handoff that separates current
<what this research does not decide> <what this research does not decide>
### Handoff ### Handoff
<planning/execution/test implications> <planning/execution/test implications; name the next workflow — `$ralplan` for planning, `$ultragoal`/`$team`/`executor` for execution — and note that this skill stops here unless the user explicitly switches workflows>
``` ```
## Stop Rules ## Stop Rules
@@ -79,5 +83,6 @@ Produce a cited, reusable best-practice answer or handoff that separates current
- Stop after a source-backed recommendation is reusable by the caller. - Stop after a source-backed recommendation is reusable by the caller.
- Stop and route upward if the task becomes dependency comparison, broad architecture, or implementation. - Stop and route upward if the task becomes dependency comparison, broad architecture, or implementation.
- Do not continue researching when remaining work would only polish wording rather than change the recommendation. - Do not continue researching when remaining work would only polish wording rather than change the recommendation.
- This skill never implements. After delivering the recommendation and handoff, stop; do not modify repo files or repo state. Resume only when the user explicitly switches to a planning or implementation workflow named in the handoff.
Task: {{ARGUMENTS}} Task: {{ARGUMENTS}}
+9 -3
View File
@@ -1,6 +1,6 @@
# PPT Master — AI generates natively editable PPTX from any document # PPT Master — AI generates natively editable PPTX from any document
[![Version](https://img.shields.io/badge/version-v2.9.0-blue.svg)](https://github.com/hugohe3/ppt-master/releases) [![Version](https://img.shields.io/badge/version-v2.10.0-blue.svg)](https://github.com/hugohe3/ppt-master/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/hugohe3/ppt-master.svg)](https://github.com/hugohe3/ppt-master/stargazers) [![GitHub stars](https://img.shields.io/github/stars/hugohe3/ppt-master.svg)](https://github.com/hugohe3/ppt-master/stargazers)
[![AtomGit stars](https://atomgit.com/hugohe3/ppt-master/star/badge.svg)](https://atomgit.com/hugohe3/ppt-master) [![AtomGit stars](https://atomgit.com/hugohe3/ppt-master/star/badge.svg)](https://atomgit.com/hugohe3/ppt-master)
@@ -8,7 +8,7 @@
English | [中文](./README_CN.md) English | [中文](./README_CN.md)
<p align="center"> <p align="center">
<sub>This project is kept free and open source with the support of <a href="https://www.packyapi.com/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a> and other sponsors.</sub> <sub>This project is kept free and open source with the support of <a href="https://www.packyapi.com/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a>, <a href="https://runapi.co/register?aff=WMLJ">RunAPI</a> and other sponsors.</sub>
</p> </p>
<table> <table>
@@ -20,6 +20,10 @@ English | [中文](./README_CN.md)
<td width="180"><a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td> <td width="180"><a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is a professional enterprise-grade AI relay service committed to stable, efficient, and low-cost AI access for businesses and developers. The platform supports mainstream models including Claude, OpenAI, and Gemini, with prices as low as <strong>7% of official rates</strong>. Register through <a href="https://apikey.fun/register?aff=PPT-MASTER">our dedicated link</a> for an exclusive perk: <strong>up to 5% off on top-ups, permanently</strong>.</td> <td>Thanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is a professional enterprise-grade AI relay service committed to stable, efficient, and low-cost AI access for businesses and developers. The platform supports mainstream models including Claude, OpenAI, and Gemini, with prices as low as <strong>7% of official rates</strong>. Register through <a href="https://apikey.fun/register?aff=PPT-MASTER">our dedicated link</a> for an exclusive perk: <strong>up to 5% off on top-ups, permanently</strong>.</td>
</tr> </tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a></td>
<td>Thanks to RunAPI for sponsoring this project! RunAPI is an efficient and stable API platform — a single API Key gives you access to 150+ leading models, including OpenAI, Claude, Gemini, DeepSeek, and Grok, at prices as low as <strong>10% of official rates</strong>, with exceptional stability and seamless compatibility with tools like Claude Code. RunAPI offers an exclusive perk for PPT Master users: register and contact an administrator via <a href="https://runapi.co/register?aff=WMLJ">our dedicated link</a> to claim <strong>¥7 in free credit</strong>.</td>
</tr>
</table> </table>
> [!IMPORTANT] > [!IMPORTANT]
@@ -187,7 +191,7 @@ PPT Master runs in **any tool with agent capability** — read/write files, exec
> **Model recommendation**: for the best results, use **Claude Opus** with `gpt-image-2`; **Gemini 3.5 Flash** currently offers great overall value for money — notably fast and well worth a try. > **Model recommendation**: for the best results, use **Claude Opus** with `gpt-image-2`; **Gemini 3.5 Flash** currently offers great overall value for money — notably fast and well worth a try.
**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.com/register?aff=ppt-master)** and **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** can help — both offer pay-as-you-go access to Claude, GPT, Gemini and more, no subscription required. **PackyCode**: 10% off with promo code **`ppt-master`** at top-up. **APIKEY.FUN**: prices as low as **7% of official rates**; register via our link for an exclusive permanent discount of up to 5% on top-ups. **🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.com/register?aff=ppt-master)**, **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.co/register?aff=WMLJ)** can help — all offer pay-as-you-go access to Claude, GPT, Gemini and more, no subscription required. **PackyCode**: 10% off with promo code **`ppt-master`** at top-up. **APIKEY.FUN**: prices as low as **7% of official rates**; register via our link for an exclusive permanent discount of up to 5% on top-ups. **RunAPI**: 150+ models via one API Key at prices as low as **10% of official rates**; register and contact an administrator to claim **¥7 in free credit**.
### 3. Set Up ### 3. Set Up
@@ -336,6 +340,8 @@ PPT Master is currently built and maintained primarily by me. Every new template
&nbsp; &nbsp;
<a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" height="40" /></a> <a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" height="40" /></a>
&nbsp; &nbsp;
<a href="https://runapi.co/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" height="40" /></a>
&nbsp;
<a href="https://m.do.co/c/547f129aabe1"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" alt="Powered by DigitalOcean" height="40" /></a> <a href="https://m.do.co/c/547f129aabe1"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" alt="Powered by DigitalOcean" height="40" /></a>
**Individual support** **Individual support**
@@ -2,8 +2,8 @@
"sourceId": "ppt-master", "sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"commit": "a0d62437f12d4f913a45b3acaadd61bba914f52e", "commit": "34d1f0057d51ff2cc15bcbbec071ef35f6fc1ae1",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/ppt-master", "sourcePath": "skills/ppt-master",
"syncedAt": "2026-06-13T16:00:00Z" "syncedAt": "2026-06-14T16:00:01Z"
} }
@@ -294,6 +294,8 @@ Read references/strategist.md
This line is required output every run — the user must always see the mode choice exists. Whether to act on it is the user's call. This line is required output every run — the user must always see the mode choice exists. Whether to act on it is the user's call.
**Mandatory — spec-refinement note** (not a ninth confirmation): after the split-mode line, you MUST append one short opt-in line (rendered in the user's language, prefixed with 💡) telling the user they may **refine the spec first** — Strategist will produce the full design spec, then stop for review/revision of any part of it before any generation, via the [refine-spec](workflows/refine-spec.md) workflow. Default is OFF: no request → the spec is written in one go and the pipeline auto-proceeds as usual. Only when the user explicitly asks (e.g. "refine the spec first") does the [refine-spec](workflows/refine-spec.md) workflow take over after the Eight Confirmations. This line, like the split-mode line, is required output every run — the user must see the choice exists; whether to act on it is theirs.
**Formula rendering policy lives inside item 7 (Typography plan)**: **Formula rendering policy lives inside item 7 (Typography plan)**:
| Policy | Behavior | | Policy | Behavior |
@@ -330,6 +332,7 @@ python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
## ✅ Strategist Phase Complete ## ✅ Strategist Phase Complete
- [x] Eight Confirmations completed (user confirmed) - [x] Eight Confirmations completed (user confirmed)
- [x] Split-mode note appended below the eight items (heavy or normal variant) - [x] Split-mode note appended below the eight items (heavy or normal variant)
- [x] Spec-refinement opt-in line appended (default OFF; only the user's explicit request enters the refine-spec workflow)
- [x] Design Specification & Content Outline generated - [x] Design Specification & Content Outline generated
- [x] Execution lock (spec_lock.md) generated - [x] Execution lock (spec_lock.md) generated
- [ ] **Next**: Auto-proceed to [Image_Generator / Executor] phase - [ ] **Next**: Auto-proceed to [Image_Generator / Executor] phase
@@ -39,6 +39,8 @@ Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative
> Note: `page_layouts` disambiguates the multiple content variants modern templates ship (e.g., `graduation_defense` has 8); the legacy table cannot. > Note: `page_layouts` disambiguates the multiple content variants modern templates ship (e.g., `graduation_defense` has 8); the legacy table cannot.
**Templates supply structure, not skin (non-mirror)**: a chart or layout template's gradients, drop-shadows, and palette are placeholder. Inherit its geometry, label / legend placement, and series-encoding logic; re-skin every fill / stroke to the deck's `visual_style` + `spec_lock.colors` — flat styles strip the gradients and shadows, gradient / glass styles repaint their own. Forbidden — shipping a template's default `<linearGradient>` / `cardShadow` / Tailwind fills unchanged. Mirror templates are the exception: §1.1 preserves their visuals verbatim.
### 1.1 Mirror-mode templates — reference-style consumption ### 1.1 Mirror-mode templates — reference-style consumption
When the project's chosen template is a `mirror` template (`design_spec.md` frontmatter declares `replication_mode: mirror`), Executor switches to a **reference-style** consumption path that bypasses placeholder substitution: When the project's chosen template is a `mirror` template (`design_spec.md` frontmatter declares `replication_mode: mirror`), Executor switches to a **reference-style** consumption path that bypasses placeholder substitution:
@@ -10,6 +10,8 @@ Neutral information delivery. Lay the facts out plainly and completely, organize
**Topic titles, not assertions**: the page title names its subject plainly ("Q3 headcount by team", "Supported file formats") — clarity for lookup beats a persuasive finding. This is the deliberate inverse of `pyramid`'s assertion titles. **Topic titles, not assertions**: the page title names its subject plainly ("Q3 headcount by team", "Supported file formats") — clarity for lookup beats a persuasive finding. This is the deliberate inverse of `pyramid`'s assertion titles.
**`core_message` states coverage, not a claim**: when filling `design_spec.md §IX`, write each page's `core_message` as what the page lays out ("Q3 headcount across teams"), not what it proves ("headcount is concentrating in engineering"). The §IX field reads as an assertion under the other modes; under `briefing` it names scope.
**Complete over selective**: include the full reference set the audience needs to scan, not only the points that support a case. Coverage is the value here. **Complete over selective**: include the full reference set the audience needs to scan, not only the points that support a case. Coverage is the value here.
**Parallel, even treatment**: sibling items get the same shape and weight so they can be compared and located quickly; nothing is dramatized over its peers unless it genuinely differs. **Parallel, even treatment**: sibling items get the same shape and weight so they can be compared and located quickly; nothing is dramatized over its peers unless it genuinely differs.
@@ -25,6 +25,8 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
**BLOCKING**: After the read, present professional recommendations for the eight items below as a bundled package and wait for explicit user confirmation. **BLOCKING**: After the read, present professional recommendations for the eight items below as a bundled package and wait for explicit user confirmation.
> **Execution discipline**: This is the last BLOCKING checkpoint in the pipeline. After confirmation, complete the Design Spec and proceed to image generation / SVG / post-processing without further pauses. > **Execution discipline**: This is the last BLOCKING checkpoint in the pipeline. After confirmation, complete the Design Spec and proceed to image generation / SVG / post-processing without further pauses.
>
> **One opt-in exception**: present the spec-refinement line alongside the split-mode note (SKILL.md Step 4). It is OFF by default — the above discipline holds unchanged. Only when the user *explicitly* asks to refine the spec do you hand off to the [refine-spec](../workflows/refine-spec.md) workflow, which produces the full spec first and stops for user review/revision of any part before generation. Never enter it unprompted.
### a. Canvas Format Confirmation ### a. Canvas Format Confirmation
@@ -94,6 +96,14 @@ Proactively provide a color scheme (HEX values) based on content characteristics
**Color rules**: 60-30-10 rule (primary 60%, secondary 30%, accent 10%); text contrast ratio >= 4.5:1; no more than 4 colors per page. **Color rules**: 60-30-10 rule (primary 60%, secondary 30%, accent 10%); text contrast ratio >= 4.5:1; no more than 4 colors per page.
**Lock the full neutral set the visual style implies** — not just primary / secondary / accent / border. Predict the extra neutral tiers the locked `visual_style` (§d Layer 2) needs and lock them now; `spec_lock.colors` must be complete before generation, and the Executor draws only from it (never invents a tone mid-deck).
| Style trait | Extra neutral tiers to lock |
|---|---|
| Layers panels / charts (e.g. `data-journalism`, `swiss-minimal`) | `surface` (panel lift), `grid` (hairline, lighter than dividers) |
| Text over imagery / dark field (e.g. `photo-editorial`, `glassmorphism`, `dark-tech`) | `scrim` / `overlay` for legibility |
| Print / hand-drawn fills (e.g. `chalkboard`, `zine`) | `block-shade`, one step off the field |
### f. Icon Usage Confirmation ### f. Icon Usage Confirmation
| Option | Approach | Suitable Scenarios | | Option | Approach | Suitable Scenarios |
@@ -41,7 +41,7 @@ pip install PyMuPDF
Hybrid converter: pure-Python for the common formats, pandoc fallback for the rest. Hybrid converter: pure-Python for the common formats, pandoc fallback for the rest.
Native path (no external binary required): Native path (no external binary required):
- `.docx` — via `mammoth` - `.docx` — via `mammoth`; OMML / Office Math equations (Word-native or MathType "Convert to Office Math") are rewritten to inline LaTeX. Classic MathType OLE objects carry no OMML and are kept only as their preview image.
- `.html` / `.htm` — via `markdownify` + `beautifulsoup4` - `.html` / `.htm` — via `markdownify` + `beautifulsoup4`
- `.epub` — via `ebooklib` + `markdownify` - `.epub` — via `ebooklib` + `markdownify`
- `.ipynb` — via `nbconvert` - `.ipynb` — via `nbconvert`
@@ -3,7 +3,7 @@
Document to Markdown Converter (hybrid Python + Pandoc fallback) Document to Markdown Converter (hybrid Python + Pandoc fallback)
Primary formats (pure Python, no external tools required): Primary formats (pure Python, no external tools required):
.docx → mammoth .docx → mammoth (OMML/Office Math equations rewritten to inline LaTeX)
.html → markdownify + BeautifulSoup .html → markdownify + BeautifulSoup
.epub → ebooklib + markdownify .epub → ebooklib + markdownify
.ipynb → nbconvert .ipynb → nbconvert
@@ -27,6 +27,7 @@ import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import uuid
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
@@ -64,8 +65,24 @@ DOCX_NS = {
"v": "urn:schemas-microsoft-com:vml", "v": "urn:schemas-microsoft-com:vml",
"o": "urn:schemas-microsoft-com:office:office", "o": "urn:schemas-microsoft-com:office:office",
"mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006",
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
} }
EMU_PER_INCH = 914400 EMU_PER_INCH = 914400
MATH_NS = DOCX_NS["m"]
W_NS = DOCX_NS["w"]
XML_SPACE_ATTR = "{http://www.w3.org/XML/1998/namespace}space"
# OMML n-ary operator chars (m:nary/m:naryPr/m:chr) → LaTeX command.
NARY_OPS = {
"": r"\sum", "": r"\prod", "": r"\coprod",
"": r"\int", "": r"\iint", "": r"\iiint", "": r"\oint",
"": r"\bigcup", "": r"\bigcap", "": r"\bigvee", "": r"\bigwedge",
}
# OMML accent chars (m:acc/m:accPr/m:chr) → LaTeX command.
ACCENT_CMDS = {
"̂": r"\hat", "̃": r"\tilde", "̄": r"\bar", "": r"\vec", "": r"\vec",
"̇": r"\dot", "̈": r"\ddot", "̌": r"\check", "́": r"\acute", "̀": r"\grave",
}
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -378,6 +395,226 @@ def _manifest_entry(
return entry return entry
# ─────────────────────────────────────────────────────────────
# OMML (Office Math) → LaTeX
# ─────────────────────────────────────────────────────────────
#
# mammoth drops all math content, so Word-native equations and MathType
# formulas saved as Office Math (OMML) vanish from the output. This pure-Python
# converter rewrites each <m:oMath> into inline `$...$` LaTeX before mammoth
# runs, so formulas survive into the Markdown in document order.
#
# Scope: OMML only. Classic MathType OLE objects (Equation.DSMT4 / MTEF binary)
# carry no OMML — they expose only a WMF/EMF preview image, which mammoth still
# emits as a picture. Decoding MTEF is out of scope.
def _m_child(elem: ET.Element, name: str) -> ET.Element | None:
"""Return the first OMML child with the given local name."""
for child in elem:
if _local_name(child) == name:
return child
return None
def _m_pr_val(elem: ET.Element, prop: str) -> str | None:
"""Return m:val of a property inside the element's *Pr block (e.g. chr)."""
for child in elem:
if not _local_name(child).endswith("Pr"):
continue
for sub in child:
if _local_name(sub) == prop:
return sub.get(f"{{{MATH_NS}}}val")
return None
def _brace(latex: str) -> str:
"""Wrap multi-char LaTeX in braces so it binds as one super/subscript arg."""
return latex if len(latex) <= 1 else "{" + latex + "}"
def _omml_part(elem: ET.Element, name: str) -> str:
"""Convert a named OMML child (e/num/den/sup/sub/...) to LaTeX."""
child = _m_child(elem, name)
return _omml_to_latex(child) if child is not None else ""
def _omml_run(elem: ET.Element) -> str:
"""Concatenate text from an OMML run, skipping property children."""
return "".join(
c.text or "" for c in elem if _local_name(c) == "t"
)
def _omml_children(elem: ET.Element) -> str:
"""Convert all non-property children in order (default/passthrough rule)."""
return "".join(
_omml_to_latex(c) for c in elem if not _local_name(c).endswith("Pr")
)
def _omml_matrix(elem: ET.Element, *, environment: str) -> str:
"""Convert a matrix (m:m) or equation array (m:eqArr) to a LaTeX env."""
rows: list[str] = []
for row in elem:
if _local_name(row) not in ("mr", "e"):
continue
if _local_name(row) == "e": # eqArr stores rows as bare <m:e>
rows.append(_omml_to_latex(row))
continue
cells = [_omml_to_latex(cell) for cell in row if _local_name(cell) == "e"]
rows.append(" & ".join(cells))
body = r" \\ ".join(rows)
return rf"\begin{{{environment}}} {body} \end{{{environment}}}"
def _omml_to_latex(elem: ET.Element) -> str:
"""Recursively convert one OMML element subtree to a LaTeX string.
Unknown elements degrade to a concatenation of their children rather than
being dropped, so rare constructs lose markup but never lose content.
"""
local = _local_name(elem)
if local == "t":
return elem.text or ""
if local == "r":
return _omml_run(elem)
if local in ("oMath", "oMathPara", "e", "num", "den", "sup", "sub",
"deg", "fName", "lim", "box", "borderBox"):
return _omml_children(elem)
if local == "sSup":
return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "sup"))
if local == "sSub":
return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "sub"))
if local == "sSubSup":
return (_brace(_omml_part(elem, "e"))
+ "_" + _brace(_omml_part(elem, "sub"))
+ "^" + _brace(_omml_part(elem, "sup")))
if local == "sPre":
return ("{}_" + _brace(_omml_part(elem, "sub"))
+ "^" + _brace(_omml_part(elem, "sup"))
+ _brace(_omml_part(elem, "e")))
if local == "f":
return r"\frac{" + _omml_part(elem, "num") + "}{" + _omml_part(elem, "den") + "}"
if local == "rad":
deg = _m_child(elem, "deg")
body = _omml_part(elem, "e")
deg_latex = _omml_to_latex(deg) if deg is not None and len(deg) else ""
return rf"\sqrt[{deg_latex}]{{{body}}}" if deg_latex else rf"\sqrt{{{body}}}"
if local == "d":
beg = _m_pr_val(elem, "begChr")
end = _m_pr_val(elem, "endChr")
beg = "(" if beg is None else (beg or ".")
end = ")" if end is None else (end or ".")
inner = "".join(_omml_to_latex(c) for c in elem if _local_name(c) == "e")
return rf"\left{beg}{inner}\right{end}"
if local == "nary":
chr_ = _m_pr_val(elem, "chr") or ""
op = NARY_OPS.get(chr_, chr_)
sub, sup = _m_child(elem, "sub"), _m_child(elem, "sup")
out = op
if sub is not None and len(sub):
out += "_" + _brace(_omml_to_latex(sub))
if sup is not None and len(sup):
out += "^" + _brace(_omml_to_latex(sup))
return out + _brace(_omml_part(elem, "e"))
if local == "func":
return "\\" + _omml_part(elem, "fName").strip() + _brace(_omml_part(elem, "e"))
if local == "limLow":
return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "lim"))
if local == "limUpp":
return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "lim"))
if local == "bar":
cmd = r"\underline" if _m_pr_val(elem, "pos") == "bot" else r"\overline"
return cmd + "{" + _omml_part(elem, "e") + "}"
if local == "acc":
cmd = ACCENT_CMDS.get(_m_pr_val(elem, "chr") or "̂", r"\hat")
return cmd + "{" + _omml_part(elem, "e") + "}"
if local == "groupChr":
return _omml_part(elem, "e")
if local == "m":
return _omml_matrix(elem, environment="matrix")
if local == "eqArr":
return _omml_matrix(elem, environment="aligned")
return _omml_children(elem)
def _make_text_run(text: str) -> ET.Element:
"""Build a <w:r><w:t xml:space="preserve">text</w:t></w:r> element."""
run = ET.Element(f"{{{W_NS}}}r")
t = ET.SubElement(run, f"{{{W_NS}}}t")
t.set(XML_SPACE_ATTR, "preserve")
t.text = text
return run
def _docx_inject_math_latex(
input_file: Path,
) -> tuple[Path, dict[str, str]] | None:
"""Replace OMML equations with alphanumeric placeholders in a temp DOCX.
Returns ``(temp_file, {placeholder: latex})`` or None when the document has
no OMML math. Placeholders are plain ``[A-Za-z0-9]`` tokens so mammoth never
markdown-escapes the LaTeX; the caller swaps each token for its `$...$` value
after mammoth has produced the Markdown.
"""
try:
with zipfile.ZipFile(input_file) as docx:
document_xml = docx.read("word/document.xml")
except (KeyError, zipfile.BadZipFile, OSError):
return None
try:
root = ET.fromstring(document_xml)
except ET.ParseError:
return None
parent_map = {child: parent for parent in root.iter() for child in parent}
targets: list[tuple[ET.Element, bool]] = []
for elem in root.iter():
local = _local_name(elem)
if local == "oMathPara":
targets.append((elem, True))
elif local == "oMath":
parent = parent_map.get(elem)
if parent is None or _local_name(parent) != "oMathPara":
targets.append((elem, False))
if not targets:
return None
token_base = uuid.uuid4().hex
replacements: dict[str, str] = {}
for index, (elem, display) in enumerate(targets):
parent = parent_map.get(elem)
if parent is None:
continue
latex = _omml_to_latex(elem).strip()
position = list(parent).index(elem)
parent.remove(elem)
if latex:
token = f"MATHEQ{token_base}{index:04d}"
delim = "$$" if display else "$"
replacements[token] = f"{delim}{latex}{delim}"
parent.insert(position, _make_text_run(token))
if not replacements:
return None
for prefix, uri in DOCX_NS.items():
if prefix != "rel":
ET.register_namespace(prefix, uri)
patched_xml = ET.tostring(root, encoding="utf-8", xml_declaration=True)
tmp = tempfile.NamedTemporaryFile(suffix=".docx", delete=False)
tmp.close()
out_path = Path(tmp.name)
with zipfile.ZipFile(input_file) as zin, \
zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = patched_xml if item.filename == "word/document.xml" else zin.read(item.filename)
zout.writestr(item, data)
return out_path, replacements
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# DOCX → Markdown (mammoth) # DOCX → Markdown (mammoth)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -434,13 +671,31 @@ def _convert_docx(input_file: Path, out_file: Path) -> str:
)) ))
return {"src": f"{rel_media_dir}/{filename}"} return {"src": f"{rel_media_dir}/{filename}"}
with input_file.open("rb") as f: # Rewrite OMML equations to LaTeX placeholders before mammoth (which would
result = mammoth.convert_to_markdown( # otherwise drop them); the placeholders are swapped back below.
f, math_injection = _docx_inject_math_latex(input_file)
convert_image=mammoth.images.img_element(_save_image), if math_injection is not None:
) math_file, math_replacements = math_injection
else:
math_file, math_replacements = None, {}
mammoth_source = math_file or input_file
try:
with mammoth_source.open("rb") as f:
result = mammoth.convert_to_markdown(
f,
convert_image=mammoth.images.img_element(_save_image),
)
finally:
if math_file is not None:
try:
math_file.unlink()
except OSError:
pass
markdown = _html_img_to_md(result.value) markdown = result.value
for token, latex in math_replacements.items():
markdown = markdown.replace(token, latex)
markdown = _html_img_to_md(markdown)
out_file.write_text(markdown, encoding="utf-8") out_file.write_text(markdown, encoding="utf-8")
if manifest: if manifest:
@@ -182,6 +182,13 @@ def parse_use_element(use_match: str) -> dict[str, str | float]:
if fill_match: if fill_match:
attrs['fill'] = fill_match.group(1) attrs['fill'] = fill_match.group(1)
# Stroke-style icons may be authored with natural SVG semantics:
# fill="none" stroke="#HEX". Keep accepting fill as the canonical color
# carrier, but preserve stroke so outline icons do not collapse to none.
stroke_match = re.search(r'stroke="([^"]+)"', use_match)
if stroke_match:
attrs['stroke'] = stroke_match.group(1)
# Live preview direct edits may write an absolute transform matrix back to # Live preview direct edits may write an absolute transform matrix back to
# the placeholder. Preserve it so the expanded icon matches the edited # the placeholder. Preserve it so the expanded icon matches the edited
# browser geometry instead of falling back to the original x/y placement. # browser geometry instead of falling back to the original x/y placement.
@@ -198,6 +205,25 @@ def parse_use_element(use_match: str) -> dict[str, str | float]:
return attrs return attrs
def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str:
"""Resolve the caller-provided color for fill or stroke icon libraries."""
fill = str(attrs.get('fill', '')).strip()
stroke = str(attrs.get('stroke', '')).strip()
if style == 'stroke':
if fill and fill != 'none':
return fill
if stroke and stroke != 'none':
return stroke
return '#000000'
if fill:
return fill
if stroke and stroke != 'none':
return stroke
return '#000000'
def generate_icon_group(attrs: dict[str, str | float], elements: list[str], style: str, base_size: float) -> str: def generate_icon_group(attrs: dict[str, str | float], elements: list[str], style: str, base_size: float) -> str:
""" """
Generate the icon's <g> element. Generate the icon's <g> element.
@@ -215,7 +241,7 @@ def generate_icon_group(attrs: dict[str, str | float], elements: list[str], styl
y = attrs.get('y', 0) y = attrs.get('y', 0)
width = attrs.get('width', base_size) width = attrs.get('width', base_size)
height = attrs.get('height', base_size) height = attrs.get('height', base_size)
color = attrs.get('fill', '#000000') color = resolve_icon_color(attrs, style)
icon_name = attrs.get('icon', 'unknown') icon_name = attrs.get('icon', 'unknown')
scale_x = width / base_size scale_x = width / base_size
@@ -290,8 +316,8 @@ def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, ver
continue continue
icon_path, _ = resolve_icon_path(str(icon_name), icons_dir) icon_path, _ = resolve_icon_path(str(icon_name), icons_dir)
color = str(attrs.get('fill', '#000000')) elements, style, base_size = extract_paths_from_icon(icon_path)
elements, style, base_size = extract_paths_from_icon(icon_path, color) color = resolve_icon_color(attrs, style)
if not elements: if not elements:
print(f"[WARN] Icon not found: {icon_name} (in {svg_path.name})") print(f"[WARN] Icon not found: {icon_name} (in {svg_path.name})")
@@ -55,6 +55,13 @@ SVG_NS = "http://www.w3.org/2000/svg"
RAMP_MIN_RATIO = 0.5 RAMP_MIN_RATIO = 0.5
RAMP_MAX_RATIO = 5.0 RAMP_MAX_RATIO = 5.0
# Modes / visual styles that legitimately use unbounded hero / poster type
# (huge cover numerals, act dividers, single-number reveals). For these the
# size-drift upper bound is dropped — the oversize is the design, not Executor
# drift. The lower bound still applies.
POSTER_SIZE_MODES = {'showcase'}
POSTER_SIZE_STYLES = {'zine'}
def _design_spec_is_brand(spec_path: Path) -> bool: def _design_spec_is_brand(spec_path: Path) -> bool:
"""Return True when a design_spec.md frontmatter declares ``kind: brand``. """Return True when a design_spec.md frontmatter declares ``kind: brand``.
@@ -735,7 +742,7 @@ class SVGQualityChecker:
if typo: if typo:
default_font = typo.get('font_family', '').strip() default_font = typo.get('font_family', '').strip()
if default_font: if default_font:
allowed_fonts.add(default_font) allowed_fonts.add(self._normalize_font_stack(default_font))
for k, v in typo.items(): for k, v in typo.items():
if k == 'font_family' or not k.endswith('_family'): if k == 'font_family' or not k.endswith('_family'):
continue continue
@@ -743,7 +750,7 @@ class SVGQualityChecker:
# Skip placeholder text like "same as body (omit if identical)" # Skip placeholder text like "same as body (omit if identical)"
if not v_clean or v_clean.lower().startswith('same as'): if not v_clean or v_clean.lower().startswith('same as'):
continue continue
allowed_fonts.add(v_clean) allowed_fonts.add(self._normalize_font_stack(v_clean))
# Sizes: declared slots are anchors; body is the ramp baseline. # Sizes: declared slots are anchors; body is the ramp baseline.
allowed_sizes = set() allowed_sizes = set()
@@ -768,22 +775,30 @@ class SVGQualityChecker:
color_drifts.add(val) color_drifts.add(val)
font_drifts = set() font_drifts = set()
for m in re.finditer(r'font-family\s*=\s*["\']([^"\']+)["\']', content): # Capture to the matching delimiter (group 1) so a double-quoted stack
val = m.group(1).strip() # containing single-quoted family names is not truncated at the inner quote.
if allowed_fonts and val not in allowed_fonts: for m in re.finditer(r'font-family\s*=\s*(["\'])(.*?)\1', content):
val = m.group(2).strip()
if allowed_fonts and self._normalize_font_stack(val) not in allowed_fonts:
font_drifts.add(val) font_drifts.add(val)
# Poster / showcase contexts use unbounded hero type — drop the ceiling.
mode = (lock.get('mode', {}).get('mode') or '').strip().lower()
vstyle = (lock.get('visual_style', {}).get('visual_style') or '').strip().lower()
max_ratio = (float('inf') if mode in POSTER_SIZE_MODES or vstyle in POSTER_SIZE_STYLES
else RAMP_MAX_RATIO)
size_drifts = set() size_drifts = set()
for m in re.finditer(r'font-size\s*=\s*["\']([^"\']+)["\']', content): for m in re.finditer(r'font-size\s*=\s*["\']([^"\']+)["\']', content):
val = self._normalize_size(m.group(1)) val = self._normalize_size(m.group(1))
if not allowed_sizes or val in allowed_sizes: if not allowed_sizes or val in allowed_sizes:
continue continue
# Intermediate values are allowed when they sit inside the ramp # Intermediate values are allowed when they sit inside the ramp
# envelope (ratio to body within [RAMP_MIN_RATIO, RAMP_MAX_RATIO]). # envelope (ratio to body within [RAMP_MIN_RATIO, max_ratio]).
if body_px and body_px > 0: if body_px and body_px > 0:
try: try:
ratio = float(val) / body_px ratio = float(val) / body_px
if RAMP_MIN_RATIO <= ratio <= RAMP_MAX_RATIO: if RAMP_MIN_RATIO <= ratio <= max_ratio:
continue continue
except ValueError: except ValueError:
pass pass
@@ -886,6 +901,15 @@ class SVGQualityChecker:
v = v[:-2].strip() v = v[:-2].strip()
return v return v
@staticmethod
def _normalize_font_stack(stack: str) -> str:
"""Normalize a font-family stack for comparison: split on commas, strip
quotes / whitespace, lowercase, rejoin. Collapses cosmetic differences
(comma spacing, single vs double quotes, case) so that
`Consolas,'Courier New',monospace` matches `Consolas, "Courier New", monospace`."""
parts = [p.strip().strip('"\'').lower() for p in stack.split(',')]
return ','.join(p for p in parts if p)
def _categorize_issue(self, error_msg: str) -> str: def _categorize_issue(self, error_msg: str) -> str:
"""Categorize issue type""" """Categorize issue type"""
if 'Invalid XML' in error_msg: if 'Invalid XML' in error_msg:
@@ -143,6 +143,29 @@ def _canvas_px(pres_root: ET.Element) -> dict[str, int | None]:
} }
def _fill_risk(tables: list[dict[str, Any]], charts: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Return a fill_risk descriptor when the slide has non-text content that text-fill cannot replace.
Only tables and charts are checked; no new dependencies are introduced.
Returns None when the slide has no such content.
"""
kinds: list[str] = []
if tables:
kinds.append("table")
if charts:
kinds.append("chart")
if not kinds:
return None
kind_str = "/".join(kinds)
return {
"has_non_text_content": True,
"reason": (
f"has non-text content ({kind_str}) that text-fill cannot replace; "
"provide table_edits/chart_edits or pick another source slide"
),
}
def analyze_pptx(pptx_path: Path) -> dict[str, Any]: def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
"""Extract a slide library with text replacement slots.""" """Extract a slide library with text replacement slots."""
with zipfile.ZipFile(pptx_path) as zf: with zipfile.ZipFile(pptx_path) as zf:
@@ -180,16 +203,18 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
tables = _analyze_tables(slide_root, slide_ref.index) tables = _analyze_tables(slide_root, slide_ref.index)
charts = _analyze_charts(zf, slide_root, slide_ref) charts = _analyze_charts(zf, slide_root, slide_ref)
slide_text = "\n".join(slot["text"] for slot in slots if slot["text"]) slide_text = "\n".join(slot["text"] for slot in slots if slot["text"])
slides.append( slide: dict[str, Any] = {
{ "slide_index": slide_ref.index,
"slide_index": slide_ref.index, "page_type": _classify_page_type(slide_ref.index, len(slide_refs), slide_text, slots),
"page_type": _classify_page_type(slide_ref.index, len(slide_refs), slide_text, slots), "text_summary": slide_text[:500],
"text_summary": slide_text[:500], "slots": slots,
"slots": slots, "tables": tables,
"tables": tables, "charts": charts,
"charts": charts, }
} risk = _fill_risk(tables, charts)
) if risk is not None:
slide["fill_risk"] = risk
slides.append(slide)
return { return {
"schema": "template_fill_pptx_library.v1", "schema": "template_fill_pptx_library.v1",
@@ -203,6 +203,11 @@ def _capacity_for_report(
return _display_width(max(capacity, old_width)) return _display_width(max(capacity, old_width))
def _library_slide_index(library: dict[str, Any]) -> dict[int, dict[str, Any]]:
"""Build a mapping from slide_index to slide dict for O(1) lookup."""
return {int(s.get("slide_index", 0)): s for s in library.get("slides", [])}
def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
"""Compare fill replacements against source slot capacity.""" """Compare fill replacements against source slot capacity."""
lookup = _slot_lookup(library) lookup = _slot_lookup(library)
@@ -427,6 +432,85 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
"message": "chart edit target and data shape are valid", "message": "chart edit target and data shape are valid",
} }
) )
# --- Guardrail 2: source slides with non-text content not covered by edits ---
# For each plan slide, if the source slide has tables/charts in the library
# but the plan slide provides no matching table_edits/chart_edits, warn that
# text-fill will silently leave the original template content in place.
lib_slides = _library_slide_index(library)
for plan_slide_index, slide in enumerate(plan.get("slides", []), start=1):
source_slide = int(slide.get("source_slide", 0))
lib_slide = lib_slides.get(source_slide)
if lib_slide is None:
continue
lib_tables = lib_slide.get("tables", [])
lib_charts = lib_slide.get("charts", [])
if not lib_tables and not lib_charts:
continue
# Check whether the plan slide provides edits covering the non-text content.
has_table_edits = bool(slide.get("table_edits"))
has_chart_edits = bool(slide.get("chart_edits"))
uncovered_kinds: list[str] = []
if lib_tables and not has_table_edits:
uncovered_kinds.append("table")
if lib_charts and not has_chart_edits:
uncovered_kinds.append("chart")
if not uncovered_kinds:
continue
kind_str = "/".join(uncovered_kinds)
summary["warn"] += 1
results.append(
{
"status": "WARN",
"plan_slide": plan_slide_index,
"source_slide": source_slide,
"message": (
f"source slide {source_slide} has non-text content ({kind_str}) "
"with no matching edits in the plan; text-fill leaves it untouched "
"and original template content may show through "
"(add table_edits/chart_edits, or pick another source slide)"
),
}
)
# --- Guardrail 1: same source slide reused too many times while unused layouts exist ---
# Use a relative condition rather than an absolute threshold: only warn when
# (a) a source slide is reused >= REUSE_WARN_THRESHOLD times, AND
# (b) there are library slides that the plan never uses at all.
# Rationale: a small template where every layout is referenced is fine even at
# high per-slide reuse; "15-page template where only 1 page is ever cloned and
# the rest sit idle" is the real smell we want to surface.
# Threshold of 3: any source appearing 3+ times in a plan is meaningful reuse
# (cover / TOC / ending typically appear at most twice), so >= 3 is a practical
# signal without being overly sensitive.
REUSE_WARN_THRESHOLD = 3
source_use_counts: dict[int, int] = {}
for slide in plan.get("slides", []):
src = int(slide.get("source_slide", 0))
if src:
source_use_counts[src] = source_use_counts.get(src, 0) + 1
all_lib_indices = {int(s.get("slide_index", 0)) for s in library.get("slides", []) if s.get("slide_index")}
used_lib_indices = set(source_use_counts.keys())
unused_lib_indices = sorted(all_lib_indices - used_lib_indices)
if unused_lib_indices:
for src, count in sorted(source_use_counts.items()):
if count >= REUSE_WARN_THRESHOLD:
unused_list = ", ".join(str(i) for i in unused_lib_indices)
summary["warn"] += 1
results.append(
{
"status": "WARN",
"source_slide": src,
"reuse_count": count,
"unused_source_slides": unused_lib_indices,
"message": (
f"source slide {src} is reused {count} times while "
f"{len(unused_lib_indices)} source layout(s) are never used "
f"(indices: {unused_list}); "
"consider using other layouts for more variety"
),
}
)
return {"schema": "template_fill_pptx_check.v1", "summary": summary, "results": results} return {"schema": "template_fill_pptx_check.v1", "summary": summary, "results": results}
@@ -441,10 +525,13 @@ def print_check_report(report: dict[str, Any]) -> None:
"{status} P{plan_slide:02d} source={source_slide} {slot_id} " "{status} P{plan_slide:02d} source={source_slide} {slot_id} "
"{role} old={old_len} new={new_len} ratio={ratio}: {message}".format(**item) "{role} old={old_len} new={new_len} ratio={ratio}: {message}".format(**item)
) )
else: elif "plan_slide" in item:
target = item.get("slot_id") or item.get("selector") or "" target = item.get("slot_id") or item.get("selector") or ""
line = ( line = (
f"{item['status']} P{item['plan_slide']:02d} " f"{item['status']} P{item['plan_slide']:02d} "
f"source={item['source_slide']} {target}: {item['message']}".strip() f"source={item['source_slide']} {target}: {item['message']}".strip()
) )
else:
# Guardrail 1 WARNs are source-level (no plan_slide); print source + message.
line = f"{item['status']} source={item.get('source_slide', '?')}: {item['message']}"
print(line) print(line)
@@ -0,0 +1,58 @@
---
description: Opt-in spec-refinement loop between the Eight Confirmations and Image/Executor. Triggered only when the user explicitly asks; the Strategist produces the full design spec, then HARD STOPS so the user can review and revise any part of it before the pipeline continues.
---
# Refine Spec Workflow
> Standalone, **opt-in** spec-review pass. The default pipeline writes `design_spec.md` + `spec_lock.md` and auto-proceeds. When the user explicitly asks to refine the spec, the Strategist produces the full spec first, then **stops** — the user reviews and revises any part of it (outline, color, typography, layout, image strategy, page rhythm, …) before any image generation or SVG work begins.
This workflow is **conditional**, same shape as the split-mode choice: it never fires on its own and the default path is unchanged. The Eight Confirmations settle design directions up front as abstract recommendations; this pass lets the user revise the **concrete spec** the Strategist produced from them. It is most valuable for a zero-background user, who can judge a finished spec far better than the up-front recommendations — and the spec's content outline (`§IX`) is usually what they most want to adjust.
## When to Run
The user **explicitly asks** to refine / review / revise the spec before generation. Recognize any of:
| Pattern | Example |
|---|---|
| "refine the spec / review the spec first" | "produce the spec first, let me review before slides" |
| "let me revise the spec, then continue" | "send me the spec to confirm, I'll edit it" |
| Any request to inspect/iterate the design spec before generation | "draft the full plan, I want to adjust it, then generate" |
**Default is OFF.** Strategist surfaces this option as one short opt-in line inside the Eight Confirmations bundle (see SKILL.md Step 4). No request → the spec is written in one go and the pipeline auto-proceeds as usual; this workflow never starts.
**Prerequisite**: the Eight Confirmations are settled (mode + visual style + the rest). This pass revises the spec the confirmations produced; it does not re-open the confirmation bundle itself.
---
## Step 1: Produce the full spec
Run the default Strategist output exactly as SKILL.md Step 4 specifies: write `design_spec.md` (§IXI) and `spec_lock.md`. Read the relevant `sources/` files so the content outline (`§IX`) carries real facts, not skeleton points. Nothing special here — this is the normal spec, just produced under the knowledge that the user is about to review it.
---
## Step 2: ⛔ HARD STOP — present, discuss, and revise
Present the produced spec to the user and **wait for explicit revision or approval before doing anything else**. This is a conditional BLOCKING point that exists only on this opt-in path; the default pipeline keeps its "auto-proceed after the Eight Confirmations" discipline untouched.
The user may revise **any part of the spec**, not just the outline — content outline, color, typography, layout, icon plan, image strategy, page rhythm. Discuss in **prose**; do not emit a scored rubric or per-axis grades (mechanical scorecards are against project convention). When useful, point out things worth a second look — but let the user drive.
**Reference — review lenses, not a checklist or score**: raise these in plain language to surface what is worth discussing. They name a *direction*, never a number — never convert any into HEX values, px sizes, ratios, page quotas, or grades.
- *Outline*: logical clarity (do the points build on each other), information density (right amount per page — nothing padded or crammed), focus (each page lands one idea), register (spoken vs formal, matched to the audience), emotional resonance (a hook to open, a payoff to close), chapter balance (page budget not lopsided).
- *Color*: does the scheme fit the content's mood and audience, and is there enough hierarchy and contrast to read comfortably — not which exact HEX.
- *Typography*: do title and body form a clear contrast or a clean concord, is the size hierarchy legible, does the type character match the visual style — not which px.
- *Layout*: does structure follow each page's information weight, or does it fall back to one uniform symmetric grid (the "AI-generated" look).
- *Icon / image*: one consistent icon character throughout; images that serve the content (hero / atmosphere used on purpose) rather than decorate.
- *Page rhythm*: do `anchor` / `dense` / `breathing` track the narrative, or is everything flatly dense.
These overlap with what the locked `mode`, visual style, and §6.1 already shape — treat them as discussion angles to surface what is worth talking about, not a second pass to redo.
**Keep both files in sync on every change.** Any revision the user approves must land in both `design_spec.md` and `spec_lock.md`; on divergence `spec_lock.md` wins (see [`strategist.md`](../references/strategist.md) §6.2). Iterate as many rounds as the user wants. The loop ends only when the user explicitly approves the spec.
---
## Step 3: Hand back
Once the user approves, `design_spec.md` and `spec_lock.md` both reflect the final, revised state. Return to SKILL.md and continue normally: Step 5 (Image Acquisition, if any `ai` / `web` rows) or Step 6 (Executor).
> Note: this workflow does NOT duplicate Strategist content. It only inserts a review-and-revise checkpoint between spec production and the rest of the pipeline. `strategist.md` / SKILL.md remain authoritative for how the spec is written.