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:
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PPT Master — AI generates natively editable PPTX from any document
|
# PPT Master — AI generates natively editable PPTX from any document
|
||||||
|
|
||||||
[](https://github.com/hugohe3/ppt-master/releases)
|
[](https://github.com/hugohe3/ppt-master/releases)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://github.com/hugohe3/ppt-master/stargazers)
|
[](https://github.com/hugohe3/ppt-master/stargazers)
|
||||||
[](https://atomgit.com/hugohe3/ppt-master)
|
[](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
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<a href="https://runapi.co/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" 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>
|
<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`
|
||||||
|
|||||||
+262
-7
@@ -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:
|
||||||
|
|||||||
+29
-3
@@ -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:
|
||||||
|
|||||||
+35
-10
@@ -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",
|
||||||
|
|||||||
+88
-1
@@ -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` (§I–XI) 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.
|
||||||
Reference in New Issue
Block a user